From 07113f9b36e348cdca8d9680adaa099e6e281323 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 30 Aug 2026 22:18:27 +0000 Subject: [PATCH] Allow completed character review inspection --- PlotLine.Tests/Program.cs | 36 +++++++++++++++++++ PlotLine/Controllers/OnboardingController.cs | 4 +-- .../StoryIntelligenceCharacters.cshtml | 18 ++++++++-- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 141bebd..33d66de 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -281,6 +281,7 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls), ("Story Intelligence Review Centre uses bounded summary query", StoryIntelligenceReviewCentreUsesBoundedSummaryQuery), ("Story Intelligence detailed review GETs use selected module paths", StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths), + ("Story Intelligence completed Character review remains inspectable", StoryIntelligenceCompletedCharacterReviewRemainsInspectable), ("Story Intelligence detailed reviews read persisted candidate pages", StoryIntelligenceDetailedReviewsReadPersistedCandidatePages), ("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution), ("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals), @@ -1138,6 +1139,41 @@ static void StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths() Assert(!persistedReviewService.Contains("GetRunAsync", StringComparison.Ordinal), "Persisted review GET service must not traverse old run batches."); } +static void StoryIntelligenceCompletedCharacterReviewRemainsInspectable() +{ + var root = PlotLineRoot(); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); + var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs")); + var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml")); + + var action = ExtractAction(controller, "public async Task StoryIntelligenceCharacters"); + Assert(action.Contains("GetCharacterReviewAsync(batchId, page)", StringComparison.Ordinal), "Character GET should use the selected persisted Character review path."); + Assert(!action.Contains("CharacterReview.Candidates.Count == 0", StringComparison.Ordinal), "Completed empty Character review must not redirect to Review Centre."); + Assert(!action.Contains("CharacterGateSatisfied &&", StringComparison.Ordinal), "Character completion must not be treated as an access blocker."); + Assert(action.TrimEnd().EndsWith("return View(model);\n }", StringComparison.Ordinal), "Character GET should render the Character page when scene analysis is complete."); + Assert(!action.Contains("PrepareCharacterCandidatesAsync", StringComparison.Ordinal), "Character GET must not regenerate candidates."); + Assert(!action.Contains("RecordCharacterImportAsync", StringComparison.Ordinal), "Character GET must not alter CharacterReviewCompletedUtc."); + Assert(!action.Contains("RecordLocationImportAsync", StringComparison.Ordinal), "Character GET must not alter Location completion."); + Assert(!action.Contains("RecordAssetImportAsync", StringComparison.Ordinal), "Character GET must not alter Asset completion."); + Assert(!action.Contains("RecordRelationshipImportAsync", StringComparison.Ordinal), "Character GET must not alter Relationship completion."); + Assert(!action.Contains("RecordKnowledgeImportAsync", StringComparison.Ordinal), "Character GET must not alter Knowledge completion."); + + var selectedReview = ExtractBetween(onboarding, "public Task GetCharacterReviewAsync", "public Task GetLocationReviewAsync"); + Assert(selectedReview.Contains("reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Completed Character page should hydrate from persisted review/canonical data."); + Assert(!selectedReview.Contains("PrepareCharacterCandidatesAsync", StringComparison.Ordinal), "Selected Character GET must not prepare candidates."); + Assert(!selectedReview.Contains("RecordCharacterImportAsync", StringComparison.Ordinal), "Selected Character GET must not mark completion."); + + var getReview = ExtractBetween(candidateService, "public async Task GetCharacterReviewAsync", "public async Task GetLocationReviewAsync"); + Assert(getReview.Contains("AliasTargetOptions = existingOptions", StringComparison.Ordinal), "Completed Character page should expose canonical resolved characters for inspection."); + Assert(!getReview.Contains("BuildReviewAsync", StringComparison.Ordinal), "Persisted Character GET must not rebuild candidates."); + Assert(!getReview.Contains("MarkReviewedAsync", StringComparison.Ordinal), "Persisted Character GET must not change decisions."); + + Assert(view.Contains("Character Review is complete.", StringComparison.Ordinal), "Completed Character page should state that the review is complete."); + Assert(view.Contains("Resolved character", StringComparison.Ordinal), "Completed Character page should list resolved canonical characters."); + Assert(view.Contains("AliasTargetOptions", StringComparison.Ordinal), "Completed Character page should use canonical Character data when no pending candidates exist."); +} + static void StoryIntelligenceDetailedReviewsReadPersistedCandidatePages() { var root = PlotLineRoot(); diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 86049ff..0976ff4 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -339,9 +339,7 @@ public sealed class OnboardingController( return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } - return model.CharacterGateSatisfied && model.CharacterReview.Candidates.Count == 0 - ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }) - : View(model); + return View(model); } [HttpGet("story-intelligence/locations")] diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml index e827c7b..6c40925 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml @@ -35,9 +35,23 @@ @if (Model.CharacterReview.Candidates.Count == 0) {
- No character decisions are waiting. -

Characters are already up to date, or there were no character suggestions ready to import.

+ Character Review is complete. +

@Model.CharacterReview.AliasTargetOptions.Count.ToString("N0") resolved character@(Model.CharacterReview.AliasTargetOptions.Count == 1 ? string.Empty : "s") are available for this book.

+ @if (Model.CharacterReview.AliasTargetOptions.Count > 0) + { +
+ @foreach (var character in Model.CharacterReview.AliasTargetOptions) + { +
+
+ @character.CharacterName +

Resolved character

+
+
+ } +
+ }