Compare commits

...

4 Commits

Author SHA1 Message Date
a42966ad8e STAMMBAUM-EXPAND: +-Button prominenter + Mini-Legende (Stammbaum-UX)
Punkt 1 - Expand-Button sichtbar/zuverlaessig:
- 40px solid-fill (war 32px outline), box-shadow, flex-centered;
  deutlich sichtbar auf 190px Karte, touch-tauglich (>=40px Tap-Target).
  Hover/focus-visible: opacity + outline.
- Edge-case (Litter mit null-Eltern): +-Button bleibt sichtbar --
  unbekannt-Karten sind korrektes Feedback fuer quarantaenierte Eltern.

Punkt 2 - Bottom-Hint Mini-Legende:
- stammbaum-hint -> stammbaum-hints ul (flex-wrap, 3 Items):
  Umwurzeln / Name=Akte / +=Nachladen. Gut lesbar auf 390px.
  de.ts: hintName + hintExpand (neue Keys; tapHint bleibt fuer card title).

e2e: +2 Specs x 2 Viewports = 4 neue Tests (Legende + Expand-Klick).
Emil (Gen 4, litterId w-emil) liefert Testanker im Mock-Stammbaum.

Gate: vitest 122/122 e2e 166+68skipped build+tsc+eslint clean.
2026-06-07 00:38:46 +02:00
76e635a122 c
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
2026-06-07 00:02:20 +02:00
00886b1c6e Merge feature/fix-8d-dedup (FIX-8D-DEDUP P0): collision-free idempotent ExternalRef + single transaction + litter ±5d unique-date link
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 8s
2026-06-06 23:33:06 +02:00
d5e8946d7d FIX-8D-DEDUP: P0 duplicate ExternalRef + WsCode→litter mismatch
FIX-1 DEDUP (P0): Two same-name siblings in one litter produced identical
ExternalRefs ('docx-{ws}-{name}-{dob}'), crashing execute on IX_Gerbils_ExternalRef.
Fix: ordinal counter per base-ref within the batch → first occurrence keeps the
base ref, subsequent ones get -2, -3 … suffix. Deterministic (JSON-order) → idempotent
re-runs find existing rows via ExternalRef path (PATH 1). HashSet guard added as a
belt-and-suspenders check.

FIX-2 TRANSACTION: execute now opens a single BeginTransactionAsync before the loop
and commits after the final SaveChangesAsync. Eager contact saves (within the tx) and
the gerbil batch save are fully atomic — crash → full rollback, no partial state.

FIX-3 LITTER LOOKUP: docx WsCode is a litter-size fraction ('4/4', '/5') — NOT a
PairingCode like 'G01/ZdkC'. The previous WsCode→PairingCode lookup was always a
no-op (hence litterLinked=0 in every dry-run). Fix: look up DB litters by LitterDob
(±5 days); link only when exactly ONE candidate exists (unambiguous, no false links).

P0 REGRESSION TEST (Test 6): SQLite + EnsureCreated → unique index enforced.
Two animals same name+litter → execute succeeds, both created, distinct ExternalRefs,
re-run = 0 new. This test would have caught the live crash.
Gate: 164/164 tests, has-pending=No, no schema change.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 23:31:46 +02:00
7 changed files with 308 additions and 166 deletions

View File

@@ -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"

View File

@@ -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());
}
}
} }
} }

View File

@@ -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}";

View File

@@ -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 })
})

View File

@@ -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. */}

View File

@@ -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) ──

View File

@@ -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',