- 上下文清单、任务列表与详情、恢复控制、运行观察、创作对话与消息流、正式变更决策面板、正文写作与候选审阅、资料研究等页面同步本轮后端能力。 - 事件标签表补齐后端事件类型;查询键与生成客户端随 HTTP 契约重新生成。 - 依赖调整后 lockfile 同步;交互用例与浏览器旅程 spec 一并更新。 - 证据:make 前端检查(lint + typecheck + Pi 工具桥 tsc)exit 0;make 前端测试 37 文件 / 120 用例通过。
207 lines
6.6 KiB
TypeScript
207 lines
6.6 KiB
TypeScript
import { afterEach, expect, it, vi } from "vitest";
|
||
import {
|
||
cleanup,
|
||
fireEvent,
|
||
render,
|
||
screen,
|
||
waitFor,
|
||
} from "@testing-library/react";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||
import { Ui候选审阅页 } from "../../src/功能/正文写作/候选审阅";
|
||
const document = {
|
||
format_version: 1,
|
||
paragraphs: [
|
||
{
|
||
paragraph_id: "p",
|
||
content: [{ type: "text", text: "当前正文", marks: [] }],
|
||
},
|
||
],
|
||
};
|
||
const current = {
|
||
work_id: "work",
|
||
chapter_id: "chapter",
|
||
branch_id: "main",
|
||
document_id: "doc",
|
||
revision: 7,
|
||
document_hash: "h7",
|
||
visible_text_hash: "t7",
|
||
visible_text: "当前正文",
|
||
document,
|
||
state: "saved",
|
||
};
|
||
const summary = (id: string) => ({
|
||
candidate_id: id,
|
||
revision: 1,
|
||
candidate_hash: `hash-${id}`,
|
||
document_id: "doc",
|
||
chapter_id: "chapter",
|
||
branch_id: "main",
|
||
origin: "author",
|
||
base_revision: 7,
|
||
current_document_revision: 7,
|
||
decision: "undecided",
|
||
stale: false,
|
||
});
|
||
const detail = (id: string) => ({
|
||
...summary(id),
|
||
document,
|
||
visible_text: `改稿-${id}`,
|
||
checks: { format: "passed", format_version: 1, quality: "not_applicable" },
|
||
changes: [],
|
||
});
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.unstubAllGlobals();
|
||
sessionStorage.clear();
|
||
});
|
||
function mount() {
|
||
const cache = new QueryClient({
|
||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||
});
|
||
const view = render(
|
||
<QueryClientProvider client={cache}>
|
||
<MemoryRouter initialEntries={["/works/work/candidates/chapter"]}>
|
||
<Routes>
|
||
<Route
|
||
path="/works/:work_id/candidates/:chapter_id"
|
||
element={<Ui候选审阅页 />}
|
||
/>
|
||
</Routes>
|
||
</MemoryRouter>
|
||
</QueryClientProvider>,
|
||
);
|
||
return { cache, view };
|
||
}
|
||
// @muse-case {"case_id":"O08-candidate-page-cache-selection","then":"连续20页只读摘要且缓存不超过3页,选中项不因翻页漂移,未选项无全文读取,离页清临时查询不清草稿"}
|
||
it("O08-candidate-page-cache-selection:摘要分页有界且选中候选跨页保留", async () => {
|
||
const calls = {
|
||
summaries: 0,
|
||
detail: 0,
|
||
fullList: 0,
|
||
document: 0,
|
||
reviews: 0,
|
||
};
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async (request: Request) => {
|
||
const url = new URL(request.url);
|
||
let data: unknown;
|
||
if (url.pathname.endsWith("session")) data = { author_id: "author" };
|
||
else if (url.pathname.endsWith("writing-candidate-summaries")) {
|
||
calls.summaries++;
|
||
const page = Number(url.searchParams.get("cursor") ?? 0);
|
||
data = {
|
||
items: [summary(`candidate-${page}`)],
|
||
next_cursor: page < 20 ? String(page + 1) : null,
|
||
};
|
||
} else if (url.pathname.endsWith("writing-candidates")) {
|
||
calls.fullList++;
|
||
throw new Error("不得调用完整候选列表");
|
||
} else if (url.pathname.includes("/writing-candidates/")) {
|
||
calls.detail++;
|
||
data = detail(url.pathname.split("/").at(-1)!);
|
||
} else if (url.pathname.endsWith("author-reviews")) {
|
||
calls.reviews++;
|
||
const body = await request.json();
|
||
data = {
|
||
review_id: "review",
|
||
review_hash: "rh",
|
||
snapshot: {},
|
||
before: current,
|
||
candidate: detail(body.candidate_id),
|
||
};
|
||
} else {
|
||
calls.document++;
|
||
data = current;
|
||
}
|
||
return new Response(JSON.stringify(data), {
|
||
headers: { "Content-Type": "application/json" },
|
||
});
|
||
}),
|
||
);
|
||
const { cache, view } = mount();
|
||
const choose = await screen.findByRole("button", { name: /候选 1/ });
|
||
expect(calls.detail).toBe(0);
|
||
fireEvent.click(choose);
|
||
await screen.findByRole("button", { name: "编辑候选或部分选择" });
|
||
expect(calls.detail).toBe(1);
|
||
expect(calls.reviews).toBe(1);
|
||
for (let page = 1; page <= 20; page++) {
|
||
fireEvent.click(screen.getByRole("button", { name: "下一页候选" }));
|
||
await waitFor(() => expect(calls.summaries).toBe(page + 1));
|
||
await screen.findByRole("button", { name: "下一页候选" });
|
||
await waitFor(() =>
|
||
expect(
|
||
cache
|
||
.getQueryCache()
|
||
.findAll({
|
||
queryKey: [
|
||
"候选工作面",
|
||
"author",
|
||
"work",
|
||
"chapter",
|
||
"main",
|
||
"pages",
|
||
],
|
||
}).length,
|
||
).toBeLessThanOrEqual(3),
|
||
);
|
||
}
|
||
expect(screen.getByText("改稿-candidate-0")).toBeTruthy();
|
||
expect(calls.detail).toBe(1);
|
||
expect(calls.fullList).toBe(0);
|
||
expect(calls.document).toBe(1);
|
||
sessionStorage.setItem("muse:unsubmitted-synthetic", "preserve");
|
||
view.unmount();
|
||
expect(
|
||
cache.getQueryCache().findAll({ queryKey: ["候选工作面"] }),
|
||
).toHaveLength(0);
|
||
expect(sessionStorage.getItem("muse:unsubmitted-synthetic")).toBe("preserve");
|
||
});
|
||
|
||
// @muse-case {"case_id":"O08-candidate-summary-stale-cas","then":"摘要读取后候选变更,打开审阅使用单项当前版本;审阅CAS拒绝时不出现采纳操作"}
|
||
it("O08-candidate-summary-stale-cas:摘要不能冒充审阅凭据", async () => {
|
||
const bodies: unknown[] = [];
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async (request: Request) => {
|
||
const url = new URL(request.url);
|
||
let data: unknown;
|
||
if (url.pathname.endsWith("session")) data = { author_id: "author" };
|
||
else if (url.pathname.endsWith("writing-candidate-summaries"))
|
||
data = { items: [summary("candidate")], next_cursor: null };
|
||
else if (url.pathname.endsWith("writing-candidates/candidate"))
|
||
data = {
|
||
...detail("candidate"),
|
||
revision: 2,
|
||
candidate_hash: "current-hash",
|
||
};
|
||
else if (url.pathname.endsWith("author-reviews")) {
|
||
bodies.push(await request.json());
|
||
return new Response(
|
||
JSON.stringify({
|
||
code: "REVISION_CONFLICT",
|
||
message: "正文又有新版本,请重新审阅",
|
||
}),
|
||
{ status: 409, headers: { "Content-Type": "application/json" } },
|
||
);
|
||
} else data = current;
|
||
return new Response(JSON.stringify(data), {
|
||
headers: { "Content-Type": "application/json" },
|
||
});
|
||
}),
|
||
);
|
||
mount();
|
||
fireEvent.click(await screen.findByRole("button", { name: /候选 1/ }));
|
||
expect(await screen.findByText("正文又有新版本,请重新审阅")).toBeTruthy();
|
||
expect(bodies).toEqual([
|
||
expect.objectContaining({
|
||
candidate_revision: 2,
|
||
candidate_hash: "current-hash",
|
||
expected_revision: 7,
|
||
}),
|
||
]);
|
||
expect(screen.queryByRole("button", { name: "采纳为正文新版本" })).toBeNull();
|
||
});
|