- 上下文清单、任务列表与详情、恢复控制、运行观察、创作对话与消息流、正式变更决策面板、正文写作与候选审阅、资料研究等页面同步本轮后端能力。 - 事件标签表补齐后端事件类型;查询键与生成客户端随 HTTP 契约重新生成。 - 依赖调整后 lockfile 同步;交互用例与浏览器旅程 spec 一并更新。 - 证据:make 前端检查(lint + typecheck + Pi 工具桥 tsc)exit 0;make 前端测试 37 文件 / 120 用例通过。
201 lines
8.0 KiB
TypeScript
201 lines
8.0 KiB
TypeScript
/** 冲突后的重看必须等待最新基线,不能靠渲染时序取到旧版本。 */
|
||
import { afterEach, expect, it, vi } from "vitest";
|
||
import {
|
||
cleanup,
|
||
fireEvent,
|
||
render,
|
||
screen,
|
||
waitFor,
|
||
} from "@testing-library/react";
|
||
import { MemoryRouter } from "react-router-dom";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.unstubAllGlobals();
|
||
sessionStorage.clear();
|
||
});
|
||
|
||
const 空模式 = { type: "object", properties: {}, required: [] };
|
||
|
||
// @muse-case {"case_id":"NC-plan-rebase-await-fresh","environment":"Vitest真实组件;HTTP传输替身不算真实业务","given":"规划候选保存遇到版本冲突,最新表单刷新仍在途","when":"冲突提示刚出现就点击重看并再次保存","then":["重看等待最新正式版本,保存携带新基线且提示已保存"],"contract":"docs/系统架构/新版设计/接口契约/命令与作者决策.md"}
|
||
it("NC-plan-rebase-await-fresh:冲突后立即重看并保存,携带最新正式版本", async () => {
|
||
const 保存请求: unknown[] = [];
|
||
let 表单次数 = 0;
|
||
const 延迟 = (毫秒: number) => new Promise((r) => setTimeout(r, 毫秒));
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async (request: Request) => {
|
||
const 地址 = new URL(request.url).pathname;
|
||
const 应答 = (体: unknown, 状态 = 200) =>
|
||
new Response(JSON.stringify(体), {
|
||
status: 状态,
|
||
headers: { "Content-Type": "application/json" },
|
||
});
|
||
if (地址.startsWith("/api/v1/session")) return 应答({});
|
||
if (地址.includes("/plan-nodes"))
|
||
return 应答({ work_id: "work", revision: 1, nodes: [] });
|
||
if (地址.includes("/plans/form")) {
|
||
表单次数 += 1;
|
||
if (表单次数 === 1)
|
||
return 应答({
|
||
work_id: "work",
|
||
chapter_id: null,
|
||
node_id: null,
|
||
plan_id: "plan",
|
||
revision: 1,
|
||
schema_binding: {},
|
||
content: {},
|
||
visible_schema: 空模式,
|
||
editable_schema: 空模式,
|
||
});
|
||
// 冲突后的刷新故意延迟,放大“重看取到旧基线”的竞态窗口。
|
||
await 延迟(250);
|
||
return 应答({
|
||
work_id: "work",
|
||
chapter_id: null,
|
||
node_id: null,
|
||
plan_id: "plan",
|
||
revision: 2,
|
||
schema_binding: {},
|
||
content: {},
|
||
visible_schema: 空模式,
|
||
editable_schema: 空模式,
|
||
});
|
||
}
|
||
if (地址.includes("/plans/candidates") && request.method === "GET")
|
||
return 应答([]);
|
||
if (地址.includes("/plans/candidates")) {
|
||
保存请求.push(await request.clone().json());
|
||
if (保存请求.length === 1)
|
||
return 应答(
|
||
{ code: "REVISION_CONFLICT", message: "规划正式版本已变化" },
|
||
409,
|
||
);
|
||
return 应答({ receipt_id: "r2", action: "manual_save", results: [] });
|
||
}
|
||
if (地址.includes("/exploration-selection"))
|
||
return 应答({ work_id: "work", revision: 0, selection: null });
|
||
return 应答({});
|
||
}),
|
||
);
|
||
const { Ui规划工作区 } = await import("../../src/功能/作品规划/规划工作区");
|
||
const 客户端 = new QueryClient({
|
||
defaultOptions: { queries: { retry: false } },
|
||
});
|
||
render(
|
||
<QueryClientProvider client={客户端}>
|
||
<MemoryRouter initialEntries={["/works/work/plans"]}>
|
||
<Ui规划工作区 />
|
||
</MemoryRouter>
|
||
</QueryClientProvider>,
|
||
);
|
||
fireEvent.click(await screen.findByRole("button", { name: "另拟大纲" }));
|
||
fireEvent.click(screen.getByRole("button", { name: "保存规划候选" }));
|
||
expect(
|
||
await screen.findByText(
|
||
"保存基线已变化。你的输入仍在下方,请先比较当前内容。",
|
||
),
|
||
).toBeTruthy();
|
||
// 冲突提示出现时最新表单仍在途;重看按钮等待新数据后才可点击。
|
||
const 重看 = screen.getByRole("button", {
|
||
name: "保留我的改动,基于当前版本重看",
|
||
});
|
||
await waitFor(() => expect((重看 as HTMLButtonElement).disabled).toBe(false));
|
||
fireEvent.click(重看);
|
||
await waitFor(() =>
|
||
expect(
|
||
screen.queryByText(
|
||
"保存基线已变化。你的输入仍在下方,请先比较当前内容。",
|
||
),
|
||
).toBeNull(),
|
||
);
|
||
fireEvent.click(screen.getByRole("button", { name: "保存规划候选" }));
|
||
expect(await screen.findByText("规划候选已保存,请打开审阅。")).toBeTruthy();
|
||
const 第二次 = 保存请求.at(-1) as { request: { expected_revision: number } };
|
||
expect(第二次.request.expected_revision).toBe(2);
|
||
});
|
||
|
||
// @muse-case {"case_id":"NC-plan-model-task-retry","environment":"Vitest真实组件;HTTP传输替身不算真实业务","given":"真实规划组件与传输替身;首次创建回执为未知503","when":"作者重试同一次创建","then":["重用相同命令、配置与基线;只显示已创建任务,不发送候选采纳"],"contract":"docs/系统架构/新版设计/模块设计/B01-作品规划.md"}
|
||
it("NC-plan-model-task-retry:规划创建回执未知时重试原命令,不自动采纳", async () => {
|
||
const 请求集: Record<string, unknown>[] = [];
|
||
let 正式写入 = 0;
|
||
const form = {
|
||
work_id: "work",
|
||
chapter_id: null,
|
||
node_id: null,
|
||
plan_id: "plan",
|
||
revision: 2,
|
||
schema_binding: {
|
||
schema_id: "outline",
|
||
base_version: 1,
|
||
effective_schema_hash: "schema",
|
||
projection_version: "view",
|
||
},
|
||
content: {},
|
||
visible_schema: 空模式,
|
||
editable_schema: 空模式,
|
||
};
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async (request: Request) => {
|
||
const path = new URL(request.url).pathname;
|
||
const reply = (body: unknown, status = 200) =>
|
||
new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: { "Content-Type": "application/json" },
|
||
});
|
||
if (path.startsWith("/api/v1/session"))
|
||
return reply({ author_id: "author" });
|
||
if (path.includes("/plan-nodes"))
|
||
return reply({ work_id: "work", revision: 1, nodes: [] });
|
||
if (path.includes("/plans/form")) return reply(form);
|
||
if (path.includes("/plans/candidates")) {
|
||
if (request.method !== "GET") 正式写入++;
|
||
return reply([]);
|
||
}
|
||
if (path.includes("/exploration-selection"))
|
||
return reply({ work_id: "work", revision: 0, selection: null });
|
||
if (path.includes("/planning-tasks")) {
|
||
请求集.push(await request.clone().json());
|
||
return 请求集.length === 1
|
||
? reply({ code: "UNKNOWN", message: "回执暂不可知" }, 503)
|
||
: reply({ task_id: "task-plan" }, 201);
|
||
}
|
||
throw new Error("未约定的请求:" + path);
|
||
}),
|
||
);
|
||
const { Ui规划工作区 } = await import("../../src/功能/作品规划/规划工作区");
|
||
render(
|
||
<QueryClientProvider
|
||
client={
|
||
new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||
}
|
||
>
|
||
<MemoryRouter initialEntries={["/works/work/plans"]}>
|
||
<Ui规划工作区 />
|
||
</MemoryRouter>
|
||
</QueryClientProvider>,
|
||
);
|
||
fireEvent.change(await screen.findByLabelText("规划要求"), {
|
||
target: { value: "细化本章承接" },
|
||
});
|
||
fireEvent.change(screen.getByLabelText("规划模型配置"), {
|
||
target: { value: "planner-config" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "创建规划任务" }));
|
||
await screen.findByText("回执暂不可知");
|
||
const 重试 = screen.getByRole("button", { name: "重试同一规划任务" });
|
||
await waitFor(() => expect((重试 as HTMLButtonElement).disabled).toBe(false));
|
||
fireEvent.click(重试);
|
||
await screen.findByText(/任务已创建:task-plan/);
|
||
expect(请求集).toHaveLength(2);
|
||
expect(请求集[0]).toEqual(请求集[1]);
|
||
expect(请求集[1]).toMatchObject({
|
||
request: { work_id: "work", expected_revision: 2, content: {} },
|
||
config_id: "planner-config",
|
||
});
|
||
expect(正式写入).toBe(0);
|
||
expect(screen.queryByText("这份规划已确认。")).toBeNull();
|
||
});
|