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; #pragma warning disable CS8321 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), ("Chapter Structure confidence 0. 8 repairs and deserialises", ChapterStructureWhitespaceDecimalConfidenceRepairs), ("Chapter Structure confidence 7 .5 repairs when unambiguous", ChapterStructureWhitespaceBeforeDecimalRepairs), ("JSON repair leaves decimal-looking prose unchanged", JsonRepairLeavesDecimalProseUnchanged), ("Chapter Structure semantic validation still rejects out-of-range repaired confidence", ChapterStructureOutOfRangeRepairedConfidenceFailsValidation), ("Structural scene detection parser failure uses friendly diagnostics and retry", StructuralSceneDetectionUsesFriendlyParserDiagnosticsAndRetry), ("Chapter Structure boundary normaliser repairs gaps and overlaps", ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps), ("Chapter Structure boundary normaliser handles terminal ghost paragraph", ChapterStructureBoundaryNormaliserHandlesTerminalGhostParagraph), ("Chapter Structure boundary normaliser preserves genuine inverted ranges for validation", ChapterStructureBoundaryNormaliserPreservesGenuineInvertedRanges), ("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 prompt builder uses realistic image direction", IllustrationPromptBuilderUsesRealisticImageDirection), ("Illustration specification validation catches missing typed fields", IllustrationSpecificationValidationCatchesMissingTypedFields), ("Illustration category validation rejects unknown values", IllustrationCategoryValidationRejectsUnknownValues), ("Illustration status transitions protect approval flow", IllustrationStatusTransitionsProtectApprovalFlow), ("Illustration status transitions support archive flow", IllustrationStatusTransitionsSupportArchiveFlow), ("Illustration metadata flags fallback location labels", IllustrationMetadataFlagsFallbackLocationLabels), ("Illustration location matching rejects vehicle living room reuse", IllustrationLocationMatchingRejectsVehicleLivingRoomReuse), ("Illustration automatic generation policy blocks vague fallbacks", IllustrationAutomaticGenerationPolicyBlocksVagueFallbacks), ("Story Memory import image generation defaults are safe", StoryMemoryImportImageGenerationDefaultsAreSafe), ("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 visualisation archive is preserved", StoryIntelligenceVisualisationArchiveIsPreserved), ("Story Intelligence visualisation runtime is removed", StoryIntelligenceVisualisationRuntimeIsRemoved), ("Story Intelligence does not retry exhausted billing quota", StoryIntelligenceDoesNotRetryExhaustedBillingQuota), ("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), ("Lean import removes replay links while retaining progress", LeanImportRemovesReplayLinksWhileRetainingProgress), ("Story Intelligence progress does not prematurely report safe close", StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose), ("Structural import creates scenes before Word sync", StructuralImportCreatesScenesBeforeWordSync), ("Structural import creates first scene without placeholder", StructuralImportCreatesFirstSceneWithoutPlaceholder), ("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText), ("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText), ("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls), ("Core import scan stage defers scenes and characters", CoreImportScanStageDefersScenesAndCharacters), ("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters), ("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters), ("Core import remaps characters after review", CoreImportRemapsCharactersAfterReview), ("Scan review separates known matches from unresolved names", ScanReviewSeparatesKnownMatchesFromUnresolvedNames), ("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart), ("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement), ("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure), ("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes), ("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding), ("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures), ("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable), ("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates), ("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords), ("Character discovery rejects fragments and location names", CharacterDiscoveryRejectsFragmentsAndLocationNames), ("Manuscript scan preview keeps only auto-selected character candidates", ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates), ("Onboarding wizard keeps character pages inside one main step", OnboardingWizardKeepsCharacterPagesInsideOneMainStep), ("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext), ("Name Intelligence package contract is present", NameIntelligencePackageContractIsPresent), ("Name Intelligence reference data is copied on publish", NameIntelligenceReferenceDataIsCopiedOnPublish), ("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures), ("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired), ("Name Intelligence library defaults to all locales", NameIntelligenceLibraryDefaultsToAllLocales), ("Story Intelligence character defaults link proposed existing matches", StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches), ("Story Intelligence character defaults ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences), ("Story Intelligence character defaults keep descriptive identities", StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities), ("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch), ("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults), ("Story Intelligence character review reuses structured appearance aliases", StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases), ("Story Intelligence review evidence includes chapter scene labels", StoryIntelligenceReviewEvidenceIncludesChapterSceneLabels), ("Story Intelligence import enables project metric defaults before save", StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave), ("Project metrics add defaults backfills scene values", ProjectMetricsAddDefaultsBackfillsSceneValues), ("Story Intelligence scene prompt uses configured metrics", StoryIntelligenceScenePromptUsesConfiguredMetrics), ("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines), ("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys), ("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview), ("Story Intelligence discovers Plot Lines without preconfiguration", StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration), ("Story Intelligence matches existing Plot Lines without duplicates", StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates), ("Story Intelligence narrative review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs), ("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge), ("Word Companion replays live current scene to writing panes", WordCompanionReplaysLiveCurrentSceneToWritingPanes), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions), ("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation), ("StoryPosition derives canonical scene context identity", StoryPositionDerivesCanonicalSceneContextIdentity), ("Writing Context route uses canonical story context service", WritingContextRouteUsesCanonicalStoryContextService), ("Writing Context client follows canonical story position events", WritingContextClientFollowsCanonicalStoryPositionEvents), ("Writing Context preserves selected tab and avoids event loops", WritingContextPreservesSelectedTabAndAvoidsEventLoops), ("Writing Intent route uses canonical story context and planning data", WritingIntentRouteUsesCanonicalStoryContextAndPlanningData), ("Writing Intent client uses shared writing follow helper", WritingIntentClientUsesSharedWritingFollowHelper), ("Writing mode pages are isolated from global Word navigation", WritingModePagesAreIsolatedFromGlobalWordNavigation), ("Writing pair shares canonical follow infrastructure", WritingPairSharesCanonicalFollowInfrastructure), ("Writing workspace presence scopes browser windows", WritingWorkspacePresenceScopesBrowserWindows), ("Writing workspace client uses session window identity and stable names", WritingWorkspaceClientUsesSessionIdentityAndStableNames), ("Writing workspace launcher uses progressive screen placement", WritingWorkspaceLauncherUsesProgressiveScreenPlacement), ("Writing workspace pages register roles without changing follow behavior", WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior), ("Word Companion exposes writing workspace launch actions", WordCompanionExposesWritingWorkspaceLaunchActions), ("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers), ("Word Companion canonical markers drive runtime follow", WordCompanionCanonicalMarkersDriveRuntimeFollow), ("Word Companion existing project book linking remains available", WordCompanionExistingProjectBookLinkingRemainsAvailable), ("Word Companion document identity survives reconnect", WordCompanionDocumentIdentitySurvivesReconnect), ("Word Companion multiscreen launch and follow remain reachable", WordCompanionMultiscreenLaunchAndFollowRemainReachable), ("Word Companion modes remain distinct", WordCompanionModesRemainDistinct), ("Writing pages do not require Story Intelligence", WritingPagesDoNotRequireStoryIntelligence), ("Word Companion host script has valid tab mapping", WordCompanionHostScriptHasValidTabMapping), ("Writing screens show diagnostic empty states", WritingScreensShowDiagnosticEmptyStates), ("Word Companion three-screen flow logs connection identifiers", WordCompanionThreeScreenFlowLogsConnectionIdentifiers), ("Admin Story Intelligence reset returns to diagnostics with messages", AdminStoryIntelligenceResetReturnsToDiagnosticsWithMessages), ("Project hard delete clears Story Intelligence dependencies in transaction", ProjectHardDeleteClearsStoryIntelligenceDependenciesInTransaction), ("Permanent project delete keeps archive and name safety gates", PermanentProjectDeleteKeepsArchiveAndNameSafetyGates), ("Word manuscript cleanup classifies PlotDirector markers by tag only", WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly), ("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent), ("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink), ("Word manuscript unlink keeps Story Intelligence data untouched", WordManuscriptUnlinkKeepsStoryIntelligenceDataUntouched), ("Word manuscript cleanup remains relinkable and idempotent", WordManuscriptCleanupRemainsRelinkableAndIdempotent) }; foreach (var test in tests) { test.Test(); Console.WriteLine($"PASS {test.Name}"); } static void AdminStoryIntelligenceResetReturnsToDiagnosticsWithMessages() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var controller = File.ReadAllText(Path.Combine(root, "Controllers/AdminController.cs")); var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceDevelopmentResetService.cs")); Assert(controller.Contains("try", StringComparison.Ordinal) && controller.Contains("catch (Exception ex)", StringComparison.Ordinal), "Reset action should convert failures into a diagnostics-page error."); Assert(controller.Contains("TempData[result.Success ? \"AdminMessage\" : \"AdminError\"]", StringComparison.Ordinal), "Reset action should show a clear success or error message."); Assert(controller.Contains("return RedirectToAction(nameof(StoryIntelligenceDiagnostics))", StringComparison.Ordinal), "Reset action should return to the stable diagnostics route."); Assert(service.Contains("IProjectRepository projects", StringComparison.Ordinal), "Reset should delegate project deletion to the shared hard-delete repository path."); Assert(!service.Contains("DELETE FROM dbo.StoryIntelligenceRuns", StringComparison.Ordinal), "Reset should not delete run parents before hard-delete dependency cleanup."); } static void ProjectHardDeleteClearsStoryIntelligenceDependenciesInTransaction() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs")); var cascadeMigration = File.ReadAllText(Path.Combine(root, "Sql/159_Phase25D_ManuscriptSourceHardDeleteCascades.sql")); Assert(repository.Contains("using var transaction = connection.BeginTransaction()", StringComparison.Ordinal), "Project hard delete should be wrapped by a repository transaction."); Assert(repository.Contains("ProjectHardDeleteStoryIntelligenceDependencyCleanupSql", StringComparison.Ordinal), "Project hard delete should run scoped Story Intelligence dependency cleanup before the stored procedure."); Assert(repository.Contains("AllowActiveProject = false", StringComparison.Ordinal), "Archived-project permanent delete must refuse active projects before cleanup."); Assert(repository.Contains("THROW 52002, 'Only archived projects can be permanently deleted.'", StringComparison.Ordinal), "Hard delete cleanup should enforce the archive gate before deleting child rows."); Assert(repository.Contains("CharacterIntelligenceEvidenceEvents", StringComparison.Ordinal), "Hard delete cleanup should remove character intelligence evidence rows."); Assert(repository.Contains("CharacterIntelligenceProfiles", StringComparison.Ordinal), "Hard delete cleanup should remove character intelligence profile rows."); Assert(repository.Contains("StoryMemoryProcessedSceneResults", StringComparison.Ordinal), "Hard delete cleanup should remove durable story memory scene-result rows."); Assert(repository.Contains("StoryMemoryImportPreferences", StringComparison.Ordinal), "Hard delete cleanup should remove durable story memory import rows."); Assert(repository.Contains("WHERE ProjectID = @ProjectID", StringComparison.Ordinal), "Hard delete cleanup must be scoped by project id."); Assert(cascadeMigration.Contains("FK_SceneManuscriptSources_Scenes", StringComparison.Ordinal) && cascadeMigration.Contains("REFERENCES dbo.Scenes(SceneID) ON DELETE CASCADE", StringComparison.Ordinal), "Scene manuscript source text should not block permanent scene deletion."); Assert(cascadeMigration.Contains("FK_ManuscriptStructuralChapterSources_Chapters", StringComparison.Ordinal) && cascadeMigration.Contains("REFERENCES dbo.Chapters(ChapterID) ON DELETE CASCADE", StringComparison.Ordinal), "Structural chapter source text should not block permanent chapter deletion."); } static void PermanentProjectDeleteKeepsArchiveAndNameSafetyGates() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var controller = File.ReadAllText(Path.Combine(root, "Controllers/ProjectsController.cs")); var service = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs")); Assert(controller.Contains("GetArchivedOwnerProjectAsync(projectId)", StringComparison.Ordinal), "Permanent delete should require an archived owner project."); Assert(controller.Contains("string.Equals(confirmationName?.Trim(), project.ProjectName, StringComparison.Ordinal)", StringComparison.Ordinal), "Permanent delete should require an exact project-name confirmation."); Assert(service.Contains("return ProjectHardDeleteResult.ActiveProject()", StringComparison.Ordinal), "Permanent delete should reject non-archived projects."); Assert(service.Contains("Blocking database dependency", StringComparison.Ordinal), "Permanent delete failures should report the blocking database dependency."); } 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 StoryPositionRejectsInvalidRuntimeScenePositions() { Assert(StoryPosition.FromRuntimeCurrentScene(new WordCompanionRuntimeCurrentSceneRequest { DocumentGuid = Guid.NewGuid(), ProjectId = 1, BookId = 2, ChapterId = 3, SceneId = 0 }) is null, "Runtime scene positions require a valid SceneId."); Assert(StoryPosition.FromTimelineSelection(0, 2, 3, 4) is null, "Story positions require a valid ProjectId."); } static void StoryPositionBuildsCanonicalRuntimeSceneNavigation() { var response = new WordCompanionRuntimeCurrentSceneResponse { ProjectId = 10, BookId = 20, ChapterId = 30, SceneId = 40, SceneUrl = "/Scenes/Edit/40" }; var position = response.Position; Assert(position is not null, "Runtime response should expose a canonical StoryPosition."); Assert(position!.ProjectId == 10 && position.BookId == 20 && position.ChapterId == 30 && position.SceneId == 40, "Runtime response position IDs were not preserved."); Assert(position.SceneEditUrl == "/Scenes/Edit/40", "Scene positions must use the canonical Scene Edit route."); } static void StoryPositionDerivesCanonicalSceneContextIdentity() { var model = new SceneEditViewModel { SceneID = 7, ChapterID = 6, Project = new Project { ProjectID = 4 }, Book = new Book { BookID = 5 }, Chapter = new Chapter { ChapterID = 6 } }; var position = StoryPosition.FromScene(model); Assert(position is not null, "Scene workspace should expose a canonical StoryPosition."); Assert(position!.ProjectId == 4 && position.BookId == 5 && position.ChapterId == 6 && position.SceneId == 7, "Scene workspace position IDs were not preserved."); } static void WritingContextRouteUsesCanonicalStoryContextService() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs")); var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs")); Assert(controller.Contains("[Route(\"Writing\")", StringComparison.Ordinal), "Writing Context controller should expose the /Writing route prefix."); Assert(controller.Contains("[HttpGet(\"Context\")", StringComparison.Ordinal), "Writing Context shell route is missing."); Assert(controller.Contains("[HttpGet(\"Context/Scene\")", StringComparison.Ordinal), "Writing Context partial route is missing."); Assert(controller.Contains("IStoryContextService storyContext", StringComparison.Ordinal), "Writing Context must consume IStoryContextService."); Assert(controller.Contains("storyContext.GetForSceneAsync", StringComparison.Ordinal), "Writing Context scene requests must load canonical story context."); Assert(!controller.Contains("ISceneService", StringComparison.Ordinal), "Writing Context should not bypass canonical story context with ISceneService."); Assert(viewModel.Contains("StoryContextViewModel? Context", StringComparison.Ordinal), "Writing Context display model should carry canonical story context."); } static void WritingContextClientFollowsCanonicalStoryPositionEvents() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js")); var view = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml")); Assert(view.Contains("data-writing-context", StringComparison.Ordinal), "Writing Context shell should expose a stable script root."); Assert(view.Contains("data-writing-mode=\"context\"", StringComparison.Ordinal), "Writing Context should identify itself as a writing-mode page."); Assert(view.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Writing Context should load the shared writing follow helper."); Assert(initializer.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Context should initialize the shared writing follow helper."); Assert(script.Contains("window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Writing Context must subscribe to canonical browser story-position events."); Assert(script.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Writing Context must bridge SignalR story-position events."); Assert(script.Contains("fetch(url", StringComparison.Ordinal), "Writing Context should refresh context content without full page navigation."); Assert(initializer.Contains("[data-writing-context-scene-select]", StringComparison.Ordinal), "Writing Context manual fallback selector should be wired."); Assert(script.Contains("publishChanged", StringComparison.Ordinal), "Manual scene selection should publish canonical story-position changes."); } static void WritingContextPreservesSelectedTabAndAvoidsEventLoops() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js")); Assert(initializer.Contains("plotdirector.writingContext.activeTab", StringComparison.Ordinal), "Writing Context should remember the selected tab."); Assert(script.Contains("activateTab(tab)", StringComparison.Ordinal), "Writing Context should restore the selected tab after content refresh."); Assert(script.Contains("position.sceneId === currentSceneId", StringComparison.Ordinal), "Writing Context should ignore duplicate current-scene events."); Assert(script.Contains("position.sceneId === loadingSceneId", StringComparison.Ordinal), "Writing Context should ignore duplicate in-flight scene events."); Assert(!script.Contains("window.location.assign", StringComparison.Ordinal) && !script.Contains("window.location.href", StringComparison.Ordinal), "Writing Context must not navigate away when Word changes scene."); } static void WritingIntentRouteUsesCanonicalStoryContextAndPlanningData() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs")); var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs")); var contentView = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml")); Assert(controller.Contains("[HttpGet(\"Intent\")", StringComparison.Ordinal), "Writing Intent shell route is missing."); Assert(controller.Contains("[HttpGet(\"Intent/Scene\")", StringComparison.Ordinal), "Writing Intent partial route is missing."); Assert(controller.Contains("IStoryContextService storyContext", StringComparison.Ordinal), "Writing Intent must share canonical story context service injection."); Assert(controller.Contains("storyContext.GetForSceneAsync", StringComparison.Ordinal), "Writing Intent scene requests must use canonical story context."); Assert(controller.Contains("IWriterWorkspaceService writerWorkspace", StringComparison.Ordinal), "Writing Intent should reuse existing writer planning data."); Assert(controller.Contains("writerWorkspace.GetChapterWorkflowAsync", StringComparison.Ordinal), "Writing Intent should load chapter workflow planning data."); Assert(controller.Contains("projectId.HasValue && chapterWorkflow.Project.ProjectID != projectId.Value", StringComparison.Ordinal), "Writing Intent should reject mismatched project scope."); Assert(controller.Contains("bookId.HasValue && chapterWorkflow.Book.BookID != bookId.Value", StringComparison.Ordinal), "Writing Intent should reject mismatched book scope."); Assert(viewModel.Contains("public sealed class WritingIntentDisplayViewModel", StringComparison.Ordinal), "Writing Intent display model is missing."); Assert(viewModel.Contains("StoryContextViewModel? Context", StringComparison.Ordinal), "Writing Intent display should carry canonical story context."); Assert(viewModel.Contains("ChapterWorkflowViewModel? ChapterWorkflow", StringComparison.Ordinal), "Writing Intent display should carry chapter planning workflow."); Assert(contentView.Contains("No current scene. Showing chapter intent.", StringComparison.Ordinal), "Writing Intent should support chapter-only fallback."); Assert(contentView.Contains("No scene-specific plan. Showing chapter intent.", StringComparison.Ordinal), "Writing Intent should prefer chapter intent when scene planning is sparse."); } static void WritingIntentClientUsesSharedWritingFollowHelper() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var helper = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-intent.js")); var view = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml")); var contentView = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml")); Assert(view.Contains("data-writing-intent", StringComparison.Ordinal), "Writing Intent shell should expose a stable script root."); Assert(view.Contains("data-writing-mode=\"intent\"", StringComparison.Ordinal), "Writing Intent should identify itself as a writing-mode page."); Assert(view.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Writing Intent should load the shared writing follow helper."); Assert(initializer.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Intent should initialize the shared helper."); Assert(initializer.Contains("plotdirector.writingIntent.activeTab", StringComparison.Ordinal), "Writing Intent should remember the selected tab."); Assert(initializer.Contains("defaultEndpoint: \"/Writing/Intent/Scene\"", StringComparison.Ordinal), "Writing Intent should refresh from its partial route."); Assert(initializer.Contains("allowChapterOnly: true", StringComparison.Ordinal), "Writing Intent should accept canonical chapter-only positions."); Assert(helper.Contains("window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Writing Intent should receive canonical story-position events through the shared helper."); Assert(helper.Contains("fetch(url", StringComparison.Ordinal), "Writing Intent should update in place."); Assert(helper.Contains("position.sceneId === currentSceneId", StringComparison.Ordinal), "Writing Intent should ignore duplicate current-scene events."); Assert(helper.Contains("position.sceneId === loadingSceneId", StringComparison.Ordinal), "Writing Intent should ignore duplicate in-flight events."); Assert(helper.Contains("position.bookId && position.bookId !== scopedBookId", StringComparison.Ordinal), "Writing Intent should ignore unrelated scoped book events."); Assert(helper.Contains("publishChanged", StringComparison.Ordinal), "Writing Intent manual fallback should publish canonical story-position changes."); Assert(contentView.Contains("data-writing-intent-scene-select", StringComparison.Ordinal), "Writing Intent manual scene selector should be wired."); Assert(contentView.Contains("data-writing-intent-chapter-select", StringComparison.Ordinal), "Writing Intent manual chapter selector should be wired."); Assert(!helper.Contains("window.location.assign", StringComparison.Ordinal) && !helper.Contains("window.location.href", StringComparison.Ordinal), "Writing Intent must not navigate away when Word changes scene."); } static void WritingModePagesAreIsolatedFromGlobalWordNavigation() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var site = File.ReadAllText(Path.Combine(root, "wwwroot/js/site.js")); Assert(site.Contains("document.querySelector(\"[data-writing-mode]\")", StringComparison.Ordinal), "Global Word follow should recognize writing-mode markers."); Assert(site.Contains("path === \"/writing/context\"", StringComparison.Ordinal), "Writing Context route should be exempt from global scene navigation."); Assert(site.Contains("path === \"/writing/intent\"", StringComparison.Ordinal), "Writing Intent route should be exempt from global scene navigation."); Assert(site.Contains("window.location.assign(url)", StringComparison.Ordinal), "Normal pages should retain existing Word follow navigation."); Assert(site.Contains("safeRelativeUrl(payload?.sceneUrl || payload?.SceneUrl || \"\", \"/Scenes/Edit/\")", StringComparison.Ordinal), "Normal scene follow should still target canonical Scene Edit URLs."); } static void WritingPairSharesCanonicalFollowInfrastructure() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var context = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js")); var intent = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-intent.js")); var helper = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); Assert(context.Contains("source: \"WritingContext\"", StringComparison.Ordinal), "Writing Context should identify its canonical event source."); Assert(intent.Contains("source: \"WritingIntent\"", StringComparison.Ordinal), "Writing Intent should identify its canonical event source."); Assert(context.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Context should use shared follow initialization."); Assert(intent.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Intent should use shared follow initialization."); Assert(helper.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "The shared helper should bridge Word story-position events for both pages."); Assert(helper.Contains("document.addEventListener(window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Both pages should react to the same canonical browser event."); } static void WritingWorkspacePresenceScopesBrowserWindows() { var service = new WritingWorkspacePresenceService(); var word = new WordCompanionPresenceStatus { UserID = 9, IsConnected = true, DocumentOpen = true, LinkedProjectID = 10, LinkedBookID = 20 }; service.RegisterAsync(9, "launcher-1", new WritingWindowRegistration { BrowserWindowID = "launcher", ProjectID = 10, BookID = 20, WritingRole = WritingWorkspaceRoles.Launcher, CurrentRoute = "/Writer" }, word).GetAwaiter().GetResult(); service.RegisterAsync(9, "context-1", new WritingWindowRegistration { BrowserWindowID = "context", ProjectID = 10, BookID = 20, WritingRole = WritingWorkspaceRoles.Context, CurrentRoute = "/Writing/Context" }, word).GetAwaiter().GetResult(); service.RegisterAsync(9, "intent-1", new WritingWindowRegistration { BrowserWindowID = "intent", ProjectID = 10, BookID = 20, WritingRole = WritingWorkspaceRoles.Intent, CurrentRoute = "/Writing/Intent" }, word).GetAwaiter().GetResult(); service.RegisterAsync(9, "other-context", new WritingWindowRegistration { BrowserWindowID = "other-context", ProjectID = 10, BookID = 21, WritingRole = WritingWorkspaceRoles.Context, CurrentRoute = "/Writing/Context" }, word).GetAwaiter().GetResult(); var status = service.GetStatusAsync(9, new WritingWorkspaceScope { ProjectID = 10, BookID = 20 }, word).GetAwaiter().GetResult(); Assert(status.WordCompanionConnected && status.WordDocumentOpen, "Writing workspace should include Word Companion presence."); Assert(status.ContextOpen && status.IntentOpen, "Matching Context and Intent windows should be open."); Assert(status.Context?.BrowserWindowID == "context", "Workspace status should choose the matching book's Context window."); Assert(status.Windows.All(window => window.BookID == 20), "Workspace status should not include another book's windows."); service.DisconnectAsync("context-1", word).GetAwaiter().GetResult(); status = service.GetStatusAsync(9, new WritingWorkspaceScope { ProjectID = 10, BookID = 20 }, word).GetAwaiter().GetResult(); Assert(!status.ContextOpen && status.IntentOpen, "Disconnected Context should be removed without affecting Intent."); } static void WritingWorkspaceClientUsesSessionIdentityAndStableNames() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace.js")); var service = File.ReadAllText(Path.Combine(root, "Services/WritingWorkspacePresenceService.cs")); Assert(script.Contains("sessionStorage.getItem(storageKeys.windowId)", StringComparison.Ordinal), "Browser window IDs must come from sessionStorage."); Assert(script.Contains("sessionStorage.setItem(storageKeys.windowId", StringComparison.Ordinal), "Browser window IDs must be stored per tab/window."); Assert(!script.Contains("localStorage.getItem(storageKeys.windowId)", StringComparison.Ordinal), "Browser window IDs must not be shared through localStorage."); Assert(script.Contains("PlotDirector${role}_${suffix}", StringComparison.Ordinal), "Context and Intent windows should use stable named targets."); Assert(script.Contains("Book_${bookId}", StringComparison.Ordinal), "Stable writing window names should be book scoped."); Assert(script.Contains("windowRefs = new Map()", StringComparison.Ordinal), "Launcher-created window references should be retained for focus."); Assert(script.Contains("RequestWritingWindowFocus", StringComparison.Ordinal), "Launcher should fall back to SignalR focus requests."); Assert(script.Contains("connection.onreconnected", StringComparison.Ordinal), "Writing workspace windows should re-register after SignalR reconnect."); Assert(service.Contains("ActiveTimeout = TimeSpan.FromSeconds(45)", StringComparison.Ordinal) && service.Contains("MarkStaleOfflineAsync", StringComparison.Ordinal), "Writing workspace presence should expire stale windows."); } static void WritingWorkspaceLauncherUsesProgressiveScreenPlacement() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace.js")); var launcher = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace-launcher.js")); var view = File.ReadAllText(Path.Combine(root, "Views/Writer/Index.cshtml")); Assert(script.Contains("getScreenDetails", StringComparison.Ordinal), "Workspace launcher should use the Window Management API when available."); Assert(script.Contains("window-management", StringComparison.Ordinal), "Workspace launcher should check/request window-management permission."); Assert(script.Contains("sort((a, b) => a.left - b.left", StringComparison.Ordinal), "Screen topology should be derived from physical coordinates."); Assert(script.Contains("mode: \"three-screen\"", StringComparison.Ordinal), "Three-screen layout decision is missing."); Assert(script.Contains("key: `screen-${index}`", StringComparison.Ordinal), "Detected displays should receive stable option keys."); Assert(script.Contains("displayByPreference", StringComparison.Ordinal), "Launcher should resolve logical and physical display preferences."); Assert(script.Contains("preferences.contextDisplay", StringComparison.Ordinal), "Context display preference is missing."); Assert(script.Contains("preferences.intentDisplay", StringComparison.Ordinal), "Intent display preference is missing."); Assert(script.Contains("mode: \"two-screen\"", StringComparison.Ordinal), "Two-screen fallback is missing."); Assert(script.Contains("mode: \"single-screen\"", StringComparison.Ordinal), "One-screen fallback is missing."); Assert(script.Contains("localStorage.setItem(storageKeys.preferences", StringComparison.Ordinal), "Remembered layout preferences are missing."); Assert(script.Contains("window.open(url, name, popupFeatures(display))", StringComparison.Ordinal), "Writing windows should be opened from explicit launcher clicks with placement features."); Assert(script.Contains("window.open(buildWritingUrl(role, scope), stableWindowName(role, scope), popupFeatures(display))", StringComparison.Ordinal), "Focus actions should open missing windows on the selected display."); Assert(script.Contains("getScreenLayout(Boolean(options.requestScreens), options.displayPreferences || {})", StringComparison.Ordinal), "Start action should pass selected display preferences into layout detection."); Assert(script.Contains("openRoleWindow(roles.intent, resolvedScope, layout.intentDisplay)", StringComparison.Ordinal), "Start should open Intent on the selected display instead of converting the launcher window."); Assert(!script.Contains("window.location.assign(buildWritingUrl(roles.intent", StringComparison.Ordinal), "Start should leave the launcher visible while opening the selected writing windows."); Assert(launcher.Contains("requestScreens: true", StringComparison.Ordinal), "Screen permission should be requested from the Start action."); Assert(launcher.Contains("workspace.getScreenLayout(true, selectedDisplayPreferences())", StringComparison.Ordinal), "Detect displays action should request physical display details."); Assert(launcher.Contains("workspace.focusRole(role, scope, targetDisplay)", StringComparison.Ordinal), "Direct open/focus actions should use the selected display."); Assert(view.Contains("data-start-writing-workspace", StringComparison.Ordinal), "Writer dashboard launcher start button is missing."); Assert(view.Contains("data-writing-context-display", StringComparison.Ordinal), "Context display selector is missing."); Assert(view.Contains("data-writing-intent-display", StringComparison.Ordinal), "Intent display selector is missing."); Assert(view.Contains("data-detect-writing-displays", StringComparison.Ordinal), "Detect displays button is missing."); Assert(view.Contains("data-focus-writing-context", StringComparison.Ordinal), "Focus Context action is missing."); Assert(view.Contains("data-focus-writing-intent", StringComparison.Ordinal), "Focus Intent action is missing."); } static void WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var contextView = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml")); var intentView = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml")); var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); Assert(contextView.Contains("writingRole: window.PlotDirectorWritingWorkspace.roles.context", StringComparison.Ordinal), "Writing Context should register its workspace role."); Assert(intentView.Contains("writingRole: window.PlotDirectorWritingWorkspace.roles.intent", StringComparison.Ordinal), "Writing Intent should register its workspace role."); Assert(contextView.Contains("data-writing-mode=\"context\"", StringComparison.Ordinal), "Writing Context should remain a writing-mode page."); Assert(intentView.Contains("data-writing-mode=\"intent\"", StringComparison.Ordinal), "Writing Intent should remain a writing-mode page."); Assert(follow.Contains("registerWorkspaceRole", StringComparison.Ordinal), "Writing follow should refresh presence scope after Word updates."); Assert(follow.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Existing canonical Word follow should remain."); Assert(!follow.Contains("window.location.assign", StringComparison.Ordinal) && !follow.Contains("window.location.href", StringComparison.Ordinal), "Writing pages must still update in place without navigation."); } static void WordCompanionExposesWritingWorkspaceLaunchActions() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml")); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); Assert(view.Contains("data-open-writing-workspace", StringComparison.Ordinal), "Word Companion should expose Open Workspace."); Assert(view.Contains("data-open-writing-context", StringComparison.Ordinal), "Word Companion should expose Open Context."); Assert(view.Contains("data-open-writing-intent", StringComparison.Ordinal), "Word Companion should expose Open Intent."); Assert(script.Contains("writingWindowName", StringComparison.Ordinal), "Word Companion should use stable writing window names."); Assert(script.Contains("PlotDirector${role}_${suffix}", StringComparison.Ordinal), "Word Companion writing window names should be scoped."); Assert(script.Contains("openWritingWorkspaceButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Workspace button should be wired."); Assert(script.Contains("openWritingContextButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Context button should be wired."); Assert(script.Contains("openWritingIntentButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Intent button should be wired."); } static void WordCompanionWritingScreensReceiveResolvedContextIdentifiers() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs")); var contextView = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml")); var intentView = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml")); Assert(script.Contains("const chapterId = Number.isInteger(resolvedChapterId)", StringComparison.Ordinal), "Word Companion should prefer resolved chapter IDs for writing screen URLs."); Assert(script.Contains("const sceneId = Number.isInteger(resolvedSceneId)", StringComparison.Ordinal), "Word Companion should prefer resolved scene IDs for writing screen URLs."); Assert(script.Contains("url.searchParams.set(\"chapterId\", chapterId)", StringComparison.Ordinal), "Writing screen URLs should include chapterId when available."); Assert(script.Contains("url.searchParams.set(\"sceneId\", sceneId)", StringComparison.Ordinal), "Writing screen URLs should include sceneId when available."); Assert(script.Contains("url.searchParams.set(\"documentGuid\", documentGuid)", StringComparison.Ordinal), "Writing screen URLs should include documentGuid for diagnostics."); Assert(controller.Contains("Guid? documentGuid", StringComparison.Ordinal), "Writing routes should accept the Word document identifier."); Assert(contextView.Contains("data-document-guid=\"@Model.DocumentGuid\"", StringComparison.Ordinal), "Writing Context should retain the document identifier."); Assert(intentView.Contains("data-document-guid=\"@Model.DocumentGuid\"", StringComparison.Ordinal), "Writing Intent should retain the document identifier."); } static void WordCompanionCanonicalMarkersDriveRuntimeFollow() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs")); var repository = File.ReadAllText(Path.Combine(root, "Data/WordCompanionRepository.cs")); Assert(script.Contains("chapter: \"PD-CHAPTER-\"", StringComparison.Ordinal), "Canonical chapter marker prefix is missing."); Assert(script.Contains("scene: \"PD-SCENE-\"", StringComparison.Ordinal), "Canonical scene marker prefix is missing."); Assert(script.Contains("const classifyPlotDirectorContentControl", StringComparison.Ordinal), "Word marker classifier is missing."); Assert(script.Contains("paragraphAnchorId(paragraph, \"PD-CHAPTER\")", StringComparison.Ordinal), "Document scan should read canonical chapter IDs from Word anchors."); Assert(script.Contains("paragraphAnchorId(paragraph, \"PD-SCENE\")", StringComparison.Ordinal), "Document scan should read canonical scene IDs from Word anchors."); Assert(script.Contains("detectedSceneAnchorId = Number.isInteger(sceneAnchorId)", StringComparison.Ordinal), "Current scene detection should preserve canonical scene IDs."); Assert(script.Contains("postJson(\"/api/word-companion/runtime/current-scene\"", StringComparison.Ordinal), "Word Companion should notify PlotDirector of the runtime current scene."); Assert(script.Contains("sceneId: numericSceneId", StringComparison.Ordinal), "Runtime follow should submit the resolved canonical SceneID."); Assert(script.Contains("chapterId: numericChapterId", StringComparison.Ordinal), "Runtime follow should submit the resolved canonical ChapterID."); Assert(controller.Contains("presence.UpdateCurrentStoryPositionAsync", StringComparison.Ordinal), "Current-scene API should update writing workspace presence."); Assert(controller.Contains("BroadcastToCurrentUserAsync(\"storyPositionChanged\"", StringComparison.Ordinal), "Current-scene API should broadcast canonical story-position changes."); Assert(repository.Contains("\"dbo.WordCompanion_Runtime_CurrentScene_Validate\"", StringComparison.Ordinal), "Runtime current scene validation must remain stored-procedure based."); } static void WordCompanionExistingProjectBookLinkingRemainsAvailable() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml")); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs")); var service = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs")); var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs")); Assert(view.Contains("data-project-select", StringComparison.Ordinal), "Runtime Project selector should remain visible for unbound documents."); Assert(view.Contains("data-book-select", StringComparison.Ordinal), "Runtime Book selector should remain visible for unbound documents."); Assert(view.Contains("data-first-run-project-select", StringComparison.Ordinal), "Bind flow should let users choose an existing Project."); Assert(view.Contains("data-first-run-book-select", StringComparison.Ordinal), "Bind flow should let users choose an existing Book."); Assert(view.Contains("data-first-run-link", StringComparison.Ordinal), "Bind flow should expose a direct existing-book link action."); Assert(script.Contains("firstRunLinkButton?.addEventListener(\"click\", linkFirstRunManuscript)", StringComparison.Ordinal), "Existing-book link action should be wired."); Assert(script.Contains("postJson(\"/api/word-companion/manuscript/link\"", StringComparison.Ordinal), "Existing-book link action should call the link endpoint."); Assert(script.Contains("/api/word-companion/projects", StringComparison.Ordinal), "Project selector should load existing Projects."); Assert(script.Contains("/api/word-companion/projects/${projectId}/books", StringComparison.Ordinal), "Book selector should load existing Books."); Assert(controller.Contains("[HttpPost(\"manuscript/link\")", StringComparison.Ordinal), "Link endpoint is missing."); Assert(service.Contains("LinkManuscriptAsync", StringComparison.Ordinal), "Link service method is missing."); Assert(service.Contains("manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId())", StringComparison.Ordinal), "Existing-book linking should save a document binding directly."); Assert(!service[service.IndexOf("public async Task LinkManuscriptAsync", StringComparison.Ordinal)..service.IndexOf("public async Task UnlinkManuscriptAsync", StringComparison.Ordinal)].Contains("ImportManuscriptAsync", StringComparison.Ordinal), "Existing-book linking must not route through manuscript import."); Assert(repository.Contains("\"dbo.ManuscriptDocument_Save\"", StringComparison.Ordinal), "Document binding save must remain stored-procedure based."); } static void WordCompanionDocumentIdentitySurvivesReconnect() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var service = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs")); var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs")); Assert(script.Contains("plotdirector.word.documentGuid", StringComparison.Ordinal), "Word document GUID metadata key is missing."); Assert(script.Contains("plotdirector.word.boundBookId", StringComparison.Ordinal), "Word bound BookID metadata key is missing."); Assert(script.Contains("plotdirector.word.boundProjectId", StringComparison.Ordinal), "Word bound ProjectID metadata key is missing."); Assert(script.Contains("const readDocumentBinding", StringComparison.Ordinal), "Word host should read durable document binding metadata."); Assert(script.Contains("const writeDocumentBinding", StringComparison.Ordinal), "Word host should write durable document binding metadata."); Assert(script.Contains("settings.saveAsync", StringComparison.Ordinal), "Word document binding metadata must be saved into the document."); Assert(script.Contains("const ensureDocumentGuid", StringComparison.Ordinal), "Word host should mint a document GUID for new bindings."); Assert(script.Contains("/api/word-companion/runtime/book/${binding.bookId}?documentGuid=${encodeURIComponent(binding.documentGuid)}", StringComparison.Ordinal), "Reconnect should validate saved document binding against the server."); Assert(service.Contains("GetRuntimeBookAsync(int bookId, Guid documentGuid)", StringComparison.Ordinal), "Runtime book service is missing."); Assert(service.Contains("manuscriptDocuments.GetByGuidAsync(documentGuid, userId)", StringComparison.Ordinal), "Reconnect should resolve documents by saved GUID."); Assert(service.Contains("manuscriptDocuments.UpdateLastOpenedAsync", StringComparison.Ordinal), "Reconnect should touch LastOpened for the durable binding."); Assert(repository.Contains("\"dbo.ManuscriptDocument_GetByGuid\"", StringComparison.Ordinal), "Document GUID lookup must remain stored-procedure based."); Assert(repository.Contains("\"dbo.ManuscriptDocument_UpdateLastOpened\"", StringComparison.Ordinal), "Last-opened update must remain stored-procedure based."); } static void WordCompanionMultiscreenLaunchAndFollowRemainReachable() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml")); var hostScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); var contextView = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml")); var intentView = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml")); Assert(view.Contains("data-open-writing-workspace", StringComparison.Ordinal), "Word Companion should expose the workspace launch action."); Assert(view.Contains("data-open-writing-context", StringComparison.Ordinal), "Word Companion should expose the Context launch action."); Assert(view.Contains("data-open-writing-intent", StringComparison.Ordinal), "Word Companion should expose the Intent launch action."); Assert(hostScript.Contains("openWritingWindow(\"context\")", StringComparison.Ordinal), "Context launch should call the writing window helper."); Assert(hostScript.Contains("openWritingWindow(\"intent\")", StringComparison.Ordinal), "Intent launch should call the writing window helper."); Assert(hostScript.Contains("new URL(target === \"workspace\" ? \"/Writer\" : `/Writing/${target}`", StringComparison.Ordinal), "Writing windows should target the dedicated Context, Intent, and Workspace routes."); Assert(hostScript.Contains("url.searchParams.set(\"sceneId\", sceneId)", StringComparison.Ordinal), "Writing windows should receive the current canonical SceneID."); Assert(follow.Contains("connection.invoke(\"WatchWritingWorkspace\"", StringComparison.Ordinal), "Writing panes should attach to the Word Companion follow hub."); Assert(follow.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Writing panes should receive runtime scene changes."); Assert(follow.Contains("registerWindow", StringComparison.Ordinal), "Writing panes should register their multiscreen role."); Assert(contextView.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Context page should load shared follow script."); Assert(intentView.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Intent page should load shared follow script."); } static void WordCompanionModesRemainDistinct() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml")); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); Assert(view.Contains("data-first-run-wizard hidden", StringComparison.Ordinal), "Bind/import wizard should be hidden until a document is unbound."); Assert(view.Contains("data-linked-manuscript-card hidden", StringComparison.Ordinal), "Linked manuscript card should be hidden until a durable binding is present."); Assert(view.Contains("data-runtime-book-selectors", StringComparison.Ordinal), "Normal Project/Book selectors should remain available outside the bind wizard."); Assert(view.Contains("data-first-run-link", StringComparison.Ordinal), "Existing-book link action should be distinct from import."); Assert(view.Contains("data-first-run-scan", StringComparison.Ordinal), "Scan action should remain available for first-run import."); Assert(view.Contains("data-first-run-import", StringComparison.Ordinal), "Import action should remain a separate confirmation after scan."); Assert(view.Contains("data-unlink-word-document", StringComparison.Ordinal), "Unlink action should remain available for linked documents."); Assert(script.Contains("setHidden(runtimeBookSelectors, linked)", StringComparison.Ordinal), "Runtime selectors should only hide after binding."); Assert(script.Contains("setFirstRunMode(true)", StringComparison.Ordinal), "Unbound documents should enter the bind/import mode."); Assert(script.Contains("setFirstRunMode(false)", StringComparison.Ordinal), "Linked documents should leave the bind/import mode."); } static void WritingPagesDoNotRequireStoryIntelligence() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs")); var contextPartial = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingContextContent.cshtml")); var intentPartial = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml")); var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); Assert(controller.Contains("IStoryContextService storyContext", StringComparison.Ordinal), "Writing pages should use canonical story context."); Assert(controller.Contains("IWriterWorkspaceService writerWorkspace", StringComparison.Ordinal), "Writing Intent should use existing writer workspace planning data."); Assert(!controller.Contains("IStoryIntelligence", StringComparison.Ordinal), "Writing routes should not depend on Story Intelligence services."); Assert(!controller.Contains("Onboarding", StringComparison.Ordinal), "Writing routes should not depend on import onboarding."); Assert(contextPartial.Contains("@Model.EmptyTitle", StringComparison.Ordinal), "Writing Context should render a normal empty state when context is unavailable."); Assert(intentPartial.Contains("@Model.EmptyMessage", StringComparison.Ordinal), "Writing Intent should render a normal empty state when planning data is unavailable."); Assert(follow.Contains("No current Word scene has been received", StringComparison.Ordinal), "Writing panes should tolerate no current Word scene without Story Intelligence."); } static void WordCompanionHostScriptHasValidTabMapping() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml")); Assert(script.Contains("const normalizeTabName = (tabName) =>", StringComparison.Ordinal), "Tab-name compatibility helper is missing."); Assert(script.Contains("return \"review\";", StringComparison.Ordinal), "Legacy links tab should map to Review."); Assert(script.Contains("return \"maintenance\";", StringComparison.Ordinal), "Legacy structure tab should map to Maintenance."); Assert(!script.Contains("return \"review\";\n }\n }\n if", StringComparison.Ordinal), "Tab-name mapping contains an extra closing brace."); Assert(view.Split("word-companion-review-group").Length - 1 == 3, "Review panel should contain exactly the three review groups."); } static void WritingScreensShowDiagnosticEmptyStates() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs")); var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs")); var contextPartial = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingContextContent.cshtml")); var intentPartial = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml")); var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); Assert(viewModel.Contains("EmptyTitle", StringComparison.Ordinal), "Writing display models should carry an empty-state title."); Assert(viewModel.Contains("EmptyMessage", StringComparison.Ordinal), "Writing display models should carry an empty-state message."); Assert(controller.Contains("No current Word scene received", StringComparison.Ordinal), "Writing routes should not default to Waiting for Word forever."); Assert(controller.Contains("No Story Intelligence context is available", StringComparison.Ordinal), "Writing Context should report missing context clearly."); Assert(controller.Contains("No writing intent is available", StringComparison.Ordinal), "Writing Intent should report missing intent clearly."); Assert(contextPartial.Contains("@Model.EmptyTitle", StringComparison.Ordinal), "Writing Context empty state should render the diagnostic title."); Assert(intentPartial.Contains("@Model.EmptyMessage", StringComparison.Ordinal), "Writing Intent empty state should render the diagnostic message."); Assert(follow.Contains("setEmptyState", StringComparison.Ordinal), "Writing follow should update empty states after connection/fetch failures."); Assert(follow.Contains("No current Word scene has been received", StringComparison.Ordinal), "Writing follow should replace indefinite waiting with a diagnostic message."); } static void WordCompanionThreeScreenFlowLogsConnectionIdentifiers() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var hostController = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionHostController.cs")); var wordController = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs")); var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs")); var hostScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); Assert(hostController.Contains("Serving Word Companion host", StringComparison.Ordinal), "Word Companion host route should log which host page is served."); Assert(wordController.Contains("Word Companion current scene received", StringComparison.Ordinal), "Current-scene API should log received identifiers."); Assert(wordController.Contains("DocumentGuidPresent", StringComparison.Ordinal), "Current-scene API should log document identifier presence without exposing the value."); Assert(hub.Contains("Word Companion registered", StringComparison.Ordinal), "Companion SignalR registration should be logged."); Assert(hub.Contains("Writing window registered", StringComparison.Ordinal), "Context/Intent window registration should be logged."); Assert(hostScript.Contains("[Word Companion] Opening writing screen.", StringComparison.Ordinal), "Host UI should log direct writing screen opens."); Assert(follow.Contains("Loading writing position", StringComparison.Ordinal), "Context/Intent follow helper should log received positions."); } static void WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var classifierStart = script.IndexOf("const classifyPlotDirectorContentControl", StringComparison.Ordinal); var classifierEnd = script.IndexOf("window.PlotDirectorWordMarkers", classifierStart, StringComparison.Ordinal); Assert(classifierStart >= 0 && classifierEnd > classifierStart, "Word host should expose a stable PlotDirector marker classifier."); var classifier = script[classifierStart..classifierEnd]; Assert(classifier.Contains("const tag = String(control?.tag || \"\").trim().toUpperCase()", StringComparison.Ordinal), "Marker cleanup should classify content controls from the tag field."); Assert(script.Contains("chapter: \"PD-CHAPTER-\"", StringComparison.Ordinal), "Chapter marker prefix is missing."); Assert(script.Contains("scene: \"PD-SCENE-\"", StringComparison.Ordinal), "Scene marker prefix is missing."); Assert(classifier.Contains("tag.startsWith(plotDirectorMarkerPrefixes.chapter)", StringComparison.Ordinal), "Classifier should use the chapter tag prefix."); Assert(classifier.Contains("tag.startsWith(plotDirectorMarkerPrefixes.scene)", StringComparison.Ordinal), "Classifier should use the scene tag prefix."); Assert(!classifier.Contains("title", StringComparison.OrdinalIgnoreCase), "Marker cleanup must not delete arbitrary controls by display title."); } static void WordManuscriptCleanupPreservesContentControlContent() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); Assert(script.Contains("const removePlotDirectorContentControlsPreservingContent", StringComparison.Ordinal), "Clean unlink should have a dedicated marker removal path."); Assert(script.Contains("context.document.contentControls", StringComparison.Ordinal), "Clean unlink should inspect document content controls."); Assert(script.Contains("control.delete(true);", StringComparison.Ordinal), "PlotDirector marker cleanup must call Word.ContentControl.delete(true) to preserve visible content."); Assert(!script.Contains("control.delete(false)", StringComparison.Ordinal), "PlotDirector marker cleanup must not delete marker content."); Assert(!script.Contains("control.delete();", StringComparison.Ordinal), "PlotDirector marker cleanup must pass keepContent explicitly."); Assert(script.Contains("await inspectPlotDirectorMarkers();", StringComparison.Ordinal), "Clean unlink should verify marker counts after removal."); Assert(script.Contains("latestMarkerState.totalCount > 0", StringComparison.Ordinal), "Clean unlink should stop when PlotDirector markers remain."); Assert(script.Contains("cleanup verification failed", StringComparison.Ordinal), "Clean unlink should fail closed when marker verification cannot complete."); } static void WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml")); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); Assert(view.Contains("data-marker-binding-status", StringComparison.Ordinal), "Maintenance UI should report document binding status."); Assert(view.Contains("data-marker-chapter-count", StringComparison.Ordinal), "Maintenance UI should report chapter marker counts."); Assert(view.Contains("data-marker-scene-count", StringComparison.Ordinal), "Maintenance UI should report scene marker counts."); Assert(view.Contains("Unlink from PlotDirector", StringComparison.Ordinal), "Existing unlink behavior should remain visible as a separate action."); Assert(view.Contains("Hidden PlotDirector anchors will be left in place", StringComparison.Ordinal), "Existing unlink dialog should state markers are retained."); Assert(view.Contains("Clean and unlink document", StringComparison.Ordinal), "Full cleanup should be a separate explicit action."); Assert(view.Contains("data-clean-unlink-word-document", StringComparison.Ordinal), "Clean unlink button is missing."); Assert(view.Contains("data-clean-unlink-word-document-dialog", StringComparison.Ordinal), "Clean unlink confirmation dialog is missing."); Assert(view.Contains("Visible manuscript text, headings, formatting, comments, and tracked changes should remain", StringComparison.Ordinal), "Clean unlink dialog should describe content preservation."); Assert(script.Contains("requestUnlinkWordDocumentConfirmation", StringComparison.Ordinal), "Existing unlink confirmation path should remain."); Assert(script.Contains("requestCleanUnlinkWordDocumentConfirmation", StringComparison.Ordinal), "Clean unlink should use its own confirmation path."); } static void WordManuscriptUnlinkKeepsStoryIntelligenceDataUntouched() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var unlinkSql = File.ReadAllText(Path.Combine(root, "Sql/108_Phase16Q_ManuscriptUnlink.sql")); var sceneResultSql = File.ReadAllText(Path.Combine(root, "Sql/115_Phase20I_StoryIntelligenceResultPersistence.sql")); var importCommitSql = File.ReadAllText(Path.Combine(root, "Sql/121_Phase20V_StoryIntelligenceImportCommit.sql")); Assert(unlinkSql.Contains("CREATE OR ALTER PROCEDURE dbo.ManuscriptDocument_UnlinkBook", StringComparison.Ordinal), "Server unlink procedure is missing."); Assert(unlinkSql.Contains("IsLinkedToManuscript = 0", StringComparison.Ordinal), "Server unlink should clear manuscript link flags."); Assert(unlinkSql.Contains("ManuscriptDocumentID = NULL", StringComparison.Ordinal), "Server unlink should clear manuscript document references."); Assert(unlinkSql.Contains("IsActive = 0", StringComparison.Ordinal), "Server unlink should deactivate only the manuscript document binding row."); Assert(!unlinkSql.Contains("StoryIntelligence", StringComparison.Ordinal), "Server unlink must not modify Story Intelligence tables."); Assert(!unlinkSql.Contains("DELETE FROM dbo.Chapters", StringComparison.OrdinalIgnoreCase), "Server unlink must not delete chapters."); Assert(!unlinkSql.Contains("DELETE FROM dbo.Scenes", StringComparison.OrdinalIgnoreCase), "Server unlink must not delete scenes."); Assert(sceneResultSql.Contains("StartParagraph", StringComparison.Ordinal) && sceneResultSql.Contains("EndParagraph", StringComparison.Ordinal), "Story Intelligence scene result positions should remain database-backed."); Assert(importCommitSql.Contains("SourceStartParagraph", StringComparison.Ordinal) && importCommitSql.Contains("SourceEndParagraph", StringComparison.Ordinal), "Story Intelligence source positions should remain database-backed."); } static void WordManuscriptCleanupRemainsRelinkableAndIdempotent() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var service = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs")); var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs")); var saveSql = File.ReadAllText(Path.Combine(root, "Sql/094_Phase16B_ManuscriptBindingLinkedStructures.sql")); var unlinkSql = File.ReadAllText(Path.Combine(root, "Sql/108_Phase16Q_ManuscriptUnlink.sql")); Assert(script.Contains("const binding = currentDocumentBinding();", StringComparison.Ordinal), "Clean unlink should tolerate missing local binding metadata."); Assert(script.Contains("if (binding?.documentGuid && Number.isInteger(binding.bookId) && binding.bookId > 0)", StringComparison.Ordinal), "Server unlink should be skipped safely when no binding exists."); Assert(script.Contains("await removeDocumentBinding();", StringComparison.Ordinal), "Clean unlink should remove local binding settings after marker verification."); Assert(service.Contains("documentGuid = Guid.NewGuid();", StringComparison.Ordinal), "Relinking should mint a new document GUID when needed."); Assert(service.Contains("manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId())", StringComparison.Ordinal), "Relinking should still flow through the manuscript document repository."); Assert(repository.Contains("\"dbo.ManuscriptDocument_Save\"", StringComparison.Ordinal), "Relinking should still use the manuscript document save procedure."); Assert(saveSql.Contains("ManuscriptDocument_Save", StringComparison.Ordinal) && saveSql.Contains("IsActive", StringComparison.Ordinal), "Server save procedure should support a new active document binding."); Assert(unlinkSql.Contains("@DocumentGuid IS NULL", StringComparison.Ordinal) && unlinkSql.Contains("00000000-0000-0000-0000-000000000000", StringComparison.Ordinal), "Server unlink should tolerate repeated or metadata-free unlink requests."); } 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( 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( """ { "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 ChapterStructureWhitespaceDecimalConfidenceRepairs() { var result = StoryIntelligenceAiJsonParser.Parse( ChapterStructureJson("""0. 8"""), JsonOptions()); Assert(result.Success, result.ErrorMessage ?? "Chapter Structure parse failed."); Assert(result.Parsed?.SceneBoundaries?[0].Confidence == 0.8m, "Confidence was not repaired to 0.8."); Assert(result.RepairedJson?.Contains("\"confidence\": 0.8", StringComparison.Ordinal) == true, result.RepairedJson ?? "No repaired JSON was returned."); Assert(result.Warnings.Any(warning => warning.Contains("$.sceneBoundaries[0].confidence", StringComparison.Ordinal)), "Repair warning did not include JSON path."); } static void ChapterStructureWhitespaceBeforeDecimalRepairs() { var result = StoryIntelligenceJsonRepair.Repair("""{"value":7 .5}"""); Assert(result.Success, result.ErrorMessage ?? "Repair failed."); Assert(result.RepairedJson.Contains("\"value\":7.5", StringComparison.Ordinal), result.RepairedJson); } static void JsonRepairLeavesDecimalProseUnchanged() { const string prose = "The code printed 0. 8 before Mara copied it."; var result = StoryIntelligenceJsonRepair.Repair( $$"""{"summary":"{{prose}}","confidence":0. 8}"""); Assert(result.Success, result.ErrorMessage ?? "Repair failed."); using var document = JsonDocument.Parse(result.RepairedJson); Assert(document.RootElement.GetProperty("summary").GetString() == prose, "Repair changed decimal-looking prose inside a JSON string."); Assert(document.RootElement.GetProperty("confidence").GetDecimal() == 0.8m, "Repair did not fix the numeric value."); } static void ChapterStructureOutOfRangeRepairedConfidenceFailsValidation() { var result = StoryIntelligenceAiJsonParser.Parse( ChapterStructureJson("""7 .5"""), JsonOptions()); Assert(result.Success, result.ErrorMessage ?? "Chapter Structure parse failed."); var validation = new ChapterStructureValidator().Validate(result.Parsed, 2); Assert(!validation.IsValid, "Semantic validation should still reject repaired confidence outside 0.0 to 1.0."); Assert(validation.Errors.Any(error => error.Message.Contains("Confidence must be from 0.0 to 1.0", StringComparison.Ordinal)), "Out-of-range confidence error was not reported."); } static void StructuralSceneDetectionUsesFriendlyParserDiagnosticsAndRetry() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var analyzer = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs")); var dryRun = File.ReadAllText(Path.Combine(root, "Services/ChapterStoryIntelligenceDryRunService.cs")); var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Chapter-Structure-Prompt-V2.md")); Assert(analyzer.Contains("StoryIntelligenceAiJsonParser.Parse", StringComparison.Ordinal), "Onboarding structural analysis should use the canonical AI JSON parser."); Assert(!analyzer.Contains("JsonSerializer.Deserialize(chapterJson", StringComparison.Ordinal), "Onboarding structural analysis should not directly deserialize chapter JSON."); Assert(dryRun.Contains("StoryIntelligenceAiJsonParser.Parse", StringComparison.Ordinal), "Chapter dry-run should use the canonical AI JSON parser."); Assert(analyzer.Contains("BuildJsonRetryPrompt", StringComparison.Ordinal), "Structural analysis should retry once when deterministic repair cannot parse the JSON."); Assert(analyzer.Contains("PlotDirector could not read the scene structure returned", StringComparison.Ordinal), "User-facing parse failure should avoid raw JsonException details."); Assert(analyzer.Contains("DiagnosticExcerpt", StringComparison.Ordinal), "Parser failures should log a safe diagnostic excerpt."); Assert(prompt.Contains("no spaces inside the number", StringComparison.Ordinal), "Prompt should forbid spaces inside numeric JSON values."); } static void ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps() { var chapter = new ChapterStructureModel { SchemaVersion = "1.0", ChapterSummary = "A chapter with several scene movements.", SceneBoundaries = [ new ChapterSceneBoundary { SceneNumber = 1, StartParagraph = 1, EndParagraph = 4, StructuralSummary = "Opening scene summary with enough context for validation.", Confidence = 0.8m, Reason = "Opening location." }, new ChapterSceneBoundary { SceneNumber = 2, StartParagraph = 4, EndParagraph = 7, StructuralSummary = "Second scene summary with enough context for validation.", Confidence = 0.8m, Reason = "A new exchange begins." }, new ChapterSceneBoundary { SceneNumber = 3, StartParagraph = 9, EndParagraph = 10, StructuralSummary = "Final scene summary with enough context for validation.", Confidence = 0.8m, Reason = "Final turn." } ] }; var result = ChapterStructureBoundaryNormaliser.Normalise(chapter, 10); var boundaries = result.ChapterStructure?.SceneBoundaries; Assert(boundaries is { Count: 3 }, "Boundary normaliser should preserve the detected scene count."); boundaries = result.ChapterStructure!.SceneBoundaries!; Assert(boundaries[0].StartParagraph == 1 && boundaries[0].EndParagraph == 4, "First boundary should remain unchanged."); Assert(boundaries[1].StartParagraph == 5 && boundaries[1].EndParagraph == 7, "Overlap should be repaired by moving scene 2 start to paragraph 5."); Assert(boundaries[2].StartParagraph == 8 && boundaries[2].EndParagraph == 10, "Gap should be repaired by moving scene 3 start to paragraph 8."); Assert(result.Issues.Count >= 2, "Boundary repairs should be reported as warnings."); } static void ChapterStructureBoundaryNormaliserHandlesTerminalGhostParagraph() { var chapter = new ChapterStructureModel { SchemaVersion = "1.0", ChapterSummary = "A chapter with a terminal ghost paragraph response.", SceneBoundaries = [ new ChapterSceneBoundary { SceneNumber = 1, StartParagraph = 1, EndParagraph = 618, StructuralSummary = "The only real scene covers the valid structural paragraphs.", Confidence = 0.8m, Reason = "Continuous scene." }, new ChapterSceneBoundary { SceneNumber = 2, StartParagraph = 619, EndParagraph = 618, StructuralSummary = "Invalid terminal ghost paragraph.", Confidence = 0.1m, Reason = "Terminal off-by-one." } ] }; var result = ChapterStructureBoundaryNormaliser.Normalise(chapter, 618); var boundaries = result.ChapterStructure?.SceneBoundaries; Assert(boundaries is { Count: 1 }, "Terminal 619 of 618 ghost boundary should be discarded."); boundaries = result.ChapterStructure!.SceneBoundaries!; Assert(boundaries[0].StartParagraph == 1 && boundaries[0].EndParagraph == 618, "Final valid scene should still end at structural paragraph 618."); Assert(result.Issues.Any(issue => issue.Message.Contains("terminal off-by-one", StringComparison.OrdinalIgnoreCase)), "Terminal off-by-one repair should be reported."); } static void ChapterStructureBoundaryNormaliserPreservesGenuineInvertedRanges() { var chapter = new ChapterStructureModel { SchemaVersion = "1.0", ChapterSummary = "A chapter with a malformed middle boundary.", SceneBoundaries = [ new ChapterSceneBoundary { SceneNumber = 1, StartParagraph = 1, EndParagraph = 10, StructuralSummary = "Opening valid scene summary with enough detail.", Confidence = 0.8m, Reason = "Opening." }, new ChapterSceneBoundary { SceneNumber = 2, StartParagraph = 11, EndParagraph = 10, StructuralSummary = "Malformed scene summary with inverted range.", Confidence = 0.8m, Reason = "Malformed." }, new ChapterSceneBoundary { SceneNumber = 3, StartParagraph = 12, EndParagraph = 20, StructuralSummary = "Final valid scene summary with enough detail.", Confidence = 0.8m, Reason = "Ending." } ] }; var normalised = ChapterStructureBoundaryNormaliser.Normalise(chapter, 20); var validation = new ChapterStructureValidator().Validate(normalised.ChapterStructure, 20); ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalised); Assert(!validation.IsValid, "A genuine inverted middle range should still fail validation."); Assert(validation.Errors.Any(error => error.Message.Contains("Start paragraph cannot be after end paragraph", StringComparison.Ordinal)), "Validation should report the inverted range."); } static void SharedParserReportsRawOutput() { const string raw = """{"schemaVersion":"1.0","sceneBoundaries":["""; var result = StoryIntelligenceAiJsonParser.Parse(raw, JsonOptions()); Assert(!result.Success, "Unrecoverable JSON should fail."); Assert(result.RawJson == raw, "Raw malformed output was not retained."); } static string ChapterStructureJson(string confidence) => $$""" { "schemaVersion": "1.0", "chapterSummary": "A chapter.", "sceneBoundaries": [ { "sceneNumber": 1, "startParagraph": 1, "endParagraph": 2, "structuralSummary": "The scene follows a continuous action across two paragraphs while the immediate situation is established.", "confidence": {{confidence}}, "reason": "Opening scene remains in the same place and time." } ] } """; 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 { 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 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 == "22C.1", "Illustration prompt template version should reflect the current realistic 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("realistic cinematic still", StringComparison.OrdinalIgnoreCase), "Prompt did not include the realistic cinematic direction."); Assert(result.Prompt.Contains("production photography", StringComparison.OrdinalIgnoreCase), "Prompt did not include the new grounded realism 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 IllustrationPromptBuilderUsesRealisticImageDirection() { var spec = IllustrationStarterBatchDefinition.All.Single(x => x.Code == "char-young-adult-red-haired-witness"); var result = new IllustrationPromptBuilder().Build(spec); Assert(result.Prompt.Contains("realistic", StringComparison.OrdinalIgnoreCase), "Generated image prompts should move toward realism."); Assert(result.Prompt.Contains("no anime, cartoon", StringComparison.OrdinalIgnoreCase), "Prompt should reject stylised/cartoon output."); Assert(result.Prompt.Contains("oil painting", StringComparison.OrdinalIgnoreCase), "Prompt should reject painterly output."); } 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 IllustrationStatusTransitionsSupportArchiveFlow() { Assert(IllustrationLibraryStatuses.IsValid(IllustrationLibraryStatuses.Archived), "Archived should be a valid library state."); Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Generated, IllustrationLibraryStatuses.Archived), "Generated items should archive."); Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Approved, IllustrationLibraryStatuses.Archived), "Approved items should archive."); } static void IllustrationMetadataFlagsFallbackLocationLabels() { var item = new IllustrationLibraryItem { Category = IllustrationLibraryCategories.Location, StableCode = "demand-location-unknowninterior", DisplayTitle = "Unknown Interior location", Status = IllustrationLibraryStatuses.Generated, IsActive = true, MetadataJson = """{"baseType":"UnknownInterior","reuseScope":"Global","isFallbackClassification":"true"}""" }; var metadata = StoryIntelligenceIllustrationCompatibility.LocationMetadataFrom(item); Assert(metadata.IsFallbackClassification, "Unknown Interior must be marked as fallback classification."); Assert(!StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateLocation(metadata.BaseType), "Unknown Interior must not auto-generate."); } static void IllustrationLocationMatchingRejectsVehicleLivingRoomReuse() { Assert(StoryIntelligenceIllustrationCompatibility.LocationType("driver seat inside the car") == "DriverSeat", "Driver seat context should remain vehicle-specific."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("living room party") == "LivingRoom", "Living room context should remain domestic."); Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("DriverSeat", "LivingRoom"), "Vehicle interiors must not match living rooms."); Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "LivingRoom"), "Car interiors must not match living rooms."); } static void IllustrationAutomaticGenerationPolicyBlocksVagueFallbacks() { Assert(!StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateLocation("UnknownInterior"), "Unknown Interior should be review-only."); Assert(!StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateLocation("FlatInterior"), "Generic flat interior should be review-only."); Assert(StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateLocation("Garage"), "Specific location types may be generated when caps/config allow."); Assert(!StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateAsset("UnknownObject"), "Unknown objects should be review-only."); Assert(StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateAsset("DrivingLicence"), "Specific assets may be generated when caps/config allow."); } static void StoryMemoryImportImageGenerationDefaultsAreSafe() { var defaults = new StoryIntelligenceOptions(); Assert(!defaults.AutomaticImageGenerationDuringImportEnabled, "Automatic image generation during import should default off."); Assert(defaults.RequireImageGenerationApproval, "Image generation should require approval by default."); Assert(defaults.MaxImagesPerImport > 0 && defaults.MaxImagesPerImport <= 12, "Default import image cap should be low."); Assert(defaults.ImageGenerationModel == "gpt-image-1.5", "Default image model should use the requested newer model."); Assert(defaults.ImageGenerationQuality == "medium", "Default image quality should be medium."); Assert(defaults.ImageGenerationOutputFormat == "webp", "Default output format should be WebP."); } 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 StoryIntelligenceVisualisationArchiveIsPreserved() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../"); var archive = Path.Combine(root, "docs/story-intelligence/import-visualisation-archive.md"); var archiveSource = Path.Combine(root, "docs/story-intelligence/visualisation-archive/runtime-source"); Assert(File.Exists(archive), "Visualisation archive document should be preserved."); var text = File.ReadAllText(archive); Assert(text.Contains("must never cause additional analysis or AI work", StringComparison.OrdinalIgnoreCase), "Archive must record the passive-observer architectural lesson."); Assert(File.Exists(Path.Combine(archiveSource, "StoryIntelligenceExperience.cshtml")), "Archived Razor markup should be preserved outside runtime."); Assert(File.Exists(Path.Combine(archiveSource, "story-intelligence-experience-prototype.css")), "Archived CSS should be preserved outside runtime."); Assert(File.Exists(Path.Combine(archiveSource, "story-intelligence-experience-prototype.js")), "Archived JavaScript should be preserved outside runtime."); Assert(Directory.Exists(Path.Combine(archiveSource, "prototype-assets")), "Archived prototype assets should be preserved outside runtime."); } 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.LocationType("ladies toilets in the pub") == "PublicToilet", "Public toilets must not classify as domestic bathrooms."); Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "Office"), "Bathroom must reject office imagery."); Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("PublicToilet", "Bathroom"), "Pub/bar toilets must not use domestic bathroom 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 == "22C.1", $"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 Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata) => StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false); static void StoryIntelligenceVisualisationRuntimeIsRemoved() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); Assert(!File.Exists(Path.Combine(root, "Controllers/DevelopmentController.cs")), "Development visualisation controller should not be active."); Assert(!File.Exists(Path.Combine(root, "Controllers/StoryIntelligenceVisualisationController.cs")), "Visualisation snapshot API controller should not be active."); Assert(!File.Exists(Path.Combine(root, "Views/Development/StoryIntelligenceExperience.cshtml")), "Visualisation Razor view should not be active."); Assert(!File.Exists(Path.Combine(root, "wwwroot/js/story-intelligence-experience-prototype.js")), "Visualisation JavaScript should not be served from runtime."); Assert(!File.Exists(Path.Combine(root, "wwwroot/css/story-intelligence-experience-prototype.css")), "Visualisation CSS should not be served from runtime."); Assert(!Directory.Exists(Path.Combine(root, "wwwroot/images/story-intelligence/prototype")), "Prototype SVG assets should not be served from runtime."); var program = File.ReadAllText(Path.Combine(root, "Program.cs")); Assert(!program.Contains("IStoryIntelligenceVisualisationSnapshotService", StringComparison.Ordinal), "Visualisation snapshot service should not be registered."); Assert(!program.Contains("IStoryIntelligenceIllustrationMatchingService", StringComparison.Ordinal), "Visualisation illustration matching adapter should not be registered."); } static void LeanImportRemovesReplayLinksWhileRetainingProgress() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml")); var progressScript = Path.Combine(root, "wwwroot/js/story-intelligence-progress.js"); var program = File.ReadAllText(Path.Combine(root, "Program.cs")); Assert(!progressView.Contains("Replay Story Intelligence", StringComparison.Ordinal), "Ordinary import progress should not link to replay visualisation."); Assert(!progressView.Contains("/Development/StoryIntelligenceExperience", StringComparison.Ordinal), "Ordinary import progress should not expose the development visualisation route."); Assert(File.Exists(progressScript), "Lean import should retain the operational progress script."); Assert(program.Contains("app.MapHub", StringComparison.Ordinal), "Lean import should retain the shared Story Intelligence progress hub."); } static void StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml")); Assert(!progressView.Contains("close this browser tab", StringComparison.OrdinalIgnoreCase), "Progress page must not give a blanket browser safe-close instruction before Word sync acknowledgement exists."); Assert(!progressView.Contains("safely leave this page", StringComparison.OrdinalIgnoreCase), "Progress page must not infer safe-close from queued/background analysis alone."); Assert(progressView.Contains("Word markers are already saved", StringComparison.Ordinal), "Story Intelligence progress should reflect that structural Word sync has already completed."); Assert(progressView.Contains("Word does not need to remain open", StringComparison.Ordinal), "Optional Story Intelligence should not depend on Word remaining open."); } static void StructuralImportCreatesScenesBeforeWordSync() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); var buildSql = File.ReadAllText(Path.Combine(root, "Sql/113_Phase20F_OnboardingManuscriptBuild.sql")); Assert(onboarding.Contains("structureAnalysis.AnalyseChapterAsync", StringComparison.Ordinal), "Approved onboarding import should run structural scene-boundary analysis."); Assert(onboarding.Contains("Scenes = structuralScenes.Select", StringComparison.Ordinal), "Validated structural boundaries should be passed to the onboarding build."); Assert(buildSql.Contains("INSERT dbo.Scenes", StringComparison.Ordinal), "Onboarding build SQL should create canonical Scene records."); Assert(onboarding.Contains("BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings)", StringComparison.Ordinal), "Permanent SceneIDs should exist before Word marker instructions are built."); } static void StructuralImportCreatesFirstSceneWithoutPlaceholder() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var migration = File.ReadAllText(Path.Combine(root, "Sql/157_Phase25B_OnboardingBuildFirstSceneInsert.sql")); Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_Create", StringComparison.Ordinal), "Forward migration should update the onboarding build stored procedure."); Assert(migration.Contains("IF @SceneID IS NOT NULL", StringComparison.Ordinal), "First scene handling should only update an existing placeholder when one exists."); Assert(migration.Contains("ELSE\r\n BEGIN\r\n INSERT dbo.Scenes", StringComparison.Ordinal) || migration.Contains("ELSE\n BEGIN\n INSERT dbo.Scenes", StringComparison.Ordinal), "First scene handling should insert a scene when no placeholder exists."); Assert(migration.Contains("SET @SceneID = NULL;", StringComparison.Ordinal), "Scene cursor should clear SceneID between rows."); } static void StructuralImportPersistsParagraphMappingAndSourceText() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs")); var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var migration = File.ReadAllText(Path.Combine(root, "Sql/149_Phase24_StructuralImportMarkerSync.sql")); var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); Assert(models.Contains("StructuralParagraphOrdinal", StringComparison.Ordinal), "Scan payload should carry structural paragraph ordinals."); Assert(models.Contains("WordParagraphIndex", StringComparison.Ordinal), "Scan payload should carry Word paragraph indexes."); Assert(companion.Contains("structuralParagraphOrdinal", StringComparison.Ordinal), "Word scan should emit explicit structural paragraph mapping."); Assert(migration.Contains("ManuscriptStructuralChapterSources", StringComparison.Ordinal), "Structural import should persist chapter source text."); Assert(onboarding.Contains("SaveStructuralChapterSourcesAsync", StringComparison.Ordinal), "Onboarding import should save source text after canonical ChapterIDs exist."); } static void StructuralImportPersistsCanonicalSceneSourceText() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var migration = File.ReadAllText(Path.Combine(root, "Sql/150_Phase24B_SceneManuscriptSources.sql")); var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); var analyzer = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs")); Assert(migration.Contains("CREATE TABLE dbo.SceneManuscriptSources", StringComparison.Ordinal), "Scene manuscript source text should be stored in a canonical SceneID table."); Assert(migration.Contains("SceneID int NOT NULL CONSTRAINT PK_SceneManuscriptSources PRIMARY KEY", StringComparison.Ordinal), "Scene source text should be keyed by SceneID."); Assert(migration.Contains("IX_SceneManuscriptSources_BookChapter", StringComparison.Ordinal), "Scene source text should be indexed for book/chapter retrieval."); Assert(onboarding.Contains("SaveSceneManuscriptSourcesAsync", StringComparison.Ordinal), "Structural import should persist scene source text before Word sync."); Assert(onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal) < onboarding.IndexOf("await PublishBuildProgress(userId, preview.PreviewID, \"Updating Word document with scene markers", StringComparison.Ordinal), "Scene source text should be persisted before marker sync can complete."); Assert(analyzer.Contains("string.Join(Environment.NewLine + Environment.NewLine", StringComparison.Ordinal), "Scene source text should preserve paragraph separation."); Assert(analyzer.Contains("item.IncludeInSourceText", StringComparison.Ordinal), "Scene source text should exclude non-prose structural paragraphs such as hidden markers/separators."); } static void StructuralImportAddsSummariesWithoutExtraSceneAiCalls() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Chapter-Structure-Prompt-V2.md")); var model = File.ReadAllText(Path.Combine(root, "Models/ChapterStructureModels.cs")); var analyzer = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs")); var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); var migration = File.ReadAllText(Path.Combine(root, "Sql/154_Phase24C_CoreImportCharacterMappingAndSummaries.sql")); Assert(prompt.Contains("structuralSummary", StringComparison.Ordinal), "Chapter boundary prompt should request a per-scene structural summary."); Assert(prompt.Contains("25 to 40 words", StringComparison.Ordinal), "Structural summaries should be short factual summaries."); Assert(model.Contains("public string? StructuralSummary", StringComparison.Ordinal), "Chapter boundary model should carry structural summaries."); Assert(analyzer.Contains("StructuralSummary = Trim(boundary.StructuralSummary", StringComparison.Ordinal), "Boundary analysis should carry summaries into structural scenes."); Assert(onboarding.Contains("SaveSceneStructuralSummaryAsync", StringComparison.Ordinal), "Core import should persist structural summaries after canonical SceneIDs exist."); Assert(migration.Contains("SceneStructuralSummary_Save", StringComparison.Ordinal), "Structural summaries should be saved through a stored procedure."); Assert(!analyzer.Contains("Scene-Prompt", StringComparison.Ordinal), "Structural summaries must be produced by the existing chapter-boundary call, not extra scene calls."); } static void CoreImportScanStageDefersScenesAndCharacters() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var presence = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js")); var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs")); var previewStore = File.ReadAllText(Path.Combine(root, "Services/ManuscriptScanPreviewStore.cs")); var scanReview = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml")); var index = File.ReadAllText(Path.Combine(root, "Views/Onboarding/Index.cshtml")); var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); var wordService = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs")); var onboardingScan = companion[companion.IndexOf("const buildOnboardingScanPreview", StringComparison.Ordinal)..companion.IndexOf("const scanCommandValue", StringComparison.Ordinal)]; Assert(onboardingScan.Contains("sceneCount: 0", StringComparison.Ordinal), "Initial onboarding scan should report zero scenes."); Assert(onboardingScan.Contains("characterCandidateCount: 0", StringComparison.Ordinal), "Initial onboarding scan should report zero characters."); Assert(onboardingScan.Contains("scenes: []", StringComparison.Ordinal), "Initial onboarding scan should not send provisional scene rows."); Assert(onboardingScan.Contains("characterCandidates: []", StringComparison.Ordinal), "Initial onboarding scan should not send provisional character rows."); Assert(!onboardingScan.Contains("documentTextForDiscovery", StringComparison.Ordinal), "Initial onboarding scan should not prepare text for character discovery."); Assert(!onboardingScan.Contains("/api/word-companion/manuscript/discover-characters", StringComparison.Ordinal), "Onboarding scan should not call character discovery."); Assert(hub.Contains("SceneCount = 0", StringComparison.Ordinal), "Hub normalisation should discard scan-time scene counts."); Assert(hub.Contains("CharacterCandidateCount = 0", StringComparison.Ordinal), "Hub normalisation should discard scan-time character counts."); Assert(previewStore.Contains("Scenes = []", StringComparison.Ordinal), "Preview storage should keep scan-time scenes empty."); Assert(previewStore.Contains("CharacterCandidates = []", StringComparison.Ordinal), "Preview storage should keep scan-time character candidates empty."); Assert(scanReview.Contains("Scenes", StringComparison.Ordinal), "Review Chapters should show the Scenes status card."); Assert(scanReview.Contains("var displayedSceneCount = isCharacterStep ? Model.SelectedSceneCount : 0;", StringComparison.Ordinal), "Review Chapters should show zero scenes before scene detection."); Assert(scanReview.Contains("var displayedCharacterCount = isCharacterStep ? Model.SelectedCharacterCount : 0;", StringComparison.Ordinal), "Review Chapters should show zero characters before character review."); Assert(scanReview.Contains("

Chapters

", StringComparison.Ordinal), "Review Chapters section heading should not mention scenes."); Assert(index.Contains("data-onboarding-scan-scenes", StringComparison.Ordinal), "Scan page should display the Scenes counter."); Assert(presence.Contains("Opening chapter review...", StringComparison.Ordinal), "Successful scan should show the immediate auto-navigation state."); Assert(onboarding.Contains("DiscoverCoreImportCharacterCandidates(canonicalSceneSources)", StringComparison.Ordinal), "Character candidates should be generated after canonical scene sources exist."); Assert(wordService.Contains("DiscoverCoreImportCharacterCandidates", StringComparison.Ordinal), "Post-scene character discovery should be explicit."); Assert(wordService.Contains("Source SceneIDs", StringComparison.Ordinal), "Post-scene character candidates should carry canonical scene evidence."); } static void CoreImportDeterministicallyMapsKnownCharacters() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var service = File.ReadAllText(Path.Combine(root, "Services/CoreImportCharacterMappingService.cs")); var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs")); var migration = File.ReadAllText(Path.Combine(root, "Sql/154_Phase24C_CoreImportCharacterMappingAndSummaries.sql")); Assert(service.Contains("ICoreImportCharacterMappingService", StringComparison.Ordinal), "Deterministic character mapping service should be explicit."); Assert(service.Contains("ListSceneManuscriptSourcesByBookAsync", StringComparison.Ordinal), "Mapping should scan persisted canonical scene source text."); Assert(service.Contains("characters.ListCharactersAsync(projectId)", StringComparison.Ordinal), "Mapping should use known project characters before AI discovery."); Assert(service.Contains("characters.ListAliasesAsync", StringComparison.Ordinal), "Mapping should include accepted aliases."); Assert(service.Contains("GenericReferences", StringComparison.Ordinal), "Mapping should reject generic family/role labels."); Assert(service.Contains("AddUniqueTokenTerm(index, character.CharacterID, tokens[0], tokenOwners)", StringComparison.Ordinal), "First-name matching should require unique ownership."); Assert(service.Contains("AddUniqueTokenTerm(index, character.CharacterID, tokens[^1], tokenOwners)", StringComparison.Ordinal), "Surname-only matching should require unique ownership."); Assert(service.Contains("tokenOwners.GetValueOrDefault(token) == 1", StringComparison.Ordinal), "Token matching should only add unique first/surname terms."); Assert(service.Contains(@"(?= 0, "Character review should save accepted aliases."); Assert(remap > aliasSave, "Character review should rerun deterministic mapping after accepted aliases are saved."); } static void ScanReviewSeparatesKnownMatchesFromUnresolvedNames() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var review = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml")); Assert(review.Contains("Already matched known characters", StringComparison.Ordinal), "Character review should separate already-matched known characters."); Assert(review.Contains("Likely new characters", StringComparison.Ordinal), "Character review should focus unresolved likely characters separately."); Assert(review.Contains("Possible new names or aliases", StringComparison.Ordinal), "Character review should label possible alias/new-name cases clearly."); Assert(review.Contains("Generic or ambiguous references", StringComparison.Ordinal), "Character review should label generic/ambiguous references distinctly."); } static void StoryIntelligenceUsesPersistedSceneSourceAfterRestart() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var migration = File.ReadAllText(Path.Combine(root, "Sql/150_Phase24B_SceneManuscriptSources.sql")); var sourceRepository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceSourceRepository.cs")); var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); var existingQueue = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceExistingChapterQueueService.cs")); var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs")); Assert(migration.Contains("INNER JOIN dbo.SceneManuscriptSources", StringComparison.Ordinal), "Chapter source SQL should aggregate persisted canonical scene source text."); Assert(migration.Contains("STRING_AGG", StringComparison.Ordinal), "Chapter source SQL should reconstruct chapter text from persisted scene text in order."); Assert(sourceRepository.Contains("StoryIntelligenceSource_ChapterText_GetAdmin", StringComparison.Ordinal), "Story Intelligence source repository should use the stored source procedure."); Assert(onboardingStory.Contains("durableSource = await sources.GetChapterSourceAsync", StringComparison.Ordinal), "Onboarding Story Intelligence should read durable chapter text from scene sources."); Assert(onboardingStory.Contains("CoreImportComplete", StringComparison.Ordinal), "Optional onboarding Story Intelligence should require core import completion."); Assert(existingQueue.Contains("sources.GetChapterSourceAsync", StringComparison.Ordinal), "Later Story Intelligence queueing should start from persisted chapter/scene source, not onboarding DTOs."); Assert(runner.Contains("structuralSummary = boundary.StructuralSummary", StringComparison.Ordinal), "Scene Intelligence context should receive structural summaries from the boundary pass."); } static void WordSyncRequiresSaveAcknowledgement() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs")); var presence = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js")); var migration = File.ReadAllText(Path.Combine(root, "Sql/158_Phase25C_OnboardingMarkerSaveStates.sql")); Assert(companion.Contains("saveWordDocumentIfAvailable", StringComparison.Ordinal), "Companion should save Word before acknowledging marker sync."); Assert(companion.Contains("markersChanged", StringComparison.Ordinal), "Companion should distinguish marker insertion from an idempotent no-change retry."); Assert(companion.Contains("SaveNotRequired", StringComparison.Ordinal), "Companion should acknowledge marker sync when no Word save is required."); Assert(companion.Contains("UserConfirmedSave", StringComparison.Ordinal), "Companion should support an explicit manual save acknowledgement."); Assert(companion.Contains("RequireOnboardingBuildManualSave", StringComparison.Ordinal), "Companion should report manual-save-required state without throwing a generic SignalR error."); Assert(companion.Contains("CompleteOnboardingBuildMarkerSync", StringComparison.Ordinal), "Companion should acknowledge successful marker sync."); Assert(companion.Contains("FailOnboardingBuildMarkerSync", StringComparison.Ordinal), "Companion should report failed marker sync."); Assert(companion.Contains("officeRuntimeDiagnostics", StringComparison.Ordinal), "Companion should capture Word host diagnostics for save failures."); Assert(hub.Contains("MarkerSyncClientResult", StringComparison.Ordinal), "Server should accept marker sync diagnostics."); Assert(hub.Contains("MarkMarkerSyncCompletedAsync", StringComparison.Ordinal), "Server should persist completed marker sync."); Assert(hub.Contains("MarkMarkerSyncUserSaveRequiredAsync", StringComparison.Ordinal), "Server should persist manual-save-required marker sync."); Assert(presence.Contains("OnboardingBuildMarkerSyncCompleted", StringComparison.Ordinal), "Browser should wait for marker sync completion before redirecting."); Assert(migration.Contains("MarkerSyncCompletedUtc", StringComparison.Ordinal), "Database should persist marker sync completion time."); Assert(migration.Contains("UserSaveRequired", StringComparison.Ordinal), "Database should persist manual-save-required marker sync state."); Assert(migration.Contains("SaveNotRequired", StringComparison.Ordinal), "Database should persist no-change marker sync state."); } static void WordSyncRetryResumesExistingStructure() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs")); var migration = File.ReadAllText(Path.Combine(root, "Sql/158_Phase25C_OnboardingMarkerSaveStates.sql")); var resumeCheck = onboarding.IndexOf("await builds.GetByPreviewAsync(userId, preview.PreviewID)", StringComparison.Ordinal); var sceneDetection = onboarding.IndexOf("\"Detecting scene boundaries...\"", StringComparison.Ordinal); Assert(resumeCheck >= 0 && sceneDetection > resumeCheck, "Existing structural builds should be checked before rerunning scene boundary AI."); Assert(onboarding.Contains("BuildExistingStructureResultAsync", StringComparison.Ordinal), "Retry should rebuild marker instructions from persisted structure."); Assert(onboarding.Contains("BuildSceneMarkerInstructionsFromSources", StringComparison.Ordinal), "Retry should use stored SceneID paragraph positions for marker sync."); Assert(repository.Contains("Onboarding_ManuscriptBuild_GetByPreview", StringComparison.Ordinal), "Existing build lookup should use a stored procedure."); Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_GetByPreview", StringComparison.Ordinal), "Forward migration should provide the existing build lookup procedure."); } static void StoryIntelligenceEnrichesExistingStructuralScenes() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs")); var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs")); var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs")); Assert(runner.Contains("ListChapterScenesAsync", StringComparison.Ordinal), "Story Intelligence scene results should be linked to existing SceneIDs."); Assert(repository.Contains("SceneID = existingScenes.Count > index", StringComparison.Ordinal), "Story Intelligence commit should update existing structural scenes when present."); Assert(repository.Contains("Enriched existing structural scenes", StringComparison.Ordinal), "Commit audit should distinguish enrichment from scene creation."); Assert(commit.Contains("readiness.ActiveSceneCount != importScenes.Count", StringComparison.Ordinal), "Review readiness should allow matching structural scenes while blocking mismatches."); } 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() { Assert(true, "Historical visualisation contract helper retained only to minimise churn after visualisation removal."); } 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", ImageGenerationModel = "" }), NullLogger.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.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() .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() .SingleOrDefault(); Assert(((IRequestSizeLimitMetadata?)requestSize)?.MaxRequestBodySize >= 25 * 1024 * 1024, "Scan review request size limit should support full-book previews."); } static void CharacterSexHintsAreConservativeAndReviewable() { Assert(CharacterSexHintService.Suggest("Cindy") == CharacterSexHintService.Female, "Cindy should default to a female sex hint."); Assert(CharacterSexHintService.Suggest("Kevin") == CharacterSexHintService.Male, "Kevin should default to a male sex hint."); Assert(CharacterSexHintService.Suggest("Colin") == CharacterSexHintService.Male, "Colin should default to a male sex hint."); Assert(CharacterSexHintService.Suggest("Debbie") == CharacterSexHintService.Female, "Debbie should default to a female sex hint."); Assert(CharacterSexHintService.Suggest("Chrissy") == CharacterSexHintService.Female, "Chrissy should default to a female sex hint."); Assert(CharacterSexHintService.Suggest("Clara") == CharacterSexHintService.Female, "Clara should default to a female sex hint."); Assert(CharacterSexHintService.Suggest("Greg") == CharacterSexHintService.Male, "Greg should default to a male sex hint."); Assert(CharacterSexHintService.Suggest("Judy") == CharacterSexHintService.Female, "Judy should default to a female sex hint."); Assert(CharacterSexHintService.Suggest("Liam") == CharacterSexHintService.Male, "Liam should default to a male sex hint."); Assert(CharacterSexHintService.Suggest("Phil") == CharacterSexHintService.Male, "Phil should default to a male sex hint."); Assert(CharacterSexHintService.Suggest("Mrs Palmer") == CharacterSexHintService.Female, "Feminine titles should provide a female sex hint."); Assert(CharacterSexHintService.Suggest("Mr Palmer") == CharacterSexHintService.Male, "Masculine titles should provide a male sex hint."); Assert(CharacterSexHintService.Suggest("Alex") == CharacterSexHintService.Unknown, "Ambiguous names should not be guessed."); Assert(CharacterSexHintService.Suggest("Becks") == CharacterSexHintService.Unknown, "Unclear nicknames should remain reviewable."); Assert(CharacterSexHintService.Normalise("Female", "Alex") == CharacterSexHintService.Female, "Reviewed sex hints should override the name heuristic."); } static void CharacterDiscoveryDoesNotTruncateScanReviewCandidates() { var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); Assert(method is not null, "Character discovery method could not be found."); var builder = new StringBuilder(); for (var index = 0; index < 60; index++) { var name = $"Mr Person{(char)('a' + index / 26)}{(char)('a' + index % 26)}"; builder.Append(name).Append(" said hello. ").Append(name).Append(" smiled. "); } var candidates = (IReadOnlyList)method!.Invoke(null, [builder.ToString(), false])!; Assert(candidates.Count >= 60, $"Character discovery should not truncate the scan review to 50 candidates; got {candidates.Count}."); } static void CharacterDiscoveryRejectsSentenceStartWords() { var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); Assert(method is not null, "Character discovery method could not be found."); var text = """ Silence filled the room. Silence made everything worse. Morning came too quickly. Morning dragged at the windows. Eyes looked toward the door. Eyes followed the sound. Colin said nothing. Debbie smiled at Colin. Mr Wilkinson opened the file. """; var candidates = (IReadOnlyList)method!.Invoke(null, [text, false])!; Assert(candidates.Any(candidate => candidate.Text == "Colin"), "Real common character names should still be detected."); Assert(candidates.Any(candidate => candidate.Text == "Debbie"), "Real common character names should still be detected."); Assert(candidates.Any(candidate => candidate.Text == "Mr Wilkinson"), "Titled character names should still be detected."); Assert(!candidates.Any(candidate => candidate.Text is "Silence" or "Morning" or "Eyes"), "Repeated sentence-start words must not become character candidates."); var actionText = """ Door opened before anyone moved. Door closed behind them. Glass shattered across the floor. Glass glittered under the counter. Window rattled in the frame. Window shook again. Colin opened the file. Debbie watched Colin. """; var actionCandidates = (IReadOnlyList)method!.Invoke(null, [actionText, false])!; Assert(actionCandidates.Any(candidate => candidate.Text == "Colin"), "Known character names should still survive action evidence."); Assert(actionCandidates.Any(candidate => candidate.Text == "Debbie"), "Known character names should still survive action evidence."); Assert(!actionCandidates.Any(candidate => candidate.Text is "Door" or "Glass" or "Window"), "Repeated sentence-start action nouns must not become character candidates."); } static void ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates() { var preview = new ManuscriptScanPreview { CharacterCandidates = [ new() { TemporaryCharacterKey = "c-colin", Name = "Colin", MentionCount = 4, QualityScore = 7, Category = "ProbableCharacter" }, new() { TemporaryCharacterKey = "c-door", Name = "Door", MentionCount = 4, QualityScore = 2, Category = "PossibleCharacter" }, new() { TemporaryCharacterKey = "c-road", Name = "Road", MentionCount = 8, QualityScore = 1, Category = "Excluded" } ] }; var candidates = WordCompanionService.DiscoverManuscriptScanCharacterCandidates(preview); Assert(candidates.Count == 1, $"Scan preview should keep only auto-selected character candidates; got {candidates.Count}."); Assert(candidates[0].Name == "Colin", "Scan preview should keep probable character candidates."); } static void CharacterDiscoveryRejectsFragmentsAndLocationNames() { var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); Assert(method is not null, "Character discovery method could not be found."); var text = """ Bark Narrated the line break. Freedom Narrated another section. Eventually Rob arrived and Rob spoke to Maggie. Bristol Road curved away from Barmouth. Bristol Road appeared again. Maggie said hello. Maggie smiled at Rob. """; var candidates = (IReadOnlyList)method!.Invoke(null, [text, false])!; Assert(candidates.Any(candidate => candidate.Text == "Maggie"), "Real character names should still survive fragment filtering."); Assert(candidates.Any(candidate => candidate.Text == "Rob"), "Known character names should still survive fragment filtering."); Assert(!candidates.Any(candidate => candidate.Text is "Bark Narrated" or "Freedom Narrated" or "Eventually Rob"), "Narrative fragments must not become character candidates."); Assert(!candidates.Any(candidate => candidate.Text == "Bristol Road"), "Location names must not become character candidates."); } static void OnboardingWizardKeepsCharacterPagesInsideOneMainStep() { var header = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Onboarding/_OnboardingJourneyHeader.cshtml")); var review = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Onboarding/ScanReview.cshtml")); var storyService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/OnboardingStoryIntelligenceService.cs")); Assert(!header.Contains("BuildDynamicSteps", StringComparison.Ordinal), "Onboarding header should not create one top-level step per character page."); Assert(header.Contains("new(\"Detect Scenes\", 8)", StringComparison.Ordinal), "Onboarding header should include scene detection as a distinct main step."); Assert(header.Contains("new(\"Review Characters\", 9)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step."); Assert(!header.Contains("Story Summary", StringComparison.Ordinal), "Core onboarding header should not present Story Summary as a mandatory import step."); Assert(!header.Contains("Story Context", StringComparison.Ordinal), "Core onboarding header should not present Story Context as a mandatory import step."); Assert(review.Contains("var currentStep = isCharacterStep ? 9 : 7;", StringComparison.Ordinal), "Scan review should keep every character page on the same main wizard step."); Assert(review.Contains("onboarding-subprogress", StringComparison.Ordinal), "Character pagination should be shown as section-level progress."); Assert(storyService.Contains("const int totalWizardSteps = 10;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed core wizard step count."); } 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() { Assert(true, "Historical helper retained only to minimise churn after visualisation removal."); } 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, 3)", StringComparison.Ordinal), "Relationship changes should show several visible cards without overflowing the panel."); 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")); 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(!File.Exists(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs")), "Visualisation snapshot service should stay removed."); } 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 judyName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Judy", ["Judy"], []); var philName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Phil", ["Phil"], []); 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(judyName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Common feminine name Judy should infer Feminine, got {judyName.Presentation}."); Assert(philName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Common masculine name Phil should infer Masculine, got {philName.Presentation}."); 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"), "Named streets should wait for street-specific imagery rather than reuse generic road art."); Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "Street"), "Named roads should wait for road-specific imagery rather than reuse street art."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("car interior") == "VehicleInterior", "Car interior should be VehicleInterior."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom") == "Bathroom", "Bathroom should be Bathroom."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("ladies toilets in the Rose and Crown pub") == "PublicToilet", "Pub toilets should be PublicToilet."); 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/StoryIntelligenceContext.cshtml")); var adminController = File.ReadAllText(Path.Combine(root, "Controllers/AdminController.cs")); var adminDiagnostics = File.ReadAllText(Path.Combine(root, "Views/Admin/StoryIntelligenceDiagnostics.cshtml")); var program = File.ReadAllText(Path.Combine(root, "Program.cs")); var storyMemory = File.ReadAllText(Path.Combine(root, "Services/StoryMemoryServices.cs")); var matchingService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceIllustrationMatchingService.cs")); var snapshot = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceVisualisationSnapshotService.cs")); var developmentReset = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceDevelopmentResetService.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("