/** 组件传输替身只验证渲染与取数路径;真实后端旅程由 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();
});