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

684 lines
27 KiB
TypeScript
Raw Permalink 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.

/** HTTP 替身交互证据;真实持久化、格式核对及浏览器旅程由集成验收承担。 */
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Blob as 节点Blob } from "node:buffer";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import {
Ui全书整理页,
Ui定稿导出页,
Ui连载计划页,
Ui读者反馈页,
} from "../../src/功能/交付连载";
import type {
全书工作面,
定稿内容,
定稿摘要,
连载工作面,
定稿命令,
连载命令,
发布命令,
反馈命令,
发布记录,
} from "../../src/功能/交付连载/数据";
const workId = "work-a";
const deliveryId = "11111111-1111-4111-8111-111111111111";
const planId = "22222222-2222-4222-8222-222222222222";
const releaseId = "33333333-3333-4333-8333-333333333333";
const taskId = "44444444-4444-4444-8444-444444444444";
const chapters = ["渡口", "归舟"].map((title, i) => ({
chapter_id: `chapter-${i + 1}`,
title,
position: i + 1,
revision: 1,
document_hash: String(i + 1).repeat(64),
branch_id: "main",
expected_current_revision: 1,
document_id: `doc-${i + 1}`,
text_hash: "a".repeat(64),
text: `${title}的冻结正文。`,
paragraphs: [`${title}的冻结正文。`],
}));
const frozen: 定稿内容 = {
delivery_id: deliveryId,
revision: 1,
work_id: workId,
manifest_hash: "b".repeat(64),
created_at: "2026-09-01T12:00:00+08:00",
manifest: {
schema_version: "delivery-v1",
work_id: workId,
directory_revision: 7,
materials: {
title: "水边旧事",
author_name: "作者甲",
introduction: "书稿简介。",
synopsis: "",
characters: "",
},
chapters,
},
chapters,
};
const release: 发布记录 = {
release_id: releaseId,
work_id: workId,
delivery_id: deliveryId,
content_hash: "c".repeat(64),
payload: {
release_id: releaseId,
delivery_id: deliveryId,
chapter_ids: [chapters[0].chapter_id],
published_at: "2026-09-02T12:00:00+08:00",
platform: "作者平台",
evidence_url: "https://example.com/published/1",
plan_id: null,
plan_revision: null,
corrects_release_id: null,
manifest_hash: frozen.manifest_hash,
verification: "author_record",
chapter_versions: [chapters[0]],
},
};
type Call = {
path: string;
method: string;
body: unknown;
credentials: RequestCredentials;
};
let book: 全书工作面;
let summaries: 定稿摘要[];
let workspace: 连载工作面;
let calls: Call[];
let override:
| ((call: Call) => Response | undefined | Promise<Response | undefined>)
| undefined;
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
function receipt(command: { command_id: string }, results: unknown[]) {
return json({
command_id: command.command_id,
receipt_id: "receipt",
target_ref: "delivery",
action: "save",
author_review_id: null,
basis: {},
impact_ids: [],
results,
});
}
function previewReply(command: 定稿命令) {
return json({
materials: command.delivery.materials,
state: "author_draft",
sources: command.delivery.chapters,
});
}
beforeEach(() => {
book = {
work_id: workId,
directory_revision: 7,
chapters: chapters.map((c) => ({
...c,
revision: 3,
state: "ready",
text: `${c.title}的当前正文。`,
})),
problems: [],
copyedit: [],
can_freeze: true,
literary_status: "not_assessed",
review_scope: ["directory", "body_presence", "copyedit"],
};
summaries = [];
workspace = { plans: [], releases: [], feedback: [] };
calls = [];
override = undefined;
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const call: Call = {
path: new URL(request.url).pathname,
method: request.method,
body:
request.method === "POST" &&
request.headers.get("Content-Type")?.includes("json")
? await request.json()
: null,
credentials: request.credentials,
};
calls.push(call);
const custom = await override?.(call);
if (custom) return custom;
if (call.method === "GET" && call.path.endsWith("/delivery-draft"))
return json(book);
if (call.path.endsWith("/copyedit")) return json(book);
if (
call.method === "GET" &&
call.path === `/api/v1/works/${workId}/deliveries`
)
return json(summaries);
if (call.path === `/api/v1/works/${workId}/serial`)
return json(workspace);
if (
call.method === "GET" &&
call.path === `/api/v1/deliveries/${deliveryId}`
)
return json(frozen);
if (call.path === "/api/v1/deliveries/preview")
return previewReply(call.body as 定稿命令);
if (call.path === "/api/v1/deliveries") {
const command = call.body as 定稿命令;
return receipt(command, [
{
delivery_id: command.delivery.delivery_id,
revision: 1,
manifest_hash: frozen.manifest_hash,
},
]);
}
if (call.path === `/api/v1/deliveries/${deliveryId}/exports`)
return json({ task_id: taskId });
if (call.path.endsWith("/advance"))
return json({ task_id: taskId, state: "completed", steps: [] });
if (call.path.includes("/files/"))
return json(
{ code: "EXPORT_NOT_READY", message: "该格式尚无已校验的导出产物" },
409,
);
if (call.path === "/api/v1/serial-plans") {
const command = call.body as 连载命令;
const row = {
plan_id: command.plan.plan_id,
revision: command.plan.expected_revision + 1,
work_id: workId,
content_hash: "plan-hash",
payload: {
delivery_id: command.plan.delivery_id,
manifest_hash: frozen.manifest_hash,
chapters: command.plan.chapters,
},
chapters: command.plan.chapters.map((c) => ({
...c,
state: "ready" as const,
blocked_by: c.depends_on,
})),
};
workspace.plans = [
...workspace.plans.filter((p) => p.plan_id !== row.plan_id),
row,
];
return receipt(command, [{ id: row.plan_id, revision: row.revision }]);
}
if (call.path === "/api/v1/releases") {
const command = call.body as 发布命令;
workspace.releases.push({
...release,
release_id: command.release.release_id,
payload: {
...release.payload,
...command.release,
chapter_versions: chapters.filter((c) =>
command.release.chapter_ids.includes(c.chapter_id),
),
},
});
return receipt(command, [
{ id: command.release.release_id, revision: 1 },
]);
}
if (call.path === "/api/v1/reader-feedback") {
const command = call.body as 反馈命令;
workspace.feedback.push({
feedback_id: command.feedback.feedback_id,
work_id: workId,
release_id: command.feedback.release_id ?? null,
content_hash: "feedback-hash",
payload: {
...command.feedback,
association: command.feedback.release_id ? "explicit" : "unlinked",
causality: "not_established",
chapter_versions: command.feedback.release_id
? release.payload.chapter_versions
: [],
adjustment_state: command.feedback.adjustment
? "requested"
: "none",
},
});
return receipt(command, [
{ id: command.feedback.feedback_id, revision: 1 },
]);
}
throw new Error(`测试未声明的 HTTP 请求:${call.method} ${call.path}`);
}),
);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function mount(path = `/works/${workId}/delivery`) {
return render(
<QueryClientProvider
client={
new QueryClient({
defaultOptions: {
queries: { retry: false, refetchOnWindowFocus: false },
},
})
}
>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/works/:work_id/delivery" element={<Ui全书整理页 />} />
<Route
path="/works/:work_id/delivery/export/:delivery_id?"
element={<Ui定稿导出页 />}
/>
<Route path="/works/:work_id/serial" element={<Ui连载计划页 />} />
<Route
path="/works/:work_id/reader-feedback"
element={<Ui读者反馈页 />}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
function fill(label: string, value: string) {
fireEvent.change(screen.getByLabelText(label), { target: { value } });
}
function fillMaterials() {
fill("交付书名", "水边旧事");
fill("作者署名", "作者甲");
}
async function preparePreview() {
await screen.findByText("目录第7版 · 共2章");
fillMaterials();
fireEvent.click(screen.getByRole("button", { name: "预览定稿材料" }));
await screen.findByRole("region", { name: "待确认定稿预览" });
}
const writes = (path: string) =>
calls.filter((c) => c.path === path && c.method === "POST");
// @muse-case {"case_id":"NC-ui-delivery-missing","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["完整目录缺正文时不能预览或冻结,不跳过缺章"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-missing:完整目录缺正文时不能预览或冻结,不跳过缺章", async () => {
book.chapters[1] = {
chapter_id: "chapter-2",
title: "归舟",
position: 2,
state: "missing",
revision: null,
};
book.can_freeze = false;
book.problems = [{ chapter_id: "chapter-2", kind: "missing_body" }];
mount();
await screen.findByText("缺少正文");
fillMaterials();
expect(
(screen.getByRole("button", { name: "预览定稿材料" }) as HTMLButtonElement)
.disabled,
).toBe(true);
const freeze = screen.getByRole("button", {
name: "确认冻结这份定稿",
}) as HTMLButtonElement;
expect(freeze.disabled).toBe(true);
fireEvent.click(freeze);
expect(writes("/api/v1/deliveries")).toHaveLength(0);
expect(
screen.getByRole("link", { name: "编辑《归舟》" }).getAttribute("href"),
).toBe(`/works/${workId}/write/chapter-2`);
expect(screen.getByText(/文学质量尚未评估/)).toBeTruthy();
});
// @muse-case {"case_id":"NC-ui-delivery-preview","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["改过材料后旧预览失效,新的冻结携带完整当前版本"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-preview:改过材料后旧预览失效,新的冻结携带完整当前版本", async () => {
mount();
await preparePreview();
fill("作品简介", "新的简介。");
expect(screen.queryByRole("region", { name: "待确认定稿预览" })).toBeNull();
expect(
(
screen.getByRole("button", {
name: "确认冻结这份定稿",
}) as HTMLButtonElement
).disabled,
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "预览定稿材料" }));
const preview = await screen.findByRole("region", { name: "待确认定稿预览" });
expect(within(preview).getByText("新的简介。")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "确认冻结这份定稿" }));
await screen.findByText("定稿已冻结,可继续导出。");
const sent = writes("/api/v1/deliveries")[0].body as 定稿命令;
expect(sent.delivery.materials.introduction).toBe("新的简介。");
expect(sent.delivery.directory_revision).toBe(7);
expect(
sent.delivery.chapters.map((c) => [
c.chapter_id,
c.revision,
c.expected_current_revision,
]),
).toEqual([
["chapter-1", 3, 3],
["chapter-2", 3, 3],
]);
});
// @muse-case {"case_id":"NC-ui-delivery-preview-late","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["迟到的预览不能重新批准后来修改过的材料"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-preview-late:迟到的预览不能重新批准后来修改过的材料", async () => {
let resolve: ((response: Response) => void) | undefined;
let pending: 定稿命令 | undefined;
override = (call) => {
if (call.path !== "/api/v1/deliveries/preview") return;
pending = call.body as 定稿命令;
return new Promise<Response>((done) => {
resolve = done;
});
};
mount();
await screen.findByText("目录第7版 · 共2章");
fillMaterials();
fireEvent.click(screen.getByRole("button", { name: "预览定稿材料" }));
await waitFor(() => expect(resolve).toBeDefined());
fill("交付书名", "已经改过的书名");
await act(async () => {
resolve!(previewReply(pending!));
});
expect(screen.queryByRole("region", { name: "待确认定稿预览" })).toBeNull();
expect(
(
screen.getByRole("button", {
name: "确认冻结这份定稿",
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
// @muse-case {"case_id":"NC-ui-delivery-retry","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["网络错误可见,冻结重试复用原命令和载荷"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-retry:网络错误可见,冻结重试复用原命令和载荷", async () => {
let attempts = 0;
override = (call) => {
if (call.path === "/api/v1/deliveries" && ++attempts === 1)
throw new Error("连接中断");
return undefined;
};
mount();
await preparePreview();
fireEvent.click(screen.getByRole("button", { name: "确认冻结这份定稿" }));
expect(await screen.findByRole("alert")).toHaveProperty(
"textContent",
"暂时无法连接工作台,请稍后重试。",
);
expect(
(
screen
.getByLabelText("交付书名")
.closest("fieldset") as HTMLFieldSetElement
).disabled,
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "重试确认原定稿" }));
await screen.findByText("定稿已冻结,可继续导出。");
const attemptsSent = writes("/api/v1/deliveries");
expect(attemptsSent).toHaveLength(2);
expect(attemptsSent[0].body).toEqual(attemptsSent[1].body);
expect((attemptsSent[0].body as 定稿命令).command_id).toBeTruthy();
expect(attemptsSent.every((c) => c.credentials === "same-origin")).toBe(true);
});
// @muse-case {"case_id":"NC-ui-delivery-stale","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["服务器拒绝旧正文版本后撤销预览并重新读取目录"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-stale:服务器拒绝旧正文版本后撤销预览并重新读取目录", async () => {
override = (call) =>
call.path === "/api/v1/deliveries"
? json(
{
code: "SOURCE_STALE",
message: "被选正文版本、哈希或当前指针已改变",
},
409,
)
: undefined;
mount();
await preparePreview();
fireEvent.click(screen.getByRole("button", { name: "确认冻结这份定稿" }));
await screen.findByText("被选正文版本、哈希或当前指针已改变");
expect(screen.queryByRole("region", { name: "待确认定稿预览" })).toBeNull();
expect(
(
screen.getByRole("button", {
name: "确认冻结这份定稿",
}) as HTMLButtonElement
).disabled,
).toBe(true);
await waitFor(() =>
expect(calls.filter((c) => c.path.endsWith("/delivery-draft")).length).toBe(
2,
),
);
expect((screen.getByLabelText("交付书名") as HTMLInputElement).value).toBe(
"水边旧事",
);
});
// @muse-case {"case_id":"NC-ui-delivery-export","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["创建四格式任务后真实推进,失败与未就绪下载均可见"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-export:创建四格式任务后真实推进,失败与未就绪下载均可见", async () => {
summaries = [frozen];
let advanceState = "failed";
override = (call) =>
call.path.endsWith("/advance")
? json({ task_id: taskId, state: advanceState })
: undefined;
mount(`/works/${workId}/delivery/export/${deliveryId}`);
await screen.findByRole("region", { name: "固定定稿内容" });
fireEvent.click(screen.getByRole("button", { name: "下载 TXT 纯文本" }));
await screen.findByText("该格式尚无已校验的导出产物");
fireEvent.click(screen.getByRole("button", { name: "启动所选格式导出" }));
await screen.findByText("导出任务失败,请在任务页查看失败步骤并恢复。");
expect(
(
writes(`/api/v1/deliveries/${deliveryId}/exports`)[0].body as {
formats: string[];
}
).formats,
).toEqual(["txt", "md", "docx", "epub"]);
expect(
writes(`/api/v1/deliveries/${deliveryId}/exports/${taskId}/advance`),
).toHaveLength(1);
expect(
screen
.getByRole("link", { name: "查看任务进度与恢复" })
.getAttribute("href"),
).toBe("/tasks");
expect(screen.queryByText("所选格式导出已完成,可以下载文件。")).toBeNull();
advanceState = "completed";
fireEvent.click(screen.getByRole("button", { name: "继续核对导出任务" }));
await screen.findByText("所选格式导出已完成,可以下载文件。");
expect(writes(`/api/v1/deliveries/${deliveryId}/exports`)).toHaveLength(1);
});
// @muse-case {"case_id":"NC-ui-delivery-download","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["正常下载通过认证请求取得 Blob 并交给浏览器"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-download:正常下载通过认证请求取得 Blob 并交给浏览器", async () => {
summaries = [frozen];
// jsdom 的 Response.blob() 返回的 Blob 只有 slice/size/type,不能读回字节;
// 用 Node buffer.Blob 提供真实字节读取,仍走 传输→openapi-fetch parseAs blob→组件 全链路。
override = (call) =>
call.path.endsWith("/files/txt")
? ({
ok: true,
status: 200,
headers: { get: () => null },
blob: async () =>
new 节点Blob(["渡口的冻结正文。"], { type: "text/plain" }),
}) as unknown as Response
: undefined;
const createObjectURL = vi
.fn<(blob: Blob) => string>()
.mockReturnValue("blob:delivery-file");
vi.stubGlobal(
"URL",
Object.assign(class extends URL {}, {
createObjectURL,
revokeObjectURL: vi.fn(),
}),
);
const click = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
mount(`/works/${workId}/delivery/export/${deliveryId}`);
fireEvent.click(
await screen.findByRole("button", { name: "下载 TXT 纯文本" }),
);
await screen.findByText("文件已交给浏览器下载。");
expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0] as Blob;
expect(await blob.text()).toBe("渡口的冻结正文。");
expect(click).toHaveBeenCalledTimes(1);
expect(calls.find((c) => c.path.endsWith("/files/txt"))?.credentials).toBe(
"same-origin",
);
});
// @muse-case {"case_id":"NC-ui-serial-release","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["到期计划仍未发布,只有作者登记才发送所选计划版本与证据"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-serial-release:到期计划仍未发布,只有作者登记才发送所选计划版本与证据", async () => {
summaries = [frozen];
workspace.plans = [
{
plan_id: planId,
revision: 2,
work_id: workId,
content_hash: "plan-hash",
payload: {
delivery_id: deliveryId,
manifest_hash: frozen.manifest_hash,
chapters: chapters.map((c, i) => ({
chapter_id: c.chapter_id,
scheduled_at: `2020-01-0${i + 1}T10:00:00+08:00`,
depends_on: i ? [chapters[0].chapter_id] : [],
})),
},
chapters: chapters.map((c, i) => ({
chapter_id: c.chapter_id,
scheduled_at: `2020-01-0${i + 1}T10:00:00+08:00`,
depends_on: i ? [chapters[0].chapter_id] : [],
state: "ready",
blocked_by: i ? [chapters[0].chapter_id] : [],
})),
},
];
mount(`/works/${workId}/serial`);
await screen.findByText("尚无真实发布登记。");
expect(screen.getAllByText(/存稿就绪,尚未登记发布/)).toHaveLength(2);
fill("维护连载计划", planId);
await screen.findByRole("region", { name: "连载计划表单" });
fill("计划时区(UTC 偏移)", "+08:00");
fill("《渡口》计划时间", "2026-09-15T10:00:00");
fill("《归舟》计划时间", "2026-09-16T10:00:00");
fireEvent.click(screen.getByRole("button", { name: "保存连载计划" }));
await screen.findByText("连载计划已保存,实际发布需另外登记。");
await screen.findByText("修改连载计划第3版");
expect(writes("/api/v1/releases")).toHaveLength(0);
const saved = writes("/api/v1/serial-plans")[0].body as 连载命令;
expect(saved.plan.expected_revision).toBe(2);
expect(saved.plan.chapters[0].scheduled_at).toBe("2026-09-15T10:00:00+08:00");
expect(saved.plan.chapters[1].depends_on).toEqual(["chapter-1"]);
fireEvent.click(screen.getByLabelText("发布《渡口》 · 正文第1版"));
fill("对应计划与版本(可选)", planId);
fill("发布平台", "作者平台");
fill("实际发布时间", "2026-09-15T10:05:00");
fill("发布证据地址", "https://example.com/published/1");
fireEvent.click(screen.getByRole("button", { name: "确认登记实际发布" }));
await screen.findByText("实际发布已登记。可以按这次发布版本记录读者反馈。");
const registered = writes("/api/v1/releases")[0].body as 发布命令;
expect(registered.release).toMatchObject({
delivery_id: deliveryId,
chapter_ids: ["chapter-1"],
plan_id: planId,
plan_revision: 3,
published_at: "2026-09-15T10:05:00+08:00",
evidence_url: "https://example.com/published/1",
});
});
// @muse-case {"case_id":"NC-ui-reader-feedback-unlinked","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["收到时间临近发布也不猜关联,未关联反馈可读回"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-reader-feedback-unlinked:收到时间临近发布也不猜关联,未关联反馈可读回", async () => {
summaries = [frozen];
workspace.releases = [release];
mount(`/works/${workId}/reader-feedback`);
await screen.findByText("尚无读者反馈。");
expect(
(screen.getByLabelText("对应实际发布") as HTMLSelectElement).value,
).toBe("");
expect(
(screen.getByLabelText("我决定提出的调整(可选)") as HTMLTextAreaElement)
.disabled,
).toBe(true);
fill("反馈来源", "编辑来信");
fill("反馈原话", "这里的动机看不明白。");
fill("收到反馈的时间", "2026-09-02T12:01:00");
fill("反馈分类", "question");
fireEvent.click(screen.getByRole("button", { name: "保存读者反馈" }));
await screen.findByText("读者反馈已保存,下方可回看原话和所关联版本。");
const sent = writes("/api/v1/reader-feedback")[0].body as 反馈命令;
expect(sent.feedback).toMatchObject({
release_id: null,
chapter_id: null,
adjustment: "",
category: "question",
});
const saved = screen.getByRole("region", { name: "已保存读者反馈" });
expect(within(saved).getByText("未关联发布版本")).toBeTruthy();
expect(within(saved).getByText("这里的动机看不明白。")).toBeTruthy();
});
// @muse-case {"case_id":"NC-ui-reader-feedback-version","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["明确发布与章节后才能提出调整,读回同时显示原版与当前版"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-reader-feedback-version:明确发布与章节后才能提出调整,读回同时显示原版与当前版", async () => {
summaries = [frozen];
workspace.releases = [release];
mount(`/works/${workId}/reader-feedback`);
await screen.findByText("尚无读者反馈。");
fill("对应实际发布", releaseId);
expect(
(screen.getByLabelText("我决定提出的调整(可选)") as HTMLTextAreaElement)
.disabled,
).toBe(true);
fill("对应章节(可选)", "chapter-1");
await screen.findByText("发布正文第1版 · 当前正文第3版");
expect(
(screen.getByLabelText("我决定提出的调整(可选)") as HTMLTextAreaElement)
.disabled,
).toBe(false);
fill("反馈来源", "发布页评论");
fill("反馈原话", "渡口的时间有误。");
fill("收到反馈的时间", "2026-09-03T09:15:00");
fill("反馈分类", "correction");
fill("我决定提出的调整(可选)", "核对渡口一段的日期。");
fireEvent.click(screen.getByRole("button", { name: "保存读者反馈" }));
await screen.findByText("读者反馈已保存,下方可回看原话和所关联版本。");
expect(
(writes("/api/v1/reader-feedback")[0].body as 反馈命令).feedback,
).toMatchObject({
release_id: releaseId,
chapter_id: "chapter-1",
adjustment: "核对渡口一段的日期。",
});
const saved = screen.getByRole("region", { name: "已保存读者反馈" });
expect(
within(saved).getByText("渡口 · 反馈所指正文第1版 · 当前正文第3版"),
).toBeTruthy();
expect(within(saved).getByText(/尚不能证明修改带来的效果/)).toBeTruthy();
});