- src/muse 新版全模块(装配/共享/上下文/任务运行/作品规划/故事世界/正文写作/审校修订/知识方法/作者经验/效果评测/交付连载/资料研究/正式变更/元数据/接入/基础设施/编排)+ 测试树(单元/契约/集成/架构/迁移/端到端/夹具) - 129 项功能全部实现与自动验证(功能覆盖.json/矩阵),含 W31 补齐的规则与代价/节奏安排/伏笔与承诺 - 旧实现按处置清单退出(702 条中 324 删,保护合同与未迁移条目留存有据);web/app.py 旧工作台退役,新工作台为唯一写入口 - 数据库/旧库迁移:真实旧库内容批次迁移链(端点守卫/PG作品正文映射/质量资产缺省投影) - 运行手册 docs/运行手册.md;W30 本机服务阶段一已运行(infra PG 为正式内容权威) - R2 执行证据与私有运行材料在 .agents.local/改造/R2-20260909/(不入库)
674 lines
23 KiB
TypeScript
674 lines
23 KiB
TypeScript
/** 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");
|
||
|
||
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();
|
||
});
|
||
|
||
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],
|
||
]);
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
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(
|
||
"水边旧事",
|
||
);
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
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",
|
||
);
|
||
});
|
||
|
||
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",
|
||
});
|
||
});
|
||
|
||
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();
|
||
});
|
||
|
||
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();
|
||
});
|