muse-agent-example/web/tests/旅程/探索草稿.spec.ts
zizi ae02fd1732 前端:工作台页面、事件标签与旅程用例
- 上下文清单、任务列表与详情、恢复控制、运行观察、创作对话与消息流、正式变更决策面板、正文写作与候选审阅、资料研究等页面同步本轮后端能力。
- 事件标签表补齐后端事件类型;查询键与生成客户端随 HTTP 契约重新生成。
- 依赖调整后 lockfile 同步;交互用例与浏览器旅程 spec 一并更新。
- 证据:make 前端检查(lint + typecheck + Pi 工具桥 tsc)exit 0;make 前端测试 37 文件 / 120 用例通过。
2026-09-18 01:15:07 +08:00

357 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

/** 实际浏览器和安装包;私人探索保存不产生正式作品或模型调用。 */
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
test.use({ trace: "off" });
// @muse-case {"case_id":"NC-browser-private-exploration","environment":"Playwright与真后端,合成私人稿","given":"已安装后端、已认证作者和空私人目录","when":"输入中文、刷新、保存与另一窗口冲突,比较后保留自己的稿","then":["输入可恢复,冲突不覆盖,正式作品列表不变"],"contract":"docs/系统架构/新版设计/模块设计/B01-作品规划.md"}
test("NC-browser-private-exploration:中文缓冲恢复冲突与独立私人保存", async ({
page,
context,
}, info) => {
if (!process.env.MUSE_WORKBENCH_URL || !process.env.MUSE_AUTHOR_PASSWORD_FILE)
throw new Error("需要明确的隔离后端和作者口令文件。");
await page.goto("/");
await page
.getByLabel("访问口令")
.fill(readFileSync(process.env.MUSE_AUTHOR_PASSWORD_FILE, "utf8").trim());
await page.getByRole("button", { name: "进入工作台" }).click();
const before = await (await page.request.get("/api/v1/works")).json();
await page.getByRole("link", { name: "先记下一个想法 →" }).click();
await page.getByRole("link", { name: "记下新想法" }).click();
await page.getByLabel("稿件名称").fill("雨夜的伞(合成探索)");
await page
.getByLabel("我的想法")
.fill(" 妹妹握着一把湿伞。\n“哥哥还没回来。”e\u0301🧑‍🏽");
await page.reload();
await expect(page.getByLabel("我的想法")).toHaveValue(
" 妹妹握着一把湿伞。\n“哥哥还没回来。”e\u0301🧑‍🏽",
);
await page.getByRole("button", { name: "保存私人稿" }).click();
await expect(page.getByText("私人稿已保存。", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "检查整理稿格式" }),
).toBeEnabled();
await page.getByRole("button", { name: "检查整理稿格式" }).click();
await expect(
page.getByRole("heading", { name: "这些地方还需要整理" }),
).toBeVisible();
const id = new URL(page.url()).pathname.split("/").at(-1)!;
const api = `/api/v1/explorations/${id}`;
const original = await (await page.request.get(api)).json();
const other = await context.newPage();
await other.goto(page.url());
await expect(other.getByLabel("我的想法")).toHaveValue(original.content);
await page.getByLabel("我的想法").fill("妹妹撑开了伞。");
await page.getByRole("button", { name: "保存私人稿" }).click();
await expect
.poll(async () => (await (await page.request.get(api)).json()).revision)
.toBe(2);
await other.getByLabel("我的想法").fill("妹妹把伞收了起来。");
await other.getByRole("button", { name: "保存私人稿" }).click();
await expect(
other.getByRole("heading", { name: "另一处保存了新内容" }),
).toBeVisible();
await expect(other.getByLabel("我的想法")).toHaveValue("妹妹把伞收了起来。");
expect((await (await page.request.get(api)).json()).content).toBe(
"妹妹撑开了伞。",
);
await other
.getByRole("button", { name: "保留我的改稿,基于当前稿保存" })
.click();
await other.getByRole("button", { name: "保存私人稿" }).click();
await expect
.poll(async () => (await (await page.request.get(api)).json()).revision)
.toBe(3);
await other.screenshot({
path: info.outputPath("私人探索与保存.png"),
fullPage: true,
});
await other.close();
// 未修改的旧缓存不会盖过另一页已经保存的新稿。
await page.reload();
await expect(page.getByLabel("我的想法")).toHaveValue("妹妹把伞收了起来。");
expect(await (await page.request.get("/api/v1/works")).json()).toEqual(
before,
);
});
// @muse-case {"case_id":"NC-browser-exploration-selection","environment":"Chrome与真实HTTP、PG","given":"真实安装后端、隔离作品与最终探索稿","when":"作者在浏览器明确选定并随后修改私人稿","then":["本书来源快照不随私人修改漂移,没有自动形成正式规划或正文"],"contract":"docs/系统架构/新版设计/模块设计/B01-作品规划.md"}
test("NC-browser-exploration-selection:明确选定后保留不可变来源", async ({
page,
}, info) => {
if (!process.env.MUSE_AUTHOR_PASSWORD_FILE)
throw new Error("需要作者口令引用。");
await page.goto("/");
await page
.getByLabel("访问口令")
.fill(readFileSync(process.env.MUSE_AUTHOR_PASSWORD_FILE, "utf8").trim());
await page.getByRole("button", { name: "进入工作台" }).click();
await page.getByRole("button", { name: "新建作品" }).click();
await page.getByLabel("名称", { exact: true }).fill("浮岛规划(合成验收)");
const workId = new URL(page.url()).pathname.split("/")[2];
await page.getByRole("button", { name: "保存档案" }).click();
await expect(page.getByLabel("新章节标题")).toBeVisible();
await page.goto("/explorations");
await page.getByRole("link", { name: "记下新想法" }).click();
const headings = [
"根设定",
"一、作品定位与核心命题",
"二、故事世界与社会系统",
"三、角色与关系",
"四、故事机制与关键实体",
"五、阶段与状态变化",
"六、情节架构与全书大纲",
"七、主题与读者体验弧线",
"八、信息、悬念与伏笔管理",
"九、叙事执行与一致性",
"待决与取舍",
];
const source =
"# 浮岛|作品探索\n\n" +
headings.map((h) => `## ${h}\n未探索:先保留浮岛停电的画面。`).join("\n\n");
await page.getByLabel("稿件名称").fill("浮岛的夜晚");
await page.getByLabel("我的想法").fill(source);
await page.getByRole("button", { name: "保存私人稿" }).click();
await expect(
page.getByRole("button", { name: "检查整理稿格式" }),
).toBeEnabled();
await page.getByRole("button", { name: "检查整理稿格式" }).click();
await expect(
page.getByRole("heading", { name: "整理稿格式完整" }),
).toBeVisible();
await page.getByLabel("交给哪部作品").selectOption(workId);
const button = page.getByRole("button", { name: "选定本稿作为规划依据" });
await expect(button).toBeEnabled();
await button.click();
await expect(
page.getByText("已选定为作品规划依据。正式规划仍需单独确认。", {
exact: true,
}),
).toBeVisible();
const path = `/api/v1/works/${workId}/exploration-selection`;
const chosen = await (await page.request.get(path)).json();
expect(chosen.revision).toBe(1);
expect(chosen.selection.snapshot.content).toBe(source);
await page.screenshot({
path: info.outputPath("探索方向交接.png"),
fullPage: true,
});
await page.getByLabel("我的想法").fill(source + "\n尚未选定的新分歧。");
await page.getByRole("button", { name: "保存私人稿" }).click();
await expect(page.getByText("私人稿已保存。", { exact: true })).toBeVisible();
expect(await (await page.request.get(path)).json()).toEqual(chosen);
expect(
(await (await page.request.get(`/api/v1/works/${workId}/chapters`)).json())
.chapters,
).toEqual([]);
});
// @muse-case {"case_id":"NC-browser-plan-approval","environment":"Chrome、真实HTTP与隔离PG","given":"真实作者工作台、安装包及隔离作品","when":"编辑动态大纲、保存候选、审阅确认、再次改稿并由两页竞争确认","then":["候选不写正式规划;确认形成新版本,迟到决定拒绝,审阅显示当时的原规划"],"contract":"docs/系统架构/新版设计/模块设计/B01-作品规划.md"}
test("NC-browser-plan-approval:动态大纲候选确认与双页冲突", async ({
page,
context,
}, info) => {
if (!process.env.MUSE_AUTHOR_PASSWORD_FILE)
throw new Error("需要作者口令引用。");
await page.goto("/");
await page
.getByLabel("访问口令")
.fill(readFileSync(process.env.MUSE_AUTHOR_PASSWORD_FILE, "utf8").trim());
await page.getByRole("button", { name: "进入工作台" }).click();
await page.getByRole("button", { name: "新建作品" }).click();
await page.getByLabel("名称", { exact: true }).fill("雪中的信(规划验收)");
const work = new URL(page.url()).pathname.split("/")[2];
await page.getByRole("button", { name: "保存档案" }).click();
await page.getByRole("link", { name: "查看作品规划" }).click();
await page.getByRole("button", { name: "另拟大纲" }).click();
await page.getByLabel("名称", { exact: true }).fill("雪中的信");
await page
.getByLabel("主线一句话", { exact: true })
.fill("邮差寻找一封无人认领的信。");
await page.getByRole("button", { name: "保存规划候选" }).click();
const plans = `/api/v1/works/${work}/plans`;
await expect(
page.getByText("规划候选已保存,请打开审阅。", { exact: true }),
).toBeVisible();
expect(await (await page.request.get(plans)).json()).toEqual([]);
await page.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
await page.getByRole("button", { name: "继续修改这份候选" }).click();
await page.getByLabel("名称", { exact: true }).fill("雪后的信");
await page.getByRole("button", { name: "保存规划候选" }).click();
await expect(
page.getByText("规划候选已保存,请打开审阅。", { exact: true }),
).toBeVisible();
await expect
.poll(
async () =>
(await (await page.request.get(`${plans}/candidates`)).json())[0]
.revision,
)
.toBe(2);
const revised = (
await (await page.request.get(`${plans}/candidates`)).json()
)[0];
expect(revised.revision).toBe(2);
const original = await (
await page.request.get(
`/api/v1/plan-candidates/${revised.candidate_id}?version=1`,
)
).json();
expect(original.content["名称"]).toBe("雪中的信");
await page.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
await page.getByRole("button", { name: "确认这份规划" }).click();
await expect(
page.getByText("这份规划已确认。", { exact: true }),
).toBeVisible();
expect((await (await page.request.get(plans)).json())[0].revision).toBe(1);
await page.getByRole("button", { name: "另拟大纲" }).click();
await page
.getByLabel("主线一句话", { exact: true })
.fill("邮差发现寄信人始终住在隔壁。");
await page.getByRole("button", { name: "保存规划候选" }).click();
await page.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
const other = await context.newPage();
await other.goto(page.url());
await other.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
await expect(
other.getByRole("button", { name: "确认这份规划" }),
).toBeVisible();
const drafting = await context.newPage();
await drafting.goto(page.url());
await drafting.getByRole("button", { name: "另拟大纲" }).click();
await drafting
.getByLabel("主线一句话", { exact: true })
.fill("邮差决定亲手递出这封信。");
await page.screenshot({
path: info.outputPath("大纲版本审阅.png"),
fullPage: true,
});
await page.getByRole("button", { name: "确认这份规划" }).click();
await expect
.poll(
async () => (await (await page.request.get(plans)).json())[0].revision,
)
.toBe(2);
await other.getByRole("button", { name: "确认这份规划" }).click();
await expect(other.getByRole("alert")).toBeVisible();
expect((await (await page.request.get(plans)).json())[0].revision).toBe(2);
await drafting.getByRole("button", { name: "保存规划候选" }).click();
await expect(
drafting.getByText("保存基线已变化。你的输入仍在下方,请先比较当前内容。", {
exact: true,
}),
).toBeVisible();
await expect(drafting.getByLabel("主线一句话", { exact: true })).toHaveValue(
"邮差决定亲手递出这封信。",
);
await drafting
.getByRole("button", { name: "保留我的改动,基于当前版本重看" })
.click();
await expect(drafting.getByLabel("主线一句话", { exact: true })).toHaveValue(
"邮差决定亲手递出这封信。",
);
// 重看会等待最新正式版本;冲突面板消失后才重建了基线。
await expect(
drafting.getByText("保存基线已变化。你的输入仍在下方,请先比较当前内容。", {
exact: true,
}),
).toBeHidden();
await drafting.getByRole("button", { name: "保存规划候选" }).click();
await expect(
drafting.getByText("规划候选已保存,请打开审阅。", { exact: true }),
).toBeVisible();
await drafting.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
await drafting.getByRole("button", { name: "确认这份规划" }).click();
await expect
.poll(
async () => (await (await page.request.get(plans)).json())[0].revision,
)
.toBe(3);
expect(
(await (await page.request.get(plans)).json())[0].content["主线一句话"],
).toBe("邮差决定亲手递出这封信。");
await drafting.screenshot({
path: info.outputPath("规划冲突恢复.png"),
fullPage: true,
});
await drafting.close();
await other.close();
});
// @muse-case {"case_id":"NC-browser-plan-nodes","environment":"真实Chrome与隔离PostgreSQL","given":"安装后的作者工作台与全新合成作品","when":"实际创建卷章场景目录,打开场景的动态表单,保存候选并明确审阅确认","then":["节点归属、目录版本和正式场景内容均能由实际HTTP回查","场景计划与所属章绑定,不写成正文或事实"],"contract":"docs/系统架构/新版设计/模块设计/B01-作品规划.md"}
test("NC-browser-plan-nodes:卷章场景目录接通动态场景规划", async ({
page,
}, info) => {
if (!process.env.MUSE_AUTHOR_PASSWORD_FILE)
throw new Error("需要作者口令引用。");
await page.goto("/");
await page
.getByLabel("访问口令")
.fill(readFileSync(process.env.MUSE_AUTHOR_PASSWORD_FILE, "utf8").trim());
await page.getByRole("button", { name: "进入工作台" }).click();
await page.getByRole("button", { name: "新建作品" }).click();
await page.getByLabel("名称", { exact: true }).fill("浮岛航线(节点验收)");
const work = new URL(page.url()).pathname.split("/")[2];
await page.getByRole("button", { name: "保存档案" }).click();
await page.getByRole("link", { name: "查看作品规划" }).click();
await page.getByText("卷、章与场景", { exact: true }).click();
await page.getByLabel("新节点名称", { exact: true }).fill("第一卷");
await page.getByRole("button", { name: "加入目录" }).click();
await page.getByLabel("新增层级").selectOption("chapter");
await page.getByLabel("新节点名称", { exact: true }).fill("起航");
await page
.getByRole("combobox", { name: "新节点归属", exact: true })
.selectOption({ label: "第一卷" });
await page.getByRole("button", { name: "加入目录" }).click();
await page.getByLabel("新增层级").selectOption("scene");
await page.getByLabel("新节点名称", { exact: true }).fill("门外访客");
await page
.getByRole("combobox", { name: "新节点归属", exact: true })
.selectOption({ label: "起航" });
await page.getByRole("button", { name: "加入目录" }).click();
await page.getByRole("button", { name: "保存节点目录" }).click();
await expect(
page.getByText("卷章与场景目录已保存。", { exact: true }),
).toBeVisible();
const directory = await (
await page.request.get(`/api/v1/works/${work}/plan-nodes`)
).json();
const chapter = directory.nodes.find(
(n: { kind: string }) => n.kind === "chapter",
),
scene = directory.nodes.find((n: { kind: string }) => n.kind === "scene");
expect(directory.revision).toBe(1);
expect(scene.parent_id).toBe(chapter.node_id);
await page.getByRole("link", { name: "填写门外访客的规划" }).click();
await page.getByRole("button", { name: "另拟节点计划" }).click();
await page
.getByLabel("目标推进收束", { exact: true })
.fill("访客想借一盏灯,船长让他进屋。");
await page.getByRole("button", { name: "保存规划候选" }).click();
await expect(
page.getByText("规划候选已保存,请打开审阅。", { exact: true }),
).toBeVisible();
await page.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
await page.getByRole("button", { name: "确认这份规划" }).click();
await expect(
page.getByText("这份规划已确认。", { exact: true }),
).toBeVisible();
const plans = await (
await page.request.get(`/api/v1/works/${work}/plans`)
).json();
expect(plans).toHaveLength(1);
expect(plans[0]).toMatchObject({
node_id: scene.node_id,
chapter_id: chapter.node_id,
type_id: "scene",
revision: 1,
});
expect(plans[0].content["目标推进收束"]).toBe(
"访客想借一盏灯,船长让他进屋。",
);
await page.screenshot({
path: info.outputPath("场景节点规划.png"),
fullPage: true,
});
});