- 上下文清单、任务列表与详情、恢复控制、运行观察、创作对话与消息流、正式变更决策面板、正文写作与候选审阅、资料研究等页面同步本轮后端能力。 - 事件标签表补齐后端事件类型;查询键与生成客户端随 HTTP 契约重新生成。 - 依赖调整后 lockfile 同步;交互用例与浏览器旅程 spec 一并更新。 - 证据:make 前端检查(lint + typecheck + Pi 工具桥 tsc)exit 0;make 前端测试 37 文件 / 120 用例通过。
357 lines
17 KiB
TypeScript
357 lines
17 KiB
TypeScript
/** 实际浏览器和安装包;私人探索保存不产生正式作品或模型调用。 */
|
||
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,
|
||
});
|
||
});
|