/** 组件传输替身只验证渲染与取数路径;真实后端旅程由 Playwright 承担。 */ import { afterEach, expect, it, vi } from "vitest"; import { cleanup, render, screen, fireEvent, waitFor, } from "@testing-library/react"; import { Ui依赖装配 } from "../../src/应用/依赖装配"; import { Ui任务上下文 } from "../../src/功能/上下文"; afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); const 有效快照 = { snapshot_id: "snap-1", task_id: "task-1", work_id: "work-1", input_hash: "a".repeat(64), state: "有效", invalidation: null, changes: [] as string[], assembly: { included_sources: ["目标章细纲", "历史:ch-1"], text_hash: "b".repeat(64), byte_size: 328, omissions: [{ source: "参考:超长素材", reason: "BUDGET_EXCEEDED" }], }, sources: [ { source_id: "doc-1", data_version: "1", schema_hash: "c".repeat(64), projection_version: "visible-text-codepoints/v1:production:generation", current_state: "有效", }, ], }; function 桩快照(快照: unknown) { return vi.fn(async (request: Request) => { expect(new URL(request.url).pathname).toBe( "/api/v1/tasks/task-1/context-snapshots/page", ); return new Response( JSON.stringify({ items: 快照, has_more: false, next_cursor: null }), { headers: { "Content-Type": "application/json" }, }, ); }); } // @muse-case {"case_id":"NC-ui-context-snapshot-list","environment":"离线组件,传输替身","given":"含省略条目的有效快照","when":"渲染任务上下文组件","then":["材料清单、字节数与省略原因渲染;取数走 /tasks/{id}/context-snapshots"],"contract":"docs/系统架构/新版设计/模块设计/B09-上下文.md"} it("NC-ui-context-snapshot-list:材料清单与省略原因渲染", async () => { vi.stubGlobal("fetch", 桩快照([有效快照])); render( , ); expect( await screen.findByText("目标章细纲", { selector: ".step-list strong" }), ).toBeTruthy(); expect(screen.getByText("历史:ch-1")).toBeTruthy(); expect(screen.getByText("省略原因:超出材料预算,整项省略")).toBeTruthy(); expect(screen.getByText(/材料 328 字节/)).toBeTruthy(); expect(screen.getByText(new RegExp("b".repeat(64)))).toBeTruthy(); expect(screen.getByText("doc-1")).toBeTruthy(); expect(screen.getByText("版本 1")).toBeTruthy(); expect( screen.getByText(/visible-text-codepoints\/v1:production:generation/), ).toBeTruthy(); expect(screen.getByText("仍有效")).toBeTruthy(); expect(screen.queryByRole("status")).toBeNull(); }); // @muse-case {"case_id":"NC-ui-context-stale-evidence","environment":"离线组件,传输替身","given":"漂移后的快照","when":"渲染任务上下文组件","then":["失效标注与具体变化项可见;来源证据仍显示版本与哈希"],"contract":"docs/系统架构/新版设计/模块设计/B09-上下文.md"} it("NC-ui-context-stale-evidence:来源漂移后失效标注与具体变化可见", async () => { const 漂移快照 = { ...有效快照, state: "SOURCE_STALE", invalidation: "文稿 ch-1 已漂移(当前版本 2,冻结版本 1);冻结范围当前失效,需重新授权", changes: ["文稿 ch-1 已从版本 1 前进到 2;来源锁定失效"], sources: [ { ...有效快照.sources[0], data_version: "1", current_state: "SOURCE_STALE", }, ], }; vi.stubGlobal("fetch", 桩快照([漂移快照])); render( , ); expect(await screen.findByText("来源已漂移,需重新授权")).toBeTruthy(); expect(screen.getByText(/文稿 ch-1 已漂移/)).toBeTruthy(); expect( screen.getByText("文稿 ch-1 已从版本 1 前进到 2;来源锁定失效"), ).toBeTruthy(); expect(screen.getByText("已漂移")).toBeTruthy(); // 失效不改写历史装配:清单与省略原因仍按冻结时原样显示。 expect(screen.getByText("历史:ch-1")).toBeTruthy(); expect(screen.getByText("省略原因:超出材料预算,整项省略")).toBeTruthy(); }); // @muse-case {"case_id":"NC-ui-context-empty-state","environment":"离线组件,传输替身","given":"没有快照的任务","when":"渲染任务上下文组件","then":["如实显示空态,不伪造材料"],"contract":"docs/系统架构/新版设计/模块设计/B09-上下文.md"} it("NC-ui-context-empty-state:无快照任务如实显示空态", async () => { vi.stubGlobal("fetch", 桩快照([])); render( , ); expect(await screen.findByText("这个任务还没有冻结材料快照")).toBeTruthy(); expect(screen.getByText("0 份冻结快照")).toBeTruthy(); }); // @muse-case {"case_id":"O08-context-explicit-page","then":"材料分页初始只读20份,只有点击才读下一页,连续加载最多保留3页"} it("O08-context-explicit-page:材料快照按页读取且缓存有界", async () => { const reads: string[] = []; vi.stubGlobal( "fetch", vi.fn(async (request: Request) => { const url = new URL(request.url); reads.push(url.searchParams.get("cursor") ?? "first"); expect(url.pathname).toBe("/api/v1/tasks/task-1/context-snapshots/page"); expect(url.searchParams.get("limit")).toBe("20"); const page = Number(url.searchParams.get("cursor") ?? 0); return new Response( JSON.stringify({ items: Array.from({ length: 20 }, (_, index) => ({ ...有效快照, snapshot_id: `s-${page}-${index}`, })), has_more: page < 3, next_cursor: page < 3 ? String(page + 1) : null, }), { headers: { "Content-Type": "application/json" } }, ); }), ); render( , ); await screen.findByText("20 份冻结快照"); expect(reads).toEqual(["first"]); for (let page = 1; page <= 3; page++) { fireEvent.click(screen.getByRole("button", { name: "加载更多材料快照" })); await waitFor(() => expect(reads).toHaveLength(page + 1)); await screen.findByText(`${Math.min(page + 1, 3) * 20} 份冻结快照`); } expect(screen.queryByRole("button", { name: "加载更多材料快照" })).toBeNull(); });