fix(encyclopedia): a planning reply that repeats a property is unusable #22

Merged
k.renner merged 1 commits from fix/encyclopedia-duplicate-json-keys into main 2026-10-27 07:29:22 +00:00
2 changed files with 16 additions and 2 deletions
@@ -26,12 +26,12 @@ internal static partial class PlanParsing
return text is not null && text.All(char.IsAsciiLetter) && Enum.TryParse(text, ignoreCase: true, out category);
}
/// <summary>The JSON object in a reply, which may be wrapped in a code fence; null when it is not one.</summary>
/// <summary>The JSON object in a reply, which may be wrapped in a code fence; null when it is not one or repeats a property.</summary>
public static JsonObject? Object(string reply)
{
try
{
return JsonNode.Parse(StripFence(reply)) as JsonObject;
return JsonNode.Parse(StripFence(reply), documentOptions: new JsonDocumentOptions { AllowDuplicateProperties = false }) as JsonObject;
}
catch (JsonException)
{
@@ -170,6 +170,20 @@ public class AssignNotesUseCaseTests
Assert.Empty(outcome.Assignments);
}
[Fact]
public async Task Assign_DuplicatePropertyInReply_IsUnusable()
{
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"G","titleEn":"G","titleEn":"H","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["garden"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a"), [], ArticlePlan.Empty(), CancellationToken.None);
Assert.True(outcome.Unusable);
Assert.Empty(outcome.Assignments);
}
[Fact]
public async Task Assign_PromptIsBounded()
{