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(); });