1013 lines
72 KiB
C#
1013 lines
72 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),
|
|
("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity),
|
|
("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene),
|
|
("Phase 21Q repairs production visualisation regressions", Phase21QRepairsProductionVisualisationRegressions),
|
|
("Phase 21Q view keeps panels bounded and real controls hidden", Phase21QViewKeepsPanelsBoundedAndRealControlsHidden),
|
|
("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly),
|
|
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
|
|
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments),
|
|
("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.Unknown, $"lad should not default to Child without explicit child-age evidence, 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") == "Hatchback", "Ford Fiesta should classify as Hatchback.");
|
|
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 void Phase21OPreservesAdultDefaultsAndRealModeContinuity()
|
|
{
|
|
var unknownNamed = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches."], []);
|
|
var explicitGirl = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mia", ["schoolgirl"], []);
|
|
var teacher = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson is a teacher."], []);
|
|
var examiner = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("driving examiner", ["female driving examiner"], []);
|
|
|
|
Assert(unknownNamed.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"Unknown age should remain Unknown before illustration fallback, got {unknownNamed.AgeBand}.");
|
|
Assert(explicitGirl.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Schoolgirl should remain explicit child evidence, got {explicitGirl.AgeBand}.");
|
|
Assert(teacher.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Teacher title/role should infer Adult, got {teacher.AgeBand}.");
|
|
Assert(examiner.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Female examiner should infer Feminine, got {examiner.Presentation}.");
|
|
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Lane names should classify as Road.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks may fall back to neutral generic car artwork.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict.");
|
|
|
|
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml"));
|
|
Assert(view.Contains("Return to Import", StringComparison.Ordinal), "Real visualisation should expose a return-to-import link.");
|
|
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls should be gated to simulation and replay modes.");
|
|
}
|
|
|
|
static void Phase21PUsesOneAuthoritativeCurrentVisualisationScene()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..");
|
|
var viewModel = File.ReadAllText(Path.Combine(root, "PlotLine", "ViewModels", "StoryIntelligenceExperiencePrototypeViewModels.cs"));
|
|
var snapshotService = File.ReadAllText(Path.Combine(root, "PlotLine", "Services", "StoryIntelligenceVisualisationSnapshotService.cs"));
|
|
var browser = File.ReadAllText(Path.Combine(root, "PlotLine", "wwwroot", "js", "story-intelligence-experience-prototype.js"));
|
|
var view = File.ReadAllText(Path.Combine(root, "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml"));
|
|
|
|
Assert(viewModel.Contains("public bool IsCurrent", StringComparison.Ordinal), "Scene snapshots must expose an authoritative current-scene marker.");
|
|
Assert(snapshotService.Contains("IsCurrent = isCurrent", StringComparison.Ordinal), "Snapshot builder must mark the selected current scene.");
|
|
Assert(snapshotService.Contains("followingCount: 0", StringComparison.Ordinal), "Real import snapshots must not include following scenes that can pull the browser ahead.");
|
|
Assert(snapshotService.Contains("Story Intelligence snapshot synchronisation", StringComparison.Ordinal), "Snapshot builder must log synchronisation evidence.");
|
|
Assert(browser.Contains("scene?.isCurrent", StringComparison.Ordinal), "Browser must choose the current scene from the authoritative marker.");
|
|
Assert(browser.Contains("Story Intelligence browser synchronisation", StringComparison.Ordinal), "Browser must log synchronisation evidence.");
|
|
Assert(view.Contains("Scenes = Model.Scenes", StringComparison.Ordinal), "Initial real page payload must include the same scene collection as polled snapshots.");
|
|
}
|
|
|
|
static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false, string hairColour = "Unknown")
|
|
=> new(
|
|
ageBand,
|
|
presentation,
|
|
hairColour,
|
|
"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 void Phase21QRepairsProductionVisualisationRegressions()
|
|
{
|
|
var unknownNamed = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches from the lane."], []);
|
|
var mrColin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mr Colin Webb", ["Mr Colin Webb is a driving examiner."], []);
|
|
var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is sixteen.", "Maggie pushed back her red hair."], []);
|
|
var darkHairCandidate = Metadata("char-young-adult-brunette-observer", "OlderTeen", "Feminine", hairColour: "Brown");
|
|
var teenCandidate = Metadata("char-young-helper", "YoungTeen", "Androgynous");
|
|
|
|
Assert(unknownNamed.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"Unknown named age must remain Unknown, got {unknownNamed.AgeBand}.");
|
|
Assert(mrColin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Mr Colin should infer Masculine, got {mrColin.Presentation}.");
|
|
Assert(mrColin.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner title/profession should infer Adult, got {mrColin.AgeBand}.");
|
|
Assert(Rejected(mrColin, teenCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Adult title/profession should reject child/teen candidates.");
|
|
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie red-hair evidence should be explicit, got {maggie.HairColour}.");
|
|
Assert(Rejected(maggie, darkHairCandidate).Any(reason => reason.Contains("hair colour hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-hair evidence must invalidate dark-haired assignments.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road/Lane.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "Hospital", "Derelict hospital should remain Hospital rather than generic interior.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bench beside the road") == "Road", "Bench context beside road should not become indoor room artwork.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Lane/Road must not use car park artwork.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not use flat interior artwork.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("blue VW Golf") == "Hatchback", "Blue VW Golf should classify as Hatchback.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetColour("blue VW Golf") == "Blue", "Blue VW Golf should retain explicit colour.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("TR6 sports car") == "SportsCar", "TR6 should remain a sports car.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not use letter/document imagery.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "SportsCar"), "Blue hatchback must not silently use red sports-car imagery.");
|
|
|
|
var serviceSource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
|
|
Assert(serviceSource.Contains("DemandGenerationThreshold = 1", StringComparison.Ordinal), "Live import demand should queue on first repeated missing archetype pass, not after dozens of scenes.");
|
|
Assert(serviceSource.Contains("ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Location and asset demand must run from the visualisation pipeline.");
|
|
Assert(serviceSource.Contains("MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence)", StringComparison.Ordinal), "Existing assignments must be revalidated against current evidence.");
|
|
Assert(serviceSource.Contains("BalancedSkinToneFallback", StringComparison.Ordinal), "Unresolved appearance should use deterministic project/book diversity rather than one global skin-tone default.");
|
|
|
|
var librarySource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/IllustrationLibraryServices.cs"));
|
|
var applyApprovedSource = librarySource[librarySource.IndexOf("public async Task ApplyApprovedIllustrationsAsync", StringComparison.Ordinal)..];
|
|
Assert(!applyApprovedSource.Contains("StarterCodes.Contains", StringComparison.Ordinal), "Generated demand illustrations must not be filtered out by the runtime approved-image lookup.");
|
|
}
|
|
|
|
static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden()
|
|
{
|
|
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
|
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js"));
|
|
var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css"));
|
|
|
|
Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation.");
|
|
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
|
|
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay.");
|
|
Assert(script.Contains("relationships.slice(0, 2)", StringComparison.Ordinal), "Relationship changes should be capped to the two most relevant visible cards.");
|
|
Assert(script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Relationship overflow should be indicated compactly.");
|
|
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
|
|
Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll.");
|
|
Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths.");
|
|
}
|
|
|
|
static void Phase21RReplayUsesPersistedDataOnly()
|
|
{
|
|
var snapshotService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
|
|
var matcher = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
|
|
var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs"));
|
|
|
|
Assert(snapshotService.Contains("BuildReplaySnapshotAsync", StringComparison.Ordinal), "Replay mode needs a dedicated snapshot builder.");
|
|
Assert(snapshotService.Contains("Mode = replayMode ? \"replay\" : \"real\"", StringComparison.Ordinal), "Replay snapshots should be explicitly labelled.");
|
|
Assert(snapshotService.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Replay must use durable Story Memory illustration assignments only.");
|
|
Assert(!snapshotService.Contains("await illustrationMatcher.ApplyPersistedCharacterIllustrationsAsync", StringComparison.Ordinal), "Replay should not use the legacy character-only matcher.");
|
|
Assert(!snapshotService.Contains("await illustrationMatcher.ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Snapshot rendering must not create location/asset demand.");
|
|
Assert(snapshotService.Contains("AiCallsDuringReplay = 0", StringComparison.Ordinal), "Replay diagnostics must state that no AI calls are made.");
|
|
Assert(development.Contains("BuildReplaySnapshotAsync(importSessionId.Value, userId)", StringComparison.Ordinal), "Development route must call the replay snapshot builder for mode=replay.");
|
|
Assert(development.Contains("RebuildStoryIntelligenceExperience", StringComparison.Ordinal), "Development route should expose a saved-analysis rebuild action.");
|
|
Assert(development.Contains("AI calls", StringComparison.Ordinal), "Rebuild action should report that it made no AI calls.");
|
|
}
|
|
|
|
static void Phase21SUsesDurableStoryMemoryAssignments()
|
|
{
|
|
var sql = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql"));
|
|
var models = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Models/StoryMemoryModels.cs"));
|
|
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs"));
|
|
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
|
|
var runner = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/PersistedStoryIntelligenceRunner.cs"));
|
|
var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs"));
|
|
|
|
Assert(sql.Contains("StoryMemoryCharacters", StringComparison.Ordinal), "Durable Story Memory character table is missing.");
|
|
Assert(sql.Contains("StoryMemoryLocations", StringComparison.Ordinal), "Durable Story Memory location table is missing.");
|
|
Assert(sql.Contains("StoryMemoryAssets", StringComparison.Ordinal), "Durable Story Memory asset table is missing.");
|
|
Assert(sql.Contains("StoryMemoryIllustrationAssignments", StringComparison.Ordinal), "Durable illustration assignment table is missing.");
|
|
Assert(sql.Contains("StoryMemoryIllustrationDemands", StringComparison.Ordinal), "Durable illustration demand table is missing.");
|
|
Assert(models.Contains("StoryMemoryAppearancePreferences", StringComparison.Ordinal), "Import-level appearance preference model is missing.");
|
|
Assert(service.Contains("IStoryMemoryService", StringComparison.Ordinal), "Durable Story Memory service is missing.");
|
|
Assert(service.Contains("QueueDemand = false", StringComparison.Ordinal) || service.Contains("QueueDemand", StringComparison.Ordinal), "Demand queueing must be explicit.");
|
|
Assert(service.Contains("InferCharacterEvidence", StringComparison.Ordinal), "Durable updater must use deterministic character evidence extraction.");
|
|
Assert(service.Contains("CharacterHardRejections", StringComparison.Ordinal), "Resolver must revalidate hard character constraints.");
|
|
Assert(service.Contains("LocationCompatible", StringComparison.Ordinal), "Resolver must enforce location compatibility.");
|
|
Assert(service.Contains("AssetCompatible", StringComparison.Ordinal), "Resolver must enforce asset compatibility.");
|
|
Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync", StringComparison.Ordinal), "Snapshot must read durable persisted assignments.");
|
|
Assert(runner.Contains("storyMemory.ProcessSceneResultAsync", StringComparison.Ordinal), "Importer must update Story Memory as scenes are persisted.");
|
|
Assert(development.Contains("StoryMemoryDiagnostics", StringComparison.Ordinal), "Development diagnostics endpoint is missing.");
|
|
Assert(development.Contains("RebuildImportSessionAsync", StringComparison.Ordinal), "Development rebuild action must call the durable memory rebuild.");
|
|
}
|
|
|
|
static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
|
|
{
|
|
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
|
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js"));
|
|
var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css"));
|
|
var progressViewModel = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/ViewModels/OnboardingViewModels.cs"));
|
|
|
|
Assert(view.Contains("mode=replay", StringComparison.Ordinal), "Replay links should open the visualisation in replay mode.");
|
|
Assert(view.Contains("data-replay-chapter", StringComparison.Ordinal), "Replay scene browser should expose chapter selection.");
|
|
Assert(view.Contains("data-detail-panel", StringComparison.Ordinal), "Dense side-panel items should have an expanded detail presentation.");
|
|
Assert(view.Contains("Rebuild Visualisation from Saved Analysis", StringComparison.Ordinal), "Replay mode should expose the development rebuild action.");
|
|
Assert(script.Contains("const replayMode = root.dataset.mode === \"replay\"", StringComparison.Ordinal), "Browser script should distinguish replay mode.");
|
|
Assert(script.Contains("setupReplayBrowser", StringComparison.Ordinal), "Replay browser setup was not found.");
|
|
Assert(script.Contains("root.dataset.failedChapters", StringComparison.Ordinal), "Failed chapters should appear as replay browser gaps.");
|
|
Assert(script.Contains("if (!realMode || !snapshotUrl || state.terminal) return;", StringComparison.Ordinal), "Replay must not poll live snapshot endpoints.");
|
|
Assert(css.Contains(".story-exp-replay-browser", StringComparison.Ordinal), "Replay browser styling was not found.");
|
|
Assert(css.Contains("scrollbar-width: none", StringComparison.Ordinal), "Side-panel native scrollbar tracks should be suppressed.");
|
|
Assert(!css.Contains(".story-exp-insight-list {\n display: grid;\n gap: 9px;\n min-height: 0;\n overflow: auto", StringComparison.Ordinal), "Nested insight lists should not create native scrollbars.");
|
|
Assert(progressViewModel.Contains("public int? ImportSessionID", StringComparison.Ordinal), "Import progress page should carry the import session for replay links.");
|
|
}
|
|
|
|
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));
|
|
}
|