4087 lines
346 KiB
C#

using System.Text.Json;
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Controllers;
using PlotLine.Data;
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),
("Location contextual names use POV and structural setting", LocationContextualNamesUsePovAndStructuralSetting),
("Location canonical keys merge punctuation and safe plurals", LocationCanonicalKeysMergePunctuationAndSafePlurals),
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
("Asset canonical keys merge contextual vehicle variants", AssetCanonicalKeysMergeContextualVehicleVariants),
("Asset contextual names resolve POV ownership", AssetContextualNamesResolvePovOwnership),
("Asset structural phrases trim neighbouring nouns", AssetStructuralPhrasesTrimNeighbouringNouns),
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
("Relationship signals infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance),
("Relationship events preserve chronological story order", RelationshipEventsPreserveChronologicalStoryOrder),
("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys),
("Knowledge suspicion and certainty remain distinct", KnowledgeSuspicionAndCertaintyRemainDistinct),
("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 character evidence is candidate specific", CoreImportCharacterEvidenceIsCandidateSpecific),
("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),
("Story Intelligence canonical mode bypasses duplicate boundary analysis", StoryIntelligenceCanonicalModeBypassesDuplicateBoundaryAnalysis),
("Story Intelligence progress uses canonical scene summaries", StoryIntelligenceProgressUsesCanonicalSceneSummaries),
("Story Intelligence downstream imports prefer canonical SceneIDs", StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds),
("Story Intelligence scene prompt no longer asks for duplicate factual summary", StoryIntelligenceScenePromptNoLongerRequestsDuplicateSummary),
("Story Intelligence review uses canonical structural summaries and counts", StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts),
("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement),
("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure),
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes),
("Core import scene titles ride existing structural AI call", CoreImportSceneTitlesRideExistingStructuralAiCall),
("Core import scene title preference is review scoped", CoreImportSceneTitlePreferenceIsReviewScoped),
("Core import scene title preference moved to Detect Scenes", CoreImportSceneTitlePreferenceMovedToDetectScenes),
("Core import Review Chapters is compact and chapter only", CoreImportReviewChaptersIsCompactAndChapterOnly),
("Core import Detect Scenes is compact and owns title option", CoreImportDetectScenesIsCompactAndOwnsTitleOption),
("Book context is canonical import metadata", BookContextIsCanonicalImportMetadata),
("Names defaults from selected book context", NamesDefaultsFromSelectedBookContext),
("Story Intelligence starts from core import complete", StoryIntelligenceStartsFromCoreImportComplete),
("Generated scene titles persist with provenance", GeneratedSceneTitlesPersistWithProvenance),
("Scene title display keeps scene numbers visible", SceneTitleDisplayKeepsSceneNumbersVisible),
("Manuscript text statistics handles pure prose", ManuscriptTextStatisticsHandlesPureProse),
("Manuscript text statistics handles pure dialogue", ManuscriptTextStatisticsHandlesPureDialogue),
("Manuscript text statistics splits narrative before dialogue", ManuscriptTextStatisticsSplitsNarrativeBeforeDialogue),
("Manuscript text statistics splits dialogue before narrative", ManuscriptTextStatisticsSplitsDialogueBeforeNarrative),
("Manuscript text statistics handles multiple dialogue passages", ManuscriptTextStatisticsHandlesMultipleDialoguePassages),
("Manuscript text statistics supports smart quotes", ManuscriptTextStatisticsSupportsSmartQuotes),
("Manuscript text statistics supports straight quotes", ManuscriptTextStatisticsSupportsStraightQuotes),
("Manuscript text statistics carries multi-paragraph dialogue", ManuscriptTextStatisticsCarriesMultiParagraphDialogue),
("Manuscript text statistics returns to prose after continued dialogue", ManuscriptTextStatisticsReturnsToProseAfterContinuedDialogue),
("Manuscript text statistics ignores empty paragraphs", ManuscriptTextStatisticsIgnoresEmptyParagraphs),
("Manuscript text statistics reconciles scene totals", ManuscriptTextStatisticsReconcilesSceneTotals),
("Manuscript text statistics aggregates chapter scenes", ManuscriptTextStatisticsAggregatesChapterScenes),
("Manuscript text statistics recalculates existing scene sources", ManuscriptTextStatisticsRecalculatesExistingSceneSources),
("Manuscript text statistics does not invoke AI", ManuscriptTextStatisticsDoesNotInvokeAi),
("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 new-format context uses canonical characters", StoryIntelligenceNewFormatContextUsesCanonicalCharacters),
("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 alias dropdown uses canonical characters", StoryIntelligenceAliasDropdownUsesCanonicalCharacters),
("Story Intelligence review omits character metrics dashboard", StoryIntelligenceReviewOmitsCharacterMetricsDashboard),
("Story Intelligence resumed chapters use canonical labels", StoryIntelligenceResumedChaptersUseCanonicalLabels),
("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch),
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
("Structural POV rides existing chapter analysis", StructuralPovRidesExistingChapterAnalysis),
("Structural POV persists through import resolution", StructuralPovPersistsThroughImportResolution),
("Chapter POV derives from canonical scenes", ChapterPovDerivesFromCanonicalScenes),
("Permanent Analyse My Book starts from saved book", PermanentAnalyseMyBookStartsFromSavedBook),
("Story Intelligence canonical context carries provenance", StoryIntelligenceCanonicalContextCarriesProvenance),
("Scene prompt protects canonical scene context", ScenePromptProtectsCanonicalSceneContext),
("Structural setting rides existing chapter analysis", StructuralSettingRidesExistingChapterAnalysis),
("Explicit narrator labels detect deterministic POV", ExplicitNarratorLabelsDetectDeterministicPov),
("AI POV JSON parses into structural models", AiPovJsonParsesIntoStructuralModels),
("Post character review resolves persisted structural POV", PostCharacterReviewResolvesPersistedStructuralPov),
("Normal scene and chapter saves persist POV", NormalSceneAndChapterSavesPersistPov),
("Character merge is a reusable stored procedure workflow", CharacterMergeIsReusableStoredProcedureWorkflow),
("Story Intelligence character review suppresses resolved narrator POV", StoryIntelligenceCharacterReviewSuppressesResolvedNarratorPov),
("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 scene prompt uses scene characters terminology", StoryIntelligenceScenePromptUsesSceneCharactersTerminology),
("Story Intelligence downstream imports support canonical scene results", StoryIntelligenceDownstreamImportsSupportCanonicalSceneResults),
("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 runtime follows multiple imported scene anchors", WordCompanionRuntimeFollowsMultipleImportedSceneAnchors),
("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"));
var dependencyCleanupMigration = File.ReadAllText(Path.Combine(root, "Sql/160_Phase25E_ProjectHardDeleteDependencyCleanup.sql"));
Assert(repository.Contains("using var transaction = connection.BeginTransaction()", StringComparison.Ordinal), "Project hard delete should be wrapped by a repository transaction.");
Assert(repository.Contains("\"dbo.ProjectHardDelete_PrepareDependencies\"", StringComparison.Ordinal), "Project hard delete should run scoped dependency cleanup through a stored procedure.");
Assert(!repository.Contains("ProjectHardDeleteStoryIntelligenceDependencyCleanupSql", StringComparison.Ordinal), "Project hard delete should not carry dependency cleanup as inline SQL.");
Assert(repository.Contains("AllowActiveProject = false", StringComparison.Ordinal), "Archived-project permanent delete must refuse active projects before cleanup.");
Assert(dependencyCleanupMigration.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(dependencyCleanupMigration.Contains("CharacterIntelligenceEvidenceEvents", StringComparison.Ordinal), "Hard delete cleanup should remove character intelligence evidence rows.");
Assert(dependencyCleanupMigration.Contains("CharacterIntelligenceProfiles", StringComparison.Ordinal), "Hard delete cleanup should remove character intelligence profile rows.");
Assert(dependencyCleanupMigration.Contains("StoryMemoryProcessedSceneResults", StringComparison.Ordinal), "Hard delete cleanup should remove durable story memory scene-result rows.");
Assert(dependencyCleanupMigration.Contains("StoryMemoryImportPreferences", StringComparison.Ordinal), "Hard delete cleanup should remove durable story memory import rows.");
Assert(dependencyCleanupMigration.Contains("SceneCharacterSuggestions", StringComparison.Ordinal), "Hard delete cleanup should remove scene character suggestion rows.");
Assert(dependencyCleanupMigration.Contains("SceneLocationSuggestions", StringComparison.Ordinal), "Hard delete cleanup should remove scene location suggestion rows.");
Assert(dependencyCleanupMigration.Contains("SceneAssetSuggestions", StringComparison.Ordinal), "Hard delete cleanup should remove scene asset suggestion rows.");
Assert(dependencyCleanupMigration.Contains("WordCompanionSceneLocations", StringComparison.Ordinal), "Hard delete cleanup should remove Word Companion scene location rows.");
Assert(dependencyCleanupMigration.Contains("WordCompanionSceneAssets", StringComparison.Ordinal), "Hard delete cleanup should remove Word Companion scene asset rows.");
Assert(dependencyCleanupMigration.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 WordCompanionRuntimeFollowsMultipleImportedSceneAnchors()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
Assert(script.Contains("paragraph.contentControls.load(\"items/tag,title\")", StringComparison.Ordinal), "Runtime structure reads must load paragraph content controls, not only text/style.");
Assert(script.Contains("chapterAnchorId: paragraphAnchorId(paragraph, \"PD-CHAPTER\")", StringComparison.Ordinal), "Runtime paragraph snapshots should carry canonical chapter anchors.");
Assert(script.Contains("sceneAnchorId: paragraphAnchorId(paragraph, \"PD-SCENE\")", StringComparison.Ordinal), "Runtime paragraph snapshots should carry canonical scene anchors.");
Assert(script.Contains("const hasCanonicalSceneAnchors = paragraphSnapshots.some", StringComparison.Ordinal), "Runtime structure should detect imported scene-anchor mode.");
Assert(script.Contains("if (paragraph.sceneAnchorId)", StringComparison.Ordinal), "Runtime structure should start scenes from PD-SCENE anchors on ordinary paragraphs.");
Assert(script.Contains("return chapter.scenes\r\n .filter((item) => item.startParagraphIndex <= paragraphIndex)", StringComparison.Ordinal)
|| script.Contains("return chapter.scenes\n .filter((item) => item.startParagraphIndex <= paragraphIndex)", StringComparison.Ordinal), "Cursor resolution should select the latest preceding scene boundary.");
Assert(script.Contains("findExactAnchorControl(paragraph, tag)", StringComparison.Ordinal), "Marker insertion should reuse only the exact canonical SceneID tag.");
Assert(!script.Contains("const existing = findAnchorControl(paragraph, prefix);", StringComparison.Ordinal), "Marker insertion must not collapse distinct PD-SCENE tags by prefix.");
}
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<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync", StringComparison.Ordinal)..service.IndexOf("public async Task<WordCompanionManuscriptUnlinkResponse?> 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<SceneIntelligenceScene>(
result.RepairedJson,
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
});
Assert(parsed is not null, "Scene JSON did not deserialise.");
}
static void ChapterStructureConfidenceRepairs()
{
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(
"""
{
"schemaVersion": "1.0",
"chapterSummary": "A chapter.",
"sceneBoundaries": [
{
"sceneNumber": 1,
"startParagraph": 1,
"endParagraph": 2,
"confidence": 0. ,
"reason": "Opening scene."
}
]
}
""",
JsonOptions());
Assert(result.Success, result.ErrorMessage ?? "Chapter Structure parse failed.");
Assert(result.Parsed?.SceneBoundaries?[0].Confidence == 0.0m, "Confidence was not repaired to 0.0.");
Assert(result.Warnings.Any(warning => warning.Contains("$.sceneBoundaries[0].confidence", StringComparison.Ordinal)), "Repair warning did not include JSON path.");
}
static void ChapterStructureWhitespaceDecimalConfidenceRepairs()
{
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(
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<ChapterStructureModel>(
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<ChapterStructureModel>", StringComparison.Ordinal), "Onboarding structural analysis should use the canonical AI JSON parser.");
Assert(!analyzer.Contains("JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson", StringComparison.Ordinal), "Onboarding structural analysis should not directly deserialize chapter JSON.");
Assert(dryRun.Contains("StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>", 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<ChapterStructureModel>(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 void LocationContextualNamesUsePovAndStructuralSetting()
{
var bethScene = new Scene { StructuralSetting = "Beth's bedroom", POVCharacterName = "Beth" };
var maggieScene = new Scene { StructuralSetting = "Maggie's bedroom", POVCharacterName = "Maggie" };
Assert(ResolveContextualLocationName("my bedroom", "bedroom", bethScene) == "Beth's bedroom", "Beth POV plus Beth structural setting should resolve my bedroom to Beth's bedroom.");
Assert(ResolveContextualLocationName("bedroom", "bedroom", maggieScene) == "Maggie's bedroom", "Maggie structural setting should resolve generic bedroom to Maggie's bedroom.");
Assert(LocationCanonicalKey("Beth's bedroom") != LocationCanonicalKey("Maggie's bedroom"), "Owned bedrooms for different POV characters must remain distinct.");
Assert(ResolveContextualLocationName("Narrator's home", null, bethScene) == "Beth's home", "Narrator-owned locations should resolve through canonical POV.");
}
static void LocationCanonicalKeysMergePunctuationAndSafePlurals()
{
Assert(LocationCanonicalKey("Mrs Patterson's house") == LocationCanonicalKey("Mrs. Patterson's house"), "Title punctuation variants should merge.");
Assert(LocationCanonicalKey("garage") == LocationCanonicalKey("garages"), "Safe singular/plural location variants should merge.");
Assert(!IsStoryLocation("road"), "Incidental generic road should not be a strong story location.");
}
static bool IsMergedLocationPhrase(string name)
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
static bool IsStoryLocation(string name)
=> InvokePrivateLocationFilter("IsStoryLocation", name);
static string ResolveContextualLocationName(string name, string? genericRoomType, Scene scene)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"ResolveContextualLocationName",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ResolveContextualLocationName was not found.");
return (string)method!.Invoke(null, [name, genericRoomType, scene])!;
}
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.");
Assert(IsAssetNameCandidate("Memory Tin"), "Structurally significant Memory Tin-style objects should be treated as story assets.");
}
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 AssetCanonicalKeysMergeContextualVehicleVariants()
{
Assert(AssetCanonicalKey("Triumph TR6") == AssetCanonicalKey("TR6"), "Triumph TR6 and TR6 should merge when the model identifies the specific vehicle.");
}
static void AssetContextualNamesResolvePovOwnership()
{
var scene = new Scene { POVCharacterName = "Beth" };
Assert(ResolveContextualAssetName("my memory tin", null, scene) == "Beth's memory tin", "POV ownership should resolve my memory tin to Beth's memory tin.");
Assert(ResolveContextualAssetName("Memory Tin", "Beth", scene) == "Beth's Memory Tin", "Explicit owner evidence should prefix owned significant assets.");
Assert(IsAssetNameCandidate("Beth's Memory Tin"), "Owned Memory Tin-style significant objects should be retained.");
}
static void AssetStructuralPhrasesTrimNeighbouringNouns()
{
Assert(CleanStructuralAssetPhrase("memory tin and photographs") == "memory tin", "Structural asset extraction should not bundle neighbouring nouns after and.");
}
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 RelationshipSignalsInferFriendshipWithoutRomance()
{
Assert(RelationshipTypeFromSignal("trust", "Maggie and Rosie hug, exchange presents and socialise together.") == "Friend", "Repeated trust, hugs, gifts and social outings should infer Friend.");
Assert(RelationshipTypeFromSignal("trust", "Maggie and Rosie hug, exchange presents and socialise together.") != "Romantic", "Friendship evidence must not overclaim Romance.");
}
static void RelationshipEventsPreserveChronologicalStoryOrder()
{
var first = (SceneID: 1, ChapterNumber: 2m, SceneNumber: 1m, SortOrder: 10);
var second = (SceneID: 2, ChapterNumber: 1m, SceneNumber: 8m, SortOrder: 80);
Assert(StoryOrderKey(second).CompareTo(StoryOrderKey(first)) < 0, "Chapter order should outrank repeated scene numbers.");
}
static void KnowledgeSignalsMapToExistingStates()
{
var states = new List<KnowledgeState>
{
new() { KnowledgeStateID = 1, StateName = "Unaware", SortOrder = 10, IsActive = true },
new() { KnowledgeStateID = 2, StateName = "Suspects", SortOrder = 20, IsActive = true },
new() { KnowledgeStateID = 3, StateName = "Partially Knows", SortOrder = 30, IsActive = true },
new() { KnowledgeStateID = 4, StateName = "Knows", SortOrder = 40, IsActive = true },
new() { KnowledgeStateID = 5, StateName = "Misunderstands", SortOrder = 50, IsActive = true }
};
Assert(KnowledgeStateFromSignal("Discovers", "the letter exists", states) == "Knows", "Discovers should map to Knows.");
Assert(KnowledgeStateFromSignal("Suspects", "Maggie is lying", states) == "Suspects", "Suspects should map to Suspects.");
Assert(KnowledgeStateFromSignal("Believes", "Gareth is trustworthy", states) == "Partially Knows", "Believes should map to Partially Knows.");
Assert(KnowledgeStateFromSignal("Misunderstands", "Beth is dead", states) == "Misunderstands", "Misunderstands should map to Misunderstands.");
Assert(KnowledgeStateFromSignal("Forgets", "the hiding place", states) == "Unaware", "Forgets should map to Unaware.");
}
static void KnowledgeDuplicateStatementsShareCanonicalKeys()
{
var first = CanonicalKnowledgeKey("Simon is Beth's father.");
var second = CanonicalKnowledgeKey("Beth discovers Simon is her father.");
var third = CanonicalKnowledgeKey("Beth learns Simon is her biological father.");
Assert(first == second && second == third, $"Expected duplicate father facts to merge, got {first}, {second}, {third}.");
}
static void KnowledgeSuspicionAndCertaintyRemainDistinct()
{
var suspects = KnowledgeKey(617, "Simon is involved.", "Suspects");
var knows = KnowledgeKey(617, "Simon is involved.", "Knows");
Assert(suspects != knows, "Suspicion and certainty must remain distinct knowledge states.");
}
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 ResolveContextualAssetName(string name, string? ownerOrHolder, Scene scene)
{
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
"ResolveContextualAssetName",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ResolveContextualAssetName was not found.");
return (string)method!.Invoke(null, [name, ownerOrHolder, scene])!;
}
static string CleanStructuralAssetPhrase(string name)
{
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
"CleanStructuralAssetPhrase",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "CleanStructuralAssetPhrase was not found.");
return (string)method!.Invoke(null, [name])!;
}
static IComparable StoryOrderKey((int SceneID, decimal ChapterNumber, decimal SceneNumber, int SortOrder) shim)
{
var nested = typeof(StoryIntelligenceRelationshipImportService).GetNestedType(
"RelationshipAppearanceImport",
System.Reflection.BindingFlags.NonPublic);
Assert(nested is not null, "RelationshipAppearanceImport was not found.");
var nestedType = nested!;
var instance = Activator.CreateInstance(nestedType, [
shim.SceneID,
shim.ChapterNumber,
shim.SceneNumber,
shim.SortOrder,
"Scene",
"trust",
"Evidence",
0.8m,
false
]);
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"StoryOrder",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "StoryOrder was not found.");
return (IComparable)method!.Invoke(null, [instance])!;
}
static string KnowledgeKey(int characterId, string statement, string knowledgeStateName)
{
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
"KnowledgeKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "KnowledgeKey was not found.");
return (string)method!.Invoke(null, [characterId, statement, knowledgeStateName])!;
}
static string KnowledgeStateFromSignal(string changeType, string statement, IReadOnlyList<KnowledgeState> states)
{
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
"KnowledgeStateFromSignal",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "KnowledgeStateFromSignal was not found.");
return (string)method!.Invoke(null, [changeType, statement, states])!;
}
static string CanonicalKnowledgeKey(string statement)
{
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
"CanonicalKnowledgeKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "CanonicalKnowledgeKey was not found.");
return (string)method!.Invoke(null, [statement])!;
}
static void IllustrationPromptBuilderSeparatesSpecAndPrompt()
{
var spec = IllustrationStarterBatchDefinition.All.Single(x => x.Code == "asset-folded-letter");
var builder = new IllustrationPromptBuilder();
var result = builder.Build(spec);
Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded.");
Assert(result.TemplateVersion == "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<string> 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<StoryIntelligenceHub>", 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("chapters selected", StringComparison.Ordinal), "Review Chapters should use a compact chapter-only status line.");
Assert(!scanReview.Contains("var displayedSceneCount = isCharacterStep ? Model.SelectedSceneCount : 0;", StringComparison.Ordinal), "Review Chapters should not invent a zero scene status card before scene detection.");
Assert(!scanReview.Contains("var displayedCharacterCount = isCharacterStep ? Model.SelectedCharacterCount : 0;", StringComparison.Ordinal), "Review Chapters should not invent a zero character status card before character review.");
Assert(scanReview.Contains("<h2>Chapters</h2>", 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("var evidenceScenes = CandidateSceneEvidence", StringComparison.Ordinal), "Post-scene character candidates should calculate candidate-specific canonical scene evidence.");
Assert(wordService.Contains("EvidenceScenes = evidenceScenes", StringComparison.Ordinal), "Post-scene character candidates should carry candidate-specific canonical scene evidence.");
Assert(!wordService.Contains("Source SceneIDs", StringComparison.Ordinal), "Post-scene character candidates should not dump every canonical SceneID into every candidate reason.");
}
static void CoreImportCharacterEvidenceIsCandidateSpecific()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var scanReview = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
var models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs"));
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var sources = new List<SceneManuscriptSourceRecord>
{
new() { SceneID = 101, ChapterID = 1, SourceText = "Rosie saw Beth. Beth smiled at Rosie.", StructuralSummary = "Beth and Rosie talk outside." },
new() { SceneID = 102, ChapterID = 1, SourceText = "Rosie waited. Rosie called Colin.", StructuralSummary = "Rosie waits for Colin." },
new() { SceneID = 201, ChapterID = 2, SourceText = "Colin answered Rosie. Colin mentioned Beth.", StructuralSummary = "Colin answers the call." }
};
var candidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sources);
var rosie = candidates.Single(candidate => candidate.Name == "Rosie");
var beth = candidates.Single(candidate => candidate.Name == "Beth");
var colin = candidates.Single(candidate => candidate.Name == "Colin");
Assert(rosie.MentionCount == rosie.EvidenceScenes.Sum(scene => scene.MentionCount), "Mention count should equal summed candidate-specific scene evidence.");
Assert(rosie.EvidenceScenes.Select(scene => scene.SceneID).Order().SequenceEqual(new[] { 101, 102, 201 }), "Rosie evidence should include only scenes where Rosie is mentioned.");
Assert(beth.EvidenceScenes.Select(scene => scene.SceneID).Order().SequenceEqual(new[] { 101, 201 }), "Beth evidence should include only scenes where Beth is mentioned.");
Assert(colin.EvidenceScenes.Select(scene => scene.SceneID).Order().SequenceEqual(new[] { 102, 201 }), "Colin evidence should include only scenes where Colin is mentioned.");
Assert(!beth.EvidenceScenes.Select(scene => scene.SceneID).SequenceEqual(colin.EvidenceScenes.Select(scene => scene.SceneID)), "Different candidates should be able to have different evidence scene sets.");
Assert(models.Contains("ManuscriptScanCharacterEvidenceScene", StringComparison.Ordinal), "Scan candidate model should carry structured evidence scenes.");
Assert(viewModels.Contains("EvidenceSceneCount => EvidenceScenes.Count", StringComparison.Ordinal), "Character review view model should expose distinct evidence scene count.");
Assert(onboarding.Contains("EvidenceScenes = candidate.EvidenceScenes", StringComparison.Ordinal), "Character review model should preserve candidate evidence scenes.");
Assert(scanReview.Contains("mention@(candidate.MentionCount == 1 ? string.Empty : \"s\") across", StringComparison.Ordinal), "Character review should explain mentions across scenes.");
Assert(scanReview.Contains("View evidence", StringComparison.Ordinal), "Character review should collapse evidence behind a disclosure.");
Assert(scanReview.Contains("data-scene-id=\"@evidence.SceneID\"", StringComparison.Ordinal), "Raw SceneIDs should be diagnostics/data attributes, not visible labels.");
Assert(!scanReview.Contains("score @candidate.QualityScore", StringComparison.Ordinal), "Character review should not expose an unexplained score label.");
Assert(scanReview.Contains("!candidate.Reason.Contains(\"Source SceneIDs:\"", StringComparison.Ordinal), "Character review should suppress legacy raw source SceneID strings.");
}
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(@"(?<![\p{{L}}\p{{N}}])", StringComparison.Ordinal), "Matching should use non-letter/number boundaries to avoid substring false positives.");
Assert(service.Contains("MatchSource = MatchSource", StringComparison.Ordinal), "Saved links should carry deterministic evidence source.");
Assert(repository.Contains("SceneManuscriptSource_ListByBook", StringComparison.Ordinal), "Scene source listing should use a stored procedure.");
Assert(migration.Contains("OccurrenceCount int NULL", StringComparison.Ordinal), "Scene-character evidence should persist occurrence counts.");
Assert(migration.Contains("FirstOccurrenceIndex int NULL", StringComparison.Ordinal), "Scene-character evidence should persist first occurrence.");
}
static void CoreImportWizardOrdersScenesBeforeCharacters()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var review = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
var complete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/BuildComplete.cshtml"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
Assert(review.Contains("value=\"structure\"", StringComparison.Ordinal), "Chapter review should submit to structural import, not character review.");
Assert(review.Contains("Continue to Scene Detection", StringComparison.Ordinal), "Chapter review should move to scene detection with clear wording.");
Assert(!review.Contains("Include all scenes", StringComparison.Ordinal), "Chapter review should not show provisional scene bulk controls.");
Assert(!review.Contains("Exclude all scenes", StringComparison.Ordinal), "Chapter review should not show provisional scene bulk controls.");
Assert(!review.Contains("Include scene", StringComparison.Ordinal), "Chapter review should not show provisional scene selectors.");
Assert(!review.Contains("No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.", StringComparison.Ordinal), "Chapter review should not expose one-scene-per-chapter fallback text.");
Assert(review.Split("name=\"intent\" value=\"structure\"", StringSplitOptions.None).Length - 1 == 1, "Chapter review should have exactly one structure progression action.");
Assert(controller.Contains("var readyToImport = string.Equals(intent, \"structure\"", StringComparison.Ordinal), "Controller should treat structure intent as the scene-creation gate.");
Assert(controller.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character review should be guarded until structural import exists.");
Assert(controller.Contains("continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport", StringComparison.Ordinal), "Structure-save errors should remain on chapter review rather than jumping to characters.");
Assert(controller.Contains("FinaliseCharacterReviewAsync", StringComparison.Ordinal), "Character review should have a distinct post-structure finalisation path.");
Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n review = new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Saving chapter review should validate only structure before scene import.");
Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n\n var chapterDecisions", StringComparison.Ordinal), "Building approved structure should not validate unresolved character decisions.");
Assert(onboarding.Contains("BuildSceneStructureSummary(chapterDecisions, structuralScenes, generateSceneTitles)", StringComparison.Ordinal), "Detected scene results should be preserved before character review.");
Assert(onboarding.Contains("ValidateScanReview(new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Character validation should run when finalising character review.");
Assert(onboarding.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character finalisation should refuse to run before structural scenes exist.");
Assert(complete.Contains("Detected scene structure", StringComparison.Ordinal), "Scene detection result page should show detected counts by chapter.");
Assert(complete.Contains("Review characters", StringComparison.Ordinal), "After Word sync, the next onboarding step should be character review.");
Assert(complete.Contains("Keep this browser workflow open to review characters.", StringComparison.Ordinal), "Scene detection completion should continue to character review without marking optional Story Intelligence as required.");
}
static void CoreImportRemapsCharactersAfterReview()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var aliasSave = onboarding.IndexOf("await AddScanCharacterAliasesAsync(completedReview, completedResult)", StringComparison.Ordinal);
var remap = onboarding.IndexOf("await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID)", aliasSave, StringComparison.Ordinal);
Assert(aliasSave >= 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("FirstNonBlank(scene.StructuralSummary, scene.Summary, source.StructuralSummary)", StringComparison.Ordinal), "Scene Intelligence context should prefer canonical scene summaries before source-row fallbacks.");
}
static void StoryIntelligenceCanonicalModeBypassesDuplicateBoundaryAnalysis()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs"));
var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml"));
var progressJs = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-progress.js"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/161_Phase25F_CanonicalSceneSourceStoryIntelligence.sql"));
Assert(runner.Contains("\"CanonicalSceneSource\"", StringComparison.Ordinal), "Runner should have an explicit canonical scene source analysis mode.");
Assert(runner.Contains("BuildCanonicalSceneBlock", StringComparison.Ordinal), "Canonical mode should build scene prompts from existing scenes.");
Assert(runner.Contains("ProcessLegacyChapterAsync", StringComparison.Ordinal), "Legacy Chapter Structure analysis should remain isolated in its fallback path.");
Assert(runner.Contains("builds.ListSceneManuscriptSourcesByChapterAsync", StringComparison.Ordinal), "Canonical mode should load scene source rows directly.");
Assert(runner.Contains("throw new StoryIntelligenceRunFailureException(\n StoryIntelligenceFailureStages.DocumentRead", StringComparison.Ordinal), "Partial canonical source coverage should fail as a data integrity issue.");
Assert(runner.Contains("SceneID = block.SceneID", StringComparison.Ordinal), "Saved scene results should retain canonical SceneIDs.");
Assert(!runner.Contains("existingScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == block.TemporarySceneNumber)", StringComparison.Ordinal), "Runner should not remap canonical scene results by temporary scene number.");
Assert(repository.Contains("SceneManuscriptSource_ListByChapter", StringComparison.Ordinal), "Canonical source lookup should use a stored procedure.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_ListByChapter", StringComparison.Ordinal), "Forward migration should provide the canonical scene source procedure.");
Assert(progressView.Contains("Loading scenes...", StringComparison.Ordinal), "Canonical progress should load scenes rather than claim to find them.");
Assert(progressJs.Contains("Loading scenes...", StringComparison.Ordinal), "Live canonical progress should load scenes rather than claim to find them.");
}
static void StoryIntelligenceProgressUsesCanonicalSceneSummaries()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/StoryIntelligenceHub.cs"));
var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml"));
var progressJs = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-progress.js"));
var css = File.ReadAllText(Path.Combine(root, "wwwroot/css/onboarding.css"));
Assert(onboardingStory.Contains("CurrentCanonicalScene(run, canonicalScenes)", StringComparison.Ordinal), "Progress model should resolve the current canonical scene for each active run.");
Assert(onboardingStory.Contains("CanonicalSceneSummary(currentScene)", StringComparison.Ordinal), "Progress model should use canonical scene summaries.");
Assert(onboardingStory.Contains("FirstConfigured(scene?.StructuralSummary, scene?.Summary)", StringComparison.Ordinal), "Canonical progress summary should prefer Scenes.StructuralSummary before Scenes.Summary.");
Assert(runner.Contains("latestSceneSummary: block.CanonicalSummary", StringComparison.Ordinal), "Live progress events should send the canonical scene summary.");
Assert(runner.Contains("CurrentSceneID = currentSceneId", StringComparison.Ordinal), "Live progress events should carry canonical SceneID.");
Assert(hub.Contains("CurrentCanonicalScene(run, canonicalScenes)", StringComparison.Ordinal), "SignalR reconnect snapshots should also resolve canonical scenes.");
Assert(progressView.Contains("data-story-latest-scene-title", StringComparison.Ordinal), "Progress UI should render the current canonical scene title.");
Assert(progressView.Contains("data-story-current-scene-id", StringComparison.Ordinal), "Rendered chapter state should carry canonical SceneID.");
Assert(!progressView.Contains("Scene summaries will appear here", StringComparison.Ordinal), "Progress UI should not imply Story Intelligence is generating summaries.");
Assert(!progressView.Contains("with summaries, context", StringComparison.Ordinal), "Progress hero should not describe summaries as Story Intelligence work.");
Assert(progressJs.Contains("currentSceneDisplayTitle", StringComparison.Ordinal), "Live browser updates should carry the current canonical scene title.");
Assert(css.Contains("white-space: normal;", StringComparison.Ordinal), "Progress info alert should allow wrapping.");
Assert(!runner.Contains("latestSceneSummary: SceneSummaryText(sceneAttempt.Parsed)", StringComparison.Ordinal), "Live progress should not use the Scene Intelligence parsed summary as the manuscript summary.");
}
static void StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
var model = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs"));
var importServices = new[]
{
"StoryIntelligenceCharacterImportService.cs",
"StoryIntelligenceRelationshipImportService.cs",
"StoryIntelligenceLocationImportService.cs",
"StoryIntelligenceKnowledgeImportService.cs",
"StoryIntelligenceAssetImportService.cs",
"StoryIntelligenceNarrativeReviewService.cs"
};
Assert(model.Contains("public int? SceneID { get; init; }", StringComparison.Ordinal), "Import preview items should carry canonical SceneID.");
Assert(commit.Contains("SceneID = sceneResult.SceneID", StringComparison.Ordinal), "Import preview should preserve the saved scene result ID.");
Assert(repository.Contains("SceneID = item.SceneID ??", StringComparison.Ordinal), "Commit should update canonical scenes by SceneID before falling back to order.");
foreach (var file in importServices)
{
var source = File.ReadAllText(Path.Combine(root, "Services", file));
Assert(source.Contains("var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);", StringComparison.Ordinal), $"{file} should index imported scenes by canonical SceneID.");
Assert(source.Contains("sceneResult.SceneID.HasValue", StringComparison.Ordinal), $"{file} should resolve scene evidence by canonical SceneID before range/number fallbacks.");
}
}
static void StoryIntelligenceScenePromptNoLongerRequestsDuplicateSummary()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
var validator = new StorySceneValidator();
Assert(prompt.Contains("structuralSummary", StringComparison.Ordinal), "Scene prompt should receive canonical structural summary context.");
Assert(prompt.Contains("Do not regenerate, rewrite or compete with that factual scene summary.", StringComparison.Ordinal), "Scene prompt should explicitly avoid duplicate factual summaries.");
Assert(!prompt.Contains("\"summary\":", StringComparison.Ordinal), "Scene prompt output shape should not request a duplicate summary object.");
Assert(!prompt.Contains("## Summary Rules", StringComparison.Ordinal), "Scene prompt should not include factual summary generation instructions.");
var validation = validator.Validate(new SceneIntelligenceScene
{
SchemaVersion = "1.0",
SceneReference = new SceneIntelligenceSceneReference { SourceLabel = "Scene 1" },
ScenePurpose = new SceneIntelligenceScenePurpose { ObservedFunction = "Reveals a threat.", Confidence = 0.8m },
PointOfView = new SceneIntelligencePointOfView { CharacterName = null, NarrativeMode = null, Confidence = 0.7m, Evidence = null },
Setting = new SceneIntelligenceSetting { LocationName = null, LocationType = "unclear", GenericRoomType = null, ParentLocationHint = null, Confidence = 0.6m },
Characters = [],
Locations = [],
Assets = [],
Relationships = [],
KnowledgeChanges = [],
TimelineClues = [],
NarrativeArcs = [],
QuestionsRaised = [],
QuestionsAnswered = [],
Observations = [],
Metrics = [],
SourceLimits = new SceneIntelligenceSourceLimits { ContainsAmbiguity = false, AmbiguityNotes = [] }
});
Assert(validation.Errors.All(error => !error.Path.StartsWith("summary", StringComparison.OrdinalIgnoreCase)), "Validator should allow new-format Scene Intelligence without a summary object.");
}
static void StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var reviewView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReview.cshtml"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
Assert(commit.Contains("BuildReviewScenes(canonicalScenes, sceneResults, importScenes)", StringComparison.Ordinal), "Review confirmation should build canonical scene previews.");
Assert(commit.Contains("CanonicalSummary(scene)", StringComparison.Ordinal), "Review previews should prefer StructuralSummary/Summary from canonical scenes.");
Assert(commit.Contains("Analysis missing", StringComparison.Ordinal), "Canonical scenes without results should still render as missing analysis.");
Assert(commit.Contains("Analysis failed", StringComparison.Ordinal), "Canonical scenes with failed results should still render as failed analysis.");
Assert(commit.Contains("GroupBy(result => result.SceneID!.Value)", StringComparison.Ordinal), "Duplicate mapped SI rows should collapse to one canonical scene preview.");
Assert(onboardingStory.Contains("FirstConfigured(scene.Summary, scene.ParsedScene.Summary?.Short", StringComparison.Ordinal), "Onboarding preview should prefer canonical summary with legacy SI fallback.");
Assert(viewModel.Contains("chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes", StringComparison.Ordinal), "Review headline total should derive from canonical scene previews when available.");
Assert(reviewView.Contains("chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes", StringComparison.Ordinal), "Per-chapter detected count should derive from canonical scene previews.");
Assert(reviewView.Contains("chapter.Scenes.Count(scene => !scene.HasWarnings)", StringComparison.Ordinal), "Ready count should not include missing or failed canonical scene analysis.");
Assert(reviewView.Contains("@scene.AnalysisStatus", StringComparison.Ordinal), "Review UI should show missing/failed analysis state on canonical scenes.");
Assert(repository.Contains("SceneID = item.SceneID ??", StringComparison.Ordinal), "Commit should target canonical SceneID when one exists.");
}
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 models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs"));
var presence = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js"));
var onboardingView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/Index.cshtml"));
var wordHostView = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/158_Phase25C_OnboardingMarkerSaveStates.sql"));
Assert(!companion.Contains("window.confirm(`${manualMessage}", StringComparison.Ordinal), "Companion should not use a blocking browser confirmation for manual Word saves.");
Assert(companion.Contains("setOnboardingManualSavePrompt", StringComparison.Ordinal), "Companion should show an explicit manual-save prompt after adding markers.");
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(hub.Contains("OnboardingBuildManualSaveRequired", StringComparison.Ordinal), "Server should notify the browser when Word needs a manual save.");
Assert(models.Contains("public int? BuildID", StringComparison.Ordinal), "Build progress should identify the build awaiting manual save.");
Assert(models.Contains("public string? MarkerStatus", StringComparison.Ordinal), "Build progress should carry marker sync state to the browser.");
Assert(presence.Contains("OnboardingBuildMarkerSyncCompleted", StringComparison.Ordinal), "Browser should wait for marker sync completion before redirecting.");
Assert(presence.Contains("OnboardingBuildManualSaveRequired", StringComparison.Ordinal), "Browser should render manual-save-required progress events.");
Assert(presence.Contains("CompleteOnboardingBuildMarkerSync\", pendingManualSaveBuildId", StringComparison.Ordinal), "Browser should confirm manual Word saves back to the server.");
Assert(onboardingView.Contains("data-onboarding-build-save-confirm", StringComparison.Ordinal), "Onboarding page should expose a manual Word save confirmation action.");
Assert(wordHostView.Contains("data-onboarding-manual-save-confirm", StringComparison.Ordinal), "Word Companion should expose a manual Word save confirmation action.");
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 complete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/BuildComplete.cshtml"));
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(onboarding.Contains("missingSceneSources", StringComparison.Ordinal), "Retry should detect partial builds with missing scene source text.");
Assert(onboarding.Contains("Completing saved scene source text before updating Word markers", StringComparison.Ordinal), "Retry should repair missing scene sources before marker sync.");
Assert(onboarding.Contains("await SaveSceneManuscriptSourcesAsync(preview, recoveredScenes, existingBuild)", StringComparison.Ordinal), "Retry should persist recovered scene source text for an existing build.");
Assert(onboarding.Contains("await manuscriptTextStatistics.RecalculateBookAsync(preview.BookID)", StringComparison.Ordinal), "Retry should repair missing scene text statistics when source text already exists.");
Assert(complete.Contains("Resync Word markers", StringComparison.Ordinal), "Scene detection complete page should expose a marker repair action for existing imported documents.");
Assert(complete.Contains("Scene detection is not rerun.", StringComparison.Ordinal), "Marker repair UI should explain it reuses existing structure.");
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 = item.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<OpenAIIllustrationImageProvider>.Instance);
Assert(!provider.IsConfigured, "Provider should not be configured without an image model.");
Assert(provider.MissingConfiguration.Count == 1, "Only image model should be missing when API key exists.");
Assert(provider.MissingConfiguration[0].Contains("image-generation model", StringComparison.OrdinalIgnoreCase), "Missing image model should be named.");
}
static void IllustrationProviderOmitsGptImageResponseFormat()
{
string? payload = null;
var handler = new CaptureImageRequestHandler(request =>
{
payload = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent(
"""{"error":{"message":"test failure","code":"invalid_request_error"}}""",
Encoding.UTF8,
"application/json")
};
});
var provider = new OpenAIIllustrationImageProvider(
new HttpClient(handler),
Options.Create(new StoryIntelligenceOptions
{
ApiKey = "configured-for-test",
ImageGenerationModel = "gpt-image-1",
ImageGenerationSize = "1024x1024"
}),
NullLogger<OpenAIIllustrationImageProvider>.Instance);
var result = provider.GenerateAsync(
new IllustrationImageGenerationRequest(1, "asset-folded-letter", IllustrationLibraryCategories.Asset, "Prompt", IllustrationPromptBuilder.CurrentTemplateVersion),
CancellationToken.None).GetAwaiter().GetResult();
Assert(payload is not null, "Provider did not send a payload.");
Assert(!payload!.Contains("response_format", StringComparison.Ordinal), "GPT image payload should not include response_format.");
Assert(payload.Contains("\"model\":\"gpt-image-1\"", StringComparison.Ordinal), "Payload should include the configured image model.");
Assert(result.ErrorMessage?.Contains("invalid_request_error", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error code.");
Assert(result.ErrorMessage?.Contains("test failure", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error message.");
}
static void ScanReviewPostSupportsFullBookFormSubmissions()
{
var method = typeof(OnboardingController).GetMethod(nameof(OnboardingController.SaveScanReview));
Assert(method is not null, "Scan review post action could not be found.");
var formLimits = method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: false)
.OfType<RequestFormLimitsAttribute>()
.SingleOrDefault();
Assert(formLimits is not null, "Scan review post action should configure expanded form limits.");
Assert(formLimits!.ValueCountLimit >= 20000, "Scan review form value limit should support full-book previews.");
Assert(formLimits.KeyLengthLimit >= 4096, "Scan review form key length limit should support indexed review fields.");
var requestSize = method.GetCustomAttributes(typeof(RequestSizeLimitAttribute), inherit: false)
.OfType<RequestSizeLimitAttribute>()
.SingleOrDefault();
Assert(((IRequestSizeLimitMetadata?)requestSize)?.MaxRequestBodySize >= 25 * 1024 * 1024, "Scan review request size limit should support full-book previews.");
}
static void 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<WordCompanionCharacterCandidateDto>)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<WordCompanionCharacterCandidateDto>)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<WordCompanionCharacterCandidateDto>)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<WordCompanionCharacterCandidateDto>)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 CoreImportSceneTitlesRideExistingStructuralAiCall()
{
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"));
Assert(prompt.Contains("suggestedTitle", StringComparison.Ordinal), "Chapter Structure prompt should request a scene title in the existing response.");
Assert(prompt.Contains("2 to 6 words", StringComparison.Ordinal), "Generated title instruction should be concise and bounded.");
Assert(model.Contains("public string? SuggestedTitle", StringComparison.Ordinal), "Chapter boundary model should carry suggested titles.");
Assert(analyzer.Contains("CleanSuggestedTitle(boundary.SuggestedTitle", StringComparison.Ordinal), "Structural analyser should clean title output while processing existing boundaries.");
Assert(!analyzer.Contains("SceneTitle-Prompt", StringComparison.Ordinal), "Scene titles should not use a separate scene-title prompt.");
}
static void CoreImportSceneTitlePreferenceIsReviewScoped()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs"));
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
Assert(models.Contains("public bool GenerateSceneTitles", StringComparison.Ordinal), "Saved scan review should persist the scene-title generation choice.");
Assert(viewModels.Contains("public bool GenerateSceneTitles", StringComparison.Ordinal), "Scan review form/view model should expose the scene-title option.");
Assert(onboarding.Contains("Title = generateSceneTitles ? scene.SuggestedTitle ?? string.Empty : string.Empty", StringComparison.Ordinal), "Unchecked scene title generation should suppress persisted generated titles.");
}
static void CoreImportSceneTitlePreferenceMovedToDetectScenes()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var review = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
var index = File.ReadAllText(Path.Combine(root, "Views/Onboarding/Index.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs"));
Assert(!review.Contains("Generate descriptive scene titles", StringComparison.Ordinal), "Chapter review should not show the scene-title checkbox.");
Assert(index.Contains("data-onboarding-generate-scene-titles", StringComparison.Ordinal), "Detect Scenes should own the scene-title checkbox.");
Assert(script.Contains("StartOnboardingProjectBuild\", previewId, generateSceneTitles", StringComparison.Ordinal), "Detect Scenes should send the title preference when starting the build.");
Assert(hub.Contains("StartOnboardingProjectBuild(Guid previewId, bool generateSceneTitles = true)", StringComparison.Ordinal), "Build hub should accept the title preference without a separate AI call.");
}
static void CoreImportReviewChaptersIsCompactAndChapterOnly()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var review = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
Assert(!review.Contains("Generate descriptive scene titles", StringComparison.Ordinal), "Review Chapters should not render the scene-title option.");
Assert(review.Contains("chapters selected", StringComparison.Ordinal), "Review Chapters should render the selected chapter summary.");
Assert(review.Contains("onboarding-review-chapter-row", StringComparison.Ordinal), "Review Chapters should render compact chapter rows.");
Assert(!review.Contains("<details class=\"onboarding-review-chapter\"", StringComparison.Ordinal), "Review Chapters should not use large disclosure cards for chapter selection.");
Assert(review.Contains("data-bulk-target=\"chapter\" data-bulk-state=\"true\"", StringComparison.Ordinal), "Review Chapters should retain Include all chapters.");
Assert(review.Contains("data-bulk-target=\"chapter\" data-bulk-state=\"false\"", StringComparison.Ordinal), "Review Chapters should retain Exclude all chapters.");
Assert(review.Contains("type=\"reset\">Restore scan defaults", StringComparison.Ordinal), "Review Chapters should retain Restore scan defaults.");
Assert(review.Contains("Continue to Scene Detection", StringComparison.Ordinal), "Review Chapters should retain Continue to Scene Detection.");
Assert(review.Contains("name=\"Chapters[@chapterIndex].Include\"", StringComparison.Ordinal), "Compact rows should still post chapter inclusion choices.");
Assert(review.Contains("name=\"Chapters[@chapterIndex].Title\"", StringComparison.Ordinal), "Compact rows should still post reviewed chapter titles.");
var checkboxIndex = review.IndexOf("type=\"checkbox\" name=\"Chapters[@chapterIndex].Include\"", StringComparison.Ordinal);
var hiddenIndex = review.IndexOf("type=\"hidden\" name=\"Chapters[@chapterIndex].Include\"", StringComparison.Ordinal);
Assert(checkboxIndex >= 0 && hiddenIndex > checkboxIndex, "Chapter include checkbox should post before the hidden false value.");
}
static void CoreImportDetectScenesIsCompactAndOwnsTitleOption()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var index = File.ReadAllText(Path.Combine(root, "Views/Onboarding/Index.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js"));
var analyzer = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs"));
var detectScenesBlock = Between(index, "@if (structureReadyToImport)", "else\n {");
Assert(CountOccurrences(index, "Generate descriptive scene titles") == 1, "Detect Scenes should render the scene-title option exactly once.");
Assert(detectScenesBlock.Contains("data-onboarding-generate-scene-titles checked", StringComparison.Ordinal), "Detect Scenes should keep the existing ON default.");
Assert(detectScenesBlock.Contains("data-word-companion-compact-status-label", StringComparison.Ordinal), "Detect Scenes should render compact Word connection status.");
Assert(detectScenesBlock.Contains("data-word-companion-document", StringComparison.Ordinal), "Detect Scenes should include the current Word document name.");
Assert(detectScenesBlock.Contains("Keep Word open while PlotDirector detects scenes and writes the scene markers.", StringComparison.Ordinal), "Detect Scenes should give the required concise Word-open instruction.");
Assert(detectScenesBlock.Contains("data-onboarding-build-panel hidden", StringComparison.Ordinal), "Detect Scenes should hide processing progress until detection starts.");
Assert(!detectScenesBlock.Contains("onboarding-companion-card", StringComparison.Ordinal), "Detect Scenes should not render the large Word Companion card.");
Assert(!detectScenesBlock.Contains("Word Companion Connected", StringComparison.Ordinal), "Detect Scenes should not render the old dashboard heading.");
Assert(!detectScenesBlock.Contains("data-onboarding-scan-counts", StringComparison.Ordinal), "Detect Scenes should not render old scan counters.");
Assert(!detectScenesBlock.Contains("Ready to detect scenes.", StringComparison.Ordinal), "Detect Scenes should not render the old ready panel.");
Assert(script.Contains("StartOnboardingProjectBuild\", previewId, generateSceneTitles", StringComparison.Ordinal), "Detect Scenes should start the existing canonical scene-detection operation.");
Assert(script.Contains("node.hidden = false;", StringComparison.Ordinal), "Processing state should reveal the build progress panel only when work starts.");
Assert(!analyzer.Contains("SceneTitle-Prompt", StringComparison.Ordinal), "Scene title generation should remain inside the existing chapter-structure call.");
}
static void BookContextIsCanonicalImportMetadata()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var model = File.ReadAllText(Path.Combine(root, "Models/CoreModels.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
var edit = File.ReadAllText(Path.Combine(root, "Views/Books/Edit.cshtml"));
var onboarding = File.ReadAllText(Path.Combine(root, "Views/Onboarding/Index.cshtml"));
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/163_Phase25H_BookContextAndLeanImportFlow.sql"));
Assert(model.Contains("public string? StoryEra", StringComparison.Ordinal), "Book should carry story era as central metadata.");
Assert(model.Contains("public string? PrimaryLocaleCode", StringComparison.Ordinal), "Book should carry primary locale as central metadata.");
Assert(viewModel.Contains("StoryEraOptions", StringComparison.Ordinal) && viewModel.Contains("PrimaryLocaleOptions", StringComparison.Ordinal), "Book edit view model should expose era and locale options.");
Assert(edit.Contains("asp-for=\"StoryEra\"", StringComparison.Ordinal) && edit.Contains("asp-for=\"PrimaryLocaleCode\"", StringComparison.Ordinal), "Book create/edit should expose era and primary locale.");
Assert(onboarding.Contains("name=\"StoryEra\"", StringComparison.Ordinal) && onboarding.Contains("name=\"PrimaryLocaleCode\"", StringComparison.Ordinal), "Onboarding book creation should capture era and primary locale.");
Assert(repository.Contains("book.StoryEra", StringComparison.Ordinal) && repository.Contains("book.PrimaryLocaleCode", StringComparison.Ordinal), "Book repository should pass context through stored procedures.");
Assert(migration.Contains("ALTER TABLE dbo.Books ADD StoryEra", StringComparison.Ordinal), "Book context migration should add StoryEra.");
Assert(migration.Contains("ALTER TABLE dbo.Books ADD PrimaryLocaleCode", StringComparison.Ordinal), "Book context migration should add PrimaryLocaleCode.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Book_Save", StringComparison.Ordinal), "Book context migration should update Book_Save.");
}
static void NamesDefaultsFromSelectedBookContext()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/NameIntelligenceService.cs"));
Assert(service.Contains("ApplyBookDefaults(filter, projectBooks, locales)", StringComparison.Ordinal), "Names library should apply selected-book defaults before searching.");
Assert(service.Contains("filter.LocaleCode = book.PrimaryLocaleCode", StringComparison.Ordinal), "Names library should default locale from Book.PrimaryLocaleCode.");
Assert(service.Contains("BookContextOptions.RepresentativeBirthYear(book.StoryEra)", StringComparison.Ordinal), "Names library should default era as a representative birth year.");
}
static void StoryIntelligenceStartsFromCoreImportComplete()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var complete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/BuildComplete.cshtml"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
Assert(complete.Contains("asp-action=\"StartStoryIntelligence\"", StringComparison.Ordinal), "Core Import Complete should start Story Intelligence directly.");
Assert(!complete.Contains("asp-action=\"StoryIntelligence\"", StringComparison.Ordinal), "Core Import Complete should not route through the redundant ready page.");
Assert(controller.Contains("public async Task<IActionResult> StoryIntelligence()\n {", StringComparison.Ordinal)
&& controller.Contains("return RedirectToAction(nameof(BuildComplete)", StringComparison.Ordinal), "Old ready route should be a compatibility redirect.");
Assert(controller.Contains("public async Task<IActionResult> StoryIntelligenceContext()\n {\n return await StoryIntelligence();", StringComparison.Ordinal), "Old context route should not remain an active wizard page.");
Assert(controller.Contains("var job = await storyIntelligence.StartAsync();", StringComparison.Ordinal), "Story Intelligence start should not require portrait/context form fields.");
Assert(service.Contains("BookContextOptions.StoryEraLabel(book?.StoryEra)", StringComparison.Ordinal), "Story Intelligence should derive era from Book context.");
Assert(service.Contains("BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)", StringComparison.Ordinal), "Story Intelligence should derive locale from Book context.");
}
static void GeneratedSceneTitlesPersistWithProvenance()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var migration = File.ReadAllText(Path.Combine(root, "Sql/162_Phase25G_CoreImportSceneTitles.sql"));
var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs"));
Assert(migration.Contains("ALTER TABLE dbo.Scenes ADD SceneTitleSource", StringComparison.Ordinal), "Scenes should track title provenance.");
Assert(migration.Contains("SceneTitleSource = N'Generated'", StringComparison.Ordinal), "Generated import titles should be stamped as generated.");
Assert(migration.Contains("SceneTitleSource = N'Author'", StringComparison.Ordinal), "Manual scene saves should promote changed titles to author provenance.");
Assert(migration.Contains("ISNULL(SceneTitleSource, N'') <> N'Author'", StringComparison.Ordinal), "Generated title updates must not overwrite author titles.");
Assert(repository.Contains("public string? SuggestedTitle", StringComparison.Ordinal), "Build payload should carry suggested titles through stored-procedure JSON.");
}
static void SceneTitleDisplayKeepsSceneNumbersVisible()
{
Assert(SceneTitleDisplay.Format(7, null) == "Scene 7", "Missing titles should render as the scene number.");
Assert(SceneTitleDisplay.Format(7, "Scene 7") == "Scene 7", "Default placeholder titles should not be repeated.");
Assert(SceneTitleDisplay.Format(7, "Behind the Fire Exit") == "Scene 7 \u00b7 Behind the Fire Exit", "Meaningful titles should render after the scene number.");
}
static void ManuscriptTextStatisticsHandlesPureProse()
{
var stats = CreateTextStatisticsService().Calculate("Beth crossed the car park and kept her phone hidden in her sleeve.");
AssertStats(stats, total: 13, dialogue: 0, prose: 13, paragraphs: 1, dialogueParagraphs: 0, proseParagraphs: 1);
}
static void ManuscriptTextStatisticsHandlesPureDialogue()
{
var stats = CreateTextStatisticsService().Calculate("\u201cI know exactly what happened after the party.\u201d");
AssertStats(stats, total: 8, dialogue: 8, prose: 0, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 0);
}
static void ManuscriptTextStatisticsSplitsNarrativeBeforeDialogue()
{
var stats = CreateTextStatisticsService().Calculate("Beth looked at Rosie. \u201cYou're not seriously suggesting that?\u201d");
AssertStats(stats, total: 9, dialogue: 5, prose: 4, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
}
static void ManuscriptTextStatisticsSplitsDialogueBeforeNarrative()
{
var stats = CreateTextStatisticsService().Calculate("\u201cI'm completely serious,\u201d Rosie said, folding her arms.");
AssertStats(stats, total: 8, dialogue: 3, prose: 5, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
}
static void ManuscriptTextStatisticsHandlesMultipleDialoguePassages()
{
var stats = CreateTextStatisticsService().Calculate("\u201cI'm completely serious,\u201d Rosie said. \u201cAnd you know I'm right.\u201d");
AssertStats(stats, total: 10, dialogue: 8, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
}
static void ManuscriptTextStatisticsSupportsSmartQuotes()
{
var stats = CreateTextStatisticsService().Calculate("Judy whispered, \u201cThe office light is still on.\u201d");
AssertStats(stats, total: 8, dialogue: 6, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
}
static void ManuscriptTextStatisticsSupportsStraightQuotes()
{
var stats = CreateTextStatisticsService().Calculate("Judy whispered, \"The office light is still on.\"");
AssertStats(stats, total: 8, dialogue: 6, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
}
static void ManuscriptTextStatisticsCarriesMultiParagraphDialogue()
{
var stats = CreateTextStatisticsService().Calculate("""
"This is the first paragraph of a long speech.
"This is the second paragraph.
"And this is the final paragraph."
""");
AssertStats(stats, total: 20, dialogue: 20, prose: 0, paragraphs: 3, dialogueParagraphs: 3, proseParagraphs: 0);
}
static void ManuscriptTextStatisticsReturnsToProseAfterContinuedDialogue()
{
var stats = CreateTextStatisticsService().Calculate("""
"This is the first paragraph of a long speech.
"This is the final paragraph."
Rosie shut the notebook and waited.
""");
AssertStats(stats, total: 20, dialogue: 14, prose: 6, paragraphs: 3, dialogueParagraphs: 2, proseParagraphs: 1);
}
static void ManuscriptTextStatisticsIgnoresEmptyParagraphs()
{
var stats = CreateTextStatisticsService().Calculate("""
Beth waited.
"Say something," Rosie said.
""");
AssertStats(stats, total: 6, dialogue: 2, prose: 4, paragraphs: 2, dialogueParagraphs: 1, proseParagraphs: 2);
}
static void ManuscriptTextStatisticsReconcilesSceneTotals()
{
var stats = CreateTextStatisticsService().Calculate("Beth shook her head. \u201cAbsolutely not.\u201d Rosie smiled anyway.");
Assert(stats.DialogueWordCount + stats.ProseWordCount == stats.TotalWordCount, "Dialogue and prose words should reconcile to total words.");
AssertStats(stats, total: 9, dialogue: 2, prose: 7, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
}
static void ManuscriptTextStatisticsAggregatesChapterScenes()
{
var service = CreateTextStatisticsService();
var sceneOne = service.Calculate("Beth stood by the door. \u201cWe leave now.\u201d");
var sceneTwo = service.Calculate("The corridor was empty and cold.");
var aggregate = service.Aggregate([sceneOne, sceneTwo]);
AssertStats(aggregate, total: 14, dialogue: 3, prose: 11, paragraphs: 2, dialogueParagraphs: 1, proseParagraphs: 2);
}
static void ManuscriptTextStatisticsRecalculatesExistingSceneSources()
{
var repository = new FakeManuscriptTextStatisticsRepository("Beth waited. \u201cTell me the truth.\u201d");
var service = new ManuscriptTextStatisticsService(repository, null!);
service.RecalculateSceneAsync(42).GetAwaiter().GetResult();
Assert(repository.SavedSceneID == 42, "Recalculation should save statistics for the requested SceneID.");
Assert(repository.SavedStatistics is not null, "Recalculation should persist calculated statistics.");
AssertStats(repository.SavedStatistics!, total: 6, dialogue: 4, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1);
}
static void ManuscriptTextStatisticsDoesNotInvokeAi()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/ManuscriptTextStatisticsService.cs"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/ManuscriptTextStatisticsRepository.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/164_Phase25I_SceneTextStatistics.sql"));
Assert(!service.Contains("IStoryIntelligence", StringComparison.Ordinal), "Text statistics service should not depend on Story Intelligence.");
Assert(!service.Contains("OpenAI", StringComparison.Ordinal), "Text statistics service should not reference OpenAI.");
Assert(!repository.Contains("CommandType.Text", StringComparison.Ordinal), "Text statistics repository should remain stored-procedure backed.");
Assert(onboarding.Contains("manuscriptTextStatistics.RecalculateSceneAsync(sceneId)", StringComparison.Ordinal), "Core import should calculate stats after canonical source persistence.");
Assert(migration.Contains("CREATE TABLE dbo.SceneTextStatistics", StringComparison.Ordinal), "Scene text statistics should be persisted.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.SceneTextStatistics_Save", StringComparison.Ordinal), "Scene text statistics should be saved by stored procedure.");
Assert(migration.Contains("ON DELETE CASCADE", StringComparison.Ordinal), "Scene text statistics should not block permanent scene deletion.");
}
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("<select class=\"form-select\" name=\"appearancePreference\"", StringComparison.Ordinal), "Appearance guidance must be a dropdown, not a radio list.");
Assert(onboarding.Contains("<select class=\"form-select\" name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Primary age group must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-select\" name=\"storyEra\"", StringComparison.Ordinal), "Story era must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-select\" name=\"storyLocation\"", StringComparison.Ordinal), "Story location must be a dropdown.");
var scanReview = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
Assert(scanReview.Contains(".SexHint", StringComparison.Ordinal) && scanReview.Contains("Non-binary / other", StringComparison.Ordinal), "Character review must expose an editable sex hint dropdown.");
Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction.");
Assert(storyMemory.Contains("SkinToneInstruction(skinTone)", StringComparison.Ordinal), "Demand generation must use hard skin-tone wording.");
Assert(storyMemory.Contains("HairColours.Red", StringComparison.Ordinal) && storyMemory.Contains("SkinTones.Light", StringComparison.Ordinal), "Red-hair demand evidence must force light skin when skin is otherwise unknown.");
Assert(storyMemory.Contains("ApplyPrimaryAgeContext", StringComparison.Ordinal), "Primary age-group context must guide generic adult/unknown character ages.");
Assert(persistedRunner.Contains("AutomaticImageGenerationDuringImportEnabled", StringComparison.Ordinal), "Live imports must gate automatic image generation behind configuration.");
Assert(persistedRunner.Contains("RequireImageGenerationApproval", StringComparison.Ordinal), "Live imports must not queue generation when approval is required.");
Assert(persistedRunner.Contains("MaxImagesPerImport", StringComparison.Ordinal), "Live imports must cap automatic demand generation.");
Assert(storyMemory.Contains("UpsertPlannedAsync(specification", StringComparison.Ordinal), "Durable Story Memory demand must create reusable illustration library items.");
Assert(storyMemory.Contains("QueueAsync(planned.IllustrationLibraryItemID", StringComparison.Ordinal), "Durable Story Memory demand must queue generated library items for the worker.");
Assert(storyMemory.Contains("demand.IllustrationLibraryItemId", StringComparison.Ordinal), "Demand fallback assignments must retain queued item IDs so generated images can attach later.");
Assert(storyMemory.Contains("CategoryCap(options, specification.Category)", StringComparison.Ordinal), "Automatic demand generation should use category-specific caps.");
Assert(storyMemory.Contains("QueuedIllustrationLibraryItemID.HasValue || item.GeneratedIllustrationLibraryItemID.HasValue", StringComparison.Ordinal), "Demand generation cap must count already queued or generated automatic demand for the import.");
Assert(storyMemory.Contains("\"Flyover\"", StringComparison.Ordinal) && storyMemory.Contains("concrete overpass", StringComparison.Ordinal), "Location prompts must distinguish road-under-flyover scenes from generic roads.");
Assert(storyMemory.Contains("semanticType is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Road-family locations must be treated as exterior scenes.");
Assert(matchingService.Contains("type is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Live location demand generation must treat lane/street/road as exterior transport scenes.");
Assert(matchingService.Contains("no indoor corridor", StringComparison.Ordinal), "Road-family location prompts must explicitly reject indoor corridor imagery.");
Assert(storyMemory.Contains("evidence.HairColour", StringComparison.Ordinal), "Character demand keys/specs must include durable hair colour evidence.");
Assert(storyMemory.Contains("Maximum one primary POV", StringComparison.Ordinal) || storyMemory.Contains("ResolveScenePov", StringComparison.Ordinal), "Story Memory must use a single POV resolver.");
Assert(!snapshot.Contains(".Where(character => !sceneCharacterNames.Contains(character.Name))", StringComparison.Ordinal), "Visualisation must not fill scene character slots from memory-only characters.");
Assert(snapshot.Contains("PovResolutionReason = resolved is null ? \"unresolved narrator\" : \"inherited same-chapter POV\"", StringComparison.Ordinal), "Generic narrators should inherit only within the current chapter.");
Assert(!snapshot.Contains("?? bookNarratorName\r\n ?? narratorResolvedName", StringComparison.Ordinal), "Generic narrator resolution must not fall through to stale book-level narrator identity.");
Assert(snapshot.Contains("BuildObservations(scene, resolvedPovName)", StringComparison.Ordinal), "Observation cards should receive the resolved POV identity.");
Assert(snapshot.Contains("NamedPovText", StringComparison.Ordinal), "Observation card text should replace generic narrator wording when POV is known.");
Assert(program.Contains("IStoryIntelligenceDevelopmentResetService", StringComparison.Ordinal), "Admin reset service must be registered.");
Assert(adminController.Contains("ResetStoryIntelligenceImports", StringComparison.Ordinal), "Admin diagnostics must expose the reset post action.");
Assert(adminDiagnostics.Contains("Reset my Story Intelligence data", StringComparison.Ordinal), "Admin diagnostics page must show the reset button.");
Assert(developmentReset.Contains("PreservedIllustrationItems", StringComparison.Ordinal), "Admin reset result must report preserved generated images.");
Assert(!developmentReset.Contains("DELETE FROM dbo.IllustrationLibraryItems", StringComparison.Ordinal), "Admin reset must preserve generated illustration library records.");
Assert(!script.Contains("renderPresenceCallout", StringComparison.Ordinal), "Stage should not draw a bordered character container.");
Assert(script.Contains("drawConnection(id, cluster, locationPoint, \"presence-cluster\", 54, `${presentCharacters.length} here`", StringComparison.Ordinal), "Presence connector should show the location join label.");
Assert(!css.Contains(".story-exp-presence-callout", StringComparison.Ordinal), "Character container styling should be removed.");
Assert(css.Contains("white-space: normal", StringComparison.Ordinal), "Location captions should wrap rather than ellipsise.");
Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not use hidden '+N more' overflow controls.");
Assert(css.Contains("-webkit-line-clamp: 3", StringComparison.Ordinal), "Insight body text should be clamped inside its card.");
Assert(resetSql.Contains("StoryIntelligence_DevelopmentAccountReset", StringComparison.Ordinal), "Phase 21U reset procedure is missing.");
Assert(resetSql.Contains("ProjectHardDelete_ForOwner", StringComparison.Ordinal), "Reset must reuse owner-scoped project hard delete.");
Assert(scenePrompt.Contains("characterAppearance", StringComparison.OrdinalIgnoreCase), "Scene prompt must request structured character appearance.");
Assert(File.Exists(Path.Combine(root, "Sql/144_Phase21V_StoryMemoryImportContext.sql")), "Import context SQL migration is missing.");
}
static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
{
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js"));
var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css"));
var progressViewModel = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/ViewModels/OnboardingViewModels.cs"));
Assert(view.Contains("mode=replay", StringComparison.Ordinal), "Replay links should open the visualisation in replay mode.");
Assert(view.Contains("data-replay-chapter", StringComparison.Ordinal), "Replay scene browser should expose chapter selection.");
Assert(view.Contains("data-detail-panel", StringComparison.Ordinal), "Dense side-panel items should have an expanded detail presentation.");
Assert(view.Contains("Rebuild Visualisation from Saved Analysis", StringComparison.Ordinal), "Replay mode should expose the development rebuild action.");
Assert(script.Contains("const replayMode = root.dataset.mode === \"replay\"", StringComparison.Ordinal), "Browser script should distinguish replay mode.");
Assert(script.Contains("setupReplayBrowser", StringComparison.Ordinal), "Replay browser setup was not found.");
Assert(script.Contains("root.dataset.failedChapters", StringComparison.Ordinal), "Failed chapters should appear as replay browser gaps.");
Assert(script.Contains("if (!realMode || !snapshotUrl || state.terminal) return;", StringComparison.Ordinal), "Replay must not poll live snapshot endpoints.");
Assert(css.Contains(".story-exp-replay-browser", StringComparison.Ordinal), "Replay browser styling was not found.");
Assert(css.Contains("scrollbar-width: none", StringComparison.Ordinal), "Side-panel native scrollbar tracks should be suppressed.");
Assert(css.Contains(".story-exp-rebuild {\n position: fixed;", StringComparison.Ordinal), "Replay rebuild controls should be fixed so they do not resize the whole screen.");
Assert(css.Contains(".story-exp-panel:has(.story-exp-insight-list:empty)", StringComparison.Ordinal), "Empty side panels should collapse instead of reserving large blank space.");
Assert(css.Contains("grid-template-columns: minmax(180px, 0.42fr) minmax(660px, 1.55fr) minmax(380px, 0.84fr);", StringComparison.Ordinal), "Right insight rail should be widened now that assets are not stage nodes.");
Assert(css.Contains("--story-side-body-font-size: 0.72rem;", StringComparison.Ordinal), "Left and right side panels should share body text sizing.");
Assert(!view.Contains("data-assets", StringComparison.Ordinal), "Asset visualisation lane should not be rendered on the main stage.");
Assert(script.Contains("Assets are recorded in insights/diagnostics but not rendered as stage nodes.", StringComparison.Ordinal), "Asset data should remain available without drawing asset stage nodes.");
Assert(script.Contains("width * 0.82 - size / 2", StringComparison.Ordinal), "Location should be a larger right-side anchor once asset nodes are removed.");
Assert(script.Contains("{ x: 0.68, y: 0.26, role: \"context\" }", StringComparison.Ordinal), "Character layout should use more of the stage width.");
Assert(script.Contains("scene.relationships", StringComparison.Ordinal), "Relationship lines should be rendered on the stage.");
Assert(script.Contains("presence-cluster", StringComparison.Ordinal), "Presence should still be shown as a location join.");
Assert(css.Contains(".story-exp-zone--location .story-exp-zone-label {\n left: 80%;", StringComparison.Ordinal), "Location lane label should follow the compact right-side anchor.");
Assert(!css.Contains("grid-template-rows: minmax(218px, 1.35fr) minmax(185px, 1.08fr) auto", StringComparison.Ordinal), "Right rail must not use fixed-height panel tracks.");
Assert(!css.Contains("grid-template-rows: auto auto minmax(170px, 1fr) minmax(96px, 0.56fr)", StringComparison.Ordinal), "Left rail must not use fixed-height panel tracks.");
Assert(script.Contains("shouldUseCharacterPortrait", StringComparison.Ordinal), "Characters should use evidence cards until portrait evidence is strong enough.");
Assert(script.Contains("shouldUseAssetImage", StringComparison.Ordinal), "Assets should use text cards until an object-specific image is confident enough.");
Assert(script.Contains("appearance pending", StringComparison.Ordinal), "Character evidence cards should expose unresolved appearance state.");
Assert(!css.Contains(".story-exp-insight-list {\n display: grid;\n gap: 9px;\n min-height: 0;\n overflow: auto", StringComparison.Ordinal), "Nested insight lists should not create native scrollbars.");
Assert(progressViewModel.Contains("public int? ImportSessionID", StringComparison.Ordinal), "Import progress page should carry the import session for replay links.");
Assert(script.Contains(".filter((relationship) => relationship.weight >= 54)", StringComparison.Ordinal), "Relationship links should render moderate-confidence relationship evidence.");
Assert(script.Contains(".slice(0, 5)", StringComparison.Ordinal), "Relationship drawing should not be limited to two lines.");
}
static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
{
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js"));
var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css"));
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
Assert(!script.Contains("node.dataset.labelSide", StringComparison.Ordinal), "Character labels must not use dynamic side positioning.");
Assert(!css.Contains(".story-character[data-label-side=\"left\"]", StringComparison.Ordinal), "Left-floating character labels must be removed.");
Assert(!css.Contains(".story-character[data-layout-role=\"partner\"] .story-character__text", StringComparison.Ordinal), "Partner labels must not float beside portraits.");
Assert(css.Contains("top: calc(var(--node-size, 150px) + 8px)", StringComparison.Ordinal), "Character captions should be fixed below portraits.");
Assert(css.Contains("background: rgba(4, 10, 20, 0.91)", StringComparison.Ordinal), "Character caption opacity should be increased for readability.");
Assert(css.Contains(".story-exp-insight span {\n display: none;", StringComparison.Ordinal), "Redundant insight tone headings should be hidden.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should classify as Lane.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should classify as Road.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Queens Court") == "Road", "Court should classify as Road.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Station Gardens") == "Road", "Gardens should classify as Road.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("driver seat inside the car") == "DriverSeat", "Driver seat should classify as DriverSeat.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Driving Test Centre waiting room") == "DrivingTestCentre", "Driving test centre should have its own location family.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road kitchen") == "Kitchen", "Functional place context should beat a nearby road name.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road car park") == "CarPark", "Car park context should beat a nearby road name.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("ladies toilets in a bar") == "PublicToilet", "Bar toilets should be PublicToilet.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "Vehicle interiors must not resolve to houses or buildings.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("passport") == "Passport", "Passport should not be Generic Object.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("sealed envelope") == "Envelope", "Envelope should not be Generic Object.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Highway Code book") == "DrivingManual", "Highway Code should classify as a driving manual.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("traffic sign") == "TrafficSign", "Traffic sign should classify as TrafficSign.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("wall clock") == "Clock", "Clock should classify as Clock.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("park bench") == "Bench", "Bench should classify as Bench.");
Assert(snapshot.Contains("type is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Road-family scene locations need a generic road fallback illustration.");
Assert(snapshot.Contains("\"loc-vehicle-interior\" => $\"{FallbackRoot}/location-vehicle-interior.svg\"", StringComparison.Ordinal), "Vehicle interiors need a non-building fallback illustration.");
Assert(snapshot.Contains("\"loc-generic-public-toilet\" => string.Empty", StringComparison.Ordinal), "Public toilets must not reuse domestic bathroom fallback art.");
Assert(snapshot.Contains("\"asset-document\" => $\"{FallbackRoot}/asset-document.svg\"", StringComparison.Ordinal), "Documents need a recognisable document fallback.");
Assert(snapshot.Contains("\"asset-traffic-sign\" => $\"{FallbackRoot}/asset-traffic-sign.svg\"", StringComparison.Ordinal), "Traffic signs need a recognisable fallback.");
}
static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext()
{
var preview = new ManuscriptScanPreview
{
CharacterCandidates =
[
new() { TemporaryCharacterKey = "c-maggie", Name = "Maggie", MentionCount = 8, QualityScore = 94, Category = "ProbableCharacter" },
new() { TemporaryCharacterKey = "c-margaret", Name = "Margaret", MentionCount = 3, QualityScore = 88, Category = "ProbableCharacter" },
new() { TemporaryCharacterKey = "c-mags", Name = "Mags", MentionCount = 2, QualityScore = 78, Category = "PossibleCharacter" },
new() { TemporaryCharacterKey = "c-kevin", Name = "Kevin", MentionCount = 4, QualityScore = 90, Category = "ProbableCharacter" },
new() { TemporaryCharacterKey = "c-road", Name = "Road", MentionCount = 10, QualityScore = 15, Category = "Excluded" }
]
};
var review = new ManuscriptScanReviewDecision
{
Characters =
[
new() { TemporaryCharacterKey = "c-maggie", Include = true, Name = "Maggie", Category = "ProbableCharacter", ExistingCharacterID = 123, SexHint = CharacterSexHintService.Female },
new() { TemporaryCharacterKey = "c-margaret", Include = true, Name = "Margaret", Category = "ProbableCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" },
new() { TemporaryCharacterKey = "c-mags", Include = true, Name = "Mags", Category = "PossibleCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" },
new() { TemporaryCharacterKey = "c-kevin", Include = true, Name = "Kevin", Category = "ProbableCharacter", SexHint = CharacterSexHintService.Male },
new() { TemporaryCharacterKey = "c-road", Include = true, Name = "Road", Category = "Excluded" }
]
};
var json = OnboardingStoryIntelligenceService.BuildKnownCharacterContextJson(preview, review);
Assert(!string.IsNullOrWhiteSpace(json), "Known character context JSON should be produced.");
var context = JsonSerializer.Deserialize<List<StoryIntelligenceKnownCharacterContext>>(json!, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var maggie = context.Single(item => item.CanonicalName == "Maggie");
Assert(maggie.ExistingCharacterID == 123, "Existing character ID should stay on the canonical scan character.");
Assert(maggie.SexHint == CharacterSexHintService.Female, "Known character context should preserve the reviewed sex hint.");
Assert(maggie.Aliases.Contains("Margaret"), "Alias target should include Margaret.");
Assert(maggie.Aliases.Contains("Mags"), "Alias target should include Mags.");
Assert(maggie.MentionCount == 13, $"Alias mention counts should accumulate, got {maggie.MentionCount}.");
Assert(context.Any(item => item.CanonicalName == "Kevin"), "Other included canonical candidates should remain available.");
Assert(context.Single(item => item.CanonicalName == "Kevin").SexHint == CharacterSexHintService.Male, "Male reviewed sex hints should be preserved.");
Assert(!context.Any(item => item.CanonicalName == "Margaret"), "Alias candidates should not become separate canonical context records.");
Assert(!context.Any(item => item.CanonicalName == "Road"), "Excluded scan candidates should not guide Story Intelligence.");
}
static void NameIntelligencePackageContractIsPresent()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Data/Reference/Names/v1.0.0");
var manifestPath = Path.Combine(root, "manifest.json");
Assert(File.Exists(manifestPath), "Name Intelligence manifest is missing.");
foreach (var required in new[] { "README.md", "DATA_CONTRACT.md", "METHODOLOGY.md", "VALIDATION.md", "validation_report.json", "name_locales.csv", "given_names.csv", "name_relationships.csv", "name_usages.csv" })
{
Assert(File.Exists(Path.Combine(root, required)), $"Name Intelligence package file is missing: {required}.");
}
using var manifest = JsonDocument.Parse(File.ReadAllText(manifestPath));
var document = manifest.RootElement;
Assert(document.GetProperty("packageId").GetString() == NameIntelligenceReferencePackage.PackageId, "Unexpected Name Intelligence package id.");
Assert(document.GetProperty("version").GetString() == NameIntelligenceReferencePackage.Version, "Unexpected Name Intelligence package version.");
Assert(CountCsvRows(Path.Combine(root, "name_locales.csv")) == document.GetProperty("files").GetProperty("name_locales").GetProperty("rows").GetInt32(), "Locale row count does not match manifest.");
Assert(CountCsvRows(Path.Combine(root, "given_names.csv")) == document.GetProperty("files").GetProperty("given_names").GetProperty("rows").GetInt32(), "Given-name row count does not match manifest.");
Assert(CountCsvRows(Path.Combine(root, "name_relationships.csv")) == document.GetProperty("files").GetProperty("name_relationships").GetProperty("rows").GetInt32(), "Relationship row count does not match manifest.");
Assert(CountCsvRows(Path.Combine(root, "name_usages.csv")) == document.GetProperty("files").GetProperty("name_usages").GetProperty("rows").GetInt32(), "Usage row count does not match manifest.");
using var validation = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "validation_report.json")));
Assert(validation.RootElement.GetProperty("ok").GetBoolean(), "Bundled Name Intelligence validation report must be green.");
}
static void NameIntelligenceReferenceDataIsCopiedOnPublish()
{
var project = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/PlotLine.csproj"));
Assert(project.Contains(@"Content Include=""Data\Reference\Names\**\*.csv;Data\Reference\Names\**\*.md""", StringComparison.Ordinal), "Name CSV and documentation files must be configured as publish content.");
Assert(project.Contains(@"Content Update=""Data\Reference\Names\**\*.json""", StringComparison.Ordinal), "Name JSON files must be configured as publish content.");
Assert(project.Contains("<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>", StringComparison.Ordinal), "Name reference package must copy to publish output.");
}
static void NameIntelligenceRepositoryUsesStoredProcedures()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var repository = File.ReadAllText(Path.Combine(root, "Data/NameIntelligenceRepository.cs"));
Assert(repository.Contains("CommandType.StoredProcedure", StringComparison.Ordinal), "Name Intelligence repository must call stored procedures.");
Assert(!repository.Contains("SELECT ", StringComparison.OrdinalIgnoreCase), "Name Intelligence repository should not contain inline SELECT SQL.");
Assert(!repository.Contains("INSERT ", StringComparison.OrdinalIgnoreCase), "Name Intelligence repository should not contain inline INSERT SQL.");
Assert(!repository.Contains("UPDATE ", StringComparison.OrdinalIgnoreCase), "Name Intelligence repository should not contain inline UPDATE SQL.");
Assert(File.Exists(Path.Combine(root, "Sql/148_Phase23_NameIntelligence.sql")), "Name Intelligence SQL migration is missing.");
}
static void NameIntelligenceReviewWarningIsWired()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml"));
Assert(service.Contains("BuildNameIntelligenceWarningsAsync", StringComparison.Ordinal), "Story Intelligence character review should build Name Intelligence warnings.");
Assert(service.Contains("RelationshipType, \"SpellingVariant\"", StringComparison.Ordinal), "Name warning should require spelling-variant evidence.");
Assert(service.Contains("nameIntelligence.RecogniseAsync", StringComparison.Ordinal), "Name warning should use the reusable Name Intelligence service.");
Assert(model.Contains("NameIntelligenceWarning", StringComparison.Ordinal), "Character review model should carry the name warning.");
Assert(view.Contains("Name warning", StringComparison.Ordinal), "Character review view should render the name warning.");
}
static void NameIntelligenceLibraryDefaultsToAllLocales()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var model = File.ReadAllText(Path.Combine(root, "Models/NameIntelligenceModels.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Names/Index.cshtml"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/152_NameLibraryAllLocalesDefault.sql"));
Assert(model.Contains("public string? LocaleCode { get; set; }", StringComparison.Ordinal), "Name library filter should not default LocaleCode to a specific country.");
Assert(!model.Contains("LocaleCode { get; set; } = \"en-US\"", StringComparison.Ordinal), "Name library must not default to the United States locale.");
Assert(view.Contains(">All locales</option>", StringComparison.Ordinal), "Name library locale dropdown should expose an All locales option.");
Assert(migration.Contains("DECLARE @NameLocaleID int = NULL", StringComparison.Ordinal), "Name search procedure should keep null locale as all locales.");
Assert(!migration.Contains("COALESCE(@LocaleCode, N'en-US')", StringComparison.Ordinal), "Name search procedure must not convert null locale back to en-US.");
}
static void StoryIntelligenceNewFormatContextUsesCanonicalCharacters()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var model = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs"));
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
Assert(service.Contains("BuildCanonicalKnownCharacterContextJsonAsync(preview, review, build)", StringComparison.Ordinal), "New-format Story Intelligence should build canonical known-character context.");
Assert(service.Contains("characters.ListCharactersAsync(preview.ProjectID)", StringComparison.Ordinal), "Canonical context should include persisted project characters.");
Assert(service.Contains("characters.ListAliasesAsync(character.CharacterID)", StringComparison.Ordinal), "Canonical context should include persisted character aliases.");
Assert(service.Contains("characters.ListSceneCharactersAsync(sceneId)", StringComparison.Ordinal), "Canonical context should include deterministic scene-character mappings.");
Assert(service.Contains("Source = \"CanonicalCoreImport\"", StringComparison.Ordinal), "New-format known-character context should be marked as canonical Core Import context.");
Assert(service.Contains("return BuildKnownCharacterContextJson(preview, review);", StringComparison.Ordinal), "Legacy scan-derived known-character context should remain fallback-only.");
Assert(model.Contains("public IReadOnlyList<int> SceneIDs", StringComparison.Ordinal), "Known-character context should carry mapped canonical scene IDs.");
Assert(prompt.Contains("do not create a character solely because it appears in `sceneCharacters`", StringComparison.Ordinal), "Scene prompt should continue preventing context-only character creation.");
}
static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches()
{
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting,
"A proposed existing character match should default to Link Existing.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Mum", 42) == StoryIntelligenceCharacterImportActions.LinkExisting,
"An explicit/proposed existing character match should beat the generic reference heuristic.");
}
static void StoryIntelligenceCharacterDefaultsIgnoreGenericReferences()
{
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Mum", null) == StoryIntelligenceCharacterImportActions.Ignore,
"Family-role references should default to Ignore.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("One of his mates", null) == StoryIntelligenceCharacterImportActions.Ignore,
"Contextual indefinite references should default to Ignore.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("creepy guy", null, StoryIntelligenceCharacterClassifications.GenericReference) == StoryIntelligenceCharacterImportActions.Ignore,
"Generic classifications should default to Ignore even when the label is descriptive.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("toddler/boy", null, StoryIntelligenceCharacterClassifications.NonCharacter) == StoryIntelligenceCharacterImportActions.Ignore,
"Non-character classifications should default to Ignore.");
}
static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities()
{
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Creepy guy", null) == StoryIntelligenceCharacterImportActions.CreateNew,
"Specific descriptive identities should remain create-new candidates.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Guy who gropes narrator", null) == StoryIntelligenceCharacterImportActions.CreateNew,
"Specific descriptive identities should not be treated as generic group references.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Zoe", null) == StoryIntelligenceCharacterImportActions.CreateNew,
"Named new characters without an existing match should default to Create New.");
}
static void StoryIntelligenceAliasDropdownUsesCanonicalCharacters()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml"));
Assert(model.Contains("AliasTargetOptions", StringComparison.Ordinal), "Character review should expose canonical alias targets.");
Assert(model.Contains("public int? AliasTargetCharacterID", StringComparison.Ordinal), "Alias submissions should post canonical CharacterID targets.");
Assert(service.Contains("AliasTargetOptions = existingOptions", StringComparison.Ordinal), "Alias target options should come from canonical existing characters.");
Assert(service.Contains(".OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal), "Canonical alias targets should be alphabetically ordered.");
Assert(service.Contains("existingIndex.Find(choice.AliasTargetCharacterID)", StringComparison.Ordinal), "Import should resolve alias targets by canonical CharacterID.");
Assert(service.Contains("coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID)", StringComparison.Ordinal), "Character import should rerun deterministic scene-character mapping after accepted aliases or new characters.");
Assert(view.Contains("name=\"Characters[@i].AliasTargetCharacterID\"", StringComparison.Ordinal), "Alias dropdown should post canonical CharacterID values.");
Assert(view.Contains("Model.CharacterReview.AliasTargetOptions", StringComparison.Ordinal), "Alias dropdown should render canonical targets, not unresolved candidates.");
Assert(!view.Contains("Model.CharacterReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key", StringComparison.Ordinal), "Alias dropdown must not be populated from unresolved candidates.");
}
static void StoryIntelligenceReviewOmitsCharacterMetricsDashboard()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
Assert(!view.Contains("_StoryIntelligencePipelineSummary", StringComparison.Ordinal), "Character Review should not render the large cross-stage metrics dashboard.");
Assert(view.Contains("onboarding-compact-status", StringComparison.Ordinal), "Character Review should keep a compact decision summary.");
Assert(model.Contains("GenericReferenceCount", StringComparison.Ordinal), "Compact summary should include useful candidate classification counts.");
Assert(view.IndexOf("story-character-card-grid", StringComparison.Ordinal) > view.IndexOf("onboarding-compact-status", StringComparison.Ordinal), "Candidate cards should appear directly after compact context.");
}
static void StoryIntelligenceResumedChaptersUseCanonicalLabels()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
Assert(service.Contains("chapters.ListByBookAsync(bookId)", StringComparison.Ordinal), "Resume should load canonical chapters for the book.");
Assert(service.Contains("CanonicalChapterTitle(chapterLookup.GetValueOrDefault(run.ChapterID)", StringComparison.Ordinal), "Committed runs should display canonical chapter titles.");
Assert(service.Contains("CanonicalChapterTitle(run.ChapterID.HasValue ? chapterLookup.GetValueOrDefault(run.ChapterID.Value) : null, run.ChapterNumber)", StringComparison.Ordinal), "Active runs should display canonical chapter titles.");
Assert(!service.Contains("FirstConfigured(run.SourceFileName, run.CurrentMessage", StringComparison.Ordinal), "Resume should never prefer Word document filenames as chapter titles.");
Assert(service.Contains("return title.StartsWith(\"Chapter \", StringComparison.OrdinalIgnoreCase) ? title : $\"{fallback} - {title}\"", StringComparison.Ordinal), "Blank titles should fall back to Chapter N and nonblank titles should include chapter number.");
}
static void StoryIntelligenceCharacterReviewPostsSelectedExistingMatch()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
Assert(model.Contains("DefaultAction", StringComparison.Ordinal), "Character review candidates should carry a server-side default action.");
Assert(model.Contains("SelectedExistingCharacterID", StringComparison.Ordinal), "Character review candidates should carry the selected existing match.");
Assert(view.Contains("name=\"Characters[@i].ExistingCharacterID\"", StringComparison.Ordinal), "Review form should post the selected existing character.");
Assert(view.Contains("data-character-existing-panel", StringComparison.Ordinal), "Review form should render an existing-character dropdown.");
Assert(service.Contains("existingIndex.Find(choice.ExistingCharacterID)", StringComparison.Ordinal), "Import should use the posted existing-character selection.");
}
static void StoryIntelligenceCharacterDefaultChangesPreservePersistedResults()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
Assert(service.Contains("runs.ListSceneResultsAsync", StringComparison.Ordinal), "Review should continue to derive candidates from saved scene results.");
Assert(service.Contains("TryReadScene(sceneResult)", StringComparison.Ordinal), "Review should continue to read persisted parsed scene JSON.");
Assert(!service.Contains("SaveSceneResultAsync", StringComparison.Ordinal), "Character review defaults must not overwrite saved Story Intelligence scene results.");
Assert(!service.Contains("QueueAdminTextAsync", StringComparison.Ordinal), "Character review defaults must not queue new Story Intelligence work.");
}
static void StructuralPovRidesExistingChapterAnalysis()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Chapter-Structure-Prompt-V2.md"));
var models = File.ReadAllText(Path.Combine(root, "Models/ChapterStructureModels.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs"));
var normaliser = File.ReadAllText(Path.Combine(root, "Services/ChapterStructureBoundaryNormaliser.cs"));
Assert(prompt.Contains("defaultPov", StringComparison.Ordinal), "Structural prompt should ask for default POV in the existing chapter pass.");
Assert(prompt.Contains("povOverride", StringComparison.Ordinal), "Structural prompt should allow scene-level POV overrides.");
Assert(prompt.Contains("Narrated by Maggie", StringComparison.Ordinal), "Structural prompt should treat explicit narrator labels as strong evidence.");
Assert(prompt.Contains("knownCharacters", StringComparison.Ordinal), "Structural prompt should prefer supplied canonical characters.");
Assert(models.Contains("ChapterStructurePovSuggestion", StringComparison.Ordinal), "Structural response model should carry compact POV suggestions.");
Assert(service.Contains("KnownCharacters { get; init; } = []", StringComparison.Ordinal), "Structural analysis request should carry known character context.");
Assert(service.Contains("SuggestedPovName", StringComparison.Ordinal), "Structural analysis should copy POV suggestions to scene boundaries.");
Assert(normaliser.Contains("PovOverride = boundary.PovOverride", StringComparison.Ordinal), "Boundary repair should preserve scene POV overrides.");
Assert(normaliser.Contains("DefaultPov = chapterStructure.DefaultPov", StringComparison.Ordinal), "Boundary repair should preserve chapter default POV.");
}
static void StructuralPovPersistsThroughImportResolution()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql"));
var mapping = onboarding.IndexOf("await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID)", StringComparison.Ordinal);
var pov = onboarding.IndexOf("await ApplyStructuralPovAsync(preview, structuralScenes, result)", StringComparison.Ordinal);
Assert(mapping >= 0 && pov > mapping, "Structural POV should resolve after deterministic canonical character mapping.");
Assert(onboarding.Contains("BuildCanonicalCharacterNameIndexAsync", StringComparison.Ordinal), "Import POV resolution should reuse canonical character names and aliases.");
Assert(onboarding.Contains("characters.ListAliasesAsync", StringComparison.Ordinal), "Import POV resolution should include accepted aliases.");
Assert(onboarding.Contains("\"StructuralInherited\"", StringComparison.Ordinal), "Inherited scene POV should carry provenance.");
Assert(onboarding.Contains("\"StructuralOverride\"", StringComparison.Ordinal), "Scene POV overrides should carry provenance.");
Assert(migration.Contains("POVSuggestedName", StringComparison.Ordinal), "Unresolved structural POV names should survive as suggested names.");
Assert(migration.Contains("@OverwriteAuthor bit = 0", StringComparison.Ordinal), "Structural POV updates should not overwrite author POV by default.");
}
static void ChapterPovDerivesFromCanonicalScenes()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
var editView = File.ReadAllText(Path.Combine(root, "Views/Chapters/Edit.cshtml"));
Assert(service.Contains("DeriveUniformScenePov(sceneRows)", StringComparison.Ordinal), "Chapter edit/detail should derive chapter POV from canonical scene rows.");
Assert(service.Contains("return derived.DisplayLabel", StringComparison.Ordinal), "Chapter POV display should prefer scene-derived POV when scenes exist.");
Assert(service.Contains("POVCharacterID = hasCanonicalScenes ? null : model.POVCharacterID", StringComparison.Ordinal), "Chapter save should not create a competing persisted chapter POV when canonical scenes exist.");
Assert(service.Contains("chapters.SetScenesPovAsync(chapterId, model.POVCharacterID, overwriteAuthor: false)", StringComparison.Ordinal), "Changed chapter POV control should update the canonical scene POVs.");
Assert(viewModel.Contains("OriginalDerivedPOVCharacterID", StringComparison.Ordinal) && viewModel.Contains("HasCanonicalScenes", StringComparison.Ordinal), "Chapter edit model should preserve the original derived scene POV state.");
Assert(editView.Contains("Current scene POV", StringComparison.Ordinal), "Chapter edit UI should expose that POV is derived from scenes.");
}
static void PermanentAnalyseMyBookStartsFromSavedBook()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var bookView = File.ReadAllText(Path.Combine(root, "Views/Books/Details.cshtml"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var core = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
Assert(bookView.Contains("StartBookStoryIntelligence", StringComparison.Ordinal), "Book details should expose a permanent Analyse My Book action.");
Assert(controller.Contains("story-intelligence/book/{bookId:int}/start", StringComparison.Ordinal), "Permanent Story Intelligence start should have a book-scoped route.");
Assert(service.Contains("Task<OnboardingStoryIntelligenceBatch?> StartBookAsync(int bookId)", StringComparison.Ordinal), "Story Intelligence service should support starting from a saved book.");
Assert(service.Contains("sources.GetChapterSourceAsync(chapter.ChapterID)", StringComparison.Ordinal), "Saved-book analysis should use durable persisted chapter text.");
Assert(service.Contains("pipelineState.RecordChapterAnalysisQueuedAsync(book.ProjectID, book.BookID, runId)", StringComparison.Ordinal), "Saved-book analysis should attach queued runs to the existing book pipeline.");
Assert(core.Contains("BuildStoryIntelligenceAction(pipeline, hasCanonicalSceneSources)", StringComparison.Ordinal), "Book details should determine action state from persisted canonical scene sources.");
}
static void StoryIntelligenceCanonicalContextCarriesProvenance()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var context = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceSceneContextService.cs"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
Assert(context.Contains("StoryIntelligenceCanonicalSceneContext", StringComparison.Ordinal), "Canonical Scene Intelligence should have one explicit context model.");
Assert(context.Contains("sourceParagraphRange", StringComparison.Ordinal), "Canonical context should carry source paragraph provenance.");
Assert(context.Contains("neighbourScenes", StringComparison.Ordinal), "Canonical context should include neighbouring scene summaries without full text.");
Assert(context.Contains("sceneCharacters", StringComparison.Ordinal), "Canonical context should include deterministic scene-character mappings.");
Assert(context.Contains("[property: JsonIgnore] string SourceText", StringComparison.Ordinal), "Canonical source text should not be duplicated inside prompt JSON.");
Assert(runner.Contains("StoryIntelligenceSceneContextService.ToPromptJson(context)", StringComparison.Ordinal), "Runner should use the canonical context service in canonical scene mode.");
Assert(program.Contains("IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService", StringComparison.Ordinal), "Canonical context service should be registered in DI.");
}
static void ScenePromptProtectsCanonicalSceneContext()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
Assert(prompt.Contains("established PlotDirector context", StringComparison.Ordinal), "Scene prompt should distinguish saved PlotDirector context.");
Assert(prompt.Contains("the current scene text block is the only manuscript source to analyse", StringComparison.Ordinal), "Scene prompt should restrict analysis to current source text.");
Assert(prompt.Contains("Do not regenerate scene boundaries, scene titles, scene numbers, chapter numbers, structural summaries, canonical POV or primary setting", StringComparison.Ordinal), "Scene prompt should protect core import facts.");
Assert(prompt.Contains("canonical PlotDirector POV metadata is authoritative", StringComparison.Ordinal), "Scene prompt should treat canonical POV as authoritative.");
Assert(prompt.Contains("do not create `Narrator` as a separate character", StringComparison.Ordinal), "Scene prompt should prevent Narrator character creation.");
}
static void StructuralSettingRidesExistingChapterAnalysis()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Chapter-Structure-Prompt-V2.md"));
var models = File.ReadAllText(Path.Combine(root, "Models/ChapterStructureModels.cs"));
var analysis = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/167_Phase26C_CanonicalStoryIntelligenceContext.sql"));
Assert(prompt.Contains("structuralSetting", StringComparison.Ordinal), "Structural prompt should collect setting in the existing scene-boundary pass.");
Assert(models.Contains("public string? StructuralSetting", StringComparison.Ordinal), "Chapter structure model should parse structural settings.");
Assert(analysis.Contains("StructuralSetting = Trim(boundary.StructuralSetting, 300)", StringComparison.Ordinal), "Structural analysis should carry compact setting to import boundaries.");
Assert(repository.Contains("StructuralSetting = structuralSetting", StringComparison.Ordinal), "Structural setting should be persisted through a stored procedure parameter.");
Assert(migration.Contains("ALTER TABLE dbo.Scenes ADD StructuralSetting", StringComparison.Ordinal), "Forward migration should add durable scene structural setting.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.SceneStructuralSummary_Save", StringComparison.Ordinal), "Forward migration should update the stored procedure used by app code.");
}
static void ExplicitNarratorLabelsDetectDeterministicPov()
{
var beth = ManuscriptStructureAnalysisService.DetectExplicitNarrator("Narrated by Beth - 7th May 1980\n\nI opened the door.");
var maggie = ManuscriptStructureAnalysisService.DetectExplicitNarrator("Narrated by: Maggie\n\nI checked my makeup.");
var narrator = ManuscriptStructureAnalysisService.DetectExplicitNarrator("narrator: Miss Grant\n\nThe test began.");
var pov = ManuscriptStructureAnalysisService.DetectExplicitNarrator("POV: Mrs Patterson\n\nShe waited.");
var absent = ManuscriptStructureAnalysisService.DetectExplicitNarrator("The morning was quiet.\n\nI opened the curtains.");
var numbered = StoryIntelligenceParagraphs.Number(StoryIntelligenceParagraphs.Split("Narrated by Beth\n\nI opened the door."));
Assert(beth?.CharacterName == "Beth", "Narrated by Beth should detect Beth.");
Assert(maggie?.CharacterName == "Maggie", "Narrated by: Maggie should detect Maggie.");
Assert(narrator?.CharacterName == "Miss Grant", "Narrator: should support titled names.");
Assert(pov?.CharacterName == "Mrs Patterson", "POV: should support titled names.");
Assert(beth?.Confidence == 0.99m && beth.IsExplicit == true, "Explicit narrator detection should be high-confidence explicit metadata.");
Assert(absent is null, "Plain prose should not produce deterministic narrator metadata.");
Assert(numbered.Contains("Narrated by Beth", StringComparison.Ordinal), "Structural preprocessing should preserve the narrator line in prompt input.");
}
static void AiPovJsonParsesIntoStructuralModels()
{
const string json = """
{
"chapterTitle": "Danger in the Dark",
"chapterSummary": "Maggie attends Rosie's birthday.",
"defaultPov": { "characterName": "Maggie", "confidence": 0.93, "evidence": "Narrated by Maggie", "isExplicit": true },
"mixedPov": true,
"sceneBoundaries": [
{
"sceneNumber": 1,
"startParagraph": 1,
"endParagraph": 4,
"structuralSummary": "Maggie phones Rosie.",
"suggestedTitle": "Morning Call",
"povOverride": null,
"confidence": 0.9,
"reason": "Time and action shift."
},
{
"sceneNumber": 2,
"startParagraph": 5,
"endParagraph": 8,
"structuralSummary": "Beth takes over the viewpoint.",
"suggestedTitle": "Beth Outside",
"povOverride": { "characterName": "Beth", "confidence": 0.88, "evidence": "Viewpoint shifts to Beth.", "isExplicit": false },
"confidence": 0.82,
"reason": "Viewpoint shift."
}
]
}
""";
var parsed = JsonSerializer.Deserialize<ChapterStructureModel>(json, JsonOptions());
Assert(parsed?.DefaultPov?.CharacterName == "Maggie", "AI-returned defaultPov should parse.");
Assert(parsed?.MixedPov == true, "AI-returned mixedPov should parse.");
Assert(parsed is not null && parsed.SceneBoundaries[1].PovOverride?.CharacterName == "Beth", "AI-returned scene povOverride should parse.");
}
static void PostCharacterReviewResolvesPersistedStructuralPov()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/166_Phase26B_StructuralPovAudit.sql"));
var aliases = onboarding.IndexOf("await AddScanCharacterAliasesAsync(completedReview, completedResult)", StringComparison.Ordinal);
var remap = onboarding.IndexOf("await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID)", aliases, StringComparison.Ordinal);
var resolve = onboarding.IndexOf("await ResolvePersistedStructuralPovAsync(preview.ProjectID, preview.BookID)", remap, StringComparison.Ordinal);
Assert(aliases >= 0 && remap > aliases && resolve > remap, "POV should resolve after accepted aliases and deterministic character mapping.");
Assert(onboarding.Contains("NormalisePovName", StringComparison.Ordinal), "POV resolution should normalise case and punctuation.");
Assert(onboarding.Contains("AddCharacterNameParts", StringComparison.Ordinal), "POV resolution should support unambiguous first names, surnames and title-stripped variants.");
Assert(onboarding.Contains("string.Equals(scene.POVSource, \"Author\"", StringComparison.Ordinal), "POV resolution should preserve author-edited scene POV.");
Assert(migration.Contains("StructuralParsedJson", StringComparison.Ordinal), "Structural source audit should persist parsed structural JSON.");
Assert(migration.Contains("ExplicitNarratorName", StringComparison.Ordinal), "Structural source audit should persist deterministic narrator metadata.");
}
static void NormalSceneAndChapterSavesPersistPov()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
var services = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
var chaptersEdit = File.ReadAllText(Path.Combine(root, "Views/Chapters/Edit.cshtml"));
var chaptersDetails = File.ReadAllText(Path.Combine(root, "Views/Chapters/Details.cshtml"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql"));
Assert(repository.Contains("chapter.POVCharacterID", StringComparison.Ordinal), "Chapter repository should pass POVCharacterID to Chapter_Save.");
Assert(repository.Contains("scene.POVCharacterID", StringComparison.Ordinal), "Scene repository should pass POVCharacterID to Scene_Save.");
Assert(services.Contains("POVCharacterID = model.POVCharacterID", StringComparison.Ordinal), "Normal scene/chapter services should persist submitted POV values.");
Assert(chaptersEdit.Contains("asp-for=\"POVCharacterID\"", StringComparison.Ordinal), "Chapter edit should expose a compact POV selector.");
Assert(chaptersDetails.Contains("Apply to scenes", StringComparison.Ordinal), "Chapter details should allow POV propagation to scenes.");
Assert(chaptersDetails.Contains("POV: @Model.PovDisplayLabel", StringComparison.Ordinal), "Chapter details should display compact POV status.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Scene_Save", StringComparison.Ordinal), "New migration should own the Scene_Save parameter update.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Chapter_Save", StringComparison.Ordinal), "New migration should own the Chapter_Save parameter update.");
}
static void CharacterMergeIsReusableStoredProcedureWorkflow()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
var services = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/CharactersController.cs"));
var details = File.ReadAllText(Path.Combine(root, "Views/Characters/Details.cshtml"));
var merge = File.ReadAllText(Path.Combine(root, "Views/Characters/Merge.cshtml"));
var siCharacters = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql"));
Assert(repository.Contains("Task<CharacterMergeResult?> MergeAsync", StringComparison.Ordinal), "Character repository should expose a reusable merge operation.");
Assert(services.Contains("Task<CharacterMergeViewModel?> GetMergeAsync", StringComparison.Ordinal), "Character service should expose merge UI data.");
Assert(controller.Contains("public async Task<IActionResult> Merge(int id)", StringComparison.Ordinal), "Normal Character UI should expose merge GET.");
Assert(controller.Contains("public async Task<IActionResult> Merge(CharacterMergeForm form)", StringComparison.Ordinal), "Normal Character UI should expose merge POST.");
Assert(details.Contains("Merge with another character", StringComparison.Ordinal), "Character details should link to merge.");
Assert(merge.Contains("References, aliases, scene appearances, POV links, relationship events and knowledge are moved", StringComparison.Ordinal), "Merge UI should explain reference propagation.");
Assert(siCharacters.Contains("characters.MergeAsync(candidate.ExistingCharacterID.Value, targetCharacterId.Value", StringComparison.Ordinal), "Story Intelligence aliasing should reuse canonical merge when an existing duplicate is known.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Character_Merge", StringComparison.Ordinal), "Character merge should be database-backed.");
Assert(migration.Contains("UPDATE dbo.RelationshipEvents", StringComparison.Ordinal), "Merge should move relationship events when relationships converge.");
Assert(migration.Contains("Archived during character merge to avoid a self-relationship", StringComparison.Ordinal), "Merge should archive self-relationships instead of flattening them.");
}
static void StoryIntelligenceCharacterReviewSuppressesResolvedNarratorPov()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
Assert(service.Contains("!importedScene.POVCharacterID.HasValue", StringComparison.Ordinal), "Scene Intelligence POV candidates should not be reintroduced when canonical scene POV already exists.");
Assert(service.Contains("IsResolvedPovReference(cleanName, importedScene)", StringComparison.Ordinal), "Narrator-style observed names should be suppressed when scene POV is resolved.");
Assert(service.Contains("IgnoredNames.Contains(Normalise(name))", StringComparison.Ordinal), "Suppression should reuse existing generic/contextual reference filtering.");
}
static void StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
Assert(service.Contains("parsed.CharacterAppearance", StringComparison.Ordinal), "Character review should inspect structured character appearance data.");
Assert(service.Contains("AddCharacterAppearanceAliases", StringComparison.Ordinal), "Structured appearance aliases should feed character candidates.");
Assert(service.Contains("Aliases = appearance.Aliases", StringComparison.Ordinal), "Structured appearance aliases should be reused without another AI call.");
}
static void StoryIntelligenceReviewEvidenceIncludesChapterSceneLabels()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var characterService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
var locationService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
var assetService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceAssetImportService.cs"));
var relationshipService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceRelationshipImportService.cs"));
var knowledgeService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceKnowledgeImportService.cs"));
var locationView = File.ReadAllText(Path.Combine(root, "Views/Locations/Details.cshtml"));
Assert(characterService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Character review first appearances should include chapter and scene.");
Assert(locationService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Location review first appearances should include chapter and scene.");
Assert(assetService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Asset review first appearances should include chapter and scene.");
Assert(relationshipService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Relationship review first appearances should include chapter and scene.");
Assert(knowledgeService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Knowledge review first appearances should include chapter and scene.");
Assert(locationView.Contains("SceneLabel(scene.ChapterNumber, scene.SceneNumber)", StringComparison.Ordinal), "Location detail scene links should include chapter context.");
}
static void StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
Assert(service.Contains("await metricTypes.AddMissingDefaultsAsync(run.ProjectID.Value)", StringComparison.Ordinal), "Story Intelligence import should enable default metric types for the project before committing scenes.");
Assert(service.Contains("metric.IsActive && metric.IsEnabledForProject", StringComparison.Ordinal), "Story Intelligence import should only save metrics enabled for the project.");
Assert(service.IndexOf("await metricTypes.AddMissingDefaultsAsync(run.ProjectID.Value)", StringComparison.Ordinal)
< service.IndexOf("ListForManagementAsync(run.ProjectID.Value)", StringComparison.Ordinal), "Metric defaults must be enabled before metric types are loaded.");
}
static void ProjectMetricsAddDefaultsBackfillsSceneValues()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var core = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/ProjectMetricsController.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/151_BackfillDefaultSceneMetricValues.sql"));
var selfHealingDefaults = File.ReadAllText(Path.Combine(root, "Sql/153_SelfHealingProjectMetricDefaults.sql"));
Assert(core.Contains("Task<int> AddMissingDefaultsAsync(int projectId)", StringComparison.Ordinal), "Metric defaults service should report inserted scene values.");
Assert(core.Contains("BackfillDefaultValuesForProjectAsync(projectId)", StringComparison.Ordinal), "Metric defaults service should backfill scene metric values.");
Assert(repository.Contains("SceneMetric_BackfillDefaultsForProject", StringComparison.Ordinal), "Metric repository should call the scene metric backfill procedure.");
Assert(controller.Contains("backfilled", StringComparison.Ordinal), "Metric controller should tell users when scene values were backfilled.");
Assert(migration.Contains("NOT EXISTS", StringComparison.Ordinal), "Metric backfill must not overwrite existing scene metric values.");
Assert(selfHealingDefaults.Contains("MERGE dbo.SceneMetricTypes", StringComparison.Ordinal), "Add missing defaults should seed missing global metric types.");
Assert(selfHealingDefaults.Contains("Overall Intensity", StringComparison.Ordinal), "Core metric seed should include the default PlotDirector metrics.");
}
static void StoryIntelligenceScenePromptUsesConfiguredMetrics()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
var validator = File.ReadAllText(Path.Combine(root, "Services/StorySceneValidator.cs"));
Assert(prompt.Contains("configuredMetrics", StringComparison.Ordinal), "Scene prompt should read configured metrics from scene context.");
Assert(prompt.Contains("use the exact `key` supplied in `sceneContext`", StringComparison.Ordinal), "Scene prompt should use runtime metric keys.");
Assert(prompt.Contains("Do not invent a separate metric catalogue", StringComparison.Ordinal), "Scene prompt should not embed an independent metric catalogue.");
Assert(!prompt.Contains("Preferred V1 metrics:", StringComparison.Ordinal), "Scene prompt should not force the old V1 metric list.");
Assert(!validator.Contains("PreferredMetrics", StringComparison.Ordinal), "Validator should not reject custom configured metric names.");
Assert(validator.Contains("configured metric scale", StringComparison.Ordinal), "Validator error guidance should refer to configured metric scales.");
}
static void StoryIntelligenceScenePromptUsesSceneCharactersTerminology()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
Assert(prompt.Contains("`sceneCharacters`", StringComparison.Ordinal), "Scene prompt should document the canonical sceneCharacters contract.");
Assert(!prompt.Contains("`knownCharacters`", StringComparison.Ordinal), "Scene prompt should not describe a separate knownCharacters contract.");
Assert(!prompt.Contains("\"subjectName\": \"Narrator\"", StringComparison.Ordinal), "Scene prompt examples should not preserve Narrator as the subject when POV is canonical.");
}
static void StoryIntelligenceDownstreamImportsSupportCanonicalSceneResults()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
foreach (var file in new[]
{
"StoryIntelligenceCharacterImportService.cs",
"StoryIntelligenceLocationImportService.cs",
"StoryIntelligenceAssetImportService.cs",
"StoryIntelligenceRelationshipImportService.cs",
"StoryIntelligenceKnowledgeImportService.cs",
"StoryIntelligenceNarrativeReviewService.cs"
})
{
var source = File.ReadAllText(Path.Combine(root, "Services", file));
Assert(source.Contains("sceneResults.Any(result => result.SceneID.HasValue)", StringComparison.Ordinal), $"{file} should detect canonical scene-result mode.");
Assert(source.Contains("? chapterScenes", StringComparison.Ordinal), $"{file} should resolve canonical SceneID results against saved chapter scenes.");
Assert(source.Contains(": chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList()", StringComparison.Ordinal), $"{file} should retain the legacy import-run fallback.");
}
}
static void StoryIntelligenceRunnerSuppliesMetricsAndPlotLines()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
Assert(runner.Contains("ISceneMetricTypeRepository metricTypes", StringComparison.Ordinal), "Runner should load metric definitions from the configured project repository.");
Assert(runner.Contains("IPlotRepository plots", StringComparison.Ordinal), "Runner should load existing Plot Lines for AI matching context.");
Assert(runner.Contains("metric.IsActive && metric.IsEnabledForProject", StringComparison.Ordinal), "Runner should only pass active project-enabled metric types.");
Assert(runner.Contains("configuredMetrics = configuredMetrics.Select", StringComparison.Ordinal), "Scene context should include configured metrics.");
Assert(runner.Contains("existingPlotLines = existingPlotLines.Select", StringComparison.Ordinal), "Scene context should include existing author-created Plot Lines.");
Assert(runner.Contains("key = MetricContextKey(metric)", StringComparison.Ordinal), "Scene prompt metrics should use stable configured keys.");
Assert(runner.Contains("metricTypeId = metric.MetricTypeID", StringComparison.Ordinal), "Scene context should preserve MetricTypeID for later persistence.");
}
static void StoryIntelligenceMetricPersistenceUsesConfiguredKeys()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
Assert(service.Contains("map[$\"metric_{metric.MetricTypeID}\"] = metric", StringComparison.Ordinal), "Metric persistence should map configured context keys back to MetricTypeID.");
Assert(service.Contains("map.TryAdd(cleanedName, metric)", StringComparison.Ordinal), "Metric persistence should also accept configured metric names.");
Assert(service.Contains("Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue)", StringComparison.Ordinal), "Metric values should be clamped to configured scale.");
Assert(repository.Contains("\"dbo.SceneMetric_SaveValue\"", StringComparison.Ordinal), "Metric values should persist through the existing stored procedure.");
Assert(repository.Contains("new { SceneID = sceneId, metric.MetricTypeID, metric.Value, metric.Notes }", StringComparison.Ordinal), "Metrics should persist by SceneID and MetricTypeID.");
}
static void StoryIntelligenceNarrativeCandidatesRequireReview()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql"));
var models = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceNarrativeModels.cs"));
var sceneModels = File.ReadAllText(Path.Combine(root, "Models/SceneIntelligenceModels.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml"));
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
var projectDelete = File.ReadAllText(Path.Combine(root, "Sql/124_Phase20Z2_StoryIntelligenceCommitAndProjectHardDelete.sql"));
var bookDelete = File.ReadAllText(Path.Combine(root, "Sql/134_Phase20AV_ArchivedBookProjectPermanentDeletion.sql"));
Assert(sql.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Narrative candidates should be persisted for review.");
Assert(sql.Contains("Status nvarchar(20) NOT NULL", StringComparison.Ordinal), "Narrative candidates should have review status.");
Assert(sql.Contains("CONSTRAINT DF_StoryIntelligenceNarrativeCandidates_Status DEFAULT N'Pending'", StringComparison.Ordinal), "Narrative candidates should default to pending review.");
Assert(models.Contains("StoryIntelligenceNarrativeCandidateTypes", StringComparison.Ordinal), "Candidate types should be explicit.");
Assert(sceneModels.Contains("SceneIntelligenceNarrativeArc", StringComparison.Ordinal), "Scene intelligence should expose explicit narrative arc signals.");
Assert(prompt.Contains("narrativeArcs", StringComparison.Ordinal) && prompt.Contains("matchingExistingPlotLineId", StringComparison.Ordinal), "Scene prompt should request narrative arc discovery and existing Plot Line matching.");
Assert(service.Contains("Where(signal => signal.SceneIds.Count > 1)", StringComparison.Ordinal), "Thread discovery should filter one-off incidental details.");
Assert(view.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "A dedicated narrative review page should be visible.");
Assert(view.Contains("Accept new", StringComparison.Ordinal) && view.Contains("Merge with existing", StringComparison.Ordinal) && view.Contains("Reject", StringComparison.Ordinal), "Review UI should support accept, merge and reject.");
Assert(projectDelete.Contains("StoryIntelligenceNarrativeCandidateEvidence", StringComparison.Ordinal) && projectDelete.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Project hard delete should clear narrative review candidates.");
Assert(bookDelete.Contains("StoryIntelligenceNarrativeCandidateEvidence", StringComparison.Ordinal) && bookDelete.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Book hard delete should clear narrative review candidates.");
}
static void StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration()
{
var signals = new[]
{
new StoryIntelligenceNarrativeSignalInput(101, "Maggie's Disappearance", "Beth notices Maggie is gone and starts looking for her.", "Plot Line", "disappearance", 0.86m, null, "Maggie is missing."),
new StoryIntelligenceNarrativeSignalInput(102, "Search for Maggie", "Beth follows a clue that may explain where Maggie went.", "Plot Line", "investigation", 0.82m, null, "Beth follows a clue."),
new StoryIntelligenceNarrativeSignalInput(103, "Finding Maggie", "The search for Maggie reaches a possible answer.", "Plot Line", "disappearance", 0.8m, null, "Maggie may have been found.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []);
var plotLine = consolidated.SingleOrDefault(signal => signal.Category == "Plot Line");
Assert(plotLine is not null, "Zero existing Plot Lines should still allow a new Plot Line candidate signal.");
if (plotLine is null) throw new InvalidOperationException("Expected a consolidated Plot Line signal.");
Assert(plotLine.ExistingPlotLineID is null, "A discovered Plot Line should not require an existing PlotLineID.");
Assert(plotLine.SceneIds.Count == 3, "The new Plot Line candidate should consolidate evidence from multiple scenes.");
Assert(plotLine.Evidence.Select(evidence => evidence.SceneID).Distinct().Count() == 3, "The candidate should retain evidence scene IDs.");
}
static void StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates()
{
var existingPlotLines = new[]
{
new PlotLineItem
{
PlotLineID = 42,
PlotLineName = "Maggie's Disappearance",
Description = "Beth searches for Maggie after she vanishes."
}
};
var signals = new[]
{
new StoryIntelligenceNarrativeSignalInput(201, "Search for Maggie", "Beth follows a clue in Maggie's disappearance.", "Plot Line", "investigation", 0.84m, null, "Beth follows a clue."),
new StoryIntelligenceNarrativeSignalInput(202, "Missing Maggie", "Maggie's absence drives the investigation.", "Plot Line", "disappearance", 0.81m, null, "Maggie remains missing."),
new StoryIntelligenceNarrativeSignalInput(203, "Where is Maggie?", "A lead suggests where Maggie may have gone.", "Plot Line", "disappearance", 0.79m, null, "A lead points to Maggie.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, existingPlotLines);
Assert(consolidated.Count == 1, "Matching scene variants should consolidate into one existing Plot Line signal, not duplicate candidates.");
Assert(consolidated[0].ExistingPlotLineID == 42, "The consolidated signal should carry the matching existing PlotLineID.");
Assert(consolidated[0].SceneIds.Count == 3, "The existing Plot Line match should retain evidence from multiple scenes.");
}
static void StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql"));
Assert(service.Contains("plots.SavePlotLineAsync(new PlotLineItem", StringComparison.Ordinal), "Accepted Plot Line candidates should create canonical PlotLines through the existing plot service.");
Assert(service.Contains("plots.SavePlotThreadAsync(new PlotThread", StringComparison.Ordinal), "Accepted Thread candidates should create canonical PlotThreads through the existing plot service.");
Assert(service.Contains("plots.SaveThreadEventAsync(new ThreadEvent", StringComparison.Ordinal), "Accepted Thread evidence should create canonical ThreadEvents.");
Assert(repository.Contains("\"dbo.PlotLine_Save\"", StringComparison.Ordinal), "PlotLine creation should remain stored-procedure based.");
Assert(repository.Contains("\"dbo.PlotThread_Save\"", StringComparison.Ordinal), "PlotThread creation should remain stored-procedure based.");
Assert(repository.Contains("\"dbo.ThreadEvent_Save\"", StringComparison.Ordinal), "ThreadEvent creation should remain stored-procedure based.");
Assert(sql.Contains("StoryIntelligenceNarrativeCandidate_MarkReviewed", StringComparison.Ordinal), "Candidate decisions should be persisted.");
}
static void StoryIntelligenceNarrativeStageFollowsKnowledge()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var stages = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs"));
var pipeline = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var knowledgeComplete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceKnowledgeComplete.cshtml"));
var pipelineConstraint = File.ReadAllText(Path.Combine(root, "Sql/156_Phase25A_NarrativeReviewPipelineConstraint.sql"));
var knowledgeImport = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceKnowledgeImportService.cs"));
Assert(stages.Contains("NarrativeReview", StringComparison.Ordinal) && stages.Contains("NarrativeImport", StringComparison.Ordinal), "Pipeline stages should include narrative review/import.");
Assert(stages.Contains("LastCompletedStage, StoryIntelligencePipelineStages.NarrativeImport", StringComparison.Ordinal), "Pipeline completion should require narrative import.");
Assert(pipeline.Contains("CurrentStage = StoryIntelligencePipelineStages.NarrativeReview", StringComparison.Ordinal), "Knowledge import should lead to narrative review.");
Assert(pipeline.Contains("RecordNarrativeImportAsync", StringComparison.Ordinal), "Narrative import should mark the pipeline complete.");
Assert(pipelineConstraint.Contains("N'NarrativeReview'", StringComparison.Ordinal) && pipelineConstraint.Contains("N'NarrativeImport'", StringComparison.Ordinal), "Database pipeline constraint should allow narrative stages.");
Assert(knowledgeImport.Contains("hasUndecidedCandidates", StringComparison.Ordinal), "Knowledge review should be able to advance when prior decisions already imported every candidate.");
Assert(onboarding.Contains("StoryIntelligenceResumeRoutes.Narrative", StringComparison.Ordinal), "Resume logic should route to narrative review.");
Assert(controller.Contains("StoryIntelligenceNarrative", StringComparison.Ordinal), "Onboarding controller should expose the narrative review stage.");
Assert(knowledgeComplete.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "Knowledge completion should lead to narrative review.");
}
static void WordCompanionReplaysLiveCurrentSceneToWritingPanes()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs"));
var presence = File.ReadAllText(Path.Combine(root, "Services/WordCompanionPresenceService.cs"));
var workspace = File.ReadAllText(Path.Combine(root, "Services/WritingWorkspacePresenceService.cs"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
Assert(controller.Contains("presence.UpdateCurrentStoryPositionAsync", StringComparison.Ordinal), "Word current-scene updates should update live presence.");
Assert(presence.Contains("CurrentStoryPosition = connected ? record.CurrentStoryPosition : null", StringComparison.Ordinal), "Presence should only replay current scene while Word is live.");
Assert(workspace.Contains("CurrentStoryPosition = wordStatus.CurrentStoryPosition", StringComparison.Ordinal), "Workspace status should carry Word's current scene.");
Assert(script.Contains("storyPositionFromWorkspaceStatus", StringComparison.Ordinal), "Writing panes should read the current scene from workspace status.");
Assert(script.Contains("WatchWritingWorkspace", StringComparison.Ordinal), "Writing panes should request scoped workspace status on startup.");
}
static int CountCsvRows(string path)
=> File.ReadLines(path).Skip(1).Count(line => !string.IsNullOrWhiteSpace(line));
static int CountOccurrences(string value, string search)
{
var count = 0;
var index = 0;
while ((index = value.IndexOf(search, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += search.Length;
}
return count;
}
static string Between(string value, string start, string end)
{
var startIndex = value.IndexOf(start, StringComparison.Ordinal);
if (startIndex < 0)
{
return string.Empty;
}
startIndex += start.Length;
var endIndex = value.IndexOf(end, startIndex, StringComparison.Ordinal);
return endIndex < 0 ? value[startIndex..] : value[startIndex..endIndex];
}
static JsonSerializerOptions JsonOptions()
=> new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
static ManuscriptTextStatisticsService CreateTextStatisticsService()
=> new(new FakeManuscriptTextStatisticsRepository(string.Empty), null!);
static void AssertStats(
ManuscriptTextStatistics stats,
int total,
int dialogue,
int prose,
int paragraphs,
int dialogueParagraphs,
int proseParagraphs)
{
Assert(stats.TotalWordCount == total, $"Expected {total:N0} total words, got {stats.TotalWordCount:N0}.");
Assert(stats.DialogueWordCount == dialogue, $"Expected {dialogue:N0} dialogue words, got {stats.DialogueWordCount:N0}.");
Assert(stats.ProseWordCount == prose, $"Expected {prose:N0} prose words, got {stats.ProseWordCount:N0}.");
Assert(stats.TotalParagraphCount == paragraphs, $"Expected {paragraphs:N0} paragraphs, got {stats.TotalParagraphCount:N0}.");
Assert(stats.DialogueParagraphCount == dialogueParagraphs, $"Expected {dialogueParagraphs:N0} dialogue paragraphs, got {stats.DialogueParagraphCount:N0}.");
Assert(stats.ProseParagraphCount == proseParagraphs, $"Expected {proseParagraphs:N0} prose paragraphs, got {stats.ProseParagraphCount:N0}.");
Assert(stats.DialogueWordCount + stats.ProseWordCount == stats.TotalWordCount, "Dialogue and prose words should reconcile to total words.");
}
static void Assert(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException(message);
}
}
internal sealed class CaptureImageRequestHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(respond(request));
}
internal sealed class FakeManuscriptTextStatisticsRepository(string sourceText) : IManuscriptTextStatisticsRepository
{
public int? SavedSceneID { get; private set; }
public ManuscriptTextStatistics? SavedStatistics { get; private set; }
public Task SaveAsync(int sceneId, ManuscriptTextStatistics statistics)
{
SavedSceneID = sceneId;
SavedStatistics = statistics;
return Task.CompletedTask;
}
public Task<ManuscriptTextStatistics?> GetBySceneAsync(int sceneId)
=> Task.FromResult<ManuscriptTextStatistics?>(SavedStatistics);
public Task<IReadOnlyDictionary<int, ManuscriptTextStatistics>> ListByChapterAsync(int chapterId)
=> Task.FromResult<IReadOnlyDictionary<int, ManuscriptTextStatistics>>(new Dictionary<int, ManuscriptTextStatistics>());
public Task<SceneManuscriptSourceRecord?> GetSourceBySceneAsync(int sceneId)
=> Task.FromResult<SceneManuscriptSourceRecord?>(new SceneManuscriptSourceRecord
{
SceneID = sceneId,
ProjectID = 10,
BookID = 20,
ChapterID = 30,
SourceText = sourceText
});
}