Compare commits
4 Commits
e367657254
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
| a42966ad8e | |||
| 76e635a122 | |||
| 00886b1c6e | |||
| d5e8946d7d |
@@ -9,10 +9,10 @@
|
|||||||
# 2. npm test + npm run build (Frontend)
|
# 2. npm test + npm run build (Frontend)
|
||||||
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
||||||
#
|
#
|
||||||
# Registry: truenas:13000 (internes Gitea Container Registry)
|
# Registry: 192.168.2.115:13000 (internes Gitea Container Registry)
|
||||||
# Images:
|
# Images:
|
||||||
# truenas:13000/gulum/gerbilmanager-api:latest
|
# 192.168.2.115:13000/gulum/gerbilmanager-api:latest
|
||||||
# truenas:13000/gulum/gerbilmanager-frontend:latest
|
# 192.168.2.115:13000/gulum/gerbilmanager-frontend:latest
|
||||||
|
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: truenas:13000
|
REGISTRY: 192.168.2.115:13000
|
||||||
REGISTRY_OWNER: gulum
|
REGISTRY_OWNER: gulum
|
||||||
DOTNET_VERSION: "10.0.x"
|
DOTNET_VERSION: "10.0.x"
|
||||||
NODE_VERSION: "22"
|
NODE_VERSION: "22"
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace GerbilManager.Tests
|
namespace GerbilManager.Tests
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// FEAT-8d-CREATE: docx importer CREATE path tests.
|
/// FEAT-8d-CREATE / FIX-8D-DEDUP: docx importer CREATE path tests.
|
||||||
/// Uses SQLite (not InMemory) so FK constraints are enforced.
|
/// Uses SQLite (not InMemory) so FK + unique-index constraints are enforced —
|
||||||
|
/// this is the only reliable way to catch duplicate-ExternalRef crashes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ImportDocxServiceTests : IDisposable
|
public class ImportDocxServiceTests : IDisposable
|
||||||
{
|
{
|
||||||
@@ -31,7 +32,7 @@ namespace GerbilManager.Tests
|
|||||||
conn.Open();
|
conn.Open();
|
||||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
var db = new ApplicationContext(opts);
|
var db = new ApplicationContext(opts);
|
||||||
db.Database.EnsureCreated();
|
db.Database.EnsureCreated(); // schema WITH unique index on ExternalRef
|
||||||
return (db, conn);
|
return (db, conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,13 +53,12 @@ namespace GerbilManager.Tests
|
|||||||
await using (conn)
|
await using (conn)
|
||||||
await using (db)
|
await using (db)
|
||||||
{
|
{
|
||||||
// Litter in DB with PairingCode matching the docx WS-code
|
// Litter in DB — date-only lookup (±5 days, exactly one candidate)
|
||||||
var litter = new Litter
|
var litter = new Litter
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Name = "Testwurf WS1",
|
Name = "Testwurf",
|
||||||
Date = new DateOnly(2023, 5, 1),
|
Date = new DateOnly(2023, 5, 1),
|
||||||
PairingCode = "WS1"
|
|
||||||
};
|
};
|
||||||
db.Litters.Add(litter);
|
db.Litters.Add(litter);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -66,7 +66,7 @@ namespace GerbilManager.Tests
|
|||||||
WriteLitters(Array.Empty<object>());
|
WriteLitters(Array.Empty<object>());
|
||||||
WriteAnimals(new[]
|
WriteAnimals(new[]
|
||||||
{
|
{
|
||||||
new { wsCode = "WS1", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
||||||
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
||||||
deathDate = "", deathCause = "", farbschlag = "" }
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
});
|
});
|
||||||
@@ -75,8 +75,8 @@ namespace GerbilManager.Tests
|
|||||||
|
|
||||||
Assert.False(report.Executed);
|
Assert.False(report.Executed);
|
||||||
Assert.Equal(1, report.Created);
|
Assert.Equal(1, report.Created);
|
||||||
Assert.Equal(1, report.LitterLinked);
|
Assert.Equal(1, report.LitterLinked); // date-only match finds the one litter
|
||||||
Assert.Equal(1, report.GoHomeFilled); // new animal will have GoHomeDate set
|
Assert.Equal(1, report.GoHomeFilled);
|
||||||
Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written
|
Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written
|
||||||
Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written
|
Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written
|
||||||
}
|
}
|
||||||
@@ -94,9 +94,8 @@ namespace GerbilManager.Tests
|
|||||||
var litter = new Litter
|
var litter = new Litter
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Name = "Testwurf WS1",
|
Name = "Testwurf",
|
||||||
Date = new DateOnly(2023, 5, 1),
|
Date = new DateOnly(2023, 5, 1),
|
||||||
PairingCode = "WS1"
|
|
||||||
};
|
};
|
||||||
db.Litters.Add(litter);
|
db.Litters.Add(litter);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -104,7 +103,7 @@ namespace GerbilManager.Tests
|
|||||||
WriteLitters(Array.Empty<object>());
|
WriteLitters(Array.Empty<object>());
|
||||||
WriteAnimals(new[]
|
WriteAnimals(new[]
|
||||||
{
|
{
|
||||||
new { wsCode = "WS1", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
||||||
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
||||||
deathDate = "", deathCause = "", farbschlag = "" }
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
});
|
});
|
||||||
@@ -120,7 +119,7 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal(new DateOnly(2023, 5, 1), gerbil.DateOfBirth);
|
Assert.Equal(new DateOnly(2023, 5, 1), gerbil.DateOfBirth);
|
||||||
Assert.Equal(Gender.female, gerbil.Gender);
|
Assert.Equal(Gender.female, gerbil.Gender);
|
||||||
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
|
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
|
||||||
Assert.Equal(litter.Id, gerbil.LitterId);
|
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
|
||||||
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
|
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder);
|
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder);
|
||||||
Assert.False(gerbil.IsResident);
|
Assert.False(gerbil.IsResident);
|
||||||
@@ -145,7 +144,7 @@ namespace GerbilManager.Tests
|
|||||||
WriteLitters(Array.Empty<object>());
|
WriteLitters(Array.Empty<object>());
|
||||||
WriteAnimals(new[]
|
WriteAnimals(new[]
|
||||||
{
|
{
|
||||||
new { wsCode = "WS2", litterDob = "15.03.2023", name = "Flash", gender = "male",
|
new { wsCode = "2/3", litterDob = "15.03.2023", name = "Flash", gender = "male",
|
||||||
owner = "", abgabeDate = "01.05.2023",
|
owner = "", abgabeDate = "01.05.2023",
|
||||||
deathDate = "", deathCause = "", farbschlag = "" }
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
});
|
});
|
||||||
@@ -213,7 +212,7 @@ namespace GerbilManager.Tests
|
|||||||
WriteLitters(Array.Empty<object>());
|
WriteLitters(Array.Empty<object>());
|
||||||
WriteAnimals(new[]
|
WriteAnimals(new[]
|
||||||
{
|
{
|
||||||
new { wsCode = "WS3", litterDob = "01.01.2022", name = "Ghost", gender = "male",
|
new { wsCode = "1/5", litterDob = "01.01.2022", name = "Ghost", gender = "male",
|
||||||
owner = "", abgabeDate = "",
|
owner = "", abgabeDate = "",
|
||||||
deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" }
|
deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" }
|
||||||
});
|
});
|
||||||
@@ -226,5 +225,52 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal("Tumor", gerbil.CauseOfDeath);
|
Assert.Equal("Tumor", gerbil.CauseOfDeath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_same_name_siblings_created_with_distinct_ExternalRefs()
|
||||||
|
{
|
||||||
|
// P0 regression: two animals in the same litter with the same normalized name
|
||||||
|
// previously caused duplicate ExternalRef → Npgsql/SQLite 23505 unique-key crash.
|
||||||
|
// Fix: ordinal disambiguation (-2) ensures uniqueness within the batch.
|
||||||
|
// The unique index on IX_Gerbils_ExternalRef (via EnsureCreated on SQLite) makes
|
||||||
|
// this test an authoritative regression gate.
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
|
||||||
|
owner = "Eva Müller", abgabeDate = "10.08.2022",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" },
|
||||||
|
// Identical name+wsCode+litterDob → base ExternalRef collision
|
||||||
|
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
|
||||||
|
owner = "Lena Braun", abgabeDate = "11.08.2022",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must NOT throw unique-key violation
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, report.Created);
|
||||||
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||||
|
|
||||||
|
var refs = (await db.Gerbils.Select(g => g.ExternalRef!).ToListAsync()).OrderBy(r => r).ToList();
|
||||||
|
// Both start with the docx- prefix
|
||||||
|
Assert.All(refs, r => Assert.StartsWith("docx-", r));
|
||||||
|
// Must be distinct (unique index enforces this in SQLite)
|
||||||
|
Assert.Equal(2, refs.Distinct().Count());
|
||||||
|
// Second occurrence gets the -2 suffix
|
||||||
|
Assert.Single(refs, r => r.EndsWith("-2"));
|
||||||
|
|
||||||
|
// Idempotent re-run: zero new, still 2 in DB
|
||||||
|
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, second.Created);
|
||||||
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
///
|
///
|
||||||
/// Created animals: Status=GivenAway (or Deceased), IsResident=false,
|
/// Created animals: Status=GivenAway (or Deceased), IsResident=false,
|
||||||
/// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx",
|
/// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx",
|
||||||
/// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}" key (idempotent).
|
/// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}[-N]" key (idempotent;
|
||||||
|
/// -N ordinal suffix disambiguates same-name siblings in a litter).
|
||||||
///
|
///
|
||||||
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
|
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
|
||||||
///
|
///
|
||||||
/// Idempotent: running multiple times is safe. Each run resolves whatever is still null.
|
/// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef.
|
||||||
|
/// Execute wraps all writes in a single transaction (atomic: crash → full rollback).
|
||||||
/// Execute is gated by the endpoint; this service only acts when asked.
|
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ImportDocxService
|
public sealed class ImportDocxService
|
||||||
@@ -56,13 +58,15 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
|
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PairingCode → Litters (WS-code normalised: spaces removed)
|
// Litter lookup by birth date (DayNumber) → list of matching DB litters.
|
||||||
|
// NOTE: WsCode in docx is a litter-size fraction ("4/4", "/5") — NOT a PairingCode.
|
||||||
|
// Date-only lookup with uniqueness guard avoids false links (only link when
|
||||||
|
// exactly one DB litter falls within ±5 days of the docx litter DOB).
|
||||||
var littersInDb = await _db.Litters
|
var littersInDb = await _db.Litters
|
||||||
.Where(l => l.PairingCode != null)
|
.Select(l => new { l.Id, l.Date })
|
||||||
.Select(l => new { l.Id, l.Date, l.PairingCode })
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var litterByWs = littersInDb
|
var littersByDayNumber = littersInDb
|
||||||
.GroupBy(l => l.PairingCode!.Replace(" ", ""))
|
.GroupBy(l => l.Date.DayNumber)
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
|
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
|
||||||
@@ -100,22 +104,48 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int animalsCreated = 0, litterLinked = 0, goHomeFilled = 0, deathFilled = 0;
|
int animalsCreated = 0, litterLinked = 0, goHomeFilled = 0, deathFilled = 0;
|
||||||
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
||||||
|
|
||||||
|
// Ordinal counter for collision-free ExternalRef within this batch.
|
||||||
|
// Two animals with the same base ref (same ws+name+litterDob) get -2, -3 suffixes.
|
||||||
|
var externalRefOrdinals = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
|
||||||
|
var batchRefs = new HashSet<string>();
|
||||||
|
|
||||||
|
// --- Planning pass (dry-run counts + execute writes) ---
|
||||||
|
// Execute path is wrapped in a single transaction for atomicity.
|
||||||
|
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||||
|
if (execute)
|
||||||
|
tx = await _db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
foreach (var da in docxAnimals)
|
foreach (var da in docxAnimals)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
|
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
|
||||||
|
|
||||||
var externalRef = DocxExternalRef(da);
|
// Collision-free ExternalRef: ordinal suffix for same-name siblings
|
||||||
|
var baseRef = DocxExternalRefBase(da);
|
||||||
|
externalRefOrdinals.TryGetValue(baseRef, out var ord);
|
||||||
|
ord++;
|
||||||
|
externalRefOrdinals[baseRef] = ord;
|
||||||
|
var externalRef = ord == 1 ? baseRef : $"{baseRef}-{ord}";
|
||||||
|
|
||||||
// Resolve litter by WS-code + approximate birth date
|
// Resolve litter: date ±5 days, unambiguous (exactly one candidate)
|
||||||
Guid? litterId = null;
|
Guid? litterId = null;
|
||||||
if (!string.IsNullOrWhiteSpace(da.WsCode) && !string.IsNullOrWhiteSpace(da.LitterDob))
|
if (!string.IsNullOrWhiteSpace(da.LitterDob))
|
||||||
{
|
{
|
||||||
var litterDob = ParseDate(da.LitterDob);
|
var litterDob = ParseDate(da.LitterDob);
|
||||||
if (litterDob is not null && litterByWs.TryGetValue(da.WsCode.Replace(" ", ""), out var cands))
|
if (litterDob is not null)
|
||||||
{
|
{
|
||||||
var match = cands.FirstOrDefault(l =>
|
var candidates = new List<Guid>();
|
||||||
Math.Abs(l.Date.DayNumber - litterDob.Value.DayNumber) <= 5);
|
for (int delta = -5; delta <= 5; delta++)
|
||||||
litterId = match?.Id;
|
{
|
||||||
|
if (littersByDayNumber.TryGetValue(litterDob.Value.DayNumber + delta, out var cl))
|
||||||
|
candidates.AddRange(cl.Select(l => l.Id));
|
||||||
|
}
|
||||||
|
if (candidates.Count == 1)
|
||||||
|
litterId = candidates[0];
|
||||||
|
// If 0 or >1 candidates: no link (avoid false links)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +176,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
{
|
{
|
||||||
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
|
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
|
||||||
_db.Contacts.Add(newContact);
|
_db.Contacts.Add(newContact);
|
||||||
await _db.SaveChangesAsync(); // persist before any Gerbil FK reference
|
await _db.SaveChangesAsync(); // flush within the outer tx
|
||||||
receiverId = newContact.Id;
|
receiverId = newContact.Id;
|
||||||
contactByNorm[normOwner] = receiverId.Value;
|
contactByNorm[normOwner] = receiverId.Value;
|
||||||
}
|
}
|
||||||
@@ -214,6 +244,9 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── PATH 3: no match → CREATE ────────────────────────────────────────────
|
// ── PATH 3: no match → CREATE ────────────────────────────────────────────
|
||||||
|
// Belt-and-suspenders: ordinal should ensure uniqueness, but guard anyway
|
||||||
|
if (!batchRefs.Add(externalRef)) { skipped++; continue; }
|
||||||
|
|
||||||
animalsCreated++;
|
animalsCreated++;
|
||||||
if (litterId is not null) litterLinked++;
|
if (litterId is not null) litterLinked++;
|
||||||
if (goHomeDate is not null) goHomeFilled++;
|
if (goHomeDate is not null) goHomeFilled++;
|
||||||
@@ -244,12 +277,25 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush all gerbil inserts + enrich updates in one shot (within the tx)
|
||||||
if (execute && (animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
if (execute && (animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (tx is not null) await tx.CommitAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// tx.DisposeAsync (in finally) rolls back if not committed
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (tx is not null) await tx.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
|
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
|
||||||
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
|
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
|
||||||
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet.");
|
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
|
||||||
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
||||||
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
|
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
|
||||||
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
|
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
|
||||||
@@ -295,12 +341,12 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stable ExternalRef key for a docx animal row — used for idempotency across re-runs.
|
/// Base ExternalRef key (before ordinal disambiguation). Two animals sharing the same
|
||||||
/// Format: "docx-{wsCode}-{normname}-{litterDob}" (lowercased, spaces/punctuation stripped).
|
/// ws+name+litterDob get this same base; the caller appends -2, -3 … for duplicates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static string DocxExternalRef(DocxAnimal da)
|
internal static string DocxExternalRefBase(DocxAnimal da)
|
||||||
{
|
{
|
||||||
var ws = da.WsCode.Replace(" ", "").ToLowerInvariant();
|
var ws = (da.WsCode ?? "").Replace(" ", "").ToLowerInvariant();
|
||||||
var name = System.Text.RegularExpressions.Regex.Replace(
|
var name = System.Text.RegularExpressions.Regex.Replace(
|
||||||
(da.Name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
(da.Name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
||||||
return $"docx-{ws}-{name}-{da.LitterDob}";
|
return $"docx-{ws}-{name}-{da.LitterDob}";
|
||||||
|
|||||||
@@ -79,3 +79,34 @@ test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', a
|
|||||||
// Karte selbst zeigt '(ohne Namen)' statt leer
|
// Karte selbst zeigt '(ohne Namen)' statt leer
|
||||||
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Mini-Legende zeigt alle drei Interaktionshinweise (STAMMBAUM-EXPAND)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
const hints = page.locator('.stammbaum-hints')
|
||||||
|
await expect(hints).toBeVisible()
|
||||||
|
await expect(hints).toContainText(t.tapHint)
|
||||||
|
await expect(hints).toContainText(t.hintName)
|
||||||
|
await expect(hints).toContainText(t.hintExpand)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('+-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Einpassen, damit die 4. Generation (Emil mit +) im Viewport liegt.
|
||||||
|
const fit = page.getByRole('button', { name: t.zoomFit })
|
||||||
|
if (await fit.isVisible()) await fit.click()
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
|
||||||
|
// +-Button muss an der Tiefengrenze erscheinen (Emil hat litterId w-emil).
|
||||||
|
const expandBtn = page.getByRole('button', { name: t.expand }).first()
|
||||||
|
await expect(expandBtn).toBeVisible()
|
||||||
|
|
||||||
|
// Klick auf +: Emil wird aufgeklappt → Max (sein Vater) taucht als Link auf.
|
||||||
|
await expandBtn.click({ force: true })
|
||||||
|
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
|
||||||
|
})
|
||||||
|
|||||||
@@ -335,7 +335,11 @@ export default function StammbaumPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="stammbaum-hint">{t.tapHint}</p>
|
<ul className="stammbaum-hints">
|
||||||
|
<li>{t.tapHint}</li>
|
||||||
|
<li>{t.hintName}</li>
|
||||||
|
<li>{t.hintExpand}</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
|
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
|
||||||
|
|||||||
@@ -34,10 +34,15 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stammbaum-hint {
|
.stammbaum-hints {
|
||||||
font-size: 0.8rem;
|
list-style: none;
|
||||||
color: var(--color-text-muted);
|
padding: 0;
|
||||||
margin: 0.4rem 0 0;
|
margin: 0.4rem 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.1rem 1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
||||||
@@ -175,20 +180,27 @@
|
|||||||
.pedigree-card__expand {
|
.pedigree-card__expand {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
width: 32px;
|
width: 40px;
|
||||||
height: 32px;
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--color-accent);
|
border: none;
|
||||||
background: var(--color-accent-soft);
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-size: 1.05rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pedigree-card__expand:hover {
|
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 1px 5px rgb(0 0 0 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedigree-card__expand:hover,
|
||||||
|
.pedigree-card__expand:focus-visible {
|
||||||
|
opacity: 0.82;
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ──
|
/* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ──
|
||||||
|
|||||||
@@ -355,6 +355,9 @@ export const de = {
|
|||||||
/** Auf Karten am Rand: weitere Vorfahren nachladen. */
|
/** Auf Karten am Rand: weitere Vorfahren nachladen. */
|
||||||
expand: 'Vorfahren laden',
|
expand: 'Vorfahren laden',
|
||||||
tapHint: 'Tippe auf ein Tier, um dessen Stammbaum anzuzeigen.',
|
tapHint: 'Tippe auf ein Tier, um dessen Stammbaum anzuzeigen.',
|
||||||
|
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
||||||
|
hintName: 'Namenslink: Tierakte öffnen',
|
||||||
|
hintExpand: '+: weitere Vorfahren nachladen',
|
||||||
zoomIn: 'Vergrößern',
|
zoomIn: 'Vergrößern',
|
||||||
zoomOut: 'Verkleinern',
|
zoomOut: 'Verkleinern',
|
||||||
zoomFit: 'Ansicht einpassen',
|
zoomFit: 'Ansicht einpassen',
|
||||||
|
|||||||
Reference in New Issue
Block a user