Compare commits
5 Commits
feature/st
...
ea527c7ec6
| Author | SHA1 | Date | |
|---|---|---|---|
| ea527c7ec6 | |||
| c80adcf060 | |||
| 886a6e3aae | |||
| 88e00b3718 | |||
| 3214855989 |
@@ -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: 192.168.2.115:13000 (internes Gitea Container Registry)
|
# Registry: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig)
|
||||||
# Images:
|
# Images:
|
||||||
# 192.168.2.115:13000/gulum/gerbilmanager-api:latest
|
# git.rismer.de/gulum/gerbilmanager-api:latest
|
||||||
# 192.168.2.115:13000/gulum/gerbilmanager-frontend:latest
|
# git.rismer.de/gulum/gerbilmanager-frontend:latest
|
||||||
|
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: 192.168.2.115:13000
|
REGISTRY: git.rismer.de
|
||||||
REGISTRY_OWNER: gulum
|
REGISTRY_OWNER: gulum
|
||||||
DOTNET_VERSION: "10.0.x"
|
DOTNET_VERSION: "10.0.x"
|
||||||
NODE_VERSION: "22"
|
NODE_VERSION: "22"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Import;
|
|||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
|
||||||
namespace GerbilManager.Tests
|
namespace GerbilManager.Tests
|
||||||
{
|
{
|
||||||
@@ -226,6 +227,52 @@ namespace GerbilManager.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Test 7: P0 REGRESSION — execute works under a retrying execution strategy ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_works_under_retrying_execution_strategy()
|
||||||
|
{
|
||||||
|
// Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls
|
||||||
|
// OnFirstExecution() at the start of ExecuteAsync, which throws
|
||||||
|
// InvalidOperationException when it detects a user-initiated transaction
|
||||||
|
// that was NOT opened through the strategy. This test wires the same check
|
||||||
|
// (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI
|
||||||
|
// without a live Npgsql instance.
|
||||||
|
//
|
||||||
|
// With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync):
|
||||||
|
// → OnFirstExecution sees active user tx → InvalidOperationException
|
||||||
|
// With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda):
|
||||||
|
// → OnFirstExecution: no tx yet → OK
|
||||||
|
var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||||
|
.UseSqlite(conn)
|
||||||
|
.ReplaceService<IExecutionStrategyFactory, FakeRetryingStrategyFactory>()
|
||||||
|
.Options;
|
||||||
|
var db = new ApplicationContext(opts);
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female",
|
||||||
|
owner = "Retry Adopter", abgabeDate = "01.03.2023",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must NOT throw InvalidOperationException (user-initiated tx rejected)
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.True(report.Executed);
|
||||||
|
Assert.Equal(1, report.Created);
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||||
|
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
|
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -273,4 +320,24 @@ namespace GerbilManager.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers for Test 7 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Execution strategy with MaxRetryCount=1 so that EF Core's base
|
||||||
|
/// OnFirstExecution() throws when it detects a user-initiated transaction
|
||||||
|
/// that was not opened through CreateExecutionStrategy().ExecuteAsync().
|
||||||
|
/// ShouldRetryOn=false → no actual retry; the check alone is what we need.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps)
|
||||||
|
: ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
protected override bool ShouldRetryOn(Exception exception) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps)
|
||||||
|
: IExecutionStrategyFactory
|
||||||
|
{
|
||||||
|
public IExecutionStrategy Create() => new FakeRetryingStrategy(deps);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
/// 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. Re-run finds existing rows via ExternalRef.
|
/// 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 wraps all writes in a single transaction via CreateExecutionStrategy() so that
|
||||||
|
/// providers using EnableRetryOnFailure (e.g. NpgsqlRetryingExecutionStrategy) are
|
||||||
|
/// compatible. The strategy lambda resets all mutable state at the top so it is safe
|
||||||
|
/// to re-run on transient-failure retry.
|
||||||
/// 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
|
||||||
@@ -94,6 +97,11 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
.GroupBy(c => NormalizeName(c.Name))
|
.GroupBy(c => NormalizeName(c.Name))
|
||||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||||
|
|
||||||
|
// Snapshot of DB contacts before any writes.
|
||||||
|
// Used to reset contactByNorm on strategy retry (rolled-back contacts vanish from DB
|
||||||
|
// but would remain in the in-memory dict without this reset).
|
||||||
|
var contactByNormBase = new Dictionary<string, Guid>(contactByNorm);
|
||||||
|
|
||||||
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
|
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
|
||||||
var colorVarietyByName = (await _db.ColorVarieties
|
var colorVarietyByName = (await _db.ColorVarieties
|
||||||
.Select(cv => new { cv.Id, cv.Name })
|
.Select(cv => new { cv.Id, cv.Name })
|
||||||
@@ -105,19 +113,15 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
||||||
|
|
||||||
// Ordinal counter for collision-free ExternalRef within this batch.
|
// 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>();
|
var externalRefOrdinals = new Dictionary<string, int>();
|
||||||
|
|
||||||
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
|
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
|
||||||
var batchRefs = new HashSet<string>();
|
var batchRefs = new HashSet<string>();
|
||||||
|
|
||||||
// --- Planning pass (dry-run counts + execute writes) ---
|
// Inner loop — shared by dry-run and execute paths.
|
||||||
// Execute path is wrapped in a single transaction for atomicity.
|
// All local variables above are captured by reference (C# closure), so the strategy
|
||||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
// lambda can reset them before each retry and RunLoopAsync sees the fresh state.
|
||||||
if (execute)
|
async Task RunLoopAsync()
|
||||||
tx = await _db.Database.BeginTransactionAsync();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
foreach (var da in docxAnimals)
|
foreach (var da in docxAnimals)
|
||||||
{
|
{
|
||||||
@@ -276,21 +280,36 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush all gerbil inserts + enrich updates in one shot (within the tx)
|
|
||||||
if (execute && (animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
if (tx is not null) await tx.CommitAsync();
|
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
|
if (!execute)
|
||||||
{
|
{
|
||||||
// tx.DisposeAsync (in finally) rolls back if not committed
|
// Dry-run: just count, no writes, no transaction needed.
|
||||||
throw;
|
await RunLoopAsync();
|
||||||
}
|
}
|
||||||
finally
|
else
|
||||||
{
|
{
|
||||||
if (tx is not null) await tx.DisposeAsync();
|
// Execute: wrap the entire transaction in the execution strategy so that providers
|
||||||
|
// with EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) are compatible.
|
||||||
|
// The lambda resets all mutable state at the top so retries start clean.
|
||||||
|
var strategy = _db.Database.CreateExecutionStrategy();
|
||||||
|
await strategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
// Reset mutable state — idempotent on strategy retry
|
||||||
|
_db.ChangeTracker.Clear();
|
||||||
|
externalRefOrdinals.Clear();
|
||||||
|
batchRefs.Clear();
|
||||||
|
animalsCreated = 0; litterLinked = 0; goHomeFilled = 0; deathFilled = 0;
|
||||||
|
ownerLinked = 0; ownerCreated = 0; skipped = 0;
|
||||||
|
// Rebuild from DB snapshot: contacts added in a failed attempt were rolled back
|
||||||
|
contactByNorm = new Dictionary<string, Guid>(contactByNormBase);
|
||||||
|
|
||||||
|
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||||
|
await RunLoopAsync();
|
||||||
|
if ((animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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.");
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Feinschliff** — siehe unten._
|
|||||||
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
|
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
|
||||||
| **A4** | **Domain-Name** (registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite veröffentlichen (Jimdo-Ersatz). Seite ist gebaut. |
|
| **A4** | **Domain-Name** (registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite veröffentlichen (Jimdo-Ersatz). Seite ist gebaut. |
|
||||||
| **A5** | **TrueNAS-Restfragen:** (a) SCALE-Version? · (c) eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? · (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Produktiv-Betrieb auf dem NAS (compose ist fertig vorbereitet). |
|
| **A5** | **TrueNAS-Restfragen:** (a) SCALE-Version? · (c) eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? · (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Produktiv-Betrieb auf dem NAS (compose ist fertig vorbereitet). |
|
||||||
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo → Einstellungen → Actions → Secrets): `REGISTRY_USER` (dein Gitea-Login) + `REGISTRY_TOKEN` (Token mit `write:package`). | CI pusht fertige Docker-Images in die Registry. (Die CI-Tests laufen bereits grün.) |
|
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo `Gulum/GerbilManager` → **Einstellungen → Actions → Secrets → Secret hinzufügen**): `REGISTRY_USER` = `gulum` · `REGISTRY_TOKEN` = Gitea-Zugriffstoken mit Scope **`write:package`** (erzeugen unter **Benutzer-Einstellungen → Anwendungen → Zugriffstoken verwalten**, Token wird nur einmal angezeigt → in das Secret kopieren). Danach Action erneut laufen lassen. Registry ist jetzt **`git.rismer.de`** (externes HTTPS) → keine `insecure-registry`-Daemon-Konfig nötig. | CI pusht fertige Docker-Images in die Registry. (Tests laufen grün; aktuell rot ist NUR der Login-Schritt: `secrets.REGISTRY_USER`/`REGISTRY_TOKEN` sind leer → „Username and password required".) |
|
||||||
|
|
||||||
## B. Kleine Aktion (jederzeit)
|
## B. Kleine Aktion (jederzeit)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ Aktuell eingebaute Häkchen-Eigenschaften (für die KI-Verkaufstexte) — **soll
|
|||||||
|
|
||||||
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
|
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
|
||||||
|
|
||||||
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
|
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**3 erledigt:** Kazumi/Filou/Sokrates ✅ — **10 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
|
||||||
|
|
||||||
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
|
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
|
||||||
| Tier | Sterbedatum — welches? |
|
| Tier | Sterbedatum — welches? |
|
||||||
@@ -60,7 +60,7 @@ Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgeta
|
|||||||
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 |
|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 |
|
||||||
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 |
|
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 |
|
||||||
| Osamu v.d. K.C. (*10.12.2015) | 01.10.2020 ↔ 18.12.2020 |
|
| Osamu v.d. K.C. (*10.12.2015) | 01.10.2020 ↔ 18.12.2020 |
|
||||||
| Filou v.d. K.C. (*24.11.2014) | 31.08.2019 ↔ 31.10.2019 |
|
| ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) |
|
||||||
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 |
|
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 |
|
||||||
|
|
||||||
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
|
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
|
||||||
@@ -69,10 +69,10 @@ Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgeta
|
|||||||
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** |
|
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** |
|
||||||
| Percy of little runners (*16.12.2017) | P-Locus: **PP** ↔ **Pp** |
|
| Percy of little runners (*16.12.2017) | P-Locus: **PP** ↔ **Pp** |
|
||||||
| Iwana of little runners (*02.10.2018) | P-Locus: **PP** ↔ **Pp** |
|
| Iwana of little runners (*02.10.2018) | P-Locus: **PP** ↔ **Pp** |
|
||||||
| Sokrates v.d. K.C. (*14.12.2015) | D-Locus: **D-** ↔ **Dd** · + Sterbedatum 20.05.**2019** ↔ **2020** |
|
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) |
|
||||||
| Eragon (Elieus, *18.05.2016) | C-Locus: **CC** (vollfarbig) ↔ **c[chm]c[chm]** (Colourpoint) |
|
| Eragon (Elieus, *18.05.2016) | C-Locus: **CC** (vollfarbig) ↔ **c[chm]c[chm]** (Colourpoint) |
|
||||||
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** |
|
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** |
|
||||||
| Kazumi v.d. K.C. (*23.04.2013) | A: **Aa**↔**aa** · G: **GG**↔**Gg** · P: **PP**↔**Pp** · Sp: **Spsp**↔**spsp** |
|
| ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) |
|
||||||
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) |
|
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) |
|
||||||
|
|
||||||
*(Alle Gencode-Varianten + Quelldateien: `tools/import/output/review-report.md`.)*
|
*(Alle Gencode-Varianten + Quelldateien: `tools/import/output/review-report.md`.)*
|
||||||
|
|||||||
@@ -79,34 +79,3 @@ 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,11 +335,7 @@ export default function StammbaumPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ul className="stammbaum-hints">
|
<p className="stammbaum-hint">{t.tapHint}</p>
|
||||||
<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,15 +34,10 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stammbaum-hints {
|
.stammbaum-hint {
|
||||||
list-style: none;
|
font-size: 0.8rem;
|
||||||
padding: 0;
|
|
||||||
margin: 0.4rem 0 0;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.1rem 1rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
||||||
@@ -180,27 +175,20 @@
|
|||||||
.pedigree-card__expand {
|
.pedigree-card__expand {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
width: 40px;
|
width: 32px;
|
||||||
height: 40px;
|
height: 32px;
|
||||||
padding: 0;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: none;
|
border: 1px solid var(--color-accent);
|
||||||
background: var(--color-accent);
|
background: var(--color-accent-soft);
|
||||||
color: #fff;
|
color: var(--color-accent);
|
||||||
font-size: 1.3rem;
|
font-size: 1.05rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
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:hover {
|
||||||
.pedigree-card__expand:focus-visible {
|
background: var(--color-accent);
|
||||||
opacity: 0.82;
|
color: #fff;
|
||||||
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,9 +355,6 @@ 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',
|
||||||
|
|||||||
@@ -119,6 +119,28 @@
|
|||||||
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
|
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
|
||||||
"dateOfDeath": "12.12.2019",
|
"dateOfDeath": "12.12.2019",
|
||||||
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
|
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kazumi von den Kleinen Chaoten",
|
||||||
|
"dob": "23.04.2013",
|
||||||
|
"decision": "voller Genotyp von der Züchterin — löst die 4 strittigen Loci: A=Aa, G=GG, P=PP, Sp=Spsp",
|
||||||
|
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Filou von den Kleinen Chaoten",
|
||||||
|
"dob": "24.11.2014",
|
||||||
|
"decision": "Sterbedatum = 31.08.2019 (die 31.10.2019-Variante war falsch); Gencode war einig",
|
||||||
|
"dateOfDeath": "31.08.2019",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sokrates von den Kleinen Chaoten",
|
||||||
|
"dob": "14.12.2015",
|
||||||
|
"decision": "D-Locus = D- (nicht Dd) + Sterbedatum = 20.05.2019 (nicht 2020). Genotyp = die einigen Loci aus dem Extrakt mit D auf D- gesetzt (Uw→G normalisiert).",
|
||||||
|
"genotype": "aa Cc[-] D- ee Gg Pp spsp",
|
||||||
|
"dateOfDeath": "20.05.2019",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user