import { afterEach, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
act,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { Ui依赖装配 } from "../../src/应用/依赖装配";
import { Ui世界资料页 } from "../../src/功能/故事世界/世界资料页";
import { Ui事实审阅 } from "../../src/功能/故事世界/事实审阅";
import { Ui实体编辑器 } from "../../src/功能/故事世界/实体编辑器";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// @muse-case {"case_id":"NC-world-edit-refresh","environment":"离线","given":"具名作者、固定来源、隔离测试配置与真实模块存储","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-world-edit-refresh:后台版本更新保留草稿并阻止沿旧基线保存"],"contract":"docs/系统架构/新版设计/模块设计/B02-故事世界.md#作者实体表单与字段提案"}
it("NC-world-edit-refresh:后台版本更新保留草稿并阻止沿旧基线保存", async () => {
let revision = 1;
const calls: Request[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
calls.push(request);
return json(
表单结果(
"work-a",
"object-a",
{
dock_name: revision === 1 ? "旧码头" : "服务器新码头",
capacity: 10,
},
revision,
),
);
}),
);
const cache = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
undefined}
onCancel={() => undefined}
/>
,
);
const input = await screen.findByLabelText(/码头名称/);
fireEvent.change(input, { target: { value: "作者仍在编辑的名字" } });
revision = 2;
await act(async () => {
await cache.invalidateQueries();
});
await screen.findByText(/已保留你的输入;取消后重新打开/);
expect((input as HTMLInputElement).value).toBe("作者仍在编辑的名字");
expect(
screen
.getByRole("button", { name: "保存为待决资料" })
.hasAttribute("disabled"),
).toBe(true);
expect(calls.every((c) => c.method === "GET")).toBe(true);
cache.clear();
});
const 登船点结构 = {
type: "object",
properties: {
dock_name: { type: "string", title: "码头名称" },
capacity: { type: "integer", title: "可容纳人数", minimum: 1 },
},
required: ["dock_name", "capacity"],
};
const 类型清单 = [
{
type_id: "boarding_point",
名称: "登船点",
语义域: "world",
实例族: ["entity"],
说明: "故事中可登船的地点",
schemas: [
{ schema_id: "boarding_point_v2", base_version: 4 },
{ schema_id: "boarding_point_v1", base_version: 3 },
],
},
{
type_id: "weather_station",
名称: "观天台",
语义域: "world",
实例族: ["entity"],
说明: "观测天象的地点",
schemas: [{ schema_id: "weather_station_v1", base_version: 1 }],
},
];
function json(value: unknown, status = 200) {
return new Response(JSON.stringify(value), {
status,
headers: { "Content-Type": "application/json" },
});
}
function 表单结果(
workId: string,
objectId: string,
content: Record = {},
revision = 0,
) {
return {
work_id: workId,
object_id: objectId,
type_id: "boarding_point",
revision,
schema_binding: {
schema_id: "boarding_point_v1",
base_version: 3,
extension_version: 2,
effective_schema_hash: "schema-current",
projection_version: "projection-current",
},
content,
visible_schema: 登船点结构,
editable_schema: 登船点结构,
visible_projection_version: "projection-current",
source_current: true,
context: {
sources: [],
independent: true,
knowledge_mode: "setting",
effective_from: null,
known_to: [],
reader_visible: true,
planned_window: null,
},
};
}
// @muse-case {"case_id":"NC-world-entity-author-flow","environment":"离线","given":"具名作者、固定来源、隔离测试配置与真实模块存储","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-world-entity-author-flow:新类型动态字段保存为提案,展示内容后才确认"],"contract":"docs/系统架构/新版设计/模块设计/B02-故事世界.md#作者实体表单与字段提案"}
it("NC-world-entity-author-flow:新类型动态字段保存为提案,展示内容后才确认", async () => {
const saveBodies: Record[] = [];
const decisionBodies: Record[] = [];
let saveAttempts = 0;
let adopted = false;
let proposal: Record | undefined;
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const url = new URL(request.url);
const path = url.pathname;
if (path === "/api/v1/session")
return json({ author_id: "author-from-session" });
if (path === "/api/v1/world/types") return json(类型清单);
if (
path === "/api/v1/works/work-a/world/entities" &&
request.method === "GET"
)
return json(
adopted
? [
{
object_id: String(proposal!.object_id),
type_id: "boarding_point",
revision: 1,
knowledge_mode: "setting",
independent: true,
依据可用: true,
生效位置: null,
计划窗口: null,
content: proposal!.content,
},
]
: [],
);
if (path.endsWith("/form")) {
expect(url.searchParams.get("schema_id")).toBe("boarding_point_v1");
expect(url.searchParams.get("base_version")).toBe("3");
return json(表单结果("work-a", path.split("/").at(-2)!));
}
if (
path === "/api/v1/works/work-a/fact-proposals" &&
request.method === "POST"
) {
const body = (await request.json()) as Record;
saveBodies.push(body);
saveAttempts += 1;
if (saveAttempts === 1) throw new Error("lost response");
const proposed = body.proposal as Record;
const changes = proposed.changes as { 路径: string[]; 值: unknown }[];
const content = Object.fromEntries(
changes.map((change) => [change.路径[0], change.值]),
);
proposal = {
proposal_id: "proposal-new-type",
revision: 7,
proposal_hash: "proposal-hash-from-service",
work_id: "work-a",
object_id: proposed.object_id,
expected_revision: 0,
schema: proposed.schema,
content,
independent: true,
sources: [],
effective_from: null,
knowledge_mode: "setting",
known_to: [],
planned_window: null,
decision: "undecided",
};
return json(
{
receipt_id: "proposal-receipt",
command_id: body.command_id,
target_ref: "proposal-new-type",
action: "save",
author_review_id: null,
basis: {},
results: [
{
proposal_id: "proposal-new-type",
proposal_hash: "proposal-hash-from-service",
},
],
impact_ids: [],
},
201,
);
}
if (
path === "/api/v1/fact-proposals/proposal-new-type" &&
request.method === "GET"
)
return json(proposal);
if (path === "/api/v1/fact-proposals/proposal-new-type/reviews")
return json({
review_id: "review-from-service",
review_hash: "review-hash-from-service",
snapshot: {},
proposal,
});
if (path === "/api/v1/fact-proposals/proposal-new-type/decisions") {
const body = (await request.json()) as Record;
decisionBodies.push(body);
adopted = body.action === "adopt";
return json({
receipt_id: "decision-receipt",
command_id: body.command_id,
target_ref: `world:${proposal!.object_id}`,
action: body.action,
author_review_id: body.author_review_id,
basis: {},
results: [],
impact_ids: [],
});
}
throw new Error(`unexpected request: ${request.method} ${path}`);
}),
);
render(
,
);
await screen.findByRole("option", { name: "登船点" });
fireEvent.change(screen.getByLabelText("资料类型"), {
target: { value: "boarding_point" },
});
fireEvent.change(screen.getByLabelText("资料格式"), {
target: { value: "1" },
});
const add = screen.getByRole("button", { name: "添加资料" });
await waitFor(() => expect((add as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(add);
const editor = await screen.findByRole("region", { name: "添加资料" });
expect(
await within(editor).findByText("这是作者直接设定的资料,不依赖正文依据。"),
).toBeTruthy();
fireEvent.change(await within(editor).findByRole("textbox"), {
target: { value: "北渡口" },
});
fireEvent.change(await within(editor).findByRole("spinbutton"), {
target: { value: "80" },
});
fireEvent.click(screen.getByRole("button", { name: "保存为待决资料" }));
await screen.findByText("暂时无法连接工作台,请稍后重试。");
fireEvent.click(screen.getByRole("button", { name: "保存为待决资料" }));
await screen.findByRole("heading", { name: "提案内容" });
expect(screen.getAllByText("北渡口").length).toBeGreaterThan(0);
expect(screen.getAllByText("80").length).toBeGreaterThan(0);
expect(screen.getByText("本书还没有已确认的世界资料。")).toBeTruthy();
expect(saveBodies).toHaveLength(2);
expect(saveBodies[1]).toEqual(saveBodies[0]);
expect(saveBodies[0]).toMatchObject({
proposal: {
work_id: "work-a",
expected_revision: 0,
schema: {
schema_id: "boarding_point_v1",
base_version: 3,
extension_version: 2,
effective_schema_hash: "schema-current",
projection_version: "projection-current",
},
content: {},
independent: true,
knowledge_mode: "setting",
reader_visible: true,
sources: [],
effective_from: null,
known_to: [],
planned_window: null,
instance_family: "entity",
},
});
expect(saveBodies[0]).not.toHaveProperty("author_id");
expect((saveBodies[0].proposal as Record).changes).toEqual(
expect.arrayContaining([
{ 路径: ["dock_name"], 动作: "set", 值: "北渡口" },
{ 路径: ["capacity"], 动作: "set", 值: 80 },
]),
);
const openNew = screen.getByRole("button", {
name: "核对当前作品并打开决定",
});
await waitFor(() =>
expect((openNew as HTMLButtonElement).disabled).toBe(false),
);
fireEvent.click(openNew);
fireEvent.click(await screen.findByRole("button", { name: "确认这份资料" }));
await screen.findByText("这份资料已确认。");
expect(decisionBodies).toHaveLength(1);
expect(decisionBodies[0]).toMatchObject({
proposal_revision: 7,
proposal_hash: "proposal-hash-from-service",
expected_revision: 0,
author_review_id: "review-from-service",
review_hash: "review-hash-from-service",
approved_changes: ["object"],
});
await screen.findByText(/登船点 · 北渡口 · r1/);
});
// @muse-case {"case_id":"NC-world-review-scope","environment":"离线","given":"具名作者、固定来源、隔离测试配置与真实模块存储","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-world-review-scope:其他作品的提案在打开审阅前被拒绝"],"contract":"docs/系统架构/新版设计/模块设计/B02-故事世界.md#作者实体表单与字段提案"}
it("NC-world-review-scope:其他作品的提案在打开审阅前被拒绝", async () => {
const calls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const path = new URL(request.url).pathname;
calls.push(`${request.method} ${path}`);
if (path === "/api/v1/session") return json({ author_id: "author" });
if (path === "/api/v1/works/work-a/world/entities") return json([]);
if (path === "/api/v1/fact-proposals/proposal-b")
return json({
proposal_id: "proposal-b",
revision: 1,
proposal_hash: "hash-b",
work_id: "work-b",
object_id: "object-b",
expected_revision: 0,
schema: {
schema_id: "boarding_point_v1",
base_version: 3,
effective_schema_hash: "schema-current",
projection_version: "projection-current",
},
content: { dock_name: "不属于本书" },
independent: true,
sources: [],
effective_from: null,
knowledge_mode: "setting",
known_to: [],
planned_window: null,
decision: "undecided",
});
throw new Error(`unexpected request: ${request.method} ${path}`);
}),
);
render(
,
);
fireEvent.change(await screen.findByLabelText("待决资料编号"), {
target: { value: "proposal-b" },
});
fireEvent.click(screen.getByRole("button", { name: "查看内容" }));
await screen.findByText("这份待决资料不属于当前作品,不能在这里决定。");
expect(calls.some((call) => call.includes("/reviews"))).toBe(false);
expect(screen.queryByText("不属于本书")).toBeNull();
});
// @muse-case {"case_id":"NC-world-review-version","environment":"离线","given":"具名作者、固定来源、隔离测试配置与真实模块存储","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-world-review-version:当前实体版本与提案基线失配时不打开旧决定"],"contract":"docs/系统架构/新版设计/模块设计/B02-故事世界.md#作者实体表单与字段提案"}
it("NC-world-review-version:当前实体版本与提案基线失配时不打开旧决定", async () => {
const requests: string[] = [];
const proposal = {
proposal_id: "proposal-old",
revision: 2,
proposal_hash: "proposal-old-hash",
work_id: "work-a",
object_id: "object-a",
expected_revision: 1,
schema: {
schema_id: "boarding_point_v1",
base_version: 3,
extension_version: 2,
effective_schema_hash: "schema-current",
projection_version: "projection-current",
},
content: { dock_name: "旧提案名", capacity: 80 },
independent: true,
sources: [],
effective_from: null,
knowledge_mode: "setting",
known_to: [],
planned_window: null,
decision: "undecided",
};
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const url = new URL(request.url);
requests.push(`${request.method} ${url.pathname}`);
if (url.pathname === "/api/v1/session")
return json({ author_id: "author" });
if (url.pathname === "/api/v1/works/work-a/world/entities")
return json([
{
object_id: "object-a",
type_id: "boarding_point",
revision: 2,
knowledge_mode: "setting",
independent: true,
依据可用: true,
生效位置: null,
计划窗口: null,
content: { dock_name: "当前名", capacity: 40 },
},
]);
if (url.pathname === "/api/v1/fact-proposals/proposal-old")
return json(proposal);
if (
url.pathname === "/api/v1/works/work-a/world/entities/object-a/form"
) {
expect(url.search).toBe("");
return json(
表单结果(
"work-a",
"object-a",
{ dock_name: "当前名", capacity: 40 },
2,
),
);
}
throw new Error(`unexpected request: ${request.method} ${url.pathname}`);
}),
);
render(
,
);
const openOld = await screen.findByRole("button", {
name: "核对当前作品并打开决定",
});
await waitFor(() =>
expect((openOld as HTMLButtonElement).disabled).toBe(false),
);
fireEvent.click(openOld);
await screen.findByText(
"当前资料已经变化,这份待决资料不能按旧版本打开决定。请重新编辑。",
);
expect(requests.some((request) => request.includes("/reviews"))).toBe(false);
expect(screen.getAllByText("旧提案名").length).toBeGreaterThan(0);
});
// @muse-case {"case_id":"NC-world-editor-scope-reset","environment":"离线","given":"具名作者、固定来源、隔离测试配置与真实模块存储","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-world-editor-scope-reset:切类型、切实体与切作品不沿用旧表单"],"contract":"docs/系统架构/新版设计/模块设计/B02-故事世界.md#作者实体表单与字段提案"}
it("NC-world-editor-scope-reset:切类型、切实体与切作品不沿用旧表单", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const url = new URL(request.url);
if (url.pathname === "/api/v1/session")
return json({ author_id: "author" });
if (url.pathname === "/api/v1/world/types") return json(类型清单);
if (url.pathname === "/api/v1/works/work-a/world/entities")
return json([
{
object_id: "object-a",
type_id: "boarding_point",
revision: 1,
knowledge_mode: "setting",
independent: true,
依据可用: true,
生效位置: null,
计划窗口: null,
content: { dock_name: "旧码头", capacity: 20 },
},
]);
if (url.pathname === "/api/v1/works/work-b/world/entities")
return json([]);
if (url.pathname.endsWith("/form")) {
const objectId = url.pathname.split("/").at(-2)!;
const workId = url.pathname.includes("work-a") ? "work-a" : "work-b";
if (url.searchParams.get("schema_id") === "weather_station_v1")
return json({
...表单结果(workId, objectId),
type_id: "weather_station",
schema_binding: {
schema_id: "weather_station_v1",
base_version: 1,
extension_version: null,
effective_schema_hash: "weather-schema-current",
projection_version: "weather-projection-current",
},
content: {},
visible_schema: {
type: "object",
properties: {
station_name: { type: "string", title: "观天台名称" },
},
required: ["station_name"],
},
editable_schema: {
type: "object",
properties: {
station_name: { type: "string", title: "观天台名称" },
},
required: ["station_name"],
},
});
return json(
表单结果(workId, objectId, { dock_name: "旧码头", capacity: 20 }, 1),
);
}
if (url.pathname.endsWith("/timeline"))
return json({
object_id: "object-a",
type_id: "boarding_point",
instance_family: "entity",
current_revision: 1,
版本: [],
});
if (url.pathname.endsWith("/impact"))
return json({
object_id: "object-a",
current_revision: 1,
受影响事实: [],
本对象待决提案: [],
章序影响: [],
复核说明: "",
});
throw new Error(`unexpected request: ${request.method} ${url.pathname}`);
}),
);
const view = render(
,
);
fireEvent.click(
await screen.findByRole("button", { name: /登船点 · 旧码头/ }),
);
fireEvent.click(screen.getByRole("button", { name: "编辑资料" }));
const editRegion = await screen.findByRole("region", { name: "编辑资料" });
const dock = await within(editRegion).findByRole("textbox");
fireEvent.change(dock, { target: { value: "未保存的新名" } });
fireEvent.change(screen.getByLabelText("资料类型"), {
target: { value: "weather_station" },
});
expect(screen.queryByDisplayValue("未保存的新名")).toBeNull();
const addStation = screen.getByRole("button", { name: "添加资料" });
await waitFor(() =>
expect((addStation as HTMLButtonElement).disabled).toBe(false),
);
fireEvent.click(addStation);
const addRegion = await screen.findByRole("region", { name: "添加资料" });
expect(await within(addRegion).findByRole("textbox")).toBeTruthy();
view.rerender(
,
);
await waitFor(() =>
expect((screen.getByLabelText("资料类型") as HTMLSelectElement).value).toBe(
"",
),
);
expect(screen.queryByLabelText("观天台名称")).toBeNull();
expect(screen.queryByDisplayValue("未保存的新名")).toBeNull();
});