848 lines
53 KiB
C#

using System.Text.Json;
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Controllers;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
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),
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys),
("Illustration prompt builder separates spec and prompt", IllustrationPromptBuilderSeparatesSpecAndPrompt),
("Illustration specification validation catches missing typed fields", IllustrationSpecificationValidationCatchesMissingTypedFields),
("Illustration category validation rejects unknown values", IllustrationCategoryValidationRejectsUnknownValues),
("Illustration status transitions protect approval flow", IllustrationStatusTransitionsProtectApprovalFlow),
("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely),
("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts),
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes),
("Story Intelligence simulation mode remains available", StoryIntelligenceSimulationModeRemainsAvailable),
("Story Intelligence visualisation contract omits raw manuscript text", StoryIntelligenceVisualisationContractOmitsRawManuscriptText),
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel),
("Illustration provider omits GPT image response format", IllustrationProviderOmitsGptImageResponseFormat),
("Illustration matching rejects adult masculine unknown art for Beth", IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth),
("Illustration matching treats Mrs title as hard feminine evidence", IllustrationMatchingTreatsMrsTitleAsHardFeminineEvidence),
("Illustration matching infers mother and aunt as mature feminine", IllustrationMatchingInfersMotherAndAuntAsMatureFeminine),
("Illustration matching rejects duplicate named portrait allocation", IllustrationMatchingRejectsDuplicateNamedPortraitAllocation),
("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches),
("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate),
("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage),
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence)
};
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 void AssetFilteringRejectsGenericObjects()
{
Assert(!IsAssetNameCandidate("chair"), "Chair should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("table"), "Table should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("street"), "Street should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("car park"), "Car park should not be treated as a story asset.");
}
static void AssetFilteringPreservesStoryAssets()
{
Assert(IsAssetNameCandidate("TR6"), "TR6 should be treated as a story asset.");
Assert(IsAssetNameCandidate("red notebook"), "Red notebook should be treated as a story asset.");
Assert(IsAssetNameCandidate("passport"), "Passport should be treated as a story asset.");
Assert(IsAssetNameCandidate("car keys"), "Car keys should be treated as a story asset.");
Assert(IsAssetNameCandidate("driving licence"), "Driving licence should be treated as a story asset.");
}
static void AssetCanonicalKeysMergeTrivialVariants()
{
Assert(AssetCanonicalKey("red notebook") == AssetCanonicalKey("the notebook"), "Notebook variants should merge.");
Assert(AssetCanonicalKey("photo") == AssetCanonicalKey("photograph"), "Photo should fold into photograph.");
Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge.");
}
static void RelationshipSignalsMapToBroadTypes()
{
Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend.");
Assert(RelationshipTypeFromSignal("driving instructor", "learner makes progress") == "Educational", "Instructor/learner should map to Educational.");
Assert(RelationshipTypeFromSignal("aunt", "family visit") == "Family", "Aunt/family should map to Family.");
Assert(RelationshipTypeFromSignal("boss", "employee at work") == "Professional", "Boss/employee should map to Professional.");
Assert(RelationshipTypeFromSignal("police", "suspect witness interview") == "Authority", "Police/suspect/witness should map to Authority.");
Assert(RelationshipTypeFromSignal("neighbour") == "Neighbour", "Neighbour should map to Neighbour.");
Assert(RelationshipTypeFromSignal("adversarial", "mistrust between them") == "Rival", "Adversarial/mistrust should map to Rival.");
Assert(RelationshipTypeFromSignal("uncertain emotional connection") == "Unknown", "Uncertain signals should map to Unknown.");
}
static void KnowledgeSignalsMapToExistingStates()
{
var states = new List<KnowledgeState>
{
new() { KnowledgeStateID = 1, StateName = "Unaware", SortOrder = 10, IsActive = true },
new() { KnowledgeStateID = 2, StateName = "Suspects", SortOrder = 20, IsActive = true },
new() { KnowledgeStateID = 3, StateName = "Partially Knows", SortOrder = 30, IsActive = true },
new() { KnowledgeStateID = 4, StateName = "Knows", SortOrder = 40, IsActive = true },
new() { KnowledgeStateID = 5, StateName = "Misunderstands", SortOrder = 50, IsActive = true }
};
Assert(KnowledgeStateFromSignal("Discovers", "the letter exists", states) == "Knows", "Discovers should map to Knows.");
Assert(KnowledgeStateFromSignal("Suspects", "Maggie is lying", states) == "Suspects", "Suspects should map to Suspects.");
Assert(KnowledgeStateFromSignal("Believes", "Gareth is trustworthy", states) == "Partially Knows", "Believes should map to Partially Knows.");
Assert(KnowledgeStateFromSignal("Misunderstands", "Beth is dead", states) == "Misunderstands", "Misunderstands should map to Misunderstands.");
Assert(KnowledgeStateFromSignal("Forgets", "the hiding place", states) == "Unaware", "Forgets should map to Unaware.");
}
static void KnowledgeDuplicateStatementsShareCanonicalKeys()
{
var first = CanonicalKnowledgeKey("Simon is Beth's father.");
var second = CanonicalKnowledgeKey("Beth discovers Simon is her father.");
var third = CanonicalKnowledgeKey("Beth learns Simon is her biological father.");
Assert(first == second && second == third, $"Expected duplicate father facts to merge, got {first}, {second}, {third}.");
}
static bool IsAssetNameCandidate(string name)
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name);
static string AssetCanonicalKey(string name)
{
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
"CanonicalAssetKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "CanonicalAssetKey was not found.");
return (string)method!.Invoke(null, [name])!;
}
static bool InvokePrivateAssetFilter(string methodName, string name)
{
var method = typeof(StoryIntelligenceAssetImportService).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 string RelationshipTypeFromSignal(string signal, string? evidence = null)
{
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"RelationshipTypeFromSignal",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "RelationshipTypeFromSignal was not found.");
return (string)method!.Invoke(null, [signal, evidence])!;
}
static string KnowledgeStateFromSignal(string changeType, string statement, IReadOnlyList<KnowledgeState> states)
{
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
"KnowledgeStateFromSignal",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "KnowledgeStateFromSignal was not found.");
return (string)method!.Invoke(null, [changeType, statement, states])!;
}
static string CanonicalKnowledgeKey(string statement)
{
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
"CanonicalKnowledgeKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "CanonicalKnowledgeKey was not found.");
return (string)method!.Invoke(null, [statement])!;
}
static void IllustrationPromptBuilderSeparatesSpecAndPrompt()
{
var spec = IllustrationStarterBatchDefinition.All.Single(x => x.Code == "asset-folded-letter");
var builder = new IllustrationPromptBuilder();
var result = builder.Build(spec);
Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded.");
Assert(result.TemplateVersion == "21J.3", "Illustration prompt template version should reflect the current approved art direction.");
Assert(result.Prompt.Contains("no visible text", StringComparison.OrdinalIgnoreCase), "Prompt did not include visual safety guidance.");
Assert(result.Prompt.Contains("Category composition rules", StringComparison.Ordinal), "Prompt did not include category composition rules.");
Assert(result.Prompt.Contains("Structured details", StringComparison.Ordinal), "Prompt did not include structured details.");
Assert(result.Prompt.Contains("modern cinematic digital illustration", StringComparison.OrdinalIgnoreCase), "Prompt did not include the new cinematic digital art direction.");
Assert(result.Prompt.Contains("premium narrative game concept art", StringComparison.OrdinalIgnoreCase), "Prompt did not include premium game concept-art guidance.");
Assert(result.Prompt.Contains("balanced cinematic lighting", StringComparison.OrdinalIgnoreCase), "Prompt did not include current lighting guidance.");
Assert(result.Prompt.Contains("avoid muddy olive and brown grading", StringComparison.OrdinalIgnoreCase), "Prompt did not explicitly avoid the previous muddy grading failure mode.");
Assert(result.Prompt.Contains(spec.Code, StringComparison.Ordinal), "Prompt did not include stable code.");
Assert(result.SpecificationJson.Contains("\"code\": \"asset-folded-letter\"", StringComparison.Ordinal), "Specification JSON did not preserve the stable code.");
Assert(result.MetadataJson.Contains("generic-no-manuscript-data", StringComparison.Ordinal), "Metadata JSON did not preserve privacy metadata.");
}
static void IllustrationSpecificationValidationCatchesMissingTypedFields()
{
var invalid = new IllustrationGenerationSpecification
{
Category = IllustrationLibraryCategories.Character,
Code = "char-invalid",
Title = "Invalid",
Mood = "Calm"
};
var errors = invalid.Validate();
Assert(errors.Any(error => error.Contains("Apparent age band", StringComparison.OrdinalIgnoreCase)), "Character age-band validation should run.");
Assert(errors.Any(error => error.Contains("Presentation", StringComparison.OrdinalIgnoreCase)), "Character presentation validation should run.");
}
static void IllustrationCategoryValidationRejectsUnknownValues()
{
Assert(IllustrationLibraryCategories.IsValid(IllustrationLibraryCategories.Character), "Character should be valid.");
Assert(IllustrationLibraryCategories.IsValid(IllustrationLibraryCategories.Location), "Location should be valid.");
Assert(IllustrationLibraryCategories.IsValid(IllustrationLibraryCategories.Asset), "Asset should be valid.");
Assert(!IllustrationLibraryCategories.IsValid("Manuscript"), "Unknown category should be rejected.");
}
static void IllustrationStatusTransitionsProtectApprovalFlow()
{
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Planned, IllustrationLibraryStatuses.Queued), "Planned should queue.");
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Generating, IllustrationLibraryStatuses.Generated), "Generating should become generated.");
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Generated, IllustrationLibraryStatuses.Approved), "Generated should approve.");
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Approved, IllustrationLibraryStatuses.Superseded), "Approved should become superseded.");
Assert(!IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Planned, IllustrationLibraryStatuses.Approved), "Planned should not approve directly.");
Assert(!IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Queued, IllustrationLibraryStatuses.Approved), "Queued should not approve directly.");
}
static void IllustrationStorageCodesAreNormalisedSafely()
{
Assert(IllustrationLibraryStorageService.SafeSegment("Asset Letter_01.png") == "asset-letter-01-png", "Storage code was not normalised.");
var failed = false;
try
{
IllustrationLibraryStorageService.SafeSegment("../");
}
catch (ArgumentException)
{
failed = true;
}
Assert(failed, "Unsafe storage code should be rejected.");
}
static void IllustrationStarterBatchHasRequiredCategoryCounts()
{
Assert(IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Character) == 12, "Starter characters should total 12.");
Assert(IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Location) == 6, "Starter locations should total 6.");
Assert(IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Asset) == 10, "Starter assets should total 10.");
Assert(IllustrationStarterBatchDefinition.All.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() == IllustrationStarterBatchDefinition.All.Count, "Starter codes should be unique.");
}
static void IllustrationStarterGenerationPlanSkipsSuccessfulItems()
{
var generatedCharacter = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 1,
Category = IllustrationLibraryCategories.Character,
StableCode = "char-young-adult-brunette-observer",
Status = IllustrationLibraryStatuses.Generated,
UpdatedUtc = DateTime.UtcNow
};
var failedAsset = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 2,
Category = IllustrationLibraryCategories.Asset,
StableCode = "asset-folded-letter",
Status = IllustrationLibraryStatuses.Failed,
UpdatedUtc = DateTime.UtcNow
};
var plan = IllustrationStarterBatchDefinition.BuildGenerationPlan([generatedCharacter, failedAsset]);
Assert(plan.CharactersToGenerate == 11, $"Expected 11 characters after one generated item, got {plan.CharactersToGenerate}.");
Assert(plan.AssetsToGenerate == 10, $"Expected failed starter asset to remain eligible, got {plan.AssetsToGenerate}.");
Assert(!IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(generatedCharacter.StableCode, [generatedCharacter, failedAsset]), "Generated starter should not be eligible.");
Assert(IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(failedAsset.StableCode, [generatedCharacter, failedAsset]), "Failed starter should be eligible.");
}
static void StoryIntelligencePrototypeUsesStarterIllustrationCodes()
{
var prototype = StoryIntelligenceExperiencePrototypeData.Build();
var codes = prototype.Scenes
.SelectMany(scene => scene.Characters.Select(character => character.LibraryCode)
.Concat([scene.Location.LibraryCode])
.Concat(scene.Assets.Select(asset => asset.LibraryCode)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
Assert(codes.Count > 0, "Prototype should carry explicit library codes.");
Assert(codes.All(code => IllustrationStarterBatchDefinition.StarterCodes.Contains(code)), "Prototype requested a stable code that is not in the starter library.");
Assert(prototype.Scenes.SelectMany(scene => scene.Characters).All(character => character.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Character SVG fallback should remain before library resolution.");
Assert(prototype.Scenes.All(scene => scene.Location.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Location SVG fallback should remain before library resolution.");
Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution.");
}
static void IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth()
{
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth", "age 15", "fifteen-year-old girl"], []);
var middleAged = Metadata("char-family-relative", "MatureAdult", "Feminine");
var masculine = Metadata("char-middle-aged-weathered-man", "MatureAdult", "Masculine");
var unknown = Metadata("char-unknown-figure", "YoungAdult", "Androgynous", isUnknownFigure: true);
Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen, $"Beth age should be YoungTeen, got {beth.AgeBand}.");
Assert(Rejected(beth, middleAged).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Beth should reject mature adult art.");
Assert(Rejected(beth, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Beth should reject masculine art.");
Assert(Rejected(beth, unknown).Any(reason => reason.Contains("Unknown Figure", StringComparison.OrdinalIgnoreCase)), "Beth should reject Unknown Figure art.");
}
static void IllustrationMatchingTreatsMrsTitleAsHardFeminineEvidence()
{
var mrsPatterson = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson", "neighbour"], []);
var masculine = Metadata("char-archival-contact", "Senior", "Masculine");
var androgynous = Metadata("char-neighbourhood-friend", "YoungAdult", "Androgynous");
var feminine = Metadata("char-family-relative", "MatureAdult", "Feminine");
Assert(mrsPatterson.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Mrs title should infer Feminine, got {mrsPatterson.Presentation}.");
Assert(Rejected(mrsPatterson, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Mrs Patterson should reject masculine art.");
Assert(Rejected(mrsPatterson, androgynous).Count == 0, "Androgynous should not be hard-rejected solely by title.");
Assert(Rejected(mrsPatterson, feminine).Count == 0, "Suitable feminine candidate should remain valid.");
}
static void IllustrationMatchingInfersMotherAndAuntAsMatureFeminine()
{
var mother = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth's mother", ["her mother", "mother of Beth", "Beth is 15"], []);
var aunt = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Aunt Elen", ["Beth's aunt", "Aunt Elen"], []);
var teen = Metadata("char-young-helper", "YoungTeen", "Androgynous");
var masculine = Metadata("char-middle-aged-weathered-man", "MatureAdult", "Masculine");
Assert(mother.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Mother should infer MatureAdult, got {mother.AgeBand}.");
Assert(mother.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Mother should infer Feminine.");
Assert(aunt.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Aunt should infer MatureAdult, got {aunt.AgeBand}.");
Assert(aunt.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Aunt should infer Feminine.");
Assert(Rejected(mother, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Mother should reject masculine art.");
Assert(Rejected(aunt, teen).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Aunt should reject teen art.");
}
static void IllustrationMatchingRejectsDuplicateNamedPortraitAllocation()
{
var rosie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Rosie", ["Rosie", "young woman"], []);
var candidate = Metadata("char-young-adult-red-haired-witness", "YoungAdult", "Feminine");
var rejected = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(rosie, candidate, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: true);
Assert(rejected.Any(reason => reason.Contains("already assigned", StringComparison.OrdinalIgnoreCase)), "Distinct significant named characters should not silently share portraits.");
}
static void IllustrationSemanticTypesPreventObviousMismatches()
{
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom at Beth's house") == "Bathroom", "Bathroom should classify as Bathroom.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "Office"), "Bathroom must reject office imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "HouseExterior"), "Kitchen must reject house exterior imagery.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "FlatInterior"), "Kitchen may use generic interior fallback.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ambulance") == "Ambulance", "Ambulance should classify as Ambulance.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Ambulance", "Car"), "Ambulance must reject sports car imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("BodyBag", "Suitcase"), "Body bag must reject suitcase/box imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Note", "Suitcase"), "Note must reject suitcase imagery.");
}
static void StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage()
{
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is fifteen.", "Beth pushed red hair aside."], ["Beth's mother entered."]);
var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is sixteen."], ["her mother spoke."]);
var socialWorker = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("social worker", ["the social worker arrived."], ["Beth pushed red hair aside."]);
var kevin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Kevin", ["Kevin"], []);
var ladyExaminer = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("lady examiner", ["the lady examiner"], []);
var toddler = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("boy", ["the boy toddler"], []);
Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen, $"Beth must not become MatureAdult, got {beth.AgeBand}.");
Assert(beth.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, "Beth's own red-hair evidence should be retained.");
Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Maggie at sixteen must not become MatureAdult, got {maggie.AgeBand}.");
Assert(socialWorker.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Unknown, $"Social worker should not inherit Beth's red hair, got {socialWorker.HairColour}.");
Assert(kevin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Kevin should be Masculine, got {kevin.Presentation}.");
Assert(ladyExaminer.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Lady examiner should be Feminine, got {ladyExaminer.Presentation}.");
Assert(toddler.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Toddler should be Child, got {toddler.AgeBand}.");
Assert(toddler.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Boy toddler should be Masculine, got {toddler.Presentation}.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane must classify as Road, not interior.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("money") == "Money", "Money must not classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("bottle") == "Bottle", "Bottle must not classify as LockedBox.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("scarves") == "Clothing", "Scarves must not classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "Document", "Driving licence should classify as Document.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ring") == "Jewellery", "Ring should classify as Jewellery.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("gun") == "Weapon", "Gun should classify as Weapon.");
}
static void IllustrationDemandArchetypesStayBroadAndUseActiveTemplate()
{
var evidence = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth", "age 15", "red hair", "light skin"], []);
var key = string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant();
Assert(key == "character|youngteen|feminine|red|light", $"Demand key was too broad or too specific: {key}");
Assert(!key.Contains("beth", StringComparison.OrdinalIgnoreCase), "Demand key must not include character name.");
Assert(!key.Contains("eye", StringComparison.OrdinalIgnoreCase), "Demand key must not include eye colour.");
Assert(IllustrationPromptBuilder.CurrentTemplateVersion == "21J.3", $"Demand generation should use the active shared template, got {IllustrationPromptBuilder.CurrentTemplateVersion}.");
}
static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes()
{
var femaleInstructor = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("female instructor", ["female instructor"], []);
var maleExaminers = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("younger male examiners", ["younger male examiners"], []);
var lad = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("tubby lad", ["tubby lad"], []);
var girl = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("girl", ["schoolgirl"], []);
var kevin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Kevin", ["Kevin", "female instructor (Kevin's)"], ["female instructor", "Kevin's examiner"]);
var colin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Colin", ["Colin mother waits nearby", "female instructor"], []);
var david = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("David", ["David the woman speaks first", "female examiner"], []);
Assert(femaleInstructor.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"female instructor should be Feminine, got {femaleInstructor.Presentation}.");
Assert(maleExaminers.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"male examiners should be Masculine, got {maleExaminers.Presentation}.");
Assert(lad.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"lad should be Masculine, got {lad.Presentation}.");
Assert(lad.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"lad should infer a young age band, got {lad.AgeBand}.");
Assert(girl.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"girl should be Feminine, got {girl.Presentation}.");
Assert(girl.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"schoolgirl should infer Child, got {girl.AgeBand}.");
Assert(kevin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Kevin should not inherit feminine presentation from his instructor, got {kevin.Presentation}.");
Assert(colin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Colin should not inherit feminine presentation from neighbouring evidence, got {colin.Presentation}.");
Assert(colin.Warnings.Any(warning => warning.Contains("corrected Colin", StringComparison.OrdinalIgnoreCase)), "Colin correction should be visible in diagnostics warnings.");
Assert(david.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"David should not inherit feminine presentation from neighbouring evidence, got {david.Presentation}.");
Assert(david.Warnings.Any(warning => warning.Contains("corrected David", StringComparison.OrdinalIgnoreCase)), "David correction should be visible in diagnostics warnings.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("social workers"), "Social workers should be treated as a group entity.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("younger male examiners"), "Plural examiners should be treated as a group entity.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("uniformed people"), "Uniformed people should be treated as a group entity.");
Assert(!StoryIntelligenceIllustrationCompatibility.IsGroupEntity("social worker"), "Singular social worker should remain an individual entity.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "LaundryUtilityRoom"), "Bathroom must not match laundry room.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Road must not match car park.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not match flat interior.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Ford Fiesta") == "Car", "Ford Fiesta should classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Car") == "Car", "Car should classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("supercharger") == "CarPart", "Supercharger should not classify as full car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("biscuit tin") == "Tin", "Biscuit tin should not classify as Photograph.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not classify as Letter.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("pass certificate") == "Document", "Pass certificate should classify as Document.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "Document", "Driving licence should classify as Document.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("thin cotton blouse") == "Clothing", "Blouse should classify as Clothing.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("two pairs of sunglasses") == "Clothing", "Sunglasses should classify as Clothing.");
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is fifteen."], []);
var adult = Metadata("char-family-relative", "MatureAdult", "Feminine");
Assert(Rejected(beth, adult).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Existing adult assignment must be discarded when Beth evidence says YoungTeen.");
}
static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false)
=> new(
ageBand,
presentation,
"Unknown",
"Unknown",
"Unknown",
"Unknown",
"Unknown",
isUnknownFigure,
false,
!isUnknownFigure,
IllustrationPromptBuilder.CurrentTemplateVersion,
"Test");
static IReadOnlyList<string> Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata)
=> StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false);
static void StoryIntelligenceSimulationModeRemainsAvailable()
{
var prototype = StoryIntelligenceExperiencePrototypeData.Build();
Assert(prototype.Scenes.Count >= 7, "Simulation should keep the existing multi-scene visual dataset.");
Assert(prototype.Scenes.Any(scene => scene.PovCharacterId == "rosie"), "Simulation should still exercise POV transitions.");
}
static void StoryIntelligenceVisualisationContractOmitsRawManuscriptText()
{
var model = new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = "real",
RunId = 42,
ChangeToken = "stable-token",
ProjectName = "Project",
BookTitle = "Book",
Scenes =
[
new StoryIntelligenceExperienceSceneState
{
Id = "scene-result-1",
SceneNumber = 1,
Title = "Analysed scene",
Summary = "Safe summary",
Location = new StoryIntelligenceExperienceLocationState { Id = "location-result-room", Name = "Room" }
}
]
};
var json = JsonSerializer.Serialize(model, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
Assert(json.Contains("\"changeToken\":\"stable-token\"", StringComparison.Ordinal), "Snapshot contract should expose a change token.");
Assert(!json.Contains("sourceText", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose raw manuscript source text.");
Assert(!json.Contains("rawResponseJson", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose raw AI responses.");
Assert(!json.Contains("prompt", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose prompts.");
}
static void IllustrationBulkRetryResultReportsCounts()
{
var result = new IllustrationBulkRetryResult(12, 4, 3);
Assert(result.Message.Contains("12 failed illustration", StringComparison.Ordinal), "Queued count should be reported.");
Assert(result.Message.Contains("4 skipped", StringComparison.Ordinal), "Skipped count should be reported.");
Assert(result.Message.Contains("3 reached the retry limit", StringComparison.Ordinal), "Retry-limit count should be reported.");
}
static void IllustrationBulkRetrySkipsBlockingDuplicateStableCodes()
{
var failed = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 27,
StableCode = "asset-photo-print",
Status = IllustrationLibraryStatuses.Failed,
IsActive = true,
ParentItemID = null
};
var planned = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 38,
StableCode = "asset-photo-print",
Status = IllustrationLibraryStatuses.Planned,
IsActive = true,
ParentItemID = null
};
var approved = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 55,
StableCode = "asset-photo-print",
Status = IllustrationLibraryStatuses.Approved,
IsActive = true,
ParentItemID = null
};
Assert(IllustrationLibraryService.ResolveBulkRetryTarget(failed, [failed, approved]) is null, "Failed duplicate should be skipped when an approved active sibling exists.");
Assert(IllustrationLibraryService.ResolveBulkRetryTarget(failed, [failed, planned])?.IllustrationLibraryItemID == planned.IllustrationLibraryItemID, "Failed duplicate should queue its planned active sibling.");
}
static void IllustrationProviderReportsMissingImageModel()
{
var provider = new OpenAIIllustrationImageProvider(
new HttpClient(),
Options.Create(new StoryIntelligenceOptions { ApiKey = "configured-for-test" }),
NullLogger<OpenAIIllustrationImageProvider>.Instance);
Assert(!provider.IsConfigured, "Provider should not be configured without an image model.");
Assert(provider.MissingConfiguration.Count == 1, "Only image model should be missing when API key exists.");
Assert(provider.MissingConfiguration[0].Contains("image-generation model", StringComparison.OrdinalIgnoreCase), "Missing image model should be named.");
}
static void IllustrationProviderOmitsGptImageResponseFormat()
{
string? payload = null;
var handler = new CaptureImageRequestHandler(request =>
{
payload = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent(
"""{"error":{"message":"test failure","code":"invalid_request_error"}}""",
Encoding.UTF8,
"application/json")
};
});
var provider = new OpenAIIllustrationImageProvider(
new HttpClient(handler),
Options.Create(new StoryIntelligenceOptions
{
ApiKey = "configured-for-test",
ImageGenerationModel = "gpt-image-1",
ImageGenerationSize = "1024x1024"
}),
NullLogger<OpenAIIllustrationImageProvider>.Instance);
var result = provider.GenerateAsync(
new IllustrationImageGenerationRequest(1, "asset-folded-letter", IllustrationLibraryCategories.Asset, "Prompt", IllustrationPromptBuilder.CurrentTemplateVersion),
CancellationToken.None).GetAwaiter().GetResult();
Assert(payload is not null, "Provider did not send a payload.");
Assert(!payload!.Contains("response_format", StringComparison.Ordinal), "GPT image payload should not include response_format.");
Assert(payload.Contains("\"model\":\"gpt-image-1\"", StringComparison.Ordinal), "Payload should include the configured image model.");
Assert(result.ErrorMessage?.Contains("invalid_request_error", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error code.");
Assert(result.ErrorMessage?.Contains("test failure", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error message.");
}
static void ScanReviewPostSupportsFullBookFormSubmissions()
{
var method = typeof(OnboardingController).GetMethod(nameof(OnboardingController.SaveScanReview));
Assert(method is not null, "Scan review post action could not be found.");
var formLimits = method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: false)
.OfType<RequestFormLimitsAttribute>()
.SingleOrDefault();
Assert(formLimits is not null, "Scan review post action should configure expanded form limits.");
Assert(formLimits!.ValueCountLimit >= 20000, "Scan review form value limit should support full-book previews.");
Assert(formLimits.KeyLengthLimit >= 4096, "Scan review form key length limit should support indexed review fields.");
var requestSize = method.GetCustomAttributes(typeof(RequestSizeLimitAttribute), inherit: false)
.OfType<RequestSizeLimitAttribute>()
.SingleOrDefault();
Assert(((IRequestSizeLimitMetadata?)requestSize)?.MaxRequestBodySize >= 25 * 1024 * 1024, "Scan review request size limit should support full-book previews.");
}
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
{
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
Assert(!view.Contains("JsonSerializer.Serialize(Model", StringComparison.Ordinal), "Live experience page should not serialise the full view model into the Razor response.");
Assert(view.Contains("JsonSerializer.Serialize(bootModel", StringComparison.Ordinal), "Live experience page should serialise only boot data.");
}
static void LiveVisualisationStripsIllustrationDiagnostics()
{
var large = new string('x', 200_000);
var model = new StoryIntelligenceExperiencePrototypeViewModel
{
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
CharacterConsistencyReport = large
},
Scenes =
[
new StoryIntelligenceExperienceSceneState
{
Id = "scene-result-1",
Location = new StoryIntelligenceExperienceLocationState(),
Characters =
[
new StoryIntelligenceExperienceCharacterState
{
Id = "character-result-beth",
Name = "Beth",
ImageResolution = new StoryIntelligenceExperienceImageResolution
{
RejectedCandidates = large,
PreviousEvidence = large,
CurrentEvidence = large,
EvidenceWarnings = large
}
}
]
}
]
};
StoryIntelligenceVisualisationSnapshotService.StripLiveDiagnostics(model);
var json = JsonSerializer.Serialize(model, JsonOptions());
Assert(!json.Contains(large, StringComparison.Ordinal), "Live snapshot should not contain large illustration diagnostic strings.");
Assert(json.Length < 20_000, $"Live snapshot remained too large after stripping diagnostics: {json.Length:N0} chars.");
}
static void IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence()
{
var source = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
Assert(!source.Contains("previousStoredEvidence = existing?.EvidenceJson", StringComparison.Ordinal), "Evidence trace must not recursively embed previous EvidenceJson.");
Assert(source.Contains("evidenceHash", StringComparison.Ordinal), "Evidence trace should keep only a compact previous evidence hash.");
}
static JsonSerializerOptions JsonOptions()
=> new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
static void Assert(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException(message);
}
}
internal sealed class CaptureImageRequestHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(respond(request));
}