Compare commits

..

9 Commits

Author SHA1 Message Date
d93e8d1586 Merge feature/gerbil-residency-toggle: residency checkbox on Gerbil create/edit form [god-QA pending] 2026-06-06 12:15:20 +02:00
114bbd92c8 Merge feature/extract-band-aware (presence-wins dedup + correctDob): conflicts 19→15 [god-QA pending] 2026-06-06 12:15:01 +02:00
a8d8ae0dfc conflict-decisions: correctDob remaps a wrong-birthdate duplicate before dedup
god added a `correctDob` (DD.MM.YYYY) decisions field: the matched (name+dob)
record is a DUPLICATE with a wrong birthdate → remap its DOB to correctDob so
dedup MERGES it into the canonical same-named animal. apply_dob_remaps runs
BEFORE dedup (it changes the dedup identity); tolerates a missing file; logged
as "DOB-Remaps: N". First use: Chelsea *15.10.2021 → *02.04.2021 (merges into
the canonical record). test_extract covers the remap + that both records then
share one name+dob identity.

Extractor-only. python + dotnet 121/121 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:14:23 +02:00
522f2eec51 docs(HUMANQUESTION): C8 answered — Himalaya exists (A- c[h]c[h]); Hermelin = aa c[h]c[h]; both kept 2026-06-06 12:13:20 +02:00
3f71d8e28e dedup: 'presence wins' — present-vs-absent token is not a conflict (Julian)
Breeder merge rule: when two source variants of the SAME animal differ ONLY
by a token PRESENT in one and ABSENT in the other — a whole locus (e.g. spsp
charted in one source, omitted in another) or a modifier on the same base
allele (e^f vs e, the [f] marker) — keep the present token; that is NOT a
conflict. Genuine VALUE contradictions still quarantine: different base
alleles (Ee↔ee), unknown-vs-filled (D-↔DD), different modifiers (c[h]↔c[chm]),
C-↔Cc[h], P-↔Pp.

Replaces the old `len(distinct normalized geno keys) > 1` test with
_genotype_conflict() (per-locus, per-allele compatibility; '?'-vs-filled is a
contradiction, modifier-present-vs-absent and whole-locus-absence are not).
Markers/flags (WP/DP/WFNZ/hörend) are already tags/flags, never genotype, so
they never reach conflict detection; empty Farbschlag/death already don't
conflict (only non-empty values are compared).

Clears Daja (keep spsp), Ichika (keep ee[f]) and the D4 marker cases:
Konflikte 19 -> 15. test_extract covers spsp/[f] present-vs-absent =
no conflict and the four genuine-contradiction shapes. python + dotnet
121/121 green; extractor-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:12:45 +02:00
0e7ec5ab61 BESTAND-FILTER follow-up: residency toggle on the Gerbil create/edit form
Completes residency end-to-end: a 'Gehört zum eigenen Bestand' checkbox on the
Rennmaus-Formular (default on for new animals) lets the user mark a tier as an
external ancestor by hand — previously isResident was only set at import. Wired
through FormState + formFromGerbil (defaults true) + the CreateGerbil payload.

German via de.ts (LF). e2e: edit Krümel, untick Bestand, save -> detail shows
the 'Extern' badge (proves form field -> payload -> persistence). Gate green:
build, eslint, vitest 78, e2e 86.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:12:32 +02:00
12604fba7c data(import): D4 Victoria Welby resolved (ee[f]) — C's mother → C gets both parents — Julian 2026-06-06 12:12:23 +02:00
311c5461fd data(import): Chelsea (merge to *02.04.2021, correctDob) + Vestra (DD) resolved — Julian 2026-06-06 12:11:41 +02:00
3634d6ef9a data(import): D3 Silvain resolved (ee, Pp) — Julian 2026-06-06 12:09:41 +02:00
17 changed files with 198 additions and 423 deletions

View File

@@ -1,69 +0,0 @@
using System.Net;
using System.Net.Http.Json;
namespace GerbilManager.Tests;
/// <summary>
/// WEB-3: Round-Trips für die lokale Webseiten-Vorschau (GET /api/preview/…):
/// veröffentlichte Seiten werden als HTML ausgeliefert, Entwürfe nicht,
/// styles.css kommt mit CSS-Content-Type.
/// </summary>
public class CmsPreviewTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public CmsPreviewTests(ApiFactory factory) => _factory = factory;
private static object PageInput(string slug, string title, string status) =>
new { slug, title, seoDescription = (string?)null, status };
private sealed record PageRow(Guid Id, string Slug);
/// <summary>Seite anlegen oder (falls von WEB-0b geseedet) auf den Zielzustand setzen.</summary>
private static async Task UpsertPageAsync(HttpClient client, string slug, string title, string status)
{
var existing = (await client.GetFromJsonAsync<List<PageRow>>("/api/pages"))!
.FirstOrDefault(p => p.Slug == slug);
if (existing is null)
{
var created = await client.PostAsJsonAsync("/api/pages", PageInput(slug, title, status));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
}
else
{
var updated = await client.PutAsJsonAsync($"/api/pages/{existing.Id}", PageInput(slug, title, status));
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
}
}
[Fact]
public async Task Vorschau_LiefertVeroeffentlichteSeiteAlsHtml_UndKeineEntwuerfe()
{
var client = _factory.CreateClient();
await UpsertPageAsync(client, "start", "Willkommen bei der Zucht", "Published");
await UpsertPageAsync(client, "e2e-entwurf", "Unsere Zucht", "Draft");
// Startseite: /api/preview/ → index.html (text/html, enthält den Titel)
var index = await client.GetAsync("/api/preview/");
Assert.Equal(HttpStatusCode.OK, index.StatusCode);
Assert.StartsWith("text/html", index.Content.Headers.ContentType!.ToString());
Assert.Contains("Willkommen bei der Zucht", await index.Content.ReadAsStringAsync());
// Expliziter Pfad funktioniert ebenso
var explicitIndex = await client.GetAsync("/api/preview/index.html");
Assert.Equal(HttpStatusCode.OK, explicitIndex.StatusCode);
// CSS mit korrektem Content-Type (Renderer: SiteRenderer.CssPath)
var css = await client.GetAsync("/api/preview/assets/site.css");
Assert.Equal(HttpStatusCode.OK, css.StatusCode);
Assert.StartsWith("text/css", css.Content.Headers.ContentType!.ToString());
// Entwurf wird NICHT gerendert (Vorschau zeigt nur Veröffentlichtes)
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/index.html")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/")).StatusCode);
// Unsinnige Pfade → 404
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/gibt-es-nicht.html")).StatusCode);
}
}

View File

@@ -32,24 +32,6 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
});
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
// und liefert die Datei mit passendem Content-Type aus. Relative
// Links/CSS der gerenderten Seite funktionieren dadurch im
// Vorschau-iframe genauso wie später auf der echten Webseite. ----
api.MapGet("/preview/{**path}", async (string? path, ApplicationContext db) =>
{
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
var key = string.IsNullOrWhiteSpace(path) ? "index.html" : path.TrimEnd('/');
if (!files.TryGetValue(key, out var content) &&
!files.TryGetValue($"{key}/index.html", out content))
{
return Results.NotFound();
}
return Results.Content(content, PreviewContentType(key));
});
// ---- pages ----
api.MapGet("/pages", async (ApplicationContext db) =>
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)
@@ -169,13 +151,6 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
private static string PreviewContentType(string path) =>
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
: path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "application/xml; charset=utf-8"
: path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "text/plain; charset=utf-8"
: "text/html; charset=utf-8";
private static BlockDto ToBlockDto(Block b) =>
new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data));

View File

@@ -51,7 +51,7 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef`**Orangeschimmel** · `efef pp`**Rotaugenschimmel** · `efef gg`**Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef`**CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
| C8 | **„Himalaya" vs. „Hermelin":** Du hast gesagt `c[h]c[h]` = **Hermelin**. In unserem Katalog gibt es aktuell ZWEI Farben mit `c[h]c[h]`: **Hermelin** (`aa c[h]c[h]`, nicht-agouti) und **Himalaya** (`A- c[h]c[h]`, agouti). Gibt es bei dir „Himalaya" überhaupt, oder ist **alles** mit `c[h]c[h]` einfach **Hermelin** (dann nehmen wir „Himalaya" raus, wie bei Schwarzschimmel)? | Farbschlag-Katalog (Himalaya behalten oder entfernen) |
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
| C9 | *(optional, technisch)* Bei den **CP-Fuchs-Farben**: Wodurch unterscheiden sich genetisch **CP-Fuchs****CP-Blaufuchs****CP-Fuchs-Hell**? (Vermutung: Blaufuchs = `dd`-Verdünnung, „-Hell" = `c[chm]c[h]` statt `c[chm]c[chm]` — stimmt das?) Aktuell rechnet das Programm alle drei als „CP-Fuchs"; mit deiner Regel können wir sie genau unterscheiden. Per Hand auswählbar sind sie schon. | Farb-Engine Feinschliff (niedrige Priorität) |
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
@@ -93,10 +93,10 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-****PP** | ✅ **PP** — Julian |
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-****DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-****Cc[h]** · E-Locus: **E-****EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee****ee** · P-Locus: **P-****Pp** | ⏳ offen |
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee****ee** · P-Locus: **P-****Pp** | **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee****ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`****ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt**es gibt **zwei** „Chelsea v.d. K.C." mit verschiedenem Geburtsdatum: **\*02.04.2021** und **\*15.10.2021**. Zwei verschiedene Tiere, oder ist ein Datum falsch? | ⏳ offen |
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt**zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
@@ -105,8 +105,8 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
| Tier | Konkreter Konflikt — was stimmt? | Status |
|---|---|---|
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-****DD** (WP gleich in beiden) | ⏳ offen |
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]****ee[f]** (Fuchs ja/nein; DP gleich in beiden) — **das ist die Mutter von „C"!** Sobald geklärt, bekommt C auch seine Mutter. | ⏳ offen |
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-****DD** (WP gleich in beiden) | **DD** — Julian |
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]****ee[f]** **Mutter von „C"!** | ✅ **ee[f]** (`Aa CC D- ee[f] Gg pp Spsp [DP]`) — Julian → C bekommt damit seine Mutter |
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |

View File

@@ -27,3 +27,17 @@ test('Toggle „Externe Ahnen einblenden“ zeigt externe Tiere mit Extern-Marki
// Der Bestand bleibt weiterhin sichtbar.
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
})
test('Bearbeiten-Formular kann ein Tier als extern markieren (Bestand-Häkchen)', async ({ page }) => {
skipUnlessMock()
// Krümel gehört zum Bestand -> Häkchen entfernen und speichern.
await page.goto('/rennmaeuse/kruemel/bearbeiten')
const check = page.getByRole('checkbox', { name: t.form.isResidentLabel })
await expect(check).toBeChecked()
await check.uncheck()
await page.getByRole('button', { name: t.form.save }).click()
// Auf der Detailseite ist Krümel jetzt als „Extern“ markiert.
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.getByText(t.externalBadge, { exact: true })).toBeVisible()
})

View File

@@ -135,29 +135,6 @@ export async function installMockApi(page: Page): Promise<MockDb> {
if (method === 'PUT') return json(route, 204)
}
// ── WEB-3: lokale Vorschau (Renderer-Dateien; nur veröffentlichte Seiten) ──
if (path === '/render-site' && method === 'GET') {
const files = db.pages
.filter((p) => p.status === 'Published')
.map((p) => ({ path: p.slug === 'start' ? 'index.html' : `${p.slug}/index.html`, size: 1000 }))
return json(route, 200, [{ path: 'assets/site.css', size: 500 }, ...files])
}
const pv = path.match(/^\/preview(?:\/(.*))?$/)
if (pv && method === 'GET') {
const key = pv[1] ? pv[1].replace(/\/$/, '') : 'index.html'
if (key === 'assets/site.css') {
return route.fulfill({ status: 200, contentType: 'text/css', body: 'body{font-family:sans-serif}' })
}
const slug = key === 'index.html' ? 'start' : key.replace(/\/index\.html$/, '')
const p = db.pages.find((x) => x.slug === slug && x.status === 'Published')
if (!p) return json(route, 404, { title: 'Not Found' })
return route.fulfill({
status: 200,
contentType: 'text/html; charset=utf-8',
body: `<!doctype html><html lang="de"><head><meta charset="utf-8"><title>${p.title}</title></head><body><h1>${p.title}</h1><p>Vorschau-Mock</p></body></html>`,
})
}
// ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ──
if (path === '/requests/sync' && method === 'POST') {
return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' })

View File

@@ -1,66 +0,0 @@
/**
* WEB-3: Lokale Vorschau der öffentlichen Webseite — iframe lädt die
* gerenderte Seite, Seiten-Wechsler (nur Veröffentlichte), Smartphone/
* Desktop-Umschalter, Einstiege von der Übersicht.
* Mock-gebunden (Seed-Seiten) → skipUnlessMock.
*/
import { de, expect, skipUnlessMock, test } from './fixtures'
const tw = de.pages.webseite
const tv = tw.vorschau
test.describe('Webseiten-Vorschau', () => {
test('Vorschau öffnet die gerenderte Startseite im iframe', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite')
await page.getByRole('link', { name: tv.button }).click()
await expect(page.getByRole('heading', { name: tv.title })).toBeVisible()
await expect(page.getByText(tv.intro)).toBeVisible()
// Inhalt der gerenderten Seite (Mock-HTML) ist im iframe sichtbar
const frame = page.frameLocator('.vorschau-iframe')
await expect(frame.getByRole('heading', { name: 'Startseite' })).toBeVisible()
})
test('Seiten-Wechsler listet nur Veröffentlichte und wechselt die Vorschau', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite/vorschau')
const select = page.getByLabel(tv.pageSelect)
// 2 veröffentlichte Seed-Seiten; Entwürfe (z. B. „Über die Zucht“) fehlen
await expect(select.locator('option')).toHaveCount(2)
await expect(select.locator('option', { hasText: 'Über die Zucht' })).toHaveCount(0)
await select.selectOption({ label: 'Abgabetiere' })
const frame = page.frameLocator('.vorschau-iframe')
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
})
test('Smartphone/Desktop-Umschalter ändert die Rahmenbreite', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite/vorschau')
// Standard: Smartphone-Rahmen
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
await page.getByRole('button', { name: tv.viewDesktop }).click()
await expect(page.locator('.vorschau-frame--phone')).toHaveCount(0)
await page.getByRole('button', { name: tv.viewPhone }).click()
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
})
test('Übersicht: „Ansehen“ nur bei Veröffentlichten, öffnet die richtige Seite', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite')
// Veröffentlichte Karte hat den Ansehen-Link, Entwurf nicht
const abgabeCard = page.locator('.webseite-card', { hasText: 'Abgabetiere' })
const draftCard = page.locator('.webseite-card', { hasText: 'Über die Zucht' })
await expect(abgabeCard.getByRole('link', { name: tv.openPage })).toBeVisible()
await expect(draftCard.getByRole('link', { name: tv.openPage })).toHaveCount(0)
await abgabeCard.getByRole('link', { name: tv.openPage }).click()
await expect(page).toHaveURL(/\/webseite\/vorschau\?seite=abgabetiere/)
const frame = page.frameLocator('.vorschau-iframe')
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
})
})

View File

@@ -24,7 +24,6 @@ import VertragWizardPage from './pages/VertragWizardPage'
import EinstellungenPage from './pages/EinstellungenPage'
import WebseitePage from './pages/WebseitePage'
import WebseiteEditorPage from './pages/WebseiteEditorPage'
import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
import AnfragenPage from './pages/AnfragenPage'
import AnfrageDetailPage from './pages/AnfrageDetailPage'
@@ -73,8 +72,6 @@ export default function App() {
{/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */}
<Route path="webseite">
<Route index element={<WebseitePage />} />
{/* WEB-3: lokale Vorschau (statischer Pfad gewinnt vor :slug) */}
<Route path="vorschau" element={<WebseiteVorschauPage />} />
<Route path=":slug" element={<WebseiteEditorPage />} />
</Route>
{/* INBOX-1: Anfragen-Posteingang */}

View File

@@ -14,15 +14,7 @@
* ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein
* typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten).
*/
import { API_BASE_URL, api } from './client'
/**
* WEB-3-Fix: Die CMS-Endpunkte liegen unter der /api-Gruppe
* (CmsEndpoints: MapGroup("/api")) — die Aufrufe hier liefen vorher gegen
* /pages und wären gegen die ECHTE API 404 gelaufen (der e2e-Mock hat den
* Unterschied kaschiert, weil er das /api-Präfix normalisiert).
*/
const CMS = '/api'
import { api } from './client'
export type PageStatus = 'Draft' | 'Published'
@@ -129,39 +121,29 @@ export function defaultBlockData(type: BlockType): BlockData {
// ── API-Aufrufe ──────────────────────────────────────────────────────────────
export function listPages(): Promise<PageSummary[]> {
return api.get<PageSummary[]>(`${CMS}/pages`)
return api.get<PageSummary[]>('/pages')
}
export function getPage(slug: string): Promise<Page> {
return api.get<Page>(`${CMS}/pages/${encodeURIComponent(slug)}`)
return api.get<Page>(`/pages/${encodeURIComponent(slug)}`)
}
export function updatePage(id: string, input: PageInput): Promise<void> {
return api.put<void>(`${CMS}/pages/${id}`, input)
return api.put<void>(`/pages/${id}`, input)
}
export function addBlock(pageId: string, input: BlockInput): Promise<Block> {
return api.post<Block>(`${CMS}/pages/${pageId}/blocks`, input)
return api.post<Block>(`/pages/${pageId}/blocks`, input)
}
export function updateBlock(id: string, input: BlockInput): Promise<void> {
return api.put<void>(`${CMS}/blocks/${id}`, input)
return api.put<void>(`/blocks/${id}`, input)
}
export function deleteBlock(id: string): Promise<void> {
return api.delete(`${CMS}/blocks/${id}`)
return api.delete(`/blocks/${id}`)
}
export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> {
return api.put<void>(`${CMS}/pages/${pageId}/blocks/order`, { blockIds })
}
/**
* WEB-3: Absolute URL der lokalen Vorschau (GET /api/preview/… rendert live).
* "start" liegt auf index.html, alle anderen Seiten auf {slug}/index.html —
* dieselbe Abbildung wie im SiteRenderer.
*/
export function previewUrl(slug?: string | null): string {
const path = !slug || slug === 'start' ? 'index.html' : `${encodeURIComponent(slug)}/index.html`
return `${API_BASE_URL}/api/preview/${path}`
return api.put<void>(`/pages/${pageId}/blocks/order`, { blockIds })
}

View File

@@ -24,6 +24,7 @@ interface FormState {
receiverContactId: string
genotype: string
notes: string
isResident: boolean
}
const EMPTY: FormState = {
@@ -41,6 +42,7 @@ const EMPTY: FormState = {
receiverContactId: '',
genotype: '',
notes: '',
isResident: true,
}
function formFromGerbil(g: {
@@ -58,6 +60,7 @@ function formFromGerbil(g: {
receiverContactId: string | null
genotype: string | null
notes: string | null
isResident?: boolean | null
}): FormState {
return {
name: g.name,
@@ -74,6 +77,7 @@ function formFromGerbil(g: {
receiverContactId: g.receiverContactId ?? '',
genotype: g.genotype ?? '',
notes: g.notes ?? '',
isResident: g.isResident ?? true,
}
}
@@ -166,6 +170,7 @@ export default function GerbilFormPage() {
receiverContactId: nn(form.receiverContactId),
genotype: nn(form.genotype),
notes: nn(form.notes),
isResident: form.isResident,
}
const result = await mutation.run(body)
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
@@ -355,6 +360,15 @@ export default function GerbilFormPage() {
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
</label>
<label className="field field--check" title={t.form.isResidentHint}>
<span>{t.form.isResidentLabel}</span>
<input
type="checkbox"
checked={form.isResident}
onChange={(e) => set('isResident', e.target.checked)}
/>
</label>
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
<div className="form-actions">

View File

@@ -41,12 +41,6 @@ export default function WebseitePage() {
<h2>{t.title}</h2>
<p className="muted">{t.intro}</p>
</div>
{/* WEB-3: lokale Vorschau der gerenderten Webseite */}
<div className="head-actions">
<Link to="/webseite/vorschau" className="btn btn--primary">
{t.vorschau.button}
</Link>
</div>
</header>
{pages.data.length === 0 ? (
@@ -59,12 +53,6 @@ export default function WebseitePage() {
<span className="gerbil-card__meta">/{p.slug}</span>
<span className="webseite-card__actions">
<StatusBadge status={p.status} />
{/* WEB-3: veröffentlichte Seiten direkt in der Vorschau öffnen */}
{p.status === 'Published' && (
<Link to={`/webseite/vorschau?seite=${encodeURIComponent(p.slug)}`} className="btn">
{t.vorschau.openPage}
</Link>
)}
<Link to={`/webseite/${p.slug}`} className="btn btn--primary">
{t.edit}
</Link>

View File

@@ -1,121 +0,0 @@
/**
* WEB-3: Lokale Vorschau der öffentlichen Webseite (/webseite/vorschau).
*
* Zeigt die vom Backend live gerenderte Seite (GET /api/preview/…) in einem
* iframe — exakt das HTML/CSS, das später veröffentlicht wird. Navigation
* INNERHALB der Vorschau funktioniert über die relativen Links der
* gerenderten Seite selbst; zusätzlich gibt es einen Seiten-Wechsler,
* einen Smartphone/Desktop-Umschalter und „Neu laden“.
* Nur veröffentlichte Seiten werden gerendert (SiteRenderer überspringt
* Entwürfe) — der Hinweis dazu steht über der Vorschau.
* Veröffentlichen selbst ist WEB-2 (gated) — hier gibt es bewusst keinen
* Publish-Knopf.
*/
import { useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { listPages, previewUrl } from '../api/pages'
import { useApi } from '../hooks/useApi'
import './webseiteVorschau.css'
type Viewport = 'phone' | 'desktop'
export default function WebseiteVorschauPage() {
const t = de.pages.webseite.vorschau
const [params] = useSearchParams()
const pages = useApi(() => listPages(), [])
const published = useMemo(
() => (pages.data ?? []).filter((p) => p.status === 'Published'),
[pages.data],
)
const requested = params.get('seite')
const [selected, setSelected] = useState<string | null>(requested)
const slug =
(selected && published.some((p) => p.slug === selected) ? selected : null) ??
(published.some((p) => p.slug === 'start') ? 'start' : (published[0]?.slug ?? null))
const [viewport, setViewport] = useState<Viewport>('phone')
const [reloadKey, setReloadKey] = useState(0)
if (pages.loading) return <p className="muted">{de.common.loading}</p>
if (pages.error) {
return (
<section className="page">
<h2>{t.title}</h2>
<div className="alert alert--error">
<span>{pages.error}</span>
<button type="button" className="btn" onClick={pages.reload}>
{de.common.retry}
</button>
</div>
</section>
)
}
return (
<section className="page vorschau-page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
<p className="muted">{t.intro}</p>
</div>
<div className="head-actions">
<Link to="/webseite" className="btn">
{de.pages.webseite.back}
</Link>
</div>
</header>
{slug === null ? (
<p className="muted">{t.empty}</p>
) : (
<>
<div className="vorschau-toolbar">
<label className="field">
<span>{t.pageSelect}</span>
<select value={slug} onChange={(e) => setSelected(e.target.value)}>
{published.map((p) => (
<option key={p.id} value={p.slug}>
{p.title}
</option>
))}
</select>
</label>
<div className="vorschau-viewports" role="group" aria-label={`${t.viewPhone} / ${t.viewDesktop}`}>
<button
type="button"
className={viewport === 'phone' ? 'btn btn--primary' : 'btn'}
onClick={() => setViewport('phone')}
>
{t.viewPhone}
</button>
<button
type="button"
className={viewport === 'desktop' ? 'btn btn--primary' : 'btn'}
onClick={() => setViewport('desktop')}
>
{t.viewDesktop}
</button>
</div>
<button type="button" className="btn" onClick={() => setReloadKey((k) => k + 1)}>
{t.reload}
</button>
</div>
<div className={viewport === 'phone' ? 'vorschau-frame vorschau-frame--phone' : 'vorschau-frame'}>
<iframe
key={`${slug}:${reloadKey}`}
className="vorschau-iframe"
title={t.frameTitle}
src={previewUrl(slug)}
/>
</div>
</>
)}
</section>
)
}

View File

@@ -1,37 +0,0 @@
/* WEB-3: Webseiten-Vorschau — seitenspezifische Stile
(Standing-Rule-Muster: eigene Datei statt index.css). */
.vorschau-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
margin: 0.75rem 0;
}
.vorschau-viewports {
display: flex;
gap: 0.35rem;
}
.vorschau-frame {
border: 1px solid var(--color-border);
border-radius: 0.6rem;
background: var(--color-surface);
overflow: hidden;
height: min(70dvh, 50rem);
}
/* Smartphone-Ansicht: schmaler Rahmen, mittig — wie ein Handy auf dem Tisch. */
.vorschau-frame--phone {
max-width: 400px;
margin-inline: auto;
}
.vorschau-iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}

View File

@@ -116,6 +116,9 @@ export const de = {
none: '— keine Angabe —',
genotypeHint:
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
isResidentLabel: 'Gehört zum eigenen Bestand',
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
save: 'Speichern',
cancel: 'Abbrechen',
saving: 'Speichern …',
@@ -652,22 +655,6 @@ export const de = {
statusPublished: 'Veröffentlicht',
edit: 'Bearbeiten',
back: 'Zurück zur Übersicht',
// ── WEB-3 (Kelly): lokale Vorschau ──
vorschau: {
button: 'Vorschau',
title: 'Vorschau der Webseite',
intro:
'So sieht deine Webseite nach dem Veröffentlichen aus. Entwürfe erscheinen hier noch nicht.',
pageSelect: 'Seite',
reload: 'Neu laden',
viewPhone: 'Smartphone',
viewDesktop: 'Desktop',
frameTitle: 'Vorschau der öffentlichen Webseite',
empty:
'Noch keine veröffentlichte Seite — stelle eine Seite auf „Veröffentlicht“, um die Vorschau zu sehen.',
draftHint: 'Entwurf — erscheint noch nicht in der Vorschau.',
openPage: 'Ansehen',
},
// Seiten-Editor (Kopf)
editor: {
pageTitleLabel: 'Seitentitel',

View File

@@ -1,5 +1,5 @@
{
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
"resolutions": [
{
"name": "Firefly von den Kleinen Chaoten",
@@ -56,6 +56,34 @@
"decision": "C-locus = Cc[h], E-locus = EE",
"genotype": "aa Cc[h] dd EE Gg P- Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Silvain von den Kleinen Chaoten",
"dob": "27.03.2022",
"decision": "E-locus = ee, P-locus = Pp",
"genotype": "aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Chelsea von den Kleinen Chaoten",
"dob": "15.10.2021",
"decision": "duplicate with wrong birthdate — same animal as Chelsea *02.04.2021; merge into it",
"correctDob": "02.04.2021",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 (Chelsea Dublette)"
},
{
"name": "Vestra von den Schlossmäusen",
"dob": "08.02.2019",
"decision": "D-locus = DD",
"genotype": "Aa Cc[chm] DD EE GG PP Spsp [WP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
},
{
"name": "Victoria Welby gen. Welby von den Kleinen Chaoten",
"dob": "16.01.2023",
"decision": "E-locus = ee[f] (Fuchs). NOTE: this is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent.",
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
}
]
}

View File

@@ -502,6 +502,52 @@ def _geno_key(genodict):
return "|".join(f"{locus}:{','.join(sorted(m[locus]))}" for locus in sorted(m))
# --- "presence wins" merge rule (Julian) -------------------------------------
# When two source variants of the SAME animal differ ONLY by a token PRESENT in one and
# ABSENT in the other — a whole locus (e.g. spsp recorded in one chart, omitted in another)
# or a modifier on the same base allele (e^f vs e, i.e. the [f] marker) — keep the present
# token; that is NOT a conflict. A genuine VALUE contradiction (different filled alleles:
# E vs e, D vs d, c^h vs c^chm) OR unknown-vs-filled (D- vs DD, the '?' second allele) STILL
# quarantines for human decision. (Markers/flags WP/DP/WFNZ/hörend are already tags/flags,
# never part of the genotype, so they never reach here.)
def _split_allele(a):
return tuple(a.split("^", 1)) if "^" in a else (a, "")
def _alleles_compatible(a, b):
if a == b:
return True
if a == "?" or b == "?":
return False # unknown vs filled = contradiction (D- vs DD)
(ba, ma), (bb, mb) = _split_allele(a), _split_allele(b)
if ba != bb:
return False # different base allele = real value diff (E vs e)
return ma == "" or mb == "" # same base, modifier present-vs-absent -> presence wins
def _pair_compatible(p, q):
if len(p) != 2 or len(q) != 2:
return p == q
return ((_alleles_compatible(p[0], q[0]) and _alleles_compatible(p[1], q[1])) or
(_alleles_compatible(p[0], q[1]) and _alleles_compatible(p[1], q[0])))
def _genotype_conflict(mapped_list):
"""True only if two variants GENUINELY contradict at a shared locus. A locus present in
one variant and absent in another is fine (presence wins); so is a modifier present-vs-
absent on the same base allele. Replaces the old `len(distinct geno keys) > 1` test."""
loci = set()
for m in mapped_list:
loci.update(m.keys())
for locus in loci:
pairs = [m[locus] for m in mapped_list if locus in m]
for i in range(len(pairs)):
for j in range(i + 1, len(pairs)):
if not _pair_compatible(pairs[i], pairs[j]):
return True
return False
def dedup(animals):
"""Merge by normalise(call-name)+DOB, with the canonical Zucht as
DISCRIMINATOR (Julian: same name+DOB+Zucht = same animal; different Zucht =
@@ -552,6 +598,7 @@ def dedup(animals):
parent_refs = list(base["parentRefs"])
genos = set()
geno_keys = set() # GEN-3b: conflict on NORMALIZED genotype (Uw==G) not raw text
mapped_variants = [] # mapped8locus per variant — for the 'presence wins' conflict test
farb = set()
deaths = set()
deaf_seen = set()
@@ -567,6 +614,7 @@ def dedup(animals):
if a["genotype"]["mapped8locus"]:
genos.add(a["genotype"]["rawGenotype"])
geno_keys.add(_geno_key(a["genotype"]))
mapped_variants.append(a["genotype"]["mapped8locus"])
if a["farbschlag"]:
farb.add(a["farbschlag"])
if a["death"]:
@@ -603,8 +651,9 @@ def dedup(animals):
"conflict": False,
}
merged.append(out)
# conflict: same animal, disagreeing NORMALIZED genotype (Uw==G) or farbschlag or death
if len(geno_keys) > 1 or len(farb) > 1 or len(deaths) > 1:
# conflict: same animal, GENUINELY disagreeing genotype (presence-vs-absence is NOT a
# conflict — Julian's 'presence wins') or >1 distinct farbschlag or >1 distinct death.
if _genotype_conflict(mapped_variants) or len(farb) > 1 or len(deaths) > 1:
out["conflict"] = True
conflicts.append({
"id": out["id"], "name": base["name"], "dob": out["dob"],
@@ -834,6 +883,31 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count,
# ------------------------------------------------------------------------ main
def apply_dob_remaps(raw_animals, path):
"""PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a
wrong birthdate — remap that raw record's DOB to correctDob so dedup MERGES it into the
canonical same-named animal (e.g. Chelsea *15.10.2021 -> *02.04.2021). Match =
norm_name(name)+norm_dob(dob). Tolerates a missing/garbled file. Returns the remap count.
Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)"""
remaps = {}
try:
with open(path, encoding="utf-8") as fh:
for r in (json.load(fh).get("resolutions") or []):
if r.get("correctDob"):
remaps[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r["correctDob"]
except (OSError, ValueError):
return 0
if not remaps:
return 0
n = 0
for a in raw_animals:
new = remaps.get((norm_name(a.get("name", "")), norm_dob(a.get("dob", ""))))
if new and a.get("dob") != new:
a["dob"] = new
n += 1
return n
def apply_conflict_decisions(merged, conflicts, path):
"""Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's
answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?,
@@ -904,8 +978,9 @@ def main():
litters = extract_wurfchronik(args.wurfchronik)
print(f"Wurfchronik: {len(litters)} Würfe")
merged, conflicts, orphans, zucht_splits = dedup(raw_animals)
decisions_path = os.path.join(HERE, "conflict-decisions.json")
dob_remaps = apply_dob_remaps(raw_animals, decisions_path) # before dedup (changes identity)
merged, conflicts, orphans, zucht_splits = dedup(raw_animals)
resolved_by_decision = apply_conflict_decisions(merged, conflicts, decisions_path)
match_stats = match_litters(merged, litters)
photo_count = sum(len(a["photos"]) for a in merged)
@@ -924,7 +999,7 @@ def main():
print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} "
f"| Konflikte: {len(conflicts)} | per Entscheidung gelöst: {resolved_by_decision} "
f"| Zucht-Splits: {len(zucht_splits)} "
f"| DOB-Remaps: {dob_remaps} | Zucht-Splits: {len(zucht_splits)} "
f"| Orphans: {len(orphans)} | Fotos: {photo_count}")
print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), "
f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig "

View File

@@ -5,10 +5,10 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
## Überblick
- Rohe Tier-Einträge aus den Stammbäumen: **950**
- Nach Zusammenführung (eindeutige Tiere): **622**
- davon mit Geburtsdatum: 327
- Nach Zusammenführung (eindeutige Tiere): **621**
- davon mit Geburtsdatum: 326
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
- Konflikte zur Klärung: **19**
- Konflikte zur Klärung: **9**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
- Fotos zugeordnet: **137**
- Würfe aus der Wurfchronik: **752**
@@ -26,20 +26,10 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|---|---|---|---|---|---|
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
| Louis von den Kleinen Chaoten | 15.07.2017 | Aa Cc[] D- Ee Gg P- spsp // Aa Cc[chm] D- Ee Uwuw[d] P- spsp | — | 01.07.2020 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| Zuleika von den Kleinen Chaoten | 24.10.2015 | aa c[chm]c[h] D- E G P- spsp // aa c[chm]c[h] D- Ee Gg P- spsp // aa c[chm]c[h] DD Ee Gg P- spsp | — | 24.02.2019 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
| Vestra von den Schlossmäusen | 08.02.2019 | Aa Cc[chm] D- EE GG PP Spsp [WP] // Aa Cc[chm] DD EE GG PP Spsp [WP] | — | 26.05.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
| Flint von den Kleinen Chaoten | 23.12.2017 | aa Cc[chm] D- ee Gg P- spsp | — | 10.05.2021 // 10.05.2022 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance |
| Milka of LennyLengo | 09.12.2018 | aa C- dd E- Gg P- Spsp // aa Cc[h] dd EE Gg P- Spsp | — | 22.12.2021 | Stammbaum von Alberto Kids, Stammbaum von Stella Kids |
| Silvain von den Kleinen Chaoten | 27.03.2022 | aa c[chm]c[chm] Dd Ee[-] Gg P- Spsp // aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp | — | 31.12.2024 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| Enya von den Kleinen Chaoten | 01.11.2017 | Aa c[chm]c[chm] D- ee[-] G- P- spsp // Aa c[chm]c[chm] D- ee[-] Uwuw[d] P- spsp | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids |
| Little Hero of Black Forest | 22.02.2018 | AA CC DD EE GG PP [WFNZ] // AA CC DD EE GG PP spsp [WFNZ] | — | 18.06.2021 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids, Stammbaum von Valentino Firehearts Kids |
| Molly of Black Forest | 13.09.2021 | /+, Aa Cc[chm] D- Ee gg P- spsp // Aa Cc[chm] Dd Ee gg Pp spsp | — | 03.05.2021 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
| Daja of Little Rose | 16.05.2021 | aa chmchm D- EE Gg P- // aa chmchm D- EE Gg P- spsp | — | — | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
| Ichika von den Kleinen Chaoten | 19.04.2020 | aa CC D- ee Gg pp spsp // aa CC D- ee[f] Gg pp spsp | — | 27.11.2023 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Watarus Kids |
| Victoria Welby gen. Welby v.d. Kleinen Chaoten | 16.01.2023 | Aa CC D- Ee[f] Gg pp Spsp [DP] // Aa CC D- ee[f] Gg pp Spsp [DP] | Goldfuchsschimmel Punktschecke DP | 17.02.2026 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
| Zac gen. Action von den Kleinen Chaoten | 25.12.2020 | aa C- D- Ee G- Pp Spsp [DP] // aa CC D- Ee G- Pp Spsp [DP] | — | 31.01.2025 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
| Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids |
@@ -101,7 +91,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
- „Charly of Golden Lights“ → Charly of Golden Lights (*05.04.2016)
- „Ziwa of Golden Lights“ → Ziwa of Golden Lights (*29.04.2016)
- „Chelsea von den Kleinen Chaoten“ → Chelsea von den Kleinen Chaoten (*02.04.2021); Chelsea von den Kleinen Chaoten (*15.10.2021)
- „Chelsea von den Kleinen Chaoten“ → Chelsea von den Kleinen Chaoten (*02.04.2021)
- „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
- „Scarlett of Samsimar“ → Scarlett of Samsimar (*05.09.2018)
@@ -143,12 +133,12 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `/+` | 7 | ? |
| `-g` | 2 | ? |
| `C(C)` | 2 | Schreibweise (C trägt c) |
| `chmchm` | 2 | Schreibweise (c[chm]c[chm]) |
| `Cc[]` | 1 | ? |
| `-psp` | 1 | ? |
| `G(G)` | 1 | ? |
| `/` | 1 | ? |
| `+2018` | 1 | ? |
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
| `c[chm]chm]` | 1 | ? |
| `Dea/dea]` | 1 | ? |
| `DD-Tumor` | 1 | ? |

View File

@@ -101,9 +101,50 @@ check("decision removes both entries from conflicts list", conflicts == [])
check("apply_conflict_decisions returns resolved count", n == 2)
check("missing decisions file tolerated (returns 0)",
e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0)
# --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges ---
dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json")
_json.dump({"resolutions": [
{"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021",
"decision": "duplicate wrong birthdate", "correctDob": "02.04.2021", "source": "test"},
]}, open(dec2, "w", encoding="utf-8"))
raw = [
{"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021"}, # the wrong-dob duplicate
{"name": "Chelsea von den Kleinen Chaoten", "dob": "02.04.2021"}, # canonical
{"name": "Other Animal", "dob": "01.01.2020"},
]
rn = e.apply_dob_remaps(raw, dec2)
check("correctDob remaps the wrong-dob record", raw[0]["dob"] == "02.04.2021")
check("correctDob leaves the canonical record alone", raw[1]["dob"] == "02.04.2021")
check("correctDob leaves unrelated records alone", raw[2]["dob"] == "01.01.2020")
check("apply_dob_remaps returns remap count", rn == 1)
check("after remap both Chelsea share one dedup identity (name+dob)",
e.norm_dob(raw[0]["dob"]) == e.norm_dob(raw[1]["dob"]))
check("missing decisions file tolerated for dob remaps (returns 0)",
e.apply_dob_remaps([], os.path.join(tempfile.gettempdir(), "nope.json")) == 0)
try: os.remove(dec2)
except OSError: pass
try: os.remove(dec_path)
except OSError: pass
# --- "presence wins" conflict rule (Julian) ---
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing filled values are.
check("spsp present vs locus absent -> no conflict",
not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}]))
check("ee[f] vs ee ([f] modifier present/absent) -> no conflict",
not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}]))
check("DD vs D- (unknown vs filled) -> conflict",
e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}]))
check("Ee vs ee (different base allele) -> conflict",
e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}]))
check("C- vs Cc[h] -> conflict",
e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}]))
check("c[h] vs c[chm] (different modifiers) -> conflict",
not e._alleles_compatible("c^h", "c^chm"))
check("identical genotypes -> no conflict",
not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}]))
# --- name-bleed guard (a parent name is not a Farbschlag) ---
check("v.d. name rejected", e.looks_like_animal_name("Tennessee von den Kleinen Chaoten"))
check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten"))