7804 lines
645 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using System.Net;
using System.Text;
using System.IO.Compression;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
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 preserves valid coverage", ChapterStructureBoundaryNormaliserPreservesValidCoverage),
("Chapter Structure boundary normaliser handles terminal ghost paragraph", ChapterStructureBoundaryNormaliserHandlesTerminalGhostParagraph),
("Chapter Structure boundary normaliser discards multiple terminal ghost paragraphs", ChapterStructureBoundaryNormaliserDiscardsMultipleTerminalGhostParagraphs),
("Chapter Structure boundary normaliser replays full book terminal empty failure", ChapterStructureBoundaryNormaliserReplaysFullBookTerminalEmptyFailure),
("Chapter Structure boundary normaliser keeps meaningful end overflow repair", ChapterStructureBoundaryNormaliserKeepsMeaningfulEndOverflowRepair),
("Chapter Structure boundary normaliser preserves genuine inverted ranges for validation", ChapterStructureBoundaryNormaliserPreservesGenuineInvertedRanges),
("Structural Word mapping uses paragraph map as authoritative text", StructuralWordMappingUsesParagraphMapAsAuthoritativeText),
("Structural Word mapping includes filtered Word paragraphs in source coverage", StructuralWordMappingIncludesFilteredWordParagraphsInSourceCoverage),
("Structural Word mapping fails precisely when map metadata is incomplete", StructuralWordMappingFailsPreciselyWhenMapMetadataIncomplete),
("Structural Word mapping keeps trailing empty normalisation mapped", StructuralWordMappingKeepsTrailingEmptyNormalisationMapped),
("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),
("Location candidate consolidation handles generic review variants", LocationCandidateConsolidationHandlesGenericReviewVariants),
("Location candidate identity is parent-aware for generic sub-locations", LocationCandidateIdentityIsParentAwareForGenericSubLocations),
("Locations page uses hierarchical sort order", LocationsPageUsesHierarchicalSortOrder),
("Location review UX supports large candidate sets", LocationReviewUxSupportsLargeCandidateSets),
("Story Intelligence auto resolves exact known locations before review", StoryIntelligenceAutoResolvesExactKnownLocationsBeforeReview),
("Story Intelligence location review uses single identity decision", StoryIntelligenceLocationReviewUsesSingleIdentityDecision),
("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 canonical keys keep unrelated owned cars distinct", AssetCanonicalKeysKeepUnrelatedOwnedCarsDistinct),
("Asset candidate keys attach unique unowned variants", AssetCandidateKeysAttachUniqueUnownedVariants),
("Asset filtering rejects relationship facts", AssetFilteringRejectsRelationshipFacts),
("Asset contextual names resolve POV ownership", AssetContextualNamesResolvePovOwnership),
("Asset structural phrases trim neighbouring nouns", AssetStructuralPhrasesTrimNeighbouringNouns),
("Asset significance is generic and not fixture-specific", AssetSignificanceIsGenericAndNotFixtureSpecific),
("Story Intelligence asset review applies current identity rules", StoryIntelligenceAssetReviewAppliesCurrentIdentityRules),
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
("Relationship signals infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance),
("Relationship events preserve chronological story order", RelationshipEventsPreserveChronologicalStoryOrder),
("Relationship progression keeps chronological state", RelationshipProgressionKeepsChronologicalState),
("Relationship observations do not create event spam", RelationshipObservationsDoNotCreateEventSpam),
("Relationship review orders by first story appearance", RelationshipReviewOrdersByFirstStoryAppearance),
("Relationship evidence priority preserves explicit semantics", RelationshipEvidencePriorityPreservesExplicitSemantics),
("Relationship evidence handles retained hostile and incidental examples", RelationshipEvidenceHandlesRetainedHostileAndIncidentalExamples),
("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
("Knowledge recipient resolves narrator through POV", KnowledgeRecipientResolvesNarratorThroughPov),
("Knowledge identity reveal preserves subjective timing", KnowledgeIdentityRevealPreservesSubjectiveTiming),
("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),
("Story Intelligence scene call uses strict structured output", StoryIntelligenceSceneCallUsesStrictStructuredOutput),
("Story Intelligence scene schema constrains confidence numbers", StoryIntelligenceSceneSchemaConstrainsConfidenceNumbers),
("Story Intelligence scene schema constrains evidence strings", StoryIntelligenceSceneSchemaConstrainsEvidenceStrings),
("Story Intelligence temporal resolves chapter anchor and afternoon", StoryIntelligenceTemporalResolvesChapterAnchorAndAfternoon),
("Story Intelligence temporal rolls over after midnight", StoryIntelligenceTemporalRollsOverAfterMidnight),
("Story Intelligence temporal resolves following morning", StoryIntelligenceTemporalResolvesFollowingMorning),
("Story Intelligence temporal resolves exact day offsets", StoryIntelligenceTemporalResolvesExactDayOffsets),
("Story Intelligence temporal resolves following weekday", StoryIntelligenceTemporalResolvesFollowingWeekday),
("Story Intelligence temporal resolves evening clock time", StoryIntelligenceTemporalResolvesEveningClockTime),
("Story Intelligence temporal rejects unanchored clock time", StoryIntelligenceTemporalRejectsUnanchoredClockTime),
("Story Intelligence temporal preserves vague few days", StoryIntelligenceTemporalPreservesVagueFewDays),
("Story Intelligence temporal importer preserves manual chronology", StoryIntelligenceTemporalImporterPreservesManualChronology),
("Story Intelligence temporal materialisation saves canonical chronology", StoryIntelligenceTemporalMaterialisationSavesCanonicalChronology),
("Story Intelligence temporal malformed values are discarded", StoryIntelligenceTemporalMalformedValuesAreDiscarded),
("Story Intelligence structured metric values validate and import", StoryIntelligenceStructuredMetricValuesValidateAndImport),
("Story Intelligence structured schema covers complete response model", StoryIntelligenceStructuredSchemaCoversCompleteResponseModel),
("Story Intelligence structured schema remains fixture agnostic", StoryIntelligenceStructuredSchemaRemainsFixtureAgnostic),
("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 basic location detector is conservative", CoreImportBasicLocationDetectorIsConservative),
("Core import basic location detector consolidates and skips existing matches", CoreImportBasicLocationDetectorConsolidatesAndSkipsExistingMatches),
("Core import basic location detector supports pending parent candidates", CoreImportBasicLocationDetectorSupportsPendingParentCandidates),
("Core import character discovery protects accepted locations", CoreImportCharacterDiscoveryProtectsAcceptedLocations),
("Core import gates characters behind basic location review", CoreImportGatesCharactersBehindBasicLocationReview),
("Core import hides exact existing character names from review", CoreImportHidesExactExistingCharacterNamesFromReview),
("Core import hides exact existing character aliases from review", CoreImportHidesExactExistingCharacterAliasesFromReview),
("Core import keeps suspected unknown aliases in review", CoreImportKeepsSuspectedUnknownAliasesInReview),
("Core import keeps ambiguous existing character matches in review", CoreImportKeepsAmbiguousExistingCharacterMatchesInReview),
("Core import keeps new characters in review", CoreImportKeepsNewCharactersInReview),
("Core import auto resolved character evidence is preserved", CoreImportAutoResolvedCharacterEvidenceIsPreserved),
("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),
("Story Intelligence review centre completion is category scoped", StoryIntelligenceReviewCentreCompletionIsCategoryScoped),
("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),
("Word Companion scan completion uses chunked transport", WordCompanionScanCompletionUsesChunkedTransport),
("Word Companion scan chunks assemble full novel payloads", WordCompanionScanChunksAssembleFullNovelPayloads),
("Word Companion scan chunks reject partial and conflicting payloads", WordCompanionScanChunksRejectPartialAndConflictingPayloads),
("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 character review uses single identity decision", StoryIntelligenceCharacterReviewUsesSingleIdentityDecision),
("Story Intelligence auto resolves exact known identities before review", StoryIntelligenceAutoResolvesExactKnownIdentitiesBeforeReview),
("Story Intelligence character matching keeps ambiguous known names visible", StoryIntelligenceCharacterMatchingKeepsAmbiguousKnownNamesVisible),
("Story Intelligence descriptive references consolidate articles", StoryIntelligenceDescriptiveReferencesConsolidateArticles),
("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),
("Entity preferred names are peer aliases", EntityPreferredNamesArePeerAliases),
("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 stabilisation gate materialises strict and legacy metrics", StoryIntelligenceStabilisationGateMaterialisesStrictAndLegacyMetrics),
("Story Intelligence modern materialisation owns finalisation boundary", StoryIntelligenceModernMaterialisationOwnsFinalisationBoundary),
("Story Intelligence narrative materialisation reads strict scene results", StoryIntelligenceNarrativeMaterialisationReadsStrictSceneResults),
("Story Intelligence stabilisation routes normal users to book", StoryIntelligenceStabilisationRoutesNormalUsersToBook),
("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview),
("Story Intelligence discovers Plot Lines without preconfiguration", StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration),
("Story Intelligence matches existing Plot Lines without duplicates", StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates),
("Narrative research prototype fixture builds hierarchy", NarrativeResearchPrototypeFixtureBuildsHierarchy),
("Narrative sibling hotel fixture generalises hierarchy", NarrativeSiblingHotelFixtureGeneralisesHierarchy),
("Narrative trivial one-scene question is suppressed", NarrativeTrivialOneSceneQuestionIsSuppressed),
("Narrative short significant thread survives", NarrativeShortSignificantThreadSurvives),
("Narrative semantic evolution consolidates by continuity", NarrativeSemanticEvolutionConsolidatesByContinuity),
("Narrative existing thread matching is supported", NarrativeExistingThreadMatchingIsSupported),
("Narrative candidate merge and parent fields are persisted", NarrativeCandidateMergeAndParentFieldsArePersisted),
("Narrative prompt uses one scene call with compact schema", NarrativePromptUsesOneSceneCallWithCompactSchema),
("Narrative production code remains fixture agnostic", NarrativeProductionCodeRemainsFixtureAgnostic),
("Whole-book Plot Intelligence prompt carries full manuscript context", WholeBookPlotIntelligencePromptCarriesFullManuscriptContext),
("Whole-book Plot Intelligence schema requires timeline event types", WholeBookPlotIntelligenceSchemaRequiresTimelineEventTypes),
("Whole-book Plot Intelligence author seed inputs persist safely", WholeBookPlotIntelligenceAuthorSeedInputsPersistSafely),
("Whole-book Plot Intelligence structured author seed match survives", WholeBookPlotIntelligenceStructuredAuthorSeedMatchSurvives),
("Whole-book Plot Intelligence unmatched seeds become review candidates", WholeBookPlotIntelligenceUnmatchedSeedsBecomeReviewCandidates),
("Whole-book Plot Intelligence uses dedicated output token headroom", WholeBookPlotIntelligenceUsesDedicatedOutputTokenHeadroom),
("Story Intelligence narrative review displays author seed provenance", StoryIntelligenceNarrativeReviewDisplaysAuthorSeedProvenance),
("Story Intelligence narrative review avoids raw author-facing IDs", StoryIntelligenceNarrativeReviewAvoidsRawAuthorFacingIds),
("Story Intelligence author seed decisions are preserved", StoryIntelligenceAuthorSeedDecisionsArePreserved),
("Whole-book Plot Intelligence production code remains fixture agnostic", WholeBookPlotIntelligenceProductionCodeRemainsFixtureAgnostic),
("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),
("Story Intelligence Review Centre route replaces linear review resume", StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume),
("Story Intelligence obsolete create scene workflow is removed", StoryIntelligenceObsoleteCreateSceneWorkflowIsRemoved),
("Story Intelligence Review Centre counts only needs-attention categories", StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories),
("Story Intelligence Review Centre uses independent tile grid", StoryIntelligenceReviewCentreUsesIndependentTileGrid),
("Story Intelligence Review Centre opens with character gate", StoryIntelligenceReviewCentreOpensWithCharacterGate),
("Story Intelligence review modules are independent after character gate", StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate),
("Story Intelligence review module completion is durable and independent", StoryIntelligenceReviewModuleCompletionIsDurableAndIndependent),
("Story Intelligence review module pages omit wizard chrome", StoryIntelligenceReviewModulePagesOmitWizardChrome),
("Story Intelligence downstream review uses completed scene analysis", StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis),
("Story Intelligence review posts do not use success interstitials", StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials),
("Story Intelligence legacy review completion routes are deleted", StoryIntelligenceLegacyReviewCompletionRoutesAreDeleted),
("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard),
("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls),
("Story Intelligence Review Centre uses bounded summary query", StoryIntelligenceReviewCentreUsesBoundedSummaryQuery),
("Story Intelligence character review queues enrichment beside plot synthesis", StoryIntelligenceCharacterReviewQueuesEnrichmentBesidePlotSynthesis),
("Story Intelligence character enrichment uses persisted SignalR progress", StoryIntelligenceCharacterEnrichmentUsesPersistedSignalRProgress),
("Story Intelligence character enrichment preserves author fields", StoryIntelligenceCharacterEnrichmentPreservesAuthorFields),
("Character detail can queue single-character enrichment", CharacterDetailCanQueueSingleCharacterEnrichment),
("Single-character enrichment is persisted and duplicate safe", SingleCharacterEnrichmentIsPersistedAndDuplicateSafe),
("Character detail enrichment status uses scoped SignalR", CharacterDetailEnrichmentStatusUsesScopedSignalR),
("Story Intelligence detailed review GETs use selected module paths", StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths),
("Story Intelligence completed Character review remains inspectable", StoryIntelligenceCompletedCharacterReviewRemainsInspectable),
("Story Intelligence Review Centre completed modules use canonical destinations", StoryIntelligenceReviewCentreCompletedModulesUseCanonicalDestinations),
("Story Intelligence detailed reviews read persisted candidate pages", StoryIntelligenceDetailedReviewsReadPersistedCandidatePages),
("Story Intelligence module completion lifecycle uses persisted candidates", StoryIntelligenceModuleCompletionLifecycleUsesPersistedCandidates),
("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution),
("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals),
("Story Intelligence recovered provider failures stay operational", StoryIntelligenceRecoveredProviderFailuresStayOperational),
("Story Intelligence recovered JSON repair stays operational", StoryIntelligenceRecoveredJsonRepairStaysOperational),
("Story Intelligence terminal failures remain user visible", StoryIntelligenceTerminalFailuresRemainUserVisible),
("Story Intelligence global banner remains visible through finalising gaps", StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps),
("Story Intelligence global banner rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots),
("Story Intelligence live global progress publishes authoritative scene aggregates", StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggregates),
("Story Intelligence global progress survives initial SignalR start races", StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces),
("Story Intelligence scene analysis completion barrier releases review", StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview),
("Story Intelligence active progress only appears in global strip", StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip),
("Story Intelligence book actions do not open review centre while running", StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning)
};
foreach (var test in tests)
{
test.Test();
Console.WriteLine($"PASS {test.Name}");
}
static void StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution()
{
var root = PlotLineRoot();
var options = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceModels.cs"));
var worker = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceWorker.cs"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/169_Phase26E_StoryIntelligenceConcurrentExecution.sql"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
var progressScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
var openAiClient = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceOpenAIInfrastructure.cs"));
Assert(options.Contains("MaxGlobalConcurrentAiRequests", StringComparison.Ordinal), "Story Intelligence concurrency must be configurable.");
Assert(worker.Contains("Task.WhenAll(workers)", StringComparison.Ordinal), "Persisted runner should run bounded worker loops.");
Assert(worker.Contains("MaxConcurrentAiRequestsPerBook", StringComparison.Ordinal), "Worker logs/config should include the per-book cap.");
Assert(runner.Contains("ClaimNextPendingFairAsync", StringComparison.Ordinal), "Runner should use the fair persisted claim procedure.");
Assert(runner.Contains("Story Intelligence scene timing", StringComparison.Ordinal), "Runner should emit scene timing instrumentation.");
Assert(runner.Contains("Story Intelligence finalisation timing", StringComparison.Ordinal), "Runner should emit finalisation timing instrumentation.");
Assert(repository.Contains("StoryIntelligenceRun_ClaimNextPendingFair", StringComparison.Ordinal), "Fair claims should use a stored procedure.");
Assert(repository.Contains("StoryIntelligenceRun_ListActiveBookSummaryForUser", StringComparison.Ordinal), "Global progress should use a stored procedure.");
Assert(migration.Contains("@MaxConcurrentRunsPerBook", StringComparison.Ordinal), "Migration should enforce a per-book cap.");
Assert(migration.Contains("@MaxConcurrentRunsPerUser", StringComparison.Ordinal), "Migration should enforce a per-user cap.");
Assert(migration.Contains("Returned to the Story Intelligence queue after an expired worker claim", StringComparison.Ordinal), "Migration should recover expired worker claims.");
Assert(controller.Contains("Progress is shown in the page header", StringComparison.Ordinal), "Book analysis should return to normal UI instead of forcing the progress page.");
Assert(layout.Contains("data-story-intelligence-global-strip", StringComparison.Ordinal), "Shared layout should render the global progress strip.");
Assert(progressScript.Contains("StoryIntelligenceRunProgressChanged", StringComparison.Ordinal), "Progress strip should listen for persisted run SignalR events.");
Assert(openAiClient.Contains("RetryConditionHeaderValue", StringComparison.Ordinal), "OpenAI retry should honour Retry-After metadata.");
}
static void StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals()
{
var root = PlotLineRoot();
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/StoryIntelligenceGlobalProgressViewModels.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceGlobalProgressService.cs"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/StoryIntelligenceHub.cs"));
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/170_Phase26F_StoryIntelligenceWholeRunProgress.sql"));
var authoritativeMigration = File.ReadAllText(Path.Combine(root, "Sql/171_Phase26G_StoryIntelligenceAuthoritativeGlobalProgress.sql"));
var book = new StoryIntelligenceGlobalProgressBookViewModel
{
BookTitle = "Test Book",
PendingRunCount = 2,
RunningRunCount = 3,
TotalExpectedScenes = 47,
CompletedScenes = 5,
FailedScenes = 0,
ActiveScenes = 15,
TotalDurationMs = TimeSpan.FromMinutes(2).Ticks / TimeSpan.TicksPerMillisecond,
Status = StoryIntelligenceRunStatuses.Running
};
var model = new StoryIntelligenceGlobalProgressViewModel { Books = [book] };
Assert(model.TotalExpectedScenes == 47, "Global progress denominator should be the expected whole-run scene total.");
Assert(model.TotalDetectedScenes == 47, "Legacy detected-scenes accessor should mirror the expected scene total for display.");
Assert(model.CompletedScenes == 5, "Completed count should count successful completed scene analyses.");
Assert(model.ActiveScenes == 15, "Active scene count should remain distinct from the denominator.");
Assert(model.ProgressPercent == 11, "5 of 47 scenes should round to 11%, not 33% from a 15-scene active subset.");
Assert(book.SummaryText.Contains("5/47", StringComparison.Ordinal), "Book summary should display 5/47.");
var finalisingBook = new StoryIntelligenceGlobalProgressBookViewModel
{
BookTitle = "Test Book",
PendingRunCount = 0,
RunningRunCount = 1,
TotalExpectedScenes = 47,
CompletedScenes = 47,
FailedScenes = 0,
ActiveScenes = 0,
TotalDurationMs = TimeSpan.FromMinutes(8).Ticks / TimeSpan.TicksPerMillisecond,
Status = "Finalising"
};
Assert(finalisingBook.ProgressPercent == 100, "Scene progress can be visually full during finalisation.");
Assert(finalisingBook.EstimatedRemaining == "Finalising", "All scene analyses complete should show finalising until pipeline state is persisted.");
var finalisingModel = new StoryIntelligenceGlobalProgressViewModel { Books = [finalisingBook] };
Assert(finalisingModel.HasActiveWork, "Finalising should keep the global strip visible even when no chapter worker remains active.");
Assert(finalisingModel.Status == "Finalising", "Finalising pipeline state should be the aggregate status.");
var failedBook = new StoryIntelligenceGlobalProgressBookViewModel
{
BookTitle = "Test Book",
PendingRunCount = 0,
RunningRunCount = 0,
TotalExpectedScenes = 47,
CompletedScenes = 46,
FailedScenes = 1,
ActiveScenes = 0,
TotalDurationMs = TimeSpan.FromMinutes(8).Ticks / TimeSpan.TicksPerMillisecond,
Status = StoryIntelligenceRunStatuses.CompletedWithWarnings
};
Assert(failedBook.ProgressPercent == 98, "46 success plus 1 failed must not display as 47 successful scenes.");
Assert(failedBook.EstimatedRemaining == "Finalising", "Terminal failures should be excluded from ETA while remaining visible.");
Assert(service.Contains("TotalExpectedScenes = summary.TotalExpectedScenes", StringComparison.Ordinal), "Global progress service should map total expected scenes.");
Assert(hub.Contains("WatchGlobalStoryIntelligenceProgress", StringComparison.Ordinal), "SignalR should expose the aggregate progress calculation.");
Assert(layout.Contains("TotalExpectedScenes", StringComparison.Ordinal), "Initial server-rendered strip should use total expected scenes.");
Assert(script.Contains("WatchGlobalStoryIntelligenceProgress", StringComparison.Ordinal), "SignalR updates should refresh aggregate progress instead of trusting chapter totals.");
Assert(!script.Contains("progress.totalDetectedScenes", StringComparison.Ordinal), "Client should not use per-run TotalDetectedScenes as the denominator.");
Assert(migration.Contains("CanonicalSceneScope", StringComparison.Ordinal), "Progress migration should derive expected scope from canonical scenes.");
Assert(migration.Contains("ActiveScenes", StringComparison.Ordinal), "Progress migration should keep active scene count distinct.");
Assert(migration.Contains("N'Finalising'", StringComparison.Ordinal), "Progress migration should expose a finalising state.");
Assert(authoritativeMigration.Contains("pipeline.Status = N'InProgress'", StringComparison.Ordinal), "Authoritative progress should include in-progress book pipelines.");
Assert(authoritativeMigration.Contains("LEFT JOIN ScopedRuns", StringComparison.Ordinal), "Finalising pipeline scopes should remain visible even if no active run row remains.");
Assert(authoritativeMigration.Contains("r.Status IN (N'Pending', N'Running')", StringComparison.Ordinal), "Queued and running work should keep active scene counts separate from completed scenes.");
}
static void StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps()
{
var root = PlotLineRoot();
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/StoryIntelligenceGlobalProgressViewModels.cs"));
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/171_Phase26G_StoryIntelligenceAuthoritativeGlobalProgress.sql"));
var finalisingBook = new StoryIntelligenceGlobalProgressBookViewModel
{
BookTitle = "Finalising Book",
TotalExpectedScenes = 49,
CompletedScenes = 49,
Status = "Finalising",
UpdatedUtc = DateTime.UtcNow
};
var model = new StoryIntelligenceGlobalProgressViewModel { Books = [finalisingBook] };
Assert(model.HasActiveWork, "Finalising book lifecycle should render the shared banner.");
Assert(model.ActiveBookCount == 1, "Finalising should count as active visible work.");
Assert(model.SummaryText.Contains("49/49", StringComparison.Ordinal), "Finalising summary should remain book-scoped instead of becoming empty.");
Assert(viewModel.Contains("|| string.Equals(Status, \"Finalising\"", StringComparison.Ordinal), "Finalising should be treated as active in the shared status model.");
Assert(layout.Contains("data-story-intelligence-updated-utc", StringComparison.Ordinal), "Initial render should expose an aggregate freshness timestamp.");
Assert(script.Contains("strip.hidden = true", StringComparison.Ordinal), "Live refresh should clear the active banner once persisted state no longer has active/finalising aggregate work.");
Assert(!script.Contains("window.setTimeout(() =>", StringComparison.Ordinal), "Completion states should not auto-hide while final review routing may still be preparing.");
Assert(migration.Contains("OR pipeline.Status = N'InProgress'", StringComparison.Ordinal), "In-progress book pipeline state should keep the aggregate row visible.");
}
static void StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots()
{
var root = PlotLineRoot();
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/StoryIntelligenceGlobalProgressViewModels.cs"));
var runningWithTerminalSceneIssue = new StoryIntelligenceGlobalProgressViewModel
{
Books =
[
new StoryIntelligenceGlobalProgressBookViewModel
{
BookTitle = "Concurrent Book",
RunningRunCount = 1,
TotalExpectedScenes = 49,
CompletedScenes = 20,
FailedScenes = 1,
ActiveScenes = 11,
Status = StoryIntelligenceRunStatuses.Running,
UpdatedUtc = DateTime.UtcNow
}
]
};
Assert(runningWithTerminalSceneIssue.Status == StoryIntelligenceRunStatuses.Running, "Active running work should outrank terminal scene warnings until finalisation.");
Assert(script.Contains("lastSnapshotTime", StringComparison.Ordinal), "Client should track the freshest aggregate snapshot.");
Assert(script.Contains("updatedTime < lastSnapshotTime", StringComparison.Ordinal), "Out-of-order SignalR refreshes should not regress the visible banner.");
Assert(script.Contains("strip.dataset.connection = \"complete\"", StringComparison.Ordinal), "Empty aggregate refreshes should clear stale completed analysis banners.");
Assert(viewModel.Contains("public DateTime? UpdatedUtc", StringComparison.Ordinal), "Aggregate payload should expose a timestamp for stale-snapshot checks.");
}
static void StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggregates()
{
var root = PlotLineRoot();
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var notifier = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceProgressNotifier.cs"));
var globalService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceGlobalProgressService.cs"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
var sceneSuccessBlock = ExtractBetween(
runner,
"completedScenes++;",
"catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))");
Assert(sceneSuccessBlock.IndexOf("await repository.UpdateProgressAsync", StringComparison.Ordinal)
< sceneSuccessBlock.IndexOf("\"Scene analysis completed\"", StringComparison.Ordinal), "Successful scene completion should persist the incremented run counters before publishing live progress.");
Assert(sceneSuccessBlock.Contains("completedScenes: completedScenes", StringComparison.Ordinal), "Successful scene completion should persist the authoritative completed-scene count.");
var sceneFailureBlock = ExtractBetween(
runner,
"failedScenes++;",
"logger.LogWarning(ex, \"Story Intelligence scene failed");
Assert(sceneFailureBlock.IndexOf("await repository.UpdateProgressAsync", StringComparison.Ordinal)
< sceneFailureBlock.IndexOf("\"Scene analysis failed\"", StringComparison.Ordinal), "Terminal scene failure should persist failed-scene counters before publishing live progress.");
Assert(sceneFailureBlock.Contains("failedScenes: failedScenes", StringComparison.Ordinal), "Terminal scene failure should persist the authoritative failed-scene count.");
Assert(globalService.Contains("Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId)", StringComparison.Ordinal), "Background publishing should be able to retrieve the same user-scoped aggregate used by page load.");
Assert(notifier.Contains("GetForUserAsync(userId)", StringComparison.Ordinal), "Live progress publish should fetch the authoritative aggregate for the run owner.");
Assert(notifier.Contains("StoryIntelligenceGlobalProgressChanged", StringComparison.Ordinal), "Live progress publish should send a compact aggregate SignalR event.");
Assert(script.Contains("connection.on(\"StoryIntelligenceGlobalProgressChanged\"", StringComparison.Ordinal)
&& script.Contains("render(snapshot);", StringComparison.Ordinal), "The global banner should render authoritative aggregate payloads directly.");
Assert(script.Contains("connection.onreconnected", StringComparison.Ordinal)
&& script.Contains("refresh();", StringComparison.Ordinal), "Reconnect should rejoin the user group and immediately catch up from persisted aggregate state.");
Assert(script.Contains("updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "Equal-timestamp out-of-order snapshots should not regress completed/failed scene totals.");
Assert(layout.Contains("data-completed-scenes", StringComparison.Ordinal), "Initial server render should seed completed count for monotonic live comparisons.");
Assert(layout.Contains("data-failed-scenes", StringComparison.Ordinal), "Initial server render should seed terminal failed count for monotonic live comparisons.");
}
static void StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces()
{
var root = PlotLineRoot();
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
var pageScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-progress.js"));
Assert(layout.Contains("User.Identity?.IsAuthenticated == true", StringComparison.Ordinal), "Authenticated pages should always render the global progress host so a later import can appear without a full refresh.");
Assert(layout.Contains("data-story-intelligence-has-active-work", StringComparison.Ordinal), "Initial render should expose whether the hidden strip currently has active work.");
Assert(layout.Contains("hidden=\"@(!storyIntelligenceProgress.HasActiveWork)\"", StringComparison.Ordinal), "The global strip should be hidden, not absent, when no active work is visible at page render.");
Assert(script.Contains("const startWithRetry = () =>", StringComparison.Ordinal), "Initial SignalR start failures should retry instead of leaving the banner permanently stale.");
Assert(script.Contains("startRetryTimer = window.setTimeout(startWithRetry, 2000)", StringComparison.Ordinal), "Initial start retry should be bounded and low-noise.");
Assert(script.Contains("connection.onclose(() =>", StringComparison.Ordinal), "The client should recover after automatic reconnect is exhausted.");
Assert(script.Contains("connection.onreconnected(() =>", StringComparison.Ordinal), "Reconnect should immediately refresh authoritative persisted progress.");
Assert(script.Contains(".then(() =>") && script.Contains("refresh();"), "Successful connection start should close the render/subscription race by fetching the current aggregate.");
Assert(script.Contains("snapshotKey !== lastSnapshotKey", StringComparison.Ordinal), "Progress monotonicity should reset only when the active book scope changes.");
Assert(script.Contains("snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "A newer but backwards snapshot for the same book must not regress the banner.");
Assert(script.Contains("StoryIntelligenceGlobalProgress]"), "Development builds should expose low-noise client diagnostics.");
Assert(pageScript.Contains("const startWithRetry = () =>", StringComparison.Ordinal), "The full progress page should retry initial SignalR start failures like the working global banner.");
Assert(pageScript.Contains("connection.onclose(() =>", StringComparison.Ordinal), "The full progress page should recover after automatic reconnect is exhausted.");
Assert(pageScript.Contains("plotdirector:story-intelligence-run-progress-applied", StringComparison.Ordinal), "Incremental run events should expose a handler-level signal after the matching DOM run is updated.");
Assert(pageScript.Contains("ignored run progress for unwatched run", StringComparison.Ordinal), "Development diagnostics should show when published run IDs do not match page DOM run IDs.");
}
static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview()
{
var root = PlotLineRoot();
var migration = File.ReadAllText(Path.Combine(root, "Sql/172_Phase26H_StoryIntelligenceSceneAnalysisCompletionBarrier.sql"));
var materialisationMigration = File.ReadAllText(Path.Combine(root, "Sql/173_Phase26I_StoryIntelligenceModernMaterialisation.sql"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligencePipelineRepository.cs"));
var pipelineState = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var worker = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceWorker.cs"));
var notificationService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceOperationalServices.cs"));
var globalScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_TryCompleteSceneAnalysis", StringComparison.Ordinal), "Scene analysis completion should be controlled by a stored procedure.");
Assert(migration.Contains("WITH (UPDLOCK, HOLDLOCK)", StringComparison.Ordinal), "The book-level barrier should use a database lock to avoid duplicate finalisers.");
Assert(migration.Contains("Status = N'NeedsReview'", StringComparison.Ordinal), "A successful scene-analysis barrier should release the book into review instead of leaving it InProgress.");
Assert(migration.Contains("CurrentStage = N'CharacterReview'", StringComparison.Ordinal), "The next persisted stage after scene analysis should be character resolution/review.");
Assert(migration.Contains("LastCompletedStage = N'SceneImport'", StringComparison.Ordinal), "The completed scene-analysis stage should be durable for page-load recovery.");
Assert(migration.Contains("AND @ActiveRunCount = 0", StringComparison.Ordinal), "The barrier should wait until no queued/running chapter work remains.");
Assert(migration.Contains("AND @FailedRunCount = 0", StringComparison.Ordinal), "The normal success path should not silently hide failed chapter runs.");
Assert(migration.Contains("@CompletedScenes + @FailedScenes >= @TotalExpectedScenes", StringComparison.Ordinal), "All expected scenes should be terminal before review is released.");
Assert(!migration.Contains("StoryIntelligenceImportCommits", StringComparison.Ordinal), "Canonical scene-analysis completion should not depend on legacy import commit rows.");
Assert(migration.Contains("AND Status = N'InProgress'", StringComparison.Ordinal), "The finaliser update should be idempotent and claim only an active pipeline.");
Assert(materialisationMigration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_FindNextReadySceneAnalysis", StringComparison.Ordinal), "Idle workers should find books stranded after restart without finalising before materialisation.");
Assert(repository.Contains("TryCompleteSceneAnalysisAsync", StringComparison.Ordinal), "Repository should expose the per-book barrier procedure.");
Assert(repository.Contains("FindNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Repository should expose the restart recovery lookup procedure.");
Assert(pipelineState.Contains("CompleteSceneAnalysisIfReadyAsync", StringComparison.Ordinal), "Pipeline state service should complete scene analysis after the final chapter run.");
Assert(pipelineState.Contains("FindNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Pipeline state service should support restart-safe recovery without completing before materialisation.");
Assert(runner.Contains("CompleteBookSceneAnalysisIfReadyAsync", StringComparison.Ordinal), "Runner should check the book barrier after chapter completion.");
Assert(runner.Contains("await CompleteBookSceneAnalysisIfReadyAsync(run, cancellationToken);", StringComparison.Ordinal), "Runner should use the barrier instead of emailing while the pipeline is still InProgress.");
Assert(worker.Contains("RecoverReadySceneAnalysisAsync", StringComparison.Ordinal), "Idle worker polling should repair ready pipelines even without new scene events.");
Assert(notificationService.Contains("NotifyIfImportTerminalAsync(int bookId, int userId", StringComparison.Ordinal), "Completion notification should be idempotently callable during recovery without re-running AI.");
Assert(globalScript.Contains("strip.dataset.connection = \"complete\"", StringComparison.Ordinal), "The global strip should clear stale 100% analysing text after review-ready state is persisted.");
}
static void StoryIntelligenceRecoveredProviderFailuresStayOperational()
{
var requestCount = 0;
var client = BuildStoryIntelligenceClient(_ =>
{
requestCount++;
return requestCount == 1
? new HttpResponseMessage(HttpStatusCode.TooManyRequests)
{
Content = new StringContent("{\"error\":{\"code\":\"rate_limit_exceeded\"}}", Encoding.UTF8, "application/json")
}
: OpenAiSuccessResponse("{}");
});
var result = client.ExecutePromptAsync("Analyse this scene.", "Test-Prompt", CancellationToken.None)
.GetAwaiter()
.GetResult();
Assert(requestCount == 2, "Recovered rate-limit/provider failures should be retried internally.");
Assert(result.RetryCount == 1, "Recovered provider retries should remain execution metadata.");
requestCount = 0;
client = BuildStoryIntelligenceClient(_ =>
{
requestCount++;
if (requestCount == 1)
{
throw new TaskCanceledException("Simulated timeout.");
}
return OpenAiSuccessResponse("{}");
});
result = client.ExecutePromptAsync("Analyse this scene.", "Test-Prompt", CancellationToken.None)
.GetAwaiter()
.GetResult();
Assert(requestCount == 2, "Recovered timeouts should be retried internally.");
Assert(result.RetryCount == 1, "Recovered timeout retries should not create user-facing scene failures.");
}
static void StoryIntelligenceRecoveredJsonRepairStaysOperational()
{
var root = PlotLineRoot();
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var notificationSql = File.ReadAllText(Path.Combine(root, "Sql/149_Phase24_LeanImportCompletionNotifications.sql"));
var progressScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-progress.js"));
var globalScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
var sceneValidationBlock = ExtractBetween(
runner,
"var validation = sceneValidator.Validate(sceneAttempt.Parsed);",
"hasWarnings = hasWarnings || validation.Warnings.Count > 0;");
var chapterValidationBlock = ExtractBetween(
runner,
"var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count);",
"ChapterStructureBoundaryNormaliser.AddIssuesTo");
Assert(sceneValidationBlock.Contains("LogRecoveredJsonWarnings", StringComparison.Ordinal), "Recovered scene JSON repair/retry warnings should stay in operational logging.");
Assert(!sceneValidationBlock.Contains("validation.Warnings.Add", StringComparison.Ordinal), "Recovered scene JSON warnings must not become validation warnings or Needs Attention.");
Assert(chapterValidationBlock.Contains("LogRecoveredJsonWarnings", StringComparison.Ordinal), "Recovered chapter JSON repair/retry warnings should stay in operational logging.");
Assert(!chapterValidationBlock.Contains("chapterValidation.Warnings.Add", StringComparison.Ordinal), "Recovered chapter JSON warnings must not become validation warnings.");
Assert(runner.Contains("SerialiseSceneParseAudit(sceneAttempt, validation.IsValid)", StringComparison.Ordinal), "Recovered scene JSON repair details should remain in technical parse audit.");
Assert(runner.Contains("jsonWarnings = parseResult.Warnings", StringComparison.Ordinal), "Recovered chapter JSON repair details should remain in technical parse audit.");
Assert(runner.Contains("LogRecoveredJsonWarnings(run, $\"Scene {block.TemporarySceneNumber:N0}\", sceneAttempt.Warnings)", StringComparison.Ordinal), "Invalid response repaired by retry should be logged, not surfaced as author review.");
Assert(notificationSql.Contains("Status IN (N'Failed', N'Cancelled')", StringComparison.Ordinal), "Failure notification should be based on final failed/cancelled runs.");
Assert(notificationSql.Contains("SET @NotificationKind = CASE WHEN @HasFailedRuns = 1 THEN N'Failure' ELSE N'Success' END", StringComparison.Ordinal), "Recovered runs should not generate failure notifications.");
Assert(progressScript.Contains("current.failedScenes += failed", StringComparison.Ordinal), "Onboarding Needs review should use persisted terminal FailedScenes only.");
Assert(globalScript.Contains("const failed = readNumber(progress.failedScenes ?? progress.FailedScenes)", StringComparison.Ordinal)
&& globalScript.Contains("readNumber(primary.failedScenes ?? primary.FailedScenes)", StringComparison.Ordinal), "Global banner should use terminal failed-scene count only.");
}
static void StoryIntelligenceTerminalFailuresRemainUserVisible()
{
var root = PlotLineRoot();
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var globalViewModel = File.ReadAllText(Path.Combine(root, "ViewModels/StoryIntelligenceGlobalProgressViewModels.cs"));
var failedSceneBlock = ExtractBetween(runner, "private async Task PersistFailedSceneAsync", "private StoryIntelligenceStageModels ResolveStageModels");
Assert(failedSceneBlock.Contains("ValidationErrorsCount = 1", StringComparison.Ordinal), "Retries exhausted for one scene should persist a terminal scene failure.");
Assert(runner.Contains("catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))", StringComparison.Ordinal), "Only unrecovered scene exceptions should enter terminal scene failure handling.");
Assert(runner.Contains("failedScenes++", StringComparison.Ordinal), "Terminal scene failures should increment the persisted failed-scene count.");
Assert(runner.Contains("failedScenes > 0 || hasWarnings", StringComparison.Ordinal), "Partial terminal scene failures should complete the chapter with warnings rather than forcing whole-run failure.");
Assert(viewModel.Contains("AnalysisIssueCount => Chapters.Count(chapter => chapter.IsFailed) + Chapters.Sum(chapter => chapter.FailedScenes ?? 0)", StringComparison.Ordinal), "Review Centre issue count should be driven by terminal chapter/run failures.");
Assert(globalViewModel.Contains("public bool HasFailures => FailedRunCount > 0 || FailedScenes > 0", StringComparison.Ordinal), "Global banner warning state should be driven by terminal persisted failures.");
}
static void StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip()
{
var root = PlotLineRoot();
var projectsView = File.ReadAllText(Path.Combine(root, "Views/Projects/Index.cshtml"));
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var onboardingCss = File.ReadAllText(Path.Combine(root, "wwwroot/css/onboarding.css"));
var themeCss = File.ReadAllText(Path.Combine(root, "wwwroot/css/plotline-theme.css"));
Assert(!projectsView.Contains("Story Intelligence is reading", StringComparison.Ordinal), "Projects page should not duplicate the active Story Intelligence progress card.");
Assert(!projectsView.Contains("StoryIntelligenceProgress", StringComparison.Ordinal), "Projects page should not provide a second View progress target while the global strip is active.");
Assert(projectsView.Contains("HasPipelineBooks && !Model.StoryIntelligence.HasActiveImport", StringComparison.Ordinal), "Saved pipeline cards should be hidden while active progress is already shown globally.");
Assert(layout.Contains("data-story-intelligence-global-strip", StringComparison.Ordinal), "Active Story Intelligence progress should remain available in the shared page header.");
Assert(controller.Contains("return RedirectToAction(\"Details\", \"Books\", new { id = bookId });", StringComparison.Ordinal), "Starting permanent book analysis should return to the book page.");
Assert(onboardingCss.Contains("[data-theme=\"dark\"] .story-intelligence-dashboard-card", StringComparison.Ordinal), "Projects Story Intelligence panel should have a dark-theme surface rule.");
Assert(onboardingCss.Contains("background: var(--plotline-panel, var(--bs-body-bg));", StringComparison.Ordinal), "Projects Story Intelligence panel should use theme-aware panel surfaces.");
Assert(themeCss.Contains("[data-theme=\"dark\"]", StringComparison.Ordinal), "Theme CSS should define dark theme scope.");
Assert(themeCss.Contains("[data-theme=\"dark\"] .trial-dashboard-banner", StringComparison.Ordinal), "Projects trial banner should have a dark-theme surface rule.");
Assert(themeCss.Contains("color-mix(in srgb, var(--plotline-surface-elevated) 92%, var(--plotline-accent-soft))", StringComparison.Ordinal), "Trial banner should use theme-aware surfaces instead of hard-coded light slabs.");
Assert(!themeCss.Contains("linear-gradient(135deg, rgba(255, 253, 248, 0.96), rgba(242, 226, 207, 0.56))", StringComparison.Ordinal), "Trial banner should not keep the old hard-coded cream background.");
}
static void StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning()
{
var root = PlotLineRoot();
var coreViewModels = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
var onboardingViewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var coreServices = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
var storyService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceService.cs"));
var bookView = File.ReadAllText(Path.Combine(root, "Views/Books/Details.cshtml"));
var projectsView = File.ReadAllText(Path.Combine(root, "Views/Projects/Index.cshtml"));
Assert(coreViewModels.Contains("public bool CanContinue", StringComparison.Ordinal), "Book action model should distinguish runnable review links from running status.");
Assert(onboardingViewModels.Contains("public bool IsInProgress", StringComparison.Ordinal), "Project dashboard book status should distinguish running analysis from saved review.");
Assert(onboardingViewModels.Contains("public bool CanContinueReview => !IsInProgress", StringComparison.Ordinal), "Project dashboard should suppress review buttons for running analysis.");
Assert(coreServices.Contains("var isInProgress = string.Equals(pipeline.Status, StoryIntelligencePipelineStatuses.InProgress", StringComparison.Ordinal), "Book action builder should inspect the pipeline status.");
Assert(coreServices.Contains("CanContinue = !isInProgress", StringComparison.Ordinal), "Book action builder should block review navigation while analysis is running.");
Assert(coreServices.Contains("ActionLabel = isInProgress ? \"Analysis running\" : \"Open Review Centre\"", StringComparison.Ordinal), "Open Review Centre should be available for completed analysis that still has review work.");
Assert(storyService.Contains("IsInProgress = string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress", StringComparison.Ordinal), "Project dashboard pipeline rows should map in-progress state.");
Assert(bookView.Contains("if (action.CanContinue)", StringComparison.Ordinal), "Book details should render the review link only when the pipeline can continue.");
Assert(bookView.Contains("<span class=\"status-pill\">Analysis running</span>", StringComparison.Ordinal), "Book details should show running status instead of a premature review-centre button.");
Assert(projectsView.Contains("primaryPipelineBook.CanContinueReview", StringComparison.Ordinal), "Projects page should only render review actions for continuable pipeline books.");
}
static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
Assert(controller.Contains("story-intelligence/review-centre", StringComparison.Ordinal), "Review Centre route should exist.");
Assert(controller.Contains("story-intelligence/book/{bookId:int}/review-centre", StringComparison.Ordinal), "Review Centre should have a persistent book-based route.");
Assert(controller.Contains("=> await StoryIntelligenceBookReviewCentre(bookId);", StringComparison.Ordinal), "Continue should render the persistent book Review Centre route.");
Assert(controller.Contains("StoryIntelligenceResumeRoutes.ReviewCentre => await StoryIntelligenceReviewCentre(target.BatchID)", StringComparison.Ordinal), "Resume routing should render Review Centre.");
Assert(service.Contains("public const string ReviewCentre = \"ReviewCentre\";", StringComparison.Ordinal), "Resume route constants should include ReviewCentre.");
Assert(service.Contains("SceneAnalysisComplete(state)\n ? StoryIntelligenceResumeRoutes.ReviewCentre", StringComparison.Ordinal), "Completed scene analysis should resume to Review Centre.");
Assert(view.Contains("Your manuscript analysis is complete.", StringComparison.Ordinal), "Review Centre should describe completed analysis.");
}
static void StoryIntelligenceObsoleteCreateSceneWorkflowIsRemoved()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml"));
var overviewView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligence.cshtml"));
var reviewViewPath = Path.Combine(root, "Views/Onboarding/StoryIntelligenceReview.cshtml");
var legacyReviewAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceReview", "[HttpGet(\"story-intelligence/review-centre\")]");
Assert(!File.Exists(reviewViewPath), "Obsolete Create Scene Intelligence view should be removed.");
Assert(legacyReviewAction.Contains("RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal), "Legacy review URL should redirect to Review Centre after analysis.");
Assert(!legacyReviewAction.Contains("View(model)", StringComparison.Ordinal), "Legacy review URL must never render obsolete UI.");
Assert(!progressView.Contains("Create scene intelligence", StringComparison.OrdinalIgnoreCase), "Progress view should not link to old create scene workflow.");
Assert(!overviewView.Contains("Create scene intelligence", StringComparison.OrdinalIgnoreCase), "Overview should not describe old create scene workflow.");
}
static void StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories()
{
var root = PlotLineRoot();
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var centre = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
var summary = File.ReadAllText(Path.Combine(root, "Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml"));
Assert(viewModel.Contains("CharacterNeedsAttentionCount => CharacterReview.SummaryNeedsAttentionCount ?? CharacterReview.Candidates.Count", StringComparison.Ordinal), "Character count should prefer lightweight summary counts.");
Assert(viewModel.Contains("LocationNeedsAttentionCount => LocationReview.SummaryNeedsAttentionCount ?? LocationReview.Candidates.Count", StringComparison.Ordinal), "Location count should prefer lightweight summary counts.");
Assert(viewModel.Contains("NarrativeNeedsAttentionCount => NarrativeReview.SummaryNeedsAttentionCount ?? NarrativeReview.Candidates.Count", StringComparison.Ordinal), "Narrative count should prefer lightweight summary counts.");
Assert(centre.Contains("Scene Metrics", StringComparison.Ordinal) && centre.Contains("MetricValueCount", StringComparison.Ordinal), "Metrics should report actual metric availability.");
Assert(!centre.Contains("Review Metrics", StringComparison.Ordinal), "Metrics should not present a fake review workflow.");
Assert(!summary.Contains("Locations identified", StringComparison.Ordinal), "Summary should not present total identified rows as review workload.");
}
static void StoryIntelligenceReviewCentreUsesIndependentTileGrid()
{
var root = PlotLineRoot();
var centre = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
var css = File.ReadAllText(Path.Combine(root, "wwwroot/css/onboarding.css"));
var tileOrder = new[]
{
"\"Characters\"",
"\"Assets\"",
"\"Knowledge\"",
"\"Locations\"",
"ReviewPlotSynthesis()",
"\"Relationships\"",
"ReviewMetrics"
};
Assert(!centre.Contains("_StoryIntelligencePipelineHeader", StringComparison.Ordinal), "Review Centre should not render wizard progress header.");
Assert(!centre.Contains("_StoryIntelligencePipelineSummary", StringComparison.Ordinal), "Review Centre should not render separate summary statistics.");
Assert(!centre.Contains("story-review-centre-list", StringComparison.Ordinal), "Review Centre should no longer use the sequential row list.");
Assert(centre.Contains("story-review-centre-grid", StringComparison.Ordinal), "Review Centre should render a category tile grid.");
Assert(centre.Contains("story-review-centre-tile--primary", StringComparison.Ordinal), "Characters tile should have subtle first-action emphasis.");
Assert(centre.Contains("Resolve Characters first.", StringComparison.Ordinal), "Header should explain character-first gating compactly.");
Assert(centre.Contains("can be reviewed in any order", StringComparison.Ordinal), "Header should communicate independent review order.");
Assert(centre.Contains("Locked until Characters are resolved", StringComparison.Ordinal), "Locked dependent tiles should be visible and clearly locked.");
Assert(centre.Contains(">Locked</span>", StringComparison.Ordinal), "Locked tile action should render as non-clickable text.");
Assert(centre.Contains("No metrics available", StringComparison.Ordinal) && centre.Contains("View on Timeline", StringComparison.Ordinal), "Scene Metrics should distinguish unavailable metrics from Timeline navigation.");
Assert(!centre.Contains("Next", StringComparison.Ordinal) && !centre.Contains("Previous", StringComparison.Ordinal), "Review Centre should not contain wizard navigation.");
Assert(!centre.Contains("Core Import", StringComparison.Ordinal) && !centre.Contains("Analyse Manuscript", StringComparison.Ordinal), "Review Centre should not show phase panels.");
var lastIndex = -1;
foreach (var marker in tileOrder)
{
var index = centre.IndexOf(marker, StringComparison.Ordinal);
Assert(index > lastIndex, $"{marker} should appear in the expected tile order.");
lastIndex = index;
}
Assert(css.Contains(".story-review-centre-grid", StringComparison.Ordinal), "Review Centre grid CSS should exist.");
Assert(css.Contains(".story-review-centre-tile {\n min-height: 12rem;", StringComparison.Ordinal), "Review Centre should use dedicated review tiles.");
Assert(css.Contains("background: var(--plotline-panel, var(--bs-body-bg));", StringComparison.Ordinal), "Review Centre tiles should use theme-aware surfaces.");
Assert(css.Contains("[data-theme=\"dark\"] .story-review-centre-tile", StringComparison.Ordinal), "Review Centre tiles should have dark-theme surface protection.");
Assert(!css.Contains(".story-review-centre-tile {\n min-height: 12rem;\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: .85rem;\n border: 1px solid rgba(31, 42, 68, .1);\n border-radius: 8px;\n padding: 1rem;\n background: rgba(255, 255, 255, .64);", StringComparison.Ordinal), "Review Centre tiles should not use hard-coded light slab backgrounds.");
Assert(css.Contains("grid-template-columns: repeat(3, minmax(0, 1fr))", StringComparison.Ordinal), "Desktop Review Centre should use three columns.");
Assert(css.Contains("@media (max-width: 980px)", StringComparison.Ordinal)
&& css.Contains("grid-template-columns: repeat(2, minmax(0, 1fr))", StringComparison.Ordinal), "Tablet Review Centre should use two columns.");
Assert(css.Contains("@media (max-width: 720px)", StringComparison.Ordinal)
&& css.Contains(".story-review-centre-grid {\n grid-template-columns: 1fr;", StringComparison.Ordinal), "Mobile Review Centre should use one column.");
}
static void StoryIntelligenceReviewCentreOpensWithCharacterGate()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var centreAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceReviewCentre", "[HttpGet(\"story-intelligence/characters\")]");
var centreView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
Assert(centreAction.Contains("return View(nameof(StoryIntelligenceReviewCentre), model);", StringComparison.Ordinal), "Review Centre should render its explicit view after completed analysis.");
Assert(!centreAction.Contains("StoryIntelligenceCharacters", StringComparison.Ordinal), "Review Centre should not redirect unresolved Characters away from the centre.");
Assert(centreView.Contains("Resolve Characters first", StringComparison.Ordinal), "Locked modules should explain the character gate.");
Assert(centreView.Contains("Locked until Characters are resolved", StringComparison.Ordinal), "Dependent modules should remain visible but locked.");
}
static void StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var locationsAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceLocations", "[HttpGet(\"story-intelligence/assets\")]");
var assetsAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceAssets", "[HttpGet(\"story-intelligence/relationships\")]");
var relationshipsAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceRelationships", "[HttpPost(\"story-intelligence/cancel\")]");
var knowledgeAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceKnowledge", "[HttpPost(\"story-intelligence/knowledge\")]");
var narrativeAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceNarrative", "[HttpPost(\"story-intelligence/narrative\")]");
Assert(!assetsAction.Contains("LocationStageComplete", StringComparison.Ordinal), "Assets should not require completed location review.");
Assert(!relationshipsAction.Contains("LocationStageComplete", StringComparison.Ordinal) && !relationshipsAction.Contains("AssetStageComplete", StringComparison.Ordinal), "Relationships should not require completed location or asset review.");
Assert(!knowledgeAction.Contains("LocationStageComplete", StringComparison.Ordinal) && !knowledgeAction.Contains("RelationshipStageComplete", StringComparison.Ordinal), "Knowledge should not require earlier non-character reviews.");
Assert(!narrativeAction.Contains("KnowledgeStageComplete", StringComparison.Ordinal) && !narrativeAction.Contains("RelationshipStageComplete", StringComparison.Ordinal), "Narrative review should not require knowledge or relationship review.");
Assert(locationsAction.Contains("CharacterGateSatisfied", StringComparison.Ordinal), "Dependent review modules should still respect the character gate.");
Assert(locationsAction.Contains("RedirectToReviewCentreWithCharacterGate", StringComparison.Ordinal), "Direct dependent module URLs should return to Review Centre when the character gate is closed.");
}
static void StoryIntelligenceReviewModuleCompletionIsDurableAndIndependent()
{
var root = PlotLineRoot();
var model = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligencePipelineRepository.cs"));
var pipeline = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs"));
var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var storyService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceService.cs"));
var coreServices = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
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 narrativeService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/175_Phase27B_StoryIntelligenceIndependentReviewState.sql"));
var synthesisSql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql"));
foreach (var column in new[]
{
"CharacterReviewCompletedUtc",
"LocationReviewCompletedUtc",
"AssetReviewCompletedUtc",
"RelationshipReviewCompletedUtc",
"KnowledgeReviewCompletedUtc",
"NarrativeReviewCompletedUtc"
})
{
Assert(model.Contains($"public DateTime? {column}", StringComparison.Ordinal), $"{column} should be exposed on persisted pipeline state.");
Assert(migration.Contains($"ADD {column} datetime2 NULL", StringComparison.Ordinal), $"{column} should be added by migration.");
Assert(migration.Contains($"p.{column}", StringComparison.Ordinal), $"{column} should be returned by pipeline read procedures.");
}
Assert(repository.Contains("request.MarkCharacterReviewComplete", StringComparison.Ordinal), "Pipeline upsert should pass durable character completion.");
Assert(repository.Contains("request.MarkNarrativeReviewComplete", StringComparison.Ordinal), "Pipeline upsert should pass durable narrative completion.");
Assert(pipeline.Contains("MarkCharacterReviewComplete = true", StringComparison.Ordinal), "Character import should mark only character review complete.");
Assert(pipeline.Contains("MarkNarrativeReviewComplete = true", StringComparison.Ordinal), "Narrative import should mark only narrative review complete.");
Assert(pipeline.Contains("MarkLocationReviewComplete = true", StringComparison.Ordinal), "Location import should mark durable location completion.");
Assert(pipeline.Contains("MarkAssetReviewComplete = true", StringComparison.Ordinal), "Asset import should mark durable asset completion.");
Assert(pipeline.Contains("MarkRelationshipReviewComplete = true", StringComparison.Ordinal), "Relationship import should mark durable relationship completion.");
Assert(pipeline.Contains("MarkKnowledgeReviewComplete = true", StringComparison.Ordinal), "Knowledge import should mark durable knowledge completion.");
Assert(migration.Contains("CharacterReviewCompletedUtc = CASE WHEN @MarkCharacterReviewComplete = 1 THEN COALESCE(CharacterReviewCompletedUtc", StringComparison.Ordinal), "Pipeline upsert must preserve character completion when other modules update.");
Assert(migration.Contains("NarrativeReviewCompletedUtc = CASE WHEN @MarkNarrativeReviewComplete = 1 THEN COALESCE(NarrativeReviewCompletedUtc", StringComparison.Ordinal), "Pipeline upsert must preserve narrative completion when other modules update.");
Assert(migration.Contains("WHERE NarrativeReviewCompletedUtc IS NULL", StringComparison.Ordinal), "Migration repair should be non-destructive.");
Assert(onboardingStory.Contains("pipeline?.CharacterReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate character completion from durable state.");
Assert(onboardingStory.Contains("pipeline?.LocationReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate location completion independently.");
Assert(onboardingStory.Contains("pipeline?.AssetReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate asset completion independently.");
Assert(onboardingStory.Contains("pipeline?.RelationshipReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate relationship completion independently.");
Assert(onboardingStory.Contains("pipeline?.KnowledgeReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate knowledge completion independently.");
Assert(onboardingStory.Contains("pipeline?.NarrativeReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate narrative completion independently.");
Assert(onboardingStory.Contains("batch.CharacterStageComplete = state.CharacterReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate character completion from durable state only.");
Assert(!onboardingStory.Contains("state.CurrentStage is StoryIntelligencePipelineStages.LocationReview", StringComparison.Ordinal), "Resume should not infer character completion from post-character stage ordinals.");
Assert(characterService.Contains("pipeline?.CharacterReviewCompletedUtc is not null", StringComparison.Ordinal) && characterService.Contains("Candidates = []", StringComparison.Ordinal), "Completed character review should not resurrect stale scene candidates.");
Assert(locationService.Contains("pipeline?.LocationReviewCompletedUtc is not null", StringComparison.Ordinal), "Completed location review should not resurrect stale scene candidates.");
Assert(assetService.Contains("pipeline?.AssetReviewCompletedUtc is not null", StringComparison.Ordinal), "Completed asset review should not resurrect stale scene candidates.");
Assert(relationshipService.Contains("pipeline?.RelationshipReviewCompletedUtc is not null", StringComparison.Ordinal), "Completed relationship review should not resurrect stale scene candidates.");
Assert(knowledgeService.Contains("pipeline?.KnowledgeReviewCompletedUtc is not null", StringComparison.Ordinal), "Completed knowledge review should not resurrect stale scene candidates.");
Assert(narrativeService.Contains("pipeline?.NarrativeReviewCompletedUtc is not null", StringComparison.Ordinal), "Completed plot review should not resurrect stale plot candidates.");
Assert(!storyService.Contains("StoryIntelligenceReadyForLocations", StringComparison.Ordinal), "Dashboard should not use old post-character location readiness ordinals.");
Assert(!storyService.Contains("StoryIntelligenceReadyForAssets", StringComparison.Ordinal), "Dashboard should not use old post-character asset readiness ordinals.");
Assert(!storyService.Contains("StoryIntelligenceReadyForRelationships", StringComparison.Ordinal), "Dashboard should not use old post-character relationship readiness ordinals.");
Assert(!storyService.Contains("StoryIntelligenceReadyForKnowledge", StringComparison.Ordinal), "Dashboard should not use old post-character knowledge readiness ordinals.");
Assert(coreServices.Contains("pipeline.CharacterReviewCompletedUtc is null", StringComparison.Ordinal), "Book action should not show character review required once durable character completion exists.");
Assert(synthesisSql.Contains("AND Status = N'Pending'", StringComparison.Ordinal), "Whole-book candidate regeneration should preserve reviewed plot decisions.");
Assert(synthesisSql.Contains("Status IN (N'Queued', N'Running', N'Completed')", StringComparison.Ordinal), "Synthesis queue should preserve current completed runs rather than replacing them.");
}
static void StoryIntelligenceReviewModulePagesOmitWizardChrome()
{
var root = PlotLineRoot();
var reviewViews = new[]
{
("StoryIntelligenceCharacters.cshtml", "Review Characters"),
("StoryIntelligenceLocations.cshtml", "Review Locations"),
("StoryIntelligenceAssets.cshtml", "Review Assets"),
("StoryIntelligenceRelationships.cshtml", "Review Relationships"),
("StoryIntelligenceKnowledge.cshtml", "Review Knowledge"),
("StoryIntelligenceNarrative.cshtml", "Review Plot Lines & Threads")
};
var forbidden = new[]
{
"_StoryIntelligencePipelineHeader",
"_StoryIntelligencePipelineSummary",
"story-pipeline",
"story-future-stage",
"story-review-heading__phase",
"Phase 3",
"Review Story Intelligence",
"Next Stage",
"Previous Stage",
"Continue to Locations",
"Continue to Assets",
"Continue to Relationships",
"Continue to Knowledge"
};
foreach (var (viewName, title) in reviewViews)
{
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName));
Assert(view.Contains("<p class=\"eyebrow\">Story Intelligence</p>", StringComparison.Ordinal), $"{viewName} should use a simple module header eyebrow.");
Assert(view.Contains("StoryIntelligenceReviewCentre", StringComparison.Ordinal), $"{viewName} should return to Review Centre.");
Assert(view.Contains($">{title}</h1>", StringComparison.Ordinal), $"{viewName} should keep its focused module title.");
Assert(view.Contains("summary class=\"story-review-card-header\"", StringComparison.Ordinal), $"{viewName} should give each candidate panel a clear shared header.");
Assert(!view.Contains("StoryIntelligenceComplete", StringComparison.Ordinal), $"{viewName} should not route to the old final wizard step.");
foreach (var marker in forbidden)
{
Assert(!view.Contains(marker, StringComparison.OrdinalIgnoreCase), $"{viewName} should not render obsolete wizard chrome: {marker}.");
}
}
var stylesheet = File.ReadAllText(Path.Combine(root, "wwwroot/css/onboarding.css"));
Assert(stylesheet.Contains(".story-review-card-header", StringComparison.Ordinal), "Shared review candidate header CSS should be present.");
Assert(stylesheet.Contains("justify-content: space-between;", StringComparison.Ordinal), "Review candidate headers should align title and status consistently.");
Assert(stylesheet.Contains("border-bottom: 1px solid color-mix(in srgb, var(--plotline-accent", StringComparison.Ordinal), "Review candidate headers should be visually separated from card bodies.");
Assert(stylesheet.Contains("[data-theme=\"dark\"] .story-review-card-header", StringComparison.Ordinal), "Review candidate headers should have dark-theme contrast rules.");
Assert(stylesheet.Contains("[data-theme=\"dark\"] .story-review-note", StringComparison.Ordinal), "Nested evidence and context blocks should stay subordinate in dark theme.");
Assert(stylesheet.Contains(".story-character-card__body .form-select", StringComparison.Ordinal), "Review card form controls should use scoped theme-aware contrast.");
var knowledgeView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceKnowledge.cshtml"));
var relationshipsView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceRelationships.cshtml"));
Assert(knowledgeView.Contains("<strong>@candidate.KnowledgeStatement</strong>", StringComparison.Ordinal), "Knowledge review headers should foreground the knowledge item.");
Assert(relationshipsView.Contains("<strong>@candidate.CharacterAName / @candidate.CharacterBName</strong>", StringComparison.Ordinal), "Relationship review headers should foreground both participants.");
}
static void StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis()
{
var root = PlotLineRoot();
var reviewServices = new[]
{
"StoryIntelligenceCharacterImportService.cs",
"StoryIntelligenceLocationImportService.cs",
"StoryIntelligenceAssetImportService.cs",
"StoryIntelligenceRelationshipImportService.cs",
"StoryIntelligenceKnowledgeImportService.cs",
"StoryIntelligenceNarrativeReviewService.cs"
};
foreach (var fileName in reviewServices)
{
var service = File.ReadAllText(Path.Combine(root, "Services", fileName));
Assert(!service.Contains("GetImportCommitAsync", StringComparison.Ordinal), $"{fileName} should not require legacy StoryIntelligenceImportCommits.");
Assert(service.Contains("StoryIntelligenceRunStatuses.Completed", StringComparison.Ordinal), $"{fileName} should use completed persisted runs as its scene-analysis prerequisite.");
Assert(service.Contains("ListSceneResultsAsync", StringComparison.Ordinal), $"{fileName} should read persisted scene analysis results.");
}
}
static void StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var postBlock = ExtractBetween(controller, "[HttpPost(\"story-intelligence/characters\")]", "[HttpPost(\"story-intelligence/locations\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/locations\")]", "[HttpPost(\"story-intelligence/assets\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/assets\")]", "[HttpPost(\"story-intelligence/relationships\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/relationships\")]", "[HttpGet(\"story-intelligence/knowledge\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/knowledge\")]", "[HttpGet(\"story-intelligence/narrative\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/narrative\")]", "[HttpPost(\"story-intelligence/plot-synthesis/retry\")]");
Assert(postBlock.Contains("StoryIntelligenceReviewCentre", StringComparison.Ordinal), "Review posts should return to the centre.");
Assert(!postBlock.Contains("StoryIntelligenceCharacterComplete", StringComparison.Ordinal), "Character post should not target success interstitial.");
Assert(!postBlock.Contains("StoryIntelligenceLocationComplete", StringComparison.Ordinal), "Location post should not target success interstitial.");
Assert(!postBlock.Contains("StoryIntelligenceAssetComplete", StringComparison.Ordinal), "Asset post should not target success interstitial.");
Assert(!postBlock.Contains("StoryIntelligenceRelationshipComplete", StringComparison.Ordinal), "Relationship post should not target success interstitial.");
Assert(!postBlock.Contains("StoryIntelligenceKnowledgeComplete", StringComparison.Ordinal), "Knowledge post should not target success interstitial.");
Assert(!postBlock.Contains("StoryIntelligenceNarrativeComplete", StringComparison.Ordinal), "Narrative post should not target success interstitial.");
}
static void StoryIntelligenceLegacyReviewCompletionRoutesAreDeleted()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var deletedRoutes = new[]
{
"story-intelligence/characters/complete",
"story-intelligence/locations/complete",
"story-intelligence/assets/complete",
"story-intelligence/relationships/complete",
"story-intelligence/knowledge/complete",
"story-intelligence/narrative/complete",
"story-intelligence/complete"
};
foreach (var route in deletedRoutes)
{
Assert(!controller.Contains($"[HttpGet(\"{route}\")]", StringComparison.Ordinal), $"{route} should be deleted, not redirected.");
}
Assert(!controller.Contains("StoryIntelligenceCharacterComplete", StringComparison.Ordinal), "Character completion action should be deleted.");
Assert(!controller.Contains("StoryIntelligenceLocationComplete", StringComparison.Ordinal), "Location completion action should be deleted.");
Assert(!controller.Contains("StoryIntelligenceAssetComplete", StringComparison.Ordinal), "Asset completion action should be deleted.");
Assert(!controller.Contains("StoryIntelligenceRelationshipComplete", StringComparison.Ordinal), "Relationship completion action should be deleted.");
Assert(!controller.Contains("StoryIntelligenceKnowledgeComplete", StringComparison.Ordinal), "Knowledge completion action should be deleted.");
Assert(!controller.Contains("StoryIntelligenceNarrativeComplete", StringComparison.Ordinal), "Narrative completion action should be deleted.");
}
static void StoryIntelligenceReviewFormsShareBusySubmitGuard()
{
var root = PlotLineRoot();
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-review-actions.js"));
var reviewViews = new[]
{
"StoryIntelligenceCharacters.cshtml",
"StoryIntelligenceLocations.cshtml",
"StoryIntelligenceAssets.cshtml",
"StoryIntelligenceRelationships.cshtml",
"StoryIntelligenceKnowledge.cshtml",
"StoryIntelligenceNarrative.cshtml"
};
Assert(script.Contains("data-story-review-form", StringComparison.Ordinal), "Shared busy helper should bind review forms.");
Assert(script.Contains("form.dataset.submitting === \"true\"", StringComparison.Ordinal), "Shared busy helper should prevent double submit.");
Assert(script.Contains("aria-busy", StringComparison.Ordinal), "Busy state should be exposed accessibly.");
foreach (var viewName in reviewViews)
{
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName));
Assert(view.Contains("data-story-review-form", StringComparison.Ordinal), $"{viewName} should opt into the shared busy helper.");
Assert(view.Contains("story-review-actions.js", StringComparison.Ordinal), $"{viewName} should load the shared busy helper.");
}
}
static void StoryIntelligenceHeavyReviewModulesOfferBoundedRendering()
{
var root = PlotLineRoot();
var heavyViews = new[]
{
"StoryIntelligenceCharacters.cshtml",
"StoryIntelligenceLocations.cshtml",
"StoryIntelligenceAssets.cshtml",
"StoryIntelligenceRelationships.cshtml",
"StoryIntelligenceKnowledge.cshtml",
"StoryIntelligenceNarrative.cshtml"
};
foreach (var viewName in heavyViews)
{
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName));
if (string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal)
|| string.Equals(viewName, "StoryIntelligenceAssets.cshtml", StringComparison.Ordinal))
{
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var module = string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal) ? "Location" : "Asset";
var candidateVariable = string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal) ? "locationCandidates" : "assetCandidates";
Assert(onboarding.Contains($"reviewCandidates.Get{module}ReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), $"{viewName} should use persisted bounded candidate pages.");
Assert(view.Contains($"var {candidateVariable} = Model.{module}Review.Candidates.ToList();", StringComparison.Ordinal), $"{viewName} should render only the persisted candidate page.");
}
else
{
Assert(view.Contains("const int previewLimit = 25", StringComparison.Ordinal) || view.Contains("const pageSize = 24;", StringComparison.Ordinal), $"{viewName} should cap visible candidates to a small page.");
}
Assert(!view.Contains("asp-route-view=\"all\"", StringComparison.Ordinal), $"{viewName} should not expose an unbounded View All path.");
Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal) || view.Contains("PendingCandidateCount", StringComparison.Ordinal), $"{viewName} should disclose hidden remaining decisions.");
}
var assets = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceAssets.cshtml"));
var relationships = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceRelationships.cshtml"));
var knowledge = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceKnowledge.cshtml"));
var narrative = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml"));
Assert(!assets.Contains("Model.AssetReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key", StringComparison.Ordinal), "Asset merge options should not enumerate the full candidate set per visible card.");
Assert(!relationships.Contains("Model.RelationshipReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key", StringComparison.Ordinal), "Relationship merge options should not enumerate the full candidate set per visible card.");
Assert(!knowledge.Contains("Model.KnowledgeReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key", StringComparison.Ordinal), "Knowledge merge options should not enumerate the full candidate set per visible card.");
Assert(!narrative.Contains("Model.NarrativeReview.Candidates.Where(item => item.NarrativeCandidateID != candidate.NarrativeCandidateID", StringComparison.Ordinal), "Narrative merge options should not enumerate the full candidate set per visible card.");
}
static void StoryIntelligenceReviewCentreAddsNoAiCalls()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var centreAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceReviewCentre", "[HttpGet(\"story-intelligence/characters\")]");
var centreView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
Assert(!centreAction.Contains("StartAsync", StringComparison.Ordinal) && !centreAction.Contains("StartBookAsync", StringComparison.Ordinal), "Review Centre should not queue AI work.");
Assert(!centreAction.Contains("EnsureCanQueueAsync", StringComparison.Ordinal), "Review Centre should not perform paid AI queue checks.");
Assert(!centreView.Contains("OpenAI", StringComparison.Ordinal) && !centreView.Contains("prompt", StringComparison.OrdinalIgnoreCase), "Review Centre view should not introduce prompt work.");
}
static void StoryIntelligenceReviewCentreUsesBoundedSummaryQuery()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var pipelineRepository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligencePipelineRepository.cs"));
var pipelineService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/176_Phase27C_StoryIntelligenceReviewCentreSummary.sql"));
var centreAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceReviewCentre", "[HttpGet(\"story-intelligence/characters\")]");
var centreSummary = ExtractBetween(service, "public async Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync", "public Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync");
Assert(centreAction.Contains("GetReviewCentreAsync(batchId)", StringComparison.Ordinal), "Review Centre GET should use the bounded summary path.");
Assert(!centreAction.Contains("GetProgressAsync(batchId)", StringComparison.Ordinal), "Review Centre GET should not build the full progress/candidate model.");
Assert(centreSummary.Contains("GetReviewFrameAsync(batchId)", StringComparison.Ordinal), "Review Centre summary should use the shared aggregate review frame.");
Assert(!centreSummary.Contains("BuildReviewAsync", StringComparison.Ordinal), "Review Centre summary should not call detailed module candidate builders.");
Assert(!centreSummary.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Review Centre summary should not generate narrative candidates.");
Assert(!centreSummary.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Review Centre summary should not parse scene intelligence results.");
Assert(!centreSummary.Contains("ListByChapterAsync", StringComparison.Ordinal), "Review Centre summary should not rebuild scene object graphs.");
Assert(pipelineRepository.Contains("StoryIntelligenceReviewCentre_GetSummary", StringComparison.Ordinal), "Repository should call the Review Centre aggregate procedure.");
Assert(pipelineService.Contains("GetReviewCentreSummaryAsync(int bookId, int userId)", StringComparison.Ordinal), "Pipeline service should expose summary hydration.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCentre_GetSummary", StringComparison.Ordinal), "Migration should create the aggregate summary procedure.");
Assert(migration.Contains("IX_StoryIntelligenceNarrativeCandidates_Book_Status_Type", StringComparison.Ordinal), "Narrative candidate counts should have a supporting index.");
Assert(!migration.Contains("ParsedJson", StringComparison.Ordinal) && !migration.Contains("OPENJSON", StringComparison.Ordinal), "Summary query should not parse Scene Intelligence JSON.");
Assert(viewModel.Contains("SummaryNeedsAttentionCount", StringComparison.Ordinal), "Tile counts should support lightweight summary values without full candidate objects.");
}
static void StoryIntelligenceCharacterReviewQueuesEnrichmentBesidePlotSynthesis()
{
var root = PlotLineRoot();
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var worker = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceWorker.cs"));
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
Assert(service.Contains("ICharacterEnrichmentService characterEnrichment", StringComparison.Ordinal), "Onboarding Story Intelligence should depend on the enrichment service.");
Assert(service.Contains("QueuePostCharacterReviewAnalysisAsync(batch)", StringComparison.Ordinal), "Character completion should use the shared post-review queue boundary.");
Assert(service.Contains("wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync", StringComparison.Ordinal), "Post-review queueing must retain Plot Lines & Threads synthesis.");
Assert(service.Contains("characterEnrichment.QueueAfterCharacterResolutionAsync", StringComparison.Ordinal), "Post-review queueing must start character enrichment.");
Assert(service.Contains("Task.WhenAll(", StringComparison.Ordinal), "Plot synthesis and character enrichment should be queued independently together.");
Assert(worker.Contains("ProcessWholeBookPlotSynthesisAsync", StringComparison.Ordinal)
&& worker.Contains("ProcessCharacterEnrichmentAsync", StringComparison.Ordinal), "The persisted worker should process both independent job types.");
Assert(program.Contains("ICharacterEnrichmentRepository, CharacterEnrichmentRepository", StringComparison.Ordinal)
&& program.Contains("ICharacterEnrichmentService, CharacterEnrichmentService", StringComparison.Ordinal), "Character enrichment services should be registered.");
}
static void StoryIntelligenceCharacterEnrichmentUsesPersistedSignalRProgress()
{
var root = PlotLineRoot();
var models = File.ReadAllText(Path.Combine(root, "Models/CharacterEnrichmentModels.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/CharacterEnrichmentRepository.cs"));
var notifier = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceProgressNotifier.cs"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/StoryIntelligenceHub.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-review-centre-progress.js"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/183_Phase27J_CharacterEnrichmentRuns.sql"));
Assert(models.Contains("ProcessedCharacters", StringComparison.Ordinal)
&& models.Contains("TotalCharacters", StringComparison.Ordinal), "Character enrichment progress should expose real processed/total counts.");
Assert(repository.Contains("\"dbo.CharacterEnrichmentRun_GetCurrentByBook\"", StringComparison.Ordinal)
&& repository.Contains("\"dbo.CharacterEnrichmentRun_UpdateProgress\"", StringComparison.Ordinal), "Character enrichment state should be reloadable from persisted run state.");
Assert(notifier.Contains("CharacterEnrichmentProgressChanged", StringComparison.Ordinal), "SignalR notifier should publish character enrichment progress.");
Assert(hub.Contains("WatchCharacterEnrichment", StringComparison.Ordinal), "SignalR hub should support reconnect/refresh snapshots for character enrichment.");
Assert(view.Contains("data-character-enrichment-tile", StringComparison.Ordinal)
&& view.Contains("View Characters", StringComparison.Ordinal)
&& view.Contains("Analysing", StringComparison.Ordinal), "Review Centre should render the Characters analysing/completed states.");
Assert(script.Contains("WatchCharacterEnrichment", StringComparison.Ordinal)
&& script.Contains("CharacterEnrichmentProgressChanged", StringComparison.Ordinal), "Review Centre script should use SignalR rather than polling.");
Assert(migration.Contains("CREATE TABLE dbo.CharacterEnrichmentRuns", StringComparison.Ordinal)
&& migration.Contains("CharacterEnrichmentRun_ClaimNext", StringComparison.Ordinal)
&& migration.Contains("CharacterEnrichmentRun_Fail", StringComparison.Ordinal), "Migration should persist queue, progress, retry, and failure state.");
}
static void StoryIntelligenceCharacterEnrichmentPreservesAuthorFields()
{
var root = PlotLineRoot();
var service = File.ReadAllText(Path.Combine(root, "Services/CharacterEnrichmentService.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/183_Phase27J_CharacterEnrichmentRuns.sql"));
Assert(service.Contains("FactMinimumConfidence = 0.9m", StringComparison.Ordinal), "Structured character facts should use a conservative confidence gate.");
Assert(service.Contains("StrongFact", StringComparison.Ordinal)
&& service.Contains("fact.SceneID is > 0", StringComparison.Ordinal), "Structured facts should require scene provenance before saving.");
Assert(migration.Contains("DefaultDescription = CASE WHEN NULLIF(LTRIM(RTRIM(c.DefaultDescription)), N'') IS NULL", StringComparison.Ordinal), "Generated summaries should not overwrite existing descriptions.");
Assert(migration.Contains("BirthDate = CASE WHEN c.BirthDate IS NULL", StringComparison.Ordinal), "Generated dates of birth should not overwrite existing values.");
Assert(migration.Contains("AgeAtSeriesStart = CASE WHEN c.AgeAtSeriesStart IS NULL", StringComparison.Ordinal), "Generated ages should not overwrite existing values.");
Assert(migration.Contains("Height = CASE WHEN NULLIF(LTRIM(RTRIM(c.Height)), N'') IS NULL", StringComparison.Ordinal), "Generated height should not overwrite existing values.");
Assert(migration.Contains("EyeColour = CASE WHEN NULLIF(LTRIM(RTRIM(c.EyeColour)), N'') IS NULL", StringComparison.Ordinal), "Generated eye colour should not overwrite existing values.");
Assert(migration.Contains("CREATE TABLE dbo.CharacterEnrichmentEvidence", StringComparison.Ordinal), "Generated facts should retain internal provenance/evidence.");
}
static void CharacterDetailCanQueueSingleCharacterEnrichment()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/CharactersController.cs"));
var characterService = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
var enrichmentService = File.ReadAllText(Path.Combine(root, "Services/CharacterEnrichmentService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Characters/Details.cshtml"));
Assert(view.Contains("Reanalyse character", StringComparison.Ordinal), "Character detail should expose a discreet reanalyse action.");
Assert(view.Contains("data-character-reanalyse-form", StringComparison.Ordinal), "Character reanalyse action should be script-addressable.");
Assert(controller.Contains("Reanalyse(int id)", StringComparison.Ordinal)
&& controller.Contains("QueueCharacterReanalysisAsync(id)", StringComparison.Ordinal), "Character controller should queue manual reanalysis through the character service.");
Assert(characterService.Contains("QueueCharacterReanalysisAsync(int characterId)", StringComparison.Ordinal), "Character service should expose a UI-independent reanalysis queue method.");
Assert(enrichmentService.Contains("QueueForCharacterAsync(int characterId, int userId, int? bookId = null)", StringComparison.Ordinal), "Character enrichment service should expose a reusable single-character queue method for future merge workflows.");
}
static void SingleCharacterEnrichmentIsPersistedAndDuplicateSafe()
{
var root = PlotLineRoot();
var migration = File.ReadAllText(Path.Combine(root, "Sql/185_Phase27L_ManualCharacterEnrichment.sql"));
var repository = File.ReadAllText(Path.Combine(root, "Data/CharacterEnrichmentRepository.cs"));
var enrichmentService = File.ReadAllText(Path.Combine(root, "Services/CharacterEnrichmentService.cs"));
var originalMigration = File.ReadAllText(Path.Combine(root, "Sql/183_Phase27J_CharacterEnrichmentRuns.sql"));
Assert(migration.Contains("ALTER TABLE dbo.CharacterEnrichmentRuns ADD CharacterID int NULL", StringComparison.Ordinal), "Manual reanalysis should persist character scope on the shared enrichment run table.");
Assert(migration.Contains("CharacterEnrichmentRun_QueueCharacter", StringComparison.Ordinal), "Manual reanalysis should use a stored procedure queue path.");
Assert(migration.Contains("UX_CharacterEnrichmentRuns_ActiveCharacter", StringComparison.Ordinal)
&& migration.Contains("Status IN (N'Queued', N'Running')", StringComparison.Ordinal), "Manual reanalysis should prevent duplicate active jobs for the same character.");
Assert(migration.Contains("WITH (UPDLOCK, HOLDLOCK)", StringComparison.Ordinal), "Manual reanalysis queue should coalesce concurrent clicks inside SQL.");
Assert(migration.Contains("WasCoalesced", StringComparison.Ordinal)
&& enrichmentService.Contains("active run already exists", StringComparison.Ordinal), "Duplicate manual reanalysis requests should be logged as coalesced.");
Assert(migration.Contains("AND (@BookID IS NULL OR ch.BookID = @BookID)", StringComparison.Ordinal), "Manual reanalysis should support explicit book/character queueing.");
Assert(migration.Contains("pua.UserID = @UserID", StringComparison.Ordinal), "Manual reanalysis should validate project ownership server-side.");
Assert(migration.Contains("CharacterEnrichmentRun_GetCurrentByBook", StringComparison.Ordinal)
&& migration.Contains("CharacterID IS NULL", StringComparison.Ordinal), "Book-level Review Centre status should ignore manual per-character runs.");
Assert(repository.Contains("QueueCharacterAsync", StringComparison.Ordinal)
&& repository.Contains("GetCharacterStatusAsync", StringComparison.Ordinal), "Repository should persist and reload manual reanalysis state.");
Assert(enrichmentService.Contains("BuildContextAsync(run.ProjectID, run.BookID, run.CharacterID)", StringComparison.Ordinal), "Worker should reuse bulk context building with a character filter.");
Assert(originalMigration.Contains("DefaultDescription = CASE WHEN NULLIF(LTRIM(RTRIM(c.DefaultDescription)), N'') IS NULL", StringComparison.Ordinal), "Shared completion should preserve author-entered descriptions.");
}
static void CharacterDetailEnrichmentStatusUsesScopedSignalR()
{
var root = PlotLineRoot();
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Characters/Details.cshtml"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/StoryIntelligenceHub.cs"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/character-enrichment-detail.js"));
var models = File.ReadAllText(Path.Combine(root, "Models/CharacterEnrichmentModels.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/185_Phase27L_ManualCharacterEnrichment.sql"));
Assert(viewModel.Contains("CharacterEnrichmentCharacterStatus CharacterAnalysis", StringComparison.Ordinal), "Character detail should reconstruct persisted enrichment status on reload.");
Assert(view.Contains("Character analysis:", StringComparison.Ordinal)
&& view.Contains("Last analysed:", StringComparison.Ordinal), "Character detail should display analysis status and last successful analysis time.");
Assert(view.Contains("Story Intelligence Analysis", StringComparison.Ordinal), "Character detail should display generated analysis separately from author profile data.");
Assert(hub.Contains("WatchCharacterEnrichmentForCharacter", StringComparison.Ordinal), "SignalR hub should support character-scoped enrichment snapshots.");
Assert(models.Contains("public int? CharacterID { get; init; }", StringComparison.Ordinal), "SignalR progress event should include character scope.");
Assert(script.Contains("CharacterEnrichmentProgressChanged", StringComparison.Ordinal)
&& script.Contains("Number(snapshot.characterID || snapshot.characterId || 0) !== characterId", StringComparison.Ordinal), "Character detail script should ignore unrelated enrichment events.");
Assert(script.Contains("window.location.reload()", StringComparison.Ordinal), "Character detail should refresh displayed generated details after completion.");
Assert(migration.Contains("LastAnalysedUtc = @LastAnalysedUtc", StringComparison.Ordinal), "Last analysed should come only from a successful completed enrichment run.");
Assert(migration.Contains("Status = COALESCE((SELECT TOP (1) Status FROM @Attempt)", StringComparison.Ordinal), "Failed latest attempts should remain visible and retryable.");
}
static void StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var expectedActions = new Dictionary<string, string>(StringComparer.Ordinal)
{
["StoryIntelligenceCharacters"] = "GetCharacterReviewAsync(batchId, page)",
["StoryIntelligenceLocations"] = "GetLocationReviewAsync(batchId, page)",
["StoryIntelligenceAssets"] = "GetAssetReviewAsync(batchId, page)",
["StoryIntelligenceRelationships"] = "GetRelationshipReviewAsync(batchId, page)",
["StoryIntelligenceKnowledge"] = "GetKnowledgeReviewAsync(batchId, page)",
["StoryIntelligenceNarrative"] = "GetNarrativeReviewAsync(batchId)"
};
foreach (var (actionName, serviceCall) in expectedActions)
{
var action = ExtractAction(controller, $"public async Task<IActionResult> {actionName}");
Assert(action.Contains(serviceCall, StringComparison.Ordinal), $"{actionName} should load only its selected review module.");
Assert(!action.Contains("GetProgressAsync(batchId)", StringComparison.Ordinal), $"{actionName} should not build full Story Intelligence progress.");
Assert(!action.Contains("ResumeBookAsync", StringComparison.Ordinal), $"{actionName} should not resume/rebuild book analysis.");
}
var selectedReview = ExtractBetween(service, "private async Task<StoryIntelligenceProgressViewModel?> GetSelectedReviewAsync", "private async Task<StoryIntelligenceReviewFrame?> GetReviewFrameAsync");
Assert(selectedReview.Contains("GetReviewFrameAsync(batchId)", StringComparison.Ordinal), "Selected module loads should start from the compact persisted review frame.");
Assert(!selectedReview.Contains("GetProgressAsync", StringComparison.Ordinal), "Selected module loads should not call full progress.");
Assert(!selectedReview.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Selected module orchestration should not read all retained scene JSON.");
Assert(!selectedReview.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Selected module orchestration should not regenerate narrative candidates.");
Assert(!selectedReview.Contains("ResumeBookAsync", StringComparison.Ordinal), "Selected module orchestration should not resume book analysis.");
var frame = ExtractBetween(service, "private async Task<StoryIntelligenceReviewFrame?> GetReviewFrameAsync", "private sealed record StoryIntelligenceReviewFrame");
Assert(frame.Contains("GetReviewCentreSummaryAsync(batch.BookID, batch.UserID)", StringComparison.Ordinal), "Selected module frame should use the persisted Review Centre summary.");
Assert(!frame.Contains("BuildReviewAsync", StringComparison.Ordinal), "Selected module frame should not build unrelated review candidates.");
Assert(!frame.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Selected module frame should not parse retained scene JSON.");
Assert(!frame.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Selected module frame should not generate candidates.");
var narrativeMethod = ExtractBetween(service, "public Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync", "private async Task<StoryIntelligenceProgressViewModel?> GetSelectedReviewAsync");
Assert(narrativeMethod.Contains("narrativeReview.GetReviewAsync(frame.Batch)", StringComparison.Ordinal), "Plot Lines & Threads should use persisted narrative candidates.");
Assert(!narrativeMethod.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Plot Lines & Threads GET should not generate narrative candidates.");
var persistedReviewService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
Assert(service.Contains("reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Character GET should read persisted review candidates.");
Assert(service.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Location GET should read persisted review candidates.");
Assert(service.Contains("reviewCandidates.GetAssetReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Asset GET should read persisted review candidates.");
Assert(service.Contains("reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Relationship GET should read persisted review candidates.");
Assert(service.Contains("reviewCandidates.GetKnowledgeReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Knowledge GET should read persisted review candidates.");
Assert(persistedReviewService.Contains("ListPageAsync", StringComparison.Ordinal), "Persisted review service should page through candidate rows.");
Assert(!persistedReviewService.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Persisted review GET service must not parse retained Scene Intelligence JSON.");
Assert(!persistedReviewService.Contains("GetRunAsync", StringComparison.Ordinal), "Persisted review GET service must not traverse old run batches.");
}
static void StoryIntelligenceCompletedCharacterReviewRemainsInspectable()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml"));
var action = ExtractAction(controller, "public async Task<IActionResult> StoryIntelligenceCharacters");
Assert(action.Contains("GetCharacterReviewAsync(batchId, page)", StringComparison.Ordinal), "Character GET should use the selected persisted Character review path.");
Assert(!action.Contains("CharacterReview.Candidates.Count == 0", StringComparison.Ordinal), "Completed empty Character review must not redirect to Review Centre.");
Assert(!action.Contains("CharacterGateSatisfied &&", StringComparison.Ordinal), "Character completion must not be treated as an access blocker.");
Assert(action.TrimEnd().EndsWith("return View(model);\n }", StringComparison.Ordinal), "Character GET should render the Character page when scene analysis is complete.");
Assert(!action.Contains("PrepareCharacterCandidatesAsync", StringComparison.Ordinal), "Character GET must not regenerate candidates.");
Assert(!action.Contains("RecordCharacterImportAsync", StringComparison.Ordinal), "Character GET must not alter CharacterReviewCompletedUtc.");
Assert(!action.Contains("RecordLocationImportAsync", StringComparison.Ordinal), "Character GET must not alter Location completion.");
Assert(!action.Contains("RecordAssetImportAsync", StringComparison.Ordinal), "Character GET must not alter Asset completion.");
Assert(!action.Contains("RecordRelationshipImportAsync", StringComparison.Ordinal), "Character GET must not alter Relationship completion.");
Assert(!action.Contains("RecordKnowledgeImportAsync", StringComparison.Ordinal), "Character GET must not alter Knowledge completion.");
var selectedReview = ExtractBetween(onboarding, "public Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync", "public Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync");
Assert(selectedReview.Contains("reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Completed Character page should hydrate from persisted review/canonical data.");
Assert(!selectedReview.Contains("PrepareCharacterCandidatesAsync", StringComparison.Ordinal), "Selected Character GET must not prepare candidates.");
Assert(!selectedReview.Contains("RecordCharacterImportAsync", StringComparison.Ordinal), "Selected Character GET must not mark completion.");
var getReview = ExtractBetween(candidateService, "public async Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync", "public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync");
Assert(getReview.Contains("AliasTargetOptions = existingOptions", StringComparison.Ordinal), "Completed Character page should expose canonical resolved characters for inspection.");
Assert(!getReview.Contains("BuildReviewAsync", StringComparison.Ordinal), "Persisted Character GET must not rebuild candidates.");
Assert(!getReview.Contains("MarkReviewedAsync", StringComparison.Ordinal), "Persisted Character GET must not change decisions.");
Assert(view.Contains("Character Review is complete.", StringComparison.Ordinal), "Completed Character page should state that the review is complete.");
Assert(view.Contains("Resolved character", StringComparison.Ordinal), "Completed Character page should list resolved canonical characters.");
Assert(view.Contains("AliasTargetOptions", StringComparison.Ordinal), "Completed Character page should use canonical Character data when no pending candidates exist.");
}
static void StoryIntelligenceReviewCentreCompletedModulesUseCanonicalDestinations()
{
var root = PlotLineRoot();
var centre = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var persistence = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var metricSql = File.ReadAllText(Path.Combine(root, "Sql/178_Phase27E_ReviewCentreMetricSummary.sql"));
Assert(centre.Contains("\"Review Characters\"", StringComparison.Ordinal), "Incomplete Characters should use the Story Intelligence review action label.");
Assert(centre.Contains("\"StoryIntelligenceCharacters\"", StringComparison.Ordinal), "Incomplete Characters should route to Character review.");
Assert(centre.Contains("\"View Characters\"", StringComparison.Ordinal), "Complete Characters should use canonical action label.");
Assert(centre.Contains("\"Characters\"", StringComparison.Ordinal) && centre.Contains("new { projectId = Model.ProjectID }", StringComparison.Ordinal), "Complete Characters should route to Characters/Index with project context.");
Assert(centre.Contains("\"Review Locations\"", StringComparison.Ordinal) && centre.Contains("\"StoryIntelligenceLocations\"", StringComparison.Ordinal), "Incomplete Locations should route to Location review.");
Assert(centre.Contains("\"View Locations\"", StringComparison.Ordinal) && centre.Contains("\"Locations\"", StringComparison.Ordinal), "Complete Locations should route to Locations/Index.");
Assert(centre.Contains("\"Review Assets\"", StringComparison.Ordinal) && centre.Contains("\"StoryIntelligenceAssets\"", StringComparison.Ordinal), "Incomplete Assets should route to Asset review.");
Assert(centre.Contains("\"View Assets\"", StringComparison.Ordinal) && centre.Contains("\"StoryAssets\"", StringComparison.Ordinal), "Complete Assets should route to StoryAssets/Index.");
Assert(centre.Contains("\"Review Relationships\"", StringComparison.Ordinal) && centre.Contains("\"StoryIntelligenceRelationships\"", StringComparison.Ordinal), "Incomplete Relationships should route to Relationship review.");
Assert(centre.Contains("\"View Relationships\"", StringComparison.Ordinal) && centre.Contains("\"RelationshipMap\"", StringComparison.Ordinal), "Complete Relationships should route to RelationshipMap/Index.");
Assert(centre.Contains("new { projectId = Model.ProjectID, bookId = Model.BookID }", StringComparison.Ordinal), "Book-scoped canonical destinations should include book context.");
Assert(centre.Contains("\"Review Knowledge\"", StringComparison.Ordinal) && centre.Contains("\"StoryIntelligenceKnowledge\"", StringComparison.Ordinal), "Incomplete Knowledge should route to Knowledge review.");
Assert(centre.Contains("\"View Knowledge\"", StringComparison.Ordinal) && centre.Contains("\"StoryBible\"", StringComparison.Ordinal), "Complete Knowledge should route to StoryBible/Index.");
Assert(centre.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "Incomplete Plot should route to Plot review.");
Assert(centre.Contains("Url.Action(\"StoryIntelligenceNarrative\", \"Onboarding\"", StringComparison.Ordinal), "Incomplete Plot should use the Story Intelligence Plot review route.");
Assert(centre.Contains("Url.Action(\"Index\", \"Timeline\", new { projectId = Model.ProjectID, bookId = Model.BookID })", StringComparison.Ordinal), "Complete Plot and available Metrics should route to Timeline.");
Assert(centre.Contains("View on Timeline", StringComparison.Ordinal), "Complete Plot should label the canonical Timeline action.");
Assert(centre.Contains("Model.MetricValueCount > 0", StringComparison.Ordinal), "Metrics availability should be based on actual metric values.");
Assert(centre.Contains("No metrics available", StringComparison.Ordinal), "Zero metrics should be clearly identified.");
Assert(centre.Contains("No action", StringComparison.Ordinal), "Zero metrics should not expose a misleading Review Metrics action.");
Assert(!centre.Contains("Review Metrics", StringComparison.Ordinal), "Review Centre should not advertise a non-existent metrics review workflow.");
Assert(model.Contains("public int MetricValueCount", StringComparison.Ordinal), "Review Centre model should expose metric availability.");
Assert(persistence.Contains("public int MetricValueCount", StringComparison.Ordinal), "Persisted summary should expose metric availability.");
Assert(service.Contains("MetricValueCount = summary.MetricValueCount", StringComparison.Ordinal), "Review Centre summary should hydrate metric availability.");
Assert(metricSql.Contains("MetricValueSummary", StringComparison.Ordinal) && metricSql.Contains("SceneMetricValues", StringComparison.Ordinal), "Metric summary SQL should count existing metric values.");
Assert(centre.Contains("var href = complete", StringComparison.Ordinal), "Module helper should switch destination by completion state.");
Assert(centre.Contains("Url.Action(reviewAction, \"Onboarding\", new { batchId = Model.BatchID })", StringComparison.Ordinal), "Incomplete modules should still open Story Intelligence review.");
Assert(centre.Contains("Url.Action(\"Index\", completeController, completeRouteValues)", StringComparison.Ordinal), "Complete modules should open canonical Index routes.");
}
static void StoryIntelligenceDetailedReviewsReadPersistedCandidatePages()
{
var root = PlotLineRoot();
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceReviewCandidateRepository.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/177_Phase27D_StoryIntelligencePersistedReviewCandidates.sql"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
var knowledgeView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceKnowledge.cshtml"));
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
foreach (var module in new[] { "Characters", "Locations", "Assets", "Relationships", "Knowledge" })
{
Assert(migration.Contains($"Module = @Module", StringComparison.Ordinal), $"{module} persisted reads should be module scoped.");
}
Assert(migration.Contains("CREATE TABLE dbo.StoryIntelligenceReviewCandidateGenerations", StringComparison.Ordinal), "Candidate preparation generations should be durable.");
Assert(migration.Contains("CREATE TABLE dbo.StoryIntelligenceReviewCandidates", StringComparison.Ordinal), "Review candidates should be durable.");
Assert(migration.Contains("CREATE TABLE dbo.StoryIntelligenceReviewCandidateEvidence", StringComparison.Ordinal), "Review evidence should be durable.");
Assert(migration.Contains("StoryIntelligenceReviewCandidate_ListPage", StringComparison.Ordinal), "Review GET should use a bounded page procedure.");
Assert(migration.Contains("IF @PageSize > 100 SET @PageSize = 100", StringComparison.Ordinal), "Page size should be bounded server-side.");
Assert(repository.Contains("CommandType.StoredProcedure", StringComparison.Ordinal), "Candidate repository should use stored procedures.");
Assert(!repository.Contains("SELECT ", StringComparison.Ordinal), "Candidate repository should not use inline SELECT SQL.");
Assert(onboarding.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Normal Location GET should use persisted candidate pages.");
Assert(onboarding.Contains("reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Normal Relationship GET should use persisted candidate pages.");
Assert(service.Contains("PreparePeerCandidatesAsync", StringComparison.Ordinal), "Scene JSON candidate builders should be isolated to deliberate preparation.");
Assert(service.Contains("LoadPageAsync", StringComparison.Ordinal), "Normal review reads should use page loading.");
Assert(!ExtractBetween(service, "public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync", "public async Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync").Contains("BuildReviewAsync", StringComparison.Ordinal), "Location GET must not call the old builder.");
Assert(!ExtractBetween(service, "public async Task<StoryIntelligenceRelationshipReviewViewModel> GetRelationshipReviewAsync", "public async Task<StoryIntelligenceKnowledgeReviewViewModel> GetKnowledgeReviewAsync").Contains("BuildReviewAsync", StringComparison.Ordinal), "Relationship GET must not call the old builder.");
Assert(program.Contains("IStoryIntelligenceReviewCandidateRepository, StoryIntelligenceReviewCandidateRepository", StringComparison.Ordinal), "Persisted candidate repository should be registered.");
Assert(program.Contains("IStoryIntelligenceReviewCandidateService, StoryIntelligenceReviewCandidateService", StringComparison.Ordinal), "Persisted candidate service should be registered.");
Assert(knowledgeView.Contains("Page @Model.KnowledgeReview.PageNumber", StringComparison.Ordinal)
&& knowledgeView.Contains("Showing @Model.KnowledgeReview.ShowingStart", StringComparison.Ordinal), "Knowledge review should show page and pending range counts.");
Assert(knowledgeView.Contains("name=\"PageNumber\"", StringComparison.Ordinal), "Knowledge review should post the current page number.");
Assert(knowledgeView.Contains("name=\"intent\" value=\"previous\"", StringComparison.Ordinal)
&& knowledgeView.Contains("name=\"intent\" value=\"next\"", StringComparison.Ordinal), "Knowledge review Previous and Next should submit current-page decisions before navigation.");
Assert(knowledgeView.Contains("if (Model.KnowledgeReview.HasNextPage)", StringComparison.Ordinal)
&& knowledgeView.Contains("name=\"intent\" value=\"save\"", StringComparison.Ordinal), "Knowledge review should show Next before the last page and Save only on the last page.");
Assert(viewModels.Contains("public int PageNumber { get; set; } = 1;", StringComparison.Ordinal), "Knowledge review posts should preserve the page being saved.");
Assert(controller.Contains("ImportStoryIntelligenceKnowledge(StoryIntelligenceKnowledgeImportForm form, string intent = \"save\")", StringComparison.Ordinal), "Knowledge POST should accept a save/navigation intent.");
Assert(controller.Contains("RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID, page = targetPage })", StringComparison.Ordinal), "Knowledge review should stay in-module while pending candidates remain.");
}
static void StoryIntelligenceModuleCompletionLifecycleUsesPersistedCandidates()
{
var root = PlotLineRoot();
var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var persistence = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs"));
var summarySql = File.ReadAllText(Path.Combine(root, "Sql/179_Phase27F_ReviewCentreCandidateLifecycleSummary.sql"));
var characterImport = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
var locationImport = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
var assetImport = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceAssetImportService.cs"));
var relationshipImport = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceRelationshipImportService.cs"));
var knowledgeImport = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceKnowledgeImportService.cs"));
Assert(candidateService.Contains("Task<int> CompleteModuleIfReadyAsync(OnboardingStoryIntelligenceBatch batch, string module)", StringComparison.Ordinal), "Candidate service should expose the shared persisted completion lifecycle.");
Assert(candidateService.Contains("await candidates.CompleteGenerationAsync(generation.ReviewCandidateGenerationID)", StringComparison.Ordinal), "Successful candidate preparation should finalize the persisted generation.");
Assert(candidateService.Contains("await CompleteModuleIfReadyAsync(batch, module, completedGeneration)", StringComparison.Ordinal), "Zero-candidate generation should immediately apply the same completion rule.");
Assert(candidateService.Contains("generation.PendingCount != 0", StringComparison.Ordinal), "Non-zero and paged candidate generations must not complete early.");
Assert(candidateService.Contains("StoryIntelligenceReviewCandidateGenerationStatuses.Ready", StringComparison.Ordinal), "Failed or never-run generations must not complete.");
Assert(candidateService.Contains("RecordCharacterImportAsync", StringComparison.Ordinal)
&& candidateService.Contains("RecordLocationImportAsync", StringComparison.Ordinal)
&& candidateService.Contains("RecordAssetImportAsync", StringComparison.Ordinal)
&& candidateService.Contains("RecordRelationshipImportAsync", StringComparison.Ordinal)
&& candidateService.Contains("RecordKnowledgeImportAsync", StringComparison.Ordinal), "All candidate-review modules should share the same completion writer path.");
Assert(onboarding.Contains("var result = await characterImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Character decisions should materialise before candidate rows are marked reviewed.");
Assert(onboarding.Contains("var pendingCount = await reviewCandidates.MarkCharacterDecisionsAsync", StringComparison.Ordinal), "Character final decision should be persisted after successful import.");
Assert(onboarding.Contains("await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters)", StringComparison.Ordinal), "Final Character decision should complete from module-wide pending count.");
Assert(onboarding.Contains("await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync", StringComparison.Ordinal), "Character completion should still trigger whole-book plot synthesis.");
Assert(onboarding.Contains("var result = await locationImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Location import should not set completion directly.");
Assert(onboarding.Contains("var result = await assetImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Asset import should not set completion directly.");
Assert(onboarding.Contains("var result = await relationshipImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Relationship import should not set completion directly.");
Assert(onboarding.Contains("var result = await knowledgeImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Knowledge import should not set completion directly.");
Assert(onboarding.Contains("ReadyPendingCount(summary.LocationCandidateGenerationStatus, summary.LocationPendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Location pending count instead of an empty detailed page.");
Assert(onboarding.Contains("ReadyPendingCount(summary.AssetCandidateGenerationStatus, summary.AssetPendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Asset pending count instead of an empty detailed page.");
Assert(onboarding.Contains("ReadyPendingCount(summary.RelationshipCandidateGenerationStatus, summary.RelationshipPendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Relationship pending count instead of an empty detailed page.");
Assert(onboarding.Contains("ReadyPendingCount(summary.KnowledgeCandidateGenerationStatus, summary.KnowledgePendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Knowledge pending count instead of an empty detailed page.");
Assert(!ExtractBetween(onboarding, "private async Task<StoryIntelligenceReviewFrame?> GetReviewFrameAsync", "private sealed record StoryIntelligenceReviewFrame").Contains("CompleteModuleIfReadyAsync", StringComparison.Ordinal), "Review Centre GET must not mark module completion.");
Assert(summarySql.Contains("CandidateSummary", StringComparison.Ordinal), "Review Centre summary SQL should include persisted current generation state.");
Assert(summarySql.Contains("generation.IsCurrent = 1", StringComparison.Ordinal), "Review Centre should read only current persisted candidate generations.");
Assert(summarySql.Contains("LocationPendingCount", StringComparison.Ordinal)
&& summarySql.Contains("AssetPendingCount", StringComparison.Ordinal)
&& summarySql.Contains("RelationshipPendingCount", StringComparison.Ordinal)
&& summarySql.Contains("KnowledgePendingCount", StringComparison.Ordinal), "Summary should expose module-wide pending counts.");
Assert(!summarySql.Contains("SceneIntelligenceJson", StringComparison.Ordinal)
&& !summarySql.Contains("SceneManuscriptSources", StringComparison.Ordinal), "Review Centre summary must not reconstruct candidates from scene JSON.");
Assert(persistence.Contains("public string? LocationCandidateGenerationStatus", StringComparison.Ordinal)
&& persistence.Contains("public int? KnowledgePendingCount", StringComparison.Ordinal), "Persisted summary model should carry generation status and pending counts.");
foreach (var importService in new[] { characterImport, locationImport, assetImport, relationshipImport, knowledgeImport })
{
Assert(!importService.Contains("await pipelineState.RecordCharacterImportAsync", StringComparison.Ordinal)
&& !importService.Contains("await pipelineState.RecordLocationImportAsync", StringComparison.Ordinal)
&& !importService.Contains("await pipelineState.RecordAssetImportAsync", StringComparison.Ordinal)
&& !importService.Contains("await pipelineState.RecordRelationshipImportAsync", StringComparison.Ordinal)
&& !importService.Contains("await pipelineState.RecordKnowledgeImportAsync", StringComparison.Ordinal), "Module import services should not own durable completion writes.");
}
Assert(onboarding.Contains("batch.LocationStageComplete = state.LocationReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should preserve completed Location state from durable timestamps.");
Assert(onboarding.Contains("batch.RelationshipStageComplete = state.RelationshipReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should preserve completed Relationship state from durable timestamps.");
Assert(onboarding.Contains("CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Knowledge)", StringComparison.Ordinal)
&& !ExtractBetween(onboarding, "public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync", "public async Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync").Contains("StoryIntelligenceReviewModules.Relationships", StringComparison.Ordinal), "One module's final decision must not complete or reopen another module.");
}
static string ExtractAction(string value, string signature)
{
var start = value.IndexOf(signature, StringComparison.Ordinal);
Assert(start >= 0, $"Could not find action signature {signature}.");
var next = value.IndexOf(" [Http", start + signature.Length, StringComparison.Ordinal);
if (next < 0)
{
next = value.IndexOf(" private ", start + signature.Length, StringComparison.Ordinal);
}
return next < 0 ? value[start..] : value[start..next];
}
static string PlotLineRoot()
=> Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
static string ExtractBetween(string value, string start, string end)
=> Between(value, start, end);
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"));
var reviewCentreCleanupMigration = File.ReadAllText(Path.Combine(root, "Sql/184_Phase27K_ProjectHardDeleteReviewCentreCleanup.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(reviewCentreCleanupMigration.Contains("CoreImportBasicLocationReviews", StringComparison.Ordinal), "Hard delete cleanup should remove basic location review rows before deleting locations.");
Assert(reviewCentreCleanupMigration.Contains("CharacterEnrichmentEvidence", StringComparison.Ordinal), "Hard delete cleanup should remove character enrichment evidence before deleting scenes or characters.");
Assert(reviewCentreCleanupMigration.Contains("CharacterEnrichmentRuns", StringComparison.Ordinal), "Hard delete cleanup should remove character enrichment runs before deleting pipeline or book 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 rangeLocations = [\"Content\", \"Start\", \"Whole\"]", StringComparison.Ordinal), "Marker insertion should try heading content before progressively smaller Word ranges.");
Assert(script.Contains("paragraph.getRange(rangeLocation).insertContentControl()", StringComparison.Ordinal), "Marker insertion should retry Word-supported paragraph range locations.");
Assert(script.Contains("await ensureAnchorControl(context, paragraphs.items[index], title, tag, prefix)", StringComparison.Ordinal), "Bulk marker sync should await each marker insertion so Word range failures are isolated.");
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 ChapterStructureBoundaryNormaliserPreservesValidCoverage()
{
var chapter = ChapterWithBoundaries(
Boundary(1, 1, 25),
Boundary(2, 26, 50),
Boundary(3, 51, 100));
var result = ChapterStructureBoundaryNormaliser.Normalise(chapter, 100);
var validation = new ChapterStructureValidator().Validate(result.ChapterStructure, 100);
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, result);
Assert(validation.IsValid, "Valid exact coverage should remain valid.");
Assert(result.ChapterStructure?.SceneBoundaries is { Count: 3 }, "Valid exact coverage should preserve scene count.");
Assert(result.Issues.Count == 0, "Valid exact coverage should not create normalisation warnings.");
}
static void ChapterStructureBoundaryNormaliserHandlesTerminalGhostParagraph()
{
var chapter = ChapterWithBoundaries(
Boundary(1, 1, 618),
Boundary(2, 619, 618));
var result = ChapterStructureBoundaryNormaliser.Normalise(chapter, 618);
var boundaries = result.ChapterStructure?.SceneBoundaries;
var validation = new ChapterStructureValidator().Validate(result.ChapterStructure, 618);
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, result);
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(validation.IsValid, "Discarding a terminal ghost boundary should leave valid chapter coverage.");
Assert(result.Issues.Any(issue => issue.Message.Contains("Discarded", StringComparison.OrdinalIgnoreCase)), "Terminal ghost repair should be reported.");
}
static void ChapterStructureBoundaryNormaliserDiscardsMultipleTerminalGhostParagraphs()
{
var chapter = ChapterWithBoundaries(
Boundary(1, 1, 100),
Boundary(2, 101, 100),
Boundary(3, 101, 100));
var result = ChapterStructureBoundaryNormaliser.Normalise(chapter, 100);
var validation = new ChapterStructureValidator().Validate(result.ChapterStructure, 100);
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, result);
var boundaries = result.ChapterStructure?.SceneBoundaries;
Assert(validation.IsValid, "Multiple terminal no-content boundaries should be discarded without failing validation.");
Assert(boundaries is { Count: 1 }, "Multiple terminal no-content boundaries should not become canonical scenes.");
Assert(result.Issues.Any(issue => issue.Message == "Discarded 2 empty trailing scene boundaries after paragraph 100."), "Multiple terminal discards should be auditable as a grouped warning.");
}
static void ChapterStructureBoundaryNormaliserReplaysFullBookTerminalEmptyFailure()
{
var chapter = ChapterWithBoundaries(
Boundary(1, 1, 11, "Returned 1"),
Boundary(2, 12, 41, "Returned 2"),
Boundary(3, 42, 66, "Returned 3"),
Boundary(4, 67, 107, "Returned 4"),
Boundary(5, 108, 154, "Returned 5"),
Boundary(6, 155, 200, "Returned 6"),
Boundary(7, 201, 276, "Returned 7"),
Boundary(8, 277, 332, "Returned 8"),
Boundary(9, 333, 346, "Returned 9"),
Boundary(10, 347, 366, "Returned 10"),
Boundary(11, 367, 366, "Returned 11"),
Boundary(12, 367, 366, "Returned 12"));
var result = ChapterStructureBoundaryNormaliser.Normalise(chapter, 366);
var validation = new ChapterStructureValidator().Validate(result.ChapterStructure, 366);
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, result);
var boundaries = result.ChapterStructure?.SceneBoundaries;
Assert(validation.IsValid, "Full-book terminal empty replay should validate after discarding empty trailing boundaries.");
Assert(boundaries is { Count: 10 }, "Full-book terminal empty replay should keep only the ten real scenes.");
boundaries = result.ChapterStructure!.SceneBoundaries!;
Assert(boundaries[9].StartParagraph == 347 && boundaries[9].EndParagraph == 366, "Final real scene coverage should remain unchanged.");
Assert(result.Issues.Any(issue => issue.Message == "Discarded 2 empty trailing scene boundaries after paragraph 366."), "Full-book terminal empty replay should record the discard count.");
}
static void ChapterStructureBoundaryNormaliserKeepsMeaningfulEndOverflowRepair()
{
var chapter = ChapterWithBoundaries(
Boundary(1, 1, 80),
Boundary(2, 81, 105));
var result = ChapterStructureBoundaryNormaliser.Normalise(chapter, 100);
var validation = new ChapterStructureValidator().Validate(result.ChapterStructure, 100);
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, result);
var boundaries = result.ChapterStructure?.SceneBoundaries;
Assert(validation.IsValid, "Existing end-overflow clamp should remain valid when the range owns real chapter paragraphs.");
Assert(boundaries is { Count: 2 }, "A content-bearing overflow scene should not be discarded.");
boundaries = result.ChapterStructure!.SceneBoundaries!;
Assert(boundaries[1].StartParagraph == 81 && boundaries[1].EndParagraph == 100, "Content-bearing overflow should be clamped to the chapter end.");
}
static void ChapterStructureBoundaryNormaliserPreservesGenuineInvertedRanges()
{
var chapter = ChapterWithBoundaries(
Boundary(1, 1, 10),
Boundary(2, 11, 10),
Boundary(3, 12, 20));
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 StructuralWordMappingUsesParagraphMapAsAuthoritativeText()
{
var request = MappedStructureRequest(
[
(1, 100, "Repeated words with an embedded line break are still one Word paragraph."),
(2, 101, "Repeated words with an embedded line break are still one Word paragraph."),
(3, 102, "Straight quote text stays mapped to a single Word paragraph."),
(4, 103, "Curly quote text stays mapped to a single Word paragraph."),
(5, 104, "Final paragraph maps directly back to Word.")
],
sourceParagraphs:
[
SourceParagraph(99, "- A Date with Disappointment", include: false, heading: true),
SourceParagraph(100, "Repeated words with an embedded line break are still one Word paragraph."),
SourceParagraph(101, "Repeated words with an embedded line break are still one Word paragraph."),
SourceParagraph(102, "Straight quote text stays mapped to a single Word paragraph."),
SourceParagraph(103, "Curly quote text stays mapped to a single Word paragraph."),
SourceParagraph(104, "Final paragraph maps directly back to Word.")
],
chapterText: string.Join("\n\n",
[
"Repeated words with an embedded\nline break are still one Word paragraph.",
"Repeated words with an embedded line break are still one Word paragraph.",
"Straight quote text stays mapped to a single Word paragraph.",
"Curly quote text stays mapped to a single Word paragraph.",
"Final paragraph maps directly back to Word."
]));
var client = new FakeStoryIntelligenceClient(ChapterStructureResponse(Boundary(1, 1, 4), Boundary(2, 5, 5)));
var service = BuildMappedStructureAnalysisService(client);
var result = service.AnalyseChapterAsync(request).GetAwaiter().GetResult();
Assert(result.Success, result.ErrorMessage ?? "Mapped analysis should succeed.");
Assert(client.Prompts.Single().Contains("[5] Final paragraph maps directly back to Word.", StringComparison.Ordinal), "Prompt should be numbered from paragraph map metadata.");
Assert(!client.Prompts.Single().Contains("[6]", StringComparison.Ordinal), "Embedded line breaks inside a Word paragraph must not create unmapped structural paragraphs.");
Assert(result.Scenes.Count == 2, "Mapped analysis should return both scenes.");
Assert(result.Scenes[1].WordStartPosition == 104 && result.Scenes[1].WordEndPosition == 104, "Scene 2 should map structural paragraph 5 directly to Word paragraph 104.");
}
static void StructuralWordMappingIncludesFilteredWordParagraphsInSourceCoverage()
{
var request = MappedStructureRequest(
[
(1, 50, "Scene opening paragraph with enough words for mapped structural analysis."),
(2, 53, "Scene closing paragraph with enough words for mapped structural analysis.")
],
sourceParagraphs:
[
SourceParagraph(49, "Chapter title excluded from structure.", include: false, heading: true),
SourceParagraph(50, "Scene opening paragraph with enough words for mapped structural analysis."),
SourceParagraph(51, "", blank: true),
SourceParagraph(52, "***", include: false, separator: true),
SourceParagraph(53, "Scene closing paragraph with enough words for mapped structural analysis.")
]);
var service = BuildMappedStructureAnalysisService(new FakeStoryIntelligenceClient(ChapterStructureResponse(Boundary(1, 1, 2))));
var result = service.AnalyseChapterAsync(request).GetAwaiter().GetResult();
Assert(result.Success, result.ErrorMessage ?? "Mapped analysis should succeed across filtered Word paragraphs.");
var scene = result.Scenes.Single();
Assert(scene.WordStartPosition == 50 && scene.WordEndPosition == 53, "Scene endpoint mapping should span filtered Word paragraphs between structural endpoints.");
Assert(scene.SourceText.Contains("Scene opening paragraph", StringComparison.Ordinal), "Source text should include the mapped start paragraph.");
Assert(scene.SourceText.Contains("Scene closing paragraph", StringComparison.Ordinal), "Source text should include the mapped end paragraph.");
}
static void StructuralWordMappingFailsPreciselyWhenMapMetadataIncomplete()
{
var request = MappedStructureRequest(
[
(1, 10, "First mapped paragraph has enough words for the structure prompt."),
(3, 12, "Third mapped paragraph exposes the missing second structural index.")
]);
var client = new FakeStoryIntelligenceClient(ChapterStructureResponse(Boundary(1, 1, 2)));
var service = BuildMappedStructureAnalysisService(client);
var result = service.AnalyseChapterAsync(request).GetAwaiter().GetResult();
Assert(!result.Success, "Missing structural paragraph map metadata should fail before invoking AI.");
Assert(client.Prompts.Count == 0, "Incomplete paragraph maps should not trigger OpenAI work.");
Assert(result.ErrorMessage?.Contains("missing structural paragraph 2", StringComparison.OrdinalIgnoreCase) == true, "Mapping failure should name the missing structural index.");
Assert(result.ErrorMessage?.Contains("nearest mapped neighbours", StringComparison.OrdinalIgnoreCase) == true, "Mapping failure should include nearest mapped neighbours.");
}
static void StructuralWordMappingKeepsTrailingEmptyNormalisationMapped()
{
var request = MappedStructureRequest(
Enumerable.Range(1, 366)
.Select(index => (index, 990 + index, $"Paragraph {index} contains enough mapped manuscript words for structural analysis."))
.ToArray());
var service = BuildMappedStructureAnalysisService(new FakeStoryIntelligenceClient(ChapterStructureResponse(
Boundary(1, 1, 11),
Boundary(2, 12, 41),
Boundary(3, 42, 66),
Boundary(4, 67, 107),
Boundary(5, 108, 154),
Boundary(6, 155, 200),
Boundary(7, 201, 276),
Boundary(8, 277, 332),
Boundary(9, 333, 346),
Boundary(10, 347, 366),
Boundary(11, 367, 366))));
var result = service.AnalyseChapterAsync(request).GetAwaiter().GetResult();
Assert(result.Success, result.ErrorMessage ?? "Trailing empty normalisation should remain compatible with Word mapping.");
Assert(result.Scenes.Count == 10, "Trailing empty scene should be discarded before Word mapping.");
Assert(result.Scenes[9].WordStartPosition == 1337 && result.Scenes[9].WordEndPosition == 1356, "Final valid scene should preserve direct Word paragraph mapping.");
}
static ChapterStructureModel ChapterWithBoundaries(params ChapterSceneBoundary[] boundaries)
=> new()
{
SchemaVersion = "1.0",
ChapterSummary = "A chapter with enough structural context for validation.",
SceneBoundaries = boundaries.ToList()
};
static ChapterSceneBoundary Boundary(int sceneNumber, int start, int end, string? title = null)
=> new()
{
SceneNumber = sceneNumber,
StartParagraph = start,
EndParagraph = end,
SuggestedTitle = title,
StructuralSummary = "This scene summary contains enough words for the chapter structure validator to accept it.",
Confidence = 0.8m,
Reason = "Structural boundary identified from the submitted chapter text."
};
static ManuscriptStructureAnalysisService BuildMappedStructureAnalysisService(FakeStoryIntelligenceClient client)
=> new(
new FakeStoryPromptRepository(),
new FakeStoryPromptVersionService(),
client,
new ChapterStructureValidator(),
NullLogger<ManuscriptStructureAnalysisService>.Instance);
static ManuscriptStructureAnalysisRequest MappedStructureRequest(
IReadOnlyList<(int Structural, int Word, string Text)> map,
IReadOnlyList<ManuscriptScanSourceParagraph>? sourceParagraphs = null,
string? chapterText = null)
=> new()
{
ProjectID = 3067,
BookID = 5075,
PreviewID = Guid.Parse("57eaf531-f483-45ae-87da-5cbb4a3ab16e"),
TemporaryChapterKey = "chapter-18",
ChapterNumber = 18,
SourceLabel = "Word manuscript: - A Date with Disappointment",
ChapterText = chapterText ?? string.Join(Environment.NewLine + Environment.NewLine, map.Select(item => item.Text)),
ParagraphMap = map
.Select(item => new ManuscriptScanParagraphMap
{
StructuralParagraphOrdinal = item.Structural,
WordParagraphIndex = item.Word,
Text = item.Text
})
.ToList(),
SourceParagraphs = sourceParagraphs ?? map.Select(item => SourceParagraph(item.Word, item.Text)).ToList()
};
static ManuscriptScanSourceParagraph SourceParagraph(
int wordIndex,
string text,
bool include = true,
bool blank = false,
bool heading = false,
bool separator = false)
=> new()
{
WordParagraphIndex = wordIndex,
Text = text,
IsBlank = blank,
IsChapterHeading = heading,
IsSceneSeparator = separator,
IncludeInSourceText = include
};
static string ChapterStructureResponse(params ChapterSceneBoundary[] boundaries)
=> JsonSerializer.Serialize(new
{
output = new[]
{
new
{
content = new[]
{
new
{
type = "output_text",
text = JsonSerializer.Serialize(ChapterWithBoundaries(boundaries), ManuscriptScanJsonOptions())
}
}
}
}
});
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 Alder"), "Mrs Alder should be recognised as a titled person.");
Assert(IsNamedOrTitledPerson("Aunt Mara"), "Aunt Mara 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("Fox and Lantern"), "Named pub should not be treated as a merged location.");
}
static void LocationFilteringPreservesStoryLocations()
{
Assert(IsStoryLocation("Harbour Trust"), "Harbour Trust should be preserved.");
Assert(IsStoryLocation("St Anselm's Church"), "St Anselm's Church should be preserved.");
Assert(IsStoryLocation("Mrs Alder's House"), "Mrs Alder's House should be preserved.");
Assert(IsStoryLocation("Northbridge 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("Harbour Trust (reception)") == LocationCanonicalKey("Harbour Trust"), "Parenthetical variants should merge.");
}
static void LocationContextualNamesUsePovAndStructuralSetting()
{
var noraScene = new Scene { StructuralSetting = "Nora's bedroom", POVCharacterName = "Nora" };
var imaniScene = new Scene { StructuralSetting = "Imani's bedroom", POVCharacterName = "Imani" };
Assert(ResolveContextualLocationName("my bedroom", "bedroom", noraScene) == "Nora's bedroom", "Nora POV plus Nora structural setting should resolve my bedroom to Nora's bedroom.");
Assert(ResolveContextualLocationName("bedroom", "bedroom", imaniScene) == "Imani's bedroom", "Imani structural setting should resolve generic bedroom to Imani's bedroom.");
Assert(LocationCanonicalKey("Nora's bedroom") != LocationCanonicalKey("Imani's bedroom"), "Owned bedrooms for different POV characters must remain distinct.");
Assert(ResolveContextualLocationName("Narrator's home", null, noraScene) == "Nora's home", "Narrator-owned locations should resolve through canonical POV.");
}
static void LocationCanonicalKeysMergePunctuationAndSafePlurals()
{
Assert(LocationCanonicalKey("Mrs Alder's house") == LocationCanonicalKey("Mrs. Alder'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 void LocationCandidateConsolidationHandlesGenericReviewVariants()
{
Assert(LocationCanonicalKey("The Crown Inn") == LocationCanonicalKey("the Crown Inn"), "Case-only variants should merge.");
Assert(LocationCanonicalKey("The Crown Inn") == LocationCanonicalKey("Crown Inn"), "Leading article variants should merge.");
Assert(LocationCanonicalKey("inside The Crown Inn") == LocationCanonicalKey("at the Crown Inn"), "Context wrappers should not create separate candidates.");
Assert(LocationCanonicalKey("The Crown Inn interior, early evening") == LocationCanonicalKey("Crown Inn"), "Temporal and broad interior suffixes should fold into the named place.");
Assert(LocationCanonicalKey("Mrs Alder's house") == LocationCanonicalKey("Mrs. Alder's house"), "Mrs/Mrs. punctuation should not split a house candidate.");
Assert(LocationCanonicalKey("Dr Hale's house") == LocationCanonicalKey("Dr. Hale's house"), "Unrelated title punctuation fixture should also merge.");
Assert(LocationCanonicalKey("Nora's house") != LocationCanonicalKey("Imani's house"), "Distinct owned houses must not merge.");
Assert(LocationCanonicalKey("The Crown Inn") != LocationCanonicalKey("The Crown Inn cellar"), "Meaningful sub-locations should remain separate.");
Assert(LocationCanonicalKey("The Meridian Hotel") != LocationCanonicalKey("The Meridian Hotel kitchen"), "Unrelated named-place sub-location fixture should remain separate.");
Assert(LocationCanonicalKey("The Meridian Hotel car park and waiting area") == LocationCanonicalKey("Meridian Hotel car park"), "Low-value waiting-area suffixes should fold into the specific parent setting.");
Assert(LocationCanonicalKey("inside Meridian Hotel car park") == LocationCanonicalKey("Meridian Hotel car park"), "Context prefixes should not split specific repeatable settings.");
Assert(IsGenericLocation("the tunnel"), "Tunnel should be recognised as a generic setting label.");
Assert(RepeatableGenericSettingKeys().Contains(LocationCanonicalKey("the tunnel")), "Repeated active tunnel settings should be allowed to survive review.");
Assert(!IsStoryLocation("a stretch of road"), "One-off incidental road prose should not be treated as a strong named location.");
Assert(IsGenericComposite("Nora's house and Imani's house"), "Multi-location owned-place conjunctions should not become one fake location.");
Assert(!IsMergedLocationPhrase("Fox and Lantern"), "Generic named compounds should not need a production special-case exception.");
}
static void LocationCandidateIdentityIsParentAwareForGenericSubLocations()
{
var maggieBedroom = ParentAwareLocationCandidateKey("maggies-house", LocationCanonicalKey("Bedroom"));
var bethBedroom = ParentAwareLocationCandidateKey("beths-house", LocationCanonicalKey("Bedroom"));
var unknownBedroom = ParentAwareLocationCandidateKey(null, LocationCanonicalKey("Bedroom"));
var maggieKitchen = ParentAwareLocationCandidateKey("maggies-house", LocationCanonicalKey("Kitchen"));
Assert(maggieBedroom != bethBedroom, "Maggie's house Bedroom and Beth's house Bedroom should remain separate candidates.");
Assert(maggieBedroom == ParentAwareLocationCandidateKey("maggies-house", LocationCanonicalKey("The bedrooms")), "Same parent and same leaf variants should consolidate.");
Assert(unknownBedroom != maggieBedroom, "Unknown-parent Bedroom must not merge into known-parent Bedroom.");
Assert(maggieBedroom != maggieKitchen, "Same parent but different leaf locations should remain separate.");
Assert(ExplicitLocationLeaf("Zoe's bedroom") == "bedroom", "Explicit Zoe bedroom reference should keep Bedroom as leaf.");
Assert(InferExplicitLocationParent("Zoe's bedroom", "bedroom") == "Zoe's home", "Explicit Zoe bedroom reference should infer Zoe's home as parent.");
Assert(InferExplicitLocationParent("the bathroom at Beth's", "bathroom") == "Beth's home", "Bathroom at Beth's should infer Beth's home as parent.");
Assert(InferExplicitLocationParent("Mrs Patterson's kitchen", "kitchen") == "Mrs Patterson's home", "Possessive kitchen should infer an owned parent.");
Assert(InferExplicitLocationParent("bathroom to Maggie's bedroom", "bedroom") is null, "Route text before an owned bedroom should not become part of the owner.");
Assert(ParentFromOwnedLocationLeaf("Maggie's bedroom") == "Maggie's home", "Owned bedroom should expose a parent identity.");
Assert(ExtractParentFromCompositeLocationSetting("Maggie's house - living room and kitchen, late night") == "Maggie's house", "Scene parent should be inherited from a composite structural setting.");
Assert(ExtractParentFromCompositeLocationSetting("Cock Hill Lane; Maggie's home and bathroom") == "Maggie's home", "Owned scene container should beat broader route/location fragments.");
Assert(ExtractParentFromCompositeLocationSetting("Cock Hill Lane; bathroom") is null, "Street-level locations should not become parents for generic rooms.");
var root = PlotLineRoot();
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceLocations.cshtml"));
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var persisted = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
Assert(service.Contains("ParentAwareCandidateKey(parentContext.ParentIdentityKey, identity.Key)", StringComparison.Ordinal), "Location consolidation should use parent plus leaf identity.");
Assert(service.Contains("ResolveParentContext(cleanName, identity.DisplayName, genericRoomType, parentLocationHint, sceneParentContext, existingIndex)", StringComparison.Ordinal), "Location preparation should infer parent context before keying.");
Assert(service.Contains("InferSceneParentContext(importedScene, parsed)", StringComparison.Ordinal), "Scene context should be available for inherited generic child locations.");
Assert(service.Contains("EnsureParentCandidate(groups, existingIndex, importedScene, parsed, parentContext)", StringComparison.Ordinal), "Unresolved parents should be preserved as parent candidates.");
Assert(service.Contains("candidate.ProposedParentCandidateKey = parentKey", StringComparison.Ordinal), "Child candidates should retain durable parent-candidate linkage.");
Assert(service.Contains("return await MaterialiseLocationCandidateAsync(parentCandidate", StringComparison.Ordinal), "Child materialisation should create/link unresolved parent candidates first.");
Assert(service.Contains("existingIndex.Find(importName, parentId)", StringComparison.Ordinal), "Existing child matching should include parent location identity.");
Assert(service.Contains("string.Equals(selection, \"__none__\"", StringComparison.Ordinal), "Author clear-parent selection should be respected.");
Assert(service.Contains("ParentLocationID = parentId", StringComparison.Ordinal), "Accepted children should be saved under the resolved parent ID.");
Assert(view.Contains("Proposed parent", StringComparison.Ordinal), "Location review should show proposed parent context.");
Assert(view.Contains("name=\"Locations[@i].ParentSelection\"", StringComparison.Ordinal), "Location review should let the author choose or clear parent context.");
Assert(view.Contains("Use proposed:", StringComparison.Ordinal), "Location review should allow accepting proposed parent context.");
Assert(view.Contains("Not determined", StringComparison.Ordinal), "Location review should allow unresolved parent context.");
Assert(view.Contains("Model.LocationReview.LocationTargetOptions", StringComparison.Ordinal), "Parent selection should use the unified Location target list.");
Assert(viewModels.Contains("ProposedParentLocationID", StringComparison.Ordinal)
&& viewModels.Contains("ProposedParentCandidateKey", StringComparison.Ordinal)
&& viewModels.Contains("ParentIdentityKey", StringComparison.Ordinal), "Persisted Location candidate payload should include first-class parent identity.");
Assert(persisted.Contains("ParentLocationOptions = parentOptions", StringComparison.Ordinal), "Persisted Location GET should hydrate parent options without rebuilding Scene JSON.");
}
static void LocationsPageUsesHierarchicalSortOrder()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Locations/Index.cshtml"));
var listMethod = ExtractBetween(service, "public async Task<LocationListViewModel?> GetLocationsAsync", "public async Task<LocationEditViewModel?> GetCreateAsync");
var treeMethod = ExtractBetween(service, "private IReadOnlyList<LocationItem> OrderLocationsAsTree", "private async Task SyncLocationAliasesAsync");
Assert(listMethod.Contains("OrderLocationsAsTree(projectLocations)", StringComparison.Ordinal), "Locations index should use parent-aware tree ordering instead of repository row order.");
Assert(!listMethod.Contains("\n Locations = await locations.ListByProjectAsync(projectId)", StringComparison.Ordinal), "Locations index should not flatten directly from globally ordered repository rows.");
Assert(treeMethod.Contains("ParentLocationID is null", StringComparison.Ordinal), "Tree ordering should begin with root locations.");
Assert(treeMethod.Contains("GroupBy(location => location.ParentLocationID", StringComparison.Ordinal), "Tree ordering should group direct children by parent.");
Assert(treeMethod.Contains("OrderBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal), "Tree ordering should sort siblings case-insensitively by location name.");
Assert(treeMethod.Contains("AddLocationAndChildren(child, depth + 1)", StringComparison.Ordinal), "Tree ordering should recurse through descendants.");
Assert(treeMethod.Contains("visited.Add(location.LocationID)", StringComparison.Ordinal), "Tree ordering should render each location once.");
Assert(treeMethod.Contains("stack.Add(location.LocationID)", StringComparison.Ordinal)
&& treeMethod.Contains("logger.LogWarning(\"Location hierarchy cycle", StringComparison.Ordinal), "Tree ordering should guard and log circular parent data.");
Assert(view.Contains("@foreach (var location in Model.Locations)", StringComparison.Ordinal), "Locations view should preserve service-provided hierarchical order.");
Assert(view.Contains("style=\"--location-depth:@location.Depth\"", StringComparison.Ordinal), "Locations view should keep existing indentation.");
}
static void LocationReviewUxSupportsLargeCandidateSets()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceLocations.cshtml"));
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
var persisted = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
Assert(view.Contains("var locationCandidates = Model.LocationReview.Candidates.ToList();", StringComparison.Ordinal), "Location review should render only the persisted candidate page.");
Assert(viewModels.Contains("PageCount => PendingCandidateCount <= 0 ? 1 : (int)Math.Ceiling(PendingCandidateCount / (double)PageSize)", StringComparison.Ordinal), "52 pending candidates should page as 25 / 25 / 2.");
Assert(viewModels.Contains("ShowingStart", StringComparison.Ordinal) && viewModels.Contains("ShowingEnd", StringComparison.Ordinal), "Location review should show the visible pending range.");
Assert(view.Contains("story-review-page-status", StringComparison.Ordinal), "Location review should show bounded review counts.");
Assert(view.Contains("Page @Model.LocationReview.PageNumber", StringComparison.Ordinal), "Location review should show Page X of Y.");
Assert(view.Contains("Showing @Model.LocationReview.ShowingStart", StringComparison.Ordinal), "Location review should show N-M of T pending.");
Assert(view.Contains("name=\"intent\" value=\"previous\"", StringComparison.Ordinal)
&& view.Contains("name=\"intent\" value=\"next\"", StringComparison.Ordinal), "Previous and Next should submit the current page so decisions persist before navigation.");
Assert(view.Contains("if (Model.LocationReview.HasNextPage)", StringComparison.Ordinal)
&& view.Contains("else", StringComparison.Ordinal)
&& view.Contains("name=\"intent\" value=\"save\"", StringComparison.Ordinal), "Location review should show Next before the last page and Save only on the last page.");
Assert(view.Contains("name=\"PageNumber\"", StringComparison.Ordinal), "Location review should post the current page number.");
Assert(view.Contains("EvidenceSummaries", StringComparison.Ordinal), "Location review should expose compact aggregated evidence.");
Assert(!view.Contains("data-location-bulk=\"create-all\"", StringComparison.Ordinal), "Location review should not offer bulk create-all.");
Assert(!view.Contains("data-location-bulk=\"ignore-all\"", StringComparison.Ordinal), "Location review should not offer bulk ignore-all.");
Assert(viewModels.Contains("public int PageNumber { get; set; } = 1;", StringComparison.Ordinal), "Location review posts should preserve the page being saved.");
Assert(persisted.Contains("pageNumber = Math.Clamp(pageNumber, 1, maxPage)", StringComparison.Ordinal), "Persisted candidate pages should clamp after pending totals change.");
Assert(controller.Contains("ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form, string intent = \"save\")", StringComparison.Ordinal), "Location review POST should accept a save/navigation intent.");
Assert(controller.Contains("pendingCount <= 0", StringComparison.Ordinal)
&& controller.Contains("RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal), "Location review should return to Review Centre only after module-wide pending reaches zero.");
Assert(controller.Contains("RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage })", StringComparison.Ordinal), "Location review should stay in-module while pending candidates remain.");
Assert(controller.Contains("var targetPage = string.Equals(intent, \"previous\"", StringComparison.Ordinal), "Previous navigation should move to the prior persisted page after saving.");
Assert(viewModels.Contains("IReadOnlyList<string> EvidenceSummaries", StringComparison.Ordinal), "Location candidates should carry aggregated evidence summaries.");
Assert(service.Contains("candidate.Appearances.Any(existing => existing.SceneID == appearance.SceneID", StringComparison.Ordinal), "Location evidence should be deduplicated per scene/observation.");
Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal), "Existing Location aliases should participate in matching.");
}
static void StoryIntelligenceAutoResolvesExactKnownLocationsBeforeReview()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
Assert(service.Contains("Task<StoryIntelligenceLocationAutoResolutionResult> ResolveAutomaticMatchesAsync", StringComparison.Ordinal),
"Location import should expose deterministic auto-resolution before persisted review generation.");
Assert(candidateService.Contains("await locationImport.ResolveAutomaticMatchesAsync(batch)", StringComparison.Ordinal),
"Persisted Location review preparation should resolve exact known identities before saving review rows.");
Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal),
"Location preferred names and all aliases should feed the identity lookup.");
Assert(service.Contains("Dictionary<string, List<LocationMatch>> byName", StringComparison.Ordinal),
"Known-location lookup should retain all LocationID owners for an alias key.");
Assert(service.Contains("matches.Select(match => match.LocationID).Distinct().Count() == 1", StringComparison.Ordinal),
"Known-location lookup should only return a match when there is exactly one owner.");
Assert(service.Contains("IsAutoResolvableKnownIdentity", StringComparison.Ordinal),
"Exact known locations should be recognised separately from reviewable new or ambiguous locations.");
Assert(service.Contains("existingIndex.Find(candidate.DisplayName)?.LocationID == candidate.ExistingLocationID.Value", StringComparison.Ordinal),
"Preferred-name and alias exact matches should auto-resolve only when the canonical lookup returns the same LocationID.");
Assert(service.Contains("!HasParentReviewIssue(candidate, existingIndex)", StringComparison.Ordinal),
"Exact known locations should only remain visible when parent hierarchy still needs review.");
Assert(service.Contains("candidate.ProposedParentLocationID.HasValue", StringComparison.Ordinal)
&& service.Contains("existing.ParentLocationID != candidate.ProposedParentLocationID", StringComparison.Ordinal),
"Existing Locations with same parent should hide, while missing/conflicting parents should remain parent-review candidates.");
Assert(service.Contains("LinkSceneLocationsAsync(candidate, locationId)", StringComparison.Ordinal),
"Auto-resolution should preserve scene/location associations.");
Assert(service.Contains("return null;", StringComparison.Ordinal) && service.Contains("if (linkExisting && !locationId.HasValue)", StringComparison.Ordinal),
"A same-location decision without a canonical or pending target must not create a duplicate Location.");
Assert(service.Contains("Identity beats hierarchy: known Locations must not be duplicated because parent context changed.", StringComparison.Ordinal),
"Known existing LocationIDs should never be imported through the CreateNew path.");
}
static void StoryIntelligenceLocationReviewUsesSingleIdentityDecision()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceLocations.cshtml"));
Assert(model.Contains("SameLocationOptions", StringComparison.Ordinal), "Location review should expose canonical LocationID targets.");
Assert(model.Contains("public int? ExistingLocationID { get; set; }", StringComparison.Ordinal), "Same-location decisions should post a canonical LocationID.");
Assert(candidateService.Contains("DecisionCanonicalID = choice.ExistingLocationID", StringComparison.Ordinal), "Persisted review decisions should record canonical LocationID targets.");
Assert(service.Contains("selectedExisting?.LocationID", StringComparison.Ordinal), "Import should resolve same-location targets by canonical LocationID.");
Assert(service.Contains("TryAddAliasAsync(locationId.Value, candidate.DisplayName", StringComparison.Ordinal), "Same-location import should attach the detected name directly to the target LocationID.");
Assert(service.Contains("UpdateExistingParentAsync(locationId.Value, parentId)", StringComparison.Ordinal), "Parent review for a known Location should update hierarchy on the same LocationID.");
Assert(view.Contains("Same location as", StringComparison.Ordinal), "Review should present one clear identity decision.");
Assert(view.Contains("<optgroup label=\"@group.Key\">", StringComparison.Ordinal), "Review should clearly distinguish canonical and pending Location targets.");
Assert(view.Contains("name=\"Locations[@i].ExistingLocationID\"", StringComparison.Ordinal), "Same-location dropdown should submit LocationID values.");
Assert(view.Contains("candidate.IsExistingMatch", StringComparison.Ordinal)
&& view.Contains("<input type=\"hidden\" name=\"Locations[@i].Action\" value=\"@StoryIntelligenceLocationImportActions.LinkExisting\"", StringComparison.Ordinal),
"Known existing matches that reach review should be fixed to LinkExisting, not CreateNew.");
Assert(model.Contains("LocationTargetOptions", StringComparison.Ordinal), "Review model should expose one unified Location target source.");
Assert(view.Contains("data-location-unified-target", StringComparison.Ordinal), "Same-location selector should use the unified target source.");
Assert(view.Contains("IsSelfTarget(target, candidate)", StringComparison.Ordinal), "Selectors should exclude the current candidate.");
Assert(view.Contains("TargetLabel(target)", StringComparison.Ordinal), "Selectors should share display naming for canonical and pending targets.");
Assert(candidateService.Contains("BuildLocationTargetOptionsAsync(projectLocations, allPendingPage.Items)", StringComparison.Ordinal),
"Persisted review GET should hydrate unified canonical and pending targets.");
Assert(candidateService.Contains("canonicalKeys.Contains(key)", StringComparison.Ordinal), "Pending targets duplicating canonical identity should be suppressed.");
Assert(candidateService.Contains(".ThenBy(option => option.DisplayName, NaturalStringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal),
"Unified Location targets should be sorted in natural alphabetical order within groups.");
Assert(File.ReadAllText(Path.Combine(root, "Services/NaturalStringComparer.cs")).Contains("CompareNumberRuns", StringComparison.Ordinal),
"Location selector sorting should compare embedded numbers naturally.");
Assert(!view.Contains("Link existing location", StringComparison.Ordinal), "Review should not expose the old separate link-existing wording.");
Assert(!view.Contains("Alias of another location", StringComparison.Ordinal), "Review should not expose alias-chain wording.");
}
static bool IsMergedLocationPhrase(string name)
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
static bool IsStoryLocation(string name)
=> InvokePrivateLocationFilter("IsStoryLocation", name);
static bool IsGenericLocation(string name)
=> InvokePrivateLocationFilter("IsGenericLocation", name);
static bool IsGenericComposite(string name)
=> InvokePrivateLocationFilter("IsGenericComposite", name);
static IReadOnlySet<string> RepeatableGenericSettingKeys()
{
var field = typeof(StoryIntelligenceLocationImportService).GetField(
"RepeatableGenericSettings",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(field is not null, "RepeatableGenericSettings was not found.");
return (IReadOnlySet<string>)field!.GetValue(null)!;
}
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 ParentAwareLocationCandidateKey(string? parentIdentityKey, string leafIdentityKey)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"ParentAwareCandidateKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ParentAwareCandidateKey was not found.");
return (string)method!.Invoke(null, [parentIdentityKey, leafIdentityKey])!;
}
static string? ExplicitLocationLeaf(string name)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"ExplicitLeaf",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ExplicitLeaf was not found.");
return (string?)method!.Invoke(null, [name]);
}
static string? InferExplicitLocationParent(string name, string leafName)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"InferExplicitParent",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "InferExplicitParent was not found.");
return (string?)method!.Invoke(null, [name, leafName]);
}
static string? ParentFromOwnedLocationLeaf(string name)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"ParentFromOwnedLeaf",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ParentFromOwnedLeaf was not found.");
return (string?)method!.Invoke(null, [name]);
}
static string? ExtractParentFromCompositeLocationSetting(string name)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"ExtractParentFromCompositeSetting",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ExtractParentFromCompositeSetting was not found.");
return (string?)method!.Invoke(null, [name]);
}
static string LocationCanonicalKey(string name)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"CanonicalLocationKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "CanonicalLocationKey was not found.");
return (string)method!.Invoke(null, [name])!;
}
static bool InvokePrivateLocationFilter(string methodName, string name)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
methodName,
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, $"{methodName} was not found.");
return method!.Invoke(null, [name]) is true;
}
static void AssetFilteringRejectsGenericObjects()
{
Assert(!IsAssetNameCandidate("chair"), "Chair should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("table"), "Table should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("street"), "Street should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("car park"), "Car park should not be treated as a story asset.");
}
static void AssetFilteringPreservesStoryAssets()
{
Assert(IsAssetNameCandidate("ZX6R"), "ZX6R 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("brass astrolabe"), "Structurally significant arbitrary 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("Kawasaki ZX6R") == AssetCanonicalKey("ZX6R"), "Manufacturer/model-code variants should merge without hard-coded manuscript vehicle names.");
Assert(AssetCanonicalKey("Mazda MX5") == AssetCanonicalKey("MX5"), "A second arbitrary manufacturer/model-code fixture should merge the same way.");
}
static void AssetCanonicalKeysKeepUnrelatedOwnedCarsDistinct()
{
Assert(AssetCanonicalKey("Nora's car") != AssetCanonicalKey("Imani's car"), "Generic owned vehicles should remain separate for unrelated books too.");
}
static void AssetCandidateKeysAttachUniqueUnownedVariants()
{
var existing = new[] { AssetCanonicalKey("Nora's pass certificate") };
Assert(FindContextualAssetKey(existing, AssetCanonicalKey("pass certificate")) == existing[0], "A unique unowned asset variant should attach to its owned contextual candidate.");
var ambiguous = new[]
{
AssetCanonicalKey("Nora's pass certificate"),
AssetCanonicalKey("Imani's pass certificate")
};
Assert(FindContextualAssetKey(ambiguous, AssetCanonicalKey("pass certificate")) is null, "Unowned asset variants must not merge when multiple owners match.");
}
static void AssetFilteringRejectsRelationshipFacts()
{
Assert(!IsAssetNameCandidate("Nora's sister Mara"), "Possessive relationship facts should not become assets.");
Assert(!IsAssetNameCandidate("Nora's mother dead in the bath"), "Event/family relationship phrases should not become assets.");
Assert(!IsAssetNameCandidate("Nora's parents in bed but Ellis"), "Relationship scene facts should not become assets.");
Assert(!IsAssetNameCandidate("Nora's number before leaving"), "Information fragments should not become assets.");
Assert(IsAssetNameCandidate("Nora's photograph"), "Actual owned asset nouns should survive relationship filtering.");
Assert(IsAssetNameCandidate("Nora's address book"), "Actual owned object phrases should survive relationship filtering.");
}
static void AssetContextualNamesResolvePovOwnership()
{
var scene = new Scene { POVCharacterName = "Nora" };
Assert(ResolveContextualAssetName("my brass astrolabe", null, scene) == "Nora's brass astrolabe", "POV ownership should resolve my brass astrolabe to Nora's brass astrolabe.");
Assert(ResolveContextualAssetName("brass astrolabe", "Nora", scene) == "Nora's brass astrolabe", "Explicit owner evidence should prefix owned significant assets.");
Assert(IsAssetNameCandidate("Nora's brass astrolabe"), "Owned arbitrary significant objects should be retained.");
var unrelatedScene = new Scene { POVCharacterName = "Imani" };
Assert(ResolveContextualAssetName("my brass astrolabe", null, unrelatedScene) == "Imani's brass astrolabe", "POV ownership should resolve arbitrary significant owned objects.");
}
static void AssetStructuralPhrasesTrimNeighbouringNouns()
{
Assert(CleanStructuralAssetPhrase("brass astrolabe and photographs") == "brass astrolabe", "Structural asset extraction should not bundle neighbouring nouns after and.");
Assert(CleanStructuralAssetPhrase("brass astrolabe containing coordinates") == "brass astrolabe", "Structural asset extraction should trim explanatory tails for arbitrary objects.");
}
static void AssetSignificanceIsGenericAndNotFixtureSpecific()
{
Assert(IsDistinctiveAssetName("brass astrolabe"), "Arbitrary significant object phrases should survive because they are distinctive.");
Assert(IsDistinctiveAssetName("obsidian compass"), "A second arbitrary significant object should use the same distinctive-name mechanism.");
Assert(!IsDistinctiveAssetName("cup"), "Single incidental props should not become significant merely by being named.");
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceAssetImportService.cs"));
Assert(!service.Contains("memory tin", StringComparison.OrdinalIgnoreCase), "Production asset logic must not special-case Memory Tin.");
Assert(!service.Contains("red notebook", StringComparison.OrdinalIgnoreCase), "Production asset logic must not special-case the red notebook fixture.");
Assert(!service.Contains("\"tr6\"", StringComparison.OrdinalIgnoreCase), "Production asset logic must not special-case TR6.");
}
static void StoryIntelligenceAssetReviewAppliesCurrentIdentityRules()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceAssetImportService.cs"));
var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceAssets.cshtml"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
Assert(service.Contains("Task<StoryIntelligenceAssetAutoResolutionResult> ResolveAutomaticMatchesAsync", StringComparison.Ordinal),
"Asset import should expose deterministic auto-resolution before persisted review generation.");
Assert(candidateService.Contains("await assetImport.ResolveAutomaticMatchesAsync(batch)", StringComparison.Ordinal),
"Persisted Asset review preparation should resolve exact known identities before saving review rows.");
Assert(service.Contains("assets.ListAliasesAsync(asset.StoryAssetID)", StringComparison.Ordinal),
"Asset preferred names and aliases should feed identity lookup.");
Assert(service.Contains("Dictionary<string, List<AssetMatch>> byName", StringComparison.Ordinal),
"Asset identity lookup should retain all owners for ambiguous names.");
Assert(service.Contains("matches.Select(match => match.StoryAssetID).Distinct().Count() == 1", StringComparison.Ordinal),
"Asset identity lookup should only auto-match when exactly one AssetID owns the name.");
Assert(service.Contains("IsAutoResolvableKnownIdentity", StringComparison.Ordinal)
&& service.Contains("existingIndex.Find(candidate.DisplayName)?.StoryAssetID == candidate.ExistingAssetID.Value", StringComparison.Ordinal),
"Exact known Asset identities should auto-resolve and hide from review.");
Assert(service.Contains("LinkSceneAssetEventsAsync(candidate, assetId", StringComparison.Ordinal),
"Auto-resolution and review import should preserve scene asset events.");
Assert(view.Contains("Same asset as", StringComparison.Ordinal), "Asset review should present one clear identity decision.");
Assert(!view.Contains("Link existing asset", StringComparison.Ordinal), "Asset review should not expose old link-existing wording.");
Assert(!view.Contains("Alias of another asset", StringComparison.Ordinal), "Asset review should not expose alias-chain wording.");
Assert(view.Contains("data-asset-unified-target", StringComparison.Ordinal), "Same-asset selector should use one unified target source.");
Assert(model.Contains("AssetTargetOptions", StringComparison.Ordinal), "Asset review model should expose unified Asset targets.");
Assert(candidateService.Contains("BuildAssetTargetOptionsAsync(projectAssets, allPendingPage.Items)", StringComparison.Ordinal),
"Persisted Asset review GET should hydrate canonical and pending targets.");
Assert(candidateService.Contains("canonicalKeys.Add(AssetTargetKey(alias))", StringComparison.Ordinal),
"Pending targets duplicating canonical aliases should be suppressed.");
Assert(candidateService.Contains(".ThenBy(option => option.DisplayName, NaturalStringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal),
"Asset targets should be sorted alphabetically by display name within groups.");
Assert(view.Contains("IsSelfTarget(target, candidate)", StringComparison.Ordinal), "Asset selectors should exclude the current candidate.");
Assert(view.Contains("name=\"Assets[@i].ExistingAssetID\"", StringComparison.Ordinal), "Same-asset decisions should post canonical AssetID values.");
Assert(candidateService.Contains("DecisionCanonicalID = choice.ExistingAssetID", StringComparison.Ordinal),
"Persisted Asset decisions should record canonical AssetID targets.");
Assert(view.Contains("candidate.IsExistingMatch", StringComparison.Ordinal)
&& view.Contains("<input type=\"hidden\" name=\"Assets[@i].Action\" value=\"@StoryIntelligenceAssetImportActions.LinkExisting\"", StringComparison.Ordinal),
"Known existing Asset matches that reach review should be fixed to LinkExisting, not CreateNew.");
Assert(view.Contains("Page @Model.AssetReview.PageNumber", StringComparison.Ordinal)
&& view.Contains("Showing @Model.AssetReview.ShowingStart", StringComparison.Ordinal),
"Asset review should show page and pending range counts.");
Assert(view.Contains("name=\"intent\" value=\"previous\"", StringComparison.Ordinal)
&& view.Contains("name=\"intent\" value=\"next\"", StringComparison.Ordinal),
"Asset review Previous and Next should submit current-page decisions before navigation.");
Assert(view.Contains("if (Model.AssetReview.HasNextPage)", StringComparison.Ordinal)
&& view.Contains("name=\"intent\" value=\"save\"", StringComparison.Ordinal),
"Asset review should show Next before the last page and Save only on the last page.");
Assert(controller.Contains("ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form, string intent = \"save\")", StringComparison.Ordinal),
"Asset POST should accept a save/navigation intent.");
Assert(controller.Contains("pendingCount <= 0", StringComparison.Ordinal)
&& controller.Contains("RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal),
"Asset review should return to Review Centre only after module-wide pending reaches zero.");
Assert(controller.Contains("RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID, page = targetPage })", StringComparison.Ordinal),
"Asset review should stay in-module while pending candidates remain.");
}
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 RelationshipProgressionKeepsChronologicalState()
{
Assert(RelationshipPairKey(501, 702) == RelationshipPairKey(702, 501), "Relationship identity should be one canonical non-directional CharacterID pair.");
var events = new (int SceneID, decimal ChapterNumber, decimal SceneNumber, int SortOrder, string StateName)[]
{
(3, 1m, 3m, 30, RelationshipStateNameFromSignal("friendship is clearly established")),
(4, 1m, 4m, 40, RelationshipStateNameFromSignal("mutual attraction becomes explicit")),
(5, 1m, 5m, 50, RelationshipStateNameFromSignal("they explicitly begin a romantic relationship as a couple")),
(6, 1m, 6m, 60, RelationshipStateNameFromSignal("Rob proposes and Maggie accepts the proposal"))
};
Assert(RelationshipStateAtScene("Unknown", events, 1) == "Unknown", "Scene 1 must not inherit later engagement.");
Assert(RelationshipStateAtScene("Unknown", events, 3) == "Friendly", "Friendship should be visible at the friendship point.");
Assert(RelationshipStateAtScene("Unknown", events, 4) == "Romantic", "Romantic attraction should be visible when it becomes explicit.");
Assert(RelationshipStateAtScene("Unknown", events, 5) == "Romantic", "A couple/romantic state should remain visible before proposal.");
Assert(RelationshipStateAtScene("Unknown", events, 6) == "Engaged", "Engagement should appear only after the proposal/acceptance event.");
}
static void RelationshipObservationsDoNotCreateEventSpam()
{
Assert(RelationshipTypeFromSignal("trust", "Maggie and Rosie hug, exchange presents and socialise together.") == "Friend", "Accumulated ordinary interactions should still establish friendship.");
Assert(!IsMeaningfulRelationshipEvent("trust", "Maggie and Rosie hug, exchange presents and socialise together."), "Ordinary hugs/gifts/socialising should not create event spam.");
Assert(IsMeaningfulRelationshipEvent("romantic", "They explicitly become a couple."), "A clear relationship transition should create an event.");
Assert(IsMeaningfulRelationshipEvent("proposal", "Rob proposes and Maggie accepts."), "Proposal acceptance should create an event.");
Assert(!IsMeaningfulRelationshipEvent("trust", "Nora and Imani exchange gifts after years of friendship."), "The non-event rule should hold for unrelated fixture names too.");
}
static void RelationshipReviewOrdersByFirstStoryAppearance()
{
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceRelationshipImportService.cs"));
Assert(service.Contains(".OrderBy(CandidateFirstStoryOrder)", StringComparison.Ordinal), "Relationship review candidates should be ordered by first story appearance.");
Assert(service.Contains("private static (decimal ChapterNumber, decimal SceneNumber, int SortOrder, int SceneID) CandidateFirstStoryOrder", StringComparison.Ordinal), "Relationship review should calculate first appearance from chapter/scene order.");
Assert(!service.Contains(".OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())", StringComparison.Ordinal), "Relationship review should not prioritise frequent relationships ahead of earlier first appearances.");
}
static void RelationshipEvidencePriorityPreservesExplicitSemantics()
{
Assert(RelationshipTypeFromEvidence("dependence", "My brother helped with the machine.", null, "Nora", "Elias") == "Sibling", "Explicit brother evidence should become Sibling.");
Assert(RelationshipTypeFromEvidence("affection", "One pile was just me and Mum, my entire childhood.", null, "Nora", "Nora's mother") == "Parent", "Explicit parent evidence should become Parent.");
Assert(RelationshipTypeFromEvidence("affection", "Iris is my best friend; it has been that way since school.", null, "Nora", "Iris") == "Friend", "Explicit best-friend evidence should become Friend.");
Assert(RelationshipTypeFromEvidence("dependence", "Nora goes next door to Mrs Alder and asks for help.", null, "Nora", "Mrs Alder") == "Neighbour", "Next-door context should become Neighbour.");
Assert(RelationshipTypeFromEvidence("obligation", "They wait together.", "Nora meets coworkers Jae and Priya at the tram stop.", "Nora", "Jae") == "Professional", "Scene coworker context should become Professional.");
Assert(RelationshipTypeFromEvidence("trust", "Dr Vale reassures Mira during the assessment.", "Dr Vale is Mira's examiner.", "Mira", "Dr Vale") == "Educational", "Generic examiner/candidate context should become Educational.");
Assert(RelationshipTypeFromEvidence("affection", "Jon looks Mira up and down and flirts.", null, "Jon", "Mira") != "Romantic", "One-sided attraction/flirtation should not establish Romantic.");
Assert(RelationshipTypeFromEvidence("conflict", "Two siblings argue and insult one another.", null, "Mira", "Talia") == "Sibling", "Sibling evidence should outrank conflict.");
Assert(RelationshipTypeFromEvidence("conflict", "Mira attacks Oren after he harms her friend.", null, "Mira", "Oren") == "Rival", "One-off adversarial interaction should stay hostile/adversarial, not friendly or romantic.");
Assert(RelationshipTypeFromEvidence("dependence", "Mary arrives to take them to the theatre and Nora gets into Mary's car.", null, "Nora", "Mary") == "Unknown", "One incidental lift should not become Ally.");
Assert(RelationshipTypeFromEvidence("secrecy", "Nora searches for her sister under another name.", null, "Nora", "Mira") == "Sibling", "Identity timing belongs to Knowledge; objective sibling evidence should remain Sibling.");
Assert(RelationshipTypeFromEvidence("family", "Nora's adoptive mother speaks to Nora.", null, "Nora", "Nora's adoptive mother") == "Parent", "Adoptive family evidence should classify the direct relationship without transitive assumptions.");
Assert(RelationshipTypeFromEvidence("admiration", "Nora regards Priya as like an older sister after the rescue.", null, "Nora", "Priya") != "Sibling", "Figurative sibling language should not create an objective Sibling relationship.");
Assert(RelationshipTypeFromEvidence("information", "Pat addresses Nora directly about looking for her sister.", null, "Pat", "Nora") != "Sibling", "Third-party kinship discussion should not make the speakers siblings.");
Assert(RelationshipTypeFromEvidence("conflict", "Oren kisses Mira before assaulting her.", null, "Oren", "Mira") == "Rival", "Assault evidence should outrank kiss wording and remain adversarial.");
Assert(RelationshipTypeFromEvidence("family", "Mira's mum tells the group to look after Mira.", null, "Mira", "Mira's mum") == "Parent", "Owned mum/mother variants should classify as Parent.");
Assert(RelationshipTypeFromEvidence("family", "Mira's mother tells the group to look after Mira.", null, "Mira", "Mira’s mum") == "Parent", "Curly apostrophe owned kinship names should classify as Parent.");
Assert(RelationshipTypeFromEvidence("affection", "Nora likes Alison and sees her as like an older sister.", "Alison is a nurse who recently moved back to town.", "Nora", "Alison") != "Medical", "Occupation-only context should not create a Medical relationship.");
}
static void RelationshipEvidenceHandlesRetainedHostileAndIncidentalExamples()
{
Assert(RelationshipTypeFromEvidence("conflict", "Maggie slaps Chrissy, calls her a bitch and threatens violence after discovering Chrissy's involvement.", null, "Maggie", "Chrissy") == "Rival", "Slap/threat evidence from retained Scene JSON should classify as Rival.");
Assert(RelationshipTypeFromEvidence("mistrust", "Rebecca demands Chrissy never speak to her again and expresses disgust.", null, "Rebecca", "Chrissy") == "Rival", "Never-speak/disgust evidence should remain adversarial.");
Assert(RelationshipTypeFromEvidence("dependence", "Maggie accepts Greg's offer of a lift and his help driving Rosie home.", null, "Maggie", "Greg") == "Unknown", "One practical lift/help should not become Authority, Professional or Ally.");
Assert(RelationshipTypeFromEvidence("protection", "Greg offers to take Rosie to the hospital and pulls the car over when she is sick.", null, "Greg", "Rosie") == "Unknown", "An incidental hospital offer should not create a professional or medical relationship.");
Assert(RelationshipTypeFromEvidence("authority", "Mary is late and is Rosie's mum expected to arrive.", null, "Maggie", "Mary") != "Authority", "A broad authority signal without an authority role should not create Authority.");
Assert(RelationshipTypeFromEvidence("professional", "Greg offers to take Rosie to the hospital and pulls the car over when she is sick.", null, "Greg", "Rosie") != "Professional", "A broad professional signal without work-role evidence should not create Professional.");
Assert(RelationshipTypeFromEvidence("protection", "Rosie's mum Mary knocked at the door and drives them in her old Ford Cortina.", null, "Mary", "Rosie") == "Parent", "Named parent evidence should classify Mary/Rosie as Parent.");
Assert(RelationshipTypeFromEvidence("authority", "The social worker says Beth's life is another box on a checklist.", null, "social worker", "Beth") == "Authority", "Explicit social-worker role evidence should still classify Authority.");
}
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 KnowledgeRecipientResolvesNarratorThroughPov()
{
var scene = new Scene { SceneID = 812, POVCharacterID = 42, POVCharacterName = "Beth" };
Assert(ResolveKnowledgeRecipientName("Narrator", scene) == "Beth", "Narrator knowledge should resolve to canonical POV character.");
Assert(ResolveKnowledgeRecipientName("I", scene) == "Beth", "First-person knowledge should resolve to canonical POV character.");
Assert(ResolveKnowledgeRecipientName("Maggie", scene) == "Maggie", "Other present characters should not automatically receive POV internal knowledge.");
var unrelated = new Scene { SceneID = 913, POVCharacterID = 77, POVCharacterName = "Nora" };
Assert(ResolveKnowledgeRecipientName("Narrator", unrelated) == "Nora", "POV knowledge resolution should be generic across books.");
}
static void KnowledgeIdentityRevealPreservesSubjectiveTiming()
{
const int maggieId = 617;
const int victorId = 901;
const int earlierSceneId = 11;
const int revealSceneId = 18;
var aliasEvidence = (Alias: "The Man in Grey", CanonicalCharacterID: victorId, SceneID: earlierSceneId);
var preRevealKey = KnowledgeKey(maggieId, "The Man in Grey is suspicious.", "Suspects");
var revealKey = KnowledgeKey(maggieId, "The Man in Grey is Victor Harrington.", "Knows");
Assert(aliasEvidence.CanonicalCharacterID == victorId, "Alias/reference evidence should point at the one canonical Victor CharacterID.");
Assert(aliasEvidence.Alias == "The Man in Grey" && aliasEvidence.SceneID == earlierSceneId, "Historical alias evidence should remain scene-tied before reveal.");
Assert(preRevealKey != revealKey, "Earlier suspicion/encounter knowledge must not equal later identity revelation.");
Assert(KnowledgeKey(maggieId, "Maggie learns The Man in Grey is Victor Harrington.", "Knows") == revealKey, "Reveal wording should deduplicate to the same subjective identity knowledge at the reveal scene.");
Assert(revealSceneId > earlierSceneId, "Reveal knowledge fixture should occur after alias evidence.");
}
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 bool IsDistinctiveAssetName(string name)
=> InvokePrivateAssetFilter("IsDistinctiveAssetName", 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 string? FindContextualAssetKey(IEnumerable<string> existingKeys, string candidateKey)
{
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
"FindContextualAssetKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "FindContextualAssetKey was not found.");
return (string?)method!.Invoke(null, [existingKeys, candidateKey]);
}
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 RelationshipTypeFromEvidence(string signal, string? evidence, string? sceneContext, string characterAName, string characterBName)
{
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"RelationshipTypeFromEvidence",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "RelationshipTypeFromEvidence was not found.");
var classification = method!.Invoke(null, [signal, evidence, sceneContext, characterAName, characterBName]);
Assert(classification is not null, "RelationshipTypeFromEvidence returned null.");
return classification.GetType().GetProperty("Type")?.GetValue(classification) as string ?? string.Empty;
}
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 RelationshipPairKey(int characterAId, int characterBId)
{
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"PairKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "PairKey was not found.");
return (string)method!.Invoke(null, [characterAId, characterBId])!;
}
static string RelationshipStateNameFromSignal(string signal)
{
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"RelationshipStateNameFromSignal",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "RelationshipStateNameFromSignal was not found.");
return (string)method!.Invoke(null, [signal])!;
}
static string RelationshipStateAtScene(
string initialState,
IEnumerable<(int SceneID, decimal ChapterNumber, decimal SceneNumber, int SortOrder, string StateName)> events,
int sceneId)
{
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"RelationshipStateAtScene",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "RelationshipStateAtScene was not found.");
return (string)method!.Invoke(null, [initialState, events, sceneId])!;
}
static bool IsMeaningfulRelationshipEvent(string signal, string evidence)
{
var nested = typeof(StoryIntelligenceRelationshipImportService).GetNestedType(
"RelationshipAppearanceImport",
System.Reflection.BindingFlags.NonPublic);
Assert(nested is not null, "RelationshipAppearanceImport was not found.");
var instance = Activator.CreateInstance(nested!, [
1,
1m,
1m,
10,
"Scene",
signal,
evidence,
0.8m,
false
]);
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"HasMeaningfulRelationshipEvent",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "HasMeaningfulRelationshipEvent was not found.");
return method!.Invoke(null, [instance]) is true;
}
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 ResolveKnowledgeRecipientName(string characterName, Scene scene)
{
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
"ResolveKnowledgeRecipientName",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ResolveKnowledgeRecipientName was not found.");
return (string)method!.Invoke(null, [characterName, scene])!;
}
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.");
var saveSceneSourceIndex = onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal);
var locationReviewIndex = onboarding.IndexOf("coreImportBasicLocations.EnsurePreparedAsync", saveSceneSourceIndex, StringComparison.Ordinal);
var markerProgressIndex = onboarding.IndexOf("Updating Word document with scene markers", locationReviewIndex, StringComparison.Ordinal);
Assert(saveSceneSourceIndex >= 0 && saveSceneSourceIndex < locationReviewIndex, "Scene source text should be persisted before basic location review.");
Assert(locationReviewIndex >= 0 && locationReviewIndex < markerProgressIndex, "Basic location review should be prepared 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("EnsurePreparedAsync(", StringComparison.Ordinal), "Core import should prepare basic location review after canonical scene sources exist.");
Assert(onboarding.Contains("PrepareCharacterCandidatesAfterLocationReviewAsync", StringComparison.Ordinal), "Character candidates should be generated only after basic location review is complete or skipped.");
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 CoreImportBasicLocationDetectorIsConservative()
{
var sources = new List<SceneManuscriptSourceRecord>
{
new()
{
SceneID = 101,
ChapterID = 1,
SourceText = "Maggie sat in the bedroom, then walked into the kitchen. In 1983 Rubery Hill changed. Later she met Zoe at The Black Horse Inn on Station Road."
}
};
var result = CoreImportBasicLocationDetector.Discover(sources, []);
var names = result.Candidates.Select(candidate => candidate.ProposedName).ToList();
Assert(names.Contains("The Black Horse Inn"), "Named pub/inn should be detected.");
Assert(names.Contains("Station Road"), "Named road should be detected.");
Assert(!names.Contains("1983 Rubery Hill"), "Year-prefixed phrases should not become locations.");
Assert(!names.Any(name => string.Equals(name, "bedroom", StringComparison.OrdinalIgnoreCase)), "Generic bedrooms should be excluded.");
Assert(!names.Any(name => string.Equals(name, "kitchen", StringComparison.OrdinalIgnoreCase)), "Generic kitchens should be excluded.");
}
static void CoreImportBasicLocationDetectorConsolidatesAndSkipsExistingMatches()
{
var sources = new List<SceneManuscriptSourceRecord>
{
new() { SceneID = 101, ChapterID = 1, SourceText = "They crossed Station Road and stopped outside Maggie's flat." },
new() { SceneID = 102, ChapterID = 1, SourceText = "Maggie's flat looked over Station Road." },
new() { SceneID = 201, ChapterID = 2, SourceText = "They returned to Station Road before dawn." }
};
var result = CoreImportBasicLocationDetector.Discover(sources, [new CoreImportExistingLocationName(55, "Station Road")]);
var names = result.Candidates.Select(candidate => candidate.ProposedName).ToList();
Assert(!names.Contains("Station Road"), "Exact existing canonical location matches should not become pointless review rows.");
var flat = result.Candidates.Single(candidate => candidate.ProposedName == "Maggie's flat");
Assert(flat.ObservationCount == 2, "Repeated residence observations should consolidate into one candidate.");
}
static void CoreImportBasicLocationDetectorSupportsPendingParentCandidates()
{
var sources = new List<SceneManuscriptSourceRecord>
{
new() { SceneID = 101, ChapterID = 1, SourceText = "Rose Cottage on Station Road had a yellow door. They later crossed Station Road." }
};
var result = CoreImportBasicLocationDetector.Discover(sources, []);
var cottage = result.Candidates.Single(candidate => candidate.ProposedName == "Rose Cottage");
Assert(result.Candidates.Any(candidate => candidate.ProposedName == "Station Road"), "The parent candidate should be discoverable in the same pass.");
Assert(cottage.ParentCandidateKey == CoreImportBasicLocationDetector.StableKey("Station Road"), "Pending parent candidates should be captured for materialisation order.");
}
static void CoreImportCharacterDiscoveryProtectsAcceptedLocations()
{
var sources = new List<SceneManuscriptSourceRecord>
{
new() { SceneID = 101, ChapterID = 1, SourceText = "Maggie walked along Station Road. Maggie waved at Beth outside The Black Horse Inn." },
new() { SceneID = 102, ChapterID = 1, SourceText = "Beth told Maggie that Station Road was quiet." }
};
var candidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sources, ["Station Road", "The Black Horse Inn"]);
var names = candidates.Select(candidate => candidate.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert(names.Contains("Maggie"), "Real character names should survive location protection.");
Assert(names.Contains("Beth"), "Other real character names should survive location protection.");
Assert(!names.Contains("Station Road"), "Accepted locations should not appear as character candidates.");
Assert(!names.Contains("The Black Horse Inn"), "Accepted location aliases should not appear as character candidates.");
}
static void CoreImportGatesCharactersBehindBasicLocationReview()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var sql = File.ReadAllText(Path.Combine(root, "Sql/181_Phase27H_CoreImportBasicLocationReview.sql"));
Assert(onboarding.Contains("Review or skip basic locations before reviewing characters.", StringComparison.Ordinal), "Onboarding service should block character finalisation while basic locations are pending.");
Assert(controller.Contains("RedirectToAction(nameof(ScanLocations)", StringComparison.Ordinal), "Onboarding controller should route pending imports to the location review page.");
Assert(sql.Contains("CoreImportBasicLocationReviews", StringComparison.Ordinal), "Location review state should be persisted in its own core import tables.");
Assert(sql.Contains("CoreImportBasicLocationReview_MarkSkipped", StringComparison.Ordinal), "Basic location review should support explicit skip.");
}
static void CoreImportHidesExactExistingCharacterNamesFromReview()
{
var candidates = new List<ManuscriptScanCharacterCandidatePreview>
{
CharacterCandidate("Beth")
};
var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates, [new CoreImportExistingCharacterName(10, "Beth")]);
Assert(result.AutoResolvedCount == 1, "Exact preferred-name matches should be auto-resolved.");
Assert(result.Candidates.Single().ExistingCharacterID == 10, "Auto-resolved candidates should carry the CharacterID.");
Assert(result.Candidates.Single().IsAutoResolvedExistingCharacter, "Auto-resolved exact matches should be hidden from identity review.");
}
static void CoreImportHidesExactExistingCharacterAliasesFromReview()
{
var candidates = new List<ManuscriptScanCharacterCandidatePreview>
{
CharacterCandidate("Becks"),
CharacterCandidate("Grace")
};
var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates,
[
new CoreImportExistingCharacterName(10, "Rebecca"),
new CoreImportExistingCharacterName(10, "Becks"),
new CoreImportExistingCharacterName(11, "Maggie"),
new CoreImportExistingCharacterName(11, "Grace")
]);
Assert(result.AutoResolvedCount == 2, "Exact known aliases should be auto-resolved.");
Assert(result.Candidates.Single(item => item.Name == "Becks").ExistingCharacterID == 10, "Becks should resolve through aliases to Rebecca's CharacterID.");
Assert(result.Candidates.Single(item => item.Name == "Grace").ExistingCharacterID == 11, "Grace should resolve through aliases to Maggie's CharacterID.");
Assert(result.Candidates.All(item => item.IsAutoResolvedExistingCharacter), "Exact aliases should not require identity review.");
}
static void CoreImportKeepsSuspectedUnknownAliasesInReview()
{
var candidates = new List<ManuscriptScanCharacterCandidatePreview>
{
CharacterCandidate("Gigi")
};
var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates, [new CoreImportExistingCharacterName(10, "Maggie")]);
Assert(result.AutoResolvedCount == 0, "A new name suspected to be an existing character should still require author review.");
Assert(!result.Candidates.Single().IsAutoResolvedExistingCharacter, "Unknown aliases should remain visible.");
}
static void CoreImportKeepsAmbiguousExistingCharacterMatchesInReview()
{
var candidates = new List<ManuscriptScanCharacterCandidatePreview>
{
CharacterCandidate("Beth")
};
var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates, [new CoreImportExistingCharacterName(10, "Beth"), new CoreImportExistingCharacterName(11, "Beth")]);
Assert(result.AutoResolvedCount == 0, "Ambiguous exact matches should still require review.");
Assert(!result.Candidates.Single().IsAutoResolvedExistingCharacter, "Ambiguous matches should remain visible.");
}
static void CoreImportKeepsNewCharactersInReview()
{
var candidates = new List<ManuscriptScanCharacterCandidatePreview>
{
CharacterCandidate("Rosie")
};
var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates, [new CoreImportExistingCharacterName(10, "Beth")]);
Assert(result.AutoResolvedCount == 0, "Genuinely new characters should not be auto-resolved.");
Assert(!result.Candidates.Single().ExistingCharacterID.HasValue, "New characters should not be assigned an existing CharacterID.");
}
static void CoreImportAutoResolvedCharacterEvidenceIsPreserved()
{
var evidence = new List<ManuscriptScanCharacterEvidenceScene>
{
new() { SceneID = 101, ChapterID = 1, ChapterNumber = 1, SceneNumber = 1, MentionCount = 2, StructuralSummary = "Beth arrives." }
};
var candidates = new List<ManuscriptScanCharacterCandidatePreview>
{
CharacterCandidate("Beth", evidence)
};
var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates, [new CoreImportExistingCharacterName(10, "Beth")]);
var resolved = result.Candidates.Single();
Assert(resolved.IsAutoResolvedExistingCharacter, "Beth should auto-resolve.");
Assert(resolved.EvidenceScenes.Single().SceneID == 101, "Auto-resolution should preserve scene evidence.");
Assert(resolved.EvidenceScenes.Single().MentionCount == 2, "Auto-resolution should preserve mention counts.");
}
static ManuscriptScanCharacterCandidatePreview CharacterCandidate(string name, IReadOnlyList<ManuscriptScanCharacterEvidenceScene>? evidence = null)
=> new()
{
TemporaryCharacterKey = $"candidate-{name.ToLowerInvariant()}",
Name = name,
MentionCount = evidence?.Sum(item => item.MentionCount) ?? 1,
QualityScore = 90,
Category = "ProbableCharacter",
EvidenceScenes = evidence ?? []
};
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 locations", StringComparison.Ordinal), "After Word sync, the next onboarding step should be location review.");
Assert(complete.Contains("Keep this browser workflow open to review locations.", StringComparison.Ordinal), "Scene detection completion should continue to location 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 = [],
TemporalAnalysis = new SceneIntelligenceTemporalAnalysis { AbsoluteDate = null, ExactTime = null, PartOfDay = null, RelativeTransition = null, RelativeAmount = null, RelativeUnit = null, TargetDayOfWeek = null, CrossesMidnight = null, Evidence = null, Confidence = 0.0m, UnresolvedReason = "No temporal signal in scene text." },
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 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(viewModel.Contains("public int TotalAnalysedScenes => Math.Max(TotalCompletedScenes, PipelineDashboard.ScenesImported)", StringComparison.Ordinal), "Review Centre should use analysed canonical scene totals after the old scene-creation view is removed.");
Assert(repository.Contains("SceneID = item.SceneID ??", StringComparison.Ordinal), "Commit should target canonical SceneID when one exists.");
}
static void StoryIntelligenceReviewCentreCompletionIsCategoryScoped()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var reviewView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
Assert(onboardingStory.Contains("LocationStageComplete = locationComplete", StringComparison.Ordinal), "Location tile completion should come from scoped durable/review state.");
Assert(onboardingStory.Contains("AssetStageComplete = assetComplete", StringComparison.Ordinal), "Asset tile completion should come from scoped durable/review state.");
Assert(onboardingStory.Contains("RelationshipStageComplete = relationshipComplete", StringComparison.Ordinal), "Relationship tile completion should come from scoped durable/review state.");
Assert(onboardingStory.Contains("KnowledgeStageComplete = knowledgeComplete", StringComparison.Ordinal), "Knowledge tile completion should come from scoped durable/review state.");
Assert(onboardingStory.Contains("NarrativeStageComplete = narrativeComplete", StringComparison.Ordinal), "Narrative tile completion should come from scoped durable/review state.");
Assert(onboardingStory.Contains("batch.LocationStageComplete = state.LocationReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate Location completion from durable state.");
Assert(onboardingStory.Contains("batch.AssetStageComplete = state.AssetReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate Asset completion from durable state.");
Assert(onboardingStory.Contains("batch.RelationshipStageComplete = state.RelationshipReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate Relationship completion from durable state.");
Assert(onboardingStory.Contains("batch.KnowledgeStageComplete = state.KnowledgeReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate Knowledge completion from durable state.");
Assert(onboardingStory.Contains("batch.NarrativeStageComplete = state.NarrativeReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate Narrative completion from durable state.");
Assert(reviewView.Contains("Model.PipelineDashboard.RelationshipStageComplete", StringComparison.Ordinal), "Review Centre should render the scoped dashboard completion flags.");
var locationGet = ExtractMethod(controller, "StoryIntelligenceLocations");
var assetGet = ExtractMethod(controller, "StoryIntelligenceAssets");
var relationshipGet = ExtractMethod(controller, "StoryIntelligenceRelationships");
var knowledgeGet = ExtractMethod(controller, "StoryIntelligenceKnowledge");
var narrativeGet = ExtractMethod(controller, "StoryIntelligenceNarrative");
Assert(!locationGet.Contains("RecordLocationImportAsync", StringComparison.Ordinal), "Opening Location review should not mark Location complete.");
Assert(!assetGet.Contains("RecordAssetImportAsync", StringComparison.Ordinal), "Opening Asset review should not mark Asset complete.");
Assert(!relationshipGet.Contains("RecordRelationshipImportAsync", StringComparison.Ordinal), "Opening Relationship review should not mark Relationship complete.");
Assert(!knowledgeGet.Contains("RecordKnowledgeImportAsync", StringComparison.Ordinal), "Opening Knowledge review should not mark Knowledge complete.");
Assert(!narrativeGet.Contains("RecordNarrativeImportAsync", StringComparison.Ordinal), "Opening Narrative review should not mark Narrative complete.");
}
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 ?? existingScene?.SceneID", 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 WordCompanionScanCompletionUsesChunkedTransport()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var hostScript = 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 store = File.ReadAllText(Path.Combine(root, "Services/ManuscriptScanPreviewStore.cs"));
var monitor = File.ReadAllText(Path.Combine(root, "Services/WordCompanionPresenceMonitor.cs"));
var presence = File.ReadAllText(Path.Combine(root, "Services/WordCompanionPresenceService.cs"));
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
Assert(hostScript.Contains("const onboardingScanChunkSize = 128 * 1024", StringComparison.Ordinal), "Word Companion scan result chunks should remain well below the SignalR receive limit.");
Assert(hostScript.Contains("completeOnboardingScanInChunks(command, preview)", StringComparison.Ordinal), "Word Companion should upload scan completion through chunked transport.");
Assert(!hostScript.Contains("invoke(\"CompleteOnboardingScan\", preview)", StringComparison.Ordinal), "Word Companion should not send full manuscript previews as one SignalR message.");
Assert(hub.Contains("ReceiveOnboardingScanChunk", StringComparison.Ordinal), "Follow hub should expose a chunk receive method.");
Assert(models.Contains("public sealed class ManuscriptScanChunk", StringComparison.Ordinal), "Scan chunk model should be explicit.");
Assert(models.Contains("public Guid? ScanID", StringComparison.Ordinal), "Scan state should expose a scan ID for correlation.");
Assert(store.Contains("A duplicate scan result chunk did not match the original payload.", StringComparison.Ordinal), "Chunk store should reject conflicting duplicate chunks.");
Assert(store.Contains("HasRecentRunningScanForUserAsync", StringComparison.Ordinal), "Scan store should expose active scan state for connection semantics.");
Assert(monitor.Contains("ActiveScanHeartbeatGrace", StringComparison.Ordinal), "Presence monitor should treat long-running scans as a distinct active state.");
Assert(monitor.Contains("scanStore.HasRecentRunningScanForUserAsync", StringComparison.Ordinal), "Presence monitor should not mark an active scanning companion offline just because normal heartbeat is quiet.");
Assert(presence.Contains("Func<int, Task<bool>>? keepConnected", StringComparison.Ordinal), "Presence stale checks should support scan-aware keep-connected semantics.");
Assert(program.Contains("options.MaximumReceiveMessageSize = 1024 * 1024", StringComparison.Ordinal), "The fix should not depend on globally raising the SignalR receive limit.");
}
static void WordCompanionScanChunksAssembleFullNovelPayloads()
{
var preview = BuildSyntheticScanPreview(chapters: 42, paragraphsPerChapter: 58, wordsPerParagraph: 82, includeUnicode: true);
var json = JsonSerializer.Serialize(preview, ManuscriptScanJsonOptions());
var compressedSize = GZip(Encoding.UTF8.GetBytes(json)).Length;
Assert(preview.TotalWordCount >= 200_000, $"Synthetic full-novel fixture should exceed 200k words; got {preview.TotalWordCount}.");
Assert(json.Length > 1024 * 1024, $"Synthetic full-novel scan JSON should exceed the old 1 MB SignalR cap; got {json.Length} chars.");
Assert(compressedSize > 0 && compressedSize < Encoding.UTF8.GetByteCount(json), "Compressed size measurement should be valid and smaller than raw JSON.");
var store = BuildScanStore();
var state = store.StartAsync(preview.UserID, preview.OnboardingID, preview.ProjectID, preview.BookID).GetAwaiter().GetResult();
var chunks = SplitScanChunks(state.ScanID!.Value, preview.OnboardingID, json, 128 * 1024);
foreach (var chunk in chunks)
{
state = store.AddChunkAsync(preview.UserID, chunk).GetAwaiter().GetResult();
}
Assert(state.Status == ManuscriptScanStatuses.Complete, "Final chunk should complete the full-novel scan.");
Assert(state.TotalWordCount == preview.TotalWordCount, "Full-novel scan should preserve word count.");
Assert(state.ChapterCount == preview.ChapterCount, "Full-novel scan should preserve chapter count.");
var stored = store.GetLatestPreviewAsync(preview.UserID, preview.OnboardingID).GetAwaiter().GetResult();
Assert(stored is not null, "Completed chunked scan should persist a preview.");
Assert(stored!.Chapters.Count == preview.Chapters.Count, "Chunked scan should preserve all chapters.");
Assert(stored.Chapters.Sum(chapter => chapter.SourceParagraphs.Count) == preview.Chapters.Sum(chapter => chapter.SourceParagraphs.Count), "Chunked scan should preserve all source paragraphs.");
Assert(stored.Chapters.SelectMany(chapter => chapter.SourceParagraphs).Any(paragraph => paragraph.Text.Contains("Rosie's", StringComparison.Ordinal)), "Chunked scan should preserve Unicode punctuation.");
var longParagraphPreview = BuildSyntheticScanPreview(chapters: 1, paragraphsPerChapter: 1, wordsPerParagraph: 200_000, includeUnicode: true);
var longParagraphJson = JsonSerializer.Serialize(longParagraphPreview, ManuscriptScanJsonOptions());
var longParagraphStore = BuildScanStore();
var longParagraphState = longParagraphStore.StartAsync(longParagraphPreview.UserID, longParagraphPreview.OnboardingID, longParagraphPreview.ProjectID, longParagraphPreview.BookID).GetAwaiter().GetResult();
foreach (var chunk in SplitScanChunks(longParagraphState.ScanID!.Value, longParagraphPreview.OnboardingID, longParagraphJson, 128 * 1024))
{
longParagraphState = longParagraphStore.AddChunkAsync(longParagraphPreview.UserID, chunk).GetAwaiter().GetResult();
}
Assert(longParagraphState.Status == ManuscriptScanStatuses.Complete, "Chunked scan should support a large individual paragraph.");
Assert(longParagraphState.TotalWordCount >= 200_000, "Large individual paragraph fixture should preserve full word count.");
}
static void WordCompanionScanChunksRejectPartialAndConflictingPayloads()
{
var preview = BuildSyntheticScanPreview(chapters: 6, paragraphsPerChapter: 20, wordsPerParagraph: 70, includeUnicode: false);
var json = JsonSerializer.Serialize(preview, ManuscriptScanJsonOptions());
var store = BuildScanStore();
var state = store.StartAsync(preview.UserID, preview.OnboardingID, preview.ProjectID, preview.BookID).GetAwaiter().GetResult();
var chunks = SplitScanChunks(state.ScanID!.Value, preview.OnboardingID, json, 4096);
Assert(chunks.Count > 2, "Six-chapter synthetic fixture should span multiple small test chunks.");
var partialState = store.AddChunkAsync(preview.UserID, chunks[0]).GetAwaiter().GetResult();
Assert(partialState.Status == ManuscriptScanStatuses.Running, "A partial chunk set must not be accepted as complete.");
var duplicateState = store.AddChunkAsync(preview.UserID, chunks[0]).GetAwaiter().GetResult();
Assert(duplicateState.Status == ManuscriptScanStatuses.Running, "An exact duplicate chunk retry should be idempotent.");
var conflicting = new ManuscriptScanChunk
{
ScanID = chunks[0].ScanID,
UserID = chunks[0].UserID,
OnboardingID = chunks[0].OnboardingID,
ChunkIndex = chunks[0].ChunkIndex,
TotalChunks = chunks[0].TotalChunks,
TotalLength = chunks[0].TotalLength,
Payload = chunks[0].Payload + "x"
};
AssertThrows(() => store.AddChunkAsync(preview.UserID, conflicting).GetAwaiter().GetResult(), "Conflicting duplicate chunks should be rejected.");
var staleScanChunk = new ManuscriptScanChunk
{
ScanID = Guid.NewGuid(),
UserID = chunks[1].UserID,
OnboardingID = chunks[1].OnboardingID,
ChunkIndex = chunks[1].ChunkIndex,
TotalChunks = chunks[1].TotalChunks,
TotalLength = chunks[1].TotalLength,
Payload = chunks[1].Payload
};
AssertThrows(() => store.AddChunkAsync(preview.UserID, staleScanChunk).GetAwaiter().GetResult(), "Chunks from a stale scan ID should be rejected.");
}
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 Locations\", 9)", StringComparison.Ordinal), "Onboarding header should include basic location review as a distinct main step.");
Assert(header.Contains("new(\"Review Characters\", 10)", 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 ? 10 : 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 = 11;", 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=\"StoryIntelligencePlotContext\"", StringComparison.Ordinal), "Core Import Complete should open the optional author Plot Line context screen before analysis.");
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("StartStoryIntelligenceWithPlotContext", StringComparison.Ordinal), "Story Intelligence start should save optional Plot Line context before queuing analysis.");
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 StoryIntelligenceCharacterReviewUsesSingleIdentityDecision()
{
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("ExistingCharacterOptions", StringComparison.Ordinal), "Character review should expose canonical existing-character targets.");
Assert(service.Contains(".OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal), "Canonical alias targets should be alphabetically ordered.");
Assert(service.Contains("existingIndex.Find(choice.ExistingCharacterID)", StringComparison.Ordinal), "Import should resolve same-character 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("Same character as existing", StringComparison.Ordinal), "Review should present one clear identity decision.");
Assert(view.Contains("name=\"Characters[@i].ExistingCharacterID\"", StringComparison.Ordinal), "Same-character dropdown should post canonical CharacterID values.");
Assert(!view.Contains("Alias of another character", StringComparison.Ordinal), "Review should not expose competing alias-chain semantics.");
Assert(!view.Contains("data-character-alias-panel", StringComparison.Ordinal), "Review should not render a separate alias target panel.");
}
static void StoryIntelligenceAutoResolvesExactKnownIdentitiesBeforeReview()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
Assert(service.Contains("ResolveAutomaticMatchesAsync", StringComparison.Ordinal), "Character import should expose deterministic auto-resolution before persisted review generation.");
Assert(service.Contains("IsAutoResolvableKnownIdentity", StringComparison.Ordinal), "Exact known identities should be recognised separately from proposed strong matches.");
Assert(service.Contains("existingIndex.Find(candidate.DisplayName)?.CharacterID == candidate.ExistingCharacterID.Value", StringComparison.Ordinal), "Only unambiguous preferred/short-name/alias lookup matches should auto-resolve.");
Assert(service.Contains("LinkCandidateAppearancesAsync(candidate, characterId", StringComparison.Ordinal), "Auto-resolution should preserve scene-character associations.");
Assert(candidateService.Contains("await characterImport.ResolveAutomaticMatchesAsync(batch)", StringComparison.Ordinal), "Persisted review preparation should resolve exact known identities before saving review rows.");
}
static void StoryIntelligenceCharacterMatchingKeepsAmbiguousKnownNamesVisible()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
Assert(service.Contains("Dictionary<string, List<CharacterMatch>> byName", StringComparison.Ordinal), "Known-name lookup should retain all CharacterID owners for an alias key.");
Assert(service.Contains("matches.Select(match => match.CharacterID).Distinct().Count() == 1", StringComparison.Ordinal), "Known-name lookup should only return a match when there is exactly one owner.");
Assert(service.Contains("HasAmbiguousExistingMatch", StringComparison.Ordinal), "Ambiguous known-name matches should be preserved on candidates.");
Assert(service.Contains("return StoryIntelligenceCharacterClassifications.Ambiguous", StringComparison.Ordinal), "Ambiguous known-name matches should remain visible for review.");
}
static void StoryIntelligenceDescriptiveReferencesConsolidateArticles()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
Assert(service.Contains("IsMergeableArticle", StringComparison.Ordinal), "Candidate keys should normalise safe leading articles.");
Assert(service.Contains("words.RemoveAt(0);", StringComparison.Ordinal), "Barman/the barman and Creepy guy/the creepy guy should collapse to one candidate key.");
Assert(service.Contains("candidate.Aliases.Add(name)", StringComparison.Ordinal), "Consolidated references should preserve alternate observed labels as aliases/evidence.");
}
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 Lena", 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("StoryIntelligenceBookPlotContext", StringComparison.Ordinal), "Book details should expose a permanent Analyse My Book action through author Plot Line context.");
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.");
var boundaries = parsed?.SceneBoundaries;
var secondBoundary = boundaries is { Count: > 1 } ? boundaries[1] : null;
Assert(secondBoundary?.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 EntityPreferredNamesArePeerAliases()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var sql = File.ReadAllText(Path.Combine(root, "Sql/180_Phase27G_EntityPreferredNameAliases.sql"));
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
var services = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
var characterController = File.ReadAllText(Path.Combine(root, "Controllers/CharactersController.cs"));
var locationController = File.ReadAllText(Path.Combine(root, "Controllers/LocationsController.cs"));
var characterDetails = File.ReadAllText(Path.Combine(root, "Views/Characters/Details.cshtml"));
var characterEdit = File.ReadAllText(Path.Combine(root, "Views/Characters/Edit.cshtml"));
var locationDetails = File.ReadAllText(Path.Combine(root, "Views/Locations/Details.cshtml"));
var locationEdit = File.ReadAllText(Path.Combine(root, "Views/Locations/Edit.cshtml"));
Assert(sql.Contains("INSERT dbo.CharacterAliases (CharacterID, Alias, SortOrder)", StringComparison.Ordinal), "Migration should backfill preferred character names into CharacterAliases.");
Assert(sql.Contains("INSERT dbo.LocationAliases (LocationID, Alias, SortOrder)", StringComparison.Ordinal), "Migration should backfill preferred location names into LocationAliases.");
Assert(sql.Contains("CREATE OR ALTER PROCEDURE dbo.Character_SetPreferredName", StringComparison.Ordinal), "Character preferred-name procedure should exist.");
Assert(sql.Contains("CREATE OR ALTER PROCEDURE dbo.Location_SetPreferredName", StringComparison.Ordinal), "Location preferred-name procedure should exist.");
Assert(sql.Contains("The preferred name must already belong to this character.", StringComparison.Ordinal), "Character preferred-name changes must verify the name belongs to the CharacterID.");
Assert(sql.Contains("The preferred name must already belong to this location.", StringComparison.Ordinal), "Location preferred-name changes must verify the name belongs to the LocationID.");
Assert(!sql.Contains("TargetAliasID", StringComparison.OrdinalIgnoreCase), "Alias migration must not introduce alias-to-alias links.");
Assert(!sql.Contains("ParentAliasID", StringComparison.OrdinalIgnoreCase), "Alias migration must not introduce alias chain parents.");
Assert(repository.Contains("Task SetPreferredNameAsync(int characterId, string preferredName)", StringComparison.Ordinal), "Character repository should expose preferred-name update by CharacterID.");
Assert(repository.Contains("\"dbo.Character_SetPreferredName\"", StringComparison.Ordinal), "Character repository should use the stored procedure.");
Assert(repository.Contains("Task SetPreferredNameAsync(int locationId, string preferredName)", StringComparison.Ordinal), "Location repository should expose preferred-name update by LocationID.");
Assert(repository.Contains("\"dbo.Location_SetPreferredName\"", StringComparison.Ordinal), "Location repository should use the stored procedure.");
Assert(services.Contains("SyncCharacterAliasesAsync(characterId, model.Aliases, model.CharacterName)", StringComparison.Ordinal), "Character saves should include the preferred display name in known names.");
Assert(services.Contains("SyncLocationAliasesAsync(locationId, model.Aliases, model.LocationName)", StringComparison.Ordinal), "Location saves should include the preferred display name in known names.");
Assert(services.Contains("await characters.SetPreferredNameAsync(characterId, preferredName)", StringComparison.Ordinal), "Character preferred-name service should only update the display label.");
Assert(services.Contains("await locations.SetPreferredNameAsync(locationId, preferredName)", StringComparison.Ordinal), "Location preferred-name service should only update the display label.");
Assert(characterController.Contains("SetPreferredName(int id, string preferredName)", StringComparison.Ordinal), "Character controller should expose Set as preferred.");
Assert(locationController.Contains("SetPreferredName(int id, string preferredName)", StringComparison.Ordinal), "Location controller should expose Set as preferred.");
Assert(characterDetails.Contains("Known names", StringComparison.Ordinal) && characterDetails.Contains("Set as preferred", StringComparison.Ordinal), "Character details should render known names and Set as preferred.");
Assert(locationDetails.Contains("Known names", StringComparison.Ordinal) && locationDetails.Contains("Set as preferred", StringComparison.Ordinal), "Location details should render known names and Set as preferred.");
Assert(characterEdit.Contains("Known names", StringComparison.Ordinal) && !characterEdit.Contains(">Aliases</label>", StringComparison.Ordinal), "Character edit terminology should avoid alias-chain wording.");
Assert(locationEdit.Contains("Known names", StringComparison.Ordinal) && !locationEdit.Contains(">Aliases</label>", StringComparison.Ordinal), "Location edit terminology should avoid alias-chain wording.");
var characterId = 28;
var characterNames = new HashSet<string>(["Maggie", "Margaret", "Margaret Grant", "Miss Grant", "Grace"], StringComparer.OrdinalIgnoreCase);
var originalCharacterLinks = (Scenes: 4, Relationships: 3, Knowledge: 5, PlotEvents: 2);
var preferredCharacterName = "Margaret Grant";
Assert(characterId == 28 && characterNames.Contains(preferredCharacterName), "Fixture preferred name should belong directly to CharacterID 28.");
Assert(characterNames.SetEquals(["Maggie", "Margaret", "Margaret Grant", "Miss Grant", "Grace"]), "Changing preferred character name must leave all known names attached.");
Assert(originalCharacterLinks == (Scenes: 4, Relationships: 3, Knowledge: 5, PlotEvents: 2), "Changing preferred character name must not alter related foreign-key counts.");
preferredCharacterName = "Maggie";
Assert(characterId == 28 && characterNames.Contains(preferredCharacterName), "Preferred character name should be reversible without changing CharacterID.");
var locationId = 57;
var locationNames = new HashSet<string>(["Grant family home", "Maggie's flat", "Maggie's home", "23 The Doweries"], StringComparer.OrdinalIgnoreCase);
var originalLocationLinks = (ParentLocationID: 12, Scenes: 7, Assets: 3);
var preferredLocationName = "23 The Doweries";
Assert(locationId == 57 && locationNames.Contains(preferredLocationName), "Fixture preferred name should belong directly to LocationID 57.");
Assert(locationNames.SetEquals(["Grant family home", "Maggie's flat", "Maggie's home", "23 The Doweries"]), "Changing preferred location name must leave all known names attached.");
Assert(originalLocationLinks == (ParentLocationID: 12, Scenes: 7, Assets: 3), "Changing preferred location name must not alter parent, scene, or asset references.");
}
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"));
var materialisation = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceMaterialisationService.cs"));
Assert(materialisation.Contains("map[$\"metric_{metric.MetricTypeID}\"] = metric", StringComparison.Ordinal), "Metric persistence should map configured context keys back to MetricTypeID.");
Assert(materialisation.Contains("map.TryAdd(cleanedName, metric)", StringComparison.Ordinal), "Metric persistence should also accept configured metric names.");
Assert(materialisation.Contains("Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue)", StringComparison.Ordinal), "Metric values should be clamped to configured scale.");
Assert(service.Contains("StoryIntelligenceMaterialisationService.BuildMetrics", StringComparison.Ordinal), "Legacy admin commit should reuse the modern-neutral metric converter.");
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 StoryIntelligenceStabilisationGateMaterialisesStrictAndLegacyMetrics()
{
var metricTypes = new[]
{
new SceneMetricType { MetricTypeID = 1, MetricName = "Overall Intensity", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
new SceneMetricType { MetricTypeID = 2, MetricName = "Tension", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
new SceneMetricType { MetricTypeID = 3, MetricName = "Emotional Weight", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
new SceneMetricType { MetricTypeID = 4, MetricName = "Action", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
new SceneMetricType { MetricTypeID = 5, MetricName = "Darkness", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
new SceneMetricType { MetricTypeID = 6, MetricName = "Romance", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
new SceneMetricType { MetricTypeID = 7, MetricName = "Sexual Charge", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
new SceneMetricType { MetricTypeID = 8, MetricName = "Violence", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
new SceneMetricType { MetricTypeID = 9, MetricName = "Hope / Lightness", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
new SceneMetricType { MetricTypeID = 10, MetricName = "Comedy", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
new SceneMetricType { MetricTypeID = 11, MetricName = "Mystery", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }
};
var map = StoryIntelligenceMaterialisationService.BuildMetricMap(metricTypes);
var strictScene = new SceneIntelligenceScene
{
MetricValues =
[
new SceneIntelligenceMetricValue { Key = "metric_1", Score = 8, Confidence = 0.91m },
new SceneIntelligenceMetricValue { Key = "metric_2", Score = 7, Confidence = 0.85m },
new SceneIntelligenceMetricValue { Key = "metric_3", Score = 12, Confidence = 0.8m }
],
Metrics = new Dictionary<string, SceneIntelligenceMetric>
{
["tension"] = new() { Score = 2, Confidence = 0.5m }
}
};
var legacyScene = new SceneIntelligenceScene
{
Metrics = new Dictionary<string, SceneIntelligenceMetric>
{
["pacing"] = new() { Score = 6, Confidence = 0.75m },
["humour"] = new() { Score = 3, Confidence = 0.7m },
["mystery"] = new() { Score = 9, Confidence = 0.88m }
}
};
var strictValues = StoryIntelligenceMaterialisationService.BuildMetrics(strictScene, map);
var legacyValues = StoryIntelligenceMaterialisationService.BuildMetrics(legacyScene, map);
Assert(strictValues.Count == 3, "Strict metricValues fixture should produce one row per configured key.");
Assert(strictValues.Single(value => value.MetricTypeID == 1).Value == 8, "Configured metric_1 should map to Overall Intensity.");
Assert(strictValues.Single(value => value.MetricTypeID == 2).Value == 7, "Configured metric_2 should map to Tension.");
Assert(strictValues.Single(value => value.MetricTypeID == 3).Value == 10, "Scores must be clamped to the configured metric scale.");
Assert(strictValues.Single(value => value.MetricTypeID == 2).Value != 2, "Modern metricValues must take priority over the legacy Metrics dictionary.");
Assert(legacyValues.Count == 3, "Legacy Metrics fixture should still materialise broad old schema aliases.");
Assert(legacyValues.Any(value => value.MetricTypeID == 1 && value.Value == 6), "Legacy pacing should map to Overall Intensity.");
Assert(legacyValues.Any(value => value.MetricTypeID == 10 && value.Value == 3), "Legacy humour should map to Comedy.");
Assert(legacyValues.Any(value => value.MetricTypeID == 11 && value.Value == 9), "Legacy mystery should map to Mystery.");
}
static void StoryIntelligenceModernMaterialisationOwnsFinalisationBoundary()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var worker = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceWorker.cs"));
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligencePipelineRepository.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/173_Phase26I_StoryIntelligenceModernMaterialisation.sql"));
var runnerMaterialise = runner.IndexOf("materialisation.MaterialiseReadyBookAsync", StringComparison.Ordinal);
var runnerComplete = runner.IndexOf("pipelineState.CompleteSceneAnalysisIfReadyAsync", StringComparison.Ordinal);
Assert(runnerMaterialise >= 0 && runnerComplete > runnerMaterialise, "Modern runner must materialise deterministic outputs before releasing Review Centre state.");
Assert(worker.Contains("FindNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Recovery should find a ready pipeline before attempting completion.");
Assert(worker.IndexOf("materialisation.MaterialiseReadyBookAsync", StringComparison.Ordinal) < worker.IndexOf("pipelineState.CompleteSceneAnalysisIfReadyAsync", StringComparison.Ordinal), "Recovery must materialise before completion.");
Assert(!program.Contains("AddHostedService<StoryIntelligenceWorker>", StringComparison.Ordinal), "Legacy StoryIntelligenceWorker must not poll normal jobs.");
Assert(program.Contains("AddHostedService<PersistedStoryIntelligenceWorker>", StringComparison.Ordinal), "Modern persisted worker must remain registered.");
Assert(program.Contains("IStoryIntelligenceMaterialisationService, StoryIntelligenceMaterialisationService", StringComparison.Ordinal), "Modern materialisation service should be registered in DI.");
Assert(repository.Contains("StoryIntelligenceBookPipeline_FindNextReadySceneAnalysis", StringComparison.Ordinal), "Recovery lookup should use a stored procedure.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_FindNextReadySceneAnalysis", StringComparison.Ordinal), "Forward migration should define the read-only recovery lookup procedure.");
}
static void StoryIntelligenceNarrativeMaterialisationReadsStrictSceneResults()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
var fixture = new SceneIntelligenceScene
{
SchemaVersion = "1.0",
NarrativeArcs =
[
new SceneIntelligenceNarrativeArc
{
Title = "The Lighthouse Signal",
Description = "A coastal signal recurs as a book-level mystery.",
Scale = "major",
ChangeType = "Introduced",
Significance = "major",
ContinuityKey = "lighthouse signal mystery",
Evidence = "The lamp flashes in a pattern no one can explain.",
Confidence = 0.9m
}
],
MetricValues = [new SceneIntelligenceMetricValue { Key = "metric_1", Score = 7, Confidence = 0.8m }]
};
var signals = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(
fixture.NarrativeArcs.Select(arc => new StoryIntelligenceNarrativeSignalInput(
501,
arc.Title,
arc.Description,
string.Equals(arc.Scale, "thread", StringComparison.OrdinalIgnoreCase) ? "Thread" : "Plot Line",
arc.ChangeType ?? arc.ArcType ?? "Developed",
arc.Significance ?? "major",
arc.ContinuityKey,
arc.ParentArcTitle,
arc.ArcType ?? "Narrative arc",
arc.Confidence,
arc.MatchingExistingPlotLineId,
arc.MatchingExistingThreadId,
arc.Evidence)),
[]);
Assert(service.Contains("IsReadableSceneResult", StringComparison.Ordinal), "Narrative review should not require the obsolete summary object to read modern strict scene results.");
Assert(service.Contains("!string.IsNullOrWhiteSpace(scene.SchemaVersion)", StringComparison.Ordinal), "Modern strict scene results should be accepted by schemaVersion.");
Assert(service.Contains("scene.NarrativeArcs?.Count > 0", StringComparison.Ordinal), "Narrative materialisation should accept scenes that carry narrative arcs.");
Assert(signals.Single().ShouldCreateCandidate, "A non-Alpha-Flame major narrative fixture should generate a review candidate through the generic consolidator.");
}
static void StoryIntelligenceStabilisationRoutesNormalUsersToBook()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var buildComplete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/BuildComplete.cshtml"));
var overview = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligence.cshtml"));
var context = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceContext.cshtml"));
var startAction = ExtractBetween(controller, "public async Task<IActionResult> StartStoryIntelligence()", "[HttpPost(\"story-intelligence/book/{bookId:int}/start\")]");
Assert(startAction.Contains("return RedirectToBook(progress);", StringComparison.Ordinal), "Fresh onboarding Story Intelligence start should return to the Book page.");
Assert(!startAction.Contains("RedirectToAction(nameof(StoryIntelligenceProgress)", StringComparison.Ordinal), "Fresh onboarding start must not route normal users to the old progress page.");
Assert(buildComplete.Contains("asp-action=\"StoryIntelligencePlotContext\"", StringComparison.Ordinal), "Core Import Complete should open optional plot context before analysis.");
Assert(overview.Contains("asp-controller=\"Books\" asp-action=\"Details\"", StringComparison.Ordinal), "Existing analysis overview should return to Book details.");
Assert(context.Contains("asp-controller=\"Books\" asp-action=\"Details\"", StringComparison.Ordinal), "Existing analysis context page should return to Book details.");
}
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("ShouldCreateCandidate", StringComparison.Ordinal), "Thread discovery should filter one-off incidental details through candidate quality rules.");
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[]
{
NarrativeSignal(101, "Maggie's Disappearance", "Beth notices Maggie is gone and starts looking for her.", "Plot Line", "Developed", "major", "missing maggie", null, "disappearance", 0.86m, null, null, "Maggie is missing."),
NarrativeSignal(102, "Search for Maggie", "Beth follows a clue that may explain where Maggie went.", "Plot Line", "Developed", "major", "missing maggie", null, "investigation", 0.82m, null, null, "Beth follows a clue."),
NarrativeSignal(103, "Finding Maggie", "The search for Maggie reaches a possible answer.", "Plot Line", "Revealed", "major", "missing maggie", null, "disappearance", 0.8m, null, 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[]
{
NarrativeSignal(201, "Search for Maggie", "Beth follows a clue in Maggie's disappearance.", "Plot Line", "Developed", "major", null, null, "investigation", 0.84m, null, null, "Beth follows a clue."),
NarrativeSignal(202, "Missing Maggie", "Maggie's absence drives the investigation.", "Plot Line", "Developed", "major", null, null, "disappearance", 0.81m, null, null, "Maggie remains missing."),
NarrativeSignal(203, "Where is Maggie?", "A lead suggests where Maggie may have gone.", "Plot Line", "Developed", "major", null, null, "disappearance", 0.79m, null, 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 StoryIntelligenceNarrativeSignalInput NarrativeSignal(
int sceneId,
string? title,
string? description,
string category,
string changeType,
string significance,
string? continuityKey,
string? parentTitle,
string arcType,
decimal? confidence,
int? matchingExistingPlotLineId,
int? matchingExistingThreadId,
string? evidence)
=> new(
sceneId,
title,
description,
category,
changeType,
significance,
continuityKey,
parentTitle,
arcType,
confidence,
matchingExistingPlotLineId,
matchingExistingThreadId,
evidence);
static void NarrativeResearchPrototypeFixtureBuildsHierarchy()
{
var signals = new[]
{
NarrativeSignal(1, "The Missing Prototype", "The research prototype is discovered missing.", "Plot Line", "Introduced", "major", "research prototype", null, "investigation", 0.91m, null, null, "The prototype is missing."),
NarrativeSignal(2, "Security Investigation", "The security chief starts tracing what happened.", "Plot Line", "Developed", "major", "research prototype", null, "investigation", 0.86m, null, null, "An investigation begins."),
NarrativeSignal(3, "Camera Footage", "Camera evidence gives the investigation its first direction.", "Thread", "Revealed", "moderate", "prototype theft evidence", "The Missing Prototype", "clue", 0.8m, null, null, "Footage shows suspicious movement."),
NarrativeSignal(4, "The Implicated Engineer", "A trusted engineer appears connected to the theft.", "Thread", "Complicated", "moderate", "prototype thief identity", "The Missing Prototype", "suspicion", 0.79m, null, null, "Access logs implicate the engineer."),
NarrativeSignal(5, "The Engineer Cleared", "Evidence clears the engineer and points to another actor.", "Thread", "Revealed", "moderate", "prototype thief identity", "The Missing Prototype", "suspicion", 0.83m, null, null, "A timestamp clears the engineer."),
NarrativeSignal(6, "Prototype Location Found", "The prototype is traced to a storage site.", "Thread", "Revealed", "moderate", "prototype location", "The Missing Prototype", "clue", 0.84m, null, null, "Delivery records reveal the location."),
NarrativeSignal(7, "Betrayer Identified", "The insider responsible is identified.", "Thread", "Resolved", "major", "prototype thief identity", "The Missing Prototype", "mystery", 0.88m, null, null, "The betrayer is identified."),
NarrativeSignal(8, "Prototype Recovered", "The missing prototype is recovered.", "Plot Line", "Resolved", "major", "research prototype", null, "investigation", 0.9m, null, null, "The device is recovered.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []);
var plotLines = consolidated.Where(signal => signal.Category == "Plot Line" && signal.ShouldCreateCandidate).ToList();
var threads = consolidated.Where(signal => signal.Category == "Thread" && signal.ShouldCreateCandidate).ToList();
Assert(plotLines.Count == 1, $"Expected one coherent prototype Plot Line, got {plotLines.Count}.");
Assert(plotLines[0].SceneIds.Order().SequenceEqual(new[] { 1, 2, 8 }), "Prototype Plot Line should retain exact supporting SceneIDs.");
Assert(threads.Count >= 2 && threads.Count < 8, "Prototype fixture should produce subordinate Threads without one candidate per scene.");
Assert(threads.Any(thread => thread.ParentCandidateKey is not null), "Prototype Threads should carry a proposed parent Plot Line candidate key.");
Assert(threads.SelectMany(thread => thread.Evidence).Any(evidence => evidence.ContributionType == "Resolved"), "Thread evidence should preserve resolution events.");
}
static void NarrativeSiblingHotelFixtureGeneralisesHierarchy()
{
var signals = new[]
{
NarrativeSignal(11, "The Failing Hotel", "Estranged siblings inherit a hotel that may have to be sold.", "Plot Line", "Introduced", "major", "hotel future", null, "family", 0.86m, null, null, "The hotel is failing."),
NarrativeSignal(12, "Selling or Saving the Hotel", "The siblings clash over whether to sell or restore the hotel.", "Plot Line", "Developed", "major", "hotel future", null, "family", 0.84m, null, null, "They argue over the hotel's future."),
NarrativeSignal(13, "Sibling Estrangement", "Old resentment shapes how the siblings handle the inheritance.", "Thread", "Introduced", "moderate", "sibling estrangement", "The Failing Hotel", "relationship", 0.82m, null, null, "They avoid discussing the past."),
NarrativeSignal(14, "Parent's Instructions", "Unusual instructions in the will raise a continuing question.", "Thread", "Introduced", "moderate", "parent instructions", "The Failing Hotel", "secret", 0.81m, null, null, "The will contains unexplained conditions."),
NarrativeSignal(15, "Working Together", "The siblings cooperate when a public event threatens the hotel.", "Thread", "Developed", "moderate", "sibling estrangement", "The Failing Hotel", "relationship", 0.83m, null, null, "They work together under pressure.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []);
Assert(consolidated.Count(signal => signal.Category == "Plot Line" && signal.ShouldCreateCandidate) == 1, "Domestic hotel fixture should consolidate into one major Plot Line.");
Assert(consolidated.Any(signal => signal.Category == "Thread" && signal.ShouldCreateCandidate && signal.ParentCandidateKey is not null), "Domestic fixture should produce child Threads through the same generic mechanism.");
}
static void NarrativeTrivialOneSceneQuestionIsSuppressed()
{
var signals = new[]
{
NarrativeSignal(21, "Will the courier arrive before six?", null, "Thread", "Introduced", "minor", null, null, "question", 0.76m, null, null, "The courier question is answered in the same exchange.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []);
Assert(consolidated.Count(signal => signal.ShouldCreateCandidate) == 0, "A one-scene minor courier question must not become a book-level Thread candidate.");
}
static void NarrativeShortSignificantThreadSurvives()
{
var signals = new[]
{
NarrativeSignal(31, "The Locked Safe", "A safe blocks access to evidence needed for the investigation.", "Thread", "Introduced", "major", "locked safe evidence", "The Missing Prototype", "obstacle", 0.81m, null, null, "The safe prevents access."),
NarrativeSignal(32, "The Safe Opens", "Opening the safe exposes decisive evidence.", "Thread", "Resolved", "major", "locked safe evidence", "The Missing Prototype", "obstacle", 0.87m, null, null, "The safe reveals decisive evidence.")
};
var thread = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, [])
.Single(signal => signal.Category == "Thread");
Assert(thread.ShouldCreateCandidate, "A short two-scene Thread should survive when its significance is major.");
Assert(thread.Evidence.Select(evidence => evidence.ContributionType).SequenceEqual(new[] { "Introduced", "Resolved" }), "Short Thread progression should preserve introduced/resolved history.");
}
static void NarrativeSemanticEvolutionConsolidatesByContinuity()
{
var signals = new[]
{
NarrativeSignal(41, "Prototype stolen", "The prototype vanishes from the lab.", "Plot Line", "Introduced", "major", "research prototype recovery", null, "investigation", 0.9m, null, null, "The prototype is gone."),
NarrativeSignal(42, "Security investigation", "Access records become the focus.", "Plot Line", "Developed", "major", "research prototype recovery", null, "investigation", 0.85m, null, null, "Security records are reviewed."),
NarrativeSignal(43, "Warehouse clue", "A shipping clue points to a warehouse.", "Plot Line", "Revealed", "major", "research prototype recovery", null, "investigation", 0.84m, null, null, "A warehouse clue appears."),
NarrativeSignal(44, "Betrayer exposed", "The insider's role is exposed.", "Plot Line", "Revealed", "major", "research prototype recovery", null, "investigation", 0.86m, null, null, "The insider is exposed."),
NarrativeSignal(45, "Device recovered", "The stolen device is recovered.", "Plot Line", "Resolved", "major", "research prototype recovery", null, "investigation", 0.88m, null, null, "The device is recovered.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []);
Assert(consolidated.Count == 1, "Semantic evolution with a shared continuity key should consolidate without another AI call.");
Assert(consolidated[0].SceneIds.Count == 5, "All semantically evolved SceneIDs should remain attached exactly.");
}
static void NarrativeExistingThreadMatchingIsSupported()
{
var existingPlotLines = new[] { new PlotLineItem { PlotLineID = 701, PlotLineName = "Corporate Sabotage", Description = "A company investigation into sabotage." } };
var existingThreads = new[] { new PlotThread { PlotThreadID = 801, PlotLineID = 701, PlotLineName = "Corporate Sabotage", ThreadTitle = "Who Tampered With Security?", Summary = "Identify who altered the security system." } };
var signals = new[]
{
NarrativeSignal(51, "Tampered cameras", "Security footage has been altered.", "Thread", "Developed", "moderate", "security tampering", "Corporate Sabotage", "mystery", 0.82m, null, 801, "The camera files were altered.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, existingPlotLines, existingThreads);
Assert(consolidated.Single().ExistingPlotThreadID == 801, "Existing canonical Threads should be matched through supplied IDs.");
Assert(consolidated.Single().RelatedPlotLineID == 701, "Existing Thread matches should preserve their parent PlotLineID.");
}
static void NarrativeCandidateMergeAndParentFieldsArePersisted()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var migration = File.ReadAllText(Path.Combine(root, "Sql/168_Phase26D_NarrativeCandidateHierarchy.sql"));
var models = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceNarrativeModels.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml"));
Assert(migration.Contains("ParentNarrativeCandidateID", StringComparison.Ordinal), "Narrative candidates should persist proposed parent candidate relationships.");
Assert(migration.Contains("MergeTargetNarrativeCandidateID", StringComparison.Ordinal), "Narrative candidates should persist candidate-to-candidate merge targets.");
Assert(models.Contains("MergeCandidate", StringComparison.Ordinal), "Review actions should include candidate-to-candidate merge.");
Assert(service.Contains("ResolveCreatedOrMergedPlotLine", StringComparison.Ordinal), "Accepted child Threads should resolve newly-created or merged parent Plot Lines before canonical Thread creation.");
Assert(service.Contains("using var transaction = connection.BeginTransaction()", StringComparison.Ordinal), "Narrative acceptance should apply canonical Plot Line/Thread/Event changes in one database transaction.");
Assert(view.Contains("Merge with candidate", StringComparison.Ordinal), "Minimal review UI should expose candidate-to-candidate merge.");
}
static void NarrativePromptUsesOneSceneCallWithCompactSchema()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var context = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceSceneContextService.cs"));
Assert(prompt.Contains("changeType", StringComparison.Ordinal) && prompt.Contains("significance", StringComparison.Ordinal), "Scene prompt should request compact narrative change and significance fields.");
Assert(prompt.Contains("matchingExistingThreadId", StringComparison.Ordinal), "Scene prompt should support existing Thread matching.");
Assert(context.Contains("existingThreads", StringComparison.Ordinal), "Canonical scene context should carry existing Threads compactly.");
Assert(!runner.Contains("ConsolidatePlotLineWithAi", StringComparison.Ordinal), "Job 4 must not add a Plot Line consolidation AI call.");
Assert(!runner.Contains("WholeBookNarrative", StringComparison.Ordinal), "Job 4 must not add a whole-book narrative AI call.");
}
static void NarrativeProductionCodeRemainsFixtureAgnostic()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var productionFiles = Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)
.Where(file => file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) == false)
.Where(file => file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) == false)
.Where(file => file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith(".cshtml", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}Docs{Path.DirectorySeparatorChar}story-intelligence", StringComparison.OrdinalIgnoreCase))
.ToList();
var forbidden = new[] { "Memory Tin", "The Alpha Flame", "Ashdown", "Maggie", "Beth", "Rob", "Rosie", "Sophie", "Simon" };
var job4Files = productionFiles.Where(file =>
file.EndsWith("StoryIntelligenceNarrativeReviewService.cs", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith("Scene-Prompt-V2.md", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith("StoryIntelligenceSceneContextService.cs", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith("PersistedStoryIntelligenceRunner.cs", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith("168_Phase26D_NarrativeCandidateHierarchy.sql", StringComparison.OrdinalIgnoreCase)).ToList();
foreach (var file in job4Files)
{
var text = File.ReadAllText(file);
foreach (var term in forbidden)
{
Assert(!text.Contains(term, StringComparison.OrdinalIgnoreCase), $"Job 4 production file {Path.GetFileName(file)} must not contain fixture term {term}.");
}
}
}
static void WholeBookPlotIntelligencePromptCarriesFullManuscriptContext()
{
var context = new WholeBookPlotSynthesisContext
{
ProjectID = 71,
BookID = 72,
BookTitle = "The Glass Meridian",
Characters =
[
new WholeBookPlotSynthesisCharacter
{
CharacterID = 101,
CanonicalName = "Nora Ell",
Aliases = ["Nora", "Doctor Ell"]
}
],
ExistingPlotLines =
[
new WholeBookPlotSynthesisExistingPlotLine
{
PlotLineID = 201,
Name = "The eclipse ledger",
BookID = null,
Threads =
[
new WholeBookPlotSynthesisExistingThread
{
PlotThreadID = 301,
Title = "The missing observatory pages",
Status = "Open"
}
]
}
],
AuthorSeeds =
[
new AuthorPlotSeed
{
AuthorPlotSeedID = 401,
Description = "A public research rivalry hides a private collaboration that changes the expedition."
}
],
Chapters =
[
new WholeBookPlotSynthesisChapter
{
ChapterID = 501,
ChapterNumber = 1,
ChapterTitle = "North Window",
Scenes =
[
new WholeBookPlotSynthesisScene
{
SceneID = 601,
SceneNumber = 1,
SceneTitle = "A coded invitation",
PovCharacterName = "Nora Ell",
StructuralSetting = "Hilltop observatory",
SourceText = "Nora finds a coded invitation tucked inside the observatory ledger."
},
new WholeBookPlotSynthesisScene
{
SceneID = 602,
SceneNumber = 2,
SceneTitle = "The false chart",
PovCharacterName = "Nora Ell",
StructuralSetting = "Map room",
SourceText = "The false chart sends the expedition toward the wrong meridian."
}
]
}
],
SourceAudit = new WholeBookPlotSynthesisSourceAudit
{
SceneCount = 2,
SourceRowCount = 2,
MissingSourceCount = 0,
DuplicateSourceSceneCount = 0
}
};
var prompt = InvokeWholeBookPrompt(context);
Assert(prompt.Contains("[BOOK]", StringComparison.Ordinal), "Whole-book prompt should include book marker.");
Assert(prompt.Contains("BookID: 72", StringComparison.Ordinal), "Whole-book prompt should include BookID.");
Assert(prompt.Contains("[CHAPTER 1]", StringComparison.Ordinal), "Whole-book prompt should include deterministic chapter markers.");
Assert(prompt.Contains("SceneID: 601", StringComparison.Ordinal) && prompt.Contains("SceneID: 602", StringComparison.Ordinal), "Whole-book prompt should include canonical SceneIDs.");
Assert(prompt.Contains("Nora finds a coded invitation", StringComparison.Ordinal), "Whole-book prompt should include full scene source text.");
Assert(prompt.Contains("AuthorPlotSeedID: 401", StringComparison.Ordinal), "Whole-book prompt should include persisted author seeds.");
Assert(prompt.Contains("ThreadID: 301", StringComparison.Ordinal), "Whole-book prompt should include existing series/project thread context.");
}
static void WholeBookPlotIntelligenceSchemaRequiresTimelineEventTypes()
{
var schema = JsonSerializer.SerializeToElement(WholeBookPlotSynthesisStructuredOutputSchema.Contract.Schema);
var eventSchema = schema.GetProperty("properties")
.GetProperty("plotLines").GetProperty("items").GetProperty("properties")
.GetProperty("threads").GetProperty("items").GetProperty("properties")
.GetProperty("events").GetProperty("items").GetProperty("properties");
var eventTypes = eventSchema.GetProperty("eventType").GetProperty("enum")
.EnumerateArray()
.Select(item => item.GetString())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var expected in new[] { "Introduced", "Mentioned", "Developed", "Complicated", "Clue Planted", "Question Raised", "Question Answered", "False Lead", "Revealed", "Payoff", "Resolved", "Contradicted", "Retconned", "Note" })
{
Assert(eventTypes.Contains(expected), $"Whole-book schema should permit ThreadEvent type {expected}.");
}
Assert(WholeBookPlotSynthesisStructuredOutputSchema.Contract.Strict, "Whole-book schema should use strict structured output.");
}
static void WholeBookPlotIntelligenceAuthorSeedInputsPersistSafely()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var contextView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceAuthorPlotContext.cshtml"));
var repository = File.ReadAllText(Path.Combine(root, "Data/WholeBookPlotIntelligenceRepository.cs"));
var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql"));
Assert(contextView.Contains("Descriptions.Count >= 3", StringComparison.Ordinal) && contextView.Contains("Enumerable.Repeat(string.Empty, 3 - Model.Descriptions.Count)", StringComparison.Ordinal), "Author context page should render the three default seed inputs.");
Assert(contextView.Contains("data-add-plot-context", StringComparison.Ordinal) && contextView.Contains("PlotSeeds[${index}]", StringComparison.Ordinal), "Author context page should allow arbitrary additional seed inputs.");
Assert(repository.Contains(".Where(description => !string.IsNullOrWhiteSpace(description))", StringComparison.Ordinal), "Repository should ignore empty seed text before persistence.");
Assert(repository.Contains(".Select(description => new { description = description.Trim() })", StringComparison.Ordinal), "Repository should persist trimmed non-empty seed text.");
Assert(sql.Contains("NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.description'))), N'') IS NOT NULL", StringComparison.Ordinal), "Database save procedure should also ignore empty seed text.");
var saveIndex = controller.IndexOf("await wholeBookPlotIntelligence.SaveAuthorPlotContextAsync", StringComparison.Ordinal);
var startBookIndex = controller.IndexOf("await storyIntelligence.StartBookAsync(form.BookID)", StringComparison.Ordinal);
Assert(saveIndex >= 0 && startBookIndex > saveIndex, "Author seed persistence must complete before Analyse My Book queues analysis.");
}
static void WholeBookPlotIntelligenceStructuredAuthorSeedMatchSurvives()
{
const string json = """
{
"metadata": { "bookStatus": "Draft", "summary": "A search is under way." },
"plotLines": [
{
"key": "PL-A",
"proposedTitle": "Hidden partnership",
"description": "A visible rivalry conceals cooperation.",
"significance": "Central author intent.",
"bookStatus": "Active",
"existingPlotLineID": 7001,
"existingPlotLineMatchConfidence": 0.82,
"existingPlotLineMatchReason": "Same long-running project line.",
"authorPlotSeedID": 9001,
"seedMatchConfidence": 0.91,
"seedMatchReason": "Matches the supplied author seed.",
"confidence": 0.88,
"threads": []
}
]
}
""";
var parsed = WholeBookPlotSynthesisResult.FromJson(json);
var result = parsed.PlotLines.Single();
Assert(result.AuthorPlotSeedID == 9001, "Structured AuthorPlotSeedID should survive response parsing.");
Assert(result.SeedMatchConfidence == 0.91m, "Seed match confidence should survive response parsing.");
Assert(result.SeedMatchReason == "Matches the supplied author seed.", "Seed match reason should survive response parsing.");
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var repository = File.ReadAllText(Path.Combine(root, "Data/WholeBookPlotIntelligenceRepository.cs"));
var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql"));
Assert(repository.Contains("plotLine.AuthorPlotSeedID", StringComparison.Ordinal), "Candidate materialisation should pass AuthorPlotSeedID to persistence.");
Assert(repository.Contains("plotLine.SeedMatchConfidence", StringComparison.Ordinal), "Candidate materialisation should pass seed confidence to persistence.");
Assert(repository.Contains("plotLine.SeedMatchReason", StringComparison.Ordinal), "Candidate materialisation should pass seed reason to persistence.");
Assert(sql.Contains("AuthorPlotSeedID = @AuthorPlotSeedID", StringComparison.Ordinal), "Candidate save procedure should retain AuthorPlotSeedID updates.");
}
static void WholeBookPlotIntelligenceUnmatchedSeedsBecomeReviewCandidates()
{
var result = new WholeBookPlotSynthesisResult
{
Metadata = new WholeBookPlotSynthesisMetadata { BookStatus = "Draft", Summary = "One detected line." },
PlotLines =
[
new WholeBookPlotSynthesisPlotLineResult
{
Key = "detected",
ProposedTitle = "Detected line",
Description = "A detected line.",
Significance = "Detected",
BookStatus = "Active",
AuthorPlotSeedID = 1,
Threads = []
}
]
};
var seeds = new[]
{
new AuthorPlotSeed { AuthorPlotSeedID = 1, Description = "A matched seed." },
new AuthorPlotSeed { AuthorPlotSeedID = 2, Description = "A dormant author seed with no current-book evidence.", MatchedPlotLineID = 42 }
};
var preserved = WholeBookPlotIntelligenceService.PreserveUnmatchedAuthorSeedsForReviewForTest(result, seeds);
var unmatched = preserved.PlotLines.Single(plotLine => plotLine.AuthorPlotSeedID == 2);
Assert(unmatched.Description == "A dormant author seed with no current-book evidence.", "Unmatched author seed candidate should show the exact seed description.");
Assert(unmatched.BookStatus == "Limited current-book evidence", "Unmatched author seed candidate should explicitly represent limited evidence.");
Assert(unmatched.Threads.Count == 0, "Unmatched author seed candidate must not fabricate thread or scene evidence.");
Assert(unmatched.ExistingPlotLineID == 42, "Unmatched author seed candidate should retain an existing Plot Line match when one is already linked.");
Assert(unmatched.ExistingPlotLineMatchConfidence == 1m, "Existing author-linked Plot Line should default to merge rather than duplicate creation.");
}
static void WholeBookPlotIntelligenceUsesDedicatedOutputTokenHeadroom()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var settings = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceModels.cs"));
var appsettings = File.ReadAllText(Path.Combine(root, "appsettings.json"));
var service = File.ReadAllText(Path.Combine(root, "Services/WholeBookPlotIntelligenceService.cs"));
Assert(settings.Contains("WholeBookPlotSynthesisMaxOutputTokens", StringComparison.Ordinal), "Whole-book Plot synthesis should have stage-specific output token configuration.");
Assert(appsettings.Contains("\"WholeBookPlotSynthesisMaxOutputTokens\": 12000", StringComparison.Ordinal), "Whole-book Plot synthesis should have headroom beyond the generic 4000-token default.");
Assert(service.Contains("WholeBookPlotSynthesisMaxOutputTokens()", StringComparison.Ordinal), "Whole-book Plot synthesis should pass its dedicated max output token override to the OpenAI client.");
Assert(service.Contains("settings.WholeBookPlotSynthesisMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens)", StringComparison.Ordinal), "Whole-book Plot synthesis should fall back to the generic setting when the dedicated value is not configured.");
}
static void StoryIntelligenceNarrativeReviewDisplaysAuthorSeedProvenance()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var models = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceNarrativeModels.cs"));
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml"));
var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql"));
Assert(models.Contains("AuthorPlotSeedDescription", StringComparison.Ordinal), "Narrative candidate model should carry author seed text.");
Assert(viewModels.Contains("AuthorPlotSeedDescription", StringComparison.Ordinal), "Review ViewModel should expose author seed text.");
Assert(service.Contains("AuthorPlotSeedDescription = candidate.AuthorPlotSeedDescription", StringComparison.Ordinal), "Review ViewModel mapping should retain author seed text.");
Assert(sql.Contains("aps.Description AS AuthorPlotSeedDescription", StringComparison.Ordinal), "Candidate list proc should join author seed descriptions.");
Assert(view.Contains("<strong>Author provided</strong>", StringComparison.Ordinal), "Review UI should render quiet author provenance.");
Assert(view.Contains("@candidate.AuthorPlotSeedDescription", StringComparison.Ordinal), "Review UI should render exact author seed text.");
Assert(view.Contains("@candidate.SeedMatchReason", StringComparison.Ordinal), "Review UI should show the match or limited-evidence reason.");
Assert(view.Contains("Discovered by PlotDirector", StringComparison.Ordinal), "Review UI should distinguish discovered candidates.");
Assert(view.Contains("Existing Plot Line: @candidate.ExistingPlotLineName", StringComparison.Ordinal), "Review UI should display existing Plot Line names, not database IDs.");
}
static void StoryIntelligenceNarrativeReviewAvoidsRawAuthorFacingIds()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml"));
Assert(!view.Contains("Scene @item.SceneID", StringComparison.Ordinal), "Review UI should not expose raw SceneIDs as visible text.");
Assert(!view.Contains(">PlotLineID", StringComparison.Ordinal) && !view.Contains("PlotLineID:", StringComparison.Ordinal), "Review UI should not expose raw PlotLineID labels.");
Assert(!view.Contains(">AuthorPlotSeedID", StringComparison.Ordinal) && !view.Contains("AuthorPlotSeedID:", StringComparison.Ordinal), "Review UI should not expose raw AuthorPlotSeedID labels.");
Assert(view.Contains("@item.SceneLabel", StringComparison.Ordinal), "Review UI should display readable chapter/scene labels.");
}
static void StoryIntelligenceAuthorSeedDecisionsArePreserved()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
Assert(sql.Contains("AND Status = N'Pending'", StringComparison.Ordinal), "Candidate save/update should not overwrite already reviewed author decisions.");
Assert(service.Contains("string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending", StringComparison.Ordinal), "Narrative import should only apply pending candidates.");
}
static void WholeBookPlotIntelligenceProductionCodeRemainsFixtureAgnostic()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var productionFiles = new[]
{
"Services/WholeBookPlotIntelligenceService.cs",
"Data/WholeBookPlotIntelligenceRepository.cs",
"Views/Onboarding/StoryIntelligenceAuthorPlotContext.cshtml",
"Views/Onboarding/StoryIntelligenceReviewCentre.cshtml",
"Views/Onboarding/StoryIntelligenceNarrative.cshtml",
"Sql/174_Phase27_WholeBookPlotIntelligence.sql"
};
var forbidden = new[] { "Memory Tin", "Triumph TR6", "Mrs Patterson", "Maggie", "Beth", "Rob", "Rosie", "Sophie", "Cindy", "Rick", "Alpha Flame" };
foreach (var relativePath in productionFiles)
{
var text = File.ReadAllText(Path.Combine(root, relativePath));
foreach (var term in forbidden)
{
Assert(!text.Contains(term, StringComparison.OrdinalIgnoreCase), $"Whole-book production file {relativePath} must not contain fixture term {term}.");
}
}
}
static string InvokeWholeBookPrompt(WholeBookPlotSynthesisContext context)
{
var method = typeof(WholeBookPlotIntelligenceService).GetMethod(
"BuildPrompt",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
return (string)(method?.Invoke(null, [context, true]) ?? throw new InvalidOperationException("Whole-book prompt builder was not found."));
}
static void StoryIntelligenceSceneCallUsesStrictStructuredOutput()
{
string? requestBody = null;
var client = BuildStoryIntelligenceClient(request =>
{
requestBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
return OpenAiSuccessResponse("""{"schemaVersion":"1.0"}""");
});
client.ExecutePromptAsync(
"Analyse this scene.",
"Scene-Prompt-V2",
CancellationToken.None,
"gpt-5-mini",
128,
StoryIntelligenceSceneStructuredOutputSchema.Contract)
.GetAwaiter()
.GetResult();
using var document = JsonDocument.Parse(requestBody ?? throw new InvalidOperationException("No request body captured."));
var root = document.RootElement;
var format = root.GetProperty("text").GetProperty("format");
Assert(root.GetProperty("model").GetString() == "gpt-5-mini", "Structured scene request should retain the selected scene model.");
Assert(format.GetProperty("type").GetString() == "json_schema", "Scene request should use Responses API text.format json_schema.");
Assert(format.GetProperty("name").GetString() == StoryIntelligenceSceneStructuredOutputSchema.ContractName, "Scene request should use the scene intelligence contract name.");
Assert(format.GetProperty("strict").GetBoolean(), "Scene request should enable strict structured output.");
Assert(format.GetProperty("schema").GetProperty("additionalProperties").GetBoolean() == false, "Root schema must be closed.");
var runner = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/PersistedStoryIntelligenceRunner.cs"));
Assert(runner.Contains("StoryIntelligenceSceneStructuredOutputSchema.Contract", StringComparison.Ordinal), "Persisted scene analysis should pass the strict scene contract.");
Assert(runner.Contains("var retryClientResult = await client.ExecutePromptAsync(", StringComparison.Ordinal), "Legacy JSON repair fallback should remain present.");
}
static void StoryIntelligenceSceneSchemaConstrainsConfidenceNumbers()
{
var schema = SceneSchemaRoot();
var properties = schema.GetProperty("properties");
AssertConfidence(properties.GetProperty("scenePurpose").GetProperty("properties").GetProperty("confidence"), "scenePurpose.confidence");
AssertConfidence(properties.GetProperty("pointOfView").GetProperty("properties").GetProperty("confidence"), "pointOfView.confidence");
AssertConfidence(properties.GetProperty("setting").GetProperty("properties").GetProperty("confidence"), "setting.confidence");
AssertConfidence(ArrayItem(properties.GetProperty("characters")).GetProperty("properties").GetProperty("confidence"), "characters.confidence");
AssertConfidence(ArrayItem(properties.GetProperty("relationships")).GetProperty("properties").GetProperty("confidence"), "relationships.confidence");
AssertConfidence(ArrayItem(properties.GetProperty("knowledgeChanges")).GetProperty("properties").GetProperty("confidence"), "knowledgeChanges.confidence");
AssertConfidence(properties.GetProperty("temporalAnalysis").GetProperty("properties").GetProperty("confidence"), "temporalAnalysis.confidence");
AssertConfidence(ArrayItem(properties.GetProperty("observations")).GetProperty("properties").GetProperty("confidence"), "observations.confidence");
var schemaText = JsonSerializer.Serialize(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);
Assert(!schemaText.Contains("0. nine", StringComparison.OrdinalIgnoreCase), "Production schema must not patch the observed malformed confidence phrase.");
}
static void StoryIntelligenceSceneSchemaConstrainsEvidenceStrings()
{
var properties = SceneSchemaRoot().GetProperty("properties");
AssertString(properties.GetProperty("pointOfView").GetProperty("properties").GetProperty("evidence"), "pointOfView.evidence", nullable: true);
AssertString(ArrayItem(properties.GetProperty("relationships")).GetProperty("properties").GetProperty("evidence"), "relationships.evidence");
AssertString(ArrayItem(properties.GetProperty("knowledgeChanges")).GetProperty("properties").GetProperty("evidence"), "knowledgeChanges.evidence");
AssertString(ArrayItem(properties.GetProperty("timelineClues")).GetProperty("properties").GetProperty("evidence"), "timelineClues.evidence");
AssertString(properties.GetProperty("temporalAnalysis").GetProperty("properties").GetProperty("evidence"), "temporalAnalysis.evidence", nullable: true);
AssertString(ArrayItem(properties.GetProperty("narrativeArcs")).GetProperty("properties").GetProperty("evidence"), "narrativeArcs.evidence");
AssertString(ArrayItem(properties.GetProperty("questionsRaised")).GetProperty("properties").GetProperty("evidence"), "questionsRaised.evidence");
AssertString(ArrayItem(properties.GetProperty("questionsAnswered")).GetProperty("properties").GetProperty("evidence"), "questionsAnswered.evidence");
AssertString(ArrayItem(properties.GetProperty("observations")).GetProperty("properties").GetProperty("evidence"), "observations.evidence");
var schemaText = JsonSerializer.Serialize(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);
Assert(!schemaText.Contains("Her mum was late", StringComparison.OrdinalIgnoreCase), "Production schema must not patch the observed malformed evidence sentence.");
}
static void StoryIntelligenceTemporalResolvesChapterAnchorAndAfternoon()
{
var context = StoryIntelligenceTemporalResolver.CreateChapterContext("4 Oct 1983");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(partOfDay: "afternoon", transition: "later", evidence: "later that afternoon", confidence: 0.8m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 4), "Later that afternoon should inherit the chapter anchor date.");
Assert(result.TimeModeName == "Exact Date", "Broad afternoon should not become an exact datetime.");
Assert(result.RelativeTimeText == "04 Oct 1983, afternoon", "Part of day should be retained without fake clock time.");
}
static void StoryIntelligenceTemporalRollsOverAfterMidnight()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), "evening");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(partOfDay: "night", crossesMidnight: true, evidence: "after midnight", confidence: 0.9m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 5), "After midnight should advance to the next calendar day.");
}
static void StoryIntelligenceTemporalResolvesFollowingMorning()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 5), "evening");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(partOfDay: "morning", transition: "following", evidence: "the following morning", confidence: 0.8m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 6), "Following morning should advance from the previous scene date.");
Assert(result.RelativeTimeText == "06 Oct 1983, morning", "Morning should remain a broad part-of-day label.");
}
static void StoryIntelligenceTemporalResolvesExactDayOffsets()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(amount: 3, unit: "day", evidence: "three days later", confidence: 0.9m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 7), "Three days later should resolve deterministically from the previous date.");
}
static void StoryIntelligenceTemporalResolvesFollowingWeekday()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(transition: "following", targetDay: "Friday", evidence: "the following Friday", confidence: 0.9m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 7), "Following Friday from Tuesday 4 Oct 1983 should resolve to Friday 7 Oct 1983.");
}
static void StoryIntelligenceTemporalResolvesEveningClockTime()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), "afternoon");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(exactTime: "eight o'clock in the evening", partOfDay: "evening", evidence: "eight o'clock in the evening", confidence: 0.95m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 4, 20, 0, 0), "Eight o'clock in the evening should resolve to 20:00 when date context exists.");
Assert(result.TimeModeName == "Exact DateTime", "Anchored evening clock time should be exact datetime.");
}
static void StoryIntelligenceTemporalRejectsUnanchoredClockTime()
{
var context = new StoryIntelligenceTemporalContext(null, null, null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(exactTime: "eight o'clock", evidence: "eight o'clock", confidence: 0.8m), context);
Assert(result.StartDateTime is null, "Unanchored eight o'clock should not invent a date or meridiem.");
Assert(result.TimeModeName == "Relative", "Unanchored clock mention should remain relative.");
}
static void StoryIntelligenceTemporalPreservesVagueFewDays()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(evidence: "a few days later", unresolvedReason: "Vague elapsed time; exact number of days is not stated.", confidence: 0.7m), context);
Assert(result.StartDateTime is null, "A few days later should not invent an exact date.");
Assert(result.TimeModeName == "Approximate", "Vague elapsed time should be approximate.");
Assert(result.RelativeTimeText == "a few days later", "Vague wording should be preserved.");
}
static void StoryIntelligenceTemporalImporterPreservesManualChronology()
{
var repository = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Data/StoryIntelligenceResultRepository.cs"));
Assert(repository.Contains("HasExistingChronology(existingScene)", StringComparison.Ordinal), "Import save path should detect existing chronology before saving scenes.");
Assert(repository.Contains("preserveExistingChronology ? existingScene!.StartDateTime : item.StartDateTime", StringComparison.Ordinal), "Import save path should preserve existing scene start date/time.");
}
static void StoryIntelligenceTemporalMaterialisationSavesCanonicalChronology()
{
var materialisation = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceMaterialisationService.cs"));
var resolver = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceTemporalResolver.cs"));
Assert(materialisation.Contains("StoryIntelligenceTemporalResolver.Resolve(parsed, temporalContext)", StringComparison.Ordinal), "Normal materialisation should resolve chronology from persisted scene results.");
Assert(materialisation.Contains("TryApplyTemporalResolution(canonicalScene, temporal, timeModeIds, timeConfidenceIds)", StringComparison.Ordinal), "Normal materialisation should apply chronology to canonical scenes.");
Assert(materialisation.Contains("await scenes.SaveAsync(canonicalScene)", StringComparison.Ordinal), "Normal materialisation should save chronology through the canonical Scene_Save path.");
Assert(materialisation.Contains("!HasExistingChronology(canonicalScene)", StringComparison.Ordinal), "Normal materialisation should preserve existing author-entered chronology.");
Assert(materialisation.Contains("direct?.TemporalAnalysis is not null || direct?.Setting is not null", StringComparison.Ordinal), "Normal materialisation should parse chronology-only scene results.");
Assert(resolver.Contains("StripOrdinalSuffixes", StringComparison.Ordinal), "Temporal resolver should parse ordinal dates such as 24th December.");
Assert(resolver.Contains("TryParseWordClock", StringComparison.Ordinal), "Temporal resolver should parse prose clock times such as ten-thirty-six.");
}
static void StoryIntelligenceTemporalMalformedValuesAreDiscarded()
{
var context = new StoryIntelligenceTemporalContext(null, null, null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(absoluteDate: "not a date", exactTime: "eight", evidence: "not a date at eight", confidence: 1.5m), context);
Assert(result.StartDateTime is null, "Malformed temporal values should not produce chronology.");
Assert(result.TimeConfidenceName == "Estimated", "Malformed confidence should not be treated as confirmed.");
}
static void StoryIntelligenceStructuredMetricValuesValidateAndImport()
{
var validator = new StorySceneValidator();
var scene = RepresentativeStructuredScene();
var validation = validator.Validate(scene);
Assert(validation.IsValid, string.Join("; ", validation.Errors.Select(error => $"{error.Path}: {error.Message}")));
var materialisation = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceMaterialisationService.cs"));
var commit = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceImportCommitService.cs"));
Assert(materialisation.Contains("parsed.MetricValues", StringComparison.Ordinal), "Modern materialisation should normalise strict metricValues for persistence.");
Assert(materialisation.Contains("metric.Key?.Trim()", StringComparison.Ordinal), "Metric normalisation should key values by configured metric key.");
Assert(commit.Contains("StoryIntelligenceMaterialisationService.BuildMetrics", StringComparison.Ordinal), "Legacy admin commit should delegate metric conversion to modern materialisation helpers.");
}
static void StoryIntelligenceStructuredSchemaCoversCompleteResponseModel()
{
var properties = SceneSchemaRoot().GetProperty("properties");
var required = properties.EnumerateObject().Select(property => property.Name).ToHashSet(StringComparer.Ordinal);
var expected = new[]
{
"schemaVersion",
"sceneReference",
"scenePurpose",
"pointOfView",
"setting",
"characters",
"characterAppearance",
"locations",
"assets",
"relationships",
"knowledgeChanges",
"timelineClues",
"temporalAnalysis",
"narrativeArcs",
"questionsRaised",
"questionsAnswered",
"observations",
"metricValues",
"sourceLimits"
};
foreach (var name in expected)
{
Assert(required.Contains(name), $"Strict scene schema is missing {name}.");
}
Assert(ArrayItem(properties.GetProperty("narrativeArcs")).GetProperty("properties").TryGetProperty("matchingExistingThreadId", out _), "Narrative signals should include existing Thread matching.");
Assert(ArrayItem(properties.GetProperty("characterAppearance")).GetProperty("properties").TryGetProperty("eyeColour", out _), "Character appearance schema should include eyeColour.");
Assert(ArrayItem(properties.GetProperty("characterAppearance")).GetProperty("properties").TryGetProperty("height", out _), "Character appearance schema should include height.");
Assert(!properties.TryGetProperty("summary", out _), "Strict scene schema should not reintroduce duplicate factual scene summaries.");
}
static void StoryIntelligenceStructuredSchemaRemainsFixtureAgnostic()
{
var schemaText = JsonSerializer.Serialize(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);
var client = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs"));
var forbidden = new[] { "Memory Tin", "Triumph TR6", "Mrs Patterson", "Maggie", "Beth", "Rob", "Rosie", "Victor", "Alpha Flame" };
foreach (var term in forbidden)
{
Assert(!schemaText.Contains(term, StringComparison.OrdinalIgnoreCase), $"Strict schema must not contain fixture term {term}.");
Assert(!client.Contains(term, StringComparison.OrdinalIgnoreCase), $"OpenAI client path must not contain fixture term {term}.");
}
}
static SceneIntelligenceScene RepresentativeStructuredScene()
=> new()
{
SchemaVersion = "1.0",
SceneReference = new SceneIntelligenceSceneReference
{
ProjectId = 12,
BookId = 34,
ChapterId = 56,
SceneId = 78,
ChapterNumber = 2,
SceneNumber = 4,
SourceLabel = "Chapter 2, Scene 4"
},
ScenePurpose = new SceneIntelligenceScenePurpose { ObservedFunction = "Reveals a missing archive key.", Confidence = 0.86m },
PointOfView = new SceneIntelligencePointOfView { CharacterName = "Iris Vale", NarrativeMode = "first person", Confidence = 0.92m, Evidence = "PlotDirector scene metadata supplies Iris Vale as POV." },
Setting = new SceneIntelligenceSetting { TimeOfDay = "night", DateOrTimeReference = null, LocationName = "Clocktower Archive", LocationType = "named", GenericRoomType = null, ParentLocationHint = null, Confidence = 0.88m },
Characters = [new SceneIntelligenceCharacter { Name = "Iris Vale", RoleInScene = "pointOfView", MentionedOnly = false, Aliases = ["Iris"], Actions = ["finds the archive key"], Confidence = 0.91m, Notes = null }],
CharacterAppearance = [new SceneIntelligenceCharacterAppearance { CanonicalName = "Iris Vale", Aliases = ["Iris"], Title = null, Presentation = "feminine", ExplicitAge = null, InferredAgeBand = "adult", HairColour = null, HairLength = null, HairStyle = null, EyeColour = null, FacialHair = null, Glasses = null, Height = null, Build = null, DistinctiveFeatures = [], Clothing = "raincoat", RoleOrOccupation = "archivist", RelationshipAgeEvidence = null, PronounEvidence = "she", Evidence = "Iris tightened her raincoat.", Confidence = 0.72m, EvidenceKind = "explicit" }],
Locations = [new SceneIntelligenceLocation { Name = "Clocktower Archive", LocationType = "named", GenericRoomType = null, ParentLocationHint = null, PresentInScene = true, MentionedOnly = false, Confidence = 0.9m, Notes = null }],
Assets = [new SceneIntelligenceAsset { Name = "brass archive key", AssetType = "key", Status = "found", OwnerOrHolder = "Iris Vale", MentionedOnly = false, Confidence = 0.87m, Notes = null }],
Relationships = [new SceneIntelligenceRelationship { CharacterA = "Iris Vale", CharacterB = "Jon Calder", RelationshipSignal = "trust", Evidence = "Iris trusts Jon with the key.", Confidence = 0.76m }],
KnowledgeChanges = [new SceneIntelligenceKnowledgeChange { RecipientCharacter = "Iris Vale", KnowledgeItem = "The archive key opens the sealed west cabinet.", ChangeType = "Discovers", SourceCharacter = null, SourceType = "document", Evidence = "The tag reads west cabinet.", Confidence = 0.83m }],
TimelineClues = [new SceneIntelligenceTimelineClue { Clue = "after midnight", RelativeOrder = "after the public lecture", AbsoluteDate = null, Evidence = "The clock had already struck twelve.", Confidence = 0.8m }],
TemporalAnalysis = new SceneIntelligenceTemporalAnalysis { AbsoluteDate = null, ExactTime = null, PartOfDay = "night", RelativeTransition = "after", RelativeAmount = null, RelativeUnit = null, TargetDayOfWeek = null, CrossesMidnight = true, Evidence = "The clock had already struck twelve.", Confidence = 0.8m, UnresolvedReason = null },
NarrativeArcs = [new SceneIntelligenceNarrativeArc { Title = "The sealed cabinet", Description = "Iris moves closer to opening the sealed cabinet.", ArcType = "mystery", Scale = "secondary", ChangeType = "Developed", Significance = "moderate", ContinuityKey = "sealed cabinet", ParentArcTitle = null, MatchingExistingPlotLineId = null, MatchingExistingThreadId = null, Evidence = "The key is labelled for the west cabinet.", Confidence = 0.82m }],
QuestionsRaised = [new SceneIntelligenceQuestionRaised { Question = "What is hidden in the sealed cabinet?", Scope = "asset", Evidence = "The cabinet has been sealed for years.", Confidence = 0.78m }],
QuestionsAnswered = [new SceneIntelligenceQuestionAnswered { Question = "Where is the archive key?", Answer = "Iris finds it behind the ledger shelf.", Evidence = "Iris finds the key behind the ledger shelf.", Confidence = 0.9m }],
Observations = [new SceneIntelligenceObservation { ObservationType = "AssetInteraction", SubjectEntityType = "Character", SubjectName = "Iris Vale", ObjectEntityType = "Asset", ObjectName = "brass archive key", Predicate = "finds", Description = "Iris finds the brass archive key.", Evidence = "Iris finds the key behind the ledger shelf.", Confidence = 0.9m }],
MetricValues = [new SceneIntelligenceMetricValue { Key = "tension", Score = 7, Confidence = 0.74m }],
SourceLimits = new SceneIntelligenceSourceLimits { ContainsAmbiguity = false, AmbiguityNotes = [] }
};
static SceneIntelligenceScene SceneWithTemporal(
string? absoluteDate = null,
string? exactTime = null,
string? partOfDay = null,
string? transition = null,
int? amount = null,
string? unit = null,
string? targetDay = null,
bool? crossesMidnight = null,
string? evidence = null,
string? unresolvedReason = null,
decimal confidence = 0.8m)
=> new()
{
Setting = new SceneIntelligenceSetting { TimeOfDay = partOfDay, DateOrTimeReference = evidence, LocationName = null, LocationType = "unclear", GenericRoomType = null, ParentLocationHint = null, Confidence = confidence },
TimelineClues = string.IsNullOrWhiteSpace(evidence)
? []
: [new SceneIntelligenceTimelineClue { Clue = evidence, RelativeOrder = transition, AbsoluteDate = absoluteDate, Evidence = evidence, Confidence = confidence }],
TemporalAnalysis = new SceneIntelligenceTemporalAnalysis
{
AbsoluteDate = absoluteDate,
ExactTime = exactTime,
PartOfDay = partOfDay,
RelativeTransition = transition,
RelativeAmount = amount,
RelativeUnit = unit,
TargetDayOfWeek = targetDay,
CrossesMidnight = crossesMidnight,
Evidence = evidence,
Confidence = confidence,
UnresolvedReason = unresolvedReason
}
};
static JsonElement SceneSchemaRoot()
=> JsonSerializer.SerializeToElement(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);
static JsonElement ArrayItem(JsonElement arraySchema)
=> arraySchema.GetProperty("items");
static void AssertConfidence(JsonElement confidenceSchema, string path)
{
Assert(confidenceSchema.GetProperty("type").GetString() == "number", $"{path} should be a number.");
Assert(confidenceSchema.GetProperty("minimum").GetInt32() == 0, $"{path} should have minimum 0.");
Assert(confidenceSchema.GetProperty("maximum").GetInt32() == 1, $"{path} should have maximum 1.");
}
static void AssertString(JsonElement stringSchema, string path, bool nullable = false)
{
if (!nullable)
{
Assert(stringSchema.GetProperty("type").GetString() == "string", $"{path} should be a string.");
return;
}
var types = stringSchema.GetProperty("type").EnumerateArray().Select(item => item.GetString()).ToHashSet(StringComparer.Ordinal);
Assert(types.SetEquals(["string", "null"]), $"{path} should be nullable string.");
}
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("\"dbo.PlotLine_Save\"", StringComparison.Ordinal), "Accepted Plot Line candidates should create canonical PlotLines through a stored procedure.");
Assert(service.Contains("\"dbo.PlotThread_Save\"", StringComparison.Ordinal), "Accepted Thread candidates should create canonical PlotThreads through a stored procedure.");
Assert(service.Contains("\"dbo.ThreadEvent_Save\"", StringComparison.Ordinal), "Accepted Thread evidence should create canonical ThreadEvents through a stored procedure.");
Assert(service.Contains("using var transaction = connection.BeginTransaction()", StringComparison.Ordinal), "Narrative acceptance should apply canonical records and candidate decisions transactionally.");
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 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("SceneAnalysisComplete(state)\n ? StoryIntelligenceResumeRoutes.ReviewCentre", StringComparison.Ordinal), "Resume logic should route completed analysis to Review Centre.");
Assert(controller.Contains("StoryIntelligenceNarrative", StringComparison.Ordinal), "Onboarding controller should expose the narrative review stage.");
Assert(controller.Contains("ImportStoryIntelligenceKnowledge", StringComparison.Ordinal) && controller.Contains("RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal), "Knowledge completion should return to Review Centre instead of auto-advancing.");
}
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);
}
}
static void AssertThrows(Action action, string message)
{
try
{
action();
}
catch
{
return;
}
throw new InvalidOperationException(message);
}
static string ExtractMethod(string source, string methodName)
{
var signature = $" {methodName}(";
var signatureIndex = source.IndexOf(signature, StringComparison.Ordinal);
Assert(signatureIndex >= 0, $"{methodName} was not found.");
var bodyStart = source.IndexOf('{', signatureIndex);
Assert(bodyStart >= 0, $"{methodName} body was not found.");
var depth = 0;
for (var index = bodyStart; index < source.Length; index++)
{
if (source[index] == '{')
{
depth++;
}
else if (source[index] == '}')
{
depth--;
if (depth == 0)
{
return source.Substring(signatureIndex, index - signatureIndex + 1);
}
}
}
throw new InvalidOperationException($"{methodName} body was not closed.");
}
static ManuscriptScanPreview BuildSyntheticScanPreview(int chapters, int paragraphsPerChapter, int wordsPerParagraph, bool includeUnicode)
{
var chapterPreviews = new List<ManuscriptScanChapterPreview>();
var paragraphIndex = 0;
var totalWords = 0;
for (var chapterIndex = 1; chapterIndex <= chapters; chapterIndex++)
{
var paragraphTexts = new List<string>();
var paragraphMap = new List<ManuscriptScanParagraphMap>();
var sourceParagraphs = new List<ManuscriptScanSourceParagraph>
{
new()
{
WordParagraphIndex = paragraphIndex,
Text = $"Chapter {chapterIndex}",
IsChapterHeading = true,
IncludeInSourceText = false
}
};
var chapterStart = paragraphIndex;
paragraphIndex++;
for (var paragraphOrdinal = 1; paragraphOrdinal <= paragraphsPerChapter; paragraphOrdinal++)
{
var sentence = includeUnicode && paragraphOrdinal % 17 == 0
? "Rosie's mum Mary said, \"Don't panic\" as the rain tapped against the window."
: "Maggie watched the road while Beth remembered Grace and Rebecca counted the passing houses.";
var text = string.Join(' ', Enumerable.Repeat(sentence, Math.Max(1, wordsPerParagraph / 13)));
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
totalWords += words;
paragraphTexts.Add(text);
paragraphMap.Add(new ManuscriptScanParagraphMap
{
StructuralParagraphOrdinal = paragraphOrdinal,
WordParagraphIndex = paragraphIndex,
Text = text
});
sourceParagraphs.Add(new ManuscriptScanSourceParagraph
{
WordParagraphIndex = paragraphIndex,
Text = text,
IncludeInSourceText = true
});
paragraphIndex++;
}
chapterPreviews.Add(new ManuscriptScanChapterPreview
{
TemporaryChapterKey = $"chapter-{chapterIndex}",
ChapterNumber = chapterIndex,
Title = $"Chapter {chapterIndex}",
WordCount = paragraphTexts.Sum(text => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length),
ChapterText = string.Join(Environment.NewLine + Environment.NewLine, paragraphTexts),
StartPosition = chapterStart,
ParagraphMap = paragraphMap,
SourceParagraphs = sourceParagraphs
});
}
return new ManuscriptScanPreview
{
UserID = 1,
OnboardingID = 100,
ProjectID = 200,
BookID = 300,
Source = ManuscriptScanSources.WordCompanion,
DocumentTitle = "Synthetic full novel",
CompanionDocumentIdentifier = Guid.NewGuid().ToString("N"),
TotalWordCount = totalWords,
ChapterCount = chapterPreviews.Count,
CreatedUtc = DateTime.UtcNow,
Chapters = chapterPreviews
};
}
static List<ManuscriptScanChunk> SplitScanChunks(Guid scanId, int onboardingId, string payload, int chunkSize)
{
var chunks = new List<ManuscriptScanChunk>();
var totalChunks = Math.Max(1, (int)Math.Ceiling(payload.Length / (double)chunkSize));
for (var chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++)
{
var start = chunkIndex * chunkSize;
chunks.Add(new ManuscriptScanChunk
{
ScanID = scanId,
UserID = 1,
OnboardingID = onboardingId,
ChunkIndex = chunkIndex,
TotalChunks = totalChunks,
TotalLength = payload.Length,
Payload = payload.Substring(start, Math.Min(chunkSize, payload.Length - start))
});
}
return chunks;
}
static ManuscriptScanPreviewStore BuildScanStore()
{
var services = new ServiceCollection().BuildServiceProvider();
return new ManuscriptScanPreviewStore(services.GetRequiredService<IServiceScopeFactory>(), NullLogger<ManuscriptScanPreviewStore>.Instance);
}
static byte[] GZip(byte[] payload)
{
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.Fastest))
{
gzip.Write(payload, 0, payload.Length);
}
return output.ToArray();
}
static JsonSerializerOptions ManuscriptScanJsonOptions()
=> new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
static StoryIntelligenceClient BuildStoryIntelligenceClient(Func<HttpRequestMessage, HttpResponseMessage> respond)
=> new(
new HttpClient(new CaptureImageRequestHandler(respond)),
Options.Create(new StoryIntelligenceOptions
{
ApiKey = "test-key",
Model = "test-model",
MaxOutputTokens = 64,
TimeoutSeconds = 1
}),
NullLogger<StoryIntelligenceClient>.Instance);
static HttpResponseMessage OpenAiSuccessResponse(string outputText)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(
JsonSerializer.Serialize(new
{
output = new[]
{
new
{
content = new[]
{
new { type = "output_text", text = outputText }
}
}
},
usage = new
{
input_tokens = 7,
output_tokens = 11
}
}),
Encoding.UTF8,
"application/json")
};
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 FakeStoryPromptRepository : IStoryPromptRepository
{
public Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken)
=> Task.FromResult("Context: {{CHAPTER_CONTEXT_JSON}}\n\nText:\n{{CHAPTER_TEXT}}");
}
internal sealed class FakeStoryPromptVersionService : IStoryPromptVersionService
{
public string GetPromptVersion(string fileName) => "Chapter-Structure-Prompt-V2";
}
internal sealed class FakeStoryIntelligenceClient(string rawResponseText) : IStoryIntelligenceClient
{
public List<string> Prompts { get; } = [];
public Task<StoryIntelligenceClientResult> ExecutePromptAsync(
string completedPrompt,
string promptVersion,
CancellationToken cancellationToken,
string? modelOverride = null,
int? maxOutputTokensOverride = null,
StoryIntelligenceResponseContract? responseContract = null)
{
Prompts.Add(completedPrompt);
return Task.FromResult(new StoryIntelligenceClientResult
{
RawResponseText = rawResponseText,
Model = modelOverride ?? "test-model",
Duration = TimeSpan.FromMilliseconds(1)
});
}
public StoryIntelligenceClientConfigurationStatus GetConfigurationStatus()
=> new()
{
ApiKeyConfigured = true,
Model = "test-model",
ChapterStructureModel = "test-structure-model",
SceneIntelligenceModel = "test-scene-model",
WholeBookPlotSynthesisModel = "test-whole-book-model",
ClientConstructed = true,
ConnectionStatus = "Configured"
};
}
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
});
}