muse-agent-example/web/tests/交互/候选审阅.test.tsx
zizi ae02fd1732 前端:工作台页面、事件标签与旅程用例
- 上下文清单、任务列表与详情、恢复控制、运行观察、创作对话与消息流、正式变更决策面板、正文写作与候选审阅、资料研究等页面同步本轮后端能力。
- 事件标签表补齐后端事件类型;查询键与生成客户端随 HTTP 契约重新生成。
- 依赖调整后 lockfile 同步;交互用例与浏览器旅程 spec 一并更新。
- 证据:make 前端检查(lint + typecheck + Pi 工具桥 tsc)exit 0;make 前端测试 37 文件 / 120 用例通过。
2026-09-18 01:15:07 +08:00

346 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { Ui决策面板 } from "../../src/功能/正式变更/决策面板";
import type { 候选审阅 } from "../../src/功能/正式变更/数据";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { Ui依赖装配 } from "../../src/应用/依赖装配";
import { Ui候选审阅页 } from "../../src/功能/正文写作/候选审阅";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
sessionStorage.clear();
});
const document = {
format_version: 1,
paragraphs: [
{
paragraph_id: "p1",
content: [{ type: "text" as const, text: "合成原文", marks: [] }],
},
],
};
const review: 候选审阅 = {
review_id: "review-original",
review_hash: "review-hash",
snapshot: {},
before: {
work_id: "work",
chapter_id: "chapter",
branch_id: "main",
document_id: "document",
revision: 3,
document,
document_hash: "base-hash",
visible_text_hash: "text-hash",
visible_text: "合成原文",
state: "saved",
},
candidate: {
candidate_id: "candidate",
revision: 2,
candidate_hash: "candidate-hash",
document_id: "document",
chapter_id: "chapter",
branch_id: "main",
origin: "author",
base_revision: 3,
current_document_revision: 3,
document,
visible_text: "合成改稿",
checks: { format: "passed", format_version: 1, quality: "not_applicable" },
decision: "undecided",
stale: false,
changes: [],
},
};
// @muse-case {"case_id":"NC-review-fixed-request-retry","environment":"Vitest真实组件;HTTP传输替身不算真实业务","given":"候选采纳响应未知且随后重新打开页面","when":"重试之前的决定","then":["仍提交原命令、候选、基线、审阅及批准清单"],"contract":"docs/系统架构/新版设计/接口契约/命令与作者决策.md"}
it("NC-review-fixed-request-retry:响应未知后保留原审阅与命令,刷新后仍重放同一决定", async () => {
const requests: unknown[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
requests.push(await request.json());
if (requests.length === 1) throw new Error("synthetic lost response");
return new Response(
JSON.stringify({
receipt_id: "receipt-one",
action: "adopt",
results: [],
}),
{ headers: { "Content-Type": "application/json" } },
);
}),
);
const view = render(
<Ui决策面板 review={review} author="author" onReopen={() => {}} />,
);
fireEvent.click(screen.getByRole("button", { name: "采纳为正文新版本" }));
await screen.findByRole("alert");
const retry = screen.getByRole("button", { name: "重试这次决定" });
expect(retry).toBeTruthy();
expect(screen.queryByRole("button", { name: "拒绝候选" })).toBeNull();
view.unmount();
render(
<Ui决策面板
review={{ ...review, review_id: "new-view-only" }}
author="author"
onReopen={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "重试这次决定" }));
await screen.findByText("已采纳");
expect(requests).toHaveLength(2);
expect(requests[1]).toEqual(requests[0]);
expect(requests[0]).toMatchObject({
candidate_revision: 2,
candidate_hash: "candidate-hash",
expected_revision: 3,
author_review_id: "review-original",
approved_changes: ["document"],
});
expect(sessionStorage.length).toBe(0);
});
// @muse-case {"case_id":"NC-review-conflict-reopen","environment":"Vitest真实组件;HTTP传输替身不算真实业务","given":"候选决定返回明确版本冲突","when":"继续点击决定入口","then":["停止旧决定并重新审阅,不能自动替换正文或候选基线"],"contract":"docs/系统架构/新版设计/接口契约/命令与作者决策.md"}
it("NC-review-conflict-reopen:明确冲突停止决定并重新展示,不以当前版本悄悄替换基线", async () => {
const calls = vi.fn(
async () =>
new Response(
JSON.stringify({ code: "REVISION_CONFLICT", message: "候选已经更新" }),
{ status: 409, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", calls);
const reopen = vi.fn();
render(<Ui决策面板 review={review} author="author" onReopen={reopen} />);
fireEvent.click(screen.getByRole("button", { name: "采纳为正文新版本" }));
await screen.findByText("候选已经更新");
expect(screen.queryByRole("button", { name: "采纳为正文新版本" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "重新打开审阅" }));
await waitFor(() => expect(reopen).toHaveBeenCalledOnce());
expect(calls).toHaveBeenCalledOnce();
expect(sessionStorage.length).toBe(0);
});
// @muse-case {"case_id":"NC-candidate-stale-buffer-choice","environment":"真实页面与Tiptap,HTTP替身;另有真实浏览器旅程","given":"本地未提交候选基于正文版本1,服务器已经是版本2","when":"打开候选缓冲、明确放弃旧改稿,再次另拟并保存","then":["旧稿首先保留可读;明确放弃不写服务端;重新打开使用当前正文和版本,能够实际保存"],"contract":"docs/系统架构/新版设计/接口契约/命令与作者决策.md"}
it("NC-candidate-stale-buffer-choice:旧无归属缓冲不自动挂接,明确复制到当前分支后可保存", async () => {
const key = "muse:candidate-draft:author:work:chapter:new";
const current = { ...review.before, revision: 2 };
const draft = {
...document,
paragraphs: [
{
paragraph_id: "p1",
content: [{ type: "text", text: "尚未提交的旧改稿", marks: [] }],
},
],
};
sessionStorage.setItem(
key,
JSON.stringify({
document: draft,
base_revision: 1,
mode: "edit",
selected: [],
}),
);
const sent: unknown[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const path = new URL(request.url).pathname;
if (request.method === "POST") sent.push(await request.json());
const data =
request.method === "POST"
? { receipt_id: "candidate-saved", results: [] }
: path.endsWith("session")
? { author_id: "author" }
: path.endsWith("writing-candidate-summaries")
? { items: [], next_cursor: null }
: current;
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}),
);
render(
<Ui依赖装配>
<MemoryRouter initialEntries={["/works/work/candidates/chapter"]}>
<Routes>
<Route
path="/works/:work_id/candidates/:chapter_id"
element={<Ui候选审阅页 />}
/>
</Routes>
</MemoryRouter>
</Ui依赖装配>,
);
fireEvent.click(await screen.findByRole("button", { name: "另拟改稿" }));
expect(
screen.getByRole("textbox", { name: "候选改稿" }).textContent,
).toContain("合成原文");
const original = sessionStorage.getItem(key);
expect(screen.getByRole("textbox", { name: "旧改稿导出" })).toBeTruthy();
fireEvent.click(
screen.getByRole("button", { name: "将旧稿复制到当前分支 main" }),
);
expect(sessionStorage.getItem(key)).toBe(original);
expect(
screen.getByRole("textbox", { name: "候选改稿" }).textContent,
).toContain("尚未提交的旧改稿");
expect(sent).toHaveLength(0);
const save = screen.getByRole("button", { name: "保存候选版本" });
expect((save as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(save);
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({
expected_revision: 2,
document: draft,
});
});
function mountCandidates(branch: string) {
return render(
<Ui依赖装配>
<MemoryRouter
initialEntries={[`/works/work/candidates/chapter?branch=${branch}`]}
>
<Routes>
<Route
path="/works/:work_id/candidates/:chapter_id"
element={<Ui候选审阅页 />}
/>
</Routes>
</MemoryRouter>
</Ui依赖装配>,
);
}
// @muse-case {"case_id": "O01-candidate-legacy-pending-branch", "environment": "离线组件测试,localStorage替身", "given": "旧格式待确认请求基于分支A的revision 7,当前进入分支B", "when": "打开候选缓冲并触发重试", "then": ["仅分支A可见并可复制原样重试", "分支B不发送旧请求"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-candidate-legacy-pending-branch:旧待确认请求仅在原分支复制并原样重试,其他分支不发送", async () => {
const key = "muse:candidate-draft:author:work:chapter:new";
const pending = {
kind: "create",
body: {
command_id: "fixed-before-refresh",
chapter_id: "chapter",
branch_id: "A",
expected_revision: 7,
document,
},
};
const raw = JSON.stringify({
document,
base_revision: 7,
mode: "edit",
selected: [],
pending,
});
sessionStorage.setItem(key, raw);
const posts: unknown[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const url = new URL(request.url);
if (request.method === "POST") {
posts.push(await request.json());
throw new Error("unknown response");
}
const data = url.pathname.endsWith("session")
? { author_id: "author" }
: url.pathname.endsWith("writing-candidate-summaries")
? { items: [], next_cursor: null }
: {
...review.before,
branch_id: url.searchParams.get("branch_id"),
revision: 7,
};
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}),
);
const b = mountCandidates("B");
fireEvent.click(await screen.findByRole("button", { name: "另拟改稿" }));
expect(
await screen.findByRole("link", { name: "返回原分支 A 确认保存结果" }),
).toBeTruthy();
expect(screen.queryByRole("button", { name: "重试候选保存" })).toBeNull();
expect(posts).toHaveLength(0);
b.unmount();
const a = mountCandidates("A");
fireEvent.click(await screen.findByRole("button", { name: "另拟改稿" }));
fireEvent.click(await screen.findByRole("button", { name: "重试候选保存" }));
await screen.findByText("暂时无法连接工作台,请稍后重试。");
a.unmount();
mountCandidates("A");
fireEvent.click(await screen.findByRole("button", { name: "另拟改稿" }));
fireEvent.click(await screen.findByRole("button", { name: "重试候选保存" }));
await waitFor(() => expect(posts).toHaveLength(2));
expect(posts).toEqual([pending.body, pending.body]);
expect(sessionStorage.getItem(key)).toBe(raw);
});
// @muse-case {"case_id": "O01-candidate-legacy-detail", "environment": "离线组件测试,HTTP替身", "given": "旧键存有候选稿,服务器已有当前分支详情", "when": "读取单项详情核对分支归属后复制", "then": ["核对通过才复制到当前分支", "原键不删除"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-candidate-legacy-detail:旧候选稿通过单项详情核对分支后复制,原键不删除", async () => {
const key = "muse:candidate-draft:author:work:chapter:candidate";
const draft = {
...document,
paragraphs: [
{
paragraph_id: "p1",
content: [{ type: "text", text: "旧候选的私人改稿", marks: [] }],
},
],
};
const raw = JSON.stringify({
document: draft,
base_revision: 3,
candidate_revision: 2,
candidate_hash: "candidate-hash",
mode: "edit",
selected: [],
});
sessionStorage.setItem(key, raw);
let reads = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const path = new URL(request.url).pathname;
let data: unknown;
if (path.endsWith("session")) data = { author_id: "author" };
else if (path.endsWith("writing-candidates/candidate")) {
reads++;
data = review.candidate;
} else if (path.endsWith("writing-candidate-summaries"))
data = { items: [review.candidate], next_cursor: null };
else if (request.method === "POST") data = review;
else data = review.before;
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}),
);
mountCandidates("main");
fireEvent.click(await screen.findByRole("button", { name: /候选 1/ }));
fireEvent.click(
await screen.findByRole("button", { name: "编辑候选或部分选择" }),
);
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "候选改稿" }).textContent,
).toContain("旧候选的私人改稿"),
);
expect(reads).toBe(2);
expect(sessionStorage.getItem(key)).toBe(raw);
});