Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ import {
type SerializedTasksMessage,
} from './components/messages/TasksStatusMessage';
import { SessionWorkflowCockpit } from './components/workflow/SessionWorkflowCockpit';
import { buildSessionWorkflowProjection } from './components/workflow/session-workflow-model';
import { serializeContextUsageMessage } from './components/messages/ContextUsageMessage';
import {
serializeStatsMessage,
Expand Down Expand Up @@ -11377,6 +11378,27 @@ export function App({
: [],
[floatingTodosState, messages, tasksDialogMessage],
);
// One projection per render for every session-workflow surface. The
// cockpit, the artifact-panel inspector and the graph embedded in the
// cockpit each used to derive their own copy of the same projection; they
// now share this one, which also carries the single task-execution index
// they all read from.
const sessionWorkflowProjection = useMemo(
() =>
sessionWorkflowEnabled
? buildSessionWorkflowProjection(
sessionWorkflowTodos,
planAgentTools,
environmentAgentTasks,
)
: undefined,
[
environmentAgentTasks,
planAgentTools,
sessionWorkflowEnabled,
sessionWorkflowTodos,
],
);
const reloadTargetedWorkspaceSettings = useCallback(async () => {
const status = await reloadWorkspaceSettings();
if (mainVoiceTarget?.route === 'workspace-qualified') {
Expand Down Expand Up @@ -17935,6 +17957,7 @@ export function App({
todos: sessionWorkflowTodos,
tools: planAgentTools,
tasks: environmentAgentTasks,
projection: sessionWorkflowProjection,
artifacts,
selectedTodoId: selectedWorkflowTodoId,
onSelectedTodoIdChange: setSelectedWorkflowTodoId,
Expand Down Expand Up @@ -19395,6 +19418,7 @@ export function App({
todos={sessionWorkflowTodos}
tools={planAgentTools}
tasks={environmentAgentTasks}
projection={sessionWorkflowProjection}
selectedTodoId={selectedWorkflowTodoId}
onSelectedTodoIdChange={setSelectedWorkflowTodoId}
onBackToChat={closeCockpit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import { AgentWorkflow } from './AgentWorkflow';
import type { EnvironmentAgentTask } from '../panels/EnvironmentPanel';
import { SideTaskPanel } from './SideTaskPanel';
import { SessionWorkflowInspector } from '../workflow/SessionWorkflowInspector';
import type { SessionWorkflowProjection } from '../workflow/session-workflow-model';
import { TerminalPanel } from '../terminal/TerminalPanel';
import { WebPreviewPanel } from '../preview/WebPreviewPanel';
import { SavedWebPreview } from '../preview/SavedWebPreview';
Expand Down Expand Up @@ -417,6 +418,8 @@ interface ArtifactPanelProps {
todos: readonly TodoItem[];
tools: readonly ACPToolCall[];
tasks: readonly DaemonSessionTaskStatus[];
/** Shared per-render projection; also feeds the cockpit and its graph. */
projection?: SessionWorkflowProjection;
artifacts: readonly DaemonSessionArtifact[];
selectedTodoId?: string;
onSelectedTodoIdChange: (todoId: string | undefined) => void;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// @vitest-environment jsdom

import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { TodoItem } from '../../adapters/types';
import { I18nProvider } from '../../i18n';
import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';

// Acceptance #10865, graph-side guarantees, pinned by counting rather than
// by inspection:
// - hovering a node re-renders without re-running the topological layering
// or the topology serialization;
// - `measure` runs at most once per animation frame even when a resize
// storm lands several schedule calls inside one frame.
// In its own file so the module mock cannot reach the behavioural suite
// next to it.
const counts = vi.hoisted(() => ({ layers: 0 }));

vi.mock('./PlanExecutionView', async (importOriginal) => {
const actual = await importOriginal<typeof import('./PlanExecutionView')>();
return {
...actual,
layerPlanTodos: (...args: Parameters<typeof actual.layerPlanTodos>) => {
counts.layers += 1;
return actual.layerPlanTodos(...args);
Comment on lines +24 to +26

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The counts.layers counter this new test relies on can never increment, so both expect(counts.layers).toBe(layersAfterMount) assertions reduce to 0 === 0 and the "hover does not re-run the topological layering" half of the file's stated guarantee is unpinned. vi.mock('./PlanExecutionView', … importOriginal) replaces the module's exported binding, but the component under test is ...actual, whose closure calls layerPlanTodos(todos) through the module-local declaration inside its own useMemo — a module mock does not rewrite intra-module call sites.

Hoist const layers = hasDependencies ? layerPlanTodos(todos) : [todos.slice()] out of the useMemo at PlanExecutionView.tsx:249 into the component body, or split layering into a separate memo keyed on focus/hover state, and every pointerover/pointerout/focus/blur re-runs the O(V+E) topological sort — the exact regression issue #10865's acceptance criterion forbids and this file's header claims to pin "by counting rather than by inspection" — while the suite stays green. The co-located JSON.stringify spy IS a genuine global-property spy and does pin the serialization half, so only the layering counter is dead; the contrast is the sibling suites, which mock a different module from the one their consumers import, and whose counters therefore do increment.

Witness:

Probe with the test's own mock shape: nodes rendered (layering really ran): 3 / counts.layers after mount: 0 / counts.layers after direct namespace call: 1.
Mutant M1 (the call hoisted into the component body), counter instrumented on the function itself: real layerPlanTodos calls afterMount = 2 / afterHover = 3 / hover delta = 1 — while the shipped file reports 'Tests 2 passed (2)'.
Intact arm (M1 reverted, instrument kept): afterMount = 1 / afterHover = 1 / hover delta = 0, shipped test green.
Control M2 (topology + JSON.stringify(topology) hoisted out instead): shipped test RED — AssertionError: expected 1 to be +0 at :107.

Give the layering a seam a mock can reach: move layerPlanTodos into its own module (e.g. planTodoLayering.ts), import it in PlanExecutionView.tsx, re-export it from there alongside the existing taskExecutionIndex shim, and point this test's vi.mock at the new module. Then add a mount-time sanity assertion — expect(layersAfterMount).toBeGreaterThan(0) — so a non-intercepting mock fails loudly instead of passing silently. Alternative: delete the dead counter and its two assertions and correct the header comment to claim only the serialization pin.

Constraint the fix must not violate: PlanExecutionView.test.tsx:10-18 imports layerPlanTodos plus getActiveAgents, getAttentionAgentTool, getPlanNodeState, nestedAgentToolsForTool and nestedTasksForTool from './PlanExecutionView', and the comment this diff adds at PlanExecutionView.tsx:30-32 states the re-export block exists "to keep this module's public surface stable for its existing importers (tests included)" — any extraction must preserve that re-export.

Acceptance: PlanExecutionView.derivation.test.tsx › 'does not re-run the layering or the topology serialization on hover'. After the fix, expect(layersAfterMount).toBeGreaterThan(0) must hold at mount, and moving the layerPlanTodos(todos) call out of the useMemo into the component body must turn expect(counts.layers).toBe(layersAfterMount) red after the pointerover dispatch. Both mutations leave the test green today — that is the gap.

中文说明

这个新测试所依赖的 counts.layers 计数器永远不会自增,因此两处 expect(counts.layers).toBe(layersAfterMount) 断言实际都退化成 0 === 0,文件头声称要钉住的「hover 不会重跑拓扑分层」这半边保证并没有被钉住。

vi.mock('./PlanExecutionView', … importOriginal) 替换的是模块导出的绑定,但被测组件是 ...actual,它的闭包通过模块内部的局部声明调用 layerPlanTodos(todos)(在组件自己的 useMemo 里)。模块 mock 不会改写模块内部的调用点,所以包装函数根本不在渲染路径上。

layerPlanTodos(todos)useMemo 里提到组件函数体,或把分层拆进另一个以 hover/focus 状态为依赖的 memo,那么每次 pointerover/pointerout/focus/blur 都会重跑 O(V+E) 的拓扑排序——正是 issue #10865 验收标准禁止、也是本文件头声称「用计数而非人工检查钉住」的那个回归——而测试套件依然全绿。

需要说明的边界:同一个测试里的 JSON.stringify spy 是真正的全局属性 spy,它确实钉住了拓扑序列化那一半,所以失效的只有分层计数器。对照的是同批新增的另外两个套件:它们 mock 的是与消费方不同的模块,所以计数器是真正的跨模块命名空间查找,确实会自增。

(上面的 Witness 是程序输出,按原样保留。)

修复方向:给分层一个 mock 能拦到的接缝——把 layerPlanTodos 移到独立模块,在 PlanExecutionView.tsx 里 import 并 re-export,然后把本测试的 vi.mock 指向新模块;同时补一条挂载期自检断言 expect(layersAfterMount).toBeGreaterThan(0),让「mock 没拦住」这种情况直接失败而不是静默通过。另一种选择是删掉这个死计数器与两处断言,并把文件头注释改为只声称钉住了序列化。

约束:PlanExecutionView.test.tsx:10-18'./PlanExecutionView' 导入 layerPlanTodos 等六个名字,而本 diff 在 PlanExecutionView.tsx:30-32 新增的注释说明 re-export 块正是「为了保持本模块对既有导入方(含测试)的公开面稳定」——任何抽取都必须保留这个 re-export。

验收:修好之后,expect(layersAfterMount).toBeGreaterThan(0) 在挂载后必须成立;把 layerPlanTodos(todos) 调用移出 useMemo 到组件体,必须让 hover 之后的 expect(counts.layers).toBe(layersAfterMount) 变红。今天这两种变更都保持绿色,这就是缺口。

— qwen3.8-max via Qwen Code /review (v0.23.0)

},
};
});

const { PlanExecutionView } = await import('./PlanExecutionView');

const todos: TodoItem[] = [
{ id: 'research', content: 'Research', status: 'completed' },
{
id: 'build',
content: 'Build',
status: 'in_progress',
blockedBy: ['research'],
},
{
id: 'verify',
content: 'Verify',
status: 'pending',
blockedBy: ['build'],
},
];

// Each test's tree is unmounted after the test: the graph binds a window
// resize listener, and a leaked listener would double-count the next test's
// resize storm.
const roots: Root[] = [];

function mount(): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(
<I18nProvider language="en">
<TranscriptRenderModeProvider>
<PlanExecutionView todos={todos} tools={[]} tasks={[]} />
Comment on lines +62 to +63

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new derivation test renders TranscriptRenderModeProvider with no value prop. transcriptRenderMode.ts:9-10 exports it as the bare TranscriptRenderModeContext.Provider, so omitting value yields undefined and overrides the createContext('interactive') default at :5-6 — React logs a required-prop error on every run, and the component under test receives a render mode no production host produces.

documentMode at PlanExecutionView.tsx:201 (const documentMode = useTranscriptRenderMode() === 'document') is computed from undefined rather than from a declared mode, and it gates real behaviour at :898 (disabled={documentMode}) on the plan-node button. So the two guarantees this file exists to pin — hover does not re-run the layering, one measure per animation frame across a resize storm — are asserted only for an accidental undefined mode, never for the modes production uses; and every CI run of the web-shell suite carries a React provider-misuse warning attributed to this new file, which readers must triage as a non-defect each time. Every other usage in the package passes a value (PlanExecutionView.test.tsx:95, UserMessage.test.tsx:77, MessageList.dom.test.tsx:430, ParallelAgentsGroup.test.tsx:105, production WebShellTranscript.tsx:249); the sibling new suite session-workflow-surfaces.test.tsx omits the provider entirely and so warns nothing.

Witness:

From running the file: stderr | components/messages/PlanExecutionView.derivation.test.tsx > PlanExecutionView derivation discipline > does not re-run the layering or the topology serialization on hover — 'The `value` prop is required for the `<Context.Provider>`. Did you misspell it or forget to pass it?' followed by '(2 tests) 51ms' passing. One warning per test, independently observed by four agents across three review rounds.
Suggested change
<TranscriptRenderModeProvider>
<PlanExecutionView todos={todos} tools={[]} tasks={[]} />
<TranscriptRenderModeProvider value="interactive">
<PlanExecutionView todos={todos} tools={[]} tasks={[]} />

Pass the mode the assertions target — — matching the context default, or drop the wrapper and let the default apply as the sibling suite does.

Constraint the fix must not violate: The value must keep documentMode false: PlanExecutionView.tsx:201 has exactly one read site, :898 disabled={documentMode}, on the plan-node button, so value="document" (the mode the sibling PlanExecutionView.test.tsx:95 uses) is not a drop-in choice here — it would render disabled on the very node the hover assertion targets via container.querySelector('[data-plan-node-id="build"]')?.closest('article'). Use 'interactive' or 'readonly', per transcriptRenderMode.ts:3 export type TranscriptRenderMode = 'interactive' | 'readonly' | 'document'.

中文说明

新增的 derivation 测试渲染 TranscriptRenderModeProvider 时没有传 valuetranscriptRenderMode.ts:9-10 导出的就是裸的 TranscriptRenderModeContext.Provider,所以省略 value 得到的是 undefined,它会覆盖:5-6createContext<TranscriptRenderMode>('interactive') 的默认值——React 每次运行都会打印必填 prop 的错误,被测组件拿到的是一个生产环境永远不会出现的渲染模式。

PlanExecutionView.tsx:201documentModeuseTranscriptRenderMode() === 'document')于是基于 undefined 计算,而它在 :898 通过 disabled={documentMode} 影响真实行为(计划节点按钮)。也就是说,本文件要钉住的两条保证——hover 不重跑分层、一次 resize 风暴只跑一轮 measure——只在一个意外的 undefined 模式下被断言过,生产真正使用的模式从未被覆盖;并且每次 CI 运行 web-shell 套件都会带上这条归属于新文件的 React provider 误用告警,读者每次都要重新判断它不是缺陷。包内其他所有使用点都传了 value(PlanExecutionView.test.tsx:95UserMessage.test.tsx:77MessageList.dom.test.tsx:430ParallelAgentsGroup.test.tsx:105,生产代码 WebShellTranscript.tsx:249);同批新增的 session-workflow-surfaces.test.tsx 干脆不套这个 provider,因此不产生告警。

(上面的 Witness 是程序输出,按原样保留。)

修复方向:传入断言真正针对的模式 <TranscriptRenderModeProvider value="interactive">(与 context 默认值一致),或者像姊妹套件那样直接去掉这层包裹、让默认值生效。

约束:传入的值必须让 documentMode 保持 false。PlanExecutionView.tsx:201 只有一个读取点,即 :898 计划节点按钮上的 disabled={documentMode},所以这里不能照搬姊妹测试 PlanExecutionView.test.tsx:95 用的 value="document"——那会让 hover 断言要操作的那个节点(container.querySelector('[data-plan-node-id="build"]')?.closest('article'))直接变成 disabled。请用 'interactive''readonly',取值范围见 transcriptRenderMode.ts:3

验收:N/A——没有任何断言读取这个 context,所以去掉新加的 value 不会让任何测试变红。可观察的验收标准是:套件运行时这条 React 告警消失,同时两个用例仍然通过。

— qwen3.8-max via Qwen Code /review (v0.23.0)

</TranscriptRenderModeProvider>
</I18nProvider>,
);
});
return container;
}

afterEach(() => {
for (const root of roots.splice(0)) {
act(() => root.unmount());
}
});

describe('PlanExecutionView derivation discipline', () => {
it('does not re-run the layering or the topology serialization on hover', () => {
const container = mount();
const stringifySpy = vi.spyOn(JSON, 'stringify');
const layersAfterMount = counts.layers;
const stringifyAfterMount = stringifySpy.mock.calls.length;

const buildNode = container
.querySelector('[data-plan-node-id="build"]')
?.closest('article');
expect(buildNode).toBeTruthy();

const edges = container.querySelector('[data-plan-edge]')?.closest('svg');
expect(edges?.getAttribute('data-focused')).toBeNull();

// jsdom has no PointerEvent; React synthesizes onPointerEnter from a
// bubbling pointerover, and onPointerLeave from pointerout.
act(() => {
buildNode?.dispatchEvent(
new MouseEvent('pointerover', { bubbles: true }),
);
});

// The hover did re-render (focus state flipped)...
const focused = container.querySelector('[data-plan-edge]')?.closest('svg');
expect(focused?.getAttribute('data-focused')).toBe('true');

// ...but the derivation did not re-run: no extra topological layering,
// no extra topology serialization.
expect(counts.layers).toBe(layersAfterMount);
expect(stringifySpy.mock.calls.length).toBe(stringifyAfterMount);

act(() => {
buildNode?.dispatchEvent(new MouseEvent('pointerout', { bubbles: true }));
});
const unfocused = container
.querySelector('[data-plan-edge]')
?.closest('svg');
expect(unfocused?.getAttribute('data-focused')).toBeNull();
expect(counts.layers).toBe(layersAfterMount);
expect(stringifySpy.mock.calls.length).toBe(stringifyAfterMount);

stringifySpy.mockRestore();
});

it('coalesces a same-frame resize storm into one measure per animation frame', () => {
const frames: FrameRequestCallback[] = [];
const animationSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
frames.push(callback);
return frames.length;
});
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
width: 100,
height: 80,
right: 100,
bottom: 80,
toJSON: () => ({}),
} as DOMRect);

const container = mount();
const nodes = container.querySelectorAll('[data-plan-node-id]').length;
expect(nodes).toBe(todos.length);

const framesAfterMount = frames.length;

// One viewport change lands as several schedule calls in the same
// frame (the window resize plus, in a real browser, the resize
// observer's per-node batch). All of them must share a single
Comment on lines +150 to +152

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The coalescing test drives only the window resize listener; the ResizeObserver source its own comment names is never exercised, because client/test/setup.ts:38-43 installs a ResizeObserverStub whose observe() is a silent no-op that never invokes the callback. packages/web-shell/vitest.config.ts sets setupFiles: ['./test/setup.ts'] with root: 'client', so the stub applies to this file.

PlanExecutionView.tsx:512-517 does construct the observer with scheduleMeasure and calls observe() on the graph element plus every node, but the stub never fires, so the only path reaching scheduleMeasure in this test is window.dispatchEvent(new Event('resize')) three times. The observer half of the storm — which the component's own comment at PlanExecutionView.tsx:493-498 names as the dominant real-world source ("Every node is observed and a window resize lands in the same frame as the observer's own batch, so one viewport change ran measure many times over") — has zero coverage. Rewire the observer to the un-coalesced function (new ResizeObserver(measure) at :515) and in a browser a single viewport change on a 20-step plan runs 20+ full measure passes, each reading 21 rects and concatenating a 20-edge signature string, while both assertions stay green.

Witness:

INTACT: PlanExecutionView.derivation.test.tsx 2/2 green (baseline). MUTANT :515 new ResizeObserver(scheduleMeasure) -> new ResizeObserver(measure): 'Test Files 3 passed (3) / Tests 35 passed (35)' — derivation 2/2, PlanExecutionView.test 30/30, surfaces 3/3 all green. Premise probe: {"nodes":3,"constructed":1,"observed":4,"callbacks":0,"framesDelta":1,"rects":4} — observer constructed, observe() called 4x (graph + 3 nodes), callback fired 0 times. FLIP CONTROL (:511 window listener -> measure, the path the test does drive): 'AssertionError: expected +0 to be 1 (:159)' — so the harness is not vacuous.

Give the test a controllable observer, following the pattern already used three times in this package (PaneHeaderActions.test.tsx:20-27 and :113, data-table.test.tsx:142-163, App.test.tsx:13270-13291): collect the constructor callbacks via vi.stubGlobal('ResizeObserver', …) before mount(), then fire the captured callback once per observed target inside the SAME act() as the window resizes, and assert the rect count taken BEFORE the frame runs is still nodes + 1. Restore with vi.unstubAllGlobals() alongside the existing animationSpy.mockRestore().

Constraint the fix must not violate: The observer callbacks must be fired inside the same act() as the window resizes and strictly before frames.at(-1)!(0) runs the frame — PlanExecutionView.tsx:502-506 is if (pending) return; pending = true; frame = requestAnimationFrame(() => { pending = false; measure(); });, so pending is cleared only when the callback executes; firing the observer after the frame has run would legitimately schedule a second frame and the delta assertion would read 2 on correct code.

Acceptance: The observable that flips is the rect count taken before the frame runs, NOT the frame delta. Measured with the observer callbacks driven 4x in the same act(): INTACT {"observerCallbacksFired":4,"framesDelta":1,"rectsAfterStorm":4} green vs MUTANT {"observerCallbacksFired":4,"framesDelta":1,"rectsAfterStorm":20} -> 'AssertionError: expected 20 to be 4'. A direct measure never schedules a frame so framesDelta stays 1, and the shipped test's rectSpy.mockClear() sits between the storm and the frame run, erasing the observer-driven reads — so an assertion on frames.length - framesAfterMount would not catch this.

中文说明

这个合并调度的测试只驱动了 window 的 resize 监听器;它自己注释里点名的另一个来源 ResizeObserver 从未被触发,因为 client/test/setup.ts:38-43 装的 ResizeObserverStubobserve() 是个静默空实现,永远不会回调。packages/web-shell/vitest.config.ts 设了 setupFiles: ['./test/setup.ts']root: 'client',所以这个 stub 对本文件生效。

PlanExecutionView.tsx:512-517 确实用 scheduleMeasure 构造了 observer,并对图容器和每个节点调用了 observe(),但 stub 从不触发回调,于是本测试里唯一能走到 scheduleMeasure 的路径就是三次 window.dispatchEvent(new Event('resize'))。而风暴里的 observer 这一半——组件自己的注释在 PlanExecutionView.tsx:493-498 明确说它是真实世界里的主要来源(「每个节点都被 observe,一次 window resize 会和 observer 自己的批量回调落在同一帧,所以一次视口变化会让 measure 跑很多遍」)——覆盖率为零。把 observer 改接到未合并的函数上(:515 写成 new ResizeObserver(measure)),在浏览器里一次视口变化对一个 20 步计划就会跑 20 多轮完整 measure,每轮读 21 个 rect 并拼接 20 条边的签名字符串,而两条断言依然全绿。

(上面的 Witness 是程序输出,按原样保留。)

修复方向:给测试一个可控的 observer,沿用本包已有的三处写法(PaneHeaderActions.test.tsx:20-27:113data-table.test.tsx:142-163App.test.tsx:13270-13291):在 mount() 之前用 vi.stubGlobal('ResizeObserver', …) 收集构造回调,然后在与 window resize 同一个 act() 里按被 observe 的目标数各触发一次回调,并断言在帧运行之前读到的 rect 数仍是 nodes + 1。收尾与现有的 animationSpy.mockRestore() 一起调用 vi.unstubAllGlobals()

约束:observer 回调必须在与 window resize 同一个 act() 内、且严格在 frames.at(-1)!(0) 执行帧之前触发——PlanExecutionView.tsx:502-506if (pending) return; pending = true; frame = requestAnimationFrame(() => { pending = false; measure(); });pending 只有在回调执行时才会被清除;如果在帧已经跑完之后再触发 observer,那会正当地再调度一帧,delta 断言在正确的代码上就会读到 2。

验收:会翻转的可观测量是帧运行之前的 rect 计数,不是帧的 delta。实测把 observer 回调在同一 act() 内触发 4 次:原状 {observerCallbacksFired:4, framesDelta:1, rectsAfterStorm:4} 绿;变异后 {observerCallbacksFired:4, framesDelta:1, rectsAfterStorm:20} 红(expected 20 to be 4)。直接调用 measure 从不调度帧,所以 framesDelta 始终是 1;而且现有测试的 rectSpy.mockClear() 正好夹在风暴与帧运行之间,会把 observer 触发的读取清掉——所以对 frames.length - framesAfterMount 加断言抓不到这个问题。

— qwen3.8-max via Qwen Code /review (v0.23.0)

// animation frame, and the frame must run `measure` exactly once.
act(() => {
window.dispatchEvent(new Event('resize'));
window.dispatchEvent(new Event('resize'));
window.dispatchEvent(new Event('resize'));
});
expect(frames.length - framesAfterMount).toBe(1);

rectSpy.mockClear();
act(() => {
frames.at(-1)!(0);
});
// One measure pass reads the graph container's rect plus one rect per
// node — not one batch per schedule call.
expect(rectSpy.mock.calls.length).toBe(nodes + 1);

// The next storm schedules one new frame again.
act(() => {
window.dispatchEvent(new Event('resize'));
window.dispatchEvent(new Event('resize'));
});
expect(frames.length - framesAfterMount).toBe(2);

animationSpy.mockRestore();
rectSpy.mockRestore();
});
});
Loading
Loading