Compare commits
4 Commits
db85a6e0dc
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| 97acaf6b10 | |||
| 9ed68ba38a | |||
| dfcd296119 | |||
| 0c94cfcbf1 |
@@ -11,17 +11,21 @@ public class CmsTests : IClassFixture<ApiFactory>
|
|||||||
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Snapshot_has_site_nav_and_six_seeded_pages()
|
public async Task Snapshot_has_site_nav_and_seeded_pages()
|
||||||
{
|
{
|
||||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
||||||
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
||||||
|
// main nav = 6 core pages (Impressum/Datenschutz are footer-only, not in navOrder)
|
||||||
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
||||||
|
|
||||||
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
||||||
Assert.Equal(6, pages.Count);
|
// 8 pages: 6 core + impressum + datenschutz (footer-only legal pages)
|
||||||
|
Assert.Equal(8, pages.Count);
|
||||||
|
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "impressum");
|
||||||
|
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "datenschutz");
|
||||||
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
||||||
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
||||||
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
||||||
|
|||||||
@@ -321,6 +321,192 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
|
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ParentFkBackfill_fills_null_litter_parent_on_reimport()
|
||||||
|
{
|
||||||
|
// Run 1: litter "Wurf A" has sire "Vater" (conflict=true — not loaded) and dam "Mutter"
|
||||||
|
// (conflict=false — loaded). After run 1: litter.FatherId = null.
|
||||||
|
// Run 2: sire "Vater" now conflict=false → loaded as NEW in run 2. Backfill via
|
||||||
|
// createdAnimalByName sets FatherId. (god steering point 3: run-2 path.)
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "backfill-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var littersJson = """
|
||||||
|
[{"id":"L-A","litterId":"A","date":"01.05.2023","damName":"Mutter [ZdkC]","sireName":"Vater [ZdkC]","totalBorn":3,"zuchtnummer":"","note":""}]
|
||||||
|
""";
|
||||||
|
// Run 1: Vater is in conflict -> not loaded
|
||||||
|
var animals1 = """
|
||||||
|
[
|
||||||
|
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||||
|
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||||
|
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var report1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, report1.Litters.ParentFksBackfilled);
|
||||||
|
var litter1 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||||
|
Assert.Null(litter1.FatherId); // Vater was quarantined -> null FK
|
||||||
|
Assert.NotNull(litter1.MotherId); // Mutter was loaded -> set
|
||||||
|
|
||||||
|
// Run 2: Vater now conflict=false -> loaded as NEW animal in this run
|
||||||
|
var animals2 = """
|
||||||
|
[
|
||||||
|
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||||
|
|
||||||
|
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(1, report2.Litters.ParentFksBackfilled); // backfill happened
|
||||||
|
var vater = await db.Gerbils.SingleAsync(g => g.ExternalRef == "vater");
|
||||||
|
var litter2 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||||
|
Assert.Equal(vater.Id, litter2.FatherId); // FK now set
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ParentFkBackfill_uses_allDb_lookup_when_parent_not_in_current_loadable()
|
||||||
|
{
|
||||||
|
// god steering point 3: the main case — parent was loaded in a PREVIOUS run (not in
|
||||||
|
// the current run's animals.json at all). Backfill must find them via allDbNormToGid.
|
||||||
|
//
|
||||||
|
// Run 1: litter "Wurf C" + dam loaded, sire quarantined -> FatherId null.
|
||||||
|
// Run 2: sire loaded (new animal).
|
||||||
|
// Run 3: animals.json has ONLY the kind (sire absent from extract). Sire is in DB
|
||||||
|
// from run 2 but NOT in the current run's loadable/createdAnimalByName.
|
||||||
|
// Backfill must use allDbNormToGid to find him.
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "backfill-db-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var littersJson = """
|
||||||
|
[{"id":"L-C","litterId":"C","date":"10.06.2023","damName":"Dame [ZdkC]","sireName":"Herr [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||||
|
""";
|
||||||
|
// Run 1: sire quarantined
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[
|
||||||
|
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Null((await db.Litters.SingleAsync(l => l.Name == "Wurf C")).FatherId);
|
||||||
|
|
||||||
|
// Run 2: sire now loaded
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[
|
||||||
|
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
var herrId = (await db.Gerbils.SingleAsync(g => g.ExternalRef == "herr")).Id;
|
||||||
|
// Run 2 itself may or may not backfill (depends on name normalization alignment).
|
||||||
|
// For the test we care about run 3.
|
||||||
|
|
||||||
|
// Run 3: sire NOT in animals.json at all (absent from new extract).
|
||||||
|
// litter still has FatherId=null if run 2 didn't backfill; if it did, we simulate
|
||||||
|
// by manually resetting FatherId to null so run 3 must fix it.
|
||||||
|
var litter3 = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||||
|
litter3.FatherId = null;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[
|
||||||
|
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
// Run 3: sire absent from loadable (NOT in createdAnimalByName), but IS in DB.
|
||||||
|
var report3 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(1, report3.Litters.ParentFksBackfilled); // allDbNormToGid path
|
||||||
|
var litter3After = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||||
|
Assert.Equal(herrId, litter3After.FatherId); // FK set from DB lookup
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ParentFkBackfill_dry_run_counts_without_writing()
|
||||||
|
{
|
||||||
|
// Dry-run on a DB with an existing null-parent litter should predict the backfill count.
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "backfill-dr-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var littersJson = """
|
||||||
|
[{"id":"L-B","litterId":"B","date":"15.06.2023","damName":"Mami [ZdkC]","sireName":"Papi [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||||
|
""";
|
||||||
|
var animals1 = """
|
||||||
|
[
|
||||||
|
{"id":"mami","name":"Mami [ZdkC]","dob":"03.03.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"papi","name":"Papi [ZdkC]","dob":"04.04.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||||
|
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
await new ImportService(db, dir, dir).RunAsync(execute: true); // run 1
|
||||||
|
|
||||||
|
// Run 2 dry-run with papi un-quarantined
|
||||||
|
var animals2 = animals1.Replace("\"conflict\":true", "\"conflict\":false");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||||
|
var dry = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||||
|
|
||||||
|
Assert.Equal(1, dry.Litters.ParentFksBackfilled); // predicted but not written
|
||||||
|
var litter = await db.Litters.SingleAsync(l => l.Name == "Wurf B");
|
||||||
|
Assert.Null(litter.FatherId); // not written in dry-run
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("01.02.2020", 2020, 2, 1)]
|
[InlineData("01.02.2020", 2020, 2, 1)]
|
||||||
[InlineData("5.3.21", 2021, 3, 5)]
|
[InlineData("5.3.21", 2021, 3, 5)]
|
||||||
|
|||||||
@@ -69,10 +69,11 @@ public class SiteRendererTests
|
|||||||
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
||||||
};
|
};
|
||||||
|
|
||||||
private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals)
|
private static JsonObject AbgabetiereBlock(string intro,
|
||||||
|
params (string name, string farbe, string? group, string? photo, string? gender, string? dob, string? note)[] animals)
|
||||||
{
|
{
|
||||||
var animalArr = new JsonArray();
|
var animalArr = new JsonArray();
|
||||||
foreach (var (name, farbe, group, photo) in animals)
|
foreach (var (name, farbe, group, photo, gender, dob, note) in animals)
|
||||||
{
|
{
|
||||||
var photos = new JsonArray();
|
var photos = new JsonArray();
|
||||||
if (photo is not null) photos.Add(photo);
|
if (photo is not null) photos.Add(photo);
|
||||||
@@ -81,6 +82,9 @@ public class SiteRendererTests
|
|||||||
["name"] = name,
|
["name"] = name,
|
||||||
["farbschlag"] = farbe,
|
["farbschlag"] = farbe,
|
||||||
["group"] = group,
|
["group"] = group,
|
||||||
|
["gender"] = gender,
|
||||||
|
["dateOfBirth"] = dob,
|
||||||
|
["characterNote"] = note,
|
||||||
["photos"] = photos,
|
["photos"] = photos,
|
||||||
["aiSaleText"] = (JsonNode?)null,
|
["aiSaleText"] = (JsonNode?)null,
|
||||||
});
|
});
|
||||||
@@ -92,6 +96,7 @@ public class SiteRendererTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -211,16 +216,52 @@ public class SiteRendererTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag()
|
public void AbgabetiereList_renders_group_card_with_name_farbschlag_and_photo()
|
||||||
{
|
{
|
||||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||||
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg")))));
|
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null)))));
|
||||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||||
Assert.Contains("Krümel", html);
|
Assert.Contains("Krümel", html);
|
||||||
Assert.Contains("CP-Agouti", html);
|
Assert.Contains("CP-Agouti", html);
|
||||||
Assert.Contains("Großbecken", html);
|
Assert.Contains("Großbecken", html);
|
||||||
Assert.Contains("/photos/files/abc.jpg", html);
|
Assert.Contains("/photos/files/abc.jpg", html);
|
||||||
Assert.Contains("cms-animal-card", html);
|
Assert.Contains("cms-group-card", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbgabetiereList_group_card_shows_gender_label_and_available_badge()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||||
|
Blocks(AbgabetiereBlock("",
|
||||||
|
("Frieda", "Agouti", "Becken 1", null, "female", null, null),
|
||||||
|
("Rosa", "PEW", "Becken 1", null, "female", null, null)))));
|
||||||
|
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||||
|
Assert.Contains("Weibchen", html);
|
||||||
|
Assert.Contains("Verfügbar", html);
|
||||||
|
Assert.Contains("cms-badge--free", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbgabetiereList_shows_dateOfBirth_and_characterNote()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||||
|
Blocks(AbgabetiereBlock("",
|
||||||
|
("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt.")))));
|
||||||
|
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||||
|
Assert.Contains("März 2024", html);
|
||||||
|
Assert.Contains("Sehr neugierig und verspielt.", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbgabetiereList_solo_animal_without_group_gets_its_own_card()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||||
|
Blocks(AbgabetiereBlock("",
|
||||||
|
("Einzelkind", "Zobel", null, null, "male", null, null)))));
|
||||||
|
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||||
|
Assert.Contains("Einzelkind", html);
|
||||||
|
Assert.Contains("Männchen", html);
|
||||||
|
Assert.Contains("cms-group-card", html);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -283,6 +324,44 @@ public class SiteRendererTests
|
|||||||
Assert.Equal(7, files.Count);
|
Assert.Equal(7, files.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_impressum_and_datenschutz_pages_are_rendered()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(
|
||||||
|
("start", "Startseite", Blocks()),
|
||||||
|
("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))),
|
||||||
|
("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter"))));
|
||||||
|
var files = SiteRenderer.Render(snap);
|
||||||
|
Assert.True(files.ContainsKey("impressum/index.html"));
|
||||||
|
Assert.True(files.ContainsKey("datenschutz/index.html"));
|
||||||
|
Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Footer_contains_impressum_and_datenschutz_links()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(
|
||||||
|
("start", "Startseite", Blocks()),
|
||||||
|
("kontakt", "Kontakt", Blocks()));
|
||||||
|
var startHtml = SiteRenderer.Render(snap)["index.html"];
|
||||||
|
Assert.Contains("impressum/index.html", startHtml);
|
||||||
|
Assert.Contains("datenschutz/index.html", startHtml);
|
||||||
|
// links in footer, not nav
|
||||||
|
Assert.Contains("site-footer-link", startHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Footer_links_use_correct_relative_path_from_subpage()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(
|
||||||
|
("start", "Startseite", Blocks()),
|
||||||
|
("kontakt", "Kontakt", Blocks()));
|
||||||
|
var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"];
|
||||||
|
// subpage needs ../ prefix for footer links
|
||||||
|
Assert.Contains("../impressum/index.html", kontaktHtml);
|
||||||
|
Assert.Contains("../datenschutz/index.html", kontaktHtml);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Markdown tests ───────────────────────────────────────────────────────
|
// ── Markdown tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -193,6 +193,15 @@ public class ApplicationContext : DbContext
|
|||||||
(6, "kontakt", "Kontakt"),
|
(6, "kontakt", "Kontakt"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// WEB-6a: footer-only legal pages (not in main nav)
|
||||||
|
(int n, string Slug, string Title, string BodyMd)[] legalPages =
|
||||||
|
{
|
||||||
|
(7, "impressum", "Impressum",
|
||||||
|
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||||
|
(8, "datenschutz", "Datenschutz",
|
||||||
|
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||||
|
};
|
||||||
|
|
||||||
var pageRows = new List<Page>();
|
var pageRows = new List<Page>();
|
||||||
var blockRows = new List<Block>();
|
var blockRows = new List<Block>();
|
||||||
foreach (var p in pages)
|
foreach (var p in pages)
|
||||||
@@ -211,6 +220,12 @@ public class ApplicationContext : DbContext
|
|||||||
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
||||||
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
||||||
});
|
});
|
||||||
|
foreach (var lp in legalPages)
|
||||||
|
{
|
||||||
|
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
|
||||||
|
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
|
||||||
|
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
|
||||||
|
}
|
||||||
|
|
||||||
modelBuilder.Entity<Page>().HasData(pageRows);
|
modelBuilder.Entity<Page>().HasData(pageRows);
|
||||||
modelBuilder.Entity<Block>().HasData(blockRows);
|
modelBuilder.Entity<Block>().HasData(blockRows);
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
private static string RenderAbgabetiereList(JsonObject d)
|
private static string RenderAbgabetiereList(JsonObject d)
|
||||||
{
|
{
|
||||||
var intro = Str(d["intro"]);
|
var intro = Str(d["intro"]);
|
||||||
var animals = d["animals"] as JsonArray ?? [];
|
var animals = (d["animals"] as JsonArray ?? [])
|
||||||
|
.OfType<JsonObject>().ToList();
|
||||||
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(intro))
|
if (!string.IsNullOrWhiteSpace(intro))
|
||||||
@@ -140,39 +141,80 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
if (animals.Count == 0)
|
if (animals.Count == 0)
|
||||||
{
|
{
|
||||||
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
||||||
|
sb.Append("</section>\n");
|
||||||
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
// Group by Becken name; null group → solo card keyed by animal name
|
||||||
|
var groups = animals
|
||||||
|
.GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? "")))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
sb.Append(" <div class=\"cms-group-list\">\n");
|
||||||
|
foreach (var grp in groups)
|
||||||
{
|
{
|
||||||
sb.Append(" <div class=\"cms-animal-grid\">\n");
|
var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
|
||||||
foreach (var node in animals)
|
var groupName = isSolo ? null : grp.Key;
|
||||||
{
|
var memberList = grp.ToList();
|
||||||
if (node is not JsonObject a) continue;
|
|
||||||
var name = H(Str(a["name"]) ?? "");
|
|
||||||
var farbe = H(Str(a["farbschlag"]) ?? "");
|
|
||||||
var group = Str(a["group"]);
|
|
||||||
var saleText = Str(a["aiSaleText"]);
|
|
||||||
var photos = a["photos"] as JsonArray ?? [];
|
|
||||||
|
|
||||||
sb.Append(" <article class=\"cms-animal-card\">\n");
|
// Determine group gender label
|
||||||
|
var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList();
|
||||||
|
string genderLabel = genders.Count == 1
|
||||||
|
? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" }
|
||||||
|
: genders.Any(g => g != "unknown") ? "Gemischt" : "";
|
||||||
|
|
||||||
// Profile photo (first photo)
|
sb.Append(" <article class=\"cms-group-card\">\n");
|
||||||
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
|
sb.Append(" <div class=\"cms-group-header\">\n");
|
||||||
if (!string.IsNullOrEmpty(firstPhoto))
|
if (!string.IsNullOrEmpty(groupName))
|
||||||
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
|
sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
|
||||||
|
var metaParts = new List<string>();
|
||||||
sb.Append(" <div class=\"cms-animal-info\">\n");
|
if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
|
||||||
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
|
metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
|
||||||
if (!string.IsNullOrEmpty(farbe))
|
sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
|
||||||
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
|
|
||||||
if (!string.IsNullOrWhiteSpace(group))
|
|
||||||
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
|
|
||||||
if (!string.IsNullOrWhiteSpace(saleText))
|
|
||||||
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
|
|
||||||
sb.Append(" </div>\n");
|
sb.Append(" </div>\n");
|
||||||
|
|
||||||
|
// Gallery: first photo of each animal in the group
|
||||||
|
var gallery = memberList
|
||||||
|
.Select(a => (
|
||||||
|
url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue<string>(),
|
||||||
|
name: Str(a["name"]) ?? ""))
|
||||||
|
.Where(x => !string.IsNullOrEmpty(x.url))
|
||||||
|
.ToList();
|
||||||
|
if (gallery.Count > 0)
|
||||||
|
{
|
||||||
|
sb.Append(" <div class=\"cms-group-gallery\">\n");
|
||||||
|
foreach (var (url, name) in gallery)
|
||||||
|
sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
|
||||||
|
sb.Append(" </div>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-animal detail rows
|
||||||
|
sb.Append(" <ul class=\"cms-animal-list\">\n");
|
||||||
|
foreach (var a in memberList)
|
||||||
|
{
|
||||||
|
var name = H(Str(a["name"]) ?? "");
|
||||||
|
var farbe = Str(a["farbschlag"]);
|
||||||
|
var dob = Str(a["dateOfBirth"]);
|
||||||
|
var note = Str(a["characterNote"]);
|
||||||
|
var saleText = Str(a["aiSaleText"]);
|
||||||
|
|
||||||
|
sb.Append(" <li class=\"cms-animal-row\">\n");
|
||||||
|
sb.Append($" <span class=\"cms-animal-name\">{name}</span>\n");
|
||||||
|
var details = new List<string>();
|
||||||
|
if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe));
|
||||||
|
if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2))
|
||||||
|
details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}");
|
||||||
|
if (details.Count > 0)
|
||||||
|
sb.Append($" <span class=\"cms-animal-detail\">{string.Join(" · ", details)}</span>\n");
|
||||||
|
var personalityText = note ?? saleText;
|
||||||
|
if (!string.IsNullOrWhiteSpace(personalityText))
|
||||||
|
sb.Append($" <span class=\"cms-animal-note\">{H(personalityText)}</span>\n");
|
||||||
|
sb.Append(" </li>\n");
|
||||||
|
}
|
||||||
|
sb.Append(" </ul>\n");
|
||||||
sb.Append(" </article>\n");
|
sb.Append(" </article>\n");
|
||||||
}
|
}
|
||||||
sb.Append(" </div>\n");
|
sb.Append(" </div>\n");
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append("</section>\n");
|
sb.Append("</section>\n");
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
@@ -212,6 +254,10 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
</main>
|
</main>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
||||||
|
<nav class="site-footer-nav" aria-label="Rechtliches">
|
||||||
|
<a href="{navBase}impressum/index.html" class="site-footer-link">Impressum</a>
|
||||||
|
<a href="{navBase}datenschutz/index.html" class="site-footer-link">Datenschutz</a>
|
||||||
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -228,6 +274,8 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
["abgabebedingungen"] = "Abgabebedingungen",
|
["abgabebedingungen"] = "Abgabebedingungen",
|
||||||
["farben-genetik"] = "Farben & Genetik",
|
["farben-genetik"] = "Farben & Genetik",
|
||||||
["kontakt"] = "Kontakt",
|
["kontakt"] = "Kontakt",
|
||||||
|
["impressum"] = "Impressum",
|
||||||
|
["datenschutz"] = "Datenschutz",
|
||||||
};
|
};
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -336,121 +384,256 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
internal static string SiteCss() => """
|
internal static string SiteCss() => """
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
--color-bg: #fdf8f3;
|
--color-bg: #fdf7f0;
|
||||||
--color-surface: #fff;
|
--color-surface: #fff;
|
||||||
--color-text: #2c2c2c;
|
--color-surface-warm: #fef9f4;
|
||||||
--color-muted: #6b6b6b;
|
--color-text: #2a2118;
|
||||||
--color-accent: #c0392b;
|
--color-muted: #7a6d62;
|
||||||
--color-border: #e0d8d0;
|
--color-accent: #b5331a;
|
||||||
--font-body: system-ui, sans-serif;
|
--color-accent-hover: #8f2815;
|
||||||
--max-w: 860px;
|
--color-accent-soft: #fdf0ed;
|
||||||
|
--color-border: #e8ddd4;
|
||||||
|
--color-badge-free: #2d7a4a;
|
||||||
|
--color-badge-free-bg: #e6f4ec;
|
||||||
|
--font-body: Georgia, "Times New Roman", serif;
|
||||||
|
--font-ui: system-ui, -apple-system, sans-serif;
|
||||||
|
--max-w: 900px;
|
||||||
|
--radius: 10px;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
line-height: 1.65;
|
line-height: 1.7;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
a { color: var(--color-accent); text-decoration: none; }
|
a { color: var(--color-accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; color: var(--color-accent-hover); }
|
||||||
img { max-width: 100%; height: auto; display: block; }
|
img { max-width: 100%; height: auto; display: block; }
|
||||||
|
p { margin-bottom: .75rem; }
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
.site-header {
|
.site-header {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 2px solid var(--color-border);
|
||||||
padding: .75rem 1rem;
|
padding: 0 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: stretch;
|
||||||
align-items: center;
|
gap: 0;
|
||||||
gap: .5rem 1.5rem;
|
min-height: 56px;
|
||||||
}
|
}
|
||||||
.site-logo {
|
.site-logo {
|
||||||
|
font-family: var(--font-body);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.1rem;
|
font-size: 1.15rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
display: flex;
|
||||||
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; }
|
align-items: center;
|
||||||
.site-nav-link {
|
padding-right: 1.5rem;
|
||||||
font-size: .9rem;
|
border-right: 1px solid var(--color-border);
|
||||||
color: var(--color-muted);
|
|
||||||
padding: .2rem .4rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
|
|
||||||
color: var(--color-accent);
|
|
||||||
background: #fef0ee;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.site-logo:hover { color: var(--color-accent); text-decoration: none; }
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding-left: .5rem;
|
||||||
|
}
|
||||||
|
.site-nav::-webkit-scrollbar { display: none; }
|
||||||
|
.site-nav-link {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
padding: .5rem .65rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; }
|
||||||
|
.site-nav-link[aria-current="page"] {
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main ── */
|
/* ── Main ── */
|
||||||
.site-main {
|
.site-main {
|
||||||
max-width: var(--max-w);
|
max-width: var(--max-w);
|
||||||
margin: 2rem auto;
|
margin: 2.5rem auto;
|
||||||
padding: 0 1rem 3rem;
|
padding: 0 1rem 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Footer ── */
|
/* ── Footer ── */
|
||||||
.site-footer {
|
.site-footer {
|
||||||
border-top: 1px solid var(--color-border);
|
background: var(--color-surface);
|
||||||
padding: 1.5rem 1rem;
|
border-top: 2px solid var(--color-border);
|
||||||
|
padding: 1.75rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
font-size: .85rem;
|
font-size: .85rem;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
.site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; }
|
||||||
|
.site-footer-link { color: var(--color-muted); font-size: .8rem; }
|
||||||
|
.site-footer-link:hover { color: var(--color-accent); }
|
||||||
|
|
||||||
/* ── CMS blocks ── */
|
/* ── CMS blocks ── */
|
||||||
.cms-heading { margin: 1.5rem 0 .5rem; }
|
.cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
|
||||||
h1.cms-heading { font-size: 1.8rem; }
|
h1.cms-heading { font-size: 2rem; margin-top: 0; }
|
||||||
h2.cms-heading { font-size: 1.4rem; }
|
h2.cms-heading { font-size: 1.5rem; }
|
||||||
|
h3.cms-heading { font-size: 1.2rem; }
|
||||||
.cms-richtext { margin: 1rem 0; }
|
.cms-richtext { margin: 1rem 0; }
|
||||||
.cms-richtext p { margin-bottom: .75rem; }
|
.cms-richtext p { margin-bottom: .75rem; }
|
||||||
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
||||||
.cms-richtext li { margin-bottom: .3rem; }
|
.cms-richtext li { margin-bottom: .35rem; }
|
||||||
.cms-image { margin: 1.5rem 0; }
|
.cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
|
||||||
.cms-image img { border-radius: 6px; }
|
.cms-image { margin: 1.75rem 0; }
|
||||||
|
.cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
||||||
.cms-gallery {
|
.cms-gallery {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
margin: 1.5rem 0;
|
margin: 1.75rem 0;
|
||||||
}
|
}
|
||||||
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; }
|
.cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
|
||||||
.cms-contact {
|
.cms-contact {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
font-family: var(--font-ui);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .4rem;
|
gap: .5rem;
|
||||||
margin: 1rem 0;
|
margin: 1.25rem 0;
|
||||||
|
background: var(--color-surface-warm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; }
|
.cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
|
||||||
|
|
||||||
/* ── Abgabetiere ── */
|
/* ── Abgabetiere — group cards ── */
|
||||||
.cms-abgabe { margin: 1rem 0; }
|
.cms-abgabe { margin: 1.25rem 0; }
|
||||||
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; }
|
.cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
|
||||||
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
||||||
.cms-animal-grid {
|
|
||||||
display: grid;
|
.cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
gap: 1.25rem;
|
.cms-group-card {
|
||||||
}
|
|
||||||
.cms-animal-card {
|
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
.cms-group-header {
|
||||||
|
padding: 1rem 1.25rem .75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem .75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-warm);
|
||||||
|
}
|
||||||
|
.cms-group-name {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.cms-group-meta {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
}
|
}
|
||||||
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
|
|
||||||
.cms-animal-info { padding: .875rem; }
|
|
||||||
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
|
|
||||||
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
|
|
||||||
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
|
|
||||||
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
/* availability badge */
|
||||||
h1.cms-heading { font-size: 1.4rem; }
|
.cms-badge {
|
||||||
.cms-animal-grid { grid-template-columns: 1fr; }
|
display: inline-block;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
padding: .2rem .55rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cms-badge--free {
|
||||||
|
background: var(--color-badge-free-bg);
|
||||||
|
color: var(--color-badge-free);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* group photo strip */
|
||||||
|
.cms-group-gallery {
|
||||||
|
display: flex;
|
||||||
|
gap: .375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: .75rem 1.25rem;
|
||||||
|
background: #f5ede4;
|
||||||
|
}
|
||||||
|
.cms-group-gallery::-webkit-scrollbar { display: none; }
|
||||||
|
.cms-group-photo {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* animal detail list */
|
||||||
|
.cms-animal-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: .75rem 1.25rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .875rem;
|
||||||
|
}
|
||||||
|
.cms-animal-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .15rem;
|
||||||
|
padding-bottom: .875rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||||
|
.cms-animal-name {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.cms-animal-detail {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.cms-animal-note {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: .925rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-top: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.site-header { padding: 0 .75rem; min-height: 50px; }
|
||||||
|
.site-logo { font-size: 1rem; padding-right: 1rem; }
|
||||||
|
h1.cms-heading { font-size: 1.5rem; }
|
||||||
|
.site-main { margin-top: 1.5rem; }
|
||||||
|
.cms-group-photo { width: 110px; height: 110px; }
|
||||||
|
}
|
||||||
|
@media (min-width: 700px) {
|
||||||
|
.cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
||||||
|
.cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; }
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
{
|
{
|
||||||
g.Id,
|
g.Id,
|
||||||
g.Name,
|
g.Name,
|
||||||
|
g.Gender,
|
||||||
|
g.DateOfBirth,
|
||||||
|
g.CharacterNote,
|
||||||
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
||||||
// group = Becken (enclosure) name — matches how the Abgabe composer
|
// group = Becken (enclosure) name — matches how the Abgabe composer
|
||||||
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
||||||
@@ -109,6 +112,9 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
arr.Add(new JsonObject
|
arr.Add(new JsonObject
|
||||||
{
|
{
|
||||||
["name"] = a.Name,
|
["name"] = a.Name,
|
||||||
|
["gender"] = a.Gender.ToString(),
|
||||||
|
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
|
||||||
|
["characterNote"] = a.CharacterNote,
|
||||||
["farbschlag"] = a.Farbschlag,
|
["farbschlag"] = a.Farbschlag,
|
||||||
["group"] = a.Group,
|
["group"] = a.Group,
|
||||||
["photos"] = photoArr,
|
["photos"] = photoArr,
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
||||||
|
|
||||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0);
|
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||||
|
int ParentFksBackfilled = 0);
|
||||||
|
|
||||||
public sealed record AnimalSummary(
|
public sealed record AnimalSummary(
|
||||||
int InSource,
|
int InSource,
|
||||||
|
|||||||
@@ -399,10 +399,67 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PARENT-FK BACKFILL (idempotent re-run): already-imported Wurfchronik litters that
|
||||||
|
// have null Father/MotherId because the parent was previously quarantined may now be
|
||||||
|
// resolvable. Two lookup sources — must check BOTH:
|
||||||
|
// (a) createdAnimalByName: animals loaded/re-linked in THIS run (new or existing).
|
||||||
|
// (b) allDbNormToGid: ALL gerbils already in the DB, for parents loaded in an
|
||||||
|
// EARLIER run who are no longer in the current extract (e.g. alreadyImported
|
||||||
|
// animals absent from this run's animals.json, or name normalization mismatch
|
||||||
|
// between animals.json and the Wurfchronik sire/dam field).
|
||||||
|
// Counted for dry-run too; writes only when execute=true.
|
||||||
|
int parentFksBackfilled = 0;
|
||||||
|
{
|
||||||
|
// Build DB-wide normalized-name lookup (supplementary to createdAnimalByName).
|
||||||
|
var allDbNormToGid = existingRows
|
||||||
|
.GroupBy(g => Normalize(StripZucht(g.Name)))
|
||||||
|
.ToDictionary(grp => grp.Key, grp => grp.First().Id);
|
||||||
|
|
||||||
|
var existingWithNullParent = await _db.Litters
|
||||||
|
.Where(l => l.FatherId == null || l.MotherId == null)
|
||||||
|
.Select(l => new { l.Id, l.Name, l.FatherId, l.MotherId })
|
||||||
|
.ToListAsync();
|
||||||
|
var sourceByName = litters
|
||||||
|
.GroupBy(sl => $"Wurf {sl.LitterId}".Trim())
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
Guid? ResolveParentForBackfill(string rawName)
|
||||||
|
{
|
||||||
|
var n = Normalize(StripZucht(rawName));
|
||||||
|
if (n.Length == 0) return null;
|
||||||
|
if (createdAnimalByName.TryGetValue(n, out var fromLoadable) && persisted.Contains(fromLoadable))
|
||||||
|
return fromLoadable;
|
||||||
|
if (allDbNormToGid.TryGetValue(n, out var fromDb) && persisted.Contains(fromDb))
|
||||||
|
return fromDb;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var el in existingWithNullParent)
|
||||||
|
{
|
||||||
|
if (!sourceByName.TryGetValue(el.Name, out var sl)) continue;
|
||||||
|
var newF = el.FatherId == null ? ResolveParentForBackfill(sl.SireName) : null;
|
||||||
|
var newM = el.MotherId == null ? ResolveParentForBackfill(sl.DamName) : null;
|
||||||
|
if (newF is null && newM is null) continue;
|
||||||
|
parentFksBackfilled++;
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Litters.FirstOrDefaultAsync(l => l.Id == el.Id);
|
||||||
|
if (row is not null)
|
||||||
|
{
|
||||||
|
if (newF is not null) row.FatherId = newF;
|
||||||
|
if (newM is not null) row.MotherId = newM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||||
if (parentLinksAdded > 0)
|
if (parentLinksAdded > 0)
|
||||||
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
||||||
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
||||||
|
if (parentFksBackfilled > 0)
|
||||||
|
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||||
if (conflictsResolvedByDecision > 0)
|
if (conflictsResolvedByDecision > 0)
|
||||||
@@ -411,7 +468,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
return new ImportReport(
|
return new ImportReport(
|
||||||
Executed: execute,
|
Executed: execute,
|
||||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped),
|
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled),
|
||||||
Animals: new AnimalSummary(
|
Animals: new AnimalSummary(
|
||||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||||
|
|||||||
1348
GerbilManagerWebAPI/Migrations/20260606131032_AddImpressumDatenschutzPages.Designer.cs
generated
Normal file
1348
GerbilManagerWebAPI/Migrations/20260606131032_AddImpressumDatenschutzPages.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddImpressumDatenschutzPages : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "Pages",
|
||||||
|
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("51720001-0000-0000-0000-000000000007"), null, "impressum", "Published", "Impressum" },
|
||||||
|
{ new Guid("51720001-0000-0000-0000-000000000008"), null, "datenschutz", "Published", "Datenschutz" }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "Blocks",
|
||||||
|
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("51720002-0000-0000-0000-000000000007"), "{\"text\":\"Impressum\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000007"), "Heading" },
|
||||||
|
{ new Guid("51720002-0000-0000-0000-000000000008"), "{\"text\":\"Datenschutz\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000008"), "Heading" },
|
||||||
|
{ new Guid("51720002-0000-0000-0000-000000000070"), "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000007"), "RichText" },
|
||||||
|
{ new Guid("51720002-0000-0000-0000-000000000080"), "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000008"), "RichText" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000007"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000008"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000070"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000080"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Pages",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720001-0000-0000-0000-000000000007"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Pages",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720001-0000-0000-0000-000000000008"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,38 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Order = 1,
|
Order = 1,
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000003"),
|
PageId = new Guid("51720001-0000-0000-0000-000000000003"),
|
||||||
Type = "AbgabetiereList"
|
Type = "AbgabetiereList"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720002-0000-0000-0000-000000000007"),
|
||||||
|
Data = "{\"text\":\"Impressum\",\"level\":1}",
|
||||||
|
Order = 0,
|
||||||
|
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
|
||||||
|
Type = "Heading"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720002-0000-0000-0000-000000000070"),
|
||||||
|
Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
||||||
|
Order = 1,
|
||||||
|
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
|
||||||
|
Type = "RichText"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720002-0000-0000-0000-000000000008"),
|
||||||
|
Data = "{\"text\":\"Datenschutz\",\"level\":1}",
|
||||||
|
Order = 0,
|
||||||
|
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
|
||||||
|
Type = "Heading"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720002-0000-0000-0000-000000000080"),
|
||||||
|
Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
||||||
|
Order = 1,
|
||||||
|
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
|
||||||
|
Type = "RichText"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -989,6 +1021,20 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Slug = "kontakt",
|
Slug = "kontakt",
|
||||||
Status = "Published",
|
Status = "Published",
|
||||||
Title = "Kontakt"
|
Title = "Kontakt"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720001-0000-0000-0000-000000000007"),
|
||||||
|
Slug = "impressum",
|
||||||
|
Status = "Published",
|
||||||
|
Title = "Impressum"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720001-0000-0000-0000-000000000008"),
|
||||||
|
Slug = "datenschutz",
|
||||||
|
Status = "Published",
|
||||||
|
Title = "Datenschutz"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
|
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
|
||||||
"dob": "16.01.2023",
|
"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. Name kept in the merged record's v.d. spelling: extract.py decision matching uses norm_name (no v.d.<->von den fold) — workaround until the canon_pair matching fix lands.",
|
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
||||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ def parse_detail(text):
|
|||||||
tail = text[dob.end():]
|
tail = text[dob.end():]
|
||||||
tail = re.sub(r"^\s*/?\+?\s?\d[\d.]*", "", tail) # drop any /+death remnant
|
tail = re.sub(r"^\s*/?\+?\s?\d[\d.]*", "", tail) # drop any /+death remnant
|
||||||
tail = tail.lstrip(" ,").strip()
|
tail = tail.lstrip(" ,").strip()
|
||||||
|
# FIX-4 (Skarlett): strip trailing "/ +YEAR" death-year artifacts leaked from compact
|
||||||
|
# chart cells (e.g. "… rere / +2018"). The DEATH regex still captures the year from
|
||||||
|
# the full cell text, so it appears as a death-date conflict — not a genotype conflict.
|
||||||
|
tail = re.sub(r"\s*/\s*\+\d{4}\s*$", "", tail).strip()
|
||||||
if gt.looks_like_genotype(tail):
|
if gt.looks_like_genotype(tail):
|
||||||
geno = tail
|
geno = tail
|
||||||
return (dob.group(1) if dob else "",
|
return (dob.group(1) if dob else "",
|
||||||
@@ -518,10 +522,11 @@ def _alleles_compatible(a, b):
|
|||||||
if a == b:
|
if a == b:
|
||||||
return True
|
return True
|
||||||
if a == "?" or b == "?":
|
if a == "?" or b == "?":
|
||||||
return False # unknown vs filled = contradiction (D- vs DD)
|
return True # specific-wins: unknown allele is compatible with any
|
||||||
|
# specified value (C- vs CC -> CC; G- vs Gg -> Gg)
|
||||||
(ba, ma), (bb, mb) = _split_allele(a), _split_allele(b)
|
(ba, ma), (bb, mb) = _split_allele(a), _split_allele(b)
|
||||||
if ba != bb:
|
if ba != bb:
|
||||||
return False # different base allele = real value diff (E vs e)
|
return False # different base allele = real value diff (E vs e, D vs d)
|
||||||
return ma == "" or mb == "" # same base, modifier present-vs-absent -> presence wins
|
return ma == "" or mb == "" # same base, modifier present-vs-absent -> presence wins
|
||||||
|
|
||||||
|
|
||||||
@@ -622,9 +627,12 @@ def dedup(animals):
|
|||||||
if a.get("deaf") is not None:
|
if a.get("deaf") is not None:
|
||||||
deaf_seen.add(a["deaf"])
|
deaf_seen.add(a["deaf"])
|
||||||
tags_set.update(a.get("tags", []))
|
tags_set.update(a.get("tags", []))
|
||||||
# pick the richest genotype (most mapped loci, then longest raw)
|
# pick the richest genotype: most mapped loci, then fewest unknowns ('?' alleles = specific
|
||||||
|
# wins, FIX-2), then longest raw string as final tiebreaker.
|
||||||
|
def _specificity(gd):
|
||||||
|
return sum(1 for pair in gd["mapped8locus"].values() for a in pair if a != "?")
|
||||||
best = max((a["genotype"] for a in grp),
|
best = max((a["genotype"] for a in grp),
|
||||||
key=lambda gd: (len(gd["mapped8locus"]), len(gd["rawGenotype"])))
|
key=lambda gd: (len(gd["mapped8locus"]), _specificity(gd), len(gd["rawGenotype"])))
|
||||||
out = {
|
out = {
|
||||||
"id": slug(base["name"], base["dob"]),
|
"id": slug(base["name"], base["dob"]),
|
||||||
"name": base["name"],
|
"name": base["name"],
|
||||||
@@ -887,21 +895,30 @@ def apply_dob_remaps(raw_animals, path):
|
|||||||
"""PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a
|
"""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
|
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 =
|
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.
|
canon_pair(name)+(dob) with same Zucht-aware logic as apply_conflict_decisions (see there).
|
||||||
|
Tolerates a missing/garbled file. Returns the remap count.
|
||||||
Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)"""
|
Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)"""
|
||||||
remaps = {}
|
remaps_full = {} # (nameCanon, zuchtCanon, dob) -> correctDob — decision carries Zucht
|
||||||
|
remaps_name = {} # (nameCanon, dob) -> correctDob — no Zucht in decision
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as fh:
|
with open(path, encoding="utf-8") as fh:
|
||||||
for r in (json.load(fh).get("resolutions") or []):
|
for r in (json.load(fh).get("resolutions") or []):
|
||||||
if r.get("correctDob"):
|
if r.get("correctDob"):
|
||||||
remaps[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r["correctDob"]
|
nc, zc = canon_pair(r.get("name", ""))
|
||||||
|
dob = norm_dob(r.get("dob", ""))
|
||||||
|
if zc:
|
||||||
|
remaps_full[(nc, zc, dob)] = r["correctDob"]
|
||||||
|
else:
|
||||||
|
remaps_name[(nc, dob)] = r["correctDob"]
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
if not remaps:
|
if not remaps_full and not remaps_name:
|
||||||
return 0
|
return 0
|
||||||
n = 0
|
n = 0
|
||||||
for a in raw_animals:
|
for a in raw_animals:
|
||||||
new = remaps.get((norm_name(a.get("name", "")), norm_dob(a.get("dob", ""))))
|
nc, zc = canon_pair(a.get("name", ""))
|
||||||
|
dob = norm_dob(a.get("dob", ""))
|
||||||
|
new = remaps_full.get((nc, zc, dob)) or remaps_name.get((nc, dob))
|
||||||
if new and a.get("dob") != new:
|
if new and a.get("dob") != new:
|
||||||
a["dob"] = new
|
a["dob"] = new
|
||||||
n += 1
|
n += 1
|
||||||
@@ -911,23 +928,36 @@ def apply_dob_remaps(raw_animals, path):
|
|||||||
def apply_conflict_decisions(merged, conflicts, path):
|
def apply_conflict_decisions(merged, conflicts, path):
|
||||||
"""Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's
|
"""Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's
|
||||||
answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?,
|
answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?,
|
||||||
farbschlag?, source}]}. Match = norm_name(name)+norm_dob(dob) (same identity as dedup). A
|
farbschlag?, source}]}. Match = canon_pair(name)+(dob):
|
||||||
matching animal: clear its conflict, mark resolvedByDecision; an explicit `genotype`
|
- When the decision name CARRIES a Zucht (zuchtCanon != ''), match on the FULL
|
||||||
(breeder notation) is parsed and becomes authoritative, `farbschlag` overrides too. Tolerates
|
(nameCanon, zuchtCanon, dob) triple — preserves the C3 rule that same name+DOB but
|
||||||
a missing/empty/garbled file. Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
|
different Zucht = different animal.
|
||||||
decisions = {}
|
- When the decision has NO Zucht, fall back to (nameCanon, dob) name-only match.
|
||||||
|
Both spellings v.d. / von den fold to the same canon. A matching animal: clear its conflict,
|
||||||
|
mark resolvedByDecision; an explicit `genotype` (breeder notation) is parsed and becomes
|
||||||
|
authoritative, `farbschlag` overrides too. Tolerates a missing/empty/garbled file.
|
||||||
|
Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
|
||||||
|
decisions_full = {} # (nameCanon, zuchtCanon, dob) -> r — when decision carries a Zucht
|
||||||
|
decisions_name = {} # (nameCanon, dob) -> r — fallback, decision has no Zucht
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as fh:
|
with open(path, encoding="utf-8") as fh:
|
||||||
for r in (json.load(fh).get("resolutions") or []):
|
for r in (json.load(fh).get("resolutions") or []):
|
||||||
decisions[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r
|
nc, zc = canon_pair(r.get("name", ""))
|
||||||
|
dob = norm_dob(r.get("dob", ""))
|
||||||
|
if zc:
|
||||||
|
decisions_full[(nc, zc, dob)] = r
|
||||||
|
else:
|
||||||
|
decisions_name[(nc, dob)] = r
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
if not decisions:
|
if not decisions_full and not decisions_name:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
resolved = 0
|
resolved = 0
|
||||||
for a in merged:
|
for a in merged:
|
||||||
d = decisions.get((norm_name(a["name"]), norm_dob(a["dob"])))
|
nc, zc = canon_pair(a["name"])
|
||||||
|
dob = norm_dob(a["dob"])
|
||||||
|
d = decisions_full.get((nc, zc, dob)) or decisions_name.get((nc, dob))
|
||||||
if not d:
|
if not d:
|
||||||
continue
|
continue
|
||||||
a["resolvedByDecision"] = True
|
a["resolvedByDecision"] = True
|
||||||
|
|||||||
@@ -102,6 +102,63 @@ check("apply_conflict_decisions returns resolved count", n == 2)
|
|||||||
check("missing decisions file tolerated (returns 0)",
|
check("missing decisions file tolerated (returns 0)",
|
||||||
e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0)
|
e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0)
|
||||||
|
|
||||||
|
# FIX-1: decision matching uses canon_pair identity -> 'von den' decision matches 'v.d.' record
|
||||||
|
dec_vd = os.path.join(tempfile.gettempdir(), "decisions-vd.json")
|
||||||
|
_json.dump({"resolutions": [
|
||||||
|
{"name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # written with 'von den'
|
||||||
|
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
|
||||||
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
|
||||||
|
]}, open(dec_vd, "w", encoding="utf-8"))
|
||||||
|
merged_vd = [
|
||||||
|
{"id": "vw", "name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # record has 'v.d.'
|
||||||
|
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
|
||||||
|
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
||||||
|
]
|
||||||
|
conflicts_vd = [{"id": "vw"}]
|
||||||
|
n_vd = e.apply_conflict_decisions(merged_vd, conflicts_vd, dec_vd)
|
||||||
|
check("FIX-1: 'von den' decision matches 'v.d.' record (canon_pair identity)", n_vd == 1)
|
||||||
|
check("FIX-1: conflict cleared for v.d. record", merged_vd[0]["conflict"] is False)
|
||||||
|
# Also verify the workaround spelling (v.d. in decision) matches a 'von den' record
|
||||||
|
_json.dump({"resolutions": [
|
||||||
|
{"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # workaround: v.d. in decision
|
||||||
|
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
|
||||||
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
|
||||||
|
]}, open(dec_vd, "w", encoding="utf-8"))
|
||||||
|
merged_vd2 = [
|
||||||
|
{"id": "vw2", "name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # record 'von den'
|
||||||
|
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
|
||||||
|
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
||||||
|
]
|
||||||
|
conflicts_vd2 = [{"id": "vw2"}]
|
||||||
|
n_vd2 = e.apply_conflict_decisions(merged_vd2, conflicts_vd2, dec_vd)
|
||||||
|
check("FIX-1: v.d. decision also matches 'von den' record (both spellings match)", n_vd2 == 1)
|
||||||
|
try: os.remove(dec_vd)
|
||||||
|
except OSError: pass
|
||||||
|
|
||||||
|
# FIX-1 C3-rule: same name+DOB, two Zuchten -> decision hits ONLY the correct Zucht (C3 isolation)
|
||||||
|
dec_c3 = os.path.join(tempfile.gettempdir(), "decisions-c3.json")
|
||||||
|
_json.dump({"resolutions": [
|
||||||
|
# Decision only for Luna from ZdkC, NOT Luna from Black Forest
|
||||||
|
{"name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
|
||||||
|
"decision": "D-locus = DD", "genotype": "aa CC DD ee gg PP spsp rere", "source": "test"},
|
||||||
|
]}, open(dec_c3, "w", encoding="utf-8"))
|
||||||
|
merged_c3 = [
|
||||||
|
{"id": "luna-kc", "name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
|
||||||
|
"conflict": True, "farbschlag": "", "death": "",
|
||||||
|
"genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
|
||||||
|
{"id": "luna-bf", "name": "Luna of Black Forest", "dob": "01.01.2020",
|
||||||
|
"conflict": True, "farbschlag": "", "death": "",
|
||||||
|
"genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
|
||||||
|
]
|
||||||
|
conflicts_c3 = [{"id": "luna-kc"}, {"id": "luna-bf"}]
|
||||||
|
n_c3 = e.apply_conflict_decisions(merged_c3, conflicts_c3, dec_c3)
|
||||||
|
check("FIX-1 C3: decision hits only the correct Zucht (luna-kc resolved)", n_c3 == 1)
|
||||||
|
check("FIX-1 C3: luna-kc conflict cleared (correct Zucht)", merged_c3[0]["conflict"] is False)
|
||||||
|
check("FIX-1 C3: luna-bf conflict NOT cleared (different Zucht)", merged_c3[1]["conflict"] is True)
|
||||||
|
check("FIX-1 C3: conflicts list has only luna-bf left", len(conflicts_c3) == 1 and conflicts_c3[0]["id"] == "luna-bf")
|
||||||
|
try: os.remove(dec_c3)
|
||||||
|
except OSError: pass
|
||||||
|
|
||||||
# --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges ---
|
# --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges ---
|
||||||
dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json")
|
dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json")
|
||||||
_json.dump({"resolutions": [
|
_json.dump({"resolutions": [
|
||||||
@@ -128,23 +185,95 @@ except OSError: pass
|
|||||||
try: os.remove(dec_path)
|
try: os.remove(dec_path)
|
||||||
except OSError: pass
|
except OSError: pass
|
||||||
|
|
||||||
# --- "presence wins" conflict rule (Julian) ---
|
# --- "presence wins" + "specific wins" conflict rules (Julian) ---
|
||||||
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing filled values are.
|
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing FILLED values are.
|
||||||
|
# FIX-2 (specific-wins): unknown allele '?' vs any specified value is also NOT a conflict —
|
||||||
|
# the specific value wins (C- vs CC -> CC; G- vs Gg -> Gg; P? vs PP -> PP).
|
||||||
check("spsp present vs locus absent -> no conflict",
|
check("spsp present vs locus absent -> no conflict",
|
||||||
not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}]))
|
not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}]))
|
||||||
check("ee[f] vs ee ([f] modifier present/absent) -> no conflict",
|
check("ee[f] vs ee ([f] modifier present/absent) -> no conflict",
|
||||||
not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}]))
|
not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}]))
|
||||||
check("DD vs D- (unknown vs filled) -> conflict",
|
# FIX-2: '?' vs specified = specific wins (was: contradiction)
|
||||||
e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}]))
|
check("FIX-2: DD vs D- (specific wins: DD wins) -> NOT conflict",
|
||||||
|
not e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}]))
|
||||||
|
check("FIX-2: C- vs Cc[h] (specific wins: c^h wins) -> NOT conflict",
|
||||||
|
not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}]))
|
||||||
|
check("FIX-2: C- vs CC (specific wins: CC) -> NOT conflict",
|
||||||
|
not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "C"]}]))
|
||||||
|
check("FIX-2: G- vs Gg (specific wins) -> NOT conflict",
|
||||||
|
not e._genotype_conflict([{"G": ["G", "?"]}, {"G": ["G", "g"]}]))
|
||||||
|
check("FIX-2: PP vs P? (specific wins: PP) -> NOT conflict",
|
||||||
|
not e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "?"]}]))
|
||||||
|
# Genuine value contradictions (both alleles specified but different) still quarantine
|
||||||
check("Ee vs ee (different base allele) -> conflict",
|
check("Ee vs ee (different base allele) -> conflict",
|
||||||
e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}]))
|
e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}]))
|
||||||
check("C- vs Cc[h] -> conflict",
|
check("DD vs Dd (both specified, D vs d) -> conflict",
|
||||||
e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}]))
|
e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "d"]}]))
|
||||||
check("c[h] vs c[chm] (different modifiers) -> conflict",
|
check("PP vs Pp (both specified) -> conflict",
|
||||||
|
e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "p"]}]))
|
||||||
|
check("c[h] vs c[chm] (different modifiers, both specified) -> conflict",
|
||||||
not e._alleles_compatible("c^h", "c^chm"))
|
not e._alleles_compatible("c^h", "c^chm"))
|
||||||
check("identical genotypes -> no conflict",
|
check("identical genotypes -> no conflict",
|
||||||
not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}]))
|
not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}]))
|
||||||
|
|
||||||
|
# FIX-2 MERGE: specific allele must survive the merge regardless of which variant comes first.
|
||||||
|
# dedup() picks the most specific genotype (fewest '?' alleles); C- vs CC -> CC must win.
|
||||||
|
def _minimal_animal(name, dob, mapped):
|
||||||
|
"""Build a minimal raw animal dict suitable for dedup()."""
|
||||||
|
from genotype import parse as gparse
|
||||||
|
raw = " ".join(f"{l}{''.join(a)}" for l, pa in mapped.items() for a in [pa])
|
||||||
|
return {
|
||||||
|
"name": name, "dob": dob, "death": "", "gender": None,
|
||||||
|
"farbschlag": "", "breeder": "", "zucht": "", "parentRefs": [],
|
||||||
|
"photos": [], "sourceFiles": ["test.xlsx"], "tags": [],
|
||||||
|
"deaf": None, "conflict": False,
|
||||||
|
"genotype": {"mapped8locus": mapped, "rawGenotype": raw, "unmappedTokens": []},
|
||||||
|
"_gen": 0, "_col": 5, "_row": 10, "_file": "test.xlsx",
|
||||||
|
"_zucht": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Order A: C- first, CC second
|
||||||
|
animals_merge_a = [
|
||||||
|
_minimal_animal("TestTier", "01.01.2020", {"C": ["C", "?"]}), # C-
|
||||||
|
_minimal_animal("TestTier", "01.01.2020", {"C": ["C", "C"]}), # CC
|
||||||
|
]
|
||||||
|
merged_ma, _, _, _ = e.dedup(animals_merge_a)
|
||||||
|
check("FIX-2 merge A (C- first): result has CC not C-",
|
||||||
|
merged_ma[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"])
|
||||||
|
|
||||||
|
# Order B: CC first, C- second (must give same result)
|
||||||
|
animals_merge_b = [
|
||||||
|
_minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "C"]}), # CC
|
||||||
|
_minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "?"]}), # C-
|
||||||
|
]
|
||||||
|
merged_mb, _, _, _ = e.dedup(animals_merge_b)
|
||||||
|
check("FIX-2 merge B (CC first): result has CC not C-",
|
||||||
|
merged_mb[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"])
|
||||||
|
|
||||||
|
# G- vs Gg: Gg must win
|
||||||
|
animals_merge_g = [
|
||||||
|
_minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "?"]}), # G-
|
||||||
|
_minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "g"]}), # Gg
|
||||||
|
]
|
||||||
|
merged_mg, _, _, _ = e.dedup(animals_merge_g)
|
||||||
|
check("FIX-2 merge G (G- vs Gg): Gg wins",
|
||||||
|
merged_mg[0]["genotype"]["mapped8locus"].get("G") == ["G", "g"])
|
||||||
|
|
||||||
|
# --- FIX-4: Skarlett parse artifact — trailing "/ +YEAR" stripped from geno, death captured ---
|
||||||
|
dob4, death4, geno4 = e.parse_detail("Skarlett,*17.04.2016, aa C- DD ee Gg PP spsp rere / +2018")
|
||||||
|
check("FIX-4: '/ +YEAR' artifact stripped from geno tail",
|
||||||
|
geno4 == "aa C- DD ee Gg PP spsp rere")
|
||||||
|
check("FIX-4: death year still captured from full cell text",
|
||||||
|
death4 == "2018")
|
||||||
|
check("FIX-4: DOB still correct",
|
||||||
|
dob4 == "17.04.2016")
|
||||||
|
# Without artifact — must be unchanged
|
||||||
|
dob5, death5, geno5 = e.parse_detail("*01.01.2020, aa C- DD ee Gg PP spsp rere")
|
||||||
|
check("FIX-4: no artifact -> geno unchanged",
|
||||||
|
geno5 == "aa C- DD ee Gg PP spsp rere")
|
||||||
|
check("FIX-4: no artifact -> no spurious death",
|
||||||
|
death5 == "")
|
||||||
|
|
||||||
# --- name-bleed guard (a parent name is not a Farbschlag) ---
|
# --- 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("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"))
|
check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten"))
|
||||||
|
|||||||
Reference in New Issue
Block a user