diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index c8a65da..73777c3 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -264,6 +264,7 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence global banner remains visible through finalising gaps", StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps), ("Story Intelligence global banner rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots), ("Story Intelligence live global progress publishes authoritative scene aggregates", StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggregates), + ("Story Intelligence global progress survives initial SignalR start races", StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces), ("Story Intelligence scene analysis completion barrier releases review", StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview), ("Story Intelligence active progress only appears in global strip", StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip), ("Story Intelligence book actions do not open review centre while running", StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning) @@ -471,13 +472,34 @@ static void StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggreg Assert(globalService.Contains("Task GetForUserAsync(int userId)", StringComparison.Ordinal), "Background publishing should be able to retrieve the same user-scoped aggregate used by page load."); Assert(notifier.Contains("GetForUserAsync(userId)", StringComparison.Ordinal), "Live progress publish should fetch the authoritative aggregate for the run owner."); Assert(notifier.Contains("StoryIntelligenceGlobalProgressChanged", StringComparison.Ordinal), "Live progress publish should send a compact aggregate SignalR event."); - Assert(script.Contains("connection.on(\"StoryIntelligenceGlobalProgressChanged\", render)", StringComparison.Ordinal), "The global banner should render authoritative aggregate payloads directly."); - Assert(script.Contains("connection.onreconnected(refresh)", StringComparison.Ordinal), "Reconnect should rejoin the user group and immediately catch up from persisted aggregate state."); + Assert(script.Contains("connection.on(\"StoryIntelligenceGlobalProgressChanged\"", StringComparison.Ordinal) + && script.Contains("render(snapshot);", StringComparison.Ordinal), "The global banner should render authoritative aggregate payloads directly."); + Assert(script.Contains("connection.onreconnected", StringComparison.Ordinal) + && script.Contains("refresh();", StringComparison.Ordinal), "Reconnect should rejoin the user group and immediately catch up from persisted aggregate state."); Assert(script.Contains("updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "Equal-timestamp out-of-order snapshots should not regress completed/failed scene totals."); Assert(layout.Contains("data-completed-scenes", StringComparison.Ordinal), "Initial server render should seed completed count for monotonic live comparisons."); Assert(layout.Contains("data-failed-scenes", StringComparison.Ordinal), "Initial server render should seed terminal failed count for monotonic live comparisons."); } +static void StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces() +{ + var root = PlotLineRoot(); + var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml")); + var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js")); + + Assert(layout.Contains("User.Identity?.IsAuthenticated == true", StringComparison.Ordinal), "Authenticated pages should always render the global progress host so a later import can appear without a full refresh."); + Assert(layout.Contains("data-story-intelligence-has-active-work", StringComparison.Ordinal), "Initial render should expose whether the hidden strip currently has active work."); + Assert(layout.Contains("hidden=\"@(!storyIntelligenceProgress.HasActiveWork)\"", StringComparison.Ordinal), "The global strip should be hidden, not absent, when no active work is visible at page render."); + Assert(script.Contains("const startWithRetry = () =>", StringComparison.Ordinal), "Initial SignalR start failures should retry instead of leaving the banner permanently stale."); + Assert(script.Contains("startRetryTimer = window.setTimeout(startWithRetry, 2000)", StringComparison.Ordinal), "Initial start retry should be bounded and low-noise."); + Assert(script.Contains("connection.onclose(() =>", StringComparison.Ordinal), "The client should recover after automatic reconnect is exhausted."); + Assert(script.Contains("connection.onreconnected(() =>", StringComparison.Ordinal), "Reconnect should immediately refresh authoritative persisted progress."); + Assert(script.Contains(".then(() =>") && script.Contains("refresh();"), "Successful connection start should close the render/subscription race by fetching the current aggregate."); + Assert(script.Contains("snapshotKey !== lastSnapshotKey", StringComparison.Ordinal), "Progress monotonicity should reset only when the active book scope changes."); + Assert(script.Contains("snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "A newer but backwards snapshot for the same book must not regress the banner."); + Assert(script.Contains("StoryIntelligenceGlobalProgress]"), "Development builds should expose low-noise client diagnostics."); +} + static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview() { var root = PlotLineRoot(); @@ -581,7 +603,8 @@ static void StoryIntelligenceRecoveredJsonRepairStaysOperational() Assert(notificationSql.Contains("Status IN (N'Failed', N'Cancelled')", StringComparison.Ordinal), "Failure notification should be based on final failed/cancelled runs."); Assert(notificationSql.Contains("SET @NotificationKind = CASE WHEN @HasFailedRuns = 1 THEN N'Failure' ELSE N'Success' END", StringComparison.Ordinal), "Recovered runs should not generate failure notifications."); Assert(progressScript.Contains("current.failedScenes += failed", StringComparison.Ordinal), "Onboarding Needs review should use persisted terminal FailedScenes only."); - Assert(globalScript.Contains("const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0", StringComparison.Ordinal), "Global banner should use terminal failed-scene count only."); + Assert(globalScript.Contains("const failed = readNumber(progress.failedScenes ?? progress.FailedScenes)", StringComparison.Ordinal) + && globalScript.Contains("readNumber(primary.failedScenes ?? primary.FailedScenes)", StringComparison.Ordinal), "Global banner should use terminal failed-scene count only."); } static void StoryIntelligenceTerminalFailuresRemainUserVisible() diff --git a/PlotLine/Views/Shared/_Layout.cshtml b/PlotLine/Views/Shared/_Layout.cshtml index fb95c40..6fc4ab7 100644 --- a/PlotLine/Views/Shared/_Layout.cshtml +++ b/PlotLine/Views/Shared/_Layout.cshtml @@ -228,7 +228,7 @@ - @if (storyIntelligenceProgress.HasActiveWork) + @if (User.Identity?.IsAuthenticated == true) { var primaryBook = storyIntelligenceProgress.Books.FirstOrDefault(book => book.HasActiveRuns); var progressUpdatedUtc = storyIntelligenceProgress.UpdatedUtc?.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture); @@ -242,8 +242,10 @@ data-story-intelligence-updated-utc="@progressUpdatedUtc" data-book-id="@primaryBook?.BookID" data-project-id="@primaryBook?.ProjectID" + data-story-intelligence-has-active-work="@storyIntelligenceProgress.HasActiveWork.ToString().ToLowerInvariant()" role="status" - aria-live="polite"> + aria-live="polite" + hidden="@(!storyIntelligenceProgress.HasActiveWork)">
diff --git a/PlotLine/wwwroot/js/story-intelligence-global-progress.js b/PlotLine/wwwroot/js/story-intelligence-global-progress.js index 72798cb..2a37d75 100644 --- a/PlotLine/wwwroot/js/story-intelligence-global-progress.js +++ b/PlotLine/wwwroot/js/story-intelligence-global-progress.js @@ -8,6 +8,16 @@ const detail = strip.querySelector("[data-story-intelligence-global-detail]"); const bar = strip.querySelector("[data-story-intelligence-global-bar]"); let refreshTimer = null; + let startRetryTimer = null; + const diagnosticsEnabled = + location.hostname === "localhost" + || location.hostname === "127.0.0.1" + || location.hostname.startsWith("dev."); + const log = (message, data) => { + if (diagnosticsEnabled && window.console?.debug) { + window.console.debug(`[StoryIntelligenceGlobalProgress] ${message}`, data || ""); + } + }; let lastSnapshotTime = Date.parse(strip.dataset.storyIntelligenceUpdatedUtc || "") || 0; const readNumber = (value) => { const parsed = Number.parseInt(value || "", 10); @@ -16,6 +26,7 @@ let lastSnapshotScore = (readNumber(strip.dataset.completedScenes) || 0) + (readNumber(strip.dataset.failedScenes) || 0); + let lastSnapshotKey = `${strip.dataset.projectId || ""}:${strip.dataset.bookId || ""}`; const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0)); const estimateRemaining = (elapsedMs, completed, failed, total, status) => { @@ -52,15 +63,36 @@ const primary = books.find((book) => book.hasActiveRuns || book.HasActiveRuns) || books[0]; const activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || books.length; const status = progress.status || progress.Status || primary.status || primary.Status || ""; - const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes) || 0; - const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0; - const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0; - const active = readNumber(progress.activeScenes ?? progress.ActiveScenes) || 0; + const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes) + ?? readNumber(primary.totalExpectedScenes ?? primary.TotalExpectedScenes) + ?? 0; + const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) + ?? readNumber(primary.completedScenes ?? primary.CompletedScenes) + ?? 0; + const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) + ?? readNumber(primary.failedScenes ?? primary.FailedScenes) + ?? 0; + const active = readNumber(progress.activeScenes ?? progress.ActiveScenes) + ?? readNumber(primary.activeScenes ?? primary.ActiveScenes) + ?? 0; const updatedRaw = progress.updatedUtc || progress.UpdatedUtc || primary.updatedUtc || primary.UpdatedUtc || ""; const updatedTime = Date.parse(updatedRaw); + const snapshotKey = `${primary.projectID ?? primary.projectId ?? ""}:${primary.bookID ?? primary.bookId ?? ""}`; const snapshotScore = completed + failed; + if (snapshotKey !== lastSnapshotKey) { + lastSnapshotKey = snapshotKey; + lastSnapshotScore = 0; + lastSnapshotTime = 0; + } + + if (snapshotScore < lastSnapshotScore) { + log("ignored backwards snapshot", { completed, failed, updatedUtc: updatedRaw, snapshotScore, lastSnapshotScore }); + return; + } + if (Number.isFinite(updatedTime)) { if (updatedTime < lastSnapshotTime || (updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore)) { + log("ignored stale snapshot", { completed, failed, updatedUtc: updatedRaw, snapshotScore, lastSnapshotScore }); return; } @@ -73,6 +105,8 @@ lastSnapshotScore = Math.max(lastSnapshotScore, snapshotScore); strip.dataset.completedScenes = String(completed); strip.dataset.failedScenes = String(failed); + strip.dataset.projectId = String(primary.projectID ?? primary.projectId ?? ""); + strip.dataset.bookId = String(primary.bookID ?? primary.bookId ?? ""); strip.hidden = false; strip.dataset.connection = "live"; @@ -105,13 +139,18 @@ title.textContent = primary.summaryText || primary.SummaryText || title.textContent; } } + log("applied snapshot", { completed, failed, total, active, status, updatedUtc: updatedRaw }); }; const refresh = () => { connection.invoke("WatchGlobalStoryIntelligenceProgress") - .then(render) + .then((snapshot) => { + log("authoritative refresh received", snapshot); + render(snapshot); + }) .catch(() => { strip.dataset.connection = "offline"; + log("authoritative refresh failed"); }); }; @@ -126,11 +165,34 @@ .build(); connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh); - connection.on("StoryIntelligenceGlobalProgressChanged", render); - connection.onreconnected(refresh); - connection.start() - .then(refresh) - .catch(() => { - strip.dataset.connection = "offline"; - }); + connection.on("StoryIntelligenceGlobalProgressChanged", (snapshot) => { + log("live snapshot received", snapshot); + render(snapshot); + }); + connection.onreconnected(() => { + log("SignalR reconnected"); + refresh(); + }); + connection.onclose(() => { + strip.dataset.connection = "offline"; + log("SignalR closed; retrying start"); + startWithRetry(); + }); + + const startWithRetry = () => { + window.clearTimeout(startRetryTimer); + connection.start() + .then(() => { + strip.dataset.connection = "live"; + log("SignalR connected"); + refresh(); + }) + .catch(() => { + strip.dataset.connection = "offline"; + log("SignalR initial start failed; retrying"); + startRetryTimer = window.setTimeout(startWithRetry, 2000); + }); + }; + + startWithRetry(); })();