Compare commits
1 Commits
feature/na
...
81f9d0ce2f
| Author | SHA1 | Date | |
|---|---|---|---|
| 81f9d0ce2f |
@@ -49,14 +49,7 @@ jobs:
|
|||||||
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
||||||
|
|
||||||
- name: dotnet-ef Tool installieren
|
- name: dotnet-ef Tool installieren
|
||||||
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
|
run: dotnet tool install --global dotnet-ef --version 10.0.*
|
||||||
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
|
|
||||||
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
|
|
||||||
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
|
|
||||||
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
|
|
||||||
run: |
|
|
||||||
dotnet tool update --global dotnet-ef --version 10.0.*
|
|
||||||
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: DB-3 EF Migrations Drift-Check
|
- name: DB-3 EF Migrations Drift-Check
|
||||||
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
||||||
@@ -66,7 +59,7 @@ jobs:
|
|||||||
dotnet ef migrations has-pending-model-changes
|
dotnet ef migrations has-pending-model-changes
|
||||||
--project GerbilManagerWebAPI
|
--project GerbilManagerWebAPI
|
||||||
--startup-project GerbilManagerWebAPI
|
--startup-project GerbilManagerWebAPI
|
||||||
--no-build --configuration Release
|
--no-build -c Release
|
||||||
|
|
||||||
- name: Tests ausfuehren
|
- name: Tests ausfuehren
|
||||||
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
||||||
|
|||||||
@@ -103,31 +103,6 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal(2, await db.Litters.CountAsync());
|
Assert.Equal(2, await db.Litters.CountAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
|
|
||||||
{
|
|
||||||
// DB-5: litters must have ExternalRef set to the source litter id on first import,
|
|
||||||
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
|
|
||||||
using var db = NewDb();
|
|
||||||
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
|
||||||
|
|
||||||
// ExternalRef is set on created litters
|
|
||||||
var litters = await db.Litters.ToListAsync();
|
|
||||||
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
|
|
||||||
Assert.Contains(litters, l => l.ExternalRef == "L1");
|
|
||||||
Assert.Contains(litters, l => l.ExternalRef == "L2");
|
|
||||||
|
|
||||||
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
|
|
||||||
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
|
|
||||||
foreach (var l in litters) l.Name = "Geänderter Name";
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
// Re-import: litters detected as existing via ExternalRef even though Name changed
|
|
||||||
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
|
||||||
Assert.Equal(0, second.Litters.Created);
|
|
||||||
Assert.Equal(2, await db.Litters.CountAsync());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using GerbilManagerWebAPI.Names;
|
|
||||||
using GerbilManagerWebAPI.SaleAd;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace GerbilManager.Tests
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
|
||||||
/// fence strip), 503-not-configured path, upstream-error path.
|
|
||||||
/// </summary>
|
|
||||||
public class NameSuggestionTests
|
|
||||||
{
|
|
||||||
// ── Prompt assembly ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
|
|
||||||
{
|
|
||||||
var prompt = NameSuggestionService.BuildSystemPrompt();
|
|
||||||
Assert.Contains("reinen JSON-Array", prompt);
|
|
||||||
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
|
|
||||||
Assert.Contains("name", prompt);
|
|
||||||
Assert.Contains("meaning", prompt);
|
|
||||||
Assert.Contains("origin", prompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
|
||||||
{
|
|
||||||
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
|
|
||||||
Assert.Contains("6", prompt);
|
|
||||||
Assert.Contains("\"A\"", prompt);
|
|
||||||
Assert.Contains("weibliche", prompt);
|
|
||||||
Assert.Contains("norn,mythg", prompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
|
|
||||||
{
|
|
||||||
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
|
|
||||||
Assert.Contains("5", prompt);
|
|
||||||
Assert.DoesNotContain("Buchstaben", prompt);
|
|
||||||
Assert.DoesNotContain("weibliche", prompt);
|
|
||||||
Assert.DoesNotContain("männliche", prompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
|
|
||||||
{
|
|
||||||
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
|
|
||||||
Assert.DoesNotContain("weibliche", prompt);
|
|
||||||
Assert.DoesNotContain("männliche", prompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── JSON parsing ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseSuggestions_verarbeitet_reines_JSON()
|
|
||||||
{
|
|
||||||
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
|
|
||||||
var result = NameSuggestionService.ParseSuggestions(json);
|
|
||||||
Assert.NotNull(result);
|
|
||||||
Assert.Single(result);
|
|
||||||
Assert.Equal("Astrid", result[0].Name);
|
|
||||||
Assert.Equal("göttliche Stärke", result[0].Meaning);
|
|
||||||
Assert.Equal("Altnordisch", result[0].Origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseSuggestions_strippt_json_Markdown_Fence()
|
|
||||||
{
|
|
||||||
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
|
|
||||||
var result = NameSuggestionService.ParseSuggestions(fenced);
|
|
||||||
Assert.NotNull(result);
|
|
||||||
Assert.Equal("Aiko", result![0].Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseSuggestions_strippt_generische_Markdown_Fence()
|
|
||||||
{
|
|
||||||
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
|
||||||
var result = NameSuggestionService.ParseSuggestions(fenced);
|
|
||||||
Assert.NotNull(result);
|
|
||||||
Assert.Equal("Luna", result![0].Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
|
|
||||||
{
|
|
||||||
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
|
|
||||||
var result = NameSuggestionService.ParseSuggestions(messy);
|
|
||||||
Assert.NotNull(result);
|
|
||||||
Assert.Equal("Sol", result![0].Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
|
|
||||||
{
|
|
||||||
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
|
|
||||||
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
|
|
||||||
Assert.Null(NameSuggestionService.ParseSuggestions(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 503: nicht konfiguriert ───────────────────────────────────────────
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(null, "key", "model")]
|
|
||||||
[InlineData("https://api.example.com/v1", null, "model")]
|
|
||||||
[InlineData("https://api.example.com/v1", "key", null)]
|
|
||||||
[InlineData(null, null, null)]
|
|
||||||
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
|
|
||||||
string? baseUrl, string? apiKey, string? model)
|
|
||||||
{
|
|
||||||
var service = CreateService(baseUrl, apiKey, model,
|
|
||||||
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
|
|
||||||
|
|
||||||
var result = await service.SuggestAsync(null, null, null, 5);
|
|
||||||
|
|
||||||
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
|
|
||||||
Assert.Null(result.Suggestions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SuggestAsync_parst_valide_JSON_Antwort()
|
|
||||||
{
|
|
||||||
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
|
|
||||||
var handler = new StubHandler(_ => Canned(payload));
|
|
||||||
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
|
|
||||||
|
|
||||||
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
|
|
||||||
|
|
||||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
|
||||||
Assert.NotNull(result.Suggestions);
|
|
||||||
Assert.Equal(2, result.Suggestions!.Count);
|
|
||||||
Assert.Equal("Astrid", result.Suggestions[0].Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
|
|
||||||
{
|
|
||||||
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
|
||||||
var handler = new StubHandler(_ => Canned(fenced));
|
|
||||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
|
||||||
|
|
||||||
var result = await service.SuggestAsync(null, null, null, 1);
|
|
||||||
|
|
||||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
|
||||||
Assert.Equal("Luna", result.Suggestions![0].Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
|
|
||||||
{
|
|
||||||
var handler = new StubHandler(_ => Canned("das ist kein json"));
|
|
||||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
|
||||||
|
|
||||||
var result = await service.SuggestAsync(null, null, null, 3);
|
|
||||||
|
|
||||||
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
|
||||||
Assert.Null(result.Suggestions);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
|
|
||||||
{
|
|
||||||
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
|
|
||||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
|
||||||
|
|
||||||
var result = await service.SuggestAsync(null, null, null, 3);
|
|
||||||
|
|
||||||
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helfer ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static NameSuggestionService CreateService(
|
|
||||||
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
|
||||||
{
|
|
||||||
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
|
||||||
return new NameSuggestionService(new HttpClient(handler), options);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HttpResponseMessage Canned(string content)
|
|
||||||
{
|
|
||||||
var completion = new
|
|
||||||
{
|
|
||||||
choices = new[] { new { message = new { role = "assistant", content } } },
|
|
||||||
};
|
|
||||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
Content = new StringContent(JsonSerializer.Serialize(completion),
|
|
||||||
Encoding.UTF8, "application/json"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
|
||||||
: HttpMessageHandler
|
|
||||||
{
|
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
||||||
=> Task.FromResult(respond(request));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,13 +49,6 @@ public class ApplicationContext : DbContext
|
|||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
// DB-4: ICU German collation on user-visible / searched text columns.
|
|
||||||
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
|
|
||||||
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
|
|
||||||
// support custom collation names and would fail EnsureCreated on the test host.
|
|
||||||
const string DeIcu = "de-x-icu";
|
|
||||||
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
|
|
||||||
|
|
||||||
modelBuilder.Entity<Gerbil>(e =>
|
modelBuilder.Entity<Gerbil>(e =>
|
||||||
{
|
{
|
||||||
// Enums persisted as their string names (readable, Gridify-friendly).
|
// Enums persisted as their string names (readable, Gridify-friendly).
|
||||||
@@ -65,14 +58,6 @@ public class ApplicationContext : DbContext
|
|||||||
// Residency defaults to true (own stock unless explicitly marked external).
|
// Residency defaults to true (own stock unless explicitly marked external).
|
||||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||||
|
|
||||||
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
|
||||||
if (isNpgsql)
|
|
||||||
{
|
|
||||||
e.Property(g => g.Name).UseCollation(DeIcu);
|
|
||||||
e.Property(g => g.NameSearch).UseCollation(DeIcu);
|
|
||||||
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
|
|
||||||
}
|
|
||||||
|
|
||||||
// FEAT-14: character traits stored as a JSON text column (works on both
|
// FEAT-14: character traits stored as a JSON text column (works on both
|
||||||
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
||||||
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||||
@@ -110,12 +95,6 @@ public class ApplicationContext : DbContext
|
|||||||
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
e.HasOne(l => l.Mother).WithMany()
|
e.HasOne(l => l.Mother).WithMany()
|
||||||
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
// DB-5: ExternalRef = source litter id from extract.py.
|
|
||||||
// Unique (filtered, nulls allowed for manually-entered litters).
|
|
||||||
e.HasIndex(l => l.ExternalRef)
|
|
||||||
.IsUnique()
|
|
||||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<HealthRecord>(e =>
|
modelBuilder.Entity<HealthRecord>(e =>
|
||||||
@@ -197,15 +176,6 @@ public class ApplicationContext : DbContext
|
|||||||
modelBuilder.Entity<MailSettings>()
|
modelBuilder.Entity<MailSettings>()
|
||||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||||
|
|
||||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
|
||||||
if (isNpgsql)
|
|
||||||
{
|
|
||||||
modelBuilder.Entity<ColorVariety>()
|
|
||||||
.Property(v => v.Name).UseCollation(DeIcu);
|
|
||||||
modelBuilder.Entity<Contact>()
|
|
||||||
.Property(c => c.Name).UseCollation(DeIcu);
|
|
||||||
}
|
|
||||||
|
|
||||||
SeedColorVarieties(modelBuilder);
|
SeedColorVarieties(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
using GerbilManagerWebAPI.Names;
|
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Endpoints
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
|
|
||||||
///
|
|
||||||
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
|
|
||||||
/// frontend maps exactly this code to its German disabled-state hint.
|
|
||||||
/// </summary>
|
|
||||||
public static class NamesEndpoints
|
|
||||||
{
|
|
||||||
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
|
|
||||||
{
|
|
||||||
app.MapGet("/names/suggest", async (
|
|
||||||
string? letter,
|
|
||||||
string? gender,
|
|
||||||
string? usages,
|
|
||||||
int count,
|
|
||||||
NameSuggestionService service,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
|
|
||||||
return result.Status switch
|
|
||||||
{
|
|
||||||
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
|
|
||||||
NameSuggestionStatus.NotConfigured => Results.Json(
|
|
||||||
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
|
|
||||||
_ => Results.Json(
|
|
||||||
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.WithTags("Names");
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -71,21 +71,17 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
else if (conf == "niedrig") dateOnly++;
|
else if (conf == "niedrig") dateOnly++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// existing rows (idempotency).
|
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
|
||||||
|
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
|
||||||
var existingGerbilExtRefs = await _db.Gerbils
|
var existingGerbilExtRefs = await _db.Gerbils
|
||||||
.Where(g => g.ExternalRef != null)
|
.Where(g => g.ExternalRef != null)
|
||||||
.Select(g => g.ExternalRef!).ToListAsync();
|
.Select(g => g.ExternalRef!).ToListAsync();
|
||||||
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
||||||
|
|
||||||
// DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
|
var existingLitterKeys = await _db.Litters
|
||||||
// Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
|
.Select(l => new { l.Name, l.Date }).ToListAsync();
|
||||||
var existingLitterData = await _db.Litters
|
var existingLitterKeySet = existingLitterKeys
|
||||||
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
|
||||||
var existingLitterKeySet = existingLitterData
|
|
||||||
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
||||||
var existingLitterExtRefSet = existingLitterData
|
|
||||||
.Where(x => x.ExternalRef != null)
|
|
||||||
.Select(x => x.ExternalRef!).ToHashSet();
|
|
||||||
|
|
||||||
// colour-variety name -> id (case-insensitive)
|
// colour-variety name -> id (case-insensitive)
|
||||||
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
||||||
@@ -98,10 +94,10 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||||
|
|
||||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||||
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
|
|
||||||
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
|
|
||||||
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||||
// so skip them early — they can never be created or linked to animals.
|
// so their existingLitterKeySet key was always "" → they were always counted as
|
||||||
|
// "created" even though the execute block skipped them (date is DateOnly d = false).
|
||||||
|
// Fix: skip undated litters early — they can never be created or linked to animals.
|
||||||
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||||
foreach (var sl in litters)
|
foreach (var sl in litters)
|
||||||
@@ -111,10 +107,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
var name = $"Wurf {sl.LitterId}".Trim();
|
var name = $"Wurf {sl.LitterId}".Trim();
|
||||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||||
// DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
|
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
||||||
// for litters imported before ExternalRef existed (those have ExternalRef = null).
|
|
||||||
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
|
|
||||||
{ littersExisting++; continue; }
|
|
||||||
|
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
litterIdMap[sl.Id] = id;
|
litterIdMap[sl.Id] = id;
|
||||||
@@ -129,7 +122,6 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
TotalBorn = sl.TotalBorn,
|
TotalBorn = sl.TotalBorn,
|
||||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||||
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (samples.Count < 8)
|
if (samples.Count < 8)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,133 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddGermanCollation : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "ExternalRef",
|
|
||||||
table: "Litters",
|
|
||||||
type: "text",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "OriginBreeder",
|
|
||||||
table: "Gerbils",
|
|
||||||
type: "text",
|
|
||||||
nullable: true,
|
|
||||||
collation: "de-x-icu",
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text",
|
|
||||||
oldNullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "NameSearch",
|
|
||||||
table: "Gerbils",
|
|
||||||
type: "text",
|
|
||||||
nullable: true,
|
|
||||||
collation: "de-x-icu",
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text",
|
|
||||||
oldNullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Name",
|
|
||||||
table: "Gerbils",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
collation: "de-x-icu",
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Name",
|
|
||||||
table: "Contacts",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
collation: "de-x-icu",
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Name",
|
|
||||||
table: "ColorVarieties",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
collation: "de-x-icu",
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Litters_ExternalRef",
|
|
||||||
table: "Litters",
|
|
||||||
column: "ExternalRef",
|
|
||||||
unique: true,
|
|
||||||
filter: "\"ExternalRef\" IS NOT NULL");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropIndex(
|
|
||||||
name: "IX_Litters_ExternalRef",
|
|
||||||
table: "Litters");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "ExternalRef",
|
|
||||||
table: "Litters");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "OriginBreeder",
|
|
||||||
table: "Gerbils",
|
|
||||||
type: "text",
|
|
||||||
nullable: true,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text",
|
|
||||||
oldNullable: true,
|
|
||||||
oldCollation: "de-x-icu");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "NameSearch",
|
|
||||||
table: "Gerbils",
|
|
||||||
type: "text",
|
|
||||||
nullable: true,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text",
|
|
||||||
oldNullable: true,
|
|
||||||
oldCollation: "de-x-icu");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Name",
|
|
||||||
table: "Gerbils",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text",
|
|
||||||
oldCollation: "de-x-icu");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Name",
|
|
||||||
table: "Contacts",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text",
|
|
||||||
oldCollation: "de-x-icu");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Name",
|
|
||||||
table: "ColorVarieties",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text",
|
|
||||||
oldCollation: "de-x-icu");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddLitterExternalRef : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddLitterLetter : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "LitterLetter",
|
|
||||||
table: "Litters",
|
|
||||||
type: "text",
|
|
||||||
nullable: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "LitterLetter",
|
|
||||||
table: "Litters");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -201,8 +201,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text");
|
||||||
.UseCollation("de-x-icu");
|
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
b.Property<int>("SortOrder")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
@@ -690,8 +689,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text");
|
||||||
.UseCollation("de-x-icu");
|
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -779,19 +777,16 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text");
|
||||||
.UseCollation("de-x-icu");
|
|
||||||
|
|
||||||
b.Property<string>("NameSearch")
|
b.Property<string>("NameSearch")
|
||||||
.HasColumnType("text")
|
.HasColumnType("text");
|
||||||
.UseCollation("de-x-icu");
|
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("OriginBreeder")
|
b.Property<string>("OriginBreeder")
|
||||||
.HasColumnType("text")
|
.HasColumnType("text");
|
||||||
.UseCollation("de-x-icu");
|
|
||||||
|
|
||||||
b.Property<Guid?>("OriginContactId")
|
b.Property<Guid?>("OriginContactId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
@@ -899,15 +894,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<string>("ExternalRef")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<Guid?>("FatherId")
|
b.Property<Guid?>("FatherId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("LitterLetter")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<Guid?>("MotherId")
|
b.Property<Guid?>("MotherId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
@@ -926,10 +915,6 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ExternalRef")
|
|
||||||
.IsUnique()
|
|
||||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
|
||||||
|
|
||||||
b.HasIndex("FatherId");
|
b.HasIndex("FatherId");
|
||||||
|
|
||||||
b.HasIndex("MotherId");
|
b.HasIndex("MotherId");
|
||||||
|
|||||||
@@ -25,14 +25,5 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
|
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
|
||||||
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
|
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
|
||||||
public string? PairingCode { get; set; }
|
public string? PairingCode { get; set; }
|
||||||
|
|
||||||
/// <summary>DB-5: stable import source id (extract.py litter Id). Unique (filtered,
|
|
||||||
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
|
|
||||||
/// Name+Date is the fallback for litters created before this column existed.</summary>
|
|
||||||
public string? ExternalRef { get; set; }
|
|
||||||
|
|
||||||
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
|
|
||||||
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
|
|
||||||
public string? LitterLetter { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
namespace GerbilManagerWebAPI.Names
|
|
||||||
{
|
|
||||||
/// <summary>FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest.</summary>
|
|
||||||
public sealed record NameSuggestion(string Name, string Meaning, string Origin);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using GerbilManagerWebAPI.Ai;
|
|
||||||
using GerbilManagerWebAPI.SaleAd;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Names
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini
|
|
||||||
/// (the same OpenAiChatClient used by sale-ads and reply-drafts).
|
|
||||||
/// Returns NotConfigured when the AI section is missing — callers map to 503.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class NameSuggestionService(HttpClient http, IOptions<AiOptions> options)
|
|
||||||
{
|
|
||||||
private readonly OpenAiChatClient _client = new(http, options);
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
public async Task<NameSuggestionResult> SuggestAsync(
|
|
||||||
string? letter, string? gender, string? usages, int count,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var aiResult = await _client.CompleteAsync(
|
|
||||||
BuildSystemPrompt(),
|
|
||||||
BuildUserPrompt(letter, gender, usages, count),
|
|
||||||
ct);
|
|
||||||
|
|
||||||
if (aiResult.Status == AiCallStatus.NotConfigured)
|
|
||||||
return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error);
|
|
||||||
if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null)
|
|
||||||
return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error);
|
|
||||||
|
|
||||||
var suggestions = ParseSuggestions(aiResult.Text);
|
|
||||||
return suggestions is null
|
|
||||||
? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.")
|
|
||||||
: new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string BuildSystemPrompt() =>
|
|
||||||
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
|
||||||
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
|
||||||
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
|
||||||
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
|
|
||||||
|
|
||||||
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.Append($"Schlage {count} Rennmaus-Namen vor");
|
|
||||||
if (!string.IsNullOrWhiteSpace(letter))
|
|
||||||
sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen");
|
|
||||||
if (!string.IsNullOrWhiteSpace(gender) &&
|
|
||||||
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
|
||||||
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
|
||||||
if (!string.IsNullOrWhiteSpace(usages))
|
|
||||||
sb.Append($", aus den Kulturkreisen: {usages}");
|
|
||||||
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
|
||||||
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
|
||||||
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
|
||||||
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around
|
|
||||||
/// its JSON output, then deserialises the array.
|
|
||||||
/// </summary>
|
|
||||||
internal static List<NameSuggestion>? ParseSuggestions(string raw)
|
|
||||||
{
|
|
||||||
var text = raw.Trim();
|
|
||||||
|
|
||||||
// Strip ```json ... ``` or ``` ... ``` fences.
|
|
||||||
if (text.StartsWith("```", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
var firstNewline = text.IndexOf('\n');
|
|
||||||
if (firstNewline >= 0) text = text[(firstNewline + 1)..];
|
|
||||||
if (text.EndsWith("```", StringComparison.Ordinal))
|
|
||||||
text = text[..^3].TrimEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the JSON array bounds defensively.
|
|
||||||
var start = text.IndexOf('[');
|
|
||||||
var end = text.LastIndexOf(']');
|
|
||||||
if (start < 0 || end <= start) return null;
|
|
||||||
text = text[start..(end + 1)];
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonSerializer.Deserialize<List<NameSuggestion>>(text, JsonOpts);
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError }
|
|
||||||
|
|
||||||
public sealed record NameSuggestionResult(
|
|
||||||
NameSuggestionStatus Status,
|
|
||||||
List<NameSuggestion>? Suggestions,
|
|
||||||
string? Error);
|
|
||||||
}
|
|
||||||
@@ -45,9 +45,6 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
|
|||||||
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
||||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
|
||||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
|
||||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
|
||||||
|
|
||||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||||
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||||
@@ -103,7 +100,6 @@ app.MapSettingsEndpoints();
|
|||||||
app.MapExportEndpoints();
|
app.MapExportEndpoints();
|
||||||
app.MapCmsEndpoints();
|
app.MapCmsEndpoints();
|
||||||
app.MapRequestEndpoints();
|
app.MapRequestEndpoints();
|
||||||
app.MapNamesEndpoints();
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
|||||||
|
|
||||||
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ~~A1~~ | ✅ **ERLEDIGT (2026-06-07): Gemini-Key hinterlegt** (user-secrets, nie committed) + LIVE verifiziert — Verkaufstext-Endpunkt generiert echten Text. KI-Verkaufstexte + Charakterbogen-KI sind **live**. (Namensgenerator live sobald Backend gemerged; Posteingang braucht zusätzlich A3 Gmail; Web-Chat-Editor wenn gebaut.) | — | — |
|
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang |
|
||||||
| ~~A2~~ | ✅ **ERLEDIGT (2026-06-07): KOSTENLOS, kein Billing nötig.** `gemini-2.0-flash` hatte kein Free-Kontingent (429), aber **`gemini-flash-latest` funktioniert im Free-Tier** (direkt + live verifiziert). Modell überall darauf umgestellt. Julian bleibt gratis. (Falls je volle Privatsphäre gewünscht: Ollama-Option steht.) | — | — |
|
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
|
||||||
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
||||||
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
||||||
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~ ✅ **AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
|
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~ ✅ **AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
|
||||||
@@ -126,10 +126,10 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
|
|||||||
| Tier | Konkreter Konflikt — was stimmt? |
|
| Tier | Konkreter Konflikt — was stimmt? |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
||||||
| ~~Little Runner's Big Ben (*03.02.2020)~~ | ✅ **Pp** (P-Locus) — Julian 2026-06-07 → in conflict-decisions.json |
|
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP** ↔ **Pp** |
|
||||||
| ~~Vance Jr. v.d. K.C. (*10.04.2022)~~ | ✅ **spsp** (ungescheckt) — Julian 2026-06-07 → in conflict-decisions.json |
|
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp** ↔ **spsp** (Schecke ja/nein) |
|
||||||
| ~~Kazu v.d. K.C. (*23.04.2013)~~ | ✅ **ee[f] · GG · PP** — Julian 2026-06-07 → in conflict-decisions.json |
|
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]** ↔ **ee[f]** · G-Locus: **Gg** ↔ **GG** · P-Locus: **P?** ↔ **PP** |
|
||||||
| ~~Skarlett v.d. K.C. (*14.07.2013)~~ | ✅ **Sterbedatum 17.04.2016** — Julian 2026-06-07 → in conflict-decisions.json |
|
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016** ↔ **2018** |
|
||||||
|
|
||||||
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# GerbilManager Produktionskonfiguration
|
# GerbilManager Produktionskonfiguration
|
||||||
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
|
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
|
||||||
|
|
||||||
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
||||||
@@ -26,4 +26,4 @@ BACKUP_KEEP_DAYS=7
|
|||||||
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
||||||
AI__BaseUrl=
|
AI__BaseUrl=
|
||||||
AI__ApiKey=
|
AI__ApiKey=
|
||||||
AI__Model=gemini-flash-latest
|
AI__Model=gemini-2.0-flash
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# GerbilManager — TrueNAS Custom Application
|
# GerbilManager — TrueNAS Custom Application
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# Vor dem ersten Start:
|
# Vor dem ersten Start:
|
||||||
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
|
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
|
||||||
@@ -47,7 +47,7 @@ services:
|
|||||||
# Leer lassen = KI deaktiviert (503 AiKeyMissing statt Fehler)
|
# Leer lassen = KI deaktiviert (503 AiKeyMissing statt Fehler)
|
||||||
AI__BaseUrl: "${AI__BaseUrl:-}"
|
AI__BaseUrl: "${AI__BaseUrl:-}"
|
||||||
AI__ApiKey: "${AI__ApiKey:-}"
|
AI__ApiKey: "${AI__ApiKey:-}"
|
||||||
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
AI__Model: "${AI__Model:-gemini-2.0-flash}"
|
||||||
volumes:
|
volumes:
|
||||||
- photos:/data/photos
|
- photos:/data/photos
|
||||||
- keys:/data/keys
|
- keys:/data/keys
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
||||||
|
|
||||||
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
||||||
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
||||||
@@ -16,7 +16,7 @@ committen**. Fehlt eine der drei, antwortet der Endpunkt mit
|
|||||||
|
|
||||||
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-flash-latest` | Free Tier |
|
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
|
||||||
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
||||||
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
||||||
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
||||||
@@ -31,7 +31,7 @@ Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
|
|||||||
```powershell
|
```powershell
|
||||||
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||||
$env:AI__ApiKey = '<schlüssel>'
|
$env:AI__ApiKey = '<schlüssel>'
|
||||||
$env:AI__Model = 'gemini-flash-latest'
|
$env:AI__Model = 'gemini-2.0-flash'
|
||||||
```
|
```
|
||||||
|
|
||||||
oder per user-secrets im API-Projekt:
|
oder per user-secrets im API-Projekt:
|
||||||
@@ -46,5 +46,5 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
||||||
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
||||||
AI__Model: gemini-flash-latest
|
AI__Model: gemini-2.0-flash
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
/**
|
|
||||||
* QA-SMOKE-2: Gezielter Live-Smoke der heute gemergten Änderungen (main e6b513e).
|
|
||||||
* Läuft NUR im LIVE-Modus (E2E_BASE_URL gesetzt).
|
|
||||||
*
|
|
||||||
* Abgedeckte Änderungen:
|
|
||||||
* 1. GEN-3h — Bracket-Notation: eef→ee[f], cchm→c[chm], C/cchm→Cc[chm]
|
|
||||||
* 2. FORM-FIELDS — originBreeder + isDeaf im Tier-Editformular
|
|
||||||
* 3. CR-2 — Partial-Update nullt keine bestehenden Felder mehr
|
|
||||||
* 4. UI-POLISH — '(ohne Namen)'-Platzhalter für namenlose Tiere
|
|
||||||
* 5. GEHEGE — Redirects + Nav (bereits in gehege-regression.spec; hier live-Verifikation)
|
|
||||||
*
|
|
||||||
* Read-Only soweit möglich. CR-2-Test: idempotentes Save (keine Wertänderung).
|
|
||||||
*/
|
|
||||||
import { de, expect, skipUnlessLive, test } from './fixtures'
|
|
||||||
|
|
||||||
const API = 'http://localhost:5179'
|
|
||||||
|
|
||||||
// ── 1. GEN-3h Bracket-Notation ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('GEN-3h: eef zeigt ee[f] (nicht "eef") im Tier-Detail', async ({ page }) => {
|
|
||||||
skipUnlessLive()
|
|
||||||
// Roswitha — genotype: "aa CC D? eef Gg P? spsp ??"
|
|
||||||
// eef = e/ef het → E>e>ef Rank → ee[f]
|
|
||||||
await page.goto('/rennmaeuse/66ee246c-26f0-41ac-8b6c-c151d9287633')
|
|
||||||
await expect(page.getByRole('heading', { name: 'Roswitha von den Kleinen Chaoten' })).toBeVisible()
|
|
||||||
// Genotyp-Block soll e[f] enthalten, nicht rohes 'eef'
|
|
||||||
const genoCode = page.locator('code').filter({ hasText: /e\[f\]/ })
|
|
||||||
await expect(genoCode).toBeVisible({ timeout: 8_000 })
|
|
||||||
// Rohes 'eef' darf nicht mehr im Code-Block stehen
|
|
||||||
await expect(page.locator('code').filter({ hasText: /[^[]eef/ })).toBeHidden()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GEN-3h: cchm/cchm zeigt c[chm]c[chm] im Tier-Detail (ZoneFire)', async ({ page }) => {
|
|
||||||
skipUnlessLive()
|
|
||||||
// ZoneFire — genotype: "Aa cchmcchm D? Ee Gg P? Spsp ??"
|
|
||||||
await page.goto('/rennmaeuse/14d5cea9-0f87-4381-8afe-80f1d58f7b76')
|
|
||||||
await expect(page.getByRole('heading', { name: 'ZoneFire' })).toBeVisible()
|
|
||||||
const genoCode = page.locator('code').filter({ hasText: /c\[chm\]/ })
|
|
||||||
await expect(genoCode).toBeVisible({ timeout: 8_000 })
|
|
||||||
await expect(page.locator('code').filter({ hasText: /cchmcchm/ })).toBeHidden()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GEN-3h: Cc[chm] + ee[f] korrekt bei Ethan (gemischter Locus)', async ({ page }) => {
|
|
||||||
skipUnlessLive()
|
|
||||||
// Ethan — genotype: "Aa Ccchm D? eef Gg Pp Spsp ??"
|
|
||||||
// Ccchm → Cc[chm]; eef → ee[f]
|
|
||||||
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4')
|
|
||||||
await expect(page.getByRole('heading', { name: 'Ethan von den Kleinen Chaoten' })).toBeVisible()
|
|
||||||
const genoCode = page.locator('code')
|
|
||||||
await expect(genoCode.filter({ hasText: /c\[chm\]/ })).toBeVisible({ timeout: 8_000 })
|
|
||||||
await expect(genoCode.filter({ hasText: /e\[f\]/ })).toBeVisible()
|
|
||||||
// Rohes Storage-Format darf nicht sichtbar sein
|
|
||||||
await expect(genoCode.filter({ hasText: /Ccchm|eef/ })).toBeHidden()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 2. FORM-FIELDS: originBreeder + isDeaf im Formular ───────────────────────
|
|
||||||
|
|
||||||
test('FORM-FIELDS: Herkunft-Freitext + Gehörlos-Dropdown im Tier-Editformular', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
skipUnlessLive()
|
|
||||||
// Ethan hat bereits originBreeder="Kleinen Chaoten", isDeaf=null
|
|
||||||
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4/bearbeiten')
|
|
||||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.form.editTitle })).toBeVisible({ timeout: 8_000 })
|
|
||||||
|
|
||||||
// Herkunft-Freitext (originBreeder) soll sichtbar und gefüllt sein
|
|
||||||
const originInput = page.getByLabel(de.pages.gerbils.fields.originBreeder)
|
|
||||||
await expect(originInput).toBeVisible()
|
|
||||||
await expect(originInput).toHaveValue('Kleinen Chaoten')
|
|
||||||
|
|
||||||
// Gehörlos-Dropdown (isDeaf) soll sichtbar sein mit "Unbekannt"-Default
|
|
||||||
// Gehörlos-Dropdown (isDeaf): label 'Gehörlos', aktuell leer (Unbekannt)
|
|
||||||
const deafLabel = page.locator('label').filter({ hasText: de.pages.gerbils.fields.isDeaf })
|
|
||||||
await expect(deafLabel).toBeVisible()
|
|
||||||
// Dropdown-Select im Label-Kontext
|
|
||||||
const deafSelect = deafLabel.locator('select')
|
|
||||||
await expect(deafSelect).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 3. CR-2: idempotentes Save nullt keine Felder ────────────────────────────
|
|
||||||
|
|
||||||
test('CR-2: Partial-PUT nullt keine bestehenden Felder (direkter API-Test)', async ({ page }) => {
|
|
||||||
skipUnlessLive()
|
|
||||||
// Snapshot VORHER
|
|
||||||
const before = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
|
|
||||||
const snap = await before.json()
|
|
||||||
expect(snap.originBreeder).toBe('Kleinen Chaoten')
|
|
||||||
expect(snap.genotype).toBeTruthy()
|
|
||||||
|
|
||||||
// Partial-PUT: nur name + gender senden (minimal body, alle anderen Felder fehlen)
|
|
||||||
// Mit PATCH-Semantik-Fix: fehlende Felder werden NICHT genullt
|
|
||||||
const res = await page.request.put(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`, {
|
|
||||||
data: {
|
|
||||||
name: snap.name,
|
|
||||||
gender: snap.gender,
|
|
||||||
status: snap.status,
|
|
||||||
isResident: snap.isResident,
|
|
||||||
// ALLE anderen Felder absichtlich weggelassen → vor CR-2-Fix würden sie auf null gesetzt
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// PATCH-Semantik: 204 No Content (kein Body), fehlende Felder bleiben erhalten
|
|
||||||
expect(res.status()).toBe(204)
|
|
||||||
|
|
||||||
// Re-Fetch und vergleichen
|
|
||||||
const afterRes = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
|
|
||||||
const updated = await afterRes.json()
|
|
||||||
|
|
||||||
expect(updated.originBreeder).toBe(snap.originBreeder) // muss erhalten bleiben
|
|
||||||
expect(updated.genotype).toBe(snap.genotype) // muss erhalten bleiben
|
|
||||||
expect(updated.dateOfBirth).toBe(snap.dateOfBirth) // muss erhalten bleiben
|
|
||||||
expect(updated.litterId).toBe(snap.litterId) // muss erhalten bleiben
|
|
||||||
expect(updated.externalRef).toBe(snap.externalRef) // muss erhalten bleiben
|
|
||||||
|
|
||||||
// Restore: idempotent (gleiche Werte gesetzt, kein Net-Change)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 4. UI-POLISH: '(ohne Namen)'-Platzhalter ─────────────────────────────────
|
|
||||||
|
|
||||||
test('UI-POLISH: namenlose Tiere zeigen "(ohne Namen)" in der Tierliste', async ({ page }) => {
|
|
||||||
skipUnlessLive()
|
|
||||||
// Alle Tiere anzeigen (inkl. externe), um namenlose zu sehen
|
|
||||||
await page.goto('/rennmaeuse')
|
|
||||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
|
||||||
// "(ohne Namen)" Platzhalter soll mindestens einmal sichtbar sein
|
|
||||||
// (Filter auf alle Tiere — ggf. "Externe anzeigen" aktivieren)
|
|
||||||
const placeholder = page.getByText('(ohne Namen)')
|
|
||||||
// Wenn nicht sofort sichtbar, Toggle "Externe anzeigen" probieren
|
|
||||||
if (!(await placeholder.isVisible({ timeout: 3_000 }).catch(() => false))) {
|
|
||||||
const toggle = page.locator('button, label').filter({ hasText: /extern|alle/i }).first()
|
|
||||||
if (await toggle.isVisible()) await toggle.click()
|
|
||||||
}
|
|
||||||
await expect(placeholder.first()).toBeVisible({ timeout: 8_000 })
|
|
||||||
})
|
|
||||||
|
|
||||||
test('UI-POLISH: namenloser Knoten im Stammbaum zeigt "(ohne Namen)"', async ({ page }) => {
|
|
||||||
skipUnlessLive()
|
|
||||||
// Stammbaum eines Tiers mit namenlosem Elternteil
|
|
||||||
// Tier "46c646a9" ist namenlos + resident; sein Elternteil im Stammbaum-Kontext
|
|
||||||
// Alternativ: Vance's Stammbaum enthält tiefe Äste, manche namenlos
|
|
||||||
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
|
|
||||||
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
|
|
||||||
// Prüfe ob "(ohne Namen)" irgendwo im Baum erscheint
|
|
||||||
const placeholder = page.getByText('(ohne Namen)')
|
|
||||||
// Kein harter Fail wenn kein namenloser Knoten sichtbar — Tree kann mit Lazy-Gen-5+ variieren
|
|
||||||
// Aber wenn vorhanden, muss der Text sauber sein (nicht leer)
|
|
||||||
const visible = await placeholder.isVisible({ timeout: 5_000 }).catch(() => false)
|
|
||||||
if (visible) {
|
|
||||||
await expect(placeholder.first()).toBeVisible()
|
|
||||||
}
|
|
||||||
// Kein JS-Error auf der Seite (implizit durch erfolgreiche Navigation)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 5. GEHEGE: Live-Verifikation der Redirect/Nav-Änderungen ─────────────────
|
|
||||||
|
|
||||||
test('GEHEGE live: /becken leitet auf /gehege weiter', async ({ page }) => {
|
|
||||||
skipUnlessLive()
|
|
||||||
await page.goto('/becken')
|
|
||||||
await expect(page).toHaveURL(/\/gehege$/, { timeout: 5_000 })
|
|
||||||
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GEHEGE live: "/" leitet auf /rennmaeuse weiter (kein Start-Screen)', async ({ page }) => {
|
|
||||||
skipUnlessLive()
|
|
||||||
await page.goto('/')
|
|
||||||
await expect(page).toHaveURL(/\/rennmaeuse$/, { timeout: 5_000 })
|
|
||||||
await expect(
|
|
||||||
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
|
|
||||||
).toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -84,34 +84,6 @@
|
|||||||
"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).",
|
"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"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Little Runner's Big Ben",
|
|
||||||
"dob": "03.02.2020",
|
|
||||||
"decision": "P-locus = Pp (not PP) — the two sources differed only at P",
|
|
||||||
"genotype": "Aa Cc[chm] DD Ee Gg Pp Spsp",
|
|
||||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Vance Jr. von den Kleinen Chaoten",
|
|
||||||
"dob": "10.04.2022",
|
|
||||||
"decision": "Sp-locus = spsp (kleines spsp, ungescheckt) — die Quellen unterschieden sich nur bei Sp (Spsp // spsp). C-Locus 'c[hm]' im Extrakt → als c[chm] normalisiert (kein gültiges Symbol; c[chm] = offensichtliche Absicht).",
|
|
||||||
"genotype": "aa Cc[chm] Dd Ee gg P- spsp",
|
|
||||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Skarlett von den Kleinen Chaoten",
|
|
||||||
"dob": "14.07.2013",
|
|
||||||
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
|
|
||||||
"dateOfDeath": "17.04.2016",
|
|
||||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Kazu von den Kleinen Chaoten",
|
|
||||||
"dob": "23.04.2013",
|
|
||||||
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
|
|
||||||
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
|
||||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user