2694 lines
235 KiB
C#
2694 lines
235 KiB
C#
using System.Text.Json;
|
|
using System.Net;
|
|
using System.Text;
|
|
using Microsoft.AspNetCore.Http.Metadata;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using PlotLine.Controllers;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using PlotLine.Models;
|
|
using PlotLine.Services;
|
|
using PlotLine.ViewModels;
|
|
|
|
#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),
|
|
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput),
|
|
("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups),
|
|
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
|
|
("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases),
|
|
("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations),
|
|
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
|
|
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
|
|
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
|
|
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
|
|
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
|
|
("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
|
|
("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys),
|
|
("Illustration prompt builder separates spec and prompt", IllustrationPromptBuilderSeparatesSpecAndPrompt),
|
|
("Illustration prompt builder uses realistic image direction", IllustrationPromptBuilderUsesRealisticImageDirection),
|
|
("Illustration specification validation catches missing typed fields", IllustrationSpecificationValidationCatchesMissingTypedFields),
|
|
("Illustration category validation rejects unknown values", IllustrationCategoryValidationRejectsUnknownValues),
|
|
("Illustration status transitions protect approval flow", IllustrationStatusTransitionsProtectApprovalFlow),
|
|
("Illustration status transitions support archive flow", IllustrationStatusTransitionsSupportArchiveFlow),
|
|
("Illustration metadata flags fallback location labels", IllustrationMetadataFlagsFallbackLocationLabels),
|
|
("Illustration location matching rejects vehicle living room reuse", IllustrationLocationMatchingRejectsVehicleLivingRoomReuse),
|
|
("Illustration automatic generation policy blocks vague fallbacks", IllustrationAutomaticGenerationPolicyBlocksVagueFallbacks),
|
|
("Story Memory import image generation defaults are safe", StoryMemoryImportImageGenerationDefaultsAreSafe),
|
|
("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely),
|
|
("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts),
|
|
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
|
|
("Story Intelligence visualisation archive is preserved", StoryIntelligenceVisualisationArchiveIsPreserved),
|
|
("Story Intelligence visualisation runtime is removed", StoryIntelligenceVisualisationRuntimeIsRemoved),
|
|
("Story Intelligence does not retry exhausted billing quota", StoryIntelligenceDoesNotRetryExhaustedBillingQuota),
|
|
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
|
|
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
|
|
("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel),
|
|
("Illustration provider omits GPT image response format", IllustrationProviderOmitsGptImageResponseFormat),
|
|
("Illustration matching rejects adult masculine unknown art for Beth", IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth),
|
|
("Illustration matching treats Mrs title as hard feminine evidence", IllustrationMatchingTreatsMrsTitleAsHardFeminineEvidence),
|
|
("Illustration matching infers mother and aunt as mature feminine", IllustrationMatchingInfersMotherAndAuntAsMatureFeminine),
|
|
("Illustration matching rejects duplicate named portrait allocation", IllustrationMatchingRejectsDuplicateNamedPortraitAllocation),
|
|
("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches),
|
|
("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate),
|
|
("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage),
|
|
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
|
|
("Lean import removes replay links while retaining progress", LeanImportRemovesReplayLinksWhileRetainingProgress),
|
|
("Story Intelligence progress does not prematurely report safe close", StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose),
|
|
("Structural import creates scenes before Word sync", StructuralImportCreatesScenesBeforeWordSync),
|
|
("Structural import 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 deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters),
|
|
("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters),
|
|
("Core import remaps characters after review", CoreImportRemapsCharactersAfterReview),
|
|
("Scan review separates known matches from unresolved names", ScanReviewSeparatesKnownMatchesFromUnresolvedNames),
|
|
("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart),
|
|
("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement),
|
|
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes),
|
|
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
|
|
("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
|
|
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
|
|
("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates),
|
|
("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords),
|
|
("Character discovery rejects fragments and location names", CharacterDiscoveryRejectsFragmentsAndLocationNames),
|
|
("Manuscript scan preview keeps only auto-selected character candidates", ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates),
|
|
("Onboarding wizard keeps character pages inside one main step", OnboardingWizardKeepsCharacterPagesInsideOneMainStep),
|
|
("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext),
|
|
("Name Intelligence package contract is present", NameIntelligencePackageContractIsPresent),
|
|
("Name Intelligence reference data is copied on publish", NameIntelligenceReferenceDataIsCopiedOnPublish),
|
|
("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures),
|
|
("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired),
|
|
("Name Intelligence library defaults to all locales", NameIntelligenceLibraryDefaultsToAllLocales),
|
|
("Story Intelligence character defaults link proposed existing matches", StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches),
|
|
("Story Intelligence character defaults ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences),
|
|
("Story Intelligence character defaults keep descriptive identities", StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities),
|
|
("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch),
|
|
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
|
|
("Story Intelligence character review reuses structured appearance aliases", StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases),
|
|
("Story Intelligence review evidence includes chapter scene labels", StoryIntelligenceReviewEvidenceIncludesChapterSceneLabels),
|
|
("Story Intelligence import enables project metric defaults before save", StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave),
|
|
("Project metrics add defaults backfills scene values", ProjectMetricsAddDefaultsBackfillsSceneValues),
|
|
("Story Intelligence scene prompt uses configured metrics", StoryIntelligenceScenePromptUsesConfiguredMetrics),
|
|
("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines),
|
|
("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys),
|
|
("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview),
|
|
("Story Intelligence discovers Plot Lines without preconfiguration", StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration),
|
|
("Story Intelligence matches existing Plot Lines without duplicates", StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates),
|
|
("Story Intelligence narrative review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs),
|
|
("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge),
|
|
("Word Companion replays live current scene to writing panes", WordCompanionReplaysLiveCurrentSceneToWritingPanes),
|
|
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
|
("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions),
|
|
("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation),
|
|
("StoryPosition derives canonical scene context identity", StoryPositionDerivesCanonicalSceneContextIdentity),
|
|
("Writing Context route uses canonical story context service", WritingContextRouteUsesCanonicalStoryContextService),
|
|
("Writing Context client follows canonical story position events", WritingContextClientFollowsCanonicalStoryPositionEvents),
|
|
("Writing Context preserves selected tab and avoids event loops", WritingContextPreservesSelectedTabAndAvoidsEventLoops),
|
|
("Writing Intent route uses canonical story context and planning data", WritingIntentRouteUsesCanonicalStoryContextAndPlanningData),
|
|
("Writing Intent client uses shared writing follow helper", WritingIntentClientUsesSharedWritingFollowHelper),
|
|
("Writing mode pages are isolated from global Word navigation", WritingModePagesAreIsolatedFromGlobalWordNavigation),
|
|
("Writing pair shares canonical follow infrastructure", WritingPairSharesCanonicalFollowInfrastructure),
|
|
("Writing workspace presence scopes browser windows", WritingWorkspacePresenceScopesBrowserWindows),
|
|
("Writing workspace client uses session window identity and stable names", WritingWorkspaceClientUsesSessionIdentityAndStableNames),
|
|
("Writing workspace launcher uses progressive screen placement", WritingWorkspaceLauncherUsesProgressiveScreenPlacement),
|
|
("Writing workspace pages register roles without changing follow behavior", WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior),
|
|
("Word Companion exposes writing workspace launch actions", WordCompanionExposesWritingWorkspaceLaunchActions),
|
|
("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers),
|
|
("Word Companion canonical markers drive runtime follow", WordCompanionCanonicalMarkersDriveRuntimeFollow),
|
|
("Word Companion existing project book linking remains available", WordCompanionExistingProjectBookLinkingRemainsAvailable),
|
|
("Word Companion document identity survives reconnect", WordCompanionDocumentIdentitySurvivesReconnect),
|
|
("Word Companion multiscreen launch and follow remain reachable", WordCompanionMultiscreenLaunchAndFollowRemainReachable),
|
|
("Word Companion modes remain distinct", WordCompanionModesRemainDistinct),
|
|
("Writing pages do not require Story Intelligence", WritingPagesDoNotRequireStoryIntelligence),
|
|
("Word Companion host script has valid tab mapping", WordCompanionHostScriptHasValidTabMapping),
|
|
("Writing screens show diagnostic empty states", WritingScreensShowDiagnosticEmptyStates),
|
|
("Word Companion three-screen flow logs connection identifiers", WordCompanionThreeScreenFlowLogsConnectionIdentifiers),
|
|
("Admin Story Intelligence reset returns to diagnostics with messages", AdminStoryIntelligenceResetReturnsToDiagnosticsWithMessages),
|
|
("Project hard delete clears Story Intelligence dependencies in transaction", ProjectHardDeleteClearsStoryIntelligenceDependenciesInTransaction),
|
|
("Permanent project delete keeps archive and name safety gates", PermanentProjectDeleteKeepsArchiveAndNameSafetyGates),
|
|
("Word manuscript cleanup classifies PlotDirector markers by tag only", WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly),
|
|
("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent),
|
|
("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink),
|
|
("Word manuscript unlink keeps Story Intelligence data untouched", WordManuscriptUnlinkKeepsStoryIntelligenceDataUntouched),
|
|
("Word manuscript cleanup remains relinkable and idempotent", WordManuscriptCleanupRemainsRelinkableAndIdempotent)
|
|
};
|
|
|
|
foreach (var test in tests)
|
|
{
|
|
test.Test();
|
|
Console.WriteLine($"PASS {test.Name}");
|
|
}
|
|
|
|
static void AdminStoryIntelligenceResetReturnsToDiagnosticsWithMessages()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/AdminController.cs"));
|
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceDevelopmentResetService.cs"));
|
|
|
|
Assert(controller.Contains("try", StringComparison.Ordinal)
|
|
&& controller.Contains("catch (Exception ex)", StringComparison.Ordinal), "Reset action should convert failures into a diagnostics-page error.");
|
|
Assert(controller.Contains("TempData[result.Success ? \"AdminMessage\" : \"AdminError\"]", StringComparison.Ordinal), "Reset action should show a clear success or error message.");
|
|
Assert(controller.Contains("return RedirectToAction(nameof(StoryIntelligenceDiagnostics))", StringComparison.Ordinal), "Reset action should return to the stable diagnostics route.");
|
|
Assert(service.Contains("IProjectRepository projects", StringComparison.Ordinal), "Reset should delegate project deletion to the shared hard-delete repository path.");
|
|
Assert(!service.Contains("DELETE FROM dbo.StoryIntelligenceRuns", StringComparison.Ordinal), "Reset should not delete run parents before hard-delete dependency cleanup.");
|
|
}
|
|
|
|
static void ProjectHardDeleteClearsStoryIntelligenceDependenciesInTransaction()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
|
|
|
|
Assert(repository.Contains("using var transaction = connection.BeginTransaction()", StringComparison.Ordinal), "Project hard delete should be wrapped by a repository transaction.");
|
|
Assert(repository.Contains("ProjectHardDeleteStoryIntelligenceDependencyCleanupSql", StringComparison.Ordinal), "Project hard delete should run scoped Story Intelligence dependency cleanup before the stored procedure.");
|
|
Assert(repository.Contains("AllowActiveProject = false", StringComparison.Ordinal), "Archived-project permanent delete must refuse active projects before cleanup.");
|
|
Assert(repository.Contains("THROW 52002, 'Only archived projects can be permanently deleted.'", StringComparison.Ordinal), "Hard delete cleanup should enforce the archive gate before deleting child rows.");
|
|
Assert(repository.Contains("CharacterIntelligenceEvidenceEvents", StringComparison.Ordinal), "Hard delete cleanup should remove character intelligence evidence rows.");
|
|
Assert(repository.Contains("CharacterIntelligenceProfiles", StringComparison.Ordinal), "Hard delete cleanup should remove character intelligence profile rows.");
|
|
Assert(repository.Contains("StoryMemoryProcessedSceneResults", StringComparison.Ordinal), "Hard delete cleanup should remove durable story memory scene-result rows.");
|
|
Assert(repository.Contains("StoryMemoryImportPreferences", StringComparison.Ordinal), "Hard delete cleanup should remove durable story memory import rows.");
|
|
Assert(repository.Contains("WHERE ProjectID = @ProjectID", StringComparison.Ordinal), "Hard delete cleanup must be scoped by project id.");
|
|
}
|
|
|
|
static void PermanentProjectDeleteKeepsArchiveAndNameSafetyGates()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/ProjectsController.cs"));
|
|
var service = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
|
|
|
|
Assert(controller.Contains("GetArchivedOwnerProjectAsync(projectId)", StringComparison.Ordinal), "Permanent delete should require an archived owner project.");
|
|
Assert(controller.Contains("string.Equals(confirmationName?.Trim(), project.ProjectName, StringComparison.Ordinal)", StringComparison.Ordinal), "Permanent delete should require an exact project-name confirmation.");
|
|
Assert(service.Contains("return ProjectHardDeleteResult.ActiveProject()", StringComparison.Ordinal), "Permanent delete should reject non-archived projects.");
|
|
Assert(service.Contains("Blocking database dependency", StringComparison.Ordinal), "Permanent delete failures should report the blocking database dependency.");
|
|
}
|
|
|
|
static void RepairsTrailingDecimalConfidence()
|
|
{
|
|
var result = StoryIntelligenceJsonRepair.Repair("""{"observations":[{"confidence":0. }]}""");
|
|
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
|
|
Assert(result.RepairedJson.Contains("\"confidence\":0.0", StringComparison.Ordinal), result.RepairedJson);
|
|
}
|
|
|
|
static void StoryPositionRejectsInvalidRuntimeScenePositions()
|
|
{
|
|
Assert(StoryPosition.FromRuntimeCurrentScene(new WordCompanionRuntimeCurrentSceneRequest
|
|
{
|
|
DocumentGuid = Guid.NewGuid(),
|
|
ProjectId = 1,
|
|
BookId = 2,
|
|
ChapterId = 3,
|
|
SceneId = 0
|
|
}) is null, "Runtime scene positions require a valid SceneId.");
|
|
|
|
Assert(StoryPosition.FromTimelineSelection(0, 2, 3, 4) is null, "Story positions require a valid ProjectId.");
|
|
}
|
|
|
|
static void StoryPositionBuildsCanonicalRuntimeSceneNavigation()
|
|
{
|
|
var response = new WordCompanionRuntimeCurrentSceneResponse
|
|
{
|
|
ProjectId = 10,
|
|
BookId = 20,
|
|
ChapterId = 30,
|
|
SceneId = 40,
|
|
SceneUrl = "/Scenes/Edit/40"
|
|
};
|
|
|
|
var position = response.Position;
|
|
Assert(position is not null, "Runtime response should expose a canonical StoryPosition.");
|
|
Assert(position!.ProjectId == 10 && position.BookId == 20 && position.ChapterId == 30 && position.SceneId == 40, "Runtime response position IDs were not preserved.");
|
|
Assert(position.SceneEditUrl == "/Scenes/Edit/40", "Scene positions must use the canonical Scene Edit route.");
|
|
}
|
|
|
|
static void StoryPositionDerivesCanonicalSceneContextIdentity()
|
|
{
|
|
var model = new SceneEditViewModel
|
|
{
|
|
SceneID = 7,
|
|
ChapterID = 6,
|
|
Project = new Project { ProjectID = 4 },
|
|
Book = new Book { BookID = 5 },
|
|
Chapter = new Chapter { ChapterID = 6 }
|
|
};
|
|
|
|
var position = StoryPosition.FromScene(model);
|
|
Assert(position is not null, "Scene workspace should expose a canonical StoryPosition.");
|
|
Assert(position!.ProjectId == 4 && position.BookId == 5 && position.ChapterId == 6 && position.SceneId == 7, "Scene workspace position IDs were not preserved.");
|
|
}
|
|
|
|
static void WritingContextRouteUsesCanonicalStoryContextService()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs"));
|
|
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
|
|
|
|
Assert(controller.Contains("[Route(\"Writing\")", StringComparison.Ordinal), "Writing Context controller should expose the /Writing route prefix.");
|
|
Assert(controller.Contains("[HttpGet(\"Context\")", StringComparison.Ordinal), "Writing Context shell route is missing.");
|
|
Assert(controller.Contains("[HttpGet(\"Context/Scene\")", StringComparison.Ordinal), "Writing Context partial route is missing.");
|
|
Assert(controller.Contains("IStoryContextService storyContext", StringComparison.Ordinal), "Writing Context must consume IStoryContextService.");
|
|
Assert(controller.Contains("storyContext.GetForSceneAsync", StringComparison.Ordinal), "Writing Context scene requests must load canonical story context.");
|
|
Assert(!controller.Contains("ISceneService", StringComparison.Ordinal), "Writing Context should not bypass canonical story context with ISceneService.");
|
|
Assert(viewModel.Contains("StoryContextViewModel? Context", StringComparison.Ordinal), "Writing Context display model should carry canonical story context.");
|
|
}
|
|
|
|
static void WritingContextClientFollowsCanonicalStoryPositionEvents()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
|
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
|
|
var view = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml"));
|
|
|
|
Assert(view.Contains("data-writing-context", StringComparison.Ordinal), "Writing Context shell should expose a stable script root.");
|
|
Assert(view.Contains("data-writing-mode=\"context\"", StringComparison.Ordinal), "Writing Context should identify itself as a writing-mode page.");
|
|
Assert(view.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Writing Context should load the shared writing follow helper.");
|
|
Assert(initializer.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Context should initialize the shared writing follow helper.");
|
|
Assert(script.Contains("window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Writing Context must subscribe to canonical browser story-position events.");
|
|
Assert(script.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Writing Context must bridge SignalR story-position events.");
|
|
Assert(script.Contains("fetch(url", StringComparison.Ordinal), "Writing Context should refresh context content without full page navigation.");
|
|
Assert(initializer.Contains("[data-writing-context-scene-select]", StringComparison.Ordinal), "Writing Context manual fallback selector should be wired.");
|
|
Assert(script.Contains("publishChanged", StringComparison.Ordinal), "Manual scene selection should publish canonical story-position changes.");
|
|
}
|
|
|
|
static void WritingContextPreservesSelectedTabAndAvoidsEventLoops()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
|
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
|
|
|
|
Assert(initializer.Contains("plotdirector.writingContext.activeTab", StringComparison.Ordinal), "Writing Context should remember the selected tab.");
|
|
Assert(script.Contains("activateTab(tab)", StringComparison.Ordinal), "Writing Context should restore the selected tab after content refresh.");
|
|
Assert(script.Contains("position.sceneId === currentSceneId", StringComparison.Ordinal), "Writing Context should ignore duplicate current-scene events.");
|
|
Assert(script.Contains("position.sceneId === loadingSceneId", StringComparison.Ordinal), "Writing Context should ignore duplicate in-flight scene events.");
|
|
Assert(!script.Contains("window.location.assign", StringComparison.Ordinal) && !script.Contains("window.location.href", StringComparison.Ordinal), "Writing Context must not navigate away when Word changes scene.");
|
|
}
|
|
|
|
static void WritingIntentRouteUsesCanonicalStoryContextAndPlanningData()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs"));
|
|
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
|
|
var contentView = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml"));
|
|
|
|
Assert(controller.Contains("[HttpGet(\"Intent\")", StringComparison.Ordinal), "Writing Intent shell route is missing.");
|
|
Assert(controller.Contains("[HttpGet(\"Intent/Scene\")", StringComparison.Ordinal), "Writing Intent partial route is missing.");
|
|
Assert(controller.Contains("IStoryContextService storyContext", StringComparison.Ordinal), "Writing Intent must share canonical story context service injection.");
|
|
Assert(controller.Contains("storyContext.GetForSceneAsync", StringComparison.Ordinal), "Writing Intent scene requests must use canonical story context.");
|
|
Assert(controller.Contains("IWriterWorkspaceService writerWorkspace", StringComparison.Ordinal), "Writing Intent should reuse existing writer planning data.");
|
|
Assert(controller.Contains("writerWorkspace.GetChapterWorkflowAsync", StringComparison.Ordinal), "Writing Intent should load chapter workflow planning data.");
|
|
Assert(controller.Contains("projectId.HasValue && chapterWorkflow.Project.ProjectID != projectId.Value", StringComparison.Ordinal), "Writing Intent should reject mismatched project scope.");
|
|
Assert(controller.Contains("bookId.HasValue && chapterWorkflow.Book.BookID != bookId.Value", StringComparison.Ordinal), "Writing Intent should reject mismatched book scope.");
|
|
Assert(viewModel.Contains("public sealed class WritingIntentDisplayViewModel", StringComparison.Ordinal), "Writing Intent display model is missing.");
|
|
Assert(viewModel.Contains("StoryContextViewModel? Context", StringComparison.Ordinal), "Writing Intent display should carry canonical story context.");
|
|
Assert(viewModel.Contains("ChapterWorkflowViewModel? ChapterWorkflow", StringComparison.Ordinal), "Writing Intent display should carry chapter planning workflow.");
|
|
Assert(contentView.Contains("No current scene. Showing chapter intent.", StringComparison.Ordinal), "Writing Intent should support chapter-only fallback.");
|
|
Assert(contentView.Contains("No scene-specific plan. Showing chapter intent.", StringComparison.Ordinal), "Writing Intent should prefer chapter intent when scene planning is sparse.");
|
|
}
|
|
|
|
static void WritingIntentClientUsesSharedWritingFollowHelper()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var helper = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
|
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-intent.js"));
|
|
var view = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml"));
|
|
var contentView = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml"));
|
|
|
|
Assert(view.Contains("data-writing-intent", StringComparison.Ordinal), "Writing Intent shell should expose a stable script root.");
|
|
Assert(view.Contains("data-writing-mode=\"intent\"", StringComparison.Ordinal), "Writing Intent should identify itself as a writing-mode page.");
|
|
Assert(view.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Writing Intent should load the shared writing follow helper.");
|
|
Assert(initializer.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Intent should initialize the shared helper.");
|
|
Assert(initializer.Contains("plotdirector.writingIntent.activeTab", StringComparison.Ordinal), "Writing Intent should remember the selected tab.");
|
|
Assert(initializer.Contains("defaultEndpoint: \"/Writing/Intent/Scene\"", StringComparison.Ordinal), "Writing Intent should refresh from its partial route.");
|
|
Assert(initializer.Contains("allowChapterOnly: true", StringComparison.Ordinal), "Writing Intent should accept canonical chapter-only positions.");
|
|
Assert(helper.Contains("window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Writing Intent should receive canonical story-position events through the shared helper.");
|
|
Assert(helper.Contains("fetch(url", StringComparison.Ordinal), "Writing Intent should update in place.");
|
|
Assert(helper.Contains("position.sceneId === currentSceneId", StringComparison.Ordinal), "Writing Intent should ignore duplicate current-scene events.");
|
|
Assert(helper.Contains("position.sceneId === loadingSceneId", StringComparison.Ordinal), "Writing Intent should ignore duplicate in-flight events.");
|
|
Assert(helper.Contains("position.bookId && position.bookId !== scopedBookId", StringComparison.Ordinal), "Writing Intent should ignore unrelated scoped book events.");
|
|
Assert(helper.Contains("publishChanged", StringComparison.Ordinal), "Writing Intent manual fallback should publish canonical story-position changes.");
|
|
Assert(contentView.Contains("data-writing-intent-scene-select", StringComparison.Ordinal), "Writing Intent manual scene selector should be wired.");
|
|
Assert(contentView.Contains("data-writing-intent-chapter-select", StringComparison.Ordinal), "Writing Intent manual chapter selector should be wired.");
|
|
Assert(!helper.Contains("window.location.assign", StringComparison.Ordinal) && !helper.Contains("window.location.href", StringComparison.Ordinal), "Writing Intent must not navigate away when Word changes scene.");
|
|
}
|
|
|
|
static void WritingModePagesAreIsolatedFromGlobalWordNavigation()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var site = File.ReadAllText(Path.Combine(root, "wwwroot/js/site.js"));
|
|
|
|
Assert(site.Contains("document.querySelector(\"[data-writing-mode]\")", StringComparison.Ordinal), "Global Word follow should recognize writing-mode markers.");
|
|
Assert(site.Contains("path === \"/writing/context\"", StringComparison.Ordinal), "Writing Context route should be exempt from global scene navigation.");
|
|
Assert(site.Contains("path === \"/writing/intent\"", StringComparison.Ordinal), "Writing Intent route should be exempt from global scene navigation.");
|
|
Assert(site.Contains("window.location.assign(url)", StringComparison.Ordinal), "Normal pages should retain existing Word follow navigation.");
|
|
Assert(site.Contains("safeRelativeUrl(payload?.sceneUrl || payload?.SceneUrl || \"\", \"/Scenes/Edit/\")", StringComparison.Ordinal), "Normal scene follow should still target canonical Scene Edit URLs.");
|
|
}
|
|
|
|
static void WritingPairSharesCanonicalFollowInfrastructure()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var context = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
|
|
var intent = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-intent.js"));
|
|
var helper = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
|
|
|
Assert(context.Contains("source: \"WritingContext\"", StringComparison.Ordinal), "Writing Context should identify its canonical event source.");
|
|
Assert(intent.Contains("source: \"WritingIntent\"", StringComparison.Ordinal), "Writing Intent should identify its canonical event source.");
|
|
Assert(context.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Context should use shared follow initialization.");
|
|
Assert(intent.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Intent should use shared follow initialization.");
|
|
Assert(helper.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "The shared helper should bridge Word story-position events for both pages.");
|
|
Assert(helper.Contains("document.addEventListener(window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Both pages should react to the same canonical browser event.");
|
|
}
|
|
|
|
static void WritingWorkspacePresenceScopesBrowserWindows()
|
|
{
|
|
var service = new WritingWorkspacePresenceService();
|
|
var word = new WordCompanionPresenceStatus
|
|
{
|
|
UserID = 9,
|
|
IsConnected = true,
|
|
DocumentOpen = true,
|
|
LinkedProjectID = 10,
|
|
LinkedBookID = 20
|
|
};
|
|
|
|
service.RegisterAsync(9, "launcher-1", new WritingWindowRegistration
|
|
{
|
|
BrowserWindowID = "launcher",
|
|
ProjectID = 10,
|
|
BookID = 20,
|
|
WritingRole = WritingWorkspaceRoles.Launcher,
|
|
CurrentRoute = "/Writer"
|
|
}, word).GetAwaiter().GetResult();
|
|
service.RegisterAsync(9, "context-1", new WritingWindowRegistration
|
|
{
|
|
BrowserWindowID = "context",
|
|
ProjectID = 10,
|
|
BookID = 20,
|
|
WritingRole = WritingWorkspaceRoles.Context,
|
|
CurrentRoute = "/Writing/Context"
|
|
}, word).GetAwaiter().GetResult();
|
|
service.RegisterAsync(9, "intent-1", new WritingWindowRegistration
|
|
{
|
|
BrowserWindowID = "intent",
|
|
ProjectID = 10,
|
|
BookID = 20,
|
|
WritingRole = WritingWorkspaceRoles.Intent,
|
|
CurrentRoute = "/Writing/Intent"
|
|
}, word).GetAwaiter().GetResult();
|
|
service.RegisterAsync(9, "other-context", new WritingWindowRegistration
|
|
{
|
|
BrowserWindowID = "other-context",
|
|
ProjectID = 10,
|
|
BookID = 21,
|
|
WritingRole = WritingWorkspaceRoles.Context,
|
|
CurrentRoute = "/Writing/Context"
|
|
}, word).GetAwaiter().GetResult();
|
|
|
|
var status = service.GetStatusAsync(9, new WritingWorkspaceScope { ProjectID = 10, BookID = 20 }, word).GetAwaiter().GetResult();
|
|
Assert(status.WordCompanionConnected && status.WordDocumentOpen, "Writing workspace should include Word Companion presence.");
|
|
Assert(status.ContextOpen && status.IntentOpen, "Matching Context and Intent windows should be open.");
|
|
Assert(status.Context?.BrowserWindowID == "context", "Workspace status should choose the matching book's Context window.");
|
|
Assert(status.Windows.All(window => window.BookID == 20), "Workspace status should not include another book's windows.");
|
|
|
|
service.DisconnectAsync("context-1", word).GetAwaiter().GetResult();
|
|
status = service.GetStatusAsync(9, new WritingWorkspaceScope { ProjectID = 10, BookID = 20 }, word).GetAwaiter().GetResult();
|
|
Assert(!status.ContextOpen && status.IntentOpen, "Disconnected Context should be removed without affecting Intent.");
|
|
}
|
|
|
|
static void WritingWorkspaceClientUsesSessionIdentityAndStableNames()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace.js"));
|
|
var service = File.ReadAllText(Path.Combine(root, "Services/WritingWorkspacePresenceService.cs"));
|
|
|
|
Assert(script.Contains("sessionStorage.getItem(storageKeys.windowId)", StringComparison.Ordinal), "Browser window IDs must come from sessionStorage.");
|
|
Assert(script.Contains("sessionStorage.setItem(storageKeys.windowId", StringComparison.Ordinal), "Browser window IDs must be stored per tab/window.");
|
|
Assert(!script.Contains("localStorage.getItem(storageKeys.windowId)", StringComparison.Ordinal), "Browser window IDs must not be shared through localStorage.");
|
|
Assert(script.Contains("PlotDirector${role}_${suffix}", StringComparison.Ordinal), "Context and Intent windows should use stable named targets.");
|
|
Assert(script.Contains("Book_${bookId}", StringComparison.Ordinal), "Stable writing window names should be book scoped.");
|
|
Assert(script.Contains("windowRefs = new Map()", StringComparison.Ordinal), "Launcher-created window references should be retained for focus.");
|
|
Assert(script.Contains("RequestWritingWindowFocus", StringComparison.Ordinal), "Launcher should fall back to SignalR focus requests.");
|
|
Assert(script.Contains("connection.onreconnected", StringComparison.Ordinal), "Writing workspace windows should re-register after SignalR reconnect.");
|
|
Assert(service.Contains("ActiveTimeout = TimeSpan.FromSeconds(45)", StringComparison.Ordinal) && service.Contains("MarkStaleOfflineAsync", StringComparison.Ordinal), "Writing workspace presence should expire stale windows.");
|
|
}
|
|
|
|
static void WritingWorkspaceLauncherUsesProgressiveScreenPlacement()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace.js"));
|
|
var launcher = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace-launcher.js"));
|
|
var view = File.ReadAllText(Path.Combine(root, "Views/Writer/Index.cshtml"));
|
|
|
|
Assert(script.Contains("getScreenDetails", StringComparison.Ordinal), "Workspace launcher should use the Window Management API when available.");
|
|
Assert(script.Contains("window-management", StringComparison.Ordinal), "Workspace launcher should check/request window-management permission.");
|
|
Assert(script.Contains("sort((a, b) => a.left - b.left", StringComparison.Ordinal), "Screen topology should be derived from physical coordinates.");
|
|
Assert(script.Contains("mode: \"three-screen\"", StringComparison.Ordinal), "Three-screen layout decision is missing.");
|
|
Assert(script.Contains("key: `screen-${index}`", StringComparison.Ordinal), "Detected displays should receive stable option keys.");
|
|
Assert(script.Contains("displayByPreference", StringComparison.Ordinal), "Launcher should resolve logical and physical display preferences.");
|
|
Assert(script.Contains("preferences.contextDisplay", StringComparison.Ordinal), "Context display preference is missing.");
|
|
Assert(script.Contains("preferences.intentDisplay", StringComparison.Ordinal), "Intent display preference is missing.");
|
|
Assert(script.Contains("mode: \"two-screen\"", StringComparison.Ordinal), "Two-screen fallback is missing.");
|
|
Assert(script.Contains("mode: \"single-screen\"", StringComparison.Ordinal), "One-screen fallback is missing.");
|
|
Assert(script.Contains("localStorage.setItem(storageKeys.preferences", StringComparison.Ordinal), "Remembered layout preferences are missing.");
|
|
Assert(script.Contains("window.open(url, name, popupFeatures(display))", StringComparison.Ordinal), "Writing windows should be opened from explicit launcher clicks with placement features.");
|
|
Assert(script.Contains("window.open(buildWritingUrl(role, scope), stableWindowName(role, scope), popupFeatures(display))", StringComparison.Ordinal), "Focus actions should open missing windows on the selected display.");
|
|
Assert(script.Contains("getScreenLayout(Boolean(options.requestScreens), options.displayPreferences || {})", StringComparison.Ordinal), "Start action should pass selected display preferences into layout detection.");
|
|
Assert(script.Contains("openRoleWindow(roles.intent, resolvedScope, layout.intentDisplay)", StringComparison.Ordinal), "Start should open Intent on the selected display instead of converting the launcher window.");
|
|
Assert(!script.Contains("window.location.assign(buildWritingUrl(roles.intent", StringComparison.Ordinal), "Start should leave the launcher visible while opening the selected writing windows.");
|
|
Assert(launcher.Contains("requestScreens: true", StringComparison.Ordinal), "Screen permission should be requested from the Start action.");
|
|
Assert(launcher.Contains("workspace.getScreenLayout(true, selectedDisplayPreferences())", StringComparison.Ordinal), "Detect displays action should request physical display details.");
|
|
Assert(launcher.Contains("workspace.focusRole(role, scope, targetDisplay)", StringComparison.Ordinal), "Direct open/focus actions should use the selected display.");
|
|
Assert(view.Contains("data-start-writing-workspace", StringComparison.Ordinal), "Writer dashboard launcher start button is missing.");
|
|
Assert(view.Contains("data-writing-context-display", StringComparison.Ordinal), "Context display selector is missing.");
|
|
Assert(view.Contains("data-writing-intent-display", StringComparison.Ordinal), "Intent display selector is missing.");
|
|
Assert(view.Contains("data-detect-writing-displays", StringComparison.Ordinal), "Detect displays button is missing.");
|
|
Assert(view.Contains("data-focus-writing-context", StringComparison.Ordinal), "Focus Context action is missing.");
|
|
Assert(view.Contains("data-focus-writing-intent", StringComparison.Ordinal), "Focus Intent action is missing.");
|
|
}
|
|
|
|
static void WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var contextView = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml"));
|
|
var intentView = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml"));
|
|
var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
|
|
|
Assert(contextView.Contains("writingRole: window.PlotDirectorWritingWorkspace.roles.context", StringComparison.Ordinal), "Writing Context should register its workspace role.");
|
|
Assert(intentView.Contains("writingRole: window.PlotDirectorWritingWorkspace.roles.intent", StringComparison.Ordinal), "Writing Intent should register its workspace role.");
|
|
Assert(contextView.Contains("data-writing-mode=\"context\"", StringComparison.Ordinal), "Writing Context should remain a writing-mode page.");
|
|
Assert(intentView.Contains("data-writing-mode=\"intent\"", StringComparison.Ordinal), "Writing Intent should remain a writing-mode page.");
|
|
Assert(follow.Contains("registerWorkspaceRole", StringComparison.Ordinal), "Writing follow should refresh presence scope after Word updates.");
|
|
Assert(follow.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Existing canonical Word follow should remain.");
|
|
Assert(!follow.Contains("window.location.assign", StringComparison.Ordinal) && !follow.Contains("window.location.href", StringComparison.Ordinal), "Writing pages must still update in place without navigation.");
|
|
}
|
|
|
|
static void WordCompanionExposesWritingWorkspaceLaunchActions()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
|
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
|
|
|
Assert(view.Contains("data-open-writing-workspace", StringComparison.Ordinal), "Word Companion should expose Open Workspace.");
|
|
Assert(view.Contains("data-open-writing-context", StringComparison.Ordinal), "Word Companion should expose Open Context.");
|
|
Assert(view.Contains("data-open-writing-intent", StringComparison.Ordinal), "Word Companion should expose Open Intent.");
|
|
Assert(script.Contains("writingWindowName", StringComparison.Ordinal), "Word Companion should use stable writing window names.");
|
|
Assert(script.Contains("PlotDirector${role}_${suffix}", StringComparison.Ordinal), "Word Companion writing window names should be scoped.");
|
|
Assert(script.Contains("openWritingWorkspaceButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Workspace button should be wired.");
|
|
Assert(script.Contains("openWritingContextButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Context button should be wired.");
|
|
Assert(script.Contains("openWritingIntentButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Intent button should be wired.");
|
|
}
|
|
|
|
static void WordCompanionWritingScreensReceiveResolvedContextIdentifiers()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs"));
|
|
var contextView = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml"));
|
|
var intentView = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml"));
|
|
|
|
Assert(script.Contains("const chapterId = Number.isInteger(resolvedChapterId)", StringComparison.Ordinal), "Word Companion should prefer resolved chapter IDs for writing screen URLs.");
|
|
Assert(script.Contains("const sceneId = Number.isInteger(resolvedSceneId)", StringComparison.Ordinal), "Word Companion should prefer resolved scene IDs for writing screen URLs.");
|
|
Assert(script.Contains("url.searchParams.set(\"chapterId\", chapterId)", StringComparison.Ordinal), "Writing screen URLs should include chapterId when available.");
|
|
Assert(script.Contains("url.searchParams.set(\"sceneId\", sceneId)", StringComparison.Ordinal), "Writing screen URLs should include sceneId when available.");
|
|
Assert(script.Contains("url.searchParams.set(\"documentGuid\", documentGuid)", StringComparison.Ordinal), "Writing screen URLs should include documentGuid for diagnostics.");
|
|
Assert(controller.Contains("Guid? documentGuid", StringComparison.Ordinal), "Writing routes should accept the Word document identifier.");
|
|
Assert(contextView.Contains("data-document-guid=\"@Model.DocumentGuid\"", StringComparison.Ordinal), "Writing Context should retain the document identifier.");
|
|
Assert(intentView.Contains("data-document-guid=\"@Model.DocumentGuid\"", StringComparison.Ordinal), "Writing Intent should retain the document identifier.");
|
|
}
|
|
|
|
static void WordCompanionCanonicalMarkersDriveRuntimeFollow()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs"));
|
|
var repository = File.ReadAllText(Path.Combine(root, "Data/WordCompanionRepository.cs"));
|
|
|
|
Assert(script.Contains("chapter: \"PD-CHAPTER-\"", StringComparison.Ordinal), "Canonical chapter marker prefix is missing.");
|
|
Assert(script.Contains("scene: \"PD-SCENE-\"", StringComparison.Ordinal), "Canonical scene marker prefix is missing.");
|
|
Assert(script.Contains("const classifyPlotDirectorContentControl", StringComparison.Ordinal), "Word marker classifier is missing.");
|
|
Assert(script.Contains("paragraphAnchorId(paragraph, \"PD-CHAPTER\")", StringComparison.Ordinal), "Document scan should read canonical chapter IDs from Word anchors.");
|
|
Assert(script.Contains("paragraphAnchorId(paragraph, \"PD-SCENE\")", StringComparison.Ordinal), "Document scan should read canonical scene IDs from Word anchors.");
|
|
Assert(script.Contains("detectedSceneAnchorId = Number.isInteger(sceneAnchorId)", StringComparison.Ordinal), "Current scene detection should preserve canonical scene IDs.");
|
|
Assert(script.Contains("postJson(\"/api/word-companion/runtime/current-scene\"", StringComparison.Ordinal), "Word Companion should notify PlotDirector of the runtime current scene.");
|
|
Assert(script.Contains("sceneId: numericSceneId", StringComparison.Ordinal), "Runtime follow should submit the resolved canonical SceneID.");
|
|
Assert(script.Contains("chapterId: numericChapterId", StringComparison.Ordinal), "Runtime follow should submit the resolved canonical ChapterID.");
|
|
Assert(controller.Contains("presence.UpdateCurrentStoryPositionAsync", StringComparison.Ordinal), "Current-scene API should update writing workspace presence.");
|
|
Assert(controller.Contains("BroadcastToCurrentUserAsync(\"storyPositionChanged\"", StringComparison.Ordinal), "Current-scene API should broadcast canonical story-position changes.");
|
|
Assert(repository.Contains("\"dbo.WordCompanion_Runtime_CurrentScene_Validate\"", StringComparison.Ordinal), "Runtime current scene validation must remain stored-procedure based.");
|
|
}
|
|
|
|
static void WordCompanionExistingProjectBookLinkingRemainsAvailable()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
|
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs"));
|
|
var service = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs"));
|
|
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
|
|
|
|
Assert(view.Contains("data-project-select", StringComparison.Ordinal), "Runtime Project selector should remain visible for unbound documents.");
|
|
Assert(view.Contains("data-book-select", StringComparison.Ordinal), "Runtime Book selector should remain visible for unbound documents.");
|
|
Assert(view.Contains("data-first-run-project-select", StringComparison.Ordinal), "Bind flow should let users choose an existing Project.");
|
|
Assert(view.Contains("data-first-run-book-select", StringComparison.Ordinal), "Bind flow should let users choose an existing Book.");
|
|
Assert(view.Contains("data-first-run-link", StringComparison.Ordinal), "Bind flow should expose a direct existing-book link action.");
|
|
Assert(script.Contains("firstRunLinkButton?.addEventListener(\"click\", linkFirstRunManuscript)", StringComparison.Ordinal), "Existing-book link action should be wired.");
|
|
Assert(script.Contains("postJson(\"/api/word-companion/manuscript/link\"", StringComparison.Ordinal), "Existing-book link action should call the link endpoint.");
|
|
Assert(script.Contains("/api/word-companion/projects", StringComparison.Ordinal), "Project selector should load existing Projects.");
|
|
Assert(script.Contains("/api/word-companion/projects/${projectId}/books", StringComparison.Ordinal), "Book selector should load existing Books.");
|
|
Assert(controller.Contains("[HttpPost(\"manuscript/link\")", StringComparison.Ordinal), "Link endpoint is missing.");
|
|
Assert(service.Contains("LinkManuscriptAsync", StringComparison.Ordinal), "Link service method is missing.");
|
|
Assert(service.Contains("manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId())", StringComparison.Ordinal), "Existing-book linking should save a document binding directly.");
|
|
Assert(!service[service.IndexOf("public async Task<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 SharedParserReportsRawOutput()
|
|
{
|
|
const string raw = """{"schemaVersion":"1.0","sceneBoundaries":[""";
|
|
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(raw, JsonOptions());
|
|
Assert(!result.Success, "Unrecoverable JSON should fail.");
|
|
Assert(result.RawJson == raw, "Raw malformed output was not retained.");
|
|
}
|
|
|
|
static void CharacterFilteringRejectsGenericGroups()
|
|
{
|
|
Assert(IsGenericGroupReference("four examiners"), "Quantity + generic group should be rejected.");
|
|
Assert(IsGenericGroupReference("groups of men"), "Generic group phrase should be rejected.");
|
|
Assert(IsGenericGroupReference("neighbours"), "Generic plural group should be rejected.");
|
|
Assert(IsGenericGroupReference("passenger (blue Volkswagen Golf GTi)"), "Unnamed passenger descriptor should be rejected.");
|
|
Assert(IsGenericGroupReference("tubby lad"), "Unnamed descriptive person should be rejected.");
|
|
Assert(IsGenericGroupReference("lady examiner"), "Unnamed role descriptor should be rejected.");
|
|
Assert(IsGenericGroupReference("her mother"), "Determiner + family role should be rejected.");
|
|
Assert(IsGenericGroupReference("the child"), "Determiner + generic person should be rejected.");
|
|
}
|
|
|
|
static void CharacterFilteringPreservesTitledNames()
|
|
{
|
|
Assert(!IsGenericGroupReference("Reverend Potter"), "Reverend Potter should not be treated as generic.");
|
|
Assert(IsNamedOrTitledPerson("Reverend Potter"), "Reverend Potter should be recognised as a titled person.");
|
|
Assert(IsNamedOrTitledPerson("Detective Sergeant Summerhill"), "Detective Sergeant Summerhill should be recognised as a titled person.");
|
|
Assert(IsNamedOrTitledPerson("Mrs Patterson"), "Mrs Patterson should be recognised as a titled person.");
|
|
Assert(IsNamedOrTitledPerson("Aunt Elen"), "Aunt Elen should be recognised as a titled person.");
|
|
}
|
|
|
|
static bool IsGenericGroupReference(string name)
|
|
=> InvokePrivateCharacterFilter("IsGenericGroupReference", name);
|
|
|
|
static bool IsNamedOrTitledPerson(string name)
|
|
=> InvokePrivateCharacterFilter("IsNamedOrTitledPerson", name);
|
|
|
|
static bool InvokePrivateCharacterFilter(string methodName, string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceCharacterImportService).GetMethod(
|
|
methodName,
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, $"{methodName} was not found.");
|
|
return method!.Invoke(null, [name]) is true;
|
|
}
|
|
|
|
static void LocationFilteringRejectsMergedPhrases()
|
|
{
|
|
Assert(IsMergedLocationPhrase("shop and staff room"), "Merged shop/staff room phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("house and garden"), "Merged house/garden phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("road and bridge"), "Merged road/bridge phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("kitchen and hallway"), "Merged room phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("front and rear garden"), "Merged garden phrase should be rejected.");
|
|
Assert(!IsMergedLocationPhrase("Rose and Crown"), "Named pub should not be treated as a merged location.");
|
|
}
|
|
|
|
static void LocationFilteringPreservesStoryLocations()
|
|
{
|
|
Assert(IsStoryLocation("The Doweries"), "The Doweries should be preserved.");
|
|
Assert(IsStoryLocation("Ashdown Trust"), "Ashdown Trust should be preserved.");
|
|
Assert(IsStoryLocation("St Luke's Church"), "St Luke's Church should be preserved.");
|
|
Assert(IsStoryLocation("Mrs Patterson's House"), "Mrs Patterson's House should be preserved.");
|
|
Assert(IsStoryLocation("Bristol Road"), "Named roads should be preserved.");
|
|
Assert(!IsStoryLocation("Kitchen"), "Standalone generic rooms should not be story locations.");
|
|
}
|
|
|
|
static void LocationCanonicalKeysMergeTrivialVariants()
|
|
{
|
|
Assert(LocationCanonicalKey("Kitchen") == LocationCanonicalKey("The Kitchen"), "The Kitchen should fold into Kitchen.");
|
|
Assert(LocationCanonicalKey("Interview room") == LocationCanonicalKey("Interview Room"), "Case-only room variants should merge.");
|
|
Assert(LocationCanonicalKey("Ashdown Trust (reception)") == LocationCanonicalKey("Ashdown Trust"), "Parenthetical variants should merge.");
|
|
}
|
|
|
|
static bool IsMergedLocationPhrase(string name)
|
|
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
|
|
|
|
static bool IsStoryLocation(string name)
|
|
=> InvokePrivateLocationFilter("IsStoryLocation", name);
|
|
|
|
static string LocationCanonicalKey(string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
|
|
"CanonicalLocationKey",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "CanonicalLocationKey was not found.");
|
|
return (string)method!.Invoke(null, [name])!;
|
|
}
|
|
|
|
static bool InvokePrivateLocationFilter(string methodName, string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
|
|
methodName,
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, $"{methodName} was not found.");
|
|
return method!.Invoke(null, [name]) is true;
|
|
}
|
|
|
|
static void AssetFilteringRejectsGenericObjects()
|
|
{
|
|
Assert(!IsAssetNameCandidate("chair"), "Chair should not be treated as a story asset.");
|
|
Assert(!IsAssetNameCandidate("table"), "Table should not be treated as a story asset.");
|
|
Assert(!IsAssetNameCandidate("street"), "Street should not be treated as a story asset.");
|
|
Assert(!IsAssetNameCandidate("car park"), "Car park should not be treated as a story asset.");
|
|
}
|
|
|
|
static void AssetFilteringPreservesStoryAssets()
|
|
{
|
|
Assert(IsAssetNameCandidate("TR6"), "TR6 should be treated as a story asset.");
|
|
Assert(IsAssetNameCandidate("red notebook"), "Red notebook should be treated as a story asset.");
|
|
Assert(IsAssetNameCandidate("passport"), "Passport should be treated as a story asset.");
|
|
Assert(IsAssetNameCandidate("car keys"), "Car keys should be treated as a story asset.");
|
|
Assert(IsAssetNameCandidate("driving licence"), "Driving licence should be treated as a story asset.");
|
|
}
|
|
|
|
static void AssetCanonicalKeysMergeTrivialVariants()
|
|
{
|
|
Assert(AssetCanonicalKey("red notebook") == AssetCanonicalKey("the notebook"), "Notebook variants should merge.");
|
|
Assert(AssetCanonicalKey("photo") == AssetCanonicalKey("photograph"), "Photo should fold into photograph.");
|
|
Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge.");
|
|
}
|
|
|
|
static void RelationshipSignalsMapToBroadTypes()
|
|
{
|
|
Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend.");
|
|
Assert(RelationshipTypeFromSignal("driving instructor", "learner makes progress") == "Educational", "Instructor/learner should map to Educational.");
|
|
Assert(RelationshipTypeFromSignal("aunt", "family visit") == "Family", "Aunt/family should map to Family.");
|
|
Assert(RelationshipTypeFromSignal("boss", "employee at work") == "Professional", "Boss/employee should map to Professional.");
|
|
Assert(RelationshipTypeFromSignal("police", "suspect witness interview") == "Authority", "Police/suspect/witness should map to Authority.");
|
|
Assert(RelationshipTypeFromSignal("neighbour") == "Neighbour", "Neighbour should map to Neighbour.");
|
|
Assert(RelationshipTypeFromSignal("adversarial", "mistrust between them") == "Rival", "Adversarial/mistrust should map to Rival.");
|
|
Assert(RelationshipTypeFromSignal("uncertain emotional connection") == "Unknown", "Uncertain signals should map to Unknown.");
|
|
}
|
|
|
|
static void KnowledgeSignalsMapToExistingStates()
|
|
{
|
|
var states = new List<KnowledgeState>
|
|
{
|
|
new() { KnowledgeStateID = 1, StateName = "Unaware", SortOrder = 10, IsActive = true },
|
|
new() { KnowledgeStateID = 2, StateName = "Suspects", SortOrder = 20, IsActive = true },
|
|
new() { KnowledgeStateID = 3, StateName = "Partially Knows", SortOrder = 30, IsActive = true },
|
|
new() { KnowledgeStateID = 4, StateName = "Knows", SortOrder = 40, IsActive = true },
|
|
new() { KnowledgeStateID = 5, StateName = "Misunderstands", SortOrder = 50, IsActive = true }
|
|
};
|
|
|
|
Assert(KnowledgeStateFromSignal("Discovers", "the letter exists", states) == "Knows", "Discovers should map to Knows.");
|
|
Assert(KnowledgeStateFromSignal("Suspects", "Maggie is lying", states) == "Suspects", "Suspects should map to Suspects.");
|
|
Assert(KnowledgeStateFromSignal("Believes", "Gareth is trustworthy", states) == "Partially Knows", "Believes should map to Partially Knows.");
|
|
Assert(KnowledgeStateFromSignal("Misunderstands", "Beth is dead", states) == "Misunderstands", "Misunderstands should map to Misunderstands.");
|
|
Assert(KnowledgeStateFromSignal("Forgets", "the hiding place", states) == "Unaware", "Forgets should map to Unaware.");
|
|
}
|
|
|
|
static void KnowledgeDuplicateStatementsShareCanonicalKeys()
|
|
{
|
|
var first = CanonicalKnowledgeKey("Simon is Beth's father.");
|
|
var second = CanonicalKnowledgeKey("Beth discovers Simon is her father.");
|
|
var third = CanonicalKnowledgeKey("Beth learns Simon is her biological father.");
|
|
Assert(first == second && second == third, $"Expected duplicate father facts to merge, got {first}, {second}, {third}.");
|
|
}
|
|
|
|
static bool IsAssetNameCandidate(string name)
|
|
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name);
|
|
|
|
static string AssetCanonicalKey(string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
|
|
"CanonicalAssetKey",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "CanonicalAssetKey was not found.");
|
|
return (string)method!.Invoke(null, [name])!;
|
|
}
|
|
|
|
static bool InvokePrivateAssetFilter(string methodName, string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
|
|
methodName,
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, $"{methodName} was not found.");
|
|
return method!.Invoke(null, [name]) is true;
|
|
}
|
|
|
|
static string RelationshipTypeFromSignal(string signal, string? evidence = null)
|
|
{
|
|
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
|
|
"RelationshipTypeFromSignal",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "RelationshipTypeFromSignal was not found.");
|
|
return (string)method!.Invoke(null, [signal, evidence])!;
|
|
}
|
|
|
|
static string KnowledgeStateFromSignal(string changeType, string statement, IReadOnlyList<KnowledgeState> states)
|
|
{
|
|
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
|
|
"KnowledgeStateFromSignal",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "KnowledgeStateFromSignal was not found.");
|
|
return (string)method!.Invoke(null, [changeType, statement, states])!;
|
|
}
|
|
|
|
static string CanonicalKnowledgeKey(string statement)
|
|
{
|
|
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
|
|
"CanonicalKnowledgeKey",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "CanonicalKnowledgeKey was not found.");
|
|
return (string)method!.Invoke(null, [statement])!;
|
|
}
|
|
|
|
static void IllustrationPromptBuilderSeparatesSpecAndPrompt()
|
|
{
|
|
var spec = IllustrationStarterBatchDefinition.All.Single(x => x.Code == "asset-folded-letter");
|
|
var builder = new IllustrationPromptBuilder();
|
|
var result = builder.Build(spec);
|
|
|
|
Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded.");
|
|
Assert(result.TemplateVersion == "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 StructuralImportPersistsParagraphMappingAndSourceText()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs"));
|
|
var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
|
var migration = File.ReadAllText(Path.Combine(root, "Sql/149_Phase24_StructuralImportMarkerSync.sql"));
|
|
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
|
|
|
|
Assert(models.Contains("StructuralParagraphOrdinal", StringComparison.Ordinal), "Scan payload should carry structural paragraph ordinals.");
|
|
Assert(models.Contains("WordParagraphIndex", StringComparison.Ordinal), "Scan payload should carry Word paragraph indexes.");
|
|
Assert(companion.Contains("structuralParagraphOrdinal", StringComparison.Ordinal), "Word scan should emit explicit structural paragraph mapping.");
|
|
Assert(migration.Contains("ManuscriptStructuralChapterSources", StringComparison.Ordinal), "Structural import should persist chapter source text.");
|
|
Assert(onboarding.Contains("SaveStructuralChapterSourcesAsync", StringComparison.Ordinal), "Onboarding import should save source text after canonical ChapterIDs exist.");
|
|
}
|
|
|
|
static void StructuralImportPersistsCanonicalSceneSourceText()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var migration = File.ReadAllText(Path.Combine(root, "Sql/150_Phase24B_SceneManuscriptSources.sql"));
|
|
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
|
|
var analyzer = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs"));
|
|
|
|
Assert(migration.Contains("CREATE TABLE dbo.SceneManuscriptSources", StringComparison.Ordinal), "Scene manuscript source text should be stored in a canonical SceneID table.");
|
|
Assert(migration.Contains("SceneID int NOT NULL CONSTRAINT PK_SceneManuscriptSources PRIMARY KEY", StringComparison.Ordinal), "Scene source text should be keyed by SceneID.");
|
|
Assert(migration.Contains("IX_SceneManuscriptSources_BookChapter", StringComparison.Ordinal), "Scene source text should be indexed for book/chapter retrieval.");
|
|
Assert(onboarding.Contains("SaveSceneManuscriptSourcesAsync", StringComparison.Ordinal), "Structural import should persist scene source text before Word sync.");
|
|
Assert(onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal)
|
|
< onboarding.IndexOf("await PublishBuildProgress(userId, preview.PreviewID, \"Updating and saving Word markers", StringComparison.Ordinal), "Scene source text should be persisted before marker sync can complete.");
|
|
Assert(analyzer.Contains("string.Join(Environment.NewLine + Environment.NewLine", StringComparison.Ordinal), "Scene source text should preserve paragraph separation.");
|
|
Assert(analyzer.Contains("item.IncludeInSourceText", StringComparison.Ordinal), "Scene source text should exclude non-prose structural paragraphs such as hidden markers/separators.");
|
|
}
|
|
|
|
static void StructuralImportAddsSummariesWithoutExtraSceneAiCalls()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Chapter-Structure-Prompt-V2.md"));
|
|
var model = File.ReadAllText(Path.Combine(root, "Models/ChapterStructureModels.cs"));
|
|
var analyzer = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs"));
|
|
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
|
|
var migration = File.ReadAllText(Path.Combine(root, "Sql/154_Phase24C_CoreImportCharacterMappingAndSummaries.sql"));
|
|
|
|
Assert(prompt.Contains("structuralSummary", StringComparison.Ordinal), "Chapter boundary prompt should request a per-scene structural summary.");
|
|
Assert(prompt.Contains("25 to 40 words", StringComparison.Ordinal), "Structural summaries should be short factual summaries.");
|
|
Assert(model.Contains("public string? StructuralSummary", StringComparison.Ordinal), "Chapter boundary model should carry structural summaries.");
|
|
Assert(analyzer.Contains("StructuralSummary = Trim(boundary.StructuralSummary", StringComparison.Ordinal), "Boundary analysis should carry summaries into structural scenes.");
|
|
Assert(onboarding.Contains("SaveSceneStructuralSummaryAsync", StringComparison.Ordinal), "Core import should persist structural summaries after canonical SceneIDs exist.");
|
|
Assert(migration.Contains("SceneStructuralSummary_Save", StringComparison.Ordinal), "Structural summaries should be saved through a stored procedure.");
|
|
Assert(analyzer.Split("ExecutePromptAsync", StringSplitOptions.None).Length - 1 == 1, "Structural summaries must be produced by the existing chapter-boundary call, not extra scene calls.");
|
|
}
|
|
|
|
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(controller.Contains("var readyToImport = string.Equals(intent, \"structure\"", StringComparison.Ordinal), "Controller should treat structure intent as the scene-creation gate.");
|
|
Assert(controller.Contains("FinaliseCharacterReviewAsync", StringComparison.Ordinal), "Character review should have a distinct post-structure finalisation path.");
|
|
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("Review characters", StringComparison.Ordinal), "After Word sync, the next onboarding step should be character review.");
|
|
Assert(complete.Contains("Word may now be closed; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion.");
|
|
}
|
|
|
|
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("structuralSummary = boundary.StructuralSummary", StringComparison.Ordinal), "Scene Intelligence context should receive structural summaries from the boundary pass.");
|
|
}
|
|
|
|
static void WordSyncRequiresSaveAcknowledgement()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
|
var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs"));
|
|
var presence = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js"));
|
|
var migration = File.ReadAllText(Path.Combine(root, "Sql/149_Phase24_StructuralImportMarkerSync.sql"));
|
|
|
|
Assert(companion.Contains("saveWordDocumentIfAvailable", StringComparison.Ordinal), "Companion should save Word before acknowledging marker sync.");
|
|
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(hub.Contains("MarkMarkerSyncCompletedAsync", StringComparison.Ordinal), "Server should persist completed marker sync.");
|
|
Assert(presence.Contains("OnboardingBuildMarkerSyncCompleted", StringComparison.Ordinal), "Browser should wait for marker sync completion before redirecting.");
|
|
Assert(migration.Contains("MarkerSyncCompletedUtc", StringComparison.Ordinal), "Database should persist marker sync completion time.");
|
|
}
|
|
|
|
static void StoryIntelligenceEnrichesExistingStructuralScenes()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
|
|
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
|
|
var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
|
|
|
|
Assert(runner.Contains("ListChapterScenesAsync", StringComparison.Ordinal), "Story Intelligence scene results should be linked to existing SceneIDs.");
|
|
Assert(repository.Contains("SceneID = existingScenes.Count > index", StringComparison.Ordinal), "Story Intelligence commit should update existing structural scenes when present.");
|
|
Assert(repository.Contains("Enriched existing structural scenes", StringComparison.Ordinal), "Commit audit should distinguish enrichment from scene creation.");
|
|
Assert(commit.Contains("readiness.ActiveSceneCount != importScenes.Count", StringComparison.Ordinal), "Review readiness should allow matching structural scenes while blocking mismatches.");
|
|
}
|
|
|
|
static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota()
|
|
{
|
|
var client = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs"));
|
|
|
|
Assert(client.Contains("IsInsufficientQuota(responseText)", StringComparison.Ordinal), "OpenAI insufficient_quota responses must be detected before retrying.");
|
|
Assert(client.Contains("request was not retried", StringComparison.Ordinal), "OpenAI insufficient_quota responses should fail clearly without retry churn.");
|
|
}
|
|
|
|
static void StoryIntelligenceVisualisationContractOmitsRawManuscriptText()
|
|
{
|
|
Assert(true, "Historical visualisation contract helper retained only to minimise churn after visualisation removal.");
|
|
}
|
|
|
|
static void IllustrationBulkRetryResultReportsCounts()
|
|
{
|
|
var result = new IllustrationBulkRetryResult(12, 4, 3);
|
|
Assert(result.Message.Contains("12 failed illustration", StringComparison.Ordinal), "Queued count should be reported.");
|
|
Assert(result.Message.Contains("4 skipped", StringComparison.Ordinal), "Skipped count should be reported.");
|
|
Assert(result.Message.Contains("3 reached the retry limit", StringComparison.Ordinal), "Retry-limit count should be reported.");
|
|
}
|
|
|
|
static void IllustrationBulkRetrySkipsBlockingDuplicateStableCodes()
|
|
{
|
|
var failed = new IllustrationLibraryItem
|
|
{
|
|
IllustrationLibraryItemID = 27,
|
|
StableCode = "asset-photo-print",
|
|
Status = IllustrationLibraryStatuses.Failed,
|
|
IsActive = true,
|
|
ParentItemID = null
|
|
};
|
|
var planned = new IllustrationLibraryItem
|
|
{
|
|
IllustrationLibraryItemID = 38,
|
|
StableCode = "asset-photo-print",
|
|
Status = IllustrationLibraryStatuses.Planned,
|
|
IsActive = true,
|
|
ParentItemID = null
|
|
};
|
|
var approved = new IllustrationLibraryItem
|
|
{
|
|
IllustrationLibraryItemID = 55,
|
|
StableCode = "asset-photo-print",
|
|
Status = IllustrationLibraryStatuses.Approved,
|
|
IsActive = true,
|
|
ParentItemID = null
|
|
};
|
|
|
|
Assert(IllustrationLibraryService.ResolveBulkRetryTarget(failed, [failed, approved]) is null, "Failed duplicate should be skipped when an approved active sibling exists.");
|
|
Assert(IllustrationLibraryService.ResolveBulkRetryTarget(failed, [failed, planned])?.IllustrationLibraryItemID == planned.IllustrationLibraryItemID, "Failed duplicate should queue its planned active sibling.");
|
|
}
|
|
|
|
static void IllustrationProviderReportsMissingImageModel()
|
|
{
|
|
var provider = new OpenAIIllustrationImageProvider(
|
|
new HttpClient(),
|
|
Options.Create(new StoryIntelligenceOptions { ApiKey = "configured-for-test", ImageGenerationModel = "" }),
|
|
NullLogger<OpenAIIllustrationImageProvider>.Instance);
|
|
|
|
Assert(!provider.IsConfigured, "Provider should not be configured without an image model.");
|
|
Assert(provider.MissingConfiguration.Count == 1, "Only image model should be missing when API key exists.");
|
|
Assert(provider.MissingConfiguration[0].Contains("image-generation model", StringComparison.OrdinalIgnoreCase), "Missing image model should be named.");
|
|
}
|
|
|
|
static void IllustrationProviderOmitsGptImageResponseFormat()
|
|
{
|
|
string? payload = null;
|
|
var handler = new CaptureImageRequestHandler(request =>
|
|
{
|
|
payload = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
|
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
|
{
|
|
Content = new StringContent(
|
|
"""{"error":{"message":"test failure","code":"invalid_request_error"}}""",
|
|
Encoding.UTF8,
|
|
"application/json")
|
|
};
|
|
});
|
|
var provider = new OpenAIIllustrationImageProvider(
|
|
new HttpClient(handler),
|
|
Options.Create(new StoryIntelligenceOptions
|
|
{
|
|
ApiKey = "configured-for-test",
|
|
ImageGenerationModel = "gpt-image-1",
|
|
ImageGenerationSize = "1024x1024"
|
|
}),
|
|
NullLogger<OpenAIIllustrationImageProvider>.Instance);
|
|
|
|
var result = provider.GenerateAsync(
|
|
new IllustrationImageGenerationRequest(1, "asset-folded-letter", IllustrationLibraryCategories.Asset, "Prompt", IllustrationPromptBuilder.CurrentTemplateVersion),
|
|
CancellationToken.None).GetAwaiter().GetResult();
|
|
|
|
Assert(payload is not null, "Provider did not send a payload.");
|
|
Assert(!payload!.Contains("response_format", StringComparison.Ordinal), "GPT image payload should not include response_format.");
|
|
Assert(payload.Contains("\"model\":\"gpt-image-1\"", StringComparison.Ordinal), "Payload should include the configured image model.");
|
|
Assert(result.ErrorMessage?.Contains("invalid_request_error", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error code.");
|
|
Assert(result.ErrorMessage?.Contains("test failure", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error message.");
|
|
}
|
|
|
|
static void ScanReviewPostSupportsFullBookFormSubmissions()
|
|
{
|
|
var method = typeof(OnboardingController).GetMethod(nameof(OnboardingController.SaveScanReview));
|
|
Assert(method is not null, "Scan review post action could not be found.");
|
|
|
|
var formLimits = method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: false)
|
|
.OfType<RequestFormLimitsAttribute>()
|
|
.SingleOrDefault();
|
|
Assert(formLimits is not null, "Scan review post action should configure expanded form limits.");
|
|
Assert(formLimits!.ValueCountLimit >= 20000, "Scan review form value limit should support full-book previews.");
|
|
Assert(formLimits.KeyLengthLimit >= 4096, "Scan review form key length limit should support indexed review fields.");
|
|
|
|
var requestSize = method.GetCustomAttributes(typeof(RequestSizeLimitAttribute), inherit: false)
|
|
.OfType<RequestSizeLimitAttribute>()
|
|
.SingleOrDefault();
|
|
Assert(((IRequestSizeLimitMetadata?)requestSize)?.MaxRequestBodySize >= 25 * 1024 * 1024, "Scan review request size limit should support full-book previews.");
|
|
}
|
|
|
|
static void CharacterSexHintsAreConservativeAndReviewable()
|
|
{
|
|
Assert(CharacterSexHintService.Suggest("Cindy") == CharacterSexHintService.Female, "Cindy should default to a female sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Kevin") == CharacterSexHintService.Male, "Kevin should default to a male sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Colin") == CharacterSexHintService.Male, "Colin should default to a male sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Debbie") == CharacterSexHintService.Female, "Debbie should default to a female sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Chrissy") == CharacterSexHintService.Female, "Chrissy should default to a female sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Clara") == CharacterSexHintService.Female, "Clara should default to a female sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Greg") == CharacterSexHintService.Male, "Greg should default to a male sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Judy") == CharacterSexHintService.Female, "Judy should default to a female sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Liam") == CharacterSexHintService.Male, "Liam should default to a male sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Phil") == CharacterSexHintService.Male, "Phil should default to a male sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Mrs Palmer") == CharacterSexHintService.Female, "Feminine titles should provide a female sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Mr Palmer") == CharacterSexHintService.Male, "Masculine titles should provide a male sex hint.");
|
|
Assert(CharacterSexHintService.Suggest("Alex") == CharacterSexHintService.Unknown, "Ambiguous names should not be guessed.");
|
|
Assert(CharacterSexHintService.Suggest("Becks") == CharacterSexHintService.Unknown, "Unclear nicknames should remain reviewable.");
|
|
Assert(CharacterSexHintService.Normalise("Female", "Alex") == CharacterSexHintService.Female, "Reviewed sex hints should override the name heuristic.");
|
|
}
|
|
|
|
static void CharacterDiscoveryDoesNotTruncateScanReviewCandidates()
|
|
{
|
|
var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "Character discovery method could not be found.");
|
|
|
|
var builder = new StringBuilder();
|
|
for (var index = 0; index < 60; index++)
|
|
{
|
|
var name = $"Mr Person{(char)('a' + index / 26)}{(char)('a' + index % 26)}";
|
|
builder.Append(name).Append(" said hello. ").Append(name).Append(" smiled. ");
|
|
}
|
|
|
|
var candidates = (IReadOnlyList<WordCompanionCharacterCandidateDto>)method!.Invoke(null, [builder.ToString(), false])!;
|
|
Assert(candidates.Count >= 60, $"Character discovery should not truncate the scan review to 50 candidates; got {candidates.Count}.");
|
|
}
|
|
|
|
static void CharacterDiscoveryRejectsSentenceStartWords()
|
|
{
|
|
var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "Character discovery method could not be found.");
|
|
|
|
var text = """
|
|
Silence filled the room. Silence made everything worse.
|
|
Morning came too quickly. Morning dragged at the windows.
|
|
Eyes looked toward the door. Eyes followed the sound.
|
|
Colin said nothing. Debbie smiled at Colin. Mr Wilkinson opened the file.
|
|
""";
|
|
|
|
var candidates = (IReadOnlyList<WordCompanionCharacterCandidateDto>)method!.Invoke(null, [text, false])!;
|
|
Assert(candidates.Any(candidate => candidate.Text == "Colin"), "Real common character names should still be detected.");
|
|
Assert(candidates.Any(candidate => candidate.Text == "Debbie"), "Real common character names should still be detected.");
|
|
Assert(candidates.Any(candidate => candidate.Text == "Mr Wilkinson"), "Titled character names should still be detected.");
|
|
Assert(!candidates.Any(candidate => candidate.Text is "Silence" or "Morning" or "Eyes"), "Repeated sentence-start words must not become character candidates.");
|
|
|
|
var actionText = """
|
|
Door opened before anyone moved. Door closed behind them.
|
|
Glass shattered across the floor. Glass glittered under the counter.
|
|
Window rattled in the frame. Window shook again.
|
|
Colin opened the file. Debbie watched Colin.
|
|
""";
|
|
var actionCandidates = (IReadOnlyList<WordCompanionCharacterCandidateDto>)method!.Invoke(null, [actionText, false])!;
|
|
Assert(actionCandidates.Any(candidate => candidate.Text == "Colin"), "Known character names should still survive action evidence.");
|
|
Assert(actionCandidates.Any(candidate => candidate.Text == "Debbie"), "Known character names should still survive action evidence.");
|
|
Assert(!actionCandidates.Any(candidate => candidate.Text is "Door" or "Glass" or "Window"), "Repeated sentence-start action nouns must not become character candidates.");
|
|
}
|
|
|
|
static void ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates()
|
|
{
|
|
var preview = new ManuscriptScanPreview
|
|
{
|
|
CharacterCandidates =
|
|
[
|
|
new() { TemporaryCharacterKey = "c-colin", Name = "Colin", MentionCount = 4, QualityScore = 7, Category = "ProbableCharacter" },
|
|
new() { TemporaryCharacterKey = "c-door", Name = "Door", MentionCount = 4, QualityScore = 2, Category = "PossibleCharacter" },
|
|
new() { TemporaryCharacterKey = "c-road", Name = "Road", MentionCount = 8, QualityScore = 1, Category = "Excluded" }
|
|
]
|
|
};
|
|
|
|
var candidates = WordCompanionService.DiscoverManuscriptScanCharacterCandidates(preview);
|
|
Assert(candidates.Count == 1, $"Scan preview should keep only auto-selected character candidates; got {candidates.Count}.");
|
|
Assert(candidates[0].Name == "Colin", "Scan preview should keep probable character candidates.");
|
|
}
|
|
|
|
static void CharacterDiscoveryRejectsFragmentsAndLocationNames()
|
|
{
|
|
var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "Character discovery method could not be found.");
|
|
|
|
var text = """
|
|
Bark Narrated the line break. Freedom Narrated another section.
|
|
Eventually Rob arrived and Rob spoke to Maggie.
|
|
Bristol Road curved away from Barmouth. Bristol Road appeared again.
|
|
Maggie said hello. Maggie smiled at Rob.
|
|
""";
|
|
|
|
var candidates = (IReadOnlyList<WordCompanionCharacterCandidateDto>)method!.Invoke(null, [text, false])!;
|
|
Assert(candidates.Any(candidate => candidate.Text == "Maggie"), "Real character names should still survive fragment filtering.");
|
|
Assert(candidates.Any(candidate => candidate.Text == "Rob"), "Known character names should still survive fragment filtering.");
|
|
Assert(!candidates.Any(candidate => candidate.Text is "Bark Narrated" or "Freedom Narrated" or "Eventually Rob"), "Narrative fragments must not become character candidates.");
|
|
Assert(!candidates.Any(candidate => candidate.Text == "Bristol Road"), "Location names must not become character candidates.");
|
|
}
|
|
|
|
static void OnboardingWizardKeepsCharacterPagesInsideOneMainStep()
|
|
{
|
|
var header = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Onboarding/_OnboardingJourneyHeader.cshtml"));
|
|
var review = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Onboarding/ScanReview.cshtml"));
|
|
var storyService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/OnboardingStoryIntelligenceService.cs"));
|
|
|
|
Assert(!header.Contains("BuildDynamicSteps", StringComparison.Ordinal), "Onboarding header should not create one top-level step per character page.");
|
|
Assert(header.Contains("new(\"Review Characters\", 8)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step.");
|
|
Assert(review.Contains("var currentStep = isCharacterStep ? 8 : 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 wizard step count.");
|
|
}
|
|
|
|
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
|
|
{
|
|
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
|
Assert(!view.Contains("JsonSerializer.Serialize(Model", StringComparison.Ordinal), "Live experience page should not serialise the full view model into the Razor response.");
|
|
Assert(view.Contains("JsonSerializer.Serialize(bootModel", StringComparison.Ordinal), "Live experience page should serialise only boot data.");
|
|
}
|
|
|
|
static void LiveVisualisationStripsIllustrationDiagnostics()
|
|
{
|
|
Assert(true, "Historical helper retained only to minimise churn after visualisation removal.");
|
|
}
|
|
|
|
static void IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence()
|
|
{
|
|
var source = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
|
|
Assert(!source.Contains("previousStoredEvidence = existing?.EvidenceJson", StringComparison.Ordinal), "Evidence trace must not recursively embed previous EvidenceJson.");
|
|
Assert(source.Contains("evidenceHash", StringComparison.Ordinal), "Evidence trace should keep only a compact previous evidence hash.");
|
|
}
|
|
|
|
static void Phase21QRepairsProductionVisualisationRegressions()
|
|
{
|
|
var unknownNamed = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches from the lane."], []);
|
|
var mrColin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mr Colin Webb", ["Mr Colin Webb is a driving examiner."], []);
|
|
var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is sixteen.", "Maggie pushed back her red hair."], []);
|
|
var darkHairCandidate = Metadata("char-young-adult-brunette-observer", "OlderTeen", "Feminine", hairColour: "Brown");
|
|
var teenCandidate = Metadata("char-young-helper", "YoungTeen", "Androgynous");
|
|
|
|
Assert(unknownNamed.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"Unknown named age must remain Unknown, got {unknownNamed.AgeBand}.");
|
|
Assert(mrColin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Mr Colin should infer Masculine, got {mrColin.Presentation}.");
|
|
Assert(mrColin.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner title/profession should infer Adult, got {mrColin.AgeBand}.");
|
|
Assert(Rejected(mrColin, teenCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Adult title/profession should reject child/teen candidates.");
|
|
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie red-hair evidence should be explicit, got {maggie.HairColour}.");
|
|
Assert(Rejected(maggie, darkHairCandidate).Any(reason => reason.Contains("hair colour hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-hair evidence must invalidate dark-haired assignments.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should classify as Lane.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "DerelictHospital", "Derelict hospital should remain typed rather than generic interior.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bench beside the road") == "Road", "Bench context beside road should not become indoor room artwork.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Lane/Road must not use car park artwork.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not use flat interior artwork.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("blue VW Golf") == "Hatchback", "Blue VW Golf should classify as Hatchback.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetColour("blue VW Golf") == "Blue", "Blue VW Golf should retain explicit colour.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("TR6 sports car") == "SportsCar", "TR6 should remain a sports car.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not use letter/document imagery.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "SportsCar"), "Blue hatchback must not silently use red sports-car imagery.");
|
|
|
|
var serviceSource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
|
|
Assert(serviceSource.Contains("DemandGenerationThreshold = 1", StringComparison.Ordinal), "Live import demand should queue on first repeated missing archetype pass, not after dozens of scenes.");
|
|
Assert(serviceSource.Contains("ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Location and asset demand must run from the visualisation pipeline.");
|
|
Assert(serviceSource.Contains("MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence)", StringComparison.Ordinal), "Existing assignments must be revalidated against current evidence.");
|
|
Assert(serviceSource.Contains("BalancedSkinToneFallback", StringComparison.Ordinal), "Unresolved appearance should use deterministic project/book diversity rather than one global skin-tone default.");
|
|
|
|
var librarySource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/IllustrationLibraryServices.cs"));
|
|
var applyApprovedSource = librarySource[librarySource.IndexOf("public async Task ApplyApprovedIllustrationsAsync", StringComparison.Ordinal)..];
|
|
Assert(!applyApprovedSource.Contains("StarterCodes.Contains", StringComparison.Ordinal), "Generated demand illustrations must not be filtered out by the runtime approved-image lookup.");
|
|
}
|
|
|
|
static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden()
|
|
{
|
|
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
|
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js"));
|
|
var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css"));
|
|
|
|
Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation.");
|
|
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
|
|
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay.");
|
|
Assert(script.Contains("relationships.slice(0, 3)", StringComparison.Ordinal), "Relationship changes should show several visible cards without overflowing the panel.");
|
|
Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not hide readable content behind '+N more' overflow controls.");
|
|
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
|
|
Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll.");
|
|
Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths.");
|
|
}
|
|
|
|
static void Phase21RReplayUsesPersistedDataOnly()
|
|
{
|
|
var snapshotService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
|
|
var matcher = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
|
|
var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs"));
|
|
|
|
Assert(snapshotService.Contains("BuildReplaySnapshotAsync", StringComparison.Ordinal), "Replay mode needs a dedicated snapshot builder.");
|
|
Assert(snapshotService.Contains("Mode = replayMode ? \"replay\" : \"real\"", StringComparison.Ordinal), "Replay snapshots should be explicitly labelled.");
|
|
Assert(snapshotService.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Replay must use durable Story Memory illustration assignments only.");
|
|
Assert(!snapshotService.Contains("await illustrationMatcher.ApplyPersistedCharacterIllustrationsAsync", StringComparison.Ordinal), "Replay should not use the legacy character-only matcher.");
|
|
Assert(!snapshotService.Contains("await illustrationMatcher.ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Snapshot rendering must not create location/asset demand.");
|
|
Assert(snapshotService.Contains("AiCallsDuringReplay = 0", StringComparison.Ordinal), "Replay diagnostics must state that no AI calls are made.");
|
|
Assert(development.Contains("BuildReplaySnapshotAsync(importSessionId.Value, userId)", StringComparison.Ordinal), "Development route must call the replay snapshot builder for mode=replay.");
|
|
Assert(development.Contains("RebuildStoryIntelligenceExperience", StringComparison.Ordinal), "Development route should expose a saved-analysis rebuild action.");
|
|
Assert(development.Contains("AI calls", StringComparison.Ordinal), "Rebuild action should report that it made no AI calls.");
|
|
}
|
|
|
|
static void Phase21SUsesDurableStoryMemoryAssignments()
|
|
{
|
|
var sql = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql"));
|
|
var models = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Models/StoryMemoryModels.cs"));
|
|
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs"));
|
|
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
|
|
var runner = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/PersistedStoryIntelligenceRunner.cs"));
|
|
var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs"));
|
|
|
|
Assert(sql.Contains("StoryMemoryCharacters", StringComparison.Ordinal), "Durable Story Memory character table is missing.");
|
|
Assert(sql.Contains("StoryMemoryLocations", StringComparison.Ordinal), "Durable Story Memory location table is missing.");
|
|
Assert(sql.Contains("StoryMemoryAssets", StringComparison.Ordinal), "Durable Story Memory asset table is missing.");
|
|
Assert(sql.Contains("StoryMemoryIllustrationAssignments", StringComparison.Ordinal), "Durable illustration assignment table is missing.");
|
|
Assert(sql.Contains("StoryMemoryIllustrationDemands", StringComparison.Ordinal), "Durable illustration demand table is missing.");
|
|
Assert(models.Contains("StoryMemoryAppearancePreferences", StringComparison.Ordinal), "Import-level appearance preference model is missing.");
|
|
Assert(service.Contains("IStoryMemoryService", StringComparison.Ordinal), "Durable Story Memory service is missing.");
|
|
Assert(service.Contains("QueueDemand = false", StringComparison.Ordinal) || service.Contains("QueueDemand", StringComparison.Ordinal), "Demand queueing must be explicit.");
|
|
Assert(service.Contains("InferCharacterEvidence", StringComparison.Ordinal), "Durable updater must use deterministic character evidence extraction.");
|
|
Assert(service.Contains("CharacterHardRejections", StringComparison.Ordinal), "Resolver must revalidate hard character constraints.");
|
|
Assert(service.Contains("LocationCompatible", StringComparison.Ordinal), "Resolver must enforce location compatibility.");
|
|
Assert(service.Contains("AssetCompatible", StringComparison.Ordinal), "Resolver must enforce asset compatibility.");
|
|
Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync", StringComparison.Ordinal), "Snapshot must read durable persisted assignments.");
|
|
Assert(runner.Contains("storyMemory.ProcessSceneResultAsync", StringComparison.Ordinal), "Importer must update Story Memory as scenes are persisted.");
|
|
Assert(development.Contains("StoryMemoryDiagnostics", StringComparison.Ordinal), "Development diagnostics endpoint is missing.");
|
|
Assert(development.Contains("RebuildImportSessionAsync", StringComparison.Ordinal), "Development rebuild action must call the durable memory rebuild.");
|
|
}
|
|
|
|
static void Phase21TRepairsDurableCharacterUnderstanding()
|
|
{
|
|
var maggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
|
|
"Maggie",
|
|
[
|
|
Attribute("AgeBand", "Adult", 0.88m, false, 10),
|
|
Attribute("AgeBand", "OlderTeen", 0.98m, true, 20),
|
|
Attribute("Presentation", "Feminine", 0.98m, true, 1),
|
|
Attribute("HairColour", "Red", 0.82m, true, 20),
|
|
Attribute("HairLength", "Long", 0.72m, true, 20)
|
|
]);
|
|
Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Explicit later teen evidence should win, got {maggie.AgeBand}.");
|
|
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Durable red-hair evidence should reach resolver, got {maggie.HairColour}.");
|
|
Assert(maggie.HairLength == "Long", $"Durable hair length should reach resolver, got {maggie.HairLength}.");
|
|
|
|
var unknown = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches."], []);
|
|
var childCandidate = Metadata("char-demand-child-feminine", "Child", "Feminine");
|
|
Assert(Rejected(unknown, childCandidate).Any(reason => reason.Contains("unknown age", StringComparison.OrdinalIgnoreCase)), "Unknown named characters must reject child portraits.");
|
|
|
|
var adult = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson is a teacher."], []);
|
|
var teenCandidate = Metadata("char-demand-olderteen-feminine", "OlderTeen", "Feminine");
|
|
Assert(Rejected(adult, teenCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Adult title/occupation evidence must reject teen portraits.");
|
|
|
|
var olderTeen = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Colin", ["Colin is a learner driver at the driving test centre."], []);
|
|
var childMasculineCandidate = Metadata("char-demand-child-masculine", "Child", "Masculine");
|
|
Assert(Rejected(olderTeen, childMasculineCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Older teen evidence must reject child portraits.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("Group of girls"), "Group of girls should not reserve a unique character portrait.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("pair of girls"), "Pair of girls should not reserve a unique character portrait.");
|
|
|
|
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs"));
|
|
Assert(service.Contains("BuildCrossSceneIdentityAliases", StringComparison.Ordinal), "Rebuild should repair cross-scene descriptive-to-named character identities from saved analysis.");
|
|
Assert(service.Contains("ExtensionEvidence(sceneCharacter.ExtensionData)", StringComparison.Ordinal), "Saved scene appearance extension data must be included in durable evidence.");
|
|
Assert(!File.Exists(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs")), "Visualisation snapshot service should stay removed.");
|
|
}
|
|
|
|
static void Phase21UExtractsMandatoryCharacterAppearanceFixtures()
|
|
{
|
|
const string source = """
|
|
Maggie, a late teenage girl, pushed back her long natural red hair while Beth, her late teenage sister, checked the mirror.
|
|
Beth was initially blonde, and Rosie was a blonde young woman.
|
|
Miss Davies smiled. "Miss Davies, but you can call me Zoe," she said.
|
|
Mum waited outside with Mrs Patterson while Colin, David, Graham, Rick and Rob spoke to the driving examiner.
|
|
""";
|
|
var facts = CharacterAppearanceExtractionService.Extract(source, ["Maggie", "Beth", "Rosie", "Miss Davies", "Zoe", "Mum", "Mrs Patterson", "Colin", "David", "Graham", "Rick", "Rob"]);
|
|
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Maggie" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Red"), "Maggie's red-hair evidence was not extracted.");
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Maggie" && fact.AttributeType == "HairLength" && fact.NormalisedValue == "Long"), "Maggie's long-hair evidence was not extracted.");
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Beth" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Blonde"), "Beth's blonde evidence was not extracted.");
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Rosie" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Blonde"), "Rosie's blonde evidence was not extracted.");
|
|
Assert(!CharacterAppearanceExtractionService.Extract("Beth went next door and mumbled, 'Mum.'", ["Beth", "Mum"]).Any(fact => fact.CanonicalName == "Beth" && fact.AttributeType == "AgeBand" && fact.NormalisedValue == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult), "Nearby Mum text must not age Beth into a mature adult.");
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Zoe Davies" && fact.AttributeType == "Alias" && fact.NormalisedValue == "miss davies"), "Miss Davies should resolve as an alias of Zoe Davies.");
|
|
Assert(facts.Any(fact => fact.CanonicalName == "Zoe Davies" && fact.AttributeType == "Alias" && fact.NormalisedValue == "zoe"), "Zoe should resolve as an alias of Zoe Davies.");
|
|
|
|
var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is a late teenage girl.", "Maggie has long natural red hair."], []);
|
|
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is a late teenage girl.", "Beth was initially blonde."], []);
|
|
var rosie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Rosie", ["Rosie is a blonde young woman."], []);
|
|
|
|
Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Maggie should be OlderTeen, got {maggie.AgeBand}.");
|
|
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie should be Red, got {maggie.HairColour}.");
|
|
var maggieMemory = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Maggie", [
|
|
new StoryMemoryCharacterAttribute { AttributeType = "HairColour", NormalisedValue = StoryIntelligenceIllustrationCompatibility.HairColours.Red, Confidence = 0.95m, IsExplicit = true }
|
|
]);
|
|
Assert(maggieMemory.SkinTone == StoryIntelligenceIllustrationCompatibility.SkinTones.Light, $"Natural red hair should default unknown skin tone to Light, got {maggieMemory.SkinTone}.");
|
|
Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Beth should be OlderTeen, got {beth.AgeBand}.");
|
|
Assert(beth.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Blonde, $"Beth should be Blonde, got {beth.HairColour}.");
|
|
Assert(rosie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, $"Rosie should be YoungAdult, got {rosie.AgeBand}.");
|
|
Assert(rosie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Blonde, $"Rosie should be Blonde, got {rosie.HairColour}.");
|
|
}
|
|
|
|
static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules()
|
|
{
|
|
var zoeAliases = CharacterAppearanceExtractionService.AliasMap("""Miss Davies, but you can call me Zoe.""", ["Miss Davies", "Zoe"]);
|
|
Assert(zoeAliases.TryGetValue("miss davies", out var missCanonical) && missCanonical == "Zoe Davies", "Miss Davies should canonicalise to Zoe Davies.");
|
|
Assert(zoeAliases.TryGetValue("zoe", out var zoeCanonical) && zoeCanonical == "Zoe Davies", "Zoe should canonicalise to Zoe Davies.");
|
|
|
|
var mum = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mum", ["Mum is mother of teenage daughters."], []);
|
|
var examiner = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("driving examiner", ["the driving examiner"], []);
|
|
var redHairedMaggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Maggie", [
|
|
new StoryMemoryCharacterAttribute { AttributeType = "HairColour", NormalisedValue = StoryIntelligenceIllustrationCompatibility.HairColours.Red, Confidence = 0.95m, IsExplicit = true }
|
|
]);
|
|
var bethFromLeakyEvidence = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Beth", [
|
|
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, Confidence = 0.9m, IsExplicit = true, EvidenceSummary = "Beth goes next door and mumbles 'Mum'" },
|
|
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, Confidence = 0.9m, IsExplicit = true, EvidenceSummary = "No fifteen-year-old girl with a future." },
|
|
new StoryMemoryCharacterAttribute { AttributeType = "Presentation", NormalisedValue = StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, Confidence = 0.95m, IsExplicit = true, EvidenceSummary = "I checked my look in the mirror." }
|
|
]);
|
|
var mrsWithTeenContext = StoryMemoryService.ApplyImportContextToEvidence(
|
|
StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Mrs Patterson", [
|
|
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, Confidence = 0.88m, IsExplicit = true }
|
|
]),
|
|
"Mrs Patterson",
|
|
new StoryMemoryImportContext(StoryMemoryAppearancePreferences.PredominantlyLight, "OlderTeen", "1980s", "UK"));
|
|
var rosieBirthday = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Rosie", [
|
|
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.Child, Confidence = 0.98m, IsExplicit = true, EvidenceSummary = "Deterministic evidence inference" },
|
|
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, Confidence = 0.90m, IsExplicit = true, EvidenceSummary = "She may be eighteen now, but she is still the baby of the group." }
|
|
]);
|
|
var alisonPronoun = StoryIntelligenceIllustrationCompatibility.NormalizePresentation("She was a nurse at Selly Oak Hospital.");
|
|
var alisonName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Alison", ["Alison"], []);
|
|
var sophie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sophie", ["Sophie glared at me. She called the pub hers."], ["Sophie is Rebecca's sister."]);
|
|
var gregFromRosieAction = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Greg", ["Greg offers to take Rosie to hospital."], []);
|
|
var judyName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Judy", ["Judy"], []);
|
|
var philName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Phil", ["Phil"], []);
|
|
var childCandidate = Metadata("char-child", "Child", "Feminine");
|
|
var darkRedCandidate = Metadata("char-red-dark", "OlderTeen", "Feminine", hairColour: "Red", skinTone: "Dark");
|
|
Assert(bethFromLeakyEvidence.AgeBand != StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Leaked family-role evidence should not age Beth up, got {bethFromLeakyEvidence.AgeBand}.");
|
|
Assert(bethFromLeakyEvidence.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Name/title guard should repair leaked Beth presentation, got {bethFromLeakyEvidence.Presentation}.");
|
|
Assert(mrsWithTeenContext.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Adult-role titles must not be overridden by primary age context, got {mrsWithTeenContext.AgeBand}.");
|
|
Assert(rosieBirthday.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, $"Rosie's explicit eighteen-year-old evidence must beat weak child leakage, got {rosieBirthday.AgeBand}.");
|
|
Assert(alisonPronoun == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Direct she/her pronoun evidence should infer Feminine, got {alisonPronoun}.");
|
|
Assert(alisonName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Common feminine name Alison should infer Feminine, got {alisonName.Presentation}.");
|
|
Assert(sophie.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Sophie/she/sister evidence should infer Feminine, got {sophie.Presentation}.");
|
|
Assert(gregFromRosieAction.Presentation != StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Greg must not inherit feminine presentation from action text mentioning Rosie.");
|
|
Assert(judyName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Common feminine name Judy should infer Feminine, got {judyName.Presentation}.");
|
|
Assert(philName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Common masculine name Phil should infer Masculine, got {philName.Presentation}.");
|
|
Assert(Rejected(mum, childCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Parent role should reject child portraits.");
|
|
Assert(Rejected(redHairedMaggie, darkRedCandidate).Any(reason => reason.Contains("skin tone hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-haired light-skin evidence must reject dark-skin generated portraits.");
|
|
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("girl", ["girl"], []).AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, "Girl alone must not mean Child.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should be Lane.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should be Road.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Street", "Road"), "Named streets should wait for street-specific imagery rather than reuse generic road art.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "Street"), "Named roads should wait for road-specific imagery rather than reuse street art.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("car interior") == "VehicleInterior", "Car interior should be VehicleInterior.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom") == "Bathroom", "Bathroom should be Bathroom.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("ladies toilets in the Rose and Crown pub") == "PublicToilet", "Pub toilets should be PublicToilet.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("waiting room") == "WaitingRoom", "Waiting room should be WaitingRoom.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "VehicleInterior must not use building art.");
|
|
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("blue VW Golf") == "Hatchback", "Blue VW Golf should be Hatchback.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetColour("blue VW Golf") == "Blue", "Blue VW Golf should be Blue.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("red TR6") == "SportsCar", "TR6 should be SportsCar.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("lipstick") == "Lipstick", "Lipstick should be Lipstick.");
|
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("skirt") == "Skirt", "Skirt should be Skirt.");
|
|
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "SportsCar"), "Hatchback must not use sports-car art when demand generation is available.");
|
|
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var onboarding = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceContext.cshtml"));
|
|
var adminController = File.ReadAllText(Path.Combine(root, "Controllers/AdminController.cs"));
|
|
var adminDiagnostics = File.ReadAllText(Path.Combine(root, "Views/Admin/StoryIntelligenceDiagnostics.cshtml"));
|
|
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
|
|
var storyMemory = File.ReadAllText(Path.Combine(root, "Services/StoryMemoryServices.cs"));
|
|
var matchingService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceIllustrationMatchingService.cs"));
|
|
var snapshot = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceVisualisationSnapshotService.cs"));
|
|
var developmentReset = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceDevelopmentResetService.cs"));
|
|
var persistedRunner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
|
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-experience-prototype.js"));
|
|
var css = File.ReadAllText(Path.Combine(root, "wwwroot/css/story-intelligence-experience-prototype.css"));
|
|
var resetSql = File.ReadAllText(Path.Combine(root, "Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql"));
|
|
var scenePrompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
|
|
|
|
Assert(onboarding.Contains("name=\"appearancePreference\"", StringComparison.Ordinal), "Import setup must ask for appearance preference.");
|
|
Assert(onboarding.Contains("name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Import setup must ask for primary age group.");
|
|
Assert(onboarding.Contains("name=\"storyEra\"", StringComparison.Ordinal), "Import setup must ask for era.");
|
|
Assert(onboarding.Contains("name=\"storyLocation\"", StringComparison.Ordinal), "Import setup must ask for location.");
|
|
Assert(onboarding.Contains("<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 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.");
|
|
}
|
|
|
|
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 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 StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
|
|
|
|
Assert(service.Contains("parsed.CharacterAppearance", StringComparison.Ordinal), "Character review should inspect structured character appearance data.");
|
|
Assert(service.Contains("AddCharacterAppearanceAliases", StringComparison.Ordinal), "Structured appearance aliases should feed character candidates.");
|
|
Assert(service.Contains("Aliases = appearance.Aliases", StringComparison.Ordinal), "Structured appearance aliases should be reused without another AI call.");
|
|
}
|
|
|
|
static void StoryIntelligenceReviewEvidenceIncludesChapterSceneLabels()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var characterService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
|
|
var locationService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
|
|
var assetService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceAssetImportService.cs"));
|
|
var relationshipService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceRelationshipImportService.cs"));
|
|
var knowledgeService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceKnowledgeImportService.cs"));
|
|
var locationView = File.ReadAllText(Path.Combine(root, "Views/Locations/Details.cshtml"));
|
|
|
|
Assert(characterService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Character review first appearances should include chapter and scene.");
|
|
Assert(locationService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Location review first appearances should include chapter and scene.");
|
|
Assert(assetService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Asset review first appearances should include chapter and scene.");
|
|
Assert(relationshipService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Relationship review first appearances should include chapter and scene.");
|
|
Assert(knowledgeService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Knowledge review first appearances should include chapter and scene.");
|
|
Assert(locationView.Contains("SceneLabel(scene.ChapterNumber, scene.SceneNumber)", StringComparison.Ordinal), "Location detail scene links should include chapter context.");
|
|
}
|
|
|
|
static void StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
|
|
|
|
Assert(service.Contains("await metricTypes.AddMissingDefaultsAsync(run.ProjectID.Value)", StringComparison.Ordinal), "Story Intelligence import should enable default metric types for the project before committing scenes.");
|
|
Assert(service.Contains("metric.IsActive && metric.IsEnabledForProject", StringComparison.Ordinal), "Story Intelligence import should only save metrics enabled for the project.");
|
|
Assert(service.IndexOf("await metricTypes.AddMissingDefaultsAsync(run.ProjectID.Value)", StringComparison.Ordinal)
|
|
< service.IndexOf("ListForManagementAsync(run.ProjectID.Value)", StringComparison.Ordinal), "Metric defaults must be enabled before metric types are loaded.");
|
|
}
|
|
|
|
static void ProjectMetricsAddDefaultsBackfillsSceneValues()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var core = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
|
|
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
|
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/ProjectMetricsController.cs"));
|
|
var migration = File.ReadAllText(Path.Combine(root, "Sql/151_BackfillDefaultSceneMetricValues.sql"));
|
|
var selfHealingDefaults = File.ReadAllText(Path.Combine(root, "Sql/153_SelfHealingProjectMetricDefaults.sql"));
|
|
|
|
Assert(core.Contains("Task<int> AddMissingDefaultsAsync(int projectId)", StringComparison.Ordinal), "Metric defaults service should report inserted scene values.");
|
|
Assert(core.Contains("BackfillDefaultValuesForProjectAsync(projectId)", StringComparison.Ordinal), "Metric defaults service should backfill scene metric values.");
|
|
Assert(repository.Contains("SceneMetric_BackfillDefaultsForProject", StringComparison.Ordinal), "Metric repository should call the scene metric backfill procedure.");
|
|
Assert(controller.Contains("backfilled", StringComparison.Ordinal), "Metric controller should tell users when scene values were backfilled.");
|
|
Assert(migration.Contains("NOT EXISTS", StringComparison.Ordinal), "Metric backfill must not overwrite existing scene metric values.");
|
|
Assert(selfHealingDefaults.Contains("MERGE dbo.SceneMetricTypes", StringComparison.Ordinal), "Add missing defaults should seed missing global metric types.");
|
|
Assert(selfHealingDefaults.Contains("Overall Intensity", StringComparison.Ordinal), "Core metric seed should include the default PlotDirector metrics.");
|
|
}
|
|
|
|
static void StoryIntelligenceScenePromptUsesConfiguredMetrics()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
|
|
var validator = File.ReadAllText(Path.Combine(root, "Services/StorySceneValidator.cs"));
|
|
|
|
Assert(prompt.Contains("configuredMetrics", StringComparison.Ordinal), "Scene prompt should read configured metrics from scene context.");
|
|
Assert(prompt.Contains("use the exact `key` supplied in `sceneContext`", StringComparison.Ordinal), "Scene prompt should use runtime metric keys.");
|
|
Assert(prompt.Contains("Do not invent a separate metric catalogue", StringComparison.Ordinal), "Scene prompt should not embed an independent metric catalogue.");
|
|
Assert(!prompt.Contains("Preferred V1 metrics:", StringComparison.Ordinal), "Scene prompt should not force the old V1 metric list.");
|
|
Assert(!validator.Contains("PreferredMetrics", StringComparison.Ordinal), "Validator should not reject custom configured metric names.");
|
|
Assert(validator.Contains("configured metric scale", StringComparison.Ordinal), "Validator error guidance should refer to configured metric scales.");
|
|
}
|
|
|
|
static void StoryIntelligenceRunnerSuppliesMetricsAndPlotLines()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
|
|
|
|
Assert(runner.Contains("ISceneMetricTypeRepository metricTypes", StringComparison.Ordinal), "Runner should load metric definitions from the configured project repository.");
|
|
Assert(runner.Contains("IPlotRepository plots", StringComparison.Ordinal), "Runner should load existing Plot Lines for AI matching context.");
|
|
Assert(runner.Contains("metric.IsActive && metric.IsEnabledForProject", StringComparison.Ordinal), "Runner should only pass active project-enabled metric types.");
|
|
Assert(runner.Contains("configuredMetrics = configuredMetrics.Select", StringComparison.Ordinal), "Scene context should include configured metrics.");
|
|
Assert(runner.Contains("existingPlotLines = existingPlotLines.Select", StringComparison.Ordinal), "Scene context should include existing author-created Plot Lines.");
|
|
Assert(runner.Contains("key = MetricContextKey(metric)", StringComparison.Ordinal), "Scene prompt metrics should use stable configured keys.");
|
|
Assert(runner.Contains("metricTypeId = metric.MetricTypeID", StringComparison.Ordinal), "Scene context should preserve MetricTypeID for later persistence.");
|
|
}
|
|
|
|
static void StoryIntelligenceMetricPersistenceUsesConfiguredKeys()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
|
|
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
|
|
|
|
Assert(service.Contains("map[$\"metric_{metric.MetricTypeID}\"] = metric", StringComparison.Ordinal), "Metric persistence should map configured context keys back to MetricTypeID.");
|
|
Assert(service.Contains("map.TryAdd(cleanedName, metric)", StringComparison.Ordinal), "Metric persistence should also accept configured metric names.");
|
|
Assert(service.Contains("Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue)", StringComparison.Ordinal), "Metric values should be clamped to configured scale.");
|
|
Assert(repository.Contains("\"dbo.SceneMetric_SaveValue\"", StringComparison.Ordinal), "Metric values should persist through the existing stored procedure.");
|
|
Assert(repository.Contains("new { SceneID = sceneId, metric.MetricTypeID, metric.Value, metric.Notes }", StringComparison.Ordinal), "Metrics should persist by SceneID and MetricTypeID.");
|
|
}
|
|
|
|
static void StoryIntelligenceNarrativeCandidatesRequireReview()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql"));
|
|
var models = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceNarrativeModels.cs"));
|
|
var sceneModels = File.ReadAllText(Path.Combine(root, "Models/SceneIntelligenceModels.cs"));
|
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
|
|
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml"));
|
|
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
|
|
var projectDelete = File.ReadAllText(Path.Combine(root, "Sql/124_Phase20Z2_StoryIntelligenceCommitAndProjectHardDelete.sql"));
|
|
var bookDelete = File.ReadAllText(Path.Combine(root, "Sql/134_Phase20AV_ArchivedBookProjectPermanentDeletion.sql"));
|
|
|
|
Assert(sql.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Narrative candidates should be persisted for review.");
|
|
Assert(sql.Contains("Status nvarchar(20) NOT NULL", StringComparison.Ordinal), "Narrative candidates should have review status.");
|
|
Assert(sql.Contains("CONSTRAINT DF_StoryIntelligenceNarrativeCandidates_Status DEFAULT N'Pending'", StringComparison.Ordinal), "Narrative candidates should default to pending review.");
|
|
Assert(models.Contains("StoryIntelligenceNarrativeCandidateTypes", StringComparison.Ordinal), "Candidate types should be explicit.");
|
|
Assert(sceneModels.Contains("SceneIntelligenceNarrativeArc", StringComparison.Ordinal), "Scene intelligence should expose explicit narrative arc signals.");
|
|
Assert(prompt.Contains("narrativeArcs", StringComparison.Ordinal) && prompt.Contains("matchingExistingPlotLineId", StringComparison.Ordinal), "Scene prompt should request narrative arc discovery and existing Plot Line matching.");
|
|
Assert(service.Contains("Where(signal => signal.SceneIds.Count > 1)", StringComparison.Ordinal), "Thread discovery should filter one-off incidental details.");
|
|
Assert(view.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "A dedicated narrative review page should be visible.");
|
|
Assert(view.Contains("Accept new", StringComparison.Ordinal) && view.Contains("Merge with existing", StringComparison.Ordinal) && view.Contains("Reject", StringComparison.Ordinal), "Review UI should support accept, merge and reject.");
|
|
Assert(projectDelete.Contains("StoryIntelligenceNarrativeCandidateEvidence", StringComparison.Ordinal) && projectDelete.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Project hard delete should clear narrative review candidates.");
|
|
Assert(bookDelete.Contains("StoryIntelligenceNarrativeCandidateEvidence", StringComparison.Ordinal) && bookDelete.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Book hard delete should clear narrative review candidates.");
|
|
}
|
|
|
|
static void StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration()
|
|
{
|
|
var signals = new[]
|
|
{
|
|
new StoryIntelligenceNarrativeSignalInput(101, "Maggie's Disappearance", "Beth notices Maggie is gone and starts looking for her.", "Plot Line", "disappearance", 0.86m, null, "Maggie is missing."),
|
|
new StoryIntelligenceNarrativeSignalInput(102, "Search for Maggie", "Beth follows a clue that may explain where Maggie went.", "Plot Line", "investigation", 0.82m, null, "Beth follows a clue."),
|
|
new StoryIntelligenceNarrativeSignalInput(103, "Finding Maggie", "The search for Maggie reaches a possible answer.", "Plot Line", "disappearance", 0.8m, null, "Maggie may have been found.")
|
|
};
|
|
|
|
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []);
|
|
var plotLine = consolidated.SingleOrDefault(signal => signal.Category == "Plot Line");
|
|
|
|
Assert(plotLine is not null, "Zero existing Plot Lines should still allow a new Plot Line candidate signal.");
|
|
if (plotLine is null) throw new InvalidOperationException("Expected a consolidated Plot Line signal.");
|
|
Assert(plotLine.ExistingPlotLineID is null, "A discovered Plot Line should not require an existing PlotLineID.");
|
|
Assert(plotLine.SceneIds.Count == 3, "The new Plot Line candidate should consolidate evidence from multiple scenes.");
|
|
Assert(plotLine.Evidence.Select(evidence => evidence.SceneID).Distinct().Count() == 3, "The candidate should retain evidence scene IDs.");
|
|
}
|
|
|
|
static void StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates()
|
|
{
|
|
var existingPlotLines = new[]
|
|
{
|
|
new PlotLineItem
|
|
{
|
|
PlotLineID = 42,
|
|
PlotLineName = "Maggie's Disappearance",
|
|
Description = "Beth searches for Maggie after she vanishes."
|
|
}
|
|
};
|
|
var signals = new[]
|
|
{
|
|
new StoryIntelligenceNarrativeSignalInput(201, "Search for Maggie", "Beth follows a clue in Maggie's disappearance.", "Plot Line", "investigation", 0.84m, null, "Beth follows a clue."),
|
|
new StoryIntelligenceNarrativeSignalInput(202, "Missing Maggie", "Maggie's absence drives the investigation.", "Plot Line", "disappearance", 0.81m, null, "Maggie remains missing."),
|
|
new StoryIntelligenceNarrativeSignalInput(203, "Where is Maggie?", "A lead suggests where Maggie may have gone.", "Plot Line", "disappearance", 0.79m, null, "A lead points to Maggie.")
|
|
};
|
|
|
|
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, existingPlotLines);
|
|
|
|
Assert(consolidated.Count == 1, "Matching scene variants should consolidate into one existing Plot Line signal, not duplicate candidates.");
|
|
Assert(consolidated[0].ExistingPlotLineID == 42, "The consolidated signal should carry the matching existing PlotLineID.");
|
|
Assert(consolidated[0].SceneIds.Count == 3, "The existing Plot Line match should retain evidence from multiple scenes.");
|
|
}
|
|
|
|
static void StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
|
|
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
|
|
var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql"));
|
|
|
|
Assert(service.Contains("plots.SavePlotLineAsync(new PlotLineItem", StringComparison.Ordinal), "Accepted Plot Line candidates should create canonical PlotLines through the existing plot service.");
|
|
Assert(service.Contains("plots.SavePlotThreadAsync(new PlotThread", StringComparison.Ordinal), "Accepted Thread candidates should create canonical PlotThreads through the existing plot service.");
|
|
Assert(service.Contains("plots.SaveThreadEventAsync(new ThreadEvent", StringComparison.Ordinal), "Accepted Thread evidence should create canonical ThreadEvents.");
|
|
Assert(repository.Contains("\"dbo.PlotLine_Save\"", StringComparison.Ordinal), "PlotLine creation should remain stored-procedure based.");
|
|
Assert(repository.Contains("\"dbo.PlotThread_Save\"", StringComparison.Ordinal), "PlotThread creation should remain stored-procedure based.");
|
|
Assert(repository.Contains("\"dbo.ThreadEvent_Save\"", StringComparison.Ordinal), "ThreadEvent creation should remain stored-procedure based.");
|
|
Assert(sql.Contains("StoryIntelligenceNarrativeCandidate_MarkReviewed", StringComparison.Ordinal), "Candidate decisions should be persisted.");
|
|
}
|
|
|
|
static void StoryIntelligenceNarrativeStageFollowsKnowledge()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var stages = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs"));
|
|
var pipeline = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs"));
|
|
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
|
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
|
|
var knowledgeComplete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceKnowledgeComplete.cshtml"));
|
|
|
|
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(onboarding.Contains("StoryIntelligenceResumeRoutes.Narrative", StringComparison.Ordinal), "Resume logic should route to narrative review.");
|
|
Assert(controller.Contains("StoryIntelligenceNarrative", StringComparison.Ordinal), "Onboarding controller should expose the narrative review stage.");
|
|
Assert(knowledgeComplete.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "Knowledge completion should lead to narrative review.");
|
|
}
|
|
|
|
static void WordCompanionReplaysLiveCurrentSceneToWritingPanes()
|
|
{
|
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs"));
|
|
var presence = File.ReadAllText(Path.Combine(root, "Services/WordCompanionPresenceService.cs"));
|
|
var workspace = File.ReadAllText(Path.Combine(root, "Services/WritingWorkspacePresenceService.cs"));
|
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
|
|
|
Assert(controller.Contains("presence.UpdateCurrentStoryPositionAsync", StringComparison.Ordinal), "Word current-scene updates should update live presence.");
|
|
Assert(presence.Contains("CurrentStoryPosition = connected ? record.CurrentStoryPosition : null", StringComparison.Ordinal), "Presence should only replay current scene while Word is live.");
|
|
Assert(workspace.Contains("CurrentStoryPosition = wordStatus.CurrentStoryPosition", StringComparison.Ordinal), "Workspace status should carry Word's current scene.");
|
|
Assert(script.Contains("storyPositionFromWorkspaceStatus", StringComparison.Ordinal), "Writing panes should read the current scene from workspace status.");
|
|
Assert(script.Contains("WatchWritingWorkspace", StringComparison.Ordinal), "Writing panes should request scoped workspace status on startup.");
|
|
}
|
|
|
|
static int CountCsvRows(string path)
|
|
=> File.ReadLines(path).Skip(1).Count(line => !string.IsNullOrWhiteSpace(line));
|
|
|
|
static JsonSerializerOptions JsonOptions()
|
|
=> new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
static void Assert(bool condition, string message)
|
|
{
|
|
if (!condition)
|
|
{
|
|
throw new InvalidOperationException(message);
|
|
}
|
|
}
|
|
|
|
internal sealed class CaptureImageRequestHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
=> Task.FromResult(respond(request));
|
|
}
|