1259 lines
101 KiB
C#
1259 lines
101 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 does not retry exhausted billing quota", StoryIntelligenceDoesNotRetryExhaustedBillingQuota),
|
|
("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),
|
|
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
|
|
("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
|
|
("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules),
|
|
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
|
|
("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") == "Lane", "Cock Hill Lane must classify as Lane, 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") == "DrivingLicence", "Driving licence should classify as DrivingLicence.");
|
|
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") == "Certificate", "Pass certificate should classify as Certificate.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "DrivingLicence", "Driving licence should classify as DrivingLicence.");
|
|
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") == "Lane", "Lane names should classify as Lane.");
|
|
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", string skinTone = "Unknown")
|
|
=> new(
|
|
ageBand,
|
|
presentation,
|
|
hairColour,
|
|
skinTone,
|
|
"Unknown",
|
|
"Unknown",
|
|
"Unknown",
|
|
isUnknownFigure,
|
|
false,
|
|
!isUnknownFigure,
|
|
IllustrationPromptBuilder.CurrentTemplateVersion,
|
|
"Test");
|
|
|
|
static StoryMemoryCharacterAttribute Attribute(string type, string value, decimal confidence, bool isExplicit, int sceneResultId)
|
|
=> new()
|
|
{
|
|
AttributeType = type,
|
|
NormalisedValue = value,
|
|
Confidence = confidence,
|
|
IsExplicit = isExplicit,
|
|
SourceSceneResultID = sceneResultId
|
|
};
|
|
|
|
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 StoryIntelligenceDoesNotRetryExhaustedBillingQuota()
|
|
{
|
|
var client = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs"));
|
|
|
|
Assert(client.Contains("IsInsufficientQuota(responseText)", StringComparison.Ordinal), "OpenAI insufficient_quota responses must be detected before retrying.");
|
|
Assert(client.Contains("request was not retried", StringComparison.Ordinal), "OpenAI insufficient_quota responses should fail clearly without retry churn.");
|
|
}
|
|
|
|
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") == "Lane", "Cock Hill Lane should classify as Lane.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "DerelictHospital", "Derelict hospital should remain typed 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, 1)", StringComparison.Ordinal), "Relationship changes should be capped to the most relevant visible card.");
|
|
Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not hide readable content behind '+N more' overflow controls.");
|
|
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 Phase21TRepairsDurableCharacterUnderstanding()
|
|
{
|
|
var maggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
|
|
"Maggie",
|
|
[
|
|
Attribute("AgeBand", "Adult", 0.88m, false, 10),
|
|
Attribute("AgeBand", "OlderTeen", 0.98m, true, 20),
|
|
Attribute("Presentation", "Feminine", 0.98m, true, 1),
|
|
Attribute("HairColour", "Red", 0.82m, true, 20),
|
|
Attribute("HairLength", "Long", 0.72m, true, 20)
|
|
]);
|
|
Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Explicit later teen evidence should win, got {maggie.AgeBand}.");
|
|
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Durable red-hair evidence should reach resolver, got {maggie.HairColour}.");
|
|
Assert(maggie.HairLength == "Long", $"Durable hair length should reach resolver, got {maggie.HairLength}.");
|
|
|
|
var unknown = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches."], []);
|
|
var childCandidate = Metadata("char-demand-child-feminine", "Child", "Feminine");
|
|
Assert(Rejected(unknown, childCandidate).Any(reason => reason.Contains("unknown age", StringComparison.OrdinalIgnoreCase)), "Unknown named characters must reject child portraits.");
|
|
|
|
var adult = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson is a teacher."], []);
|
|
var teenCandidate = Metadata("char-demand-olderteen-feminine", "OlderTeen", "Feminine");
|
|
Assert(Rejected(adult, teenCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Adult title/occupation evidence must reject teen portraits.");
|
|
|
|
var olderTeen = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Colin", ["Colin is a learner driver at the driving test centre."], []);
|
|
var childMasculineCandidate = Metadata("char-demand-child-masculine", "Child", "Masculine");
|
|
Assert(Rejected(olderTeen, childMasculineCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Older teen evidence must reject child portraits.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("Group of girls"), "Group of girls should not reserve a unique character portrait.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("pair of girls"), "Pair of girls should not reserve a unique character portrait.");
|
|
|
|
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs"));
|
|
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
|
|
Assert(service.Contains("BuildCrossSceneIdentityAliases", StringComparison.Ordinal), "Rebuild should repair cross-scene descriptive-to-named character identities from saved analysis.");
|
|
Assert(service.Contains("ExtensionEvidence(sceneCharacter.ExtensionData)", StringComparison.Ordinal), "Saved scene appearance extension data must be included in durable evidence.");
|
|
Assert(snapshot.Contains("NonPovRole(memoryCharacter.Role)", StringComparison.Ordinal), "Carried memory characters must not keep POV role outside the resolved current POV.");
|
|
Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Live and replay must apply durable Story Memory assignments.");
|
|
}
|
|
|
|
static void Phase21UExtractsMandatoryCharacterAppearanceFixtures()
|
|
{
|
|
const string source = """
|
|
Maggie, a late teenage girl, pushed back her long natural red hair while Beth, her late teenage sister, checked the mirror.
|
|
Beth was initially blonde, and Rosie was a blonde young woman.
|
|
Miss Davies smiled. "Miss Davies, but you can call me Zoe," she said.
|
|
Mum waited outside with Mrs Patterson while Colin, David, Graham, Rick and Rob spoke to the driving examiner.
|
|
""";
|
|
var facts = CharacterAppearanceExtractionService.Extract(source, ["Maggie", "Beth", "Rosie", "Miss Davies", "Zoe", "Mum", "Mrs Patterson", "Colin", "David", "Graham", "Rick", "Rob"]);
|
|
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Maggie" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Red"), "Maggie's red-hair evidence was not extracted.");
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Maggie" && fact.AttributeType == "HairLength" && fact.NormalisedValue == "Long"), "Maggie's long-hair evidence was not extracted.");
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Beth" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Blonde"), "Beth's blonde evidence was not extracted.");
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Rosie" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Blonde"), "Rosie's blonde evidence was not extracted.");
|
|
Assert(!CharacterAppearanceExtractionService.Extract("Beth went next door and mumbled, 'Mum.'", ["Beth", "Mum"]).Any(fact => fact.CanonicalName == "Beth" && fact.AttributeType == "AgeBand" && fact.NormalisedValue == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult), "Nearby Mum text must not age Beth into a mature adult.");
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Zoe Davies" && fact.AttributeType == "Alias" && fact.NormalisedValue == "miss davies"), "Miss Davies should resolve as an alias of Zoe Davies.");
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Zoe Davies" && fact.AttributeType == "Alias" && fact.NormalisedValue == "zoe"), "Zoe should resolve as an alias of Zoe Davies.");
|
|
|
|
var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is a late teenage girl.", "Maggie has long natural red hair."], []);
|
|
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is a late teenage girl.", "Beth was initially blonde."], []);
|
|
var rosie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Rosie", ["Rosie is a blonde young woman."], []);
|
|
|
|
Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Maggie should be OlderTeen, got {maggie.AgeBand}.");
|
|
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie should be Red, got {maggie.HairColour}.");
|
|
var maggieMemory = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Maggie", [
|
|
new StoryMemoryCharacterAttribute { AttributeType = "HairColour", NormalisedValue = StoryIntelligenceIllustrationCompatibility.HairColours.Red, Confidence = 0.95m, IsExplicit = true }
|
|
]);
|
|
Assert(maggieMemory.SkinTone == StoryIntelligenceIllustrationCompatibility.SkinTones.Light, $"Natural red hair should default unknown skin tone to Light, got {maggieMemory.SkinTone}.");
|
|
Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Beth should be OlderTeen, got {beth.AgeBand}.");
|
|
Assert(beth.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Blonde, $"Beth should be Blonde, got {beth.HairColour}.");
|
|
Assert(rosie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, $"Rosie should be YoungAdult, got {rosie.AgeBand}.");
|
|
Assert(rosie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Blonde, $"Rosie should be Blonde, got {rosie.HairColour}.");
|
|
}
|
|
|
|
static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules()
|
|
{
|
|
var zoeAliases = CharacterAppearanceExtractionService.AliasMap("""Miss Davies, but you can call me Zoe.""", ["Miss Davies", "Zoe"]);
|
|
Assert(zoeAliases.TryGetValue("miss davies", out var missCanonical) && missCanonical == "Zoe Davies", "Miss Davies should canonicalise to Zoe Davies.");
|
|
Assert(zoeAliases.TryGetValue("zoe", out var zoeCanonical) && zoeCanonical == "Zoe Davies", "Zoe should canonicalise to Zoe Davies.");
|
|
|
|
var mum = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mum", ["Mum is mother of teenage daughters."], []);
|
|
var examiner = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("driving examiner", ["the driving examiner"], []);
|
|
var redHairedMaggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Maggie", [
|
|
new StoryMemoryCharacterAttribute { AttributeType = "HairColour", NormalisedValue = StoryIntelligenceIllustrationCompatibility.HairColours.Red, Confidence = 0.95m, IsExplicit = true }
|
|
]);
|
|
var bethFromLeakyEvidence = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Beth", [
|
|
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, Confidence = 0.9m, IsExplicit = true, EvidenceSummary = "Beth goes next door and mumbles 'Mum'" },
|
|
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, Confidence = 0.9m, IsExplicit = true, EvidenceSummary = "No fifteen-year-old girl with a future." },
|
|
new StoryMemoryCharacterAttribute { AttributeType = "Presentation", NormalisedValue = StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, Confidence = 0.95m, IsExplicit = true, EvidenceSummary = "I checked my look in the mirror." }
|
|
]);
|
|
var mrsWithTeenContext = StoryMemoryService.ApplyImportContextToEvidence(
|
|
StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Mrs Patterson", [
|
|
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, Confidence = 0.88m, IsExplicit = true }
|
|
]),
|
|
"Mrs Patterson",
|
|
new StoryMemoryImportContext(StoryMemoryAppearancePreferences.PredominantlyLight, "OlderTeen", "1980s", "UK"));
|
|
var rosieBirthday = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Rosie", [
|
|
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.Child, Confidence = 0.98m, IsExplicit = true, EvidenceSummary = "Deterministic evidence inference" },
|
|
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, Confidence = 0.90m, IsExplicit = true, EvidenceSummary = "She may be eighteen now, but she is still the baby of the group." }
|
|
]);
|
|
var alisonPronoun = StoryIntelligenceIllustrationCompatibility.NormalizePresentation("She was a nurse at Selly Oak Hospital.");
|
|
var alisonName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Alison", ["Alison"], []);
|
|
var sophie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sophie", ["Sophie glared at me. She called the pub hers."], ["Sophie is Rebecca's sister."]);
|
|
var gregFromRosieAction = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Greg", ["Greg offers to take Rosie to hospital."], []);
|
|
var childCandidate = Metadata("char-child", "Child", "Feminine");
|
|
var darkRedCandidate = Metadata("char-red-dark", "OlderTeen", "Feminine", hairColour: "Red", skinTone: "Dark");
|
|
Assert(bethFromLeakyEvidence.AgeBand != StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Leaked family-role evidence should not age Beth up, got {bethFromLeakyEvidence.AgeBand}.");
|
|
Assert(bethFromLeakyEvidence.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Name/title guard should repair leaked Beth presentation, got {bethFromLeakyEvidence.Presentation}.");
|
|
Assert(mrsWithTeenContext.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Adult-role titles must not be overridden by primary age context, got {mrsWithTeenContext.AgeBand}.");
|
|
Assert(rosieBirthday.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, $"Rosie's explicit eighteen-year-old evidence must beat weak child leakage, got {rosieBirthday.AgeBand}.");
|
|
Assert(alisonPronoun == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Direct she/her pronoun evidence should infer Feminine, got {alisonPronoun}.");
|
|
Assert(alisonName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Common feminine name Alison should infer Feminine, got {alisonName.Presentation}.");
|
|
Assert(sophie.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Sophie/she/sister evidence should infer Feminine, got {sophie.Presentation}.");
|
|
Assert(gregFromRosieAction.Presentation != StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Greg must not inherit feminine presentation from action text mentioning Rosie.");
|
|
Assert(Rejected(mum, childCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Parent role should reject child portraits.");
|
|
Assert(Rejected(redHairedMaggie, darkRedCandidate).Any(reason => reason.Contains("skin tone hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-haired light-skin evidence must reject dark-skin generated portraits.");
|
|
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("girl", ["girl"], []).AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, "Girl alone must not mean Child.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should be Lane.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should be Road.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Street", "Road"), "Side streets should be able to reuse road artwork while demand-specific street art is pending.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "Street"), "Named roads should be able to reuse street artwork when it is the closest generated road-family image.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("car interior") == "VehicleInterior", "Car interior should be VehicleInterior.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom") == "Bathroom", "Bathroom should be Bathroom.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("waiting room") == "WaitingRoom", "Waiting room should be WaitingRoom.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "VehicleInterior must not use building art.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("blue VW Golf") == "Hatchback", "Blue VW Golf should be Hatchback.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetColour("blue VW Golf") == "Blue", "Blue VW Golf should be Blue.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("red TR6") == "SportsCar", "TR6 should be SportsCar.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("lipstick") == "Lipstick", "Lipstick should be Lipstick.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("skirt") == "Skirt", "Skirt should be Skirt.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "SportsCar"), "Hatchback must not use sports-car art when demand generation is available.");
|
|
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var onboarding = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligence.cshtml"));
|
|
var storyMemory = File.ReadAllText(Path.Combine(root, "Services/StoryMemoryServices.cs"));
|
|
var persistedRunner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
|
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-experience-prototype.js"));
|
|
var css = File.ReadAllText(Path.Combine(root, "wwwroot/css/story-intelligence-experience-prototype.css"));
|
|
var resetSql = File.ReadAllText(Path.Combine(root, "Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql"));
|
|
var scenePrompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
|
|
|
|
Assert(onboarding.Contains("name=\"appearancePreference\"", StringComparison.Ordinal), "Import setup must ask for appearance preference.");
|
|
Assert(onboarding.Contains("name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Import setup must ask for primary age group.");
|
|
Assert(onboarding.Contains("name=\"storyEra\"", StringComparison.Ordinal), "Import setup must ask for era.");
|
|
Assert(onboarding.Contains("name=\"storyLocation\"", StringComparison.Ordinal), "Import setup must ask for location.");
|
|
Assert(onboarding.Contains("<select class=\"form-control\" name=\"appearancePreference\"", StringComparison.Ordinal), "Appearance guidance must be a dropdown, not a radio list.");
|
|
Assert(onboarding.Contains("<select class=\"form-control\" name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Primary age group must be a dropdown.");
|
|
Assert(onboarding.Contains("<select class=\"form-control\" name=\"storyEra\"", StringComparison.Ordinal), "Story era must be a dropdown.");
|
|
Assert(onboarding.Contains("<select class=\"form-control\" name=\"storyLocation\"", StringComparison.Ordinal), "Story location must be a dropdown.");
|
|
Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction.");
|
|
Assert(storyMemory.Contains("SkinToneInstruction(skinTone)", StringComparison.Ordinal), "Demand generation must use hard skin-tone wording.");
|
|
Assert(storyMemory.Contains("HairColours.Red", StringComparison.Ordinal) && storyMemory.Contains("SkinTones.Light", StringComparison.Ordinal), "Red-hair demand evidence must force light skin when skin is otherwise unknown.");
|
|
Assert(storyMemory.Contains("ApplyPrimaryAgeContext", StringComparison.Ordinal), "Primary age-group context must guide generic adult/unknown character ages.");
|
|
Assert(persistedRunner.Contains("QueueDemand = true", StringComparison.Ordinal), "Live imports must queue missing illustration demand, not only record it.");
|
|
Assert(persistedRunner.Contains("MaxDemandToQueue = 24", StringComparison.Ordinal), "Live imports must cap automatic demand generation.");
|
|
Assert(storyMemory.Contains("UpsertPlannedAsync(specification", StringComparison.Ordinal), "Durable Story Memory demand must create reusable illustration library items.");
|
|
Assert(storyMemory.Contains("QueueAsync(planned.IllustrationLibraryItemID", StringComparison.Ordinal), "Durable Story Memory demand must queue generated library items for the worker.");
|
|
Assert(storyMemory.Contains("demand.IllustrationLibraryItemId", StringComparison.Ordinal), "Demand fallback assignments must retain queued item IDs so generated images can attach later.");
|
|
Assert(storyMemory.Contains("var categoryCap = maxDemandToQueue;", StringComparison.Ordinal), "Automatic demand generation should cap in-flight work per category, not starve locations and assets behind a divided cap.");
|
|
Assert(storyMemory.Contains("or IllustrationLibraryStatuses.Generating);", StringComparison.Ordinal), "Demand generation cap must count only in-flight jobs, not already generated preserved images.");
|
|
Assert(!storyMemory.Contains("or IllustrationLibraryStatuses.Generated\r\n or IllustrationLibraryStatuses.Approved", StringComparison.Ordinal), "Generated or approved preserved images must not permanently fill the demand queue cap.");
|
|
Assert(storyMemory.Contains("\"Flyover\"", StringComparison.Ordinal) && storyMemory.Contains("concrete overpass", StringComparison.Ordinal), "Location prompts must distinguish road-under-flyover scenes from generic roads.");
|
|
Assert(storyMemory.Contains("semanticType is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Road-family locations must be treated as exterior scenes.");
|
|
Assert(storyMemory.Contains("evidence.HairColour", StringComparison.Ordinal), "Character demand keys/specs must include durable hair colour evidence.");
|
|
Assert(storyMemory.Contains("Maximum one primary POV", StringComparison.Ordinal) || storyMemory.Contains("ResolveScenePov", StringComparison.Ordinal), "Story Memory must use a single POV resolver.");
|
|
Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not use hidden '+N more' overflow controls.");
|
|
Assert(css.Contains("-webkit-line-clamp: 3", StringComparison.Ordinal), "Insight body text should be clamped inside its card.");
|
|
Assert(resetSql.Contains("StoryIntelligence_DevelopmentAccountReset", StringComparison.Ordinal), "Phase 21U reset procedure is missing.");
|
|
Assert(resetSql.Contains("ProjectHardDelete_ForOwner", StringComparison.Ordinal), "Reset must reuse owner-scoped project hard delete.");
|
|
Assert(scenePrompt.Contains("characterAppearance", StringComparison.OrdinalIgnoreCase), "Scene prompt must request structured character appearance.");
|
|
Assert(File.Exists(Path.Combine(root, "Sql/144_Phase21V_StoryMemoryImportContext.sql")), "Import context SQL migration is missing.");
|
|
}
|
|
|
|
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-rebuild {\n position: fixed;", StringComparison.Ordinal), "Replay rebuild controls should be fixed so they do not resize the whole screen.");
|
|
Assert(css.Contains(".story-exp-panel:has(.story-exp-insight-list:empty)", StringComparison.Ordinal), "Empty side panels should collapse instead of reserving large blank space.");
|
|
Assert(css.Contains("grid-template-columns: minmax(180px, 0.42fr) minmax(660px, 1.55fr) minmax(380px, 0.84fr);", StringComparison.Ordinal), "Right insight rail should be widened now that assets are not stage nodes.");
|
|
Assert(css.Contains("--story-side-body-font-size: 0.72rem;", StringComparison.Ordinal), "Left and right side panels should share body text sizing.");
|
|
Assert(!view.Contains("data-assets", StringComparison.Ordinal), "Asset visualisation lane should not be rendered on the main stage.");
|
|
Assert(script.Contains("Assets are recorded in insights/diagnostics but not rendered as stage nodes.", StringComparison.Ordinal), "Asset data should remain available without drawing asset stage nodes.");
|
|
Assert(script.Contains("width * 0.84 - size / 2", StringComparison.Ordinal), "Location should be a compact right-side anchor once asset nodes are removed.");
|
|
Assert(script.Contains("{ x: 0.60, y: 0.66, role: \"context\" }", StringComparison.Ordinal), "Character layout should use more of the stage width.");
|
|
Assert(script.Contains("presence-cluster", StringComparison.Ordinal), "Presence should be shown as a single location callout instead of many confusing location lines.");
|
|
Assert(css.Contains(".story-exp-zone--location .story-exp-zone-label {\n left: 80%;", StringComparison.Ordinal), "Location lane label should follow the compact right-side anchor.");
|
|
Assert(!css.Contains("grid-template-rows: minmax(218px, 1.35fr) minmax(185px, 1.08fr) auto", StringComparison.Ordinal), "Right rail must not use fixed-height panel tracks.");
|
|
Assert(!css.Contains("grid-template-rows: auto auto minmax(170px, 1fr) minmax(96px, 0.56fr)", StringComparison.Ordinal), "Left rail must not use fixed-height panel tracks.");
|
|
Assert(script.Contains("shouldUseCharacterPortrait", StringComparison.Ordinal), "Characters should use evidence cards until portrait evidence is strong enough.");
|
|
Assert(script.Contains("shouldUseAssetImage", StringComparison.Ordinal), "Assets should use text cards until an object-specific image is confident enough.");
|
|
Assert(script.Contains("appearance pending", StringComparison.Ordinal), "Character evidence cards should expose unresolved appearance state.");
|
|
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 void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
|
|
{
|
|
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 snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
|
|
|
|
Assert(!script.Contains("node.dataset.labelSide", StringComparison.Ordinal), "Character labels must not use dynamic side positioning.");
|
|
Assert(!css.Contains(".story-character[data-label-side=\"left\"]", StringComparison.Ordinal), "Left-floating character labels must be removed.");
|
|
Assert(!css.Contains(".story-character[data-layout-role=\"partner\"] .story-character__text", StringComparison.Ordinal), "Partner labels must not float beside portraits.");
|
|
Assert(css.Contains("top: calc(var(--node-size, 150px) + 8px)", StringComparison.Ordinal), "Character captions should be fixed below portraits.");
|
|
Assert(css.Contains("background: rgba(4, 10, 20, 0.91)", StringComparison.Ordinal), "Character caption opacity should be increased for readability.");
|
|
Assert(css.Contains(".story-exp-insight span {\n display: none;", StringComparison.Ordinal), "Redundant insight tone headings should be hidden.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should classify as Lane.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should classify as Road.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Queens Court") == "Road", "Court should classify as Road.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Station Gardens") == "Road", "Gardens should classify as Road.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("driver seat inside the car") == "DriverSeat", "Driver seat should classify as DriverSeat.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Driving Test Centre waiting room") == "DrivingTestCentre", "Driving test centre should have its own location family.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "Vehicle interiors must not resolve to houses or buildings.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("passport") == "Passport", "Passport should not be Generic Object.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("sealed envelope") == "Envelope", "Envelope should not be Generic Object.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Highway Code book") == "DrivingManual", "Highway Code should classify as a driving manual.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("traffic sign") == "TrafficSign", "Traffic sign should classify as TrafficSign.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("wall clock") == "Clock", "Clock should classify as Clock.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("park bench") == "Bench", "Bench should classify as Bench.");
|
|
|
|
Assert(snapshot.Contains("type is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Road-family scene locations need a generic road fallback illustration.");
|
|
Assert(snapshot.Contains("\"loc-vehicle-interior\" => $\"{FallbackRoot}/location-vehicle-interior.svg\"", StringComparison.Ordinal), "Vehicle interiors need a non-building fallback illustration.");
|
|
Assert(snapshot.Contains("\"asset-document\" => $\"{FallbackRoot}/asset-document.svg\"", StringComparison.Ordinal), "Documents need a recognisable document fallback.");
|
|
Assert(snapshot.Contains("\"asset-traffic-sign\" => $\"{FallbackRoot}/asset-traffic-sign.svg\"", StringComparison.Ordinal), "Traffic signs need a recognisable fallback.");
|
|
}
|
|
|
|
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));
|
|
}
|