- 上下文清单、任务列表与详情、恢复控制、运行观察、创作对话与消息流、正式变更决策面板、正文写作与候选审阅、资料研究等页面同步本轮后端能力。 - 事件标签表补齐后端事件类型;查询键与生成客户端随 HTTP 契约重新生成。 - 依赖调整后 lockfile 同步;交互用例与浏览器旅程 spec 一并更新。 - 证据:make 前端检查(lint + typecheck + Pi 工具桥 tsc)exit 0;make 前端测试 37 文件 / 120 用例通过。
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
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);
|
||
});
|