前端:工作台页面、事件标签与旅程用例

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

1
web/node_modules Symbolic link
View File

@ -0,0 +1 @@
/Users/qingse/Sync/local-git/oh-my-muse/agent-example/web/node_modules

View File

@ -10,8 +10,9 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src",
"typecheck": "tsc -b --noEmit"
"lint": "eslint src tests",
"typecheck": "tsc -b --noEmit",
"test:journey": "playwright test"
},
"dependencies": {
"@tanstack/react-query": "^5.102.8",
@ -28,7 +29,6 @@
"@eslint/js": "9.15.0",
"@playwright/test": "^1.49.0",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "22.20.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@ -37,7 +37,6 @@
"eslint-plugin-react-hooks": "^5.0.0",
"jsdom": "^26.1.0",
"openapi-typescript": "^7.13.0",
"prettier": "^3.9.6",
"typescript": "^5.6.3",
"typescript-eslint": "^8.15.0",
"vite": "^6.0.1",

View File

@ -1,4 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
import { 核对旅程环境 } from "./旅程环境";
if (!process.argv.includes("--list")) 核对旅程环境(process.env);
// 真实浏览器旅程:对真后端与隔离库执行中文输入与实际点击。
// 用例登记在 web/tests/旅程/,覆盖环境合同与端到端交互。
@ -7,10 +10,10 @@ export default defineConfig({
timeout: 60_000,
retries: 0,
use: {
baseURL: process.env.MUSE_WORKBENCH_URL || "http://127.0.0.1:5173",
baseURL: process.env.MUSE_WORKBENCH_URL,
locale: "zh-CN",
trace: "retain-on-failure",
launchOptions: {executablePath: process.env.MUSE_BROWSER_EXECUTABLE},
launchOptions: { executablePath: process.env.MUSE_BROWSER_EXECUTABLE },
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});

23
web/pnpm-lock.yaml generated
View File

@ -45,9 +45,6 @@ importers:
'@testing-library/react':
specifier: ^16.3.3
version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@testing-library/user-event':
specifier: ^14.6.7
version: 14.6.7(@testing-library/dom@10.4.1)
'@types/node':
specifier: 22.20.1
version: 22.20.1
@ -72,9 +69,6 @@ importers:
openapi-typescript:
specifier: ^7.13.0
version: 7.13.0(typescript@5.9.3)
prettier:
specifier: ^3.9.6
version: 3.9.6
typescript:
specifier: ^5.6.3
version: 5.9.3
@ -765,12 +759,6 @@ packages:
'@types/react-dom':
optional: true
'@testing-library/user-event@14.6.7':
resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@tiptap/core@3.31.3':
resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==}
peerDependencies:
@ -1606,11 +1594,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.9.6:
resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
engines: {node: '>=14'}
hasBin: true
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@ -2550,10 +2533,6 @@ snapshots:
'@types/react': 18.3.31
'@types/react-dom': 18.3.7(@types/react@18.3.31)
'@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)':
dependencies:
'@testing-library/dom': 10.4.1
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/pm': 3.31.3
@ -3465,8 +3444,6 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.9.6: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1

View File

@ -45,6 +45,15 @@ export function Ui任务上下文({
<p>任务实际消费的材料会在冻结后出现在这里,并标注每处来源的版本。</p>
</div>
)}
{query.hasNextPage && (
<button
disabled={query.isFetchingNextPage}
onClick={() => void query.fetchNextPage()}
>
加载更多材料快照
</button>
)}
<p className="muted">每页20份,最多保留最近载入的3页。</p>
</section>
);
}

View File

@ -1,7 +1,7 @@
/** 上下文功能的数据接入:任务材料快照的查询与稳定标签。 */
import { useQuery } from "@tanstack/react-query";
import { useInfiniteQuery } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
import { 查询键 } from "../../接口/查询键";
import { 任务查询键 } from "../../接口/查询键";
import type { operations } from "../../接口/生成/类型";
export type 任务上下文快照 =
@ -9,16 +9,33 @@ export type 任务上下文快照 =
/** 快照键挂在任务键之下:任务事件失效任务查询时一并刷新材料。 */
export function useMuse任务上下文(scope: string | null, taskId: string) {
return useQuery({
queryKey: 查询键(scope, taskId, "context-snapshots"),
const query = useInfiniteQuery({
queryKey: 任务查询键(scope, taskId, "context-snapshots"),
enabled: !!taskId,
queryFn: async ({ signal }) =>
(
await 客户端.GET("/api/v1/tasks/{task_id}/context-snapshots", {
params: { path: { task_id: taskId }, signal },
})
).data!,
initialPageParam: null as string | null,
maxPages: 3,
queryFn: async ({ signal, pageParam }) => {
const { data } = await 客户端.GET(
"/api/v1/tasks/{task_id}/context-snapshots/page",
{
params: {
path: { task_id: taskId },
query: {
work_id: scope ?? undefined,
limit: 20,
cursor: pageParam ?? undefined,
},
},
signal,
},
);
if (!data) throw new Error("材料快照分页未返回。");
return data;
},
getNextPageParam: (page) =>
page.has_more ? (page.next_cursor ?? undefined) : undefined,
});
return { ...query, data: query.data?.pages.flatMap((page) => page.items) };
}
export const 快照状态: Record<string, string> = {

View File

@ -4,8 +4,8 @@ import { useSearchParams } from "react-router-dom";
import { 客户端 } from "../../接口/生成/客户端";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { useMuse作品会话 } from "../../应用/作品会话";
import { 查询键 } from "../../接口/查询键";
import { 状态 } from "./数据";
import { 任务查询键, 任务列表键 } from "../../接口/查询键";
import { 状态, 读取任务 } from "./数据";
import { Ui任务详情 } from "./任务详情";
import { Ui发起生成 } from "../创作对话/会话面板";
@ -13,7 +13,7 @@ export function Ui任务列表() {
const [params] = useSearchParams();
const work = useMuse作品会话();
const allTasks = useQuery({
queryKey: ["任务总览"],
queryKey: 任务列表键(null),
queryFn: async ({ signal }) =>
(await 客户端.GET("/api/v1/tasks", { signal })).data!,
});
@ -25,7 +25,7 @@ export function Ui任务列表() {
),
];
const list = useQuery({
queryKey: 查询键(work.workId, "任务列表"),
queryKey: 任务列表键(work.workId),
queryFn: async ({ signal }) =>
(
await 客户端.GET("/api/v1/tasks", {
@ -37,18 +37,9 @@ export function Ui任务列表() {
const [input, setInput] = useState(params.get("task") ?? "");
const [taskId, setTaskId] = useState(params.get("task") ?? "");
const task = useQuery({
queryKey: 查询键(work.workId, taskId),
queryKey: 任务查询键(work.workId, taskId),
enabled: !!taskId,
queryFn: async ({ signal }) =>
(
await 客户端.GET("/api/v1/tasks/{task_id}", {
params: {
path: { task_id: taskId },
query: { work_id: work.workId ?? undefined },
},
signal,
})
).data!,
queryFn: ({ signal }) => 读取任务(taskId, work.workId, signal),
});
function find(event: FormEvent) {
event.preventDefault();

View File

@ -0,0 +1,183 @@
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
import { 订阅任务事件 } from "../../接口/事件连接";
import { 任务查询键, 任务查询根 } from "../../接口/查询键";
import { 读取任务, type 任务, type 任务事件 } from "./数据";
const terminal = (task: 任务) =>
(task.state === "completed" || task.state === "cancelled") &&
task.execution_final_sequence !== null &&
task.execution_final_sequence !== undefined;
/** 每个挂载作用域最多一条执行流和一个快照请求;费用是独立的显式读回。 */
export function useMuse任务订阅(task: 任务, scope: string | null) {
const cache = useQueryClient();
const [events, setEvents] = useState<任务事件[]>([]),
[notice, setNotice] = useState("");
useEffect(() => {
let alive = true,
recovering = false,
complete = terminal(task),
requiredSequence = task.last_sequence;
let close = () => {},
timer: ReturnType<typeof setTimeout> | undefined,
inFlight: Promise<任务> | undefined;
const historyAbort = new AbortController();
const append = (event: 任务事件) => {
// 只保留工作面显示需要的近期增量,不把任意大payload积存在事件面板。
const item =
event.event_type === "text.delta"
? {
...event,
payload: { 文本: String(event.payload.文本 ?? "").slice(-4096) },
}
: event;
setEvents((old) =>
old.some((value) => value.sequence === item.sequence)
? old
: [...old, item].sort((a, b) => a.sequence - b.sequence).slice(-40),
);
};
async function history(snapshot: 任务) {
const { data } = await 客户端.GET("/api/v1/tasks/{task_id}/events", {
params: {
path: { task_id: snapshot.task_id },
query: {
cursor: Math.max(0, snapshot.last_sequence - 40),
limit: 40,
work_id: scope ?? undefined,
},
},
signal: historyAbort.signal,
});
if (!alive || !data) return;
if (data.reset_required) {
setNotice("较早事件已归档,当前任务与结果已从快照恢复。");
return;
}
data.items
.filter((event) => event.sequence <= snapshot.last_sequence)
.forEach(append);
}
function refresh() {
if (inFlight) return inFlight;
inFlight = cache.fetchQuery({
queryKey: 任务查询键(scope, task.task_id),
queryFn: ({ signal }) => 读取任务(task.task_id, scope, signal),
staleTime: 0,
});
const request = inFlight;
void request
.finally(() => {
if (inFlight === request) inFlight = undefined;
})
.catch(() => {});
return request;
}
async function refreshRelated(final = false) {
if (final && inFlight) await inFlight.catch(() => {});
const snapshot = await refresh();
if (!alive) return;
await Promise.all(
["context-snapshots", "运行观察"].map((section) =>
cache.invalidateQueries({
queryKey: 任务查询键(scope, task.task_id, section),
exact: true,
}),
),
);
if (!complete && snapshot.last_sequence < requiredSequence) schedule();
}
function schedule() {
if (timer || !alive || recovering || complete) return;
timer = setTimeout(() => {
timer = undefined;
void refreshRelated().catch(() => {
if (alive) setNotice("进度读取暂未完成,可重新读取任务。");
});
}, 100);
}
function subscribe(snapshot: 任务) {
if (!alive) return;
if (terminal(snapshot)) {
complete = true;
return;
}
close = 订阅任务事件(
task.task_id,
{
收到(event) {
if (!alive) return;
append(event);
setNotice("");
requiredSequence = Math.max(requiredSequence, event.sequence);
if (event.event_type !== "text.delta") schedule();
},
缺口() {
if (!alive || recovering) return;
recovering = true;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
setNotice("事件有缺口,正在从任务快照恢复。");
const restore = async () => {
if (inFlight) await inFlight.catch(() => {});
return refresh();
};
void restore()
.then((snapshot) => {
if (!alive) return;
recovering = false;
requiredSequence = snapshot.last_sequence;
setEvents([]);
setNotice("");
void history(snapshot).catch(() => {
if (alive)
setNotice("近期事件暂时无法读取,任务快照已恢复。");
});
subscribe(snapshot);
})
.catch(() => {
if (alive) {
recovering = false;
setNotice("任务快照恢复失败,请重新打开此任务。");
}
});
},
重连() {
if (alive) setNotice("连接正在恢复,任务会继续保留。");
},
完成() {
complete = true;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
setNotice("执行记录已完整接收。");
void refreshRelated(true).catch(() => {
if (alive) setNotice("执行记录已收尾,最终快照暂未读回。");
});
},
},
{ cursor: snapshot.last_sequence, workId: scope },
);
}
setEvents([]);
void history(task).catch(() => {
if (alive) setNotice("近期事件暂时无法读取。");
});
subscribe(task);
return () => {
alive = false;
close();
historyAbort.abort();
if (timer) clearTimeout(timer);
void cache.cancelQueries({ queryKey: 任务查询根(scope, task.task_id) });
};
// task是挂载时的权威快照,后续刷新不能重建仍活跃的订阅。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cache, task.task_id, scope]);
return { events, notice };
}

View File

@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Ui任务上下文 } from "../上下文";
import { Ui消息流 } from "../创作对话/消息流";
import { Ui作者动作 } from "../创作对话/作者动作";
@ -7,10 +6,10 @@ import { useMuse作者会话 } from "../../应用/作者会话";
import { Ui恢复控制 } from "./恢复控制";
import { Ui任务预算 } from "./任务预算";
import { Ui运行观察 } from "../作者经验/运行观察";
import { 订阅任务事件 } from "../../接口/事件连接";
import { 查询键 } from "../../接口/查询键";
import { useMuse任务订阅 } from "./任务订阅";
import { Ui调用账目 } from "./调用账目";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { 状态, 事件名, type 任务, type 任务事件 } from "./数据";
import { 状态, 事件名, type 任务 } from "./数据";
export function Ui任务详情({
task,
@ -19,32 +18,18 @@ export function Ui任务详情({
task: 任务;
scope: string | null;
}) {
const cache = useQueryClient();
const [events, setEvents] = useState<任务事件[]>([]);
const [notice, setNotice] = useState("");
const [openedCandidate, setOpenedCandidate] = useState<string>();
const { events, notice } = useMuse任务订阅(task, scope);
const 生成中 = task.task_type === "生成正文";
const 候选事件 = [...events]
.reverse()
.find((条) => 条.event_type === "candidate.saved");
const eventCandidate = 候选事件?.payload.candidate_id;
const 候选ID = 生成中
? ((候选事件?.payload as { candidate_id?: string } | undefined)
?.candidate_id ?? null)
? (task.result_refs?.find((ref) => ref.kind === "writing_candidate")
?.candidate_id ??
(typeof eventCandidate === "string" ? eventCandidate : null))
: null;
useEffect(
() =>
订阅任务事件(task.task_id, {
收到: (事件) => {
setEvents((旧) => [...旧, 事件].slice(-40));
setNotice("");
void cache.invalidateQueries({
queryKey: 查询键(scope, task.task_id),
});
},
缺口: () => setNotice("较早的事件已归档,请重新读取任务进度。"),
重连: () => setNotice("连接正在恢复,任务会继续保留。"),
}),
[task.task_id, scope, cache],
);
return (
<div className="task-columns">
<section className="paper-panel">
@ -53,7 +38,8 @@ export function Ui任务详情({
<span className="state-pill">{状态[task.state]}</span>
</div>
<p className="mono muted">{task.task_id}</p>
<Ui恢复控制 task={task} />
<Ui恢复控制 task={task} scope={scope} />
<Ui调用账目 task={task} scope={scope} />
{task.supports_step_advance && <Ui任务预算 taskId={task.task_id} />}
<ol className="step-list">
{task.steps.map((step, index) => (
@ -72,8 +58,13 @@ export function Ui任务详情({
<section className="event-panel">
<div className="panel-heading">
<h2>过程记录</h2>
<span className="muted">实时更新</span>
<span className="muted">
{task.execution_final_sequence != null ? "执行已收尾" : "实时更新"}
</span>
</div>
<p className="muted">
显示最近40条过程记录;正文增量仅作有限预览,完整结果请打开候选。
</p>
{notice && <Ui反馈状态 message={notice} />}
<ol className="event-list" aria-live="polite">
{events.map((event) => (
@ -107,7 +98,18 @@ export function Ui任务详情({
<span className="muted">生成过程与候选检查</span>
</div>
<Ui消息流 事件={events} />
{候选ID && <Ui生成候选动作 scope={scope} candidateId={候选ID} />}
{候选ID && openedCandidate !== 候选ID && (
<button onClick={() => setOpenedCandidate(候选ID)}>
打开生成候选
</button>
)}
{openedCandidate && (
<Ui生成候选动作
key={openedCandidate}
scope={scope}
candidateId={openedCandidate}
/>
)}
</section>
)}
</div>

View File

@ -2,9 +2,16 @@ import { useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { 任务查询根, 任务列表键 } from "../../接口/查询键";
import type { 任务 } from "./数据";
export function Ui恢复控制({ task }: { task: 任务 }) {
export function Ui恢复控制({
task,
scope,
}: {
task: 任务;
scope: string | null;
}) {
const cache = useQueryClient();
const [busy, setBusy] = useState(false);
const [controlError, setControlError] = useState("");
@ -19,7 +26,13 @@ export function Ui恢复控制({ task }: { task: 任务 }) {
await 客户端.POST("/api/v1/tasks/{task_id}/advance", {
params: { path: { task_id: task.task_id } },
});
await cache.invalidateQueries();
await Promise.all([
cache.invalidateQueries({ queryKey: 任务查询根(scope, task.task_id) }),
cache.invalidateQueries({ queryKey: 任务列表键(scope) }),
...(scope
? [cache.invalidateQueries({ queryKey: 任务列表键(null) })]
: []),
]);
} catch (e) {
setControlError(
e instanceof Error ? e.message : "该步骤未完成,请查看任务状态。",
@ -44,7 +57,13 @@ export function Ui恢复控制({ task }: { task: 任务 }) {
},
});
pending.current = null;
await cache.invalidateQueries();
await Promise.all([
cache.invalidateQueries({ queryKey: 任务查询根(scope, task.task_id) }),
cache.invalidateQueries({ queryKey: 任务列表键(scope) }),
...(scope
? [cache.invalidateQueries({ queryKey: 任务列表键(null) })]
: []),
]);
} catch (e) {
setControlError(e instanceof Error ? e.message : "控制请求未完成。");
} finally {

View File

@ -1,3 +1,4 @@
import { 客户端 } from "../../接口/生成/客户端";
import type { operations } from "../../接口/生成/类型";
export type 任务 =
@ -27,3 +28,19 @@ export const 事件名: Record<string, string> = {
"step.checkpoint": "进度已保存",
"call.unknown": "等待结果核对",
};
export async function 读取任务(
taskId: string,
work: string | null,
signal?: AbortSignal,
) {
const { data } = await 客户端.GET("/api/v1/tasks/{task_id}", {
params: {
path: { task_id: taskId },
query: { work_id: work ?? undefined },
},
signal,
});
if (!data) throw new Error("任务快照未返回。");
return data;
}

View File

@ -0,0 +1,346 @@
import { useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMuse作者会话 } from "../../应用/作者会话";
import { 客户端 } from "../../接口/生成/客户端";
import type { operations } from "../../接口/生成/类型";
import { 接口错误 } from "../../接口/传输";
import { 任务查询键 } from "../../接口/查询键";
import type { 任务 } from "./数据";
type 账目 =
operations["read_task_calls"]["responses"][200]["content"]["application/json"];
type 费用请求 =
operations["settle_task_call"]["requestBody"]["content"]["application/json"];
type 核对请求 =
operations["reconcile_task_call"]["requestBody"]["content"]["application/json"];
type 待确认 =
| { kind: "settlement"; body: 费用请求 }
| { kind: "reconciliation"; body: 核对请求 };
const object = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
function 有效待确认(value: unknown): value is 待确认 {
if (
!object(value) ||
!object(value.body) ||
typeof value.body.command_id !== "string" ||
!value.body.command_id
)
return false;
const body = value.body;
if (value.kind === "reconciliation")
return typeof body.evidence_id === "string" && !!body.evidence_id;
return (
value.kind === "settlement" &&
Number.isInteger(body.expected_revision) &&
(typeof body.amount === "string" || typeof body.amount === "number") &&
body.currency === "USD" &&
typeof body.source_reference === "string" &&
typeof body.source_statement === "string" &&
typeof body.source_hash === "string" &&
/^[0-9a-f]{64}$/.test(body.source_hash) &&
body.source_kind === "author_attestation" &&
typeof body.zero_cost_confirmed === "boolean"
);
}
export function Ui调用账目({
task,
scope,
}: {
task: 任务;
scope: string | null;
}) {
const author = useMuse作者会话();
const query = useQuery({
queryKey: 任务查询键(scope, task.task_id, "calls"),
queryFn: async ({ signal }) => {
const { data } = await 客户端.GET("/api/v1/tasks/{task_id}/calls", {
params: { path: { task_id: task.task_id } },
signal,
});
if (!data) throw new Error("调用账目未返回。");
return data;
},
refetchOnWindowFocus: false,
});
return (
<section aria-label="调用费用">
<h3>调用费用与结果核对</h3>
<button disabled={query.isFetching} onClick={() => void query.refetch()}>
刷新调用账目
</button>
{query.error && <p role="alert">{query.error.message}</p>}
{query.data && (
<>
<p>
账目版本 {query.data.accounting_revision} · 最后读取{" "}
{new Date(query.dataUpdatedAt).toLocaleTimeString("zh-CN")}
</p>
<p>
执行任务
{task.state === "cancelled"
? "已取消"
: task.state === "completed"
? "已完成"
: "仍可接续"}
;补录费用不会重新运行任务。
</p>
{query.data.calls.length === 0 && <p>尚无调用账目。</p>}
{author.data &&
query.data.calls.map((call) => (
<Ui调用费用项
key={call.call_id}
author={author.data.author_id}
ledger={query.data!}
call={call}
onRead={() => query.refetch().then(() => {})}
/>
))}
</>
)}
</section>
);
}
function Ui调用费用项({
author,
ledger,
call,
onRead,
}: {
author: string;
ledger: 账目;
call: 账目["calls"][number];
onRead: () => Promise<void>;
}) {
const key =
"muse:call-accounting:v1:" +
JSON.stringify([author, ledger.task_id, call.call_id]);
const [load] = useState(() => {
try {
const raw = sessionStorage.getItem(key);
if (!raw) return { pending: undefined, raw: null };
const value: unknown = JSON.parse(raw);
if (!有效待确认(value))
throw new Error("待确认账目记录无法读取,请保留浏览器资料。");
return { pending: value, raw };
} catch (e) {
return {
pending: undefined,
raw: null,
error: e instanceof Error ? e.message : "账目缓冲无法读取。",
};
}
});
const [pending, setPending] = useState(load.pending),
observed = useRef(load.raw);
const [amount, setAmount] = useState(""),
[reference, setReference] = useState(""),
[statement, setStatement] = useState(""),
[zero, setZero] = useState(false),
[evidence, setEvidence] = useState("");
const [busy, setBusy] = useState(false),
[error, setError] = useState(load.error ?? ""),
[notice, setNotice] = useState("");
const busyRef = useRef(false);
async function send(kind: 待确认["kind"]) {
if (busyRef.current || load.error) return;
busyRef.current = true;
setBusy(true);
setError("");
setNotice("");
let frozen = pending;
try {
if (!frozen) {
if (kind === "settlement") {
const hash = Array.from(
new Uint8Array(
await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(statement),
),
),
(b) => b.toString(16).padStart(2, "0"),
).join("");
frozen = {
kind,
body: {
command_id: crypto.randomUUID(),
expected_revision: ledger.accounting_revision,
amount,
currency: "USD",
source_reference: reference,
source_hash: hash,
source_statement: statement,
source_kind: "author_attestation",
zero_cost_confirmed: zero,
},
};
} else
frozen = {
kind,
body: { command_id: crypto.randomUUID(), evidence_id: evidence },
};
}
if (sessionStorage.getItem(key) !== observed.current)
throw new Error("待确认账目已由另一页面更新,请重新打开核对。");
const raw = JSON.stringify(frozen);
sessionStorage.setItem(key, raw);
observed.current = raw;
setPending(frozen);
const path = { task_id: ledger.task_id, call_id: call.call_id };
if (frozen.kind === "settlement") {
const { data } = await 客户端.POST(
"/api/v1/tasks/{task_id}/calls/{call_id}/settlements",
{ params: { path }, body: frozen.body },
);
if (!data?.receipt_id) throw new Error("结算回执缺失,请重试原请求。");
setNotice(`已保存费用确认,回执 ${data.receipt_id}`);
} else {
const { data } = await 客户端.POST(
"/api/v1/tasks/{task_id}/calls/{call_id}/reconciliations",
{ params: { path }, body: frozen.body },
);
if (data?.command_id !== frozen.body.command_id)
throw new Error("响应核对回执缺失,请重试原请求。");
setNotice("已核对保存响应,可另行选择恢复任务。");
}
if (sessionStorage.getItem(key) === observed.current)
sessionStorage.removeItem(key);
observed.current = null;
setPending(undefined);
await onRead();
} catch (cause) {
if (
cause instanceof 接口错误 &&
cause.status >= 400 &&
cause.status < 500 &&
cause.status !== 408
) {
if (sessionStorage.getItem(key) === observed.current)
sessionStorage.removeItem(key);
observed.current = null;
setPending(undefined);
}
setError(cause instanceof Error ? cause.message : "账目尚未确认。");
} finally {
busyRef.current = false;
setBusy(false);
}
}
const locked = busy || !!pending || !!load.error;
const recoverable =
ledger.state === "paused" || ledger.state === "reconciling";
return (
<section aria-label={`调用 ${call.call_id}`}>
<p>
{call.role_id} · {call.call_id} ·{" "}
{call.state === "settled"
? `已结 ${call.actual_amount} USD`
: call.state === "released"
? "已释放未发送预留"
: "费用待确认"}
</p>
{call.receipt_id && <p>账目回执 {call.receipt_id}</p>}
{pending ? (
<button
disabled={busy || !!load.error}
onClick={() => void send(pending.kind)}
>
重试原账目请求
</button>
) : (
<>
{call.state === "unknown" && (
<form
onSubmit={(event) => {
event.preventDefault();
void send("settlement");
}}
>
<p>请填写你核对过的费用依据,此记录属于作者确认。</p>
<label>
实际费用 USD
<input
value={amount}
required
pattern="[0-9]+(\.[0-9]+)?"
disabled={locked}
onChange={(event) => setAmount(event.target.value)}
/>
</label>
<label>
费用来源引用
<input
value={reference}
required
disabled={locked}
onChange={(event) => setReference(event.target.value)}
/>
</label>
<label>
费用依据原文
<textarea
value={statement}
maxLength={8000}
required
disabled={locked}
onChange={(event) => setStatement(event.target.value)}
/>
</label>
{Number(amount) === 0 && amount !== "" && (
<label>
<input
type="checkbox"
checked={zero}
required
disabled={locked}
onChange={(event) => setZero(event.target.checked)}
/>
我已核对依据,确认本次调用费用为零
</label>
)}
<button disabled={locked}>确认并补录费用</button>
</form>
)}
{recoverable &&
call.state === "settled" &&
call.delivery_state === "missing" && (
<div>
<label>
核对已保存的完整响应
<select
value={evidence}
disabled={locked}
onChange={(event) => setEvidence(event.target.value)}
>
<option value="">选择完整响应证据</option>
{call.response_evidence
.filter(
(item) =>
item.retention === "full" &&
item.outcome === "completed",
)
.map((item) => (
<option key={item.evidence_id} value={item.evidence_id}>
{item.evidence_id}
</option>
))}
</select>
</label>
<button
disabled={locked || !evidence}
onClick={() => void send("reconciliation")}
>
核对原调用响应
</button>
</div>
)}
</>
)}
{error && <p role="alert">{error}</p>}
{notice && <p role="status">{notice}</p>}
</section>
);
}

View File

@ -2,7 +2,7 @@
import { useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
import { 查询键 } from "../../接口/查询键";
import { 任务查询键 } from "../../接口/查询键";
import type { operations } from "../../接口/生成/类型";
import { 状态 } from "../任务运行/数据";
@ -43,7 +43,7 @@ export function Ui运行观察({
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const command = useRef<{ signature: string; id: string }>();
const queryKey = 查询键(scope, taskId, "运行观察");
const queryKey = 任务查询键(scope, taskId, "运行观察");
const observations = useQuery({
queryKey,
queryFn: async ({ signal }) => {

View File

@ -24,31 +24,37 @@ export function Ui作者动作({
const [失败, set失败] = useState("");
const 报告 = useMuse审校报告(scope, candidateId, 候选?.revision ?? null);
const 读取并审阅 = useCallback(async () => {
set失败("");
try {
const { data } = await 客户端.GET(
"/api/v1/writing-candidates/{candidate_id}",
{ params: { path: { candidate_id: candidateId } } },
);
if (!data) throw new Error("候选读取为空");
set候选(data);
set审阅(
await 打开审阅({
const 读取并审阅 = useCallback(
async (signal?: AbortSignal) => {
set失败("");
try {
const { data } = await 客户端.GET(
"/api/v1/writing-candidates/{candidate_id}",
{ params: { path: { candidate_id: candidateId } }, signal },
);
if (!data) throw new Error("候选读取为空");
if (signal?.aborted) return;
set候选(data);
const result = await 打开审阅({
candidate_id: candidateId,
candidate_revision: data.revision,
candidate_hash: data.candidate_hash,
target_ref: data.document_id,
expected_revision: data.current_document_revision,
}),
);
} catch (错误) {
set失败(错误 instanceof Error ? 错误.message : "候选读取失败");
}
}, [candidateId]);
});
if (!signal?.aborted) set审阅(result);
} catch (错误) {
if (signal?.aborted) return;
set失败(错误 instanceof Error ? 错误.message : "候选读取失败");
}
},
[candidateId],
);
useEffect(() => {
void 读取并审阅();
const controller = new AbortController();
void 读取并审阅(controller.signal);
return () => controller.abort();
}, [读取并审阅]);
return (
@ -98,11 +104,17 @@ export function Ui作者动作({
)}
{失败 && <Ui反馈状态 message={失败} error />}
{审阅 ? (
<Ui决策面板 review={审阅} author={author} onReopen={() => void 读取并审阅()} />
<Ui决策面板
review={审阅}
author={author}
onReopen={() => void 读取并审阅()}
/>
) : (
!失败 && <Ui反馈状态 message="正在打开候选审阅…" />
)}
<p className="muted">必需检查未通过的候选不能采纳;服务端按候选版本核对检查结论。</p>
<p className="muted">
必需检查未通过的候选不能采纳;服务端按候选版本核对检查结论。
</p>
</section>
);
}

View File

@ -1,7 +1,7 @@
/** 创作对话的数据接入:发起生成、事件解读与候选检查报告查询。 */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
import { 查询键 } from "../../接口/查询键";
import { 查询键, 任务列表键 } from "../../接口/查询键";
import { 接口错误 } from "../../接口/传输";
import type { operations } from "../../接口/生成/类型";
@ -24,6 +24,8 @@ export const 事件名补充: Record<string, string> = {
"candidate.saved": "候选已保存",
"check.completed": "检查完成",
"tool.read": "读取材料",
"tool.denied": "材料读取被拒",
"context.usage": "记录上下文用量",
};
/** 发起生成正文:命令身份由调用方生成并保持幂等。 */
@ -52,7 +54,7 @@ export function useMuse发起生成(scope: string | null) {
);
},
onSuccess: () => {
void cache.invalidateQueries({ queryKey: 查询键(scope, "tasks") });
void cache.invalidateQueries({ queryKey: 任务列表键(scope) });
},
});
}
@ -78,8 +80,8 @@ export function useMuse审校报告(
params: {
path: { candidate_id: 身份 },
query: revision ? { revision } : undefined,
signal,
},
signal,
})
).data!;
},

View File

@ -5,15 +5,15 @@ import { 事件名补充 } from "./数据";
/** 把任务事件流折叠为可读消息;正文段落按写作顺序累积成稿。 */
export function 折叠消息(事件: 任务事件[]): {
正文段落: string[];
正文段落: { key: string; text: string }[];
动作: { key: string; 文案: string }[];
} {
const 正文段落: string[] = [];
const 正文段落: { key: string; text: string }[] = [];
const 动作: { key: string; 文案: string }[] = [];
for (const 条 of 事件) {
const 载荷 = 条.payload as Record<string, unknown>;
const 载荷 = 条.payload;
if (条.event_type === "text.delta" && typeof 载荷?.文本 === "string") {
正文段落.push(载荷.文本);
正文段落.push({ key: 条.event_id, text: 载荷.文本 });
continue;
}
const 名 = 事件名补充[条.event_type] ?? 事件名[条.event_type];
@ -26,7 +26,9 @@ export function Ui消息流({ 事件 }: { 事件: 任务事件[] }) {
const { 正文段落, 动作 } = 折叠消息(事件);
if (!正文段落.length && !动作.length)
return (
<p className="muted">生成过程的事件会在这里按顺序出现;页面重开后仍可找回。</p>
<p className="muted">
生成过程的事件会在这里按顺序出现;页面重开后仍可找回。
</p>
);
return (
<div className="event-panel" aria-live="polite">
@ -39,8 +41,8 @@ export function Ui消息流({ 事件 }: { 事件: 任务事件[] }) {
)}
{正文段落.length > 0 && (
<article className="draft-stream">
{正文段落.map((段, 序) => (
<p key={序}>{段}</p>
{正文段落.map((段) => (
<p key={段.key}>{段.text}</p>
))}
</article>
)}

View File

@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import type { 候选审阅 } from "./数据";
import { useMuse作者决定 } from "./审阅控制";
@ -5,12 +6,21 @@ export function Ui决策面板({
review,
author,
onReopen,
onDecided,
}: {
review: 候选审阅;
author: string;
onReopen: () => void;
onDecided?: () => void;
}) {
const state = useMuse作者决定(review, author);
const notified = useRef<string>();
useEffect(() => {
if (state.receipt && notified.current !== state.receipt.receipt_id) {
notified.current = state.receipt.receipt_id;
onDecided?.();
}
}, [state.receipt, onDecided]);
const final = ["adopt", "reject"].includes(review.candidate.decision);
const labels = { adopt: "已采纳", reject: "已拒绝", defer: "已暂缓" };
return (

View File

@ -1,6 +1,7 @@
import { useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { useMuse正文路由, 正文路由 } from "./路由参数";
import { useMuse作者会话 } from "../../应用/作者会话";
import { 接口错误 } from "../../接口/传输";
import { Ui正文编辑器 } from "../../界面/编辑器/正文编辑器";
@ -8,26 +9,30 @@ import { Ui反馈状态 } from "../../界面/反馈状态";
import { 打开审阅, Ui决策面板, type 候选审阅 } from "../正式变更";
import {
读取正文,
读取候选列表,
读取候选摘要,
读取候选,
建立人工候选,
修改候选,
type 正文草稿,
type 正文记录,
type 正文候选,
type 保存请求,
type 候选改稿请求,
type 候选摘要,
} from "./数据";
import {
候选浏览器缓冲,
候选归属错误,
请求属于范围,
type 改稿缓冲,
type 待存请求,
} from "./候选缓冲";
import { Ui版本比较 } from "./版本比较";
export function Ui候选审阅页() {
const { work_id = "", chapter_id = "" } = useParams();
const { work_id, chapter_id = "", branch } = useMuse正文路由();
const author = useMuse作者会话();
const [params] = useSearchParams();
const branch = params.get("branch") ?? "main";
if (!author.data) return <Ui反馈状态 message="正在读取作者会话…" />;
return (
<Ui候选工作面
key={`${author.data.author_id}:${work_id}:${chapter_id}:${branch}`}
key={JSON.stringify([author.data.author_id, work_id, chapter_id, branch])}
author={author.data.author_id}
work={work_id}
chapter={chapter_id}
@ -47,17 +52,51 @@ function Ui候选工作面({
chapter: string;
branch: string;
}) {
const query = useQuery({
queryKey: ["正文候选", author, work, chapter, branch],
queryFn: async () => {
const [document, candidates] = await Promise.all([
读取正文(chapter, branch),
读取候选列表(chapter, branch),
]);
if (document.work_id !== work) throw new Error("章节不属于当前作品。");
return { document, candidates };
const cache = useQueryClient();
const rootKey = useMemo(
() => ["候选工作面", author, work, chapter, branch] as const,
[author, work, chapter, branch],
);
const [cursor, setCursor] = useState<string | null>(null),
[previous, setPrevious] = useState<(string | null)[]>([]);
const [selectedId, setSelectedId] = useState<string>();
const current = useQuery({
queryKey: ["正文", author, work, chapter, branch],
queryFn: async ({ signal }) => {
const document = await 读取正文(chapter, branch, signal);
if (
document.work_id !== work ||
document.chapter_id !== chapter ||
document.branch_id !== branch
)
throw new Error("章节不属于当前作品或分支。");
return document;
},
});
const query = useQuery({
queryKey: [...rootKey, "pages", cursor],
queryFn: ({ signal }) => 读取候选摘要(chapter, branch, cursor, signal),
gcTime: 60_000,
});
useEffect(() => {
const pages = cache
.getQueryCache()
.findAll({ queryKey: [...rootKey, "pages"] })
.sort((a, b) => b.state.dataUpdatedAt - a.state.dataUpdatedAt);
pages
.filter((page) => page.queryKey[page.queryKey.length - 1] !== cursor)
.slice(2)
.forEach((page) =>
cache.removeQueries({ queryKey: page.queryKey, exact: true }),
);
}, [cache, rootKey, query.data, cursor]);
useEffect(
() => () => {
void cache.cancelQueries({ queryKey: rootKey });
cache.removeQueries({ queryKey: rootKey });
},
[cache, rootKey],
);
const [review, setReview] = useState<候选审阅>(),
[error, setError] = useState("");
const [editing, setEditing] = useState<正文候选 | "new">();
@ -65,18 +104,51 @@ function Ui候选工作面({
[notice, setNotice] = useState("");
async function reopen() {
setReview(undefined);
await query.refetch();
await Promise.all([
query.refetch(),
current.refetch(),
cache.invalidateQueries({ queryKey: [...rootKey, "detail"] }),
]);
}
async function open(candidate: 正文候选) {
async function open(candidate: Pick<候选摘要, "candidate_id" | "revision">) {
setSelectedId(candidate.candidate_id);
setOpening(true);
setError("");
try {
const detailKey = [
...rootKey,
"detail",
candidate.candidate_id,
candidate.revision,
];
const detail = await cache.fetchQuery({
queryKey: detailKey,
queryFn: ({ signal }) => 读取候选(candidate.candidate_id, signal),
staleTime: 0,
gcTime: 60_000,
});
if (detail.chapter_id !== chapter || detail.branch_id !== branch)
throw new Error("候选不属于当前章节或分支。");
const details = cache
.getQueryCache()
.findAll({ queryKey: [...rootKey, "detail"] })
.sort((a, b) => b.state.dataUpdatedAt - a.state.dataUpdatedAt);
details
.filter(
(item) =>
item.queryKey[6] !== candidate.candidate_id ||
item.queryKey[7] !== candidate.revision,
)
.slice(1)
.forEach((item) =>
cache.removeQueries({ queryKey: item.queryKey, exact: true }),
);
const result = await 打开审阅({
candidate_id: candidate.candidate_id,
candidate_revision: candidate.revision,
candidate_hash: candidate.candidate_hash,
target_ref: candidate.document_id,
expected_revision: candidate.current_document_revision,
candidate_id: detail.candidate_id,
candidate_revision: detail.revision,
candidate_hash: detail.candidate_hash,
target_ref: detail.document_id,
expected_revision: detail.current_document_revision,
});
setReview(result);
} catch (e) {
@ -85,10 +157,16 @@ function Ui候选工作面({
setOpening(false);
}
}
if (query.isPending) return <Ui反馈状态 message="正在读取正文与候选…" />;
if (query.error || !query.data)
if (current.isPending || query.isPending)
return <Ui反馈状态 message="正在读取正文与候选…" />;
if (current.error || query.error || !current.data || !query.data)
return (
<Ui反馈状态 error message={query.error?.message ?? "候选读取失败。"} />
<Ui反馈状态
error
message={
current.error?.message ?? query.error?.message ?? "候选读取失败。"
}
/>
);
const states = {
undecided: "未决定",
@ -103,10 +181,7 @@ function Ui候选工作面({
<p className="eyebrow">比较与取舍</p>
<h1>正文候选</h1>
</div>
<Link
className="profile-link"
to={`/works/${encodeURIComponent(work)}/write/${encodeURIComponent(chapter)}?branch=${encodeURIComponent(branch)}`}
>
<Link className="profile-link" to={正文路由(work, chapter, branch)}>
返回正文
</Link>
</header>
@ -116,7 +191,7 @@ function Ui候选工作面({
<Ui候选改稿
key={editing === "new" ? "new" : editing.candidate_id}
author={author}
current={query.data.document}
current={current.data}
candidate={editing === "new" ? undefined : editing}
onSaved={async () => {
setEditing(undefined);
@ -141,13 +216,14 @@ function Ui候选工作面({
另拟改稿
</button>
<div className="candidate-list">
{query.data.candidates.length === 0 ? (
{query.data.items.length === 0 ? (
<p>这章还没有候选。</p>
) : (
query.data.candidates.map((candidate, index) => (
query.data.items.map((candidate, index) => (
<button
key={candidate.candidate_id}
disabled={opening}
aria-pressed={selectedId === candidate.candidate_id}
onClick={() => void open(candidate)}
>
候选 {index + 1} · 版本 {candidate.revision} ·{" "}
@ -157,6 +233,46 @@ function Ui候选工作面({
))
)}
</div>
<nav aria-label="候选分页">
<button
disabled={!previous.length || opening}
onClick={() => {
setCursor(previous[previous.length - 1]);
setPrevious(previous.slice(0, -1));
}}
>
上一页候选
</button>
<button
disabled={!query.data.next_cursor || opening}
onClick={() => {
setPrevious([...previous, cursor].slice(-2));
setCursor(query.data!.next_cursor!);
}}
>
下一页候选
</button>
<button
disabled={opening}
onClick={() => {
setCursor(null);
setPrevious([]);
void query.refetch();
}}
>
刷新候选列表
</button>
</nav>
{selectedId && !review && (
<button
disabled={opening}
onClick={() =>
void open({ candidate_id: selectedId, revision: 0 })
}
>
重新打开已选候选
</button>
)}
</section>
{review && (
<section className="paper-panel" aria-label="被审候选">
@ -183,6 +299,15 @@ function Ui候选工作面({
review={review}
author={author}
onReopen={() => void reopen()}
onDecided={() => {
void Promise.all([
query.refetch(),
current.refetch(),
cache.invalidateQueries({
queryKey: [...rootKey, "detail"],
}),
]);
}}
/>
</section>
)}
@ -192,18 +317,6 @@ function Ui候选工作面({
);
}
type 待存请求 =
{ kind: "create"; body: 保存请求 } | { kind: "edit"; body: 候选改稿请求 };
type 改稿缓冲 = {
document: 正文草稿;
base_revision: number;
candidate_revision?: number;
candidate_hash?: string;
mode: "edit" | "select";
selected: string[];
pending?: 待存请求;
};
/** 候选缓冲与正文缓冲分开,保存前固定请求;刷新或结果未知后仍重放相同命令。 */
function Ui候选改稿({
author,
@ -218,23 +331,41 @@ function Ui候选改稿({
onSaved: () => Promise<void>;
onClose: () => void;
}) {
const key = `muse:candidate-draft:${author}:${current.work_id}:${current.chapter_id}:${candidate?.candidate_id ?? "new"}`;
const scope = {
author_id: author,
work_id: current.work_id,
chapter_id: current.chapter_id,
branch_id: current.branch_id,
candidate_id: candidate?.candidate_id ?? "new",
};
const [storage] = useState(() => 候选浏览器缓冲(scope));
const [load] = useState(() => {
try {
const saved = sessionStorage.getItem(key);
return { value: saved ? (JSON.parse(saved) as 改稿缓冲) : undefined };
} catch {
const value = storage.read();
return { value, legacy: value ? undefined : storage.legacy(), error: "" };
} catch (e) {
return {
value: undefined,
error: "候选缓冲无法读取,请先保留浏览器资料。",
legacy: undefined,
origin: e instanceof 候选归属错误 ? e.scope : undefined,
error:
e instanceof Error
? e.message
: "候选缓冲无法读取,请保留浏览器资料。",
};
}
});
const [generation, setGeneration] = useState(0);
const [legacy, setLegacy] = useState(load.legacy);
const [legacyChecking, setLegacyChecking] = useState(
!!load.legacy && !!candidate && !load.value,
);
const [buffer, setBuffer] = useState<改稿缓冲>(
() =>
load.value ?? {
document: candidate?.document ?? current.document,
base_revision: candidate?.base_revision ?? current.revision,
base_hash: current.document_hash,
candidate_revision: candidate?.revision,
candidate_hash: candidate?.candidate_hash,
mode: "edit",
@ -248,18 +379,70 @@ function Ui候选改稿({
[error, setError] = useState(load.error ?? "");
const stale =
buffer.base_revision !== current.revision ||
(candidate && buffer.candidate_revision !== candidate.revision);
(buffer.base_hash !== undefined &&
buffer.base_hash !== current.document_hash) ||
(candidate &&
(buffer.candidate_revision !== candidate.revision ||
buffer.candidate_hash !== candidate.candidate_hash));
useEffect(() => {
if (load.value || !load.legacy) return;
let alive = true;
const original = load.legacy.value;
async function migrate() {
try {
let confirmed =
original.pending?.kind === "create" &&
请求属于范围(original.pending, scope);
if (candidate) {
const detail = await 读取候选(candidate.candidate_id);
confirmed =
detail.candidate_id === scope.candidate_id &&
detail.chapter_id === scope.chapter_id &&
detail.branch_id === scope.branch_id &&
detail.document_id === current.document_id &&
请求属于范围(original.pending, scope);
}
if (alive && confirmed) {
storage.write(original);
latest.current = original;
setBuffer(original);
setGeneration((n) => n + 1);
setLegacy(undefined);
}
} catch (e) {
if (alive)
setError(
e instanceof Error ? e.message : "旧改稿归属无法确认,已保留。",
);
} finally {
if (alive) setLegacyChecking(false);
}
}
void migrate();
return () => {
alive = false;
};
// 该编辑器实例身份固定;查询刷新不能重绑草稿。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function change(next: 改稿缓冲) {
latest.current = next;
setBuffer(next);
try {
sessionStorage.setItem(key, JSON.stringify(next));
storage.write(next);
} catch {
setError("本地缓冲写入失败,请先复制保留改稿。");
}
}
async function save() {
if (busyRef.current || composing || (stale && !latest.current.pending))
if (
busyRef.current ||
composing ||
load.error ||
legacyChecking ||
!请求属于范围(latest.current.pending, scope) ||
(stale && !latest.current.pending)
)
return;
busyRef.current = true;
setBusy(true);
@ -292,16 +475,21 @@ function Ui候选改稿({
});
try {
const frozen = { ...draft, pending };
sessionStorage.setItem(key, JSON.stringify(frozen));
storage.write(frozen);
latest.current = frozen;
setBuffer(frozen);
await (pending.kind === "create"
? 建立人工候选(pending.body)
: 修改候选(pending.body));
sessionStorage.removeItem(key);
storage.clear();
await onSaved();
} catch (e) {
if (e instanceof 接口错误 && e.status >= 400 && e.status < 500)
if (
e instanceof 接口错误 &&
e.status >= 400 &&
e.status < 500 &&
e.status !== 408
)
change({ ...latest.current, pending: undefined });
setError(e instanceof Error ? e.message : "候选尚未保存,请重试。");
} finally {
@ -309,11 +497,67 @@ function Ui候选改稿({
setBusy(false);
}
}
const locked = busy || !!buffer.pending;
const locked = busy || !!buffer.pending || !!load.error || legacyChecking;
return (
<section className="paper-panel candidate-editor">
<h2>{candidate ? "调整候选" : "另拟改稿"}</h2>
<p>这里的修改保留为候选,保存后重新比较再决定。</p>
{load.origin && load.origin.author_id === author && (
<Link
to={正文路由(
load.origin.work_id,
load.origin.chapter_id,
load.origin.branch_id,
"candidates",
)}
>
返回原分支 {load.origin.branch_id} 确认缓冲
</Link>
)}
{legacy && (
<section aria-label="待确认旧改稿">
<h3>待确认旧改稿</h3>
<p>旧记录没有可用于当前分支的归属证明,原记录保持不变。</p>
{legacy.value.pending?.kind === "create" && (
<Link
to={正文路由(
scope.work_id,
scope.chapter_id,
legacy.value.pending.body.branch_id,
"candidates",
)}
>
返回原分支 {legacy.value.pending.body.branch_id} 确认保存结果
</Link>
)}
<textarea readOnly aria-label="旧改稿导出" value={legacy.raw} />
{!legacy.value.pending && (
<button
disabled={locked}
onClick={() => {
const copied = {
...legacy.value,
base_revision: current.revision,
base_hash: current.document_hash,
candidate_revision: candidate?.revision,
candidate_hash: candidate?.candidate_hash,
};
try {
storage.write(copied);
latest.current = copied;
setBuffer(copied);
setGeneration((n) => n + 1);
setLegacy(undefined);
} catch (e) {
setError(e instanceof Error ? e.message : "复制失败。");
}
}}
>
将旧稿复制到当前分支 {scope.branch_id}
</button>
)}
</section>
)}
{load.value && <p role="status">已恢复本浏览器中的候选改稿。</p>}
{stale && (
<div className="conflict-panel">
@ -323,8 +567,12 @@ function Ui候选改稿({
<button
disabled={busy || !!buffer.pending}
onClick={() => {
sessionStorage.removeItem(key);
onClose();
try {
storage.discard();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "无法放弃缓冲。");
}
}}
>
放弃旧改稿,返回当前版本
@ -384,6 +632,7 @@ function Ui候选改稿({
</div>
) : (
<Ui正文编辑器
key={generation}
initial={buffer.document}
label="候选改稿"
editable={!locked}
@ -396,6 +645,8 @@ function Ui候选改稿({
className="primary"
disabled={
busy ||
!!load.error ||
legacyChecking ||
composing ||
(!buffer.pending &&
(!!stale ||

View File

@ -0,0 +1,208 @@
/** 候选私人缓冲:完整身份、原请求与写入归属一同保存;旧键始终只读。 */
import type { 正文草稿, 保存请求, 候选改稿请求 } from "./数据";
import type { 编辑范围 } from "./编辑缓冲";
export type 候选范围 = 编辑范围 & { candidate_id: string };
export type 待存请求 =
{ kind: "create"; body: 保存请求 } | { kind: "edit"; body: 候选改稿请求 };
export type 改稿缓冲 = {
document: 正文草稿;
base_revision: number;
base_hash?: string;
candidate_revision?: number;
candidate_hash?: string;
mode: "edit" | "select";
selected: string[];
pending?: 待存请求;
};
const identity = (s: 候选范围) => [
s.author_id,
s.work_id,
s.chapter_id,
s.branch_id,
s.candidate_id,
];
export const 候选缓冲键 = (s: 候选范围) =>
"muse:candidate-draft:v2:" + JSON.stringify(identity(s));
export const 旧候选缓冲键 = (s: 候选范围) =>
`muse:candidate-draft:${s.author_id}:${s.work_id}:${s.chapter_id}:${s.candidate_id}`;
const object = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null;
function 有效范围(v: unknown): v is 候选范围 {
return (
object(v) &&
["author_id", "work_id", "chapter_id", "branch_id", "candidate_id"].every(
(k) => typeof v[k] === "string",
)
);
}
export class 候选归属错误 extends Error {
constructor(public readonly scope: 候选范围) {
super(
`候选缓冲属于分支 ${scope.branch_id},原记录已保留,请回原分支确认。`,
);
}
}
export function 有效正文(v: unknown): v is 正文草稿 {
return (
object(v) &&
v.format_version === 1 &&
Array.isArray(v.paragraphs) &&
v.paragraphs.every(
(p) =>
object(p) &&
typeof p.paragraph_id === "string" &&
Array.isArray(p.content) &&
p.content.every(
(n) =>
object(n) &&
((n.type === "text" &&
typeof n.text === "string" &&
Array.isArray(n.marks) &&
n.marks.every((m) => m === "strong" || m === "emphasis")) ||
(n.type === "line_break" && typeof n.newline === "string")),
),
)
);
}
export function 有效改稿(v: unknown): v is 改稿缓冲 {
if (
!object(v) ||
!有效正文(v.document) ||
!Number.isInteger(v.base_revision) ||
!["edit", "select"].includes(String(v.mode)) ||
!Array.isArray(v.selected) ||
!v.selected.every((x) => typeof x === "string")
)
return false;
if (v.pending === undefined) return true;
const p = v.pending;
if (
!object(p) ||
!object(p.body) ||
typeof p.body.command_id !== "string" ||
!p.body.command_id
)
return false;
if (p.kind === "create")
return (
typeof p.body.chapter_id === "string" &&
typeof p.body.branch_id === "string" &&
Number.isInteger(p.body.expected_revision) &&
有效正文(p.body.document)
);
return (
p.kind === "edit" &&
typeof p.body.candidate_id === "string" &&
Number.isInteger(p.body.expected_candidate_revision) &&
typeof p.body.expected_candidate_hash === "string" &&
(有效正文(p.body.document) ||
(Array.isArray(p.body.selected_changes) &&
p.body.selected_changes.every((x) => typeof x === "string")))
);
}
export function 请求属于范围(p: 待存请求 | undefined, s: 候选范围) {
return (
!p ||
(p.kind === "create"
? s.candidate_id === "new" &&
p.body.chapter_id === s.chapter_id &&
p.body.branch_id === s.branch_id
: p.body.candidate_id === s.candidate_id)
);
}
export function 候选浏览器缓冲(
scope: 候选范围,
storage: Storage = sessionStorage,
) {
const key = 候选缓冲键(scope),
legacyKey = 旧候选缓冲键(scope),
owner = crypto.randomUUID();
let observed: string | null = null;
let readable = false;
let written: string | undefined;
return {
key,
read(): 改稿缓冲 | undefined {
readable = false;
observed = storage.getItem(key);
if (observed === null) {
readable = true;
return undefined;
}
const value: unknown = JSON.parse(observed);
if (
!object(value) ||
value.schema_version !== 2 ||
!有效范围(value.scope) ||
!有效改稿(value.draft)
)
throw new Error("候选缓冲结构损坏,原记录已保留,请导出浏览器资料。");
const storedScope = value.scope;
if (
identity(scope).some((v, i) => v !== identity(storedScope)[i]) ||
!请求属于范围(value.draft.pending, scope)
) {
const original =
value.draft.pending?.kind === "create"
? {
...value.scope,
branch_id: value.draft.pending.body.branch_id,
chapter_id: value.draft.pending.body.chapter_id,
}
: value.scope;
throw new 候选归属错误(original);
}
if (
scope.candidate_id !== "new" &&
(!Number.isInteger(value.draft.candidate_revision) ||
typeof value.draft.candidate_hash !== "string")
)
throw new Error("候选缓冲缺少版本依据,原记录已保留。");
readable = true;
return value.draft;
},
legacy() {
const raw = storage.getItem(legacyKey);
if (raw === null) return undefined;
const value: unknown = JSON.parse(raw);
if (!有效改稿(value))
throw new Error("旧候选缓冲损坏,原记录已保留,请导出浏览器资料。");
if (
scope.candidate_id !== "new" &&
(!Number.isInteger(value.candidate_revision) ||
typeof value.candidate_hash !== "string")
)
throw new Error("旧候选缓冲缺少版本依据,原记录已保留。");
return { value, raw };
},
write(draft: 改稿缓冲) {
if (!readable) throw new Error("缓冲未经有效读取,不能覆盖原记录。");
if (!请求属于范围(draft.pending, scope))
throw new Error("原请求属于其他分支,不能从当前页面发送。");
if (storage.getItem(key) !== observed)
throw new Error("候选缓冲已由其他会话更新,请保留当前文字并重新打开。");
const text = JSON.stringify({
schema_version: 2,
scope,
owner_id: owner,
draft,
});
storage.setItem(key, text);
observed = written = text;
},
discard() {
if (storage.getItem(key) !== observed)
throw new Error("候选缓冲已由其他会话更新,不能放弃。");
storage.removeItem(key);
observed = null;
written = undefined;
},
clear() {
if (written !== undefined && storage.getItem(key) === written) {
storage.removeItem(key);
observed = null;
written = undefined;
}
},
};
}

View File

@ -76,3 +76,34 @@ export async function 修改候选(body: 候选改稿请求) {
if (!data) throw new Error("候选修改回执为空,请重试。");
return data;
}
export async function 读取候选(candidate_id: string, signal?: AbortSignal) {
const { data } = await 客户端.GET(
"/api/v1/writing-candidates/{candidate_id}",
{ params: { path: { candidate_id } }, signal },
);
if (!data) throw new Error("候选详情读取失败。");
return data;
}
export type 候选摘要 =
operations["list_writing_candidate_summaries"]["responses"][200]["content"]["application/json"]["items"][number];
export async function 读取候选摘要(
chapter_id: string,
branch_id: string,
cursor: string | null,
signal?: AbortSignal,
) {
const { data } = await 客户端.GET(
"/api/v1/chapters/{chapter_id}/writing-candidate-summaries",
{
params: {
path: { chapter_id },
query: { branch_id, cursor: cursor ?? undefined, limit: 20 },
},
signal,
},
);
if (!data) throw new Error("候选摘要未返回。");
return data;
}

View File

@ -1,13 +1,19 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { Link } from "react-router-dom";
import { useMuse作者会话 } from "../../应用/作者会话";
import { useMuse正文路由, 正文路由 } from "./路由参数";
import { Ui章节目录 } from "../作品规划";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { Ui正文编辑器 } from "../../界面/编辑器/正文编辑器";
import { 正文文本 } from "../../界面/编辑器/文档适配";
import { 读取正文, 保存正文, type 正文记录 } from "./数据";
import { 浏览器缓冲, type 编辑范围, type 缓冲记录 } from "./编辑缓冲";
import {
浏览器缓冲,
type 编辑范围,
type 缓冲记录,
type 缓冲存储,
} from "./编辑缓冲";
import { 创建编辑会话 } from "./编辑会话";
import { useMuse自动保存 } from "./保存控制";
import { Ui版本比较 } from "./版本比较";
@ -16,16 +22,19 @@ import { Ui选段改写 } from "./选段改写";
import type { 原文坐标 } from "../../界面/编辑器/选区定位";
export function Ui正文页() {
const { work_id = "", chapter_id } = useParams();
const { work_id, chapter_id, branch } = useMuse正文路由();
const author = useMuse作者会话();
const [params] = useSearchParams();
const branch = params.get("branch") ?? "main";
return (
<main className="writing-layout">
<Ui章节目录 key={work_id} work_id={work_id} />
{chapter_id && author.data ? (
<Ui打开章节
key={`${author.data.author_id}:${work_id}:${chapter_id}:${branch}`}
key={JSON.stringify([
author.data.author_id,
work_id,
chapter_id,
branch,
])}
scope={{
author_id: author.data.author_id,
work_id,
@ -55,7 +64,11 @@ function Ui打开章节({ scope }: { scope: 编辑范围 }) {
],
queryFn: async ({ signal }) => {
const result = await 读取正文(scope.chapter_id, scope.branch_id, signal);
if (result.work_id !== scope.work_id)
if (
result.work_id !== scope.work_id ||
result.chapter_id !== scope.chapter_id ||
result.branch_id !== scope.branch_id
)
throw new Error("章节不属于当前作品,请从目录重新打开。");
return result;
},
@ -88,6 +101,10 @@ function Ui缓冲恢复({
onRestored: () => Promise<void>;
}) {
const [buffer] = useState(() => 浏览器缓冲(scope));
useEffect(() => {
buffer.activate?.();
return () => buffer.dispose?.();
}, [buffer]);
const [local] = useState(() => {
try {
return { value: buffer.read(), error: "" };
@ -129,6 +146,7 @@ function Ui缓冲恢复({
<section className="writing-pane">
{local.error && <Ui反馈状态 error message={local.error} />}
<Ui编辑正文
buffer={buffer}
scope={scope}
record={record}
restored={choice === "restore" ? local.value : undefined}
@ -139,6 +157,7 @@ function Ui缓冲恢复({
}
function Ui编辑正文({
buffer,
scope,
record,
restored,
@ -146,11 +165,12 @@ function Ui编辑正文({
}: {
scope: 编辑范围;
record: 正文记录;
buffer: 缓冲存储;
restored?: 缓冲记录;
onRestored: () => Promise<void>;
}) {
const [session] = useState(() =>
创建编辑会话(scope, record, 保存正文, 浏览器缓冲(scope), restored),
创建编辑会话(scope, record, 保存正文, buffer, restored),
);
const state = useMuse自动保存(session);
const [selection, setSelection] = useState<{
@ -184,8 +204,7 @@ function Ui编辑正文({
{state.composing ? "正在输入…" : labels[state.status]}
</span>
<small>
版本 {state.base_revision} ·{" "}
{Array.from(正文文本(state.draft)).length} 字符
版本 {state.base_revision} · {state.codepoints} 字符
</small>
<button
onClick={() => void session.save()}
@ -225,7 +244,7 @@ function Ui编辑正文({
<>
<Ui版本比较
before={latest.visible_text}
after={正文文本(state.draft)}
after={state.visible_text}
/>
<button
onClick={() => {
@ -259,7 +278,7 @@ function Ui编辑正文({
current={{
...record,
revision: state.base_revision,
visible_text: 正文文本(state.draft),
visible_text: state.visible_text,
document: state.draft,
}}
canRestore={state.status === "saved" && !state.composing}
@ -280,7 +299,12 @@ function Ui编辑正文({
{state.status === "saved" && !state.composing && (
<Link
className="candidate-entry"
to={`/works/${encodeURIComponent(scope.work_id)}/candidates/${encodeURIComponent(scope.chapter_id)}?branch=${encodeURIComponent(scope.branch_id)}`}
to={正文路由(
scope.work_id,
scope.chapter_id,
scope.branch_id,
"candidates",
)}
>
比较正文候选
</Link>

View File

@ -5,6 +5,7 @@ import { 客户端 } from "../../接口/生成/客户端";
import { 接口错误 } from "../../接口/传输";
import { useMuse作者会话 } from "../../应用/作者会话";
import { 读取正文, type 正文记录 } from "./数据";
import { 正文路由 } from "./路由参数";
import { Ui版本比较 } from "./版本比较";
type 分支对照 = {
@ -57,8 +58,8 @@ export function Ui分支管理({
return data as { branch_id: string; current_revision: number }[];
},
});
const route = (branch: string, page = "write") =>
`/works/${encodeURIComponent(current.work_id)}/${page}/${encodeURIComponent(current.chapter_id)}?branch=${encodeURIComponent(branch)}`;
const route = (branch: string, page: "write" | "candidates" = "write") =>
正文路由(current.work_id, current.chapter_id, branch, page);
async function 执行(action: "create" | "compare" | "merge") {
setBusy(true);
setError("");

View File

@ -1,4 +1,5 @@
/** 一个对象只服务一个固定编辑范围;服务器回执从不替换仍在输入的草稿。 */
import { 正文文本, type 正文统计 } from "../../界面/编辑器/文档适配";
import { 接口错误 } from "../../接口/传输";
import type { 正文草稿, 正文记录, 保存请求 } from "./数据";
import type { 编辑范围, 缓冲记录, 缓冲存储 } from "./编辑缓冲";
@ -6,6 +7,10 @@ import type { 编辑范围, 缓冲记录, 缓冲存储 } from "./编辑缓冲";
type 保存结果 = { revision: number; document_hash: string; receipt_id: string };
type 会话快照 = {
draft: 正文草稿;
visible_text: string;
codepoints: number;
edit_revision: number;
sent_edit_revision?: number;
base_revision: number;
base_hash: string;
status: "saved" | "dirty" | "saving" | "error" | "conflict";
@ -22,7 +27,22 @@ export function 创建编辑会话(
buffer: 缓冲存储,
restored?: 缓冲记录,
) {
const text = 正文文本(restored?.draft ?? server.document);
const editRevision = restored?.edit_revision ?? (restored?.pending ? 1 : 0);
// 旧v1缓冲没有编辑代次:仅在恢复边界判定原请求是否包含全部本地输入。
const sentRevision =
restored?.sent_edit_revision ??
(restored?.pending
? JSON.stringify(restored.draft) ===
JSON.stringify(restored.pending.document)
? editRevision
: editRevision - 1
: undefined);
let state: 会话快照 = {
visible_text: text,
codepoints: Array.from(text).length,
edit_revision: editRevision,
sent_edit_revision: sentRevision,
draft: restored?.draft ?? server.document,
base_revision: restored?.base_revision ?? server.revision,
base_hash: restored?.base_hash ?? server.document_hash,
@ -48,6 +68,8 @@ export function 创建编辑会话(
else
buffer.write({
scope,
edit_revision: state.edit_revision,
sent_edit_revision: state.sent_edit_revision,
base_revision: state.base_revision,
base_hash: state.base_hash,
draft: state.draft,
@ -55,12 +77,14 @@ export function 创建编辑会话(
updated_at: Date.now(),
});
state = { ...state, buffer_warning: "" };
return true;
} catch {
state = {
...state,
buffer_warning:
"浏览器未能保留缓冲。请保持页面打开并尝试保存,或复制当前全文。",
};
return false;
}
};
return {
@ -74,12 +98,15 @@ export function 创建编辑会话(
listeners.delete(listener);
};
},
change(draft: 正文草稿) {
if (!alive || JSON.stringify(draft) === JSON.stringify(state.draft))
return;
change(draft: 正文草稿, stats?: 正文统计) {
if (!alive || draft === state.draft) return;
const visible_text = stats?.visible_text ?? 正文文本(draft);
state = {
...state,
draft,
visible_text,
codepoints: stats?.codepoints ?? Array.from(visible_text).length,
edit_revision: state.edit_revision + 1,
status: state.status === "saved" ? "dirty" : state.status,
};
persist();
@ -106,16 +133,36 @@ export function 创建编辑会话(
document: structuredClone(state.draft),
};
busy = true;
state = { ...state, pending: request, status: "saving", message: "" };
persist();
state = {
...state,
sent_edit_revision: state.pending
? state.sent_edit_revision
: state.edit_revision,
pending: request,
status: "saving",
message: "",
};
if (!persist()) {
busy = false;
state = {
...state,
status: "error",
message:
"原请求尚未可靠保留,未发送保存。请先保留全文并恢复本地缓冲。",
};
emit();
return;
}
emit();
try {
const receipt = await send(request);
const unchanged =
JSON.stringify(state.draft) === JSON.stringify(request.document);
state.sent_edit_revision !== undefined &&
state.edit_revision === state.sent_edit_revision;
state = {
...state,
pending: undefined,
sent_edit_revision: undefined,
base_revision: receipt.revision,
base_hash: receipt.document_hash,
status: unchanged ? "saved" : "dirty",
@ -126,7 +173,8 @@ export function 创建编辑会话(
const rejected =
error instanceof 接口错误 &&
error.status >= 400 &&
error.status < 500;
error.status < 500 &&
error.status !== 408;
state = {
...state,
pending: rejected ? undefined : state.pending,

View File

@ -9,6 +9,8 @@ export type 编辑范围 = {
};
export type 缓冲记录 = {
scope: 编辑范围;
edit_revision?: number;
sent_edit_revision?: number;
base_revision: number;
base_hash: string;
draft: 正文草稿;
@ -16,7 +18,12 @@ export type 缓冲记录 = {
updated_at: number;
owner_id?: string;
};
export type 缓冲存储 = { write: (value: 缓冲记录) => void; clear: () => void };
export type 缓冲存储 = {
write: (value: 缓冲记录) => void;
clear: () => void;
dispose?: () => void;
activate?: () => void;
};
const prefix = (scope: 编辑范围) =>
"muse:draft:v1:" +
@ -33,39 +40,83 @@ export function 浏览器缓冲(
storage: Storage = localStorage,
): 缓冲存储 & { read: () => 缓冲记录 | undefined } {
const owner = crypto.randomUUID();
const scopePrefix = prefix(scope);
let written: { key: string; text: string } | undefined;
const keys = () =>
const discover = () =>
Array.from({ length: storage.length }, (_, i) => storage.key(i)).filter(
(k): k is string => k !== null && k.startsWith(prefix(scope)),
(k): k is string => k !== null && k.startsWith(scopePrefix),
);
let known = new Map<string, string>();
let invalidated = true;
let discovered = false;
const invalidate = (event: StorageEvent) => {
if (
event.storageArea === storage &&
(event.key === null || event.key.startsWith(scopePrefix))
)
invalidated = true;
};
const activate = () => window.addEventListener("storage", invalidate);
const refresh = (explicitRead = false) => {
if (!invalidated) return;
const current = new Map(discover().map((k) => [k, storage.getItem(k)!]));
// storage事件只使发现失效,不能代表作者同意接管后来出现的草稿。
if (
!explicitRead &&
discovered &&
(current.size !== known.size ||
[...current].some(([key, text]) => known.get(key) !== text))
)
throw new Error("当前缓冲已由另一个编辑会话更新。");
known = current;
discovered = true;
invalidated = false;
};
return {
activate,
dispose() {
window.removeEventListener("storage", invalidate);
},
read() {
const values = keys().map(
(k) => JSON.parse(storage.getItem(k)!) as 缓冲记录,
invalidated = true;
refresh(true);
const values = [...known.values()].map(
(text) => JSON.parse(text) as 缓冲记录,
);
return values.sort((a, b) => b.updated_at - a.updated_at)[0];
},
write(value) {
refresh();
if (written && storage.getItem(written.key) !== written.text)
throw new Error("当前缓冲已由另一个编辑会话更新。");
const key = prefix(scope) + value.base_revision;
const key = scopePrefix + value.base_revision;
if (storage.getItem(key) !== (known.get(key) ?? null))
throw new Error("当前缓冲已由另一个编辑会话更新。");
const text = JSON.stringify({ ...value, owner_id: owner });
// 先写新基线成功,再移除同一范围旧缓冲,配额错误时保留已有输入。
storage.setItem(key, text);
keys()
.filter((k) => k !== key)
.forEach((k) => storage.removeItem(k));
known.forEach((old, k) => {
if (k !== key && storage.getItem(k) === old) {
storage.removeItem(k);
known.delete(k);
}
});
known.set(key, text);
written = { key, text };
},
clear() {
if (written) {
if (storage.getItem(written.key) === written.text) {
storage.removeItem(written.key);
written=undefined;
known.delete(written.key);
written = undefined;
}
} else {
// 无写入归属的clear仅供恢复页面的明确“放弃缓冲”动作。
keys().forEach((k) => storage.removeItem(k));
// 只放弃作者已经看过且未被另一会话改写的字节。
known.forEach((text, k) => {
if (storage.getItem(k) === text) storage.removeItem(k);
});
known.clear();
}
},
};

View File

@ -0,0 +1,15 @@
import { useParams, useSearchParams } from "react-router-dom";
/** 路由器提供原始参数;链接只在边界编码一次。 */
export function useMuse正文路由() {
const { work_id = "", chapter_id } = useParams();
const [query] = useSearchParams();
return { work_id, chapter_id, branch: query.get("branch") ?? "main" };
}
export function 正文路由(
work: string,
chapter: string,
branch: string,
page: "write" | "candidates" = "write",
) {
return `/works/${encodeURIComponent(work)}/${page}/${encodeURIComponent(chapter)}?branch=${encodeURIComponent(branch)}`;
}

View File

@ -11,19 +11,24 @@ export function Ui拆书工作区() {
const 来源 = useMuse来源列表(scope);
const [当前, set当前] = useState<string | null>(null);
const 状态 = useMuse拆书状态(scope, 当前);
const [消息, set消息] = useState<{ 文本: string; 错误?: boolean } | null>(null);
const [消息, set消息] = useState<{ 文本: string; 错误?: boolean } | null>(
null,
);
async function 提交(form: HTMLFormElement) {
const 值 = new FormData(form);
try {
const 回执 = await 发起拆书(当前!, {
config_id: (值.get("config_id") as string) || null,
config_id: String(值.get("config_id") ?? "").trim(),
reason: (值.get("reason") as string) || null,
});
set消息({ 文本: `拆书任务已登记:${回执.task_id}` });
await 状态.refetch();
} catch (e) {
set消息({ 文本: e instanceof Error ? e.message : "发起失败,可重试。", 错误: true });
set消息({
文本: e instanceof Error ? e.message : "发起失败,可重试。",
错误: true,
});
}
}
@ -33,7 +38,10 @@ export function Ui拆书工作区() {
set消息({ 文本: "恢复已登记;任务将在工作循环继续。" });
await 状态.refetch();
} catch (e) {
set消息({ 文本: e instanceof Error ? e.message : "恢复失败。", 错误: true });
set消息({
文本: e instanceof Error ? e.message : "恢复失败。",
错误: true,
});
}
}
@ -100,8 +108,12 @@ export function Ui拆书工作区() {
<input name="reason" placeholder="例如:题材接近,节奏可借鉴" />
</label>
<label>
运行配置编号(可选)
<input name="config_id" placeholder="留空使用部署默认" />
运行配置编号
<input
name="config_id"
placeholder="已验证并启用的分析配置 ID"
required
/>
</label>
<button type="submit">发起拆解</button>
</form>
@ -115,7 +127,9 @@ export function Ui拆书工作区() {
<section className="paper-panel" aria-label="拆书台账">
<div className="panel-heading">
<h2>拆书台账</h2>
<span className="muted">{状态.data?.台账.length ?? 0} 次拆解</span>
<span className="muted">
{状态.data?.台账.length ?? 0} 次拆解
</span>
</div>
{状态.isPending ? (
<Ui反馈状态 message="正在读取台账…" />
@ -125,12 +139,15 @@ export function Ui拆书工作区() {
<ul className="event-list">
{状态.data!.台账.map((行) => (
<li key={行.task_id}>
<span className="mono">{行.task_id.slice(0, 8)}</span> · {行.状态} · 完成{" "}
{行.完成窗} 窗
<span className="mono">{行.task_id.slice(0, 8)}</span> ·{" "}
{行.状态} · 完成 {行.完成窗} 窗
{行.失败窗.length > 0 && ` · 失败 ${行.失败窗.join("、")}`}
{行.歧义数 > 0 && ` · 歧义 ${行.歧义数}`}
{行.状态 === "失败" && (
<button className="text-button" onClick={() => void 恢复(行.task_id)}>
<button
className="text-button"
onClick={() => void 恢复(行.task_id)}
>
恢复重试
</button>
)}
@ -145,7 +162,9 @@ export function Ui拆书工作区() {
<section className="paper-panel" aria-label="原文与分析对照">
<div className="panel-heading">
<h2>原文与分析对照</h2>
<span className="muted">{对照.状态 === "已完成" ? "任务已完成" : `状态:${对照.状态}`}</span>
<span className="muted">
{对照.状态 === "已完成" ? "任务已完成" : `状态:${对照.状态}`}
</span>
</div>
{对照.版本ID ? (
<Ui原文分析对照
@ -155,7 +174,9 @@ export function Ui拆书工作区() {
窗口列表={窗口列表}
/>
) : (
<p className="muted">任务尚未完成;完成后此处显示窗口原文与分析对照。</p>
<p className="muted">
任务尚未完成;完成后此处显示窗口原文与分析对照。
</p>
)}
</section>
)}

View File

@ -31,37 +31,46 @@ export function useMuse来源列表(scope: string | null) {
return useQuery({
queryKey: 查询键(scope, "sources"),
queryFn: async ({ signal }) =>
(await 客户端.GET("/api/v1/sources", { signal, params: {} })).data as unknown as 来源行[],
(await 客户端.GET("/api/v1/sources", { signal, params: {} }))
.data as unknown as 来源行[],
});
}
export function useMuse版本原文(scope: string | null, sourceId: string | null, revision: number | null) {
export function useMuse版本原文(
scope: string | null,
sourceId: string | null,
revision: number | null,
) {
return useQuery({
queryKey: 查询键(scope, sourceId ?? "", "version", String(revision ?? 0)),
enabled: !!sourceId && !!revision,
queryFn: async ({ signal }) =>
((await 客户端.GET("/api/v1/sources/{source_id}/versions/{revision}", {
signal,
params: { path: { source_id: sourceId!, revision: revision! } },
})).data ?? null) as 版本行 | null,
((
await 客户端.GET("/api/v1/sources/{source_id}/versions/{revision}", {
signal,
params: { path: { source_id: sourceId!, revision: revision! } },
})
).data ?? null) as 版本行 | null,
});
}
export function useMuse快照列表(scope: string | null, rankingId: string | null) {
export function useMuse快照列表(
scope: string | null,
rankingId: string | null,
) {
return useQuery({
queryKey: 查询键(scope, rankingId ?? "", "snapshots"),
enabled: !!rankingId,
queryFn: async ({ signal }) =>
(await 客户端.GET("/api/v1/rankings/{ranking_id}/snapshots", {
signal,
params: { path: { ranking_id: rankingId! } },
})).data as unknown as 快照行[],
(
await 客户端.GET("/api/v1/rankings/{ranking_id}/snapshots", {
signal,
params: { path: { ranking_id: rankingId! } },
})
).data as unknown as 快照行[],
});
}
export interface 拆书台账行 {
task_id: string;
状态: string;
@ -95,35 +104,46 @@ export function useMuse拆书状态(scope: string | null, sourceId: string | nul
queryKey: 查询键(scope, sourceId ?? "", "analyses"),
enabled: !!sourceId,
queryFn: async ({ signal }) =>
((await 客户端.GET("/api/v1/sources/{source_id}/analyses", {
signal,
params: { path: { source_id: sourceId! } },
})).data ?? null) as 拆书状态 | null,
((
await 客户端.GET("/api/v1/sources/{source_id}/analyses", {
signal,
params: { path: { source_id: sourceId! } },
})
).data ?? null) as 拆书状态 | null,
});
}
export async function 发起拆书(
sourceId: string,
输入: { config_id?: string | null; reason?: string | null },
输入: { config_id: string; reason?: string | null },
) {
if (!输入.config_id.trim()) throw new Error("请明确指定已启用的分析配置。");
const { 客户端 } = await import("../../接口/生成/客户端");
const { data, error } = await 客户端.POST("/api/v1/sources/{source_id}/analyses", {
params: { path: { source_id: sourceId } },
body: {
command_id: `research-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
config_id: 输入.config_id ?? undefined,
reason: 输入.reason ?? undefined,
const { data, error } = await 客户端.POST(
"/api/v1/sources/{source_id}/analyses",
{
params: { path: { source_id: sourceId } },
body: {
command_id: `research-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
config_id: 输入.config_id,
reason: 输入.reason ?? undefined,
},
},
});
if (!data) throw new Error((error as { message?: string })?.message ?? "发起失败");
);
if (!data)
throw new Error((error as { message?: string })?.message ?? "发起失败");
return data as unknown as { task_id: string; 版本ID: string };
}
export async function 恢复拆书(taskId: string) {
const { 客户端 } = await import("../../接口/生成/客户端");
const { data, error } = await 客户端.POST("/api/v1/analyses/{task_id}/resume", {
params: { path: { task_id: taskId } },
});
if (!data) throw new Error((error as { message?: string })?.message ?? "恢复失败");
const { data, error } = await 客户端.POST(
"/api/v1/analyses/{task_id}/resume",
{
params: { path: { task_id: taskId } },
},
);
if (!data)
throw new Error((error as { message?: string })?.message ?? "恢复失败");
return data as unknown as { task_id: string; 状态: string };
}

View File

@ -1,6 +1,11 @@
/** 榜单与选题页:时点快照观察与样本选题依据;只陈述观察,不推断因果。 */
import { useState } from "react";
import { useMuse来源列表, useMuse快照列表, useMuse版本原文, 发起拆书 } from "./数据";
import {
useMuse来源列表,
useMuse快照列表,
useMuse版本原文,
发起拆书,
} from "./数据";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { useMuse作者会话 } from "../../应用/作者会话";
@ -14,20 +19,29 @@ export function Ui榜单与选题页() {
const [样本B, set样本B] = useState<string | null>(null);
const 版本A = useMuse版本原文(scope, 样本A, 最新版本(来源.data, 样本A));
const 版本B = useMuse版本原文(scope, 样本B, 最新版本(来源.data, 样本B));
const [消息, set消息] = useState<{ 文本: string; 错误?: boolean } | null>(null);
const [消息, set消息] = useState<{ 文本: string; 错误?: boolean } | null>(
null,
);
async function 选书发起(form: HTMLFormElement) {
const 理由 = String(new FormData(form).get("reason") ?? "").trim();
const 配置 = String(new FormData(form).get("config_id") ?? "").trim();
const 目标 = 样本A ?? 样本B;
if (!目标 || !理由) {
set消息({ 文本: "选定样本并写明理由后再发起。", 错误: true });
if (!目标 || !理由 || !配置) {
set消息({
文本: "选定样本、写明理由并指定有效分析配置后再发起。",
错误: true,
});
return;
}
try {
const 回执 = await 发起拆书(目标, { reason: 理由 });
const 回执 = await 发起拆书(目标, { reason: 理由, config_id: 配置 });
set消息({ 文本: `已按选书理由登记拆书任务:${回执.task_id}` });
} catch (e) {
set消息({ 文本: e instanceof Error ? e.message : "发起失败。", 错误: true });
set消息({
文本: e instanceof Error ? e.message : "发起失败。",
错误: true,
});
}
}
@ -110,8 +124,14 @@ export function Ui榜单与选题页() {
<Ui反馈状态 message="正在读取样本原文…" />
) : (
<ul className="event-list">
<li>样本 A:{样本A ?? "未选"} · 字数 {版本A.data ? 版本A.data.content.length : "—"}</li>
<li>样本 B:{样本B ?? "未选"} · 字数 {版本B.data ? 版本B.data.content.length : "—"}</li>
<li>
样本 A:{样本A ?? "未选"} · 字数{" "}
{版本A.data ? 版本A.data.content.length : "—"}
</li>
<li>
样本 B:{样本B ?? "未选"} · 字数{" "}
{版本B.data ? 版本B.data.content.length : "—"}
</li>
</ul>
)}
<form
@ -123,7 +143,18 @@ export function Ui榜单与选题页() {
>
<label>
选书理由
<input name="reason" placeholder="为什么拆这本;写入拆书任务依据" />
<input
name="reason"
placeholder="为什么拆这本;写入拆书任务依据"
/>
</label>
<label>
分析配置
<input
name="config_id"
placeholder="已验证并启用的分析配置 ID"
required
/>
</label>
<button type="submit">按理由发起拆解</button>
</form>
@ -139,7 +170,10 @@ export function Ui榜单与选题页() {
);
}
function 最新版本(来源列表: { source_id: string; 最新版本: number }[] | undefined, id: string | null) {
function 最新版本(
来源列表: { source_id: string; 最新版本: number }[] | undefined,
id: string | null,
) {
if (!id || !来源列表) return null;
return 来源列表.find((源) => 源.source_id === id)?.最新版本 ?? null;
}

View File

@ -1,34 +1,90 @@
import type { operations } from "./生成/类型";
export type 任务事件 =
operations["read_task_events"]["responses"][200]["content"]["application/json"]["items"][number];
/** 原生 EventSource 携带 Last-Event-ID 重连;页面离开时显式关闭。 */
/** 只消费固定作用域执行流;原生重连使用Last-Event-ID,reset必须先恢复快照。 */
export function 订阅任务事件(
任务ID: string,
回调: { 收到: (事件: 任务事件) => void; 缺口: () => void; 重连: () => void },
taskId: string,
callbacks: {
收到: (event: 任务事件) => void;
缺口: () => void;
重连: () => void;
完成?: (sequence: number) => void;
},
options: { cursor?: number; workId?: string | null } = {},
) {
const 连接 = new EventSource(
`/api/v1/tasks/${encodeURIComponent(任务ID)}/events/stream`,
const query = new URLSearchParams({
protocol: "bounded-v2",
cursor: String(options.cursor ?? 0),
});
if (options.workId) query.set("work_id", options.workId);
const source = new EventSource(
`/api/v1/tasks/${encodeURIComponent(taskId)}/events/stream?${query}`,
);
let 活跃 = true;
let 最后序号 = 0;
连接.addEventListener("task-event", (消息) => {
if (!活跃) return;
const 事件 = JSON.parse((消息 as MessageEvent).data) as 任务事件;
if (事件.sequence <= 最后序号) return;
最后序号 = 事件.sequence;
回调.收到(事件);
});
连接.addEventListener("reset", () => {
if (活跃) 回调.缺口();
连接.close();
});
连接.onerror = () => {
if (活跃) 回调.重连();
let active = true,
last = options.cursor ?? 0;
const close = () => {
if (active) {
active = false;
source.close();
}
};
return () => {
活跃 = false;
连接.close();
const reset = () => {
if (!active) return;
close();
callbacks.缺口();
};
source.addEventListener("task-event", (message) => {
if (!active) return;
try {
const event: 任务事件 = JSON.parse((message as MessageEvent).data);
if (
!Number.isInteger(event.sequence) ||
typeof event.event_id !== "string" ||
typeof event.event_type !== "string" ||
typeof event.occurred_at !== "string" ||
!event.payload ||
typeof event.payload !== "object"
) {
reset();
return;
}
if (event.sequence <= last) return;
if (event.sequence !== last + 1) {
reset();
return;
}
last = event.sequence;
callbacks.收到(event);
} catch {
reset();
}
});
source.addEventListener("reset", reset);
source.addEventListener("stream-complete", (message) => {
if (!active) return;
try {
const {
execution_final_sequence: final,
}: { execution_final_sequence: unknown } = JSON.parse(
(message as MessageEvent).data,
);
if (
typeof final !== "number" ||
!Number.isInteger(final) ||
final !== last
) {
reset();
return;
}
close();
callbacks.完成?.(final);
} catch {
reset();
}
});
source.onerror = () => {
if (active) callbacks.重连();
};
return close;
}

View File

@ -7,3 +7,13 @@ export function 查询键(
) {
return ["作品", 作品, 目标, 版本, 用途] as const;
}
/** 根键只表达任务范围;快照和各子查询分别挂载,避免把current当根前缀。 */
export const 任务查询根 = (work: string | null, taskId: string) =>
["任务", work, taskId] as const;
export const 任务查询键 = (
work: string | null,
taskId: string,
section = "snapshot",
) => [...任务查询根(work, taskId), section] as const;
export const 任务列表键 = (work: string | null) => ["任务列表", work] as const;

File diff suppressed because it is too large Load Diff

View File

@ -30,23 +30,41 @@ export function 转为编辑文档(draft: 正文草稿): JSONContent {
};
}
export type 正文统计 = { visible_text: string; codepoints: number };
export function 转为正文(doc: JSONContent): 正文草稿 {
return 转为正文快照(doc).draft;
}
export function 转为正文快照(doc: JSONContent) {
const pieces: string[] = [];
let codepoints = 0;
const append = (text: string) => {
pieces.push(text);
for (const _ of text) {
void _;
codepoints++;
}
};
if (doc.type !== "doc") throw new Error("编辑器文档类型不受支持。");
return {
const draft: 正文草稿 = {
format_version: 1,
paragraphs: (doc.content ?? []).map((p) => {
paragraphs: (doc.content ?? []).map((p, index) => {
if (index) append("\n");
if (p.type !== "paragraph" || typeof p.attrs?.paragraph_id !== "string")
throw new Error("段落缺少稳定身份。");
return {
paragraph_id: p.attrs.paragraph_id,
content: (p.content ?? []).map((n) => {
if (n.type === "hardBreak")
if (n.type === "hardBreak") {
append(n.attrs?.newline ?? "\n");
return {
type: "line_break" as const,
newline: n.attrs?.newline ?? "\n",
};
}
if (n.type !== "text" || n.text === undefined)
throw new Error("正文含未登记节点。");
append(n.text);
const marks = (n.marks ?? []).map((m) => m.type);
if (marks.some((m) => m !== "bold" && m !== "italic"))
throw new Error("正文样式不受支持。");
@ -61,6 +79,7 @@ export function 转为正文(doc: JSONContent): 正文草稿 {
};
}),
};
return { draft, visible_text: pieces.join(""), codepoints };
}
export function 正文文本(draft: 正文草稿): string {

View File

@ -1,8 +1,13 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { EditorContent, Extension, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import UniqueID from "@tiptap/extension-unique-id";
import { 转为编辑文档, 转为正文, type 正文草稿 } from "./文档适配";
import {
转为编辑文档,
转为正文快照,
type 正文草稿,
type 正文统计,
} from "./文档适配";
import { 解析纯文本 } from "./粘贴处理";
import { 捕获选区, type 原文坐标 } from "./选区定位";
@ -49,7 +54,7 @@ export function 正文扩展() {
type 属性 = {
initial: 正文草稿;
onChange: (value: 正文草稿) => void;
onChange: (value: 正文草稿, stats?: 正文统计) => void;
onComposition: (active: boolean) => void;
editable?: boolean;
label?: string;
@ -66,10 +71,11 @@ export function Ui正文编辑器({
}: 属性) {
const handlers = useRef({ onChange, onComposition, onSelection });
handlers.current = { onChange, onComposition, onSelection };
const [content] = useState(() => 转为编辑文档(initial));
const editor = useEditor(
{
extensions: 正文扩展(),
content: 转为编辑文档(initial),
content,
onSelectionUpdate: ({ editor }) => {
try {
handlers.current.onSelection?.(
@ -114,8 +120,11 @@ export function Ui正文编辑器({
return true;
},
},
onUpdate: ({ editor }) =>
handlers.current.onChange(转为正文(editor.getJSON())),
onUpdate: ({ editor, transaction }) => {
if (!transaction.docChanged) return;
const { draft, ...stats } = 转为正文快照(editor.getJSON());
handlers.current.onChange(draft, stats);
},
},
[],
);
@ -134,24 +143,28 @@ export function Ui正文编辑器({
<div className="editor-tools" role="toolbar" aria-label="正文样式">
<button
type="button"
disabled={!editable}
onClick={() => editor?.chain().focus().toggleBold().run()}
>
加粗
</button>
<button
type="button"
disabled={!editable}
onClick={() => editor?.chain().focus().toggleItalic().run()}
>
强调
</button>
<button
type="button"
disabled={!editable}
onClick={() => editor?.chain().focus().undo().run()}
>
撤销
</button>
<button
type="button"
disabled={!editable}
onClick={() => editor?.chain().focus().redo().run()}
>
重做

View File

@ -1,6 +1,12 @@
/** 组件传输替身只验证渲染与取数路径;真实后端旅程由 Playwright 承担。 */
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import {
cleanup,
render,
screen,
fireEvent,
waitFor,
} from "@testing-library/react";
import { Ui依赖装配 } from "../../src/应用/依赖装配";
import { Ui任务上下文 } from "../../src/功能/上下文";
@ -37,14 +43,18 @@ const 有效快照 = {
function 桩快照(快照: unknown) {
return vi.fn(async (request: Request) => {
expect(new URL(request.url).pathname).toBe(
"/api/v1/tasks/task-1/context-snapshots",
"/api/v1/tasks/task-1/context-snapshots/page",
);
return new Response(
JSON.stringify({ items: 快照, has_more: false, next_cursor: null }),
{
headers: { "Content-Type": "application/json" },
},
);
return new Response(JSON.stringify(快照), {
headers: { "Content-Type": "application/json" },
});
});
}
// @muse-case {"case_id":"NC-ui-context-snapshot-list","environment":"离线组件,传输替身","given":"含省略条目的有效快照","when":"渲染任务上下文组件","then":["材料清单、字节数与省略原因渲染;取数走 /tasks/{id}/context-snapshots"],"contract":"docs/系统架构/新版设计/模块设计/B09-上下文.md"}
it("NC-ui-context-snapshot-list:材料清单与省略原因渲染", async () => {
vi.stubGlobal("fetch", 桩快照([有效快照]));
render(
@ -56,9 +66,7 @@ it("NC-ui-context-snapshot-list:材料清单与省略原因渲染", async () =
await screen.findByText("目标章细纲", { selector: ".step-list strong" }),
).toBeTruthy();
expect(screen.getByText("历史:ch-1")).toBeTruthy();
expect(
screen.getByText("省略原因:超出材料预算,整项省略"),
).toBeTruthy();
expect(screen.getByText("省略原因:超出材料预算,整项省略")).toBeTruthy();
expect(screen.getByText(/材料 328 字节/)).toBeTruthy();
expect(screen.getByText(new RegExp("b".repeat(64)))).toBeTruthy();
expect(screen.getByText("doc-1")).toBeTruthy();
@ -70,14 +78,20 @@ it("NC-ui-context-snapshot-list:材料清单与省略原因渲染", async () =
expect(screen.queryByRole("status")).toBeNull();
});
// @muse-case {"case_id":"NC-ui-context-stale-evidence","environment":"离线组件,传输替身","given":"漂移后的快照","when":"渲染任务上下文组件","then":["失效标注与具体变化项可见;来源证据仍显示版本与哈希"],"contract":"docs/系统架构/新版设计/模块设计/B09-上下文.md"}
it("NC-ui-context-stale-evidence:来源漂移后失效标注与具体变化可见", async () => {
const 漂移快照 = {
...有效快照,
state: "SOURCE_STALE",
invalidation: "文稿 ch-1 已漂移(当前版本 2,冻结版本 1);冻结范围当前失效,需重新授权",
invalidation:
"文稿 ch-1 已漂移(当前版本 2,冻结版本 1);冻结范围当前失效,需重新授权",
changes: ["文稿 ch-1 已从版本 1 前进到 2;来源锁定失效"],
sources: [
{ ...有效快照.sources[0], data_version: "1", current_state: "SOURCE_STALE" },
{
...有效快照.sources[0],
data_version: "1",
current_state: "SOURCE_STALE",
},
],
};
vi.stubGlobal("fetch", 桩快照([漂移快照]));
@ -97,6 +111,7 @@ it("NC-ui-context-stale-evidence:来源漂移后失效标注与具体变化可
expect(screen.getByText("省略原因:超出材料预算,整项省略")).toBeTruthy();
});
// @muse-case {"case_id":"NC-ui-context-empty-state","environment":"离线组件,传输替身","given":"没有快照的任务","when":"渲染任务上下文组件","then":["如实显示空态,不伪造材料"],"contract":"docs/系统架构/新版设计/模块设计/B09-上下文.md"}
it("NC-ui-context-empty-state:无快照任务如实显示空态", async () => {
vi.stubGlobal("fetch", 桩快照([]));
render(
@ -104,8 +119,45 @@ it("NC-ui-context-empty-state:无快照任务如实显示空态", async () =>
<Ui任务上下文 taskId="task-1" scope={null} />
</Ui依赖装配>,
);
expect(
await screen.findByText("这个任务还没有冻结材料快照"),
).toBeTruthy();
expect(await screen.findByText("这个任务还没有冻结材料快照")).toBeTruthy();
expect(screen.getByText("0 份冻结快照")).toBeTruthy();
});
// @muse-case {"case_id":"O08-context-explicit-page","then":"材料分页初始只读20份,只有点击才读下一页,连续加载最多保留3页"}
it("O08-context-explicit-page:材料快照按页读取且缓存有界", async () => {
const reads: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const url = new URL(request.url);
reads.push(url.searchParams.get("cursor") ?? "first");
expect(url.pathname).toBe("/api/v1/tasks/task-1/context-snapshots/page");
expect(url.searchParams.get("limit")).toBe("20");
const page = Number(url.searchParams.get("cursor") ?? 0);
return new Response(
JSON.stringify({
items: Array.from({ length: 20 }, (_, index) => ({
...有效快照,
snapshot_id: `s-${page}-${index}`,
})),
has_more: page < 3,
next_cursor: page < 3 ? String(page + 1) : null,
}),
{ headers: { "Content-Type": "application/json" } },
);
}),
);
render(
<Ui依赖装配>
<Ui任务上下文 taskId="task-1" scope="work-1" />
</Ui依赖装配>,
);
await screen.findByText("20 份冻结快照");
expect(reads).toEqual(["first"]);
for (let page = 1; page <= 3; page++) {
fireEvent.click(screen.getByRole("button", { name: "加载更多材料快照" }));
await waitFor(() => expect(reads).toHaveLength(page + 1));
await screen.findByText(`${Math.min(page + 1, 3) * 20} 份冻结快照`);
}
expect(screen.queryByRole("button", { name: "加载更多材料快照" })).toBeNull();
});

View File

@ -19,6 +19,7 @@ afterEach(() => {
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[] = [];
@ -157,6 +158,7 @@ function 表单结果(
};
}
// @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<string, unknown>[] = [];
const decisionBodies: Record<string, unknown>[] = [];
@ -365,6 +367,7 @@ it("NC-world-entity-author-flow:新类型动态字段保存为提案,展示
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(
@ -414,6 +417,7 @@ it("NC-world-review-scope:其他作品的提案在打开审阅前被拒绝", a
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 = {
@ -497,6 +501,7 @@ it("NC-world-review-version:当前实体版本与提案基线失配时不打
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",

View File

@ -316,6 +316,7 @@ async function preparePreview() {
const writes = (path: string) =>
calls.filter((c) => c.path === path && c.method === "POST");
// @muse-case {"case_id":"NC-ui-delivery-missing","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["完整目录缺正文时不能预览或冻结,不跳过缺章"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-missing:完整目录缺正文时不能预览或冻结,不跳过缺章", async () => {
book.chapters[1] = {
chapter_id: "chapter-2",
@ -345,6 +346,7 @@ it("NC-ui-delivery-missing:完整目录缺正文时不能预览或冻结,不
expect(screen.getByText(/文学质量尚未评估/)).toBeTruthy();
});
// @muse-case {"case_id":"NC-ui-delivery-preview","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["改过材料后旧预览失效,新的冻结携带完整当前版本"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-preview:改过材料后旧预览失效,新的冻结携带完整当前版本", async () => {
mount();
await preparePreview();
@ -377,6 +379,7 @@ it("NC-ui-delivery-preview:改过材料后旧预览失效,新的冻结携带
]);
});
// @muse-case {"case_id":"NC-ui-delivery-preview-late","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["迟到的预览不能重新批准后来修改过的材料"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-preview-late:迟到的预览不能重新批准后来修改过的材料", async () => {
let resolve: ((response: Response) => void) | undefined;
let pending: 定稿命令 | undefined;
@ -406,6 +409,7 @@ it("NC-ui-delivery-preview-late:迟到的预览不能重新批准后来修改
).toBe(true);
});
// @muse-case {"case_id":"NC-ui-delivery-retry","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["网络错误可见,冻结重试复用原命令和载荷"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-retry:网络错误可见,冻结重试复用原命令和载荷", async () => {
let attempts = 0;
override = (call) => {
@ -436,6 +440,7 @@ it("NC-ui-delivery-retry:网络错误可见,冻结重试复用原命令和
expect(attemptsSent.every((c) => c.credentials === "same-origin")).toBe(true);
});
// @muse-case {"case_id":"NC-ui-delivery-stale","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["服务器拒绝旧正文版本后撤销预览并重新读取目录"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-stale:服务器拒绝旧正文版本后撤销预览并重新读取目录", async () => {
override = (call) =>
call.path === "/api/v1/deliveries"
@ -469,6 +474,7 @@ it("NC-ui-delivery-stale:服务器拒绝旧正文版本后撤销预览并重
);
});
// @muse-case {"case_id":"NC-ui-delivery-export","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["创建四格式任务后真实推进,失败与未就绪下载均可见"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-export:创建四格式任务后真实推进,失败与未就绪下载均可见", async () => {
summaries = [frozen];
let advanceState = "failed";
@ -504,6 +510,7 @@ it("NC-ui-delivery-export:创建四格式任务后真实推进,失败与未
expect(writes(`/api/v1/deliveries/${deliveryId}/exports`)).toHaveLength(1);
});
// @muse-case {"case_id":"NC-ui-delivery-download","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["正常下载通过认证请求取得 Blob 并交给浏览器"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-delivery-download:正常下载通过认证请求取得 Blob 并交给浏览器", async () => {
summaries = [frozen];
// jsdom 的 Response.blob() 返回的 Blob 只有 slice/size/type,不能读回字节;
@ -545,6 +552,7 @@ it("NC-ui-delivery-download:正常下载通过认证请求取得 Blob 并交
);
});
// @muse-case {"case_id":"NC-ui-serial-release","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["到期计划仍未发布,只有作者登记才发送所选计划版本与证据"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-serial-release:到期计划仍未发布,只有作者登记才发送所选计划版本与证据", async () => {
summaries = [frozen];
workspace.plans = [
@ -605,6 +613,7 @@ it("NC-ui-serial-release:到期计划仍未发布,只有作者登记才发
});
});
// @muse-case {"case_id":"NC-ui-reader-feedback-unlinked","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["收到时间临近发布也不猜关联,未关联反馈可读回"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-reader-feedback-unlinked:收到时间临近发布也不猜关联,未关联反馈可读回", async () => {
summaries = [frozen];
workspace.releases = [release];
@ -635,6 +644,7 @@ it("NC-ui-reader-feedback-unlinked:收到时间临近发布也不猜关联,
expect(within(saved).getByText("这里的动机看不明白。")).toBeTruthy();
});
// @muse-case {"case_id":"NC-ui-reader-feedback-version","environment":"前端HTTP替身","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["明确发布与章节后才能提出调整,读回同时显示原版与当前版"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
it("NC-ui-reader-feedback-version:明确发布与章节后才能提出调整,读回同时显示原版与当前版", async () => {
summaries = [frozen];
workspace.releases = [release];

View File

@ -0,0 +1,272 @@
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useMuse任务订阅 } from "../../src/功能/任务运行/任务订阅";
import type { 任务 } from "../../src/功能/任务运行/数据";
class Source {
static instances: Source[] = [];
handlers = new Map<string, ((event: MessageEvent) => void)[]>();
onerror: (() => void) | null = null;
close = vi.fn();
constructor(public url: string) {
Source.instances.push(this);
}
addEventListener(type: string, callback: (event: MessageEvent) => void) {
this.handlers.set(type, [...(this.handlers.get(type) ?? []), callback]);
}
emit(type: string, data: unknown) {
this.handlers
.get(type)
?.forEach((callback) =>
callback(new MessageEvent(type, { data: JSON.stringify(data) })),
);
}
}
const running: 任务 = {
task_id: "task",
state: "running",
work_id: "work",
task_type: "生成正文",
supports_step_advance: false,
last_sequence: 0,
steps: [],
execution_final_sequence: null,
accounting_revision: 0,
result_refs: [],
};
function Probe({ task = running }: { task?: 任务 }) {
const { events, notice } = useMuse任务订阅(task, "work");
return (
<>
<output>{events.map((e) => e.sequence).join(",")}</output>
<p>{notice}</p>
</>
);
}
function event(sequence: number, type = "text.delta") {
return {
event_id: `event-${sequence}`,
sequence,
event_type: type,
occurred_at: "2026-09-17T00:00:00Z",
payload: { 文本: "合成增量" },
};
}
function setup(snapshot: () => 任务) {
vi.useFakeTimers();
Source.instances = [];
vi.stubGlobal("EventSource", Source);
const counts = { snapshot: 0, history: 0 };
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const path = new URL(request.url).pathname;
let data: unknown;
if (path.endsWith("/events")) {
counts.history++;
data = {
items: [],
reset_required: false,
first_sequence: 1,
last_sequence: snapshot().last_sequence,
execution_final_sequence: snapshot().execution_final_sequence,
};
} else {
counts.snapshot++;
data = snapshot();
}
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}),
);
const cache = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return {
counts,
cache,
view: (task = running) =>
render(
<QueryClientProvider client={cache}>
<Probe task={task} />
</QueryClientProvider>,
),
};
}
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
const flush = () =>
act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
// @muse-case {"case_id":"O08-stream-terminal-idle","then":"已终态任务只读取一页有限历史,不建立持续SSE;推进60秒不重读"}
it("O08-stream-terminal-idle:已终态快照没有持续事件连接", async () => {
const terminal: 任务 = {
...running,
state: "cancelled",
last_sequence: 50,
execution_final_sequence: 50,
};
const { counts, view } = setup(() => terminal);
view(terminal);
await flush();
expect(Source.instances).toHaveLength(0);
expect(counts).toEqual({ snapshot: 0, history: 1 });
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(counts).toEqual({ snapshot: 0, history: 1 });
});
// @muse-case {"case_id":"O08-stream-delta-final-close","then":"100条text.delta不刷新任务;状态合并读一次;最终游标只close一次且60秒无续连"}
it("O08-stream-delta-final-close:增量不刷快照,执行最终游标双端收尾", async () => {
let snapshot = running;
const { counts, view } = setup(() => snapshot);
view();
await flush();
const source = Source.instances[0];
act(() => {
for (let i = 1; i <= 100; i++) source.emit("task-event", event(i));
});
await act(() => vi.advanceTimersByTimeAsync(200));
expect(counts.snapshot).toBe(0);
snapshot = { ...running, last_sequence: 102 };
act(() => {
source.emit("task-event", event(101, "step.completed"));
source.emit("task-event", event(102, "candidate.saved"));
});
await act(() => vi.advanceTimersByTimeAsync(100));
expect(counts.snapshot).toBe(1);
snapshot = {
...running,
state: "completed",
last_sequence: 103,
execution_final_sequence: 103,
};
act(() => {
source.emit("task-event", event(103, "task.completed"));
source.emit("stream-complete", { execution_final_sequence: 103 });
});
await flush();
expect(source.close).toHaveBeenCalledTimes(1);
expect(counts.snapshot).toBe(2);
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(counts.snapshot).toBe(2);
expect(Source.instances).toHaveLength(1);
});
// @muse-case {"case_id":"O08-stream-reset-resume","then":"reset关闭旧源,只读一次权威快照后从新last_sequence续连,旧源及重复序号不追加"}
it("O08-stream-reset-resume:缺口按新快照恢复且重复事件只消费一次", async () => {
const snapshot = { ...running, last_sequence: 10 };
const { counts, view } = setup(() => snapshot);
view();
await flush();
const old = Source.instances[0];
act(() => old.emit("reset", {}));
await flush();
expect(old.close).toHaveBeenCalledTimes(1);
expect(counts.snapshot).toBe(1);
expect(Source.instances).toHaveLength(2);
const next = Source.instances[1];
expect(next.url).toContain("cursor=10");
act(() => {
old.emit("task-event", event(1));
next.emit("task-event", event(11));
next.emit("task-event", event(11));
});
expect(screen.getByRole("status").textContent).toBe("11");
});
// @muse-case {"case_id":"O08-stream-reset-after-inflight","then":"reset等旧快照请求结束后只发一次新权威读取,后续游标采用新快照而非旧在途结果"}
it("O08-stream-reset-after-inflight:缺口不把较早在途快照当作恢复结果", async () => {
const { counts, view } = setup(() => running);
let finish!: (response: Response) => void;
let snapshots = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const path = new URL(request.url).pathname;
if (path.endsWith("/events"))
return new Response(
JSON.stringify({
items: [],
reset_required: false,
first_sequence: 1,
last_sequence: 0,
}),
{ headers: { "Content-Type": "application/json" } },
);
snapshots++;
if (snapshots === 1)
return new Promise<Response>((resolve) => {
finish = resolve;
});
return new Response(JSON.stringify({ ...running, last_sequence: 10 }), {
headers: { "Content-Type": "application/json" },
});
}),
);
view();
await flush();
const old = Source.instances[0];
act(() => old.emit("task-event", event(1, "step.started")));
await act(() => vi.advanceTimersByTimeAsync(100));
expect(snapshots).toBe(1);
act(() => old.emit("reset", {}));
await flush();
expect(snapshots).toBe(1);
await act(async () =>
finish(
new Response(JSON.stringify({ ...running, last_sequence: 1 }), {
headers: { "Content-Type": "application/json" },
}),
),
);
await flush();
expect(snapshots).toBe(2);
expect(Source.instances).toHaveLength(2);
expect(Source.instances[1].url).toContain("cursor=10");
expect(counts.snapshot).toBe(0);
});
// @muse-case {"case_id":"O08-stream-unmount-aborts-read","then":"离开任务时关闭执行流并取消在途只读快照,迟到响应不产生新订阅"}
it("O08-stream-unmount-aborts-read:离开作用域结束订阅和在途读取", async () => {
const { view } = setup(() => running);
let signal: AbortSignal | undefined;
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
if (new URL(request.url).pathname.endsWith("/events"))
return new Response(
JSON.stringify({
items: [],
reset_required: false,
first_sequence: 1,
last_sequence: 0,
}),
{ headers: { "Content-Type": "application/json" } },
);
signal = request.signal;
return new Promise<Response>((_, reject) =>
request.signal.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError")),
),
);
}),
);
const mounted = view();
await flush();
const source = Source.instances[0];
act(() => source.emit("task-event", event(1, "step.started")));
await act(() => vi.advanceTimersByTimeAsync(100));
expect(signal?.aborted).toBe(false);
mounted.unmount();
await flush();
expect(signal?.aborted).toBe(true);
expect(source.close).toHaveBeenCalledTimes(1);
expect(Source.instances).toHaveLength(1);
});

View File

@ -70,6 +70,7 @@ beforeEach(() => {
});
afterEach(cleanup);
// @muse-case {"case_id":"NC-ui-preference-conflict","environment":"离线界面和传输替身","given":"真实作者、作品及确切正文反馈版本","when":"保存范围偏好、审阅冲突、确认、撤回及重新消费","then":["点名冲突并审阅后才能确认"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md"}
it("NC-ui-preference-conflict:点名冲突并审阅后才能确认", async () => {
api.list.mockResolvedValue([
row("old", "active", "解释人物感受。"),
@ -101,6 +102,7 @@ it("NC-ui-preference-conflict:点名冲突并审阅后才能确认", async ()
);
});
// @muse-case {"case_id":"NC-ui-preference-stale","environment":"离线界面和传输替身","given":"真实作者、作品及确切正文反馈版本","when":"保存范围偏好、审阅冲突、确认、撤回及重新消费","then":["旧批准失败后清除可提交审阅"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md"}
it("NC-ui-preference-stale:旧批准失败后清除可提交审阅", async () => {
api.list.mockResolvedValue([row()]);
api.confirm.mockRejectedValue(new Error("偏好版本已改变"));
@ -116,6 +118,7 @@ it("NC-ui-preference-stale:旧批准失败后清除可提交审阅", async ()
).toBeNull();
});
// @muse-case {"case_id":"NC-ui-preference-scope","environment":"离线界面和传输替身","given":"真实作者、作品及确切正文反馈版本","when":"保存范围偏好、审阅冲突、确认、撤回及重新消费","then":["任务要求不被编辑成长期范围"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md"}
it("NC-ui-preference-scope:任务要求不被编辑成长期范围", async () => {
api.list.mockResolvedValue([
{

View File

@ -62,6 +62,7 @@ const review: 候选审阅 = {
},
};
// @muse-case {"case_id":"NC-review-fixed-request-retry","environment":"Vitest真实组件;HTTP传输替身不算真实业务","given":"候选采纳响应未知且随后重新打开页面","when":"重试之前的决定","then":["仍提交原命令、候选、基线、审阅及批准清单"],"contract":"docs/系统架构/新版设计/接口契约/命令与作者决策.md"}
it("NC-review-fixed-request-retry:响应未知后保留原审阅与命令,刷新后仍重放同一决定", async () => {
const requests: unknown[] = [];
vi.stubGlobal(
@ -109,6 +110,7 @@ it("NC-review-fixed-request-retry:响应未知后保留原审阅与命令,
expect(sessionStorage.length).toBe(0);
});
// @muse-case {"case_id":"NC-review-conflict-reopen","environment":"Vitest真实组件;HTTP传输替身不算真实业务","given":"候选决定返回明确版本冲突","when":"继续点击决定入口","then":["停止旧决定并重新审阅,不能自动替换正文或候选基线"],"contract":"docs/系统架构/新版设计/接口契约/命令与作者决策.md"}
it("NC-review-conflict-reopen:明确冲突停止决定并重新展示,不以当前版本悄悄替换基线", async () => {
const calls = vi.fn(
async () =>
@ -129,7 +131,8 @@ it("NC-review-conflict-reopen:明确冲突停止决定并重新展示,不以
expect(sessionStorage.length).toBe(0);
});
it("NC-candidate-stale-buffer-choice:明确放弃旧缓冲后能基于当前正文另拟改稿", async () => {
// @muse-case {"case_id":"NC-candidate-stale-buffer-choice","environment":"真实页面与Tiptap,HTTP替身;另有真实浏览器旅程","given":"本地未提交候选基于正文版本1,服务器已经是版本2","when":"打开候选缓冲、明确放弃旧改稿,再次另拟并保存","then":["旧稿首先保留可读;明确放弃不写服务端;重新打开使用当前正文和版本,能够实际保存"],"contract":"docs/系统架构/新版设计/接口契约/命令与作者决策.md"}
it("NC-candidate-stale-buffer-choice:旧无归属缓冲不自动挂接,明确复制到当前分支后可保存", async () => {
const key = "muse:candidate-draft:author:work:chapter:new";
const current = { ...review.before, revision: 2 };
const draft = {
@ -161,8 +164,8 @@ it("NC-candidate-stale-buffer-choice:明确放弃旧缓冲后能基于当前
? { receipt_id: "candidate-saved", results: [] }
: path.endsWith("session")
? { author_id: "author" }
: path.endsWith("writing-candidates")
? []
: path.endsWith("writing-candidate-summaries")
? { items: [], next_cursor: null }
: current;
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
@ -184,26 +187,159 @@ it("NC-candidate-stale-buffer-choice:明确放弃旧缓冲后能基于当前
fireEvent.click(await screen.findByRole("button", { name: "另拟改稿" }));
expect(
screen.getByRole("textbox", { name: "候选改稿" }).textContent,
).toContain("尚未提交的旧改稿");
expect(
(screen.getByRole("button", { name: "保存候选版本" }) as HTMLButtonElement)
.disabled,
).toBe(true);
).toContain("合成原文");
const original = sessionStorage.getItem(key);
expect(screen.getByRole("textbox", { name: "旧改稿导出" })).toBeTruthy();
fireEvent.click(
screen.getByRole("button", { name: "放弃旧改稿,返回当前版本" }),
screen.getByRole("button", { name: "将旧稿复制到当前分支 main" }),
);
expect(sessionStorage.getItem(key)).toBeNull();
expect(sent).toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "另拟改稿" }));
expect(sessionStorage.getItem(key)).toBe(original);
expect(
screen.getByRole("textbox", { name: "候选改稿" }).textContent,
).toContain("合成原文");
).toContain("尚未提交的旧改稿");
expect(sent).toHaveLength(0);
const save = screen.getByRole("button", { name: "保存候选版本" });
expect((save as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(save);
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({
expected_revision: 2,
document: current.document,
document: draft,
});
});
function mountCandidates(branch: string) {
return render(
<Ui依赖装配>
<MemoryRouter
initialEntries={[`/works/work/candidates/chapter?branch=${branch}`]}
>
<Routes>
<Route
path="/works/:work_id/candidates/:chapter_id"
element={<Ui候选审阅页 />}
/>
</Routes>
</MemoryRouter>
</Ui依赖装配>,
);
}
// @muse-case {"case_id": "O01-candidate-legacy-pending-branch", "environment": "离线组件测试,localStorage替身", "given": "旧格式待确认请求基于分支A的revision 7,当前进入分支B", "when": "打开候选缓冲并触发重试", "then": ["仅分支A可见并可复制原样重试", "分支B不发送旧请求"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-candidate-legacy-pending-branch:旧待确认请求仅在原分支复制并原样重试,其他分支不发送", async () => {
const key = "muse:candidate-draft:author:work:chapter:new";
const pending = {
kind: "create",
body: {
command_id: "fixed-before-refresh",
chapter_id: "chapter",
branch_id: "A",
expected_revision: 7,
document,
},
};
const raw = JSON.stringify({
document,
base_revision: 7,
mode: "edit",
selected: [],
pending,
});
sessionStorage.setItem(key, raw);
const posts: unknown[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const url = new URL(request.url);
if (request.method === "POST") {
posts.push(await request.json());
throw new Error("unknown response");
}
const data = url.pathname.endsWith("session")
? { author_id: "author" }
: url.pathname.endsWith("writing-candidate-summaries")
? { items: [], next_cursor: null }
: {
...review.before,
branch_id: url.searchParams.get("branch_id"),
revision: 7,
};
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}),
);
const b = mountCandidates("B");
fireEvent.click(await screen.findByRole("button", { name: "另拟改稿" }));
expect(
await screen.findByRole("link", { name: "返回原分支 A 确认保存结果" }),
).toBeTruthy();
expect(screen.queryByRole("button", { name: "重试候选保存" })).toBeNull();
expect(posts).toHaveLength(0);
b.unmount();
const a = mountCandidates("A");
fireEvent.click(await screen.findByRole("button", { name: "另拟改稿" }));
fireEvent.click(await screen.findByRole("button", { name: "重试候选保存" }));
await screen.findByText("暂时无法连接工作台,请稍后重试。");
a.unmount();
mountCandidates("A");
fireEvent.click(await screen.findByRole("button", { name: "另拟改稿" }));
fireEvent.click(await screen.findByRole("button", { name: "重试候选保存" }));
await waitFor(() => expect(posts).toHaveLength(2));
expect(posts).toEqual([pending.body, pending.body]);
expect(sessionStorage.getItem(key)).toBe(raw);
});
// @muse-case {"case_id": "O01-candidate-legacy-detail", "environment": "离线组件测试,HTTP替身", "given": "旧键存有候选稿,服务器已有当前分支详情", "when": "读取单项详情核对分支归属后复制", "then": ["核对通过才复制到当前分支", "原键不删除"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-candidate-legacy-detail:旧候选稿通过单项详情核对分支后复制,原键不删除", async () => {
const key = "muse:candidate-draft:author:work:chapter:candidate";
const draft = {
...document,
paragraphs: [
{
paragraph_id: "p1",
content: [{ type: "text", text: "旧候选的私人改稿", marks: [] }],
},
],
};
const raw = JSON.stringify({
document: draft,
base_revision: 3,
candidate_revision: 2,
candidate_hash: "candidate-hash",
mode: "edit",
selected: [],
});
sessionStorage.setItem(key, raw);
let reads = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const path = new URL(request.url).pathname;
let data: unknown;
if (path.endsWith("session")) data = { author_id: "author" };
else if (path.endsWith("writing-candidates/candidate")) {
reads++;
data = review.candidate;
} else if (path.endsWith("writing-candidate-summaries"))
data = { items: [review.candidate], next_cursor: null };
else if (request.method === "POST") data = review;
else data = review.before;
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}),
);
mountCandidates("main");
fireEvent.click(await screen.findByRole("button", { name: /候选 1/ }));
fireEvent.click(
await screen.findByRole("button", { name: "编辑候选或部分选择" }),
);
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "候选改稿" }).textContent,
).toContain("旧候选的私人改稿"),
);
expect(reads).toBe(2);
expect(sessionStorage.getItem(key)).toBe(raw);
});

View File

@ -0,0 +1,206 @@
import { afterEach, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { Ui候选审阅页 } from "../../src/功能/正文写作/候选审阅";
const document = {
format_version: 1,
paragraphs: [
{
paragraph_id: "p",
content: [{ type: "text", text: "当前正文", marks: [] }],
},
],
};
const current = {
work_id: "work",
chapter_id: "chapter",
branch_id: "main",
document_id: "doc",
revision: 7,
document_hash: "h7",
visible_text_hash: "t7",
visible_text: "当前正文",
document,
state: "saved",
};
const summary = (id: string) => ({
candidate_id: id,
revision: 1,
candidate_hash: `hash-${id}`,
document_id: "doc",
chapter_id: "chapter",
branch_id: "main",
origin: "author",
base_revision: 7,
current_document_revision: 7,
decision: "undecided",
stale: false,
});
const detail = (id: string) => ({
...summary(id),
document,
visible_text: `改稿-${id}`,
checks: { format: "passed", format_version: 1, quality: "not_applicable" },
changes: [],
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
sessionStorage.clear();
});
function mount() {
const cache = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
const view = render(
<QueryClientProvider client={cache}>
<MemoryRouter initialEntries={["/works/work/candidates/chapter"]}>
<Routes>
<Route
path="/works/:work_id/candidates/:chapter_id"
element={<Ui候选审阅页 />}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
return { cache, view };
}
// @muse-case {"case_id":"O08-candidate-page-cache-selection","then":"连续20页只读摘要且缓存不超过3页,选中项不因翻页漂移,未选项无全文读取,离页清临时查询不清草稿"}
it("O08-candidate-page-cache-selection:摘要分页有界且选中候选跨页保留", async () => {
const calls = {
summaries: 0,
detail: 0,
fullList: 0,
document: 0,
reviews: 0,
};
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const url = new URL(request.url);
let data: unknown;
if (url.pathname.endsWith("session")) data = { author_id: "author" };
else if (url.pathname.endsWith("writing-candidate-summaries")) {
calls.summaries++;
const page = Number(url.searchParams.get("cursor") ?? 0);
data = {
items: [summary(`candidate-${page}`)],
next_cursor: page < 20 ? String(page + 1) : null,
};
} else if (url.pathname.endsWith("writing-candidates")) {
calls.fullList++;
throw new Error("不得调用完整候选列表");
} else if (url.pathname.includes("/writing-candidates/")) {
calls.detail++;
data = detail(url.pathname.split("/").at(-1)!);
} else if (url.pathname.endsWith("author-reviews")) {
calls.reviews++;
const body = await request.json();
data = {
review_id: "review",
review_hash: "rh",
snapshot: {},
before: current,
candidate: detail(body.candidate_id),
};
} else {
calls.document++;
data = current;
}
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}),
);
const { cache, view } = mount();
const choose = await screen.findByRole("button", { name: /候选 1/ });
expect(calls.detail).toBe(0);
fireEvent.click(choose);
await screen.findByRole("button", { name: "编辑候选或部分选择" });
expect(calls.detail).toBe(1);
expect(calls.reviews).toBe(1);
for (let page = 1; page <= 20; page++) {
fireEvent.click(screen.getByRole("button", { name: "下一页候选" }));
await waitFor(() => expect(calls.summaries).toBe(page + 1));
await screen.findByRole("button", { name: "下一页候选" });
await waitFor(() =>
expect(
cache
.getQueryCache()
.findAll({
queryKey: [
"候选工作面",
"author",
"work",
"chapter",
"main",
"pages",
],
}).length,
).toBeLessThanOrEqual(3),
);
}
expect(screen.getByText("改稿-candidate-0")).toBeTruthy();
expect(calls.detail).toBe(1);
expect(calls.fullList).toBe(0);
expect(calls.document).toBe(1);
sessionStorage.setItem("muse:unsubmitted-synthetic", "preserve");
view.unmount();
expect(
cache.getQueryCache().findAll({ queryKey: ["候选工作面"] }),
).toHaveLength(0);
expect(sessionStorage.getItem("muse:unsubmitted-synthetic")).toBe("preserve");
});
// @muse-case {"case_id":"O08-candidate-summary-stale-cas","then":"摘要读取后候选变更,打开审阅使用单项当前版本;审阅CAS拒绝时不出现采纳操作"}
it("O08-candidate-summary-stale-cas:摘要不能冒充审阅凭据", async () => {
const bodies: unknown[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const url = new URL(request.url);
let data: unknown;
if (url.pathname.endsWith("session")) data = { author_id: "author" };
else if (url.pathname.endsWith("writing-candidate-summaries"))
data = { items: [summary("candidate")], next_cursor: null };
else if (url.pathname.endsWith("writing-candidates/candidate"))
data = {
...detail("candidate"),
revision: 2,
candidate_hash: "current-hash",
};
else if (url.pathname.endsWith("author-reviews")) {
bodies.push(await request.json());
return new Response(
JSON.stringify({
code: "REVISION_CONFLICT",
message: "正文又有新版本,请重新审阅",
}),
{ status: 409, headers: { "Content-Type": "application/json" } },
);
} else data = current;
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}),
);
mount();
fireEvent.click(await screen.findByRole("button", { name: /候选 1/ }));
expect(await screen.findByText("正文又有新版本,请重新审阅")).toBeTruthy();
expect(bodies).toEqual([
expect.objectContaining({
candidate_revision: 2,
candidate_hash: "current-hash",
expected_revision: 7,
}),
]);
expect(screen.queryByRole("button", { name: "采纳为正文新版本" })).toBeNull();
});

View File

@ -71,6 +71,7 @@ beforeEach(() => {
});
afterEach(cleanup);
// @muse-case {"case_id":"NC-ui-improvement-retry","environment":"离线界面与传输替身","given":"确切方法版本、真实反馈和同作者用途配置","when":"登记具体改进、实际验证和owner启停并恢复已提交结果;记录分版本观察","then":["核对后明确启用且网络重试复用原命令"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md"}
it("NC-ui-improvement-retry:核对后明确启用且网络重试复用原命令", async () => {
api.act.mockRejectedValueOnce(new Error("连接中断"));
mount();
@ -96,6 +97,7 @@ it("NC-ui-improvement-retry:核对后明确启用且网络重试复用原命
expect(api.act.mock.calls[0].slice(1)).toEqual([point, "enable", "proof"]);
});
// @muse-case {"case_id":"NC-ui-improvement-recover","environment":"离线界面与传输替身","given":"确切方法版本、真实反馈和同作者用途配置","when":"登记具体改进、实际验证和owner启停并恢复已提交结果;记录分版本观察","then":["刷新后保留请求仍用原版本与命令恢复"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md"}
it("NC-ui-improvement-recover:刷新后保留请求仍用原版本与命令恢复", async () => {
api.read.mockResolvedValue({
...row(),
@ -124,6 +126,7 @@ it("NC-ui-improvement-recover:刷新后保留请求仍用原版本与命令恢
expect(api.check).not.toHaveBeenCalled();
});
// @muse-case {"case_id":"NC-ui-improvement-version","environment":"离线界面与传输替身","given":"确切方法版本、真实反馈和同作者用途配置","when":"登记具体改进、实际验证和owner启停并恢复已提交结果;记录分版本观察","then":["已改变目标不继续当前启停"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md"}
it("NC-ui-improvement-version:已改变目标不继续当前启停", async () => {
api.read.mockResolvedValue({
...row(),

View File

@ -0,0 +1,31 @@
import { afterEach, expect, it, vi } from "vitest";
import { 发起拆书 } from "../../src/功能/资料研究/数据";
afterEach(() => vi.unstubAllGlobals());
// @muse-case {"case_id":"O08-research-explicit-config","then":"空或空白配置本地拒绝且无POST,已明确配置原样传入分析请求,不使用默认"}
it("O08-research-explicit-config:分析配置必须明确指定", async () => {
const bodies: unknown[] = [];
const fetch = vi.fn(async (request: Request) => {
bodies.push(await request.json());
return new Response(JSON.stringify({ task_id: "task" }), {
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetch);
await expect(发起拆书("source", { config_id: "" })).rejects.toThrow(
"明确指定",
);
await expect(发起拆书("source", { config_id: " " })).rejects.toThrow(
"明确指定",
);
expect(fetch).not.toHaveBeenCalled();
await 发起拆书("source", {
config_id: "approved-analysis-config",
reason: "合成测试",
});
expect(bodies).toEqual([
expect.objectContaining({
config_id: "approved-analysis-config",
reason: "合成测试",
}),
]);
});

View File

@ -31,6 +31,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});
// @muse-case {"case_id":"NC-ui-work-scope","environment":"离线组件,传输替身","given":"作品A响应尚未到达","when":"切到B并交付迟到的A响应","then":["页面和请求范围保持B"],"contract":".agent/rules/测试隔离.md"}
it("NC-ui-work-scope:切作品后迟到响应不覆盖当前作品", async () => {
let finishA!: (response: Response) => void;
const delayed = new Promise<Response>((resolve) => {
@ -77,3 +78,26 @@ it("NC-ui-work-scope:切作品后迟到响应不覆盖当前作品", async ()
);
expect(screen.getByRole("button", { name: /乙的任务/ })).toBeTruthy();
});
// @muse-case {"case_id":"O08-task-list-global-dedupe","then":"全部作品列表与筛选来源共用相同查询键,只发一次同范围GET"}
it("O08-task-list-global-dedupe:全局列表取数复用", async () => {
let reads = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
if (new URL(request.url).pathname === "/api/v1/tasks") reads++;
return new Response(JSON.stringify([taskA, taskB]), {
headers: { "Content-Type": "application/json" },
});
}),
);
render(
<Ui依赖装配>
<MemoryRouter>
<Ui任务列表 />
</MemoryRouter>
</Ui依赖装配>,
);
await screen.findByRole("button", { name: /甲的任务/ });
expect(reads).toBe(1);
});

View File

@ -22,6 +22,7 @@ function 事件(类型: string, 载荷: Record<string, unknown>, 序 = 1): 任
} as 任务事件;
}
// @muse-case {"case_id":"NC-ui-dialog-stream","environment":"离线组件传输替身","given":"创作对话组件传输替身","when":"渲染与提交","then":["按合同呈现"],"contract":"docs/系统架构/新版设计/接口契约/任务工具与事件.md"}
it("NC-ui-dialog-stream:正文段落按序累积、动作事件标注", () => {
render(
<Ui依赖装配>
@ -41,6 +42,7 @@ it("NC-ui-dialog-stream:正文段落按序累积、动作事件标注", () =>
expect(screen.getByText("候选已保存")).toBeTruthy();
});
// @muse-case {"case_id":"NC-ui-dialog-empty","environment":"离线组件传输替身","given":"创作对话组件传输替身","when":"渲染与提交","then":["按合同呈现"],"contract":"docs/系统架构/新版设计/接口契约/任务工具与事件.md"}
it("NC-ui-dialog-empty:无事件时给出找回提示不空白", () => {
render(
<Ui依赖装配>
@ -50,6 +52,7 @@ it("NC-ui-dialog-empty:无事件时给出找回提示不空白", () => {
expect(screen.getByText(/页面重开后仍可找回/)).toBeTruthy();
});
// @muse-case {"case_id":"NC-ui-dialog-start","environment":"离线组件传输替身","given":"创作对话组件传输替身","when":"渲染与提交","then":["按合同呈现"],"contract":"docs/系统架构/新版设计/接口契约/任务工具与事件.md"}
it("NC-ui-dialog-start:发起成功展示任务回执", async () => {
const 请求: string[] = [];
vi.stubGlobal(

View File

@ -5,6 +5,7 @@ import { 生成字段修改, 投影表单值 } from "../../src/界面/结构表
afterEach(cleanup);
// @muse-case {"case_id":"NC-form-rebase-intent","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"本地只改名称,服务器另改摘要","when":"冲突后刷新并保存","then":["只提交作者改动,服务器新摘要保留"],"contract":"docs/系统架构/新版设计/接口契约/元数据投影与版本.md"}
it("NC-form-rebase-intent:冲突刷新只保留作者真正改动的字段", async () => {
const schema = {
type: "object",
@ -39,6 +40,7 @@ it("NC-form-rebase-intent:冲突刷新只保留作者真正改动的字段", a
fireEvent.click(screen.getByRole("button", { name: "保存档案" }));
expect(output).toEqual([{ 路径: ["名称"], 动作: "set", 值: "我改的名字" }]);
});
// @muse-case {"case_id":"NC-form-editable-projection","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"嵌套字段可见与可编辑集合不同","when":"修改一个叶子并保存","then":["隐藏值不显示,只读值不可改,提交叶子修改而非浅替换"],"contract":"docs/系统架构/新版设计/接口契约/元数据投影与版本.md"}
it("NC-form-editable-projection:嵌套字段只提交可编辑叶子并隐藏受限值", async () => {
const visible = {
type: "object",
@ -81,6 +83,7 @@ it("NC-form-editable-projection:嵌套字段只提交可编辑叶子并隐藏
expect(output).toEqual([{ 路径: ["人物", "姓名"], 动作: "set", 值: "新名" }]);
});
// @muse-case {"case_id":"NC-form-projection-refresh","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"原缓冲包含现已隐藏的字段","when":"使用新的服务器投影","then":["移除不允许显示的旧值,保留仍可编辑的输入"],"contract":"docs/系统架构/新版设计/接口契约/元数据投影与版本.md"}
it("NC-form-projection-refresh:新投影清除不允许显示的旧输入", () => {
const schema = { type: "object", properties: { 公开: { type: "string" } } };
expect(投影表单值(schema, { 公开: "保留", 秘密: "旧输入" })).toEqual({

View File

@ -4,6 +4,7 @@ import { Ui文学报告, Ui诊断报告 } from "../../src/功能/审校修订/
import type { 文学结果, 诊断结果 } from "../../src/功能/审校修订/数据";
describe("审校报告的覆盖、过期与修留问", () => {
// @muse-case {"case_id":"NC-review-report-partial","environment":"离线组件","given":"NC-review-report-partial:未审与零问题分开显示","when":"作者页面操作","then":["NC-review-report-partial:未审与零问题分开显示"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
it("NC-review-report-partial:未审与零问题分开显示", () => {
const result: 文学结果 = {
task_id: "t",
@ -31,6 +32,7 @@ describe("审校报告的覆盖、过期与修留问", () => {
expect(screen.getByRole("alert").textContent).toContain("正文已更新");
expect(screen.queryByText("完整文学报告")).toBeNull();
});
// @muse-case {"case_id":"NC-review-diagnosis-retain","environment":"离线组件","given":"NC-review-diagnosis-retain:候选预览及有意表达不显示强改","when":"作者页面操作","then":["NC-review-diagnosis-retain:候选预览及有意表达不显示强改"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
it("NC-review-diagnosis-retain:候选预览及有意表达不显示强改", () => {
const result: 诊断结果 = {
diagnosis_id: "d",

View File

@ -51,6 +51,7 @@ beforeEach(() => {
});
afterEach(cleanup);
// @muse-case {"case_id":"NC-ui-method-enable","environment":"离线组件与传输替身","given":"确切方法确认版本和明确作者身份","when":"核对启用凭据并执行当前目标的作者状态操作","then":["核对具体凭据后由作者启用当前版本"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
it("NC-ui-method-enable:核对具体凭据后由作者启用当前版本", async () => {
render(<Ui方法详情 methodId="method-a" />);
const enable = screen.getByRole("button", {
@ -79,6 +80,7 @@ it("NC-ui-method-enable:核对具体凭据后由作者启用当前版本", asy
expect(enable.disabled).toBe(true);
});
// @muse-case {"case_id":"NC-ui-method-proof-scope","environment":"离线组件与传输替身","given":"确切方法确认版本和明确作者身份","when":"核对启用凭据并执行当前目标的作者状态操作","then":["换方法或修改凭据后旧核对不再放行"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
it("NC-ui-method-proof-scope:换方法或修改凭据后旧核对不再放行", async () => {
const view = render(<Ui方法详情 methodId="method-a" />);
fireEvent.change(screen.getByLabelText("启用凭据编号"), {
@ -115,6 +117,7 @@ it("NC-ui-method-proof-scope:换方法或修改凭据后旧核对不再放行"
expect(mocks.post).not.toHaveBeenCalled();
});
// @muse-case {"case_id":"NC-ui-method-proof-error","environment":"离线组件与传输替身","given":"确切方法确认版本和明确作者身份","when":"核对启用凭据并执行当前目标的作者状态操作","then":["失效凭据的拒绝可见且不触发启用"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
it("NC-ui-method-proof-error:失效凭据的拒绝可见且不触发启用", async () => {
mocks.get.mockResolvedValue({ error: { message: "标定来源已停止" } });
render(<Ui方法详情 methodId="method-a" />);

View File

@ -52,6 +52,7 @@ beforeEach(() => {
});
afterEach(cleanup);
// @muse-case {"case_id":"NC-ui-revision-session-wait","environment":"前端","given":"合成资料、真实S02配置与明确作者授权","when":"通过实际返修会话和作者入口执行并重新读取","then":["NC-ui-revision-session-wait:任务尚未完成不能点击下一轮"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
it("NC-ui-revision-session-wait:任务尚未完成不能点击下一轮", async () => {
api.GET.mockResolvedValue({
data: {
@ -76,6 +77,7 @@ it("NC-ui-revision-session-wait:任务尚未完成不能点击下一轮", asyn
expect(api.POST).not.toHaveBeenCalled();
});
// @muse-case {"case_id":"NC-ui-revision-session-retry","environment":"前端","given":"合成资料、真实S02配置与明确作者授权","when":"通过实际返修会话和作者入口执行并重新读取","then":["NC-ui-revision-session-retry:明确重试提交精确轮次和候选后重读"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
it("NC-ui-revision-session-retry:明确重试提交精确轮次和候选后重读", async () => {
show();
const button = await screen.findByRole("button", { name: "请写手再试一轮" });
@ -117,6 +119,7 @@ it("NC-ui-revision-session-retry:明确重试提交精确轮次和候选后重
expect(screen.queryByRole("button", { name: "请写手再试一轮" })).toBeNull();
});
// @muse-case {"case_id":"NC-ui-revision-session-limit","environment":"前端","given":"合成资料、真实S02配置与明确作者授权","when":"通过实际返修会话和作者入口执行并重新读取","then":["NC-ui-revision-session-limit:轮数用完仍能选择原文且不自动采纳"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
it("NC-ui-revision-session-limit:轮数用完仍能选择原文且不自动采纳", async () => {
api.GET.mockResolvedValue({
data: {
@ -153,6 +156,7 @@ it("NC-ui-revision-session-limit:轮数用完仍能选择原文且不自动采
expect(api.POST.mock.calls[0][0]).not.toContain("writing-candidates");
});
// @muse-case {"case_id":"NC-ui-revision-session-replay","environment":"前端","given":"合成资料、真实S02配置与明确作者授权","when":"通过实际返修会话和作者入口执行并重新读取","then":["NC-ui-revision-session-replay:保存失败保留错误并用原命令重试"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
it("NC-ui-revision-session-replay:保存失败保留错误并用原命令重试", async () => {
api.POST.mockRejectedValue(new Error("连接中断"));
show();

View File

@ -42,6 +42,7 @@ const policy = {
},
};
// @muse-case {"case_id":"NC-ui-system-not-ready","environment":"前端","given":"明确隔离配置、发布包和受控测试资料","when":"经实际入口执行对应操作并读回","then":["NC-ui-system-not-ready:未知迁移和系统错误明确可见"],"contract":"docs/系统架构/新版设计/文件设计/工程配置与部署.md"}
it("NC-ui-system-not-ready:未知迁移和系统错误明确可见", async () => {
vi.stubGlobal(
"fetch",
@ -85,6 +86,7 @@ it("NC-ui-system-not-ready:未知迁移和系统错误明确可见", async ()
);
});
// @muse-case {"case_id":"NC-ui-system-redacted","environment":"前端","given":"明确隔离配置、发布包和受控测试资料","when":"经实际入口执行对应操作并读回","then":["NC-ui-system-redacted:配置摘要忽略凭据和提供方私有地址"],"contract":"docs/系统架构/新版设计/文件设计/工程配置与部署.md"}
it("NC-ui-system-redacted:配置摘要忽略凭据和提供方私有地址", async () => {
vi.stubGlobal(
"fetch",
@ -125,6 +127,7 @@ it("NC-ui-system-redacted:配置摘要忽略凭据和提供方私有地址", a
expect(screen.queryByRole("button", { name: /启用/ })).toBeNull();
});
// @muse-case {"case_id":"NC-ui-system-draft","environment":"前端","given":"明确隔离配置、发布包和受控测试资料","when":"经实际入口执行对应操作并读回","then":["NC-ui-system-draft:typed 表单只保存凭据引用且回执仍为草案"],"contract":"docs/系统架构/新版设计/文件设计/工程配置与部署.md"}
it("NC-ui-system-draft:typed 表单只保存凭据引用且回执仍为草案", async () => {
let savedBody: unknown;
vi.stubGlobal(
@ -224,6 +227,7 @@ it("NC-ui-system-draft:typed 表单只保存凭据引用且回执仍为草案"
expect(screen.queryByRole("button", { name: /启用/ })).toBeNull();
});
// @muse-case {"case_id":"NC-ui-system-inventory","environment":"前端","given":"明确隔离配置、发布包和受控测试资料","when":"经实际入口执行对应操作并读回","then":["NC-ui-system-inventory:owner 作品和任务盘点链接真实页面"],"contract":"docs/系统架构/新版设计/文件设计/工程配置与部署.md"}
it("NC-ui-system-inventory:owner 作品和任务盘点链接真实页面", async () => {
vi.stubGlobal(
"fetch",
@ -248,6 +252,7 @@ it("NC-ui-system-inventory:owner 作品和任务盘点链接真实页面", asy
);
});
// @muse-case {"case_id":"NC-ui-system-backup-unknown","environment":"前端","given":"明确隔离配置、发布包和受控测试资料","when":"经实际入口执行对应操作并读回","then":["NC-ui-system-backup-unknown:未盘点备份不冒充备份或恢复成功"],"contract":"docs/系统架构/新版设计/文件设计/工程配置与部署.md"}
it("NC-ui-system-backup-unknown:未盘点备份不冒充备份或恢复成功", async () => {
vi.stubGlobal(
"fetch",
@ -271,6 +276,7 @@ const backup = {
database_bytes: 4096,
};
// @muse-case {"case_id":"NC-ui-backup-database-bytes","environment":"前端","given":"显式隔离配置、合成数据与已知发布构建","when":"经真实维护、业务或浏览器入口执行并读取持久结果","then":["NC-ui-backup-database-bytes:零原文文件仍单独显示数据库字节"],"contract":"docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口"}
it("NC-ui-backup-database-bytes:零原文文件仍单独显示数据库字节", async () => {
vi.stubGlobal(
"fetch",
@ -284,6 +290,7 @@ it("NC-ui-backup-database-bytes:零原文文件仍单独显示数据库字节"
);
});
// @muse-case {"case_id":"NC-ui-backup-receipt-guard","environment":"前端","given":"显式隔离配置、合成数据与已知发布构建","when":"经真实维护、业务或浏览器入口执行并读取持久结果","then":["NC-ui-backup-receipt-guard:错身份与冒充恢复的回执均拒绝"],"contract":"docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口"}
it("NC-ui-backup-receipt-guard:错身份与冒充恢复的回执均拒绝", async () => {
let verifyCount = 0;
vi.stubGlobal(
@ -324,6 +331,7 @@ it("NC-ui-backup-receipt-guard:错身份与冒充恢复的回执均拒绝", as
expect(verifyCount).toBe(2);
});
// @muse-case {"case_id":"NC-ui-backup-actions","environment":"前端","given":"显式隔离配置、合成数据与已知发布构建","when":"经真实维护、业务或浏览器入口执行并读取持久结果","then":["NC-ui-backup-actions:核对和下载后只显示已取得的结果"],"contract":"docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口"}
it("NC-ui-backup-actions:核对和下载后只显示已取得的结果", async () => {
const createObjectURL = vi
.fn<(blob: Blob) => string>()
@ -377,6 +385,7 @@ it("NC-ui-backup-actions:核对和下载后只显示已取得的结果", async
expect(downloadedName).toBe(`${backup.backup_id}.tar`);
});
// @muse-case {"case_id":"NC-ui-backup-unreadable","environment":"前端","given":"显式隔离配置、合成数据与已知发布构建","when":"经真实维护、业务或浏览器入口执行并读取持久结果","then":["NC-ui-backup-unreadable:坏清单明确告知且不提供操作"],"contract":"docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口"}
it("NC-ui-backup-unreadable:坏清单明确告知且不提供操作", async () => {
vi.stubGlobal(
"fetch",

View File

@ -18,6 +18,7 @@ afterEach(() => {
const 空模式 = { type: "object", properties: {}, required: [] };
// @muse-case {"case_id":"NC-plan-rebase-await-fresh","environment":"Vitest真实组件;HTTP传输替身不算真实业务","given":"规划候选保存遇到版本冲突,最新表单刷新仍在途","when":"冲突提示刚出现就点击重看并再次保存","then":["重看等待最新正式版本,保存携带新基线且提示已保存"],"contract":"docs/系统架构/新版设计/接口契约/命令与作者决策.md"}
it("NC-plan-rebase-await-fresh:冲突后立即重看并保存,携带最新正式版本", async () => {
const 保存请求: unknown[] = [];
let 表单次数 = 0;
@ -115,6 +116,7 @@ it("NC-plan-rebase-await-fresh:冲突后立即重看并保存,携带最新
expect(第二次.request.expected_revision).toBe(2);
});
// @muse-case {"case_id":"NC-plan-model-task-retry","environment":"Vitest真实组件;HTTP传输替身不算真实业务","given":"真实规划组件与传输替身;首次创建回执为未知503","when":"作者重试同一次创建","then":["重用相同命令、配置与基线;只显示已创建任务,不发送候选采纳"],"contract":"docs/系统架构/新版设计/模块设计/B01-作品规划.md"}
it("NC-plan-model-task-retry:规划创建回执未知时重试原命令,不自动采纳", async () => {
const 请求集: Record<string, unknown>[] = [];
let 正式写入 = 0;

View File

@ -15,6 +15,7 @@ afterEach(() => {
vi.clearAllMocks();
});
// @muse-case {"case_id":"NC-rule-activation-review","environment":"离线","given":"固定请求、来源与作者;按用例环境准备独立测试输入","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-rule-activation-review:读取和打开审阅都不代作者启用,同次决定可重试"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
it("NC-rule-activation-review:读取和打开审阅都不代作者启用,同次决定可重试", async () => {
api.GET.mockResolvedValue({
data: { rule_id: "l002", current: null, consumable: false, reason: null },
@ -52,6 +53,7 @@ it("NC-rule-activation-review:读取和打开审阅都不代作者启用,同
expect(api.POST.mock.calls[2][1].body).toEqual(first);
});
// @muse-case {"case_id":"NC-rule-activation-old-version","environment":"离线","given":"固定请求、来源与作者;按用例环境准备独立测试输入","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-rule-activation-old-version:候选已更新时停用请求仍点名实际生效旧版"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
it("NC-rule-activation-old-version:候选已更新时停用请求仍点名实际生效旧版", async () => {
api.GET.mockResolvedValue({
data: {

View File

@ -221,6 +221,7 @@ beforeEach(() => {
});
afterEach(cleanup);
// @muse-case {"case_id":"NC-规则改善-反馈提案与方法分流","environment":"离线","given":"固定请求、来源与作者;按用例环境准备独立测试输入","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-规则改善-反馈提案与方法分流:使用真实反馈和服务端规则哈希,方法仍走原入口"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md#有来源的运行观察"}
it("NC-规则改善-反馈提案与方法分流:使用真实反馈和服务端规则哈希,方法仍走原入口", async () => {
mount("/improvements/new?feedback=feedback-1");
await screen.findByText("人物口头禅应当保留");
@ -288,6 +289,7 @@ it("NC-规则改善-反馈提案与方法分流:使用真实反馈和服务端
});
});
// @muse-case {"case_id":"NC-规则改善-先审后入与未知响应","environment":"离线","given":"固定请求、来源与作者;按用例环境准备独立测试输入","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-规则改善-先审后入与未知响应:作者再次确认才启用,重试复用完整原请求"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md#有来源的运行观察"}
it("NC-规则改善-先审后入与未知响应:作者再次确认才启用,重试复用完整原请求", async () => {
current.consumption = [
{
@ -377,6 +379,7 @@ it("NC-规则改善-先审后入与未知响应:作者再次确认才启用,
).toHaveLength(1);
});
// @muse-case {"case_id":"NC-规则改善-刷新恢复","environment":"离线","given":"固定请求、来源与作者;按用例环境准备独立测试输入","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-规则改善-刷新恢复:保留的待处理请求沿用原定位和审阅,不重新审阅"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md#有来源的运行观察"}
it("NC-规则改善-刷新恢复:保留的待处理请求沿用原定位和审阅,不重新审阅", async () => {
const command: 规则改进命令 = {
command_id: "persisted-command",
@ -420,6 +423,7 @@ it("NC-规则改善-刷新恢复:保留的待处理请求沿用原定位和审
);
});
// @muse-case {"case_id":"NC-规则改善-旧版停用","environment":"离线","given":"固定请求、来源与作者;按用例环境准备独立测试输入","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-规则改善-旧版停用:可停用本提案仍生效的旧版,不得停其他版本或重新启用旧版"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md#有来源的运行观察"}
it("NC-规则改善-旧版停用:可停用本提案仍生效的旧版,不得停其他版本或重新启用旧版", async () => {
current.is_current_target = false;
current.target_state = "active";

View File

@ -71,6 +71,7 @@ function 报告(id = "sample-only"): 实验报告 {
};
}
// @muse-case {"case_id":"NC-ui-evaluation-report-null","environment":"前端组件,传输替身","given":"实验报告页面","when":"读取或切换实验并核对显示","then":["未知成本、固定覆盖分母和未标定状态准确呈现"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
it("NC-ui-evaluation-report-null:未知成本和未标定不显示为零或通过", () => {
render(<Ui实验报告 内容={报告()} />);
expect(screen.getByText("实际总费用").nextElementSibling?.textContent).toBe(
@ -91,6 +92,7 @@ it("NC-ui-evaluation-report-null:未知成本和未标定不显示为零或通
expect(screen.getByText("比较未完成 1 例")).toBeTruthy();
});
// @muse-case {"case_id":"NC-ui-evaluation-primary-pair","environment":"前端组件,传输替身","given":"ABC逐例报告","when":"呈现主比较与两组诊断结果","then":["明确显示A/C主比较,不以B在其他对照中的胜出覆盖主结果"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
it("NC-ui-evaluation-primary-pair:ABC主比较和诊断比较分开显示", () => {
const r = 报告();
r.primary_pair = ["A", "C"];
@ -105,6 +107,7 @@ it("NC-ui-evaluation-primary-pair:ABC主比较和诊断比较分开显示", ()
).toBeTruthy();
});
// @muse-case {"case_id":"NC-ui-evaluation-report-switch","environment":"前端组件,传输替身","given":"实验报告页面","when":"读取或切换实验并核对显示","then":["查询隔离后迟到响应不覆盖当前实验"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
it("NC-ui-evaluation-report-switch:切换实验后迟到报告不能覆盖当前实验", async () => {
let finishA!: (response: Response) => void;
const delayed = new Promise<Response>((resolve) => {
@ -151,6 +154,7 @@ it("NC-ui-evaluation-report-switch:切换实验后迟到报告不能覆盖当
expect(screen.getByRole("heading", { name: /sample-B/ })).toBeTruthy();
});
// @muse-case {"case_id":"NC-ui-evaluation-quotes","environment":"前端组件传输替身","given":"两侧已经反向的定位报告","when":"展开判断理由和引文","then":["使用固定两侧映射显示实际C/A,保留换行;原文中的HTML按文本显示"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
it("NC-ui-evaluation-quotes:换序后的真实实验臂与原字引文正确对应", () => {
const r = 报告();
r.samples[0].decisions = [
@ -204,6 +208,7 @@ it("NC-ui-evaluation-quotes:换序后的真实实验臂与原字引文正确
expect(screen.getByText("动作的因果顺序更清楚。")).toBeTruthy();
});
// @muse-case {"case_id":"NC-ui-evaluation-corrections","environment":"前端组件传输替身","given":"已发生拒绝且纠正上限为零的报告","when":"展开执行和费用记录","then":["零上限、拒绝与已发生费用仍显示,不显示为已启用"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
it("NC-ui-evaluation-corrections:零纠正上限和已发生拒绝均可见", () => {
const r = 报告();
r.samples[0].units = [
@ -254,6 +259,7 @@ it("NC-ui-evaluation-corrections:零纠正上限和已发生拒绝均可见",
);
});
// @muse-case {"case_id":"NC-ui-evaluation-contract-failure","environment":"前端组件传输替身","given":"已持久化的非引文判断合同拒绝","when":"展开失败原因","then":["说明合同不完整且未自动纠正,不误称引文缺失"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
it("NC-ui-evaluation-contract-failure:非引文拒绝不误报为缺少引文", () => {
const r = 报告();
r.samples[0].units = [

View File

@ -0,0 +1,215 @@
import { webcrypto } from "node:crypto";
import { afterEach, expect, it, vi } from "vitest";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { Ui依赖装配 } from "../../src/应用/依赖装配";
import { Ui任务详情 } from "../../src/功能/任务运行/任务详情";
import type { 任务 } from "../../src/功能/任务运行/数据";
vi.mock("../../src/功能/上下文", () => ({ Ui任务上下文: () => null }));
vi.mock("../../src/功能/作者经验/运行观察", () => ({ Ui运行观察: () => null }));
const task: 任务 = {
task_id: "cancelled-task",
state: "cancelled",
work_id: "work",
task_type: "生成正文",
supports_step_advance: false,
last_sequence: 9,
execution_final_sequence: 9,
accounting_revision: 0,
result_refs: [],
steps: [],
};
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
sessionStorage.clear();
});
// @muse-case {"case_id":"O08-accounting-after-cancel-replay","then":"取消任务未知账目补录在刷新后重放相同请求,回执后读到新账目版本,任务仍取消且无持续SSE或费用轮询"}
it("O08-accounting-after-cancel-replay:取消后补账与原请求恢复不复活执行", async () => {
vi.stubGlobal("crypto", webcrypto);
const eventSource = vi.fn(() => {
throw new Error("终态不得建立SSE");
});
vi.stubGlobal("EventSource", eventSource);
const sent: Record<string, unknown>[] = [];
let revision = 0,
reads = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const path = new URL(request.url).pathname;
let data: unknown;
if (path.endsWith("session")) data = { author_id: "author" };
else if (path.endsWith("/events"))
data = {
items: [],
reset_required: false,
first_sequence: 1,
last_sequence: 9,
execution_final_sequence: 9,
};
else if (path.endsWith("/settlements")) {
const body = await request.json();
sent.push(body);
if (sent.length === 1) throw new Error("lost response");
revision = 1;
data = {
...body,
author_id: "author",
task_id: task.task_id,
call_id: "call",
receipt_id: "settled-receipt",
state: "cancelled",
accounting_revision: 1,
execution_final_sequence: 9,
};
} else if (path.endsWith("/calls")) {
reads++;
data = {
task_id: task.task_id,
state: "cancelled",
accounting_revision: revision,
execution_final_sequence: 9,
calls: [
{
call_id: "call",
attempt_id: "attempt",
role_id: "writer",
state: revision ? "settled" : "unknown",
actual_amount: revision ? "0.12" : null,
reserved_amount: "1",
receipt_id: revision ? "settled-receipt" : null,
delivery_state: "missing",
response_evidence: [],
},
],
};
} else throw new Error(`非预期读取 ${path}`);
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}),
);
const mount = () =>
render(
<Ui依赖装配>
<Ui任务详情 task={task} scope="work" />
</Ui依赖装配>,
);
const first = mount();
fireEvent.change(await screen.findByLabelText("实际费用 USD"), {
target: { value: "0.12" },
});
fireEvent.change(screen.getByLabelText("费用来源引用"), {
target: { value: "provider-statement-1" },
});
fireEvent.change(screen.getByLabelText("费用依据原文"), {
target: { value: "调用call实际费用0.12 USD" },
});
fireEvent.click(screen.getByRole("button", { name: "确认并补录费用" }));
await screen.findByRole("button", { name: "重试原账目请求" });
await screen.findByText("暂时无法连接工作台,请稍后重试。");
first.unmount();
mount();
fireEvent.click(
await screen.findByRole("button", { name: "重试原账目请求" }),
);
await screen.findByText(/已结 0.12 USD/);
expect(sent).toHaveLength(2);
expect(sent[1]).toEqual(sent[0]);
expect(sent[0]).toMatchObject({
expected_revision: 0,
amount: "0.12",
source_kind: "author_attestation",
source_statement: "调用call实际费用0.12 USD",
});
expect(sent[0].source_hash).toMatch(/^[0-9a-f]{64}$/);
expect(screen.getByText(/账目版本 1/)).toBeTruthy();
expect(screen.getByText("已取消")).toBeTruthy();
expect(eventSource).not.toHaveBeenCalled();
const previous = reads;
vi.useFakeTimers();
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(reads).toBe(previous);
fireEvent.click(screen.getByRole("button", { name: "刷新调用账目" }));
await act(() => vi.advanceTimersByTimeAsync(0));
expect(reads).toBe(previous + 1);
expect(screen.queryByRole("button", { name: "核对原调用响应" })).toBeNull();
expect(sessionStorage.length).toBe(0);
});
// @muse-case {"case_id":"O08-task-persisted-result-reference","then":"终态空历史仍从快照展示候选按钮,点击前不读候选全文,点击后读取单项详情,服务端拒绝时不绕过核验"}
it("O08-task-persisted-result-reference:不用重放事件找回生成候选", async () => {
const withResult: 任务 = {
...task,
result_refs: [
{
kind: "writing_candidate",
candidate_id: "persisted-candidate",
chapter_id: "chapter",
branch_id: "main",
},
],
};
const readCandidates: string[] = [];
vi.stubGlobal(
"EventSource",
vi.fn(() => {
throw new Error("终态不订阅");
}),
);
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const path = new URL(request.url).pathname;
let data: unknown;
if (path.endsWith("session")) data = { author_id: "author" };
else if (path.endsWith("/events"))
data = {
items: [],
reset_required: false,
first_sequence: 10,
last_sequence: 9,
};
else if (path.endsWith("/calls"))
data = {
task_id: task.task_id,
state: "cancelled",
accounting_revision: 0,
execution_final_sequence: 9,
calls: [],
};
else {
readCandidates.push(path);
return new Response(
JSON.stringify({
code: "SOURCE_STALE",
message: "合成候选当前需重新核验",
}),
{ status: 409, headers: { "Content-Type": "application/json" } },
);
}
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}),
);
render(
<Ui依赖装配>
<Ui任务详情 task={withResult} scope="work" />
</Ui依赖装配>,
);
const open = await screen.findByRole("button", { name: "打开生成候选" });
expect(readCandidates).toEqual([]);
fireEvent.click(open);
await screen.findByRole("heading", { name: "候选检查与决定" });
expect(readCandidates).toContain(
"/api/v1/writing-candidates/persisted-candidate",
);
});

View File

@ -40,6 +40,7 @@ function show() {
);
}
// @muse-case {"case_id":"NC-run-observation-retry","environment":"离线","given":"固定请求、来源与作者;按用例环境准备独立测试输入","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-run-observation-retry:先核来源,失败保留原话并重试同一观察命令"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md#有来源的运行观察"}
it("NC-run-observation-retry:先核来源,失败保留原话并重试同一观察命令", async () => {
api.POST.mockResolvedValueOnce({ data: source });
show();
@ -75,6 +76,7 @@ it("NC-run-observation-retry:先核来源,失败保留原话并重试同一
expect(body.observation).not.toHaveProperty("state");
});
// @muse-case {"case_id":"NC-run-observation-source","environment":"离线","given":"固定请求、来源与作者;按用例环境准备独立测试输入","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-run-observation-source:不同任务或尝试的读回不能成为保存依据"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md#有来源的运行观察"}
it("NC-run-observation-source:不同任务或尝试的读回不能成为保存依据", async () => {
api.POST.mockResolvedValue({
data: { ...source, runtime: { ...source.runtime, task_id: "other-task" } },

View File

@ -32,6 +32,7 @@ function answer(value: unknown) {
headers: { "Content-Type": "application/json" },
});
}
// @muse-case {"case_id":"NC-revision-proof-incomplete","environment":"离线","given":"具名作者、固定来源、隔离测试配置与真实模块存储","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-revision-proof-incomplete:缺判断和未标定保持可见,读取不派发或采纳"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
it("NC-revision-proof-incomplete:缺判断和未标定保持可见,读取不派发或采纳", async () => {
const calls: Request[] = [];
vi.stubGlobal(
@ -56,6 +57,7 @@ it("NC-revision-proof-incomplete:缺判断和未标定保持可见,读取不
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe("GET");
});
// @muse-case {"case_id":"NC-revision-proof-scope","environment":"离线","given":"具名作者、固定来源、隔离测试配置与真实模块存储","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-revision-proof-scope:其他会话的证明不能显示为当前候选的建议"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
it("NC-revision-proof-scope:其他会话的证明不能显示为当前候选的建议", async () => {
vi.stubGlobal(
"fetch",
@ -80,6 +82,7 @@ it("NC-revision-proof-scope:其他会话的证明不能显示为当前候选
expect(screen.queryByText(/当前建议/)).toBeNull();
});
// @muse-case {"case_id":"NC-revision-proof-selection","environment":"离线","given":"具名作者、固定来源、隔离测试配置与真实模块存储","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-revision-proof-selection:作者选段的二版比较记录仍绑定本轮确切候选"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
it("NC-revision-proof-selection:作者选段的二版比较记录仍绑定本轮确切候选", async () => {
vi.stubGlobal(
"fetch",
@ -100,6 +103,7 @@ it("NC-revision-proof-selection:作者选段的二版比较记录仍绑定本
await screen.findByText(/当前建议保留原文/);
});
// @muse-case {"case_id":"NC-revision-proof-candidate","environment":"离线","given":"具名作者、固定来源、隔离测试配置与真实模块存储","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-revision-proof-candidate:同会话上一轮候选的证明不能冒充本轮结果"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
it("NC-revision-proof-candidate:同会话上一轮候选的证明不能冒充本轮结果", async () => {
vi.stubGlobal(
"fetch",

View File

@ -9,6 +9,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});
// @muse-case {"case_id":"NC-ui-session-close","environment":"离线组件,传输替身","given":"已连接的作者页面","when":"点击断开并收到成功响应","then":["作者会话归零,任务页面卸载并回到连接表单"],"contract":"docs/系统架构/新版设计/工作台设计.md"}
it("NC-ui-session-close:退出成功卸载任务并回到连接表单", async () => {
vi.stubGlobal(
"fetch",

View File

@ -22,6 +22,7 @@ const p = (id: string, text: string) =>
schema.node("paragraph", { paragraph_id: id }, [schema.text(text)]);
describe("作者真实圈选", () => {
// @muse-case {"case_id":"NC-selection-location","environment":"离线浏览器组件","given":"真实ProseMirror文档位置","when":"捕获浏览器选择并转换码点","then":["NC-selection-location:重复原句按第二段定位"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-selection-location:重复原句按第二段定位", () => {
const doc = schema.node("doc", null, [
p("p1", "雨停了。"),
@ -31,6 +32,7 @@ describe("作者真实圈选", () => {
{ paragraph_id: "p2", start: 0, end: 4, original: "雨停了。" },
]);
});
// @muse-case {"case_id":"NC-selection-unicode","environment":"离线浏览器组件","given":"真实ProseMirror文档位置","when":"捕获浏览器选择并转换码点","then":["NC-selection-unicode:表情、组合字符和CRLF不使用UTF16假码点"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-selection-unicode:表情、组合字符和CRLF不使用UTF16假码点", () => {
const doc = schema.node("doc", null, [
schema.node("paragraph", { paragraph_id: "p" }, [
@ -44,6 +46,7 @@ describe("作者真实圈选", () => {
]);
expect(() => 捕获选区(doc, 2, 3)).toThrow(/Unicode/);
});
// @muse-case {"case_id":"NC-selection-style","environment":"离线浏览器组件","given":"真实ProseMirror文档位置","when":"捕获浏览器选择并转换码点","then":["NC-selection-style:跨样式圈选拆成相邻明确选段"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-selection-style:跨样式圈选拆成相邻明确选段", () => {
const doc = schema.node("doc", null, [
schema.node("paragraph", { paragraph_id: "p" }, [

View File

@ -12,6 +12,7 @@ afterEach(() => {
sessionStorage.clear();
});
// @muse-case {"case_id":"TC-00f820aca9ee","environment":"离线组件测试;正式写入另有真后端旅程","when":"渲染明确数据并执行作者动作。","contract":"docs/系统架构/新版设计/工作台设计.md"}
it("TC-00f820aca9ee:规划标题和摘要从载荷提取", () => {
const 载荷 = { targetChapter: 2, chapterGoal: "林深被隔离审讯", chapterEndHook: "茧准备出击" };
const { 标题, 摘要 } = 规划条目摘要(载荷);
@ -27,7 +28,8 @@ it("TC-00f820aca9ee:规划标题和摘要从载荷提取", () => {
expect(规划条目摘要(undefined).标题).toBe("规划候选");
});
it("TC-00f820aca9ee:候选列表展示提取结果且决定动作发真实请求", async () => {
// @muse-case {"case_id":"TC-00f820aca9ee-动作","environment":"离线组件测试;正式写入另有真后端旅程","when":"渲染明确数据并执行作者动作。","contract":"docs/系统架构/新版设计/工作台设计.md"}
it("TC-00f820aca9ee-动作:候选列表展示提取结果且决定动作发真实请求", async () => {
const 请求: { 方法: string; 地址: string; 体: unknown }[] = [];
const 候选载荷 = { targetChapter: 2, chapterGoal: "林深被隔离审讯" };
vi.stubGlobal(
@ -127,6 +129,7 @@ it("TC-00f820aca9ee:候选列表展示提取结果且决定动作发真实请
});
// @muse-case {"case_id":"TC-f9bc3a8d21cf","environment":"离线组件测试;正式写入另有真后端旅程","when":"渲染明确数据并执行作者动作。","contract":"docs/系统架构/新版设计/工作台设计.md"}
it("TC-f9bc3a8d21cf:诊断结果展示正式正文汇总与来源引文", () => {
render(
<Ui诊断报告
@ -159,6 +162,7 @@ it("TC-f9bc3a8d21cf:诊断结果展示正式正文汇总与来源引文", () =
expect(screen.getByText("值得注意的是")).toBeTruthy();
});
// @muse-case {"case_id":"TC-ea9587d92a04","environment":"离线组件测试;正式写入另有真后端旅程","when":"渲染明确数据并执行作者动作。","contract":"docs/系统架构/新版设计/工作台设计.md"}
it("TC-ea9587d92a04:正文依据变化时渲染失败关闭而非崩溃", () => {
render(
<Ui诊断报告
@ -190,6 +194,7 @@ it("TC-ea9587d92a04:正文依据变化时渲染失败关闭而非崩溃", () =
expect(screen.queryByText(/NameError/)).toBeNull();
});
// @muse-case {"case_id":"TC-8098487cc24d","environment":"离线组件测试;正式写入另有真后端旅程","when":"渲染明确数据并执行作者动作。","contract":"docs/系统架构/新版设计/工作台设计.md"}
it("TC-8098487cc24d:作品定位与命中详情随诊断结果展示", () => {
render(
<Ui诊断报告

View File

@ -0,0 +1,15 @@
import { expect, it } from "vitest";
import { 核对旅程环境 } from "../../旅程环境";
// @muse-case {"case_id":"O08-journey-explicit-environment","then":"真实旅程缺少隔离标志、明确URL或现有浏览器路径时立即拒绝,不默认连接localhost"}
it("O08-journey-explicit-environment:缺环境直接失败", () => {
expect(() => 核对旅程环境({})).toThrow("隔离");
expect(() => 核对旅程环境({ MUSE_ISOLATED_TEST_ENVIRONMENT: "1" })).toThrow(
"URL",
);
expect(() =>
核对旅程环境({
MUSE_ISOLATED_TEST_ENVIRONMENT: "1",
MUSE_WORKBENCH_URL: "http://127.0.0.1:9999",
}),
).toThrow("MUSE_BROWSER_EXECUTABLE");
});

View File

@ -0,0 +1,32 @@
import { afterEach, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { useMuse正文路由, 正文路由 } from "../../src/功能/正文写作/路由参数";
afterEach(cleanup);
function Probe() {
const value = useMuse正文路由();
return <output>{JSON.stringify(value)}</output>;
}
// @muse-case {"case_id": "O01-route-raw-identity", "environment": "离线路由单元", "given": "作品、章、分支含中文与保留字符", "when": "构造正文与候选路由并渲染链接", "then": ["路由使用解码身份", "构造链接只编码一次"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-route-raw-identity:正文与候选路由使用解码身份且构造链接只编码一次", () => {
const work = "作品:甲%一",
chapter = "章#二 空格",
branch = "分支/B?%末";
const url = 正文路由(work, chapter, branch, "candidates");
expect(url).toContain(encodeURIComponent(chapter));
render(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route
path="/works/:work_id/candidates/:chapter_id"
element={<Probe />}
/>
</Routes>
</MemoryRouter>,
);
expect(JSON.parse(screen.getByRole("status").textContent!)).toEqual({
work_id: work,
chapter_id: chapter,
branch,
});
});

View File

@ -4,7 +4,8 @@ import { expect, test } from "@playwright/test";
test.use({ trace: "off" });
test("NC-browser-world-workbench:世界维护三页旅程", async ({ page }, info) => {
// @muse-case {"case_id":"NC-w19-browser","environment":"真实隔离库 + Chrome","given":"隔离浏览器环境与预置作品/实体","when":"浏览世界资料/人物关系/时间伏笔三页并展开轨迹与影响","then":["实体与轨迹可见","弧线分区呈现","影响计数可见"],"contract":"docs/系统架构/新版设计/模块设计/B02-故事世界.md"}
test("NC-w19-browser:世界维护三页旅程", async ({ page }, info) => {
if (!process.env.MUSE_WORKBENCH_URL || !process.env.MUSE_AUTHOR_PASSWORD_FILE)
throw new Error("需要明确的隔离后端和作者口令文件。");
const workId = process.env.MUSE_WORK_ID!;

View File

@ -5,6 +5,7 @@ import { expect, test } from "@playwright/test";
// 登录包含本批访问口令,不将认证操作写入trace或HAR。
test.use({trace:"off"});
// @muse-case {"case_id":"NC-browser-manual-writing","environment":"Playwright Chromium、真实HTTP、隔离PostgreSQL;无模型调用","given":"已安装应用、明确的独立后端及作者口令文件","when":"浏览器创建作品章节,输入中文,重开、并发保存、回退、恢复断网缓冲","then":["页面与真实PG版本一致,原文和段落保持,冲突和离线输入不丢失"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
test("NC-browser-manual-writing:创建手写重开冲突回退和断网恢复", async ({page,context},info)=>{
if (!process.env.MUSE_WORKBENCH_URL || !process.env.MUSE_AUTHOR_PASSWORD_FILE)
throw new Error("浏览器验收必须指定独立后端与口令文件,不使用默认业务连接。");

View File

@ -4,6 +4,7 @@ import { expect, test } from "@playwright/test";
test.use({ trace: "off" });
// @muse-case {"case_id":"NC-browser-context-workbench","environment":"真实浏览器","given":"真实隔离库上的工作台与一处真实漂移","when":"真实浏览器登录并打开任务详情","then":["材料清单/来源证据/失效说明可见(DOM 断言 24 条,页面与控制台错误 0);截图视觉判定四项清晰可读"],"contract":"docs/系统架构/新版设计/接口契约/任务工具与事件.md"}
test("NC-browser-context-workbench:任务材料清单、来源证据与失效说明", async ({
page,
}, info) => {

View File

@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-task-execution","environment":"浏览器","given":"显式作者、隔离配置、合成数据与已知发布构建","when":"经真实维护、业务或浏览器入口执行并读取持久结果","then":["NC-browser-task-execution:批准额度后单步执行到真实候选"],"contract":"docs/系统架构/新版设计/模块设计/S02-任务运行.md"}
test("NC-browser-task-execution:批准额度后单步执行到真实候选", async ({ page }, info) => {
const file = process.env.MUSE_AUTHOR_PASSWORD_FILE;
const taskId = process.env.MUSE_EXECUTION_TASK_ID;

View File

@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-author-preference","environment":"浏览器与隔离PG","given":"真实作者、作品及确切正文反馈版本","when":"保存范围偏好、审阅冲突、确认、撤回及重新消费","then":["原稿反馈经审阅确认并撤回"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md"}
test("NC-browser-author-preference:原稿反馈经审阅确认并撤回", async ({
page,
}, info) => {

View File

@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
test.use({ trace: "off" });
// @muse-case {"case_id":"NC-browser-author-review","environment":"真实浏览器与隔离PG","given":"NC-browser-author-review:规则诊断与声音确认使用真实后端","when":"作者页面操作","then":["NC-browser-author-review:规则诊断与声音确认使用真实后端"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
test("NC-browser-author-review:规则诊断与声音确认使用真实后端", async ({
page,
}, info) => {

View File

@ -4,6 +4,7 @@ import { expect, test } from "@playwright/test";
test.use({ trace: "off" });
// @muse-case {"case_id":"NC-browser-candidate-review","environment":"Playwright Chromium、实际HTTP和隔离PG;无模型调用","given":"安装后的真实后端、专用PG、中文正文和作者会话","when":"另拟改稿、刷新恢复、保存候选、按段选择、两个审阅页面竞争、采纳并重开正文","then":["候选修改不写正文,旧审阅冲突,采纳追加正文且保留未选段落"],"contract":"docs/系统架构/新版设计/接口契约/命令与作者决策.md"}
test("NC-browser-candidate-review:改稿恢复部分选择旧审阅冲突与真实采纳", async ({
page,
context,

View File

@ -1,6 +1,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-concrete-improvement","environment":"真实浏览器与隔离PG","given":"确切方法版本、真实反馈和同作者用途配置","when":"登记具体改进、实际验证和owner启停并恢复已提交结果;记录分版本观察","then":["真实反馈到具体方法提案且无凭据不启用"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md"}
test("NC-browser-concrete-improvement:真实反馈到具体方法提案且无凭据不启用", async ({
page,
}, info) => {

View File

@ -0,0 +1,97 @@
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
test.use({ trace: "off" });
// @muse-case {"case_id":"O08-browser-branch-buffer-lifecycle","then":"真实浏览器中同章两分支草稿刷新不串用,未知保存刷新重放原请求;终态任务没有持续事件请求"}
test("O08-browser-branch-buffer-lifecycle:分支缓冲与终态读取旅程", async ({
page,
}, info) => {
test.setTimeout(120_000);
const work = process.env.MUSE_WORK_ID,
chapter = process.env.MUSE_CHAPTER_ID,
taskId = process.env.MUSE_TERMINAL_TASK_ID;
const branchA = process.env.MUSE_BRANCH_A,
branchB = process.env.MUSE_BRANCH_B;
if (
!work ||
!chapter ||
!taskId ||
!branchA ||
!branchB ||
branchA === branchB
)
throw new Error(
"需要预置合成作品、同章两个独立分支与已completed/cancelled任务身份。",
);
await page.goto("/");
await page
.getByLabel("访问口令")
.fill(readFileSync(process.env.MUSE_AUTHOR_PASSWORD_FILE!, "utf8").trim());
await page.getByRole("button", { name: "进入工作台" }).click();
const route = (branch: string) =>
`/works/${encodeURIComponent(work)}/candidates/${encodeURIComponent(chapter)}?branch=${encodeURIComponent(branch)}`;
await page.goto(route(branchA));
await page.getByRole("button", { name: "另拟改稿" }).click();
const aText = `A分支合成稿-${Date.now()}`;
await page.getByRole("textbox", { name: "候选改稿" }).fill(aText);
await page.goto(route(branchB));
await page.getByRole("button", { name: "另拟改稿" }).click();
await expect(
page.getByRole("textbox", { name: "候选改稿" }),
).not.toContainText(aText);
const bText = `B分支合成稿-${Date.now()}`;
await page.getByRole("textbox", { name: "候选改稿" }).fill(bText);
await page.reload();
await page.getByRole("button", { name: "另拟改稿" }).click();
await expect(page.getByRole("textbox", { name: "候选改稿" })).toContainText(
bText,
);
await page.goto(route(branchA));
await page.getByRole("button", { name: "另拟改稿" }).click();
await expect(page.getByRole("textbox", { name: "候选改稿" })).toContainText(
aText,
);
const requests: unknown[] = [];
let lose = true;
await page.route(
`**/api/v1/chapters/${encodeURIComponent(chapter)}/writing-candidates`,
async (intercepted) => {
if (intercepted.request().method() !== "POST") {
await intercepted.continue();
return;
}
requests.push(intercepted.request().postDataJSON());
const response = await intercepted.fetch();
if (lose) {
lose = false;
await intercepted.abort("failed");
} else await intercepted.fulfill({ response });
},
);
await page.getByRole("button", { name: "保存候选版本", exact: true }).click();
await expect(
page.getByRole("button", { name: "重试候选保存" }),
).toBeVisible();
await page.reload();
await page.getByRole("button", { name: "另拟改稿" }).click();
await page.getByRole("button", { name: "重试候选保存" }).click();
await expect(page.getByText(/候选新版本已保存/)).toBeVisible();
expect(requests).toHaveLength(2);
expect(requests[1]).toEqual(requests[0]);
const streamRequests: string[] = [],
taskReads: string[] = [];
page.on("request", (request) => {
if (request.url().includes("/events/stream"))
streamRequests.push(request.url());
if (new URL(request.url()).pathname === `/api/v1/tasks/${taskId}`)
taskReads.push(request.url());
});
await page.goto(`/tasks?task=${encodeURIComponent(taskId)}`);
await expect(page.getByText("执行已收尾")).toBeVisible();
await page.waitForTimeout(60_000);
expect(streamRequests).toHaveLength(0);
expect(taskReads).toHaveLength(1);
await page.screenshot({
path: info.outputPath("终态执行与独立账目.png"),
fullPage: true,
});
});

View File

@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-dynamic-world","environment":"隔离PG/合成HTTP及浏览器","given":"具名作者、固定来源、隔离测试配置与真实模块存储","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-browser-dynamic-world:新类型具名编辑确认后刷新并再次修改"],"contract":"docs/系统架构/新版设计/模块设计/B02-故事世界.md#作者实体表单与字段提案"}
test("NC-browser-dynamic-world:新类型具名编辑确认后刷新并再次修改", async ({
page,
}, info) => {

View File

@ -1,6 +1,7 @@
import { readFileSync, statSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-backup-roundtrip","environment":"浏览器","given":"显式隔离配置、合成数据与已知发布构建","when":"经真实维护、业务或浏览器入口执行并读取持久结果","then":["NC-browser-backup-roundtrip:真实备份核对与下载"],"contract":"docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口"}
test("NC-browser-backup-roundtrip:真实备份核对与下载", async ({
page,
}, info) => {

View File

@ -1,6 +1,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-delivery-serial","environment":"真实浏览器与隔离PG","given":"本作者完整目录、确切正文版本与独立隔离数据","when":"通过定稿、S02导出、跨章候选或连载反馈实际入口执行并检查失败恢复","then":["全书定稿实际导出与版本反馈"],"contract":"docs/系统架构/新版设计/模块设计/B08-交付连载.md"}
test("NC-browser-delivery-serial:全书定稿实际导出与版本反馈", async ({
page,
}, info) => {

View File

@ -3,6 +3,7 @@ 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,
@ -74,6 +75,7 @@ test("NC-browser-private-exploration:中文缓冲恢复冲突与独立私人
);
});
// @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) => {
@ -144,6 +146,7 @@ test("NC-browser-exploration-selection:明确选定后保留不可变来源",
).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,
@ -275,6 +278,7 @@ test("NC-browser-plan-approval:动态大纲候选确认与双页冲突", async
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) => {

View File

@ -1,6 +1,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-method-enable","environment":"真实浏览器与隔离PG/S01","given":"确切方法确认版本和明确作者身份","when":"核对启用凭据并执行当前目标的作者状态操作","then":["核对已测版本并明确启用写手用途"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
test("NC-browser-method-enable:核对已测版本并明确启用写手用途", async ({
page,
}, info) => {

View File

@ -4,7 +4,8 @@ import { expect, test } from "@playwright/test";
test.use({ trace: "off" });
test("NC-browser-method-library:方法库与绑定旅程", async ({ page }, info) => {
// @muse-case {"case_id":"NC-w20-browser","environment":"真实隔离库 + Chrome","given":"隔离浏览器环境与已启用绑定方法","when":"浏览方法库与版本绑定页并执行混合检索","then":["清单与类型过滤可见","检索命中并标注绑定","绑定固定版本可见"],"contract":"docs/系统架构/新版设计/模块设计/B04-知识方法.md"}
test("NC-w20-browser:方法库与绑定旅程", async ({ page }, info) => {
if (!process.env.MUSE_WORKBENCH_URL || !process.env.MUSE_AUTHOR_PASSWORD_FILE)
throw new Error("需要明确的隔离后端和作者口令文件。");
const workId = process.env.MUSE_WORK_ID!;

View File

@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-revision-session","environment":"浏览器","given":"合成资料、真实S02配置与明确作者授权","when":"通过实际返修会话和作者入口执行并重新读取","then":["NC-browser-revision-session:原文选择保存后刷新仍停止"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
test("NC-browser-revision-session:原文选择保存后刷新仍停止", async ({
page,
}, info) => {

View File

@ -4,6 +4,7 @@ import { expect, test } from "@playwright/test";
test.use({ trace: "off" });
// @muse-case {"case_id":"NC-browser-generation-dialog","environment":"真实浏览器隔离库","given":"隔离后端已有跑完的通过与失败生成任务","when":"真实浏览器打开任务","then":["创作对话与正文可见","检查报告可见","失败候选采纳被阻断"],"contract":"docs/系统架构/新版设计/接口契约/任务工具与事件.md"}
test("NC-browser-generation-dialog:创作对话、独立检查与采纳阻断", async ({
page,
}, info) => {

View File

@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-system-release","environment":"浏览器","given":"明确隔离配置、发布包和受控测试资料","when":"经实际入口执行对应操作并读回","then":["NC-browser-system-release:安装包系统状态草案保存与重读"],"contract":"docs/系统架构/新版设计/文件设计/工程配置与部署.md"}
test("NC-browser-system-release:安装包系统状态草案保存与重读", async ({
page,
}, info) => {

View File

@ -4,6 +4,7 @@ import { expect, test } from "@playwright/test";
test.use({ trace: "off" });
// @muse-case {"case_id":"NC-browser-planning-shadow","environment":"真实浏览器与隔离HTTP/PG;不代表真实模型","given":"已授权隔离环境及预先完成的受控规划交付","when":"浏览器创建新任务并确认已有模型候选","then":["真实创建任务仅显示已创建;模型候选经作者点击确认后才成为正式规划"],"contract":"docs/系统架构/新版设计/模块设计/B01-作品规划.md"}
test("NC-browser-planning-shadow:创建真实任务与确认模型规划候选", async ({
page,
}, info) => {

View File

@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-rule-activation","environment":"隔离PG/合成HTTP及浏览器","given":"固定请求、来源与作者;按用例环境准备独立测试输入","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-browser-rule-activation:凭据只打开审阅,作者启用刷新后可明确停用"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
test("NC-browser-rule-activation:凭据只打开审阅,作者启用刷新后可明确停用", async ({
page,
}, info) => {

View File

@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-rule-improvement","environment":"隔离PG/合成HTTP及浏览器","given":"固定请求、来源与作者;按用例环境准备独立测试输入","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-browser-rule-improvement:反馈提案经实际验证后明确启停并读回诊断"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md#有来源的运行观察"}
test("NC-browser-rule-improvement:反馈提案经实际验证后明确启停并读回诊断", async ({
page,
}, info) => {

View File

@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
import type { 实验报告 } from "../../src/功能/效果评测/数据";
// @muse-case {"case_id":"NC-browser-evaluation-report","environment":"真实浏览器与隔离PG","given":"实验报告页面","when":"读取或切换实验并核对显示","then":["实际HTTP报告哈希、覆盖、未知费用及刷新读回一致","资格报告显示实际标定覆盖和来源链接,读取不新增模型调用或启用","实际检测发现和原字引文在报告页读回,未知与未配置分别显示","实际效果分层、判据理由与凭据经工作台读回和封存;正式启用仍未评定"],"contract":"docs/系统架构/新版设计/模块设计/B10-效果评测.md"}
test("NC-browser-evaluation-report:隔离报告与未知费用真实读回", async ({
page,
}, info) => {

View File

@ -4,7 +4,8 @@ import { expect, test } from "@playwright/test";
test.use({ trace: "off" });
test("NC-browser-source-workbench:资料对照页与榜单失败态", async ({ page }, info) => {
// @muse-case {"case_id":"NC-w17-browser","environment":"真实隔离库 + Chrome","given":"隔离浏览器环境与预置来源/快照","when":"打开资料对照页并应用清理、展开榜单快照","then":["来源与原文对照可见","清理说明可见","失败快照如实呈现"],"contract":"docs/系统架构/新版设计/模块设计/B03-资料研究.md"}
test("NC-w17-browser:资料对照页与榜单失败态", async ({ page }, info) => {
if (!process.env.MUSE_WORKBENCH_URL || !process.env.MUSE_AUTHOR_PASSWORD_FILE)
throw new Error("需要明确的隔离后端和作者口令文件。");
const sourceId = process.env.MUSE_SOURCE_ID!;
@ -47,7 +48,8 @@ test("NC-browser-source-workbench:资料对照页与榜单失败态", async ({
});
test("NC-browser-research-workbench:拆书工作区发起与台账呈现", async ({ page }, info) => {
// @muse-case {"case_id":"NC-w18-browser","environment":"真实隔离库 + Chrome","given":"隔离浏览器环境与预置来源","when":"打开拆书工作区并按选书理由发起拆解","then":["台账条目出现","进度与失败如实呈现"],"contract":"docs/系统架构/新版设计/模块设计/B03-资料研究.md"}
test("NC-w18-browser:拆书工作区发起与台账呈现", async ({ page }, info) => {
if (!process.env.MUSE_WORKBENCH_URL || !process.env.MUSE_AUTHOR_PASSWORD_FILE)
throw new Error("需要明确的隔离后端和作者口令文件。");
const sourceId = process.env.MUSE_SOURCE_ID!;

View File

@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-run-observation","environment":"隔离PG/合成HTTP及浏览器","given":"固定请求、来源与作者;按用例环境准备独立测试输入","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-browser-run-observation:核对真实尝试后记录原话并刷新读回"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md#有来源的运行观察"}
test("NC-browser-run-observation:核对真实尝试后记录原话并刷新读回", async ({
page,
}, info) => {

View File

@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// @muse-case {"case_id":"NC-browser-revision-comparison","environment":"隔离PG/合成HTTP及浏览器","given":"具名作者、固定来源、隔离测试配置与真实模块存储","when":"通过实际公开入口执行并核对拒绝、状态及持久结果","then":["NC-browser-revision-comparison:读取双评委记录后作者仍可保留原文"],"contract":"docs/系统架构/新版设计/模块设计/B06-审校修订.md"}
test("NC-browser-revision-comparison:读取双评委记录后作者仍可保留原文", async ({
page,
}, info) => {

View File

@ -17,6 +17,7 @@ afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
});
// @muse-case {"case_id":"NC-editor-ime-save","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"StrictMode和真实编辑器组件","when":"触发DOM组合输入并结束组合","then":["组合期间无提交,结束后提交完整中文;不计原生OS输入法验收"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-editor-ime-save:StrictMode编辑会话在组合结束后保存完整中文", async () => {
const writes: unknown[] = [];
vi.stubGlobal(

View File

@ -32,6 +32,7 @@ const scope = {
branch_id: "main",
};
// @muse-case {"case_id":"NC-editor-save-generation","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"保存尚在途,作者继续输入","when":"旧请求返回成功","then":["推进服务器基线,保留新输入与未提交状态"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-editor-save-generation:保存中的新输入保留,迟到回执只更新原会话", async () => {
let finish!: (v: {
revision: number;
@ -62,6 +63,7 @@ it("NC-editor-save-generation:保存中的新输入保留,迟到回执只更
expect(save).toHaveBeenCalledTimes(1);
});
// @muse-case {"case_id":"NC-editor-save-conflict","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"保存遇到真实版本冲突代码","when":"保留输入并尝试再次提交","then":["不自动提升基线或覆盖服务器"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-editor-save-conflict:版本冲突保留本地稿且不会自动换基线提交", async () => {
const save = vi.fn(async () => {
throw new 接口错误("REVISION_CONFLICT", "正文已更新", 409);
@ -79,6 +81,7 @@ it("NC-editor-save-conflict:版本冲突保留本地稿且不会自动换基
expect(save).toHaveBeenCalledTimes(1);
});
// @muse-case {"case_id":"NC-editor-save-retry","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"请求结果未知,随后继续编辑","when":"重试保存","then":["重用原命令和原载荷,新输入保留"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-editor-save-retry:未知结果重试原命令,后续输入不改变在途载荷", async () => {
const requests: 保存请求[] = [];
const save = vi.fn(async (r: 保存请求) => {

View File

@ -0,0 +1,110 @@
import { afterEach, expect, it, vi } from "vitest";
import {
候选浏览器缓冲,
候选缓冲键,
旧候选缓冲键,
type 候选范围,
type 改稿缓冲,
} from "../../src/功能/正文写作/候选缓冲";
const scope: 候选范围 = {
author_id: "author",
work_id: "work",
chapter_id: "chapter",
branch_id: "A",
candidate_id: "new",
};
const draft: 改稿缓冲 = {
document: {
format_version: 1,
paragraphs: [
{
paragraph_id: "p",
content: [{ type: "text", text: "A稿", marks: [] }],
},
],
},
base_revision: 7,
base_hash: "hash",
mode: "edit",
selected: [],
};
afterEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
// @muse-case {"case_id": "O01-candidate-branch-scope", "environment": "离线前端单元", "when": "同版本的两分支草稿互不读取或覆盖,分隔符无歧义", "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-candidate-branch-scope:同版本的两分支草稿互不读取或覆盖,分隔符无歧义", () => {
const a = 候选浏览器缓冲(scope),
bScope = { ...scope, branch_id: "B" },
b = 候选浏览器缓冲(bScope);
a.read();
b.read();
a.write(draft);
expect(b.read()).toBeUndefined();
b.write({ ...draft, base_hash: "B" });
expect(a.read()).toEqual(draft);
expect(候选缓冲键({ ...scope, author_id: "a:b", work_id: "c" })).not.toEqual(
候选缓冲键({ ...scope, author_id: "a", work_id: "b:c" }),
);
});
// @muse-case {"case_id": "O01-candidate-original-pending", "environment": "离线前端单元", "when": "刷新保持原请求,跨分支pending拒绝且字节不变", "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-candidate-original-pending:刷新保持原请求,跨分支pending拒绝且字节不变", () => {
const pending = {
kind: "create" as const,
body: {
command_id: "original",
chapter_id: "chapter",
branch_id: "A",
expected_revision: 7,
document: draft.document,
},
};
const a = 候选浏览器缓冲(scope);
a.read();
a.write({ ...draft, pending });
expect(候选浏览器缓冲(scope).read()?.pending).toEqual(pending);
const other = { ...scope, branch_id: "B" },
key = 候选缓冲键(other);
const raw = JSON.stringify({
schema_version: 2,
scope: other,
draft: { ...draft, pending },
});
sessionStorage.setItem(key, raw);
expect(() => 候选浏览器缓冲(other).read()).toThrow("分支 A");
expect(sessionStorage.getItem(key)).toBe(raw);
});
// @muse-case {"case_id": "O01-candidate-owner-quota", "environment": "离线前端单元", "when": "迟到clear不删新owner,配额失败保留旧字节", "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-candidate-owner-quota:迟到clear不删新owner,配额失败保留旧字节", () => {
const a = 候选浏览器缓冲(scope);
a.read();
a.write(draft);
const b = 候选浏览器缓冲(scope);
b.read();
b.write({ ...draft, base_hash: "new" });
const raw = sessionStorage.getItem(候选缓冲键(scope));
expect(() => a.write(draft)).toThrow("其他会话");
a.clear();
expect(sessionStorage.getItem(候选缓冲键(scope))).toBe(raw);
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("quota");
});
expect(() => b.write(draft)).toThrow("quota");
expect(sessionStorage.getItem(候选缓冲键(scope))).toBe(raw);
});
// @muse-case {"case_id": "O01-candidate-legacy-corrupt", "environment": "离线前端单元", "when": "旧键只读保留且损坏新记录不允许覆盖", "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-candidate-legacy-corrupt:旧键只读保留且损坏新记录不允许覆盖", () => {
const key = 旧候选缓冲键(scope),
raw = JSON.stringify(draft);
sessionStorage.setItem(key, raw);
const a = 候选浏览器缓冲(scope);
expect(a.legacy()?.value).toEqual(draft);
a.read();
a.write(draft);
a.clear();
expect(sessionStorage.getItem(key)).toBe(raw);
sessionStorage.setItem(候选缓冲键(scope), "{broken");
expect(() => a.read()).toThrow();
expect(() => a.write(draft)).toThrow("不能覆盖");
expect(sessionStorage.getItem(候选缓冲键(scope))).toBe("{broken");
});

View File

@ -14,6 +14,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});
// @muse-case {"case_id":"NC-editor-restore-conflict","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"打开历史后正文基线改变","when":"回退返回明确409","then":["解除编辑器只读,提示重新读取基线而非重试旧版本"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-editor-restore-conflict:明确回退冲突解除只读并要求重新核对", async () => {
const record: 正文记录 = {
work_id: "w",

View File

@ -5,6 +5,7 @@ import type { 正文草稿, 正文记录 } from "../../src/功能/正文写作/
afterEach(() => localStorage.clear());
// @muse-case {"case_id":"NC-editor-buffer-late-owner","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"旧会话保存尚在途,章节已重新打开","when":"新会话编辑后交付旧回执","then":["新会话私人缓冲保留,旧会话不能清除"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-editor-buffer-late-owner:旧会话迟到成功不清除重开后的新稿", async () => {
const scope = {
author_id: "author",
@ -57,6 +58,7 @@ it("NC-editor-buffer-late-owner:旧会话迟到成功不清除重开后的新
await request;
expect(浏览器缓冲(scope).read()?.draft).toEqual(draft("重开后新增文字"));
});
// @muse-case {"case_id":"NC-editor-buffer-scope","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"多个章的私人未提交缓冲","when":"刷新读取与推进其中一个基线","then":["按作者作品章分支和版本区分,清理不影响另一章"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-editor-buffer-scope:刷新读取私人缓冲且切章不混用基线", () => {
const a = {
author_id: "author",

View File

@ -12,6 +12,7 @@ import type { 正文草稿 } from "../../src/功能/正文写作/数据";
const editors: Editor[] = [];
afterEach(() => editors.splice(0).forEach((e) => e.destroy()));
// @muse-case {"case_id":"NC-editor-document-roundtrip","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"真实Tiptap与正文JSON","when":"打开后编辑文本","then":["段落身份、组合字符、空白和显式CRLF保留"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-editor-document-roundtrip:真实编辑器往返保留段落身份和原字符", () => {
const draft: 正文草稿 = {
format_version: 1,
@ -44,6 +45,7 @@ it("NC-editor-document-roundtrip:真实编辑器往返保留段落身份和原
expect(正文文本(转为正文(e.getJSON()))).toContain("e\u0301");
});
// @muse-case {"case_id":"NC-editor-plaintext-paste","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"连续空行与混合换行","when":"经真实PM解析器粘贴","then":["不折叠空行,实际可见文本与输入相同"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-editor-plaintext-paste:纯文本粘贴连续空行不被默认解析器折叠", () => {
const e = new Editor({ extensions: 正文扩展() });
editors.push(e);

View File

@ -0,0 +1,59 @@
import { useState } from "react";
import { afterEach, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { Ui正文编辑器 } from "../../src/界面/编辑器/正文编辑器";
import * as adapter from "../../src/界面/编辑器/文档适配";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// @muse-case {"case_id": "O01-editor-document-transaction", "environment": "离线组件测试", "given": "正文编辑器一次输入事务", "when": "组件重绘与光标移动", "then": ["转为正式文档只执行一次", "初始转换不受影响"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-editor-document-transaction:一次正文事务只转一次正式文档,组件重绘及光标移动不转正文", async () => {
const convert = vi.spyOn(adapter, "转为正文快照"),
initial = vi.spyOn(adapter, "转为编辑文档");
const changes = vi.fn();
function Editor() {
const [draft, setDraft] = useState<adapter.正文草稿>({
format_version: 1,
paragraphs: [{ paragraph_id: "p", content: [] }],
});
return (
<Ui正文编辑器
initial={draft}
onChange={(value, stats) => {
changes(value, stats);
setDraft(value);
}}
onComposition={() => {}}
/>
);
}
render(<Editor />);
const textbox = screen.getByRole("textbox", { name: "章节正文" });
const paragraph = textbox.querySelector("p")!;
paragraph.textContent = "新文😀";
fireEvent.input(textbox, { inputType: "insertText", data: "新文😀" });
await waitFor(() => expect(changes).toHaveBeenCalledTimes(1));
expect(convert).toHaveBeenCalledTimes(1);
expect(initial).toHaveBeenCalledTimes(1);
expect(changes.mock.calls[0][1]).toEqual({
visible_text: "新文😀",
codepoints: 3,
});
const selection = window.getSelection()!,
range = document.createRange();
range.setStart(paragraph.firstChild!, 1);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
fireEvent(document, new Event("selectionchange"));
fireEvent.focus(textbox);
expect(convert).toHaveBeenCalledTimes(1);
expect(changes).toHaveBeenCalledTimes(1);
});

View File

@ -0,0 +1,326 @@
import { StrictMode, useEffect, useState } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { 创建编辑会话 } from "../../src/功能/正文写作/编辑会话";
import { 浏览器缓冲 } from "../../src/功能/正文写作/编辑缓冲";
import { useMuse自动保存 } from "../../src/功能/正文写作/保存控制";
import type { 正文草稿, 正文记录 } from "../../src/功能/正文写作/数据";
const scope = {
author_id: "a",
work_id: "w",
chapter_id: "c",
branch_id: "main",
};
const draft = (text: string): 正文草稿 => ({
format_version: 1,
paragraphs: [
{ paragraph_id: "p", content: [{ type: "text", text, marks: [] }] },
],
});
const record: 正文记录 = {
...scope,
document_id: "doc",
revision: 7,
document_hash: "h7",
visible_text_hash: "t7",
visible_text: "原文",
document: draft("原文"),
state: "saved",
};
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
localStorage.clear();
});
// @muse-case {"case_id": "O01-editor-hot-path", "environment": "离线单元,Storage替身", "given": "编辑会话已建立", "when": "一次输入后继续输入", "then": ["一次输入仅一次序列化写入", "后续输入不扫描storage键", "owner冲突仍拒绝"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-editor-hot-path:一次输入仅一次序列化写入,后续输入不扫描storage键且owner冲突仍拒绝", () => {
const buffer = 浏览器缓冲(scope);
buffer.read();
const session = 创建编辑会话(scope, record, vi.fn(), buffer);
const stringify = vi.spyOn(JSON, "stringify"),
write = vi.spyOn(Storage.prototype, "setItem"),
keys = vi.spyOn(Storage.prototype, "key");
session.change(draft("新文😀"), { visible_text: "新文😀", codepoints: 3 });
// scope前缀预计算之外,热路径只序列化一次持久载荷。
expect(write).toHaveBeenCalledTimes(1);
expect(keys).not.toHaveBeenCalled();
expect(stringify).toHaveBeenCalledTimes(1);
session.composition(true);
session.composition(false);
expect(write).toHaveBeenCalledTimes(1);
const rival = 浏览器缓冲(scope);
rival.read();
rival.write({
scope,
base_revision: 7,
base_hash: "h7",
draft: draft("另页"),
updated_at: 1,
});
session.change(draft("本页"));
expect(session.snapshot().buffer_warning).toContain("未能保留缓冲");
expect(rival.read()?.draft).toEqual(draft("另页"));
session.dispose();
rival.dispose?.();
});
// @muse-case {"case_id": "O01-editor-revision-debounce", "environment": "离线单元,假时钟", "given": "连续输入超过900ms", "when": "防抖到期发送保存", "then": ["只发一次", "保存中新增输入在回执后另存一次"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-editor-revision-debounce:连续输入900ms后只发一次,保存中新增输入在回执后另存一次", async () => {
vi.useFakeTimers();
let complete!: (value: {
revision: number;
document_hash: string;
receipt_id: string;
}) => void;
const send = vi.fn(
() =>
new Promise<{
revision: number;
document_hash: string;
receipt_id: string;
}>((resolve) => {
complete = resolve;
}),
);
const session = 创建编辑会话(scope, record, send, {
write: vi.fn(),
clear: vi.fn(),
});
function Probe() {
useMuse自动保存(session);
return null;
}
render(<Probe />);
act(() => session.change(draft("一")));
await act(() => vi.advanceTimersByTimeAsync(800));
act(() => session.change(draft("二")));
await act(() => vi.advanceTimersByTimeAsync(899));
expect(send).not.toHaveBeenCalled();
await act(() => vi.advanceTimersByTimeAsync(1));
expect(send).toHaveBeenCalledTimes(1);
act(() => session.change(draft("三")));
await act(async () =>
complete({ revision: 8, document_hash: "h8", receipt_id: "r8" }),
);
expect(session.snapshot().status).toBe("dirty");
await act(() => vi.advanceTimersByTimeAsync(900));
expect(send).toHaveBeenCalledTimes(2);
await act(async () =>
complete({ revision: 9, document_hash: "h9", receipt_id: "r9" }),
);
await act(() => vi.advanceTimersByTimeAsync(2000));
expect(session.snapshot().status).toBe("saved");
expect(send).toHaveBeenCalledTimes(2);
session.dispose();
});
// @muse-case {"case_id": "O01-editor-storage-invalidation", "environment": "离线单元,Storage事件替身", "given": "同范围与新范围各自的storage事件", "when": "事件触发失效检查", "then": ["同范围发现新增基线", "其他范围不触发扫描"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-editor-storage-invalidation:同范围storage事件后发现新增基线,其他范围不触发扫描", () => {
const buffer = 浏览器缓冲(scope);
buffer.read();
buffer.activate?.();
buffer.write({
scope,
base_revision: 7,
base_hash: "h7",
draft: draft("本页"),
updated_at: 1,
});
const keys = vi.spyOn(Storage.prototype, "key");
window.dispatchEvent(
new StorageEvent("storage", {
storageArea: localStorage,
key: "another-scope",
}),
);
buffer.write({
scope,
base_revision: 7,
base_hash: "h7",
draft: draft("继续"),
updated_at: 2,
});
expect(keys).not.toHaveBeenCalled();
const rival = 浏览器缓冲(scope);
rival.read();
rival.write({
scope,
base_revision: 8,
base_hash: "h8",
draft: draft("另页迁基线"),
updated_at: 3,
});
window.dispatchEvent(
new StorageEvent("storage", { storageArea: localStorage, key: null }),
);
expect(() =>
buffer.write({
scope,
base_revision: 7,
base_hash: "h7",
draft: draft("拒绝覆盖"),
updated_at: 4,
}),
).toThrow("另一个编辑会话");
expect(rival.read()?.base_revision).toBe(8);
buffer.dispose?.();
rival.dispose?.();
});
// @muse-case {"case_id": "O01-editor-pending-durable", "environment": "离线单元,写入失败注入", "given": "原请求落盘失败", "when": "尝试外发与恢复", "then": ["落盘失败时不外发", "恢复后沿用同一命令"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-editor-pending-durable:原请求落盘失败时不外发,恢复后仍沿用同一命令", async () => {
let unavailable = true;
const send = vi.fn(async () => ({
revision: 8,
document_hash: "h8",
receipt_id: "r8",
}));
const session = 创建编辑会话(scope, record, send, {
write: () => {
if (unavailable) throw new Error("quota");
},
clear: vi.fn(),
});
session.change(draft("待存文字"));
await session.save();
expect(send).not.toHaveBeenCalled();
const original = session.snapshot().pending;
expect(original?.command_id).toBeTruthy();
unavailable = false;
await session.save();
expect(send).toHaveBeenCalledWith(original);
expect(session.snapshot().status).toBe("saved");
session.dispose();
});
// @muse-case {"case_id": "O01-editor-discard-owner", "environment": "离线单元", "given": "恢复选择期间其他会话已更新草稿", "when": "明确放弃旧稿", "then": ["不能删除新owner的草稿"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-editor-discard-owner:恢复选择期间其他会话更新,明确放弃也不能删除新owner草稿", () => {
const writer = 浏览器缓冲(scope);
writer.write({
scope,
base_revision: 7,
base_hash: "h7",
draft: draft("旧稿"),
updated_at: 1,
});
const recovery = 浏览器缓冲(scope);
recovery.read();
writer.write({
scope,
base_revision: 7,
base_hash: "h7",
draft: draft("新稿"),
updated_at: 2,
});
window.dispatchEvent(
new StorageEvent("storage", { storageArea: localStorage, key: null }),
);
recovery.clear();
expect(writer.read()?.draft).toEqual(draft("新稿"));
writer.dispose?.();
recovery.dispose?.();
});
// @muse-case {"case_id": "O01-editor-empty-owner-race", "environment": "离线单元", "given": "两页初始无稿,另一页首次写入", "when": "storage事件到达空页", "then": ["不授权空页接管"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-editor-empty-owner-race:两页初始无稿,另一页首次写入后的storage事件不授权空页接管", () => {
const a = 浏览器缓冲(scope),
b = 浏览器缓冲(scope);
expect(a.read()).toBeUndefined();
expect(b.read()).toBeUndefined();
a.activate?.();
b.activate?.();
const value = {
scope,
base_revision: 7,
base_hash: "h7",
draft: draft("B先写入"),
updated_at: 1,
};
b.write(value);
const before = localStorage.getItem(localStorage.key(0)!);
window.dispatchEvent(
new StorageEvent("storage", {
storageArea: localStorage,
key: localStorage.key(0),
}),
);
expect(() => a.write({ ...value, draft: draft("A不应覆盖B") })).toThrow(
"另一个编辑会话",
);
expect(localStorage.getItem(localStorage.key(0)!)).toBe(before);
// 明确重新读取B的稿件后可继续,storage事件本身不具有此授权。
expect(a.read()?.draft).toEqual(value.draft);
a.write({ ...value, draft: draft("已看过B后继续") });
expect(a.read()?.draft).toEqual(draft("已看过B后继续"));
a.dispose?.();
b.dispose?.();
});
// @muse-case {"case_id": "O01-editor-legacy-pending-revisions", "environment": "离线单元", "given": "旧v1原请求已覆盖草稿", "when": "保存回执到达", "then": ["不重复保存", "后来输入仍另存"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-editor-legacy-pending-revisions:旧v1原请求已覆盖草稿时回执后不重复保存,后来输入仍另存", async () => {
for (const hasLaterInput of [false, true]) {
const pending = {
command_id: "legacy-fixed",
chapter_id: scope.chapter_id,
branch_id: scope.branch_id,
expected_revision: 7,
document: draft("已发送"),
};
const send = vi.fn(async () => ({
revision: 8,
document_hash: "h8",
receipt_id: "r8",
}));
const local = hasLaterInput ? draft("发送后继续输入") : draft("已发送");
const session = 创建编辑会话(
scope,
record,
send,
{ write: vi.fn(), clear: vi.fn() },
{
scope,
base_revision: 7,
base_hash: "h7",
draft: local,
pending,
updated_at: 1,
},
);
await session.save();
expect(send).toHaveBeenCalledWith(pending);
expect(session.snapshot().status).toBe(hasLaterInput ? "dirty" : "saved");
await session.save();
expect(send).toHaveBeenCalledTimes(hasLaterInput ? 2 : 1);
if (hasLaterInput) {
expect(send.mock.calls[1]).toEqual([
expect.objectContaining({ document: local, expected_revision: 8 }),
]);
}
session.dispose();
}
});
// @muse-case {"case_id": "O01-editor-strict-storage-lifecycle", "environment": "离线组件测试,StrictMode", "given": "StrictMode丢弃初始化实例", "when": "组件生命周期推进与卸载", "then": ["丢弃实例不注册监听", "卸载后监听平衡"], "contract": "docs/系统架构/新版设计/工作台设计.md"}
it("O01-editor-strict-storage-lifecycle:StrictMode丢弃初始化实例不注册监听,卸载后监听平衡", () => {
const added = vi.spyOn(window, "addEventListener"),
removed = vi.spyOn(window, "removeEventListener");
function Probe() {
const [buffer] = useState(() => 浏览器缓冲(scope));
useEffect(() => {
buffer.activate?.();
return () => buffer.dispose?.();
}, [buffer]);
return null;
}
const view = render(
<StrictMode>
<Probe />
</StrictMode>,
);
const count = () =>
added.mock.calls.filter(([type]) => type === "storage").length -
removed.mock.calls.filter(([type]) => type === "storage").length;
expect(count()).toBe(1);
view.unmount();
expect(count()).toBe(0);
});

View File

@ -1,6 +1,7 @@
import { expect, it } from "vitest";
import { 码点到UTF16, UTF16到码点 } from "../../src/界面/编辑器/字符位置";
// @muse-case {"case_id":"NC-editor-codepoint-selection","environment":"离线组件/实际编辑器;HTTP响应替身不计真实链路","given":"emoji代理对和组合字符","when":"转换编辑器位置与码点位置","then":["合法位置可逆,不截断代理对"],"contract":"docs/系统架构/新版设计/接口契约/正文结构与中文选区.md"}
it("NC-editor-codepoint-selection:组合字符与代理对选区转换可逆", () => {
const text = "甲🧑‍🏽e\u0301乙";
for (let i = 0; i <= Array.from(text).length; i++) {

File diff suppressed because one or more lines are too long

31
web/旅程环境.ts Normal file
View File

@ -0,0 +1,31 @@
import { accessSync, constants } from "node:fs";
import { isAbsolute } from "node:path";
/** 仅核对具名隔离环境;不启动服务、安装浏览器或读取口令内容。 */
export function 核对旅程环境(env: NodeJS.ProcessEnv) {
if (env.MUSE_ISOLATED_TEST_ENVIRONMENT !== "1")
throw new Error(
"真实旅程需要 MUSE_ISOLATED_TEST_ENVIRONMENT=1 明确确认隔离测试环境。",
);
if (!env.MUSE_WORKBENCH_URL)
throw new Error("真实旅程需要明确 MUSE_WORKBENCH_URL,不使用默认后端。");
const url = new URL(env.MUSE_WORKBENCH_URL);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password
)
throw new Error("工作台URL必须为无内嵌凭据的HTTP(S)地址。");
for (const name of [
"MUSE_BROWSER_EXECUTABLE",
"MUSE_AUTHOR_PASSWORD_FILE",
] as const) {
const file = env[name];
if (!file || !isAbsolute(file))
throw new Error(`真实旅程需要具名绝对路径 ${name}。`);
accessSync(
file,
name === "MUSE_BROWSER_EXECUTABLE" ? constants.X_OK : constants.R_OK,
);
}
}