559 lines
30 KiB
C#

using System.Text.Json;
using System.Net;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PlotLine.Models;
using PlotLine.Services;
var tests = new (string Name, Action Test)[]
{
("Malformed confidence 0. is repaired to 0.0", RepairsTrailingDecimalConfidence),
("Markdown fences are stripped", StripsMarkdownFence),
("Unescaped backslash in evidence is repaired", RepairsUnescapedBackslash),
("Truncated JSON is rejected", RejectsTruncatedJson),
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput),
("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups),
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases),
("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations),
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
("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),
("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)
};
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 == "21D.2", "Illustration prompt template version should reflect the vivid art-direction reset.");
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("bright balanced lighting", StringComparison.OrdinalIgnoreCase), "Prompt did not include brighter 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 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 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));
}