FEAT-14a: Charakterbogen on Gerbil (traits + note) + sale-ad request extension
- Gerbil.CharacterTraits (List<string>, JSON text column, opaque labels) + CharacterNote (string?). On GerbilDto + Input; round-trips on GET/POST/PUT. Additive migration. - SaleAdAnimal gains Traits (German labels) + CharacterNote; prompt builder emits "Charakter: …" + "Charakter-Notiz: …" lines for the AI Verkaufstext. - Tests: traits/note round-trip (POST->GET via SQLite host), empty-default, prompt inclusion.
This commit is contained in:
75
GerbilManager.Tests/GerbilCharacterTests.cs
Normal file
75
GerbilManager.Tests/GerbilCharacterTests.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using GerbilManagerWebAPI.SaleAd;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>FEAT-14a: Charakterbogen (traits + note) persists/round-trips, and the
|
||||||
|
/// sale-ad prompt includes the character info.</summary>
|
||||||
|
public class GerbilCharacterTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public GerbilCharacterTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CharacterTraits_and_note_round_trip()
|
||||||
|
{
|
||||||
|
var create = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||||
|
{
|
||||||
|
name = "Charakter Test",
|
||||||
|
gender = "female",
|
||||||
|
characterTraits = new[] { "zutraulich", "handzahm" },
|
||||||
|
characterNote = "sehr aktiv und neugierig",
|
||||||
|
}));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||||
|
|
||||||
|
var created = await create.Content.ReadAsStringAsync();
|
||||||
|
// POST response is the created GerbilDto — traits serialise straight back
|
||||||
|
Assert.Contains("zutraulich", created);
|
||||||
|
Assert.Contains("handzahm", created);
|
||||||
|
Assert.Contains("sehr aktiv und neugierig", created);
|
||||||
|
|
||||||
|
var id = Regex.Match(created, "\"id\":\"([^\"]+)\"").Groups[1].Value;
|
||||||
|
Assert.NotEqual("", id);
|
||||||
|
|
||||||
|
// re-read from the DB to prove persistence (not just echo)
|
||||||
|
var fetched = await _client.GetStringAsync($"/gerbils/{id}");
|
||||||
|
Assert.Contains("zutraulich", fetched);
|
||||||
|
Assert.Contains("handzahm", fetched);
|
||||||
|
Assert.Contains("sehr aktiv und neugierig", fetched);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Omitting_traits_defaults_to_empty_list()
|
||||||
|
{
|
||||||
|
var create = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||||
|
{
|
||||||
|
name = "Ohne Charakter",
|
||||||
|
gender = "male",
|
||||||
|
}));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||||
|
var body = await create.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("\"characterTraits\":[]", body.Replace(" ", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaleAd_prompt_includes_traits_and_note()
|
||||||
|
{
|
||||||
|
var request = new SaleAdRequest(
|
||||||
|
Animals: new List<SaleAdAnimal>
|
||||||
|
{
|
||||||
|
new("Bella", "Agouti", "2025-01-15", "verspielt",
|
||||||
|
Traits: new List<string> { "zutraulich", "handzahm" },
|
||||||
|
CharacterNote: "liebt Sonnenblumenkerne"),
|
||||||
|
},
|
||||||
|
StatusLine: "FREI",
|
||||||
|
Hints: "");
|
||||||
|
|
||||||
|
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||||
|
|
||||||
|
Assert.Contains("Charakter: zutraulich, handzahm", prompt);
|
||||||
|
Assert.Contains("liebt Sonnenblumenkerne", prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,19 @@ public class ApplicationContext : DbContext
|
|||||||
e.Property(g => g.Gender).HasConversion<string>();
|
e.Property(g => g.Gender).HasConversion<string>();
|
||||||
e.Property(g => g.Status).HasConversion<string>();
|
e.Property(g => g.Status).HasConversion<string>();
|
||||||
|
|
||||||
|
// FEAT-14: character traits stored as a JSON text column (works on both
|
||||||
|
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
||||||
|
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||||
|
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
|
||||||
|
v => string.IsNullOrEmpty(v)
|
||||||
|
? new List<string>()
|
||||||
|
: System.Text.Json.JsonSerializer.Deserialize<List<string>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<string>());
|
||||||
|
var traitsComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<string>>(
|
||||||
|
(a, b) => (a ?? new List<string>()).SequenceEqual(b ?? new List<string>()),
|
||||||
|
v => v == null ? 0 : v.Aggregate(0, (h, s) => HashCode.Combine(h, s.GetHashCode())),
|
||||||
|
v => v.ToList());
|
||||||
|
e.Property(g => g.CharacterTraits).HasConversion(traitsConverter, traitsComparer);
|
||||||
|
|
||||||
e.HasOne(g => g.Litter).WithMany()
|
e.HasOne(g => g.Litter).WithMany()
|
||||||
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasOne(g => g.OriginContact).WithMany()
|
e.HasOne(g => g.OriginContact).WithMany()
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string? Notes,
|
string? Notes,
|
||||||
string? ImportSource,
|
string? ImportSource,
|
||||||
string? ExternalRef,
|
string? ExternalRef,
|
||||||
string? OriginBreeder);
|
string? OriginBreeder,
|
||||||
|
List<string> CharacterTraits,
|
||||||
|
string? CharacterNote);
|
||||||
|
|
||||||
public record LitterDto(
|
public record LitterDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -71,7 +73,9 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string? Notes,
|
string? Notes,
|
||||||
string? ImportSource,
|
string? ImportSource,
|
||||||
string? ExternalRef,
|
string? ExternalRef,
|
||||||
string? OriginBreeder);
|
string? OriginBreeder,
|
||||||
|
List<string>? CharacterTraits,
|
||||||
|
string? CharacterNote);
|
||||||
|
|
||||||
public record LitterInput(
|
public record LitterInput(
|
||||||
string Name,
|
string Name,
|
||||||
|
|||||||
@@ -95,11 +95,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
g.ImportSource = i.ImportSource;
|
g.ImportSource = i.ImportSource;
|
||||||
g.ExternalRef = i.ExternalRef;
|
g.ExternalRef = i.ExternalRef;
|
||||||
g.OriginBreeder = i.OriginBreeder;
|
g.OriginBreeder = i.OriginBreeder;
|
||||||
|
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
||||||
|
g.CharacterNote = i.CharacterNote;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static GerbilDto ToDto(Gerbil g) => new(
|
internal static GerbilDto ToDto(Gerbil g) => new(
|
||||||
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
||||||
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
||||||
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder);
|
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||||
|
g.CharacterTraits, g.CharacterNote);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1043
GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.Designer.cs
generated
Normal file
1043
GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCharacterFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CharacterNote",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CharacterTraits",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CharacterNote",
|
||||||
|
table: "Gerbils");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CharacterTraits",
|
||||||
|
table: "Gerbils");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -661,6 +661,13 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<string>("CauseOfDeath")
|
b.Property<string>("CauseOfDeath")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CharacterNote")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CharacterTraits")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<Guid?>("ColorVarietyId")
|
b.Property<Guid?>("ColorVarietyId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// Kept in sync automatically on save (see ApplicationContext.SaveChanges). Gridify-filterable
|
/// Kept in sync automatically on save (see ApplicationContext.SaveChanges). Gridify-filterable
|
||||||
/// so "clan kleine chaoten" matches "Clan-Kleine-Chaoten" (client strips separators too).</summary>
|
/// so "clan kleine chaoten" matches "Clan-Kleine-Chaoten" (client strips separators too).</summary>
|
||||||
public string? NameSearch { get; set; }
|
public string? NameSearch { get; set; }
|
||||||
|
|
||||||
|
/// <summary>FEAT-14: Charakterbogen trait labels (opaque to the backend — the
|
||||||
|
/// {key,label} vocabulary lives frontend-side). Stored as a JSON text column.</summary>
|
||||||
|
public List<string> CharacterTraits { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>FEAT-14: free-text character note; feeds the AI Verkaufstext.</summary>
|
||||||
|
public string? CharacterNote { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ namespace GerbilManagerWebAPI.SaleAd
|
|||||||
string? Farbschlag,
|
string? Farbschlag,
|
||||||
/// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary>
|
/// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary>
|
||||||
string? DateOfBirth,
|
string? DateOfBirth,
|
||||||
string? Notes);
|
string? Notes,
|
||||||
|
/// <summary>FEAT-14: Charakterbogen trait LABELS (German, human-readable) for the prompt.</summary>
|
||||||
|
List<string>? Traits = null,
|
||||||
|
/// <summary>FEAT-14: free-text character note for the prompt.</summary>
|
||||||
|
string? CharacterNote = null);
|
||||||
|
|
||||||
public sealed record SaleAdRequest(
|
public sealed record SaleAdRequest(
|
||||||
List<SaleAdAnimal> Animals,
|
List<SaleAdAnimal> Animals,
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ namespace GerbilManagerWebAPI.SaleAd
|
|||||||
sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}");
|
sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}");
|
||||||
if (!string.IsNullOrWhiteSpace(animal.Notes))
|
if (!string.IsNullOrWhiteSpace(animal.Notes))
|
||||||
sb.Append($" | Notizen: {animal.Notes}");
|
sb.Append($" | Notizen: {animal.Notes}");
|
||||||
|
if (animal.Traits is { Count: > 0 })
|
||||||
|
sb.Append($" | Charakter: {string.Join(", ", animal.Traits)}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(animal.CharacterNote))
|
||||||
|
sb.Append($" | Charakter-Notiz: {animal.CharacterNote}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(request.Hints))
|
if (!string.IsNullOrWhiteSpace(request.Hints))
|
||||||
|
|||||||
Reference in New Issue
Block a user