返回 DeepSeek-Reasonix
todo-panel-lifecycle.test.tsx
根目录 / desktop / frontend / src / __tests__ / todo-panel-lifecycle.test.tsx
1 // Run: tsx src/__tests__/todo-panel-lifecycle.test.tsx
2
3 import assert from "node:assert/strict";
4 import { readFileSync } from "node:fs";
5 import React, { act } from "react";
6 import { JSDOM } from "jsdom";
7 import { createRoot } from "react-dom/client";
8
9 import { TodoPanel } from "../components/TodoPanel";
10 import { LocaleProvider } from "../lib/i18n";
11
12 const todoCss = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
13 assert.match(
14 todoCss,
15 /\.todo-exit\s*\{[^}]*animation:\s*shelf-in 240ms 900ms ease reverse both;/s,
16 "the completion animation holds for 900ms before its 240ms fade",
17 );
18
19 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
20 pretendToBeVisual: true,
21 url: "http://localhost/",
22 });
23 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
24 globalThis.window = dom.window as unknown as Window & typeof globalThis;
25 globalThis.document = dom.window.document;
26 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
27 globalThis.Node = dom.window.Node;
28 globalThis.HTMLElement = dom.window.HTMLElement;
29
30 interface ScheduledTimer {
31 id: number;
32 at: number;
33 callback: () => void;
34 }
35
36 let now = 0;
37 let nextTimerId = 1;
38 let timers: ScheduledTimer[] = [];
39 Object.defineProperty(window, "setTimeout", {
40 configurable: true,
41 value: (callback: () => void, delay = 0) => {
42 const id = nextTimerId++;
43 timers.push({ id, at: now + Number(delay), callback });
44 return id;
45 },
46 });
47 Object.defineProperty(window, "clearTimeout", {
48 configurable: true,
49 value: (id: number) => {
50 timers = timers.filter((timer) => timer.id !== id);
51 },
52 });
53
54 function advanceTimersBy(ms: number): void {
55 const target = now + ms;
56 while (true) {
57 timers.sort((a, b) => a.at - b.at || a.id - b.id);
58 const timer = timers[0];
59 if (!timer || timer.at > target) break;
60 timers.shift();
61 now = timer.at;
62 timer.callback();
63 }
64 now = target;
65 }
66
67 const host = document.getElementById("root");
68 if (!host) throw new Error("missing test root");
69 const root = createRoot(host);
70 let dismissCount = 0;
71 const onDismiss = () => { dismissCount += 1; };
72
73 await act(async () => {
74 root.render(
75 <LocaleProvider>
76 <TodoPanel
77 key="restored"
78 stateKey="session:restored\0batch"
79 todos={[
80 { content: "Inspect", status: "completed" },
81 { content: "Verify", status: "completed" },
82 { content: "Ship", status: "completed" },
83 ]}
84 running={false}
85 pendingPrompt={false}
86 onDismiss={onDismiss}
87 />
88 </LocaleProvider>,
89 );
90 });
91 assert.equal(host.querySelector(".prompt-shelf"), null, "a restored completed batch does not reattach above the composer");
92 assert.equal(timers.length, 0, "a restored completed batch does not schedule a stale fade");
93
94 await act(async () => {
95 root.render(
96 <LocaleProvider>
97 <TodoPanel
98 key="live"
99 stateKey="session:test\0batch"
100 todos={[
101 { content: "Inspect", status: "completed" },
102 { content: "Verify", status: "in_progress" },
103 { content: "Ship", status: "pending" },
104 ]}
105 running
106 pendingPrompt={false}
107 onDismiss={onDismiss}
108 />
109 </LocaleProvider>,
110 );
111 });
112 assert.ok(host.querySelector(".prompt-shelf"), "an incomplete batch renders above the composer");
113
114 await act(async () => {
115 root.render(
116 <LocaleProvider>
117 <TodoPanel
118 key="live"
119 stateKey="session:test\0batch"
120 todos={[
121 { content: "Inspect", status: "completed" },
122 { content: "Verify", status: "completed" },
123 { content: "Ship", status: "completed" },
124 ]}
125 running={false}
126 pendingPrompt={false}
127 onDismiss={onDismiss}
128 />
129 </LocaleProvider>,
130 );
131 });
132 assert.equal(host.textContent?.includes("3/3"), true, "the final completed count remains visible during the hold");
133 assert.ok(host.querySelector(".todo-exit"), "the completed shelf owns the delayed fade animation");
134
135 await act(async () => advanceTimersBy(899));
136 assert.ok(host.querySelector(".prompt-shelf"), "completion does not exit before the 900ms hold");
137
138 await act(async () => advanceTimersBy(1));
139 assert.ok(host.querySelector(".prompt-shelf"), "fade starts before the completion exit");
140
141 await act(async () => advanceTimersBy(239));
142 assert.ok(host.querySelector(".prompt-shelf"), "completion exit waits for the 240ms fade");
143 await act(async () => advanceTimersBy(1));
144 assert.equal(host.querySelector(".prompt-shelf"), null, "the completed shelf exits after its 1.14s completion transition");
145 assert.equal(dismissCount, 0, "automatic completion does not persist a manual-dismissal record");
146
147 await act(async () => root.unmount());
148 console.log("todo panel lifecycle checks passed");
149
149 lines Plain Text