215 lines
9.5 KiB
C#
215 lines
9.5 KiB
C#
using System.Text.Json;
|
|
using PlotLine.Models;
|
|
using PlotLine.Services;
|
|
|
|
var tests = new (string Name, Action Test)[]
|
|
{
|
|
("Malformed confidence 0. is repaired to 0.0", RepairsTrailingDecimalConfidence),
|
|
("Markdown fences are stripped", StripsMarkdownFence),
|
|
("Unescaped backslash in evidence is repaired", RepairsUnescapedBackslash),
|
|
("Truncated JSON is rejected", RejectsTruncatedJson),
|
|
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
|
|
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
|
|
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput),
|
|
("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups),
|
|
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
|
|
("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases),
|
|
("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations),
|
|
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants)
|
|
};
|
|
|
|
foreach (var test in tests)
|
|
{
|
|
test.Test();
|
|
Console.WriteLine($"PASS {test.Name}");
|
|
}
|
|
|
|
static void RepairsTrailingDecimalConfidence()
|
|
{
|
|
var result = StoryIntelligenceJsonRepair.Repair("""{"observations":[{"confidence":0. }]}""");
|
|
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
|
|
Assert(result.RepairedJson.Contains("\"confidence\":0.0", StringComparison.Ordinal), result.RepairedJson);
|
|
}
|
|
|
|
static void StripsMarkdownFence()
|
|
{
|
|
var result = StoryIntelligenceJsonRepair.Repair("""
|
|
```json
|
|
{"schemaVersion":"1.0"}
|
|
```
|
|
""");
|
|
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
|
|
Assert(result.RepairedJson == """{"schemaVersion":"1.0"}""", result.RepairedJson);
|
|
}
|
|
|
|
static void RepairsUnescapedBackslash()
|
|
{
|
|
var result = StoryIntelligenceJsonRepair.Repair("""{"evidence":"Mara reads C:\qnotes"}""");
|
|
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
|
|
using var document = JsonDocument.Parse(result.RepairedJson);
|
|
var evidence = document.RootElement.GetProperty("evidence").GetString();
|
|
Assert(evidence == @"Mara reads C:\qnotes", evidence ?? "Evidence was null.");
|
|
}
|
|
|
|
static void RejectsTruncatedJson()
|
|
{
|
|
var result = StoryIntelligenceJsonRepair.Repair("""{"observations":[{"confidence":""");
|
|
Assert(!result.Success, "Truncated JSON should not be silently repaired.");
|
|
}
|
|
|
|
static void RepairedJsonDeserialises()
|
|
{
|
|
var result = StoryIntelligenceJsonRepair.Repair("""
|
|
{
|
|
"schemaVersion": "1.0",
|
|
"sceneReference": { "sceneId": null },
|
|
"summary": { "short": "A scene.", "detailed": "A scene.", "confidence": 0. },
|
|
"observations": []
|
|
}
|
|
""");
|
|
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
|
|
var parsed = JsonSerializer.Deserialize<SceneIntelligenceScene>(
|
|
result.RepairedJson,
|
|
new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
Assert(parsed is not null, "Scene JSON did not deserialise.");
|
|
}
|
|
|
|
static void ChapterStructureConfidenceRepairs()
|
|
{
|
|
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(
|
|
"""
|
|
{
|
|
"schemaVersion": "1.0",
|
|
"chapterSummary": "A chapter.",
|
|
"sceneBoundaries": [
|
|
{
|
|
"sceneNumber": 1,
|
|
"startParagraph": 1,
|
|
"endParagraph": 2,
|
|
"confidence": 0. ,
|
|
"reason": "Opening scene."
|
|
}
|
|
]
|
|
}
|
|
""",
|
|
JsonOptions());
|
|
|
|
Assert(result.Success, result.ErrorMessage ?? "Chapter Structure parse failed.");
|
|
Assert(result.Parsed?.SceneBoundaries?[0].Confidence == 0.0m, "Confidence was not repaired to 0.0.");
|
|
Assert(result.Warnings.Any(warning => warning.Contains("$.sceneBoundaries[0].confidence", StringComparison.Ordinal)), "Repair warning did not include JSON path.");
|
|
}
|
|
|
|
static void SharedParserReportsRawOutput()
|
|
{
|
|
const string raw = """{"schemaVersion":"1.0","sceneBoundaries":[""";
|
|
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(raw, JsonOptions());
|
|
Assert(!result.Success, "Unrecoverable JSON should fail.");
|
|
Assert(result.RawJson == raw, "Raw malformed output was not retained.");
|
|
}
|
|
|
|
static void CharacterFilteringRejectsGenericGroups()
|
|
{
|
|
Assert(IsGenericGroupReference("four examiners"), "Quantity + generic group should be rejected.");
|
|
Assert(IsGenericGroupReference("groups of men"), "Generic group phrase should be rejected.");
|
|
Assert(IsGenericGroupReference("neighbours"), "Generic plural group should be rejected.");
|
|
Assert(IsGenericGroupReference("passenger (blue Volkswagen Golf GTi)"), "Unnamed passenger descriptor should be rejected.");
|
|
Assert(IsGenericGroupReference("tubby lad"), "Unnamed descriptive person should be rejected.");
|
|
Assert(IsGenericGroupReference("lady examiner"), "Unnamed role descriptor should be rejected.");
|
|
Assert(IsGenericGroupReference("her mother"), "Determiner + family role should be rejected.");
|
|
Assert(IsGenericGroupReference("the child"), "Determiner + generic person should be rejected.");
|
|
}
|
|
|
|
static void CharacterFilteringPreservesTitledNames()
|
|
{
|
|
Assert(!IsGenericGroupReference("Reverend Potter"), "Reverend Potter should not be treated as generic.");
|
|
Assert(IsNamedOrTitledPerson("Reverend Potter"), "Reverend Potter should be recognised as a titled person.");
|
|
Assert(IsNamedOrTitledPerson("Detective Sergeant Summerhill"), "Detective Sergeant Summerhill should be recognised as a titled person.");
|
|
Assert(IsNamedOrTitledPerson("Mrs Patterson"), "Mrs Patterson should be recognised as a titled person.");
|
|
Assert(IsNamedOrTitledPerson("Aunt Elen"), "Aunt Elen should be recognised as a titled person.");
|
|
}
|
|
|
|
static bool IsGenericGroupReference(string name)
|
|
=> InvokePrivateCharacterFilter("IsGenericGroupReference", name);
|
|
|
|
static bool IsNamedOrTitledPerson(string name)
|
|
=> InvokePrivateCharacterFilter("IsNamedOrTitledPerson", name);
|
|
|
|
static bool InvokePrivateCharacterFilter(string methodName, string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceCharacterImportService).GetMethod(
|
|
methodName,
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, $"{methodName} was not found.");
|
|
return method!.Invoke(null, [name]) is true;
|
|
}
|
|
|
|
static void LocationFilteringRejectsMergedPhrases()
|
|
{
|
|
Assert(IsMergedLocationPhrase("shop and staff room"), "Merged shop/staff room phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("house and garden"), "Merged house/garden phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("road and bridge"), "Merged road/bridge phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("kitchen and hallway"), "Merged room phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("front and rear garden"), "Merged garden phrase should be rejected.");
|
|
Assert(!IsMergedLocationPhrase("Rose and Crown"), "Named pub should not be treated as a merged location.");
|
|
}
|
|
|
|
static void LocationFilteringPreservesStoryLocations()
|
|
{
|
|
Assert(IsStoryLocation("The Doweries"), "The Doweries should be preserved.");
|
|
Assert(IsStoryLocation("Ashdown Trust"), "Ashdown Trust should be preserved.");
|
|
Assert(IsStoryLocation("St Luke's Church"), "St Luke's Church should be preserved.");
|
|
Assert(IsStoryLocation("Mrs Patterson's House"), "Mrs Patterson's House should be preserved.");
|
|
Assert(IsStoryLocation("Bristol Road"), "Named roads should be preserved.");
|
|
Assert(!IsStoryLocation("Kitchen"), "Standalone generic rooms should not be story locations.");
|
|
}
|
|
|
|
static void LocationCanonicalKeysMergeTrivialVariants()
|
|
{
|
|
Assert(LocationCanonicalKey("Kitchen") == LocationCanonicalKey("The Kitchen"), "The Kitchen should fold into Kitchen.");
|
|
Assert(LocationCanonicalKey("Interview room") == LocationCanonicalKey("Interview Room"), "Case-only room variants should merge.");
|
|
Assert(LocationCanonicalKey("Ashdown Trust (reception)") == LocationCanonicalKey("Ashdown Trust"), "Parenthetical variants should merge.");
|
|
}
|
|
|
|
static bool IsMergedLocationPhrase(string name)
|
|
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
|
|
|
|
static bool IsStoryLocation(string name)
|
|
=> InvokePrivateLocationFilter("IsStoryLocation", name);
|
|
|
|
static string LocationCanonicalKey(string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
|
|
"CanonicalLocationKey",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "CanonicalLocationKey was not found.");
|
|
return (string)method!.Invoke(null, [name])!;
|
|
}
|
|
|
|
static bool InvokePrivateLocationFilter(string methodName, string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
|
|
methodName,
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, $"{methodName} was not found.");
|
|
return method!.Invoke(null, [name]) is true;
|
|
}
|
|
|
|
static JsonSerializerOptions JsonOptions()
|
|
=> new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
static void Assert(bool condition, string message)
|
|
{
|
|
if (!condition)
|
|
{
|
|
throw new InvalidOperationException(message);
|
|
}
|
|
}
|