返回 DeepSeek-Reasonix
navigation-surface-transition.test.ts
根目录 / desktop / frontend / src / __tests__ / navigation-surface-transition.test.ts
1 // Run: tsx src/__tests__/navigation-surface-transition.test.ts
2
3 import { readFileSync } from "node:fs";
4 import { navigateWorkspace } from "../app-runtime/navigationOwner";
5 import {
6 advanceSurfacePaintCommit,
7 beginNavigationSurfaceState,
8 createNavigationSurfaceTicket,
9 guardBackendNavigationResult,
10 markNavigationTargetMasked,
11 matchesNavigationSurfaceTicket,
12 settleNavigationSurfaceIntent,
13 settleNavigationSurfaceState,
14 } from "../lib/navigationSurfaceTransition";
15
16 let passed = 0;
17 let failed = 0;
18
19 function ok(value: boolean, label: string) {
20 process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
21 if (value) passed += 1;
22 else failed += 1;
23 }
24
25 console.log("\nnavigation surface transition");
26
27 let active: number | null = 1;
28 active = 2; // B supersedes A before A completes.
29 active = 3; // C supersedes queued B.
30 active = settleNavigationSurfaceIntent(active, 1);
31 ok(active === 3, "A completion cannot release C's mask");
32 active = settleNavigationSurfaceIntent(active, 2);
33 ok(active === 3, "coalesced B completion cannot release C's mask");
34 active = settleNavigationSurfaceIntent(active, 3);
35 ok(active === null, "the latest completion releases its own mask");
36
37 let surface = beginNavigationSurfaceState(9);
38 surface = markNavigationTargetMasked(surface, 8);
39 ok(surface?.phase === "source-retained", "a stale request cannot replace the retained source");
40 surface = markNavigationTargetMasked(surface, 9, "tab-target");
41 ok(surface?.phase === "target-masked", "the latest request mounts its target under the mask");
42 surface = settleNavigationSurfaceState(surface, 8);
43 ok(surface?.intent === 9, "a stale paint terminal cannot release the latest mask");
44 surface = settleNavigationSurfaceState(surface, 9);
45 ok(surface === null, "the matching paint terminal releases the mask");
46
47 const ticketA1 = createNavigationSurfaceTicket(20, "tab-a", "session-a:1");
48 const ticketB = createNavigationSurfaceTicket(21, "tab-b", "session-b:1");
49 const ticketA2 = createNavigationSurfaceTicket(22, "tab-a", "session-a:1");
50 ok(matchesNavigationSurfaceTicket(ticketA1, ticketA1.token, 20, "tab-a", "session-a:1"), "paint acknowledgement matches the complete ticket");
51 ok(!matchesNavigationSurfaceTicket(ticketB, ticketA1.token, 21, "tab-b", "session-b:1"), "an old paint token cannot commit B");
52 ok(!matchesNavigationSurfaceTicket(ticketA2, ticketA1.token, 22, "tab-a", "session-a:1"), "A → B → A cannot revive A's old paint token");
53 ok(!matchesNavigationSurfaceTicket(ticketA1, ticketA1.token, 20, "tab-a", "session-a:2"), "same-tab replacement session rejects the old ticket");
54
55 let paint = advanceSurfacePaintCommit({ attempts: 0, stableFrames: 0 }, {
56 rendered: true, placementReady: true, geometryReady: true, geometryKey: "755:1200:445",
57 });
58 ok(paint.outcome === undefined, "one paint frame cannot expose the target");
59 paint = advanceSurfacePaintCommit(paint.progress, {
60 rendered: true, placementReady: true, geometryReady: true, geometryKey: "859:1200:341",
61 });
62 ok(paint.outcome === undefined, "a changed viewport geometry restarts the stability gate");
63 paint = advanceSurfacePaintCommit(paint.progress, {
64 rendered: true, placementReady: true, geometryReady: true, geometryKey: "859:1200:341",
65 });
66 ok(paint.outcome === "ready", "two stable geometry frames commit the target");
67 let stalled = { attempts: 0, stableFrames: 0 };
68 let recoveryRequests = 0;
69 let degraded: string | undefined;
70 for (let frame = 0; frame < 180; frame += 1) {
71 const decision = advanceSurfacePaintCommit(stalled, {
72 rendered: true, placementReady: false, geometryReady: false, geometryKey: "755:0:0",
73 });
74 stalled = decision.progress;
75 if (decision.requestRecovery) recoveryRequests += 1;
76 degraded = decision.outcome;
77 }
78 ok(recoveryRequests === 2, "stalled placement receives two bounded recovery opportunities");
79 ok(degraded === "degraded", "the second failed placement terminates without permanent loading");
80
81 let reasserted = "";
82 const currentAccepted = await guardBackendNavigationResult({
83 intent: 4,
84 targetTabId: "tab-current",
85 kind: "tab.reveal-background",
86 isIntentCurrent: (intent) => intent === 4,
87 reassert: async (kind, tabId) => { reasserted = `${kind}:${tabId}`; },
88 });
89 ok(currentAccepted, "the current backend navigation result is accepted");
90 ok(reasserted === "", "the current backend navigation result does not reassert");
91
92 let releaseReassert!: () => void;
93 const reassertGate = new Promise<void>((resolve) => { releaseReassert = resolve; });
94 let staleReassertStarted = false;
95 const staleAcceptedPromise = guardBackendNavigationResult({
96 intent: 4,
97 targetTabId: "tab-stale",
98 kind: "tab.reveal-background",
99 isIntentCurrent: (intent) => intent === 5,
100 reassert: async (kind, tabId) => {
101 staleReassertStarted = true;
102 reasserted = `${kind}:${tabId}`;
103 await reassertGate;
104 },
105 });
106 await Promise.resolve();
107 ok(staleReassertStarted, "a stale backend-activating result starts visible-tab reassertion");
108 releaseReassert();
109 ok(await staleAcceptedPromise === false, "a stale backend-activating result is rejected after reassertion");
110 ok(reasserted === "tab.reveal-background:tab-stale", "stale reassertion receives the mutating target identity");
111
112 const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8");
113 const chatPaneSource = readFileSync(new URL("../app-shell/ChatPaneRegion.tsx", import.meta.url), "utf8");
114 const appViewSource = readFileSync(new URL("../app-shell/AppRuntimeView.tsx", import.meta.url), "utf8");
115 const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8");
116 const surfaceHookSource = readFileSync(new URL("../lib/useNavigationSurface.ts", import.meta.url), "utf8");
117 const tabBarSource = readFileSync(new URL("../app-runtime/useTabBarCommands.ts", import.meta.url), "utf8");
118 const stylesSource = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
119 ok(surfaceHookSource.includes("flushSync(() => {"), "navigation masking commits synchronously before the bridge await");
120 ok(surfaceHookSource.includes("setPreserved(rendered?.items.length ? rendered : null)"), "the last stable transcript is retained during navigation");
121 ok(sessionCompositionSource.includes("visibleTranscriptItems,") && appViewSource.includes("items: session.transcript.visibleTranscriptItems"), "the visible transcript is decoupled from the hydrating target");
122 ok(chatPaneSource.includes("transcript-navigation-overlay"), "remote navigation retains its blocking transcript overlay");
123 ok(/const presentationTransitioning = runtimeTransitioning && core\.remoteSurfaceActive;/.test(appViewSource)
124 && appViewSource.includes("transitioning={presentationTransitioning}"),
125 "local navigation bypasses the blocking overlay while remote navigation keeps the existing behavior");
126 ok(/\.transcript-navigation-overlay\s*\{[\s\S]*?background:\s*var\(--chat-bg, var\(--bg\)\)/.test(stylesSource), "the remote navigation overlay remains opaque while target rows settle");
127 ok(chatPaneSource.includes("live={transitioning ? undefined : state.live}"), "App removes source live output during navigation");
128 ok(!appSource.includes("hidden={composerSurfaceHidden || undefined}"), "navigation no longer collapses the composer footprint");
129 // Masked Composer/Todo/rewind layout is exercised through the mounted production
130 // DecisionFooterRegion in decision-footer-lifecycle.test.tsx, not App source text.
131 ok(/\.footer--navigation-hidden\s*\{[\s\S]*?visibility:\s*hidden;[\s\S]*?pointer-events:\s*none;/.test(stylesSource), "masked target footer cannot paint or receive input");
132 ok(appViewSource.includes('style={core.surface.surface?.phase === "source-retained"') && sessionCompositionSource.includes("const visibleDecisionSurface = decisionSurface"), "target-masked paint uses the target footer geometry");
133 ok((tabBarSource.match(/guardBackendNavigationResult\(\{/g) ?? []).length === 2, "both Reveal paths guard stale backend activation results");
134 ok(surfaceHookSource.includes("navigation.paint-ready"), "surface settlement is diagnosed only from target paint readiness");
135
136 let currentWorkspaceIntent = 30;
137 let releaseWorkspace!: (picked: string) => void;
138 const workspaceResult = new Promise<string>((resolve) => { releaseWorkspace = resolve; });
139 const workspaceCalls: string[] = [];
140 const staleWorkspace = navigateWorkspace("/workspace-a", {
141 claimIntent: () => currentWorkspaceIntent,
142 beginSurface: (intent) => workspaceCalls.push(`begin:${intent}`),
143 isIntentCurrent: (intent) => intent === currentWorkspaceIntent,
144 pickWorkspace: async () => "",
145 switchWorkspace: async (path, intent) => {
146 workspaceCalls.push(`switch:${intent}:${path}`);
147 return workspaceResult;
148 },
149 markProjectChanged: (updater) => { updater(0); workspaceCalls.push("changed"); },
150 refreshTabsAfterMutation: async (latest) => { workspaceCalls.push(`refresh:${latest() ? "current" : "stale"}`); },
151 maskTarget: (intent) => workspaceCalls.push(`mask:${intent}`),
152 });
153 currentWorkspaceIntent = 31;
154 releaseWorkspace("/workspace-a");
155 ok(await staleWorkspace === "/workspace-a", "source workspace data may finish after a newer navigation");
156 ok(!workspaceCalls.includes("changed") && !workspaceCalls.some((call) => call.startsWith("refresh:")), "stale workspace completion cannot mutate current UI");
157 ok(workspaceCalls[workspaceCalls.length - 1] === "mask:30", "old workspace finally addresses only its own surface intent");
158
159 console.log(`\n${passed} passed, ${failed} failed`);
160 if (failed > 0) process.exit(1);
161
161 lines TYPESCRIPT