前端:工作台页面、事件标签与旅程用例
- 上下文清单、任务列表与详情、恢复控制、运行观察、创作对话与消息流、正式变更决策面板、正文写作与候选审阅、资料研究等页面同步本轮后端能力。 - 事件标签表补齐后端事件类型;查询键与生成客户端随 HTTP 契约重新生成。 - 依赖调整后 lockfile 同步;交互用例与浏览器旅程 spec 一并更新。 - 证据:make 前端检查(lint + typecheck + Pi 工具桥 tsc)exit 0;make 前端测试 37 文件 / 120 用例通过。
This commit is contained in:
parent
d909d1bd1b
commit
ae02fd1732
1
web/node_modules
Symbolic link
1
web/node_modules
Symbolic link
@ -0,0 +1 @@
|
||||
/Users/qingse/Sync/local-git/oh-my-muse/agent-example/web/node_modules
|
||||
@ -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",
|
||||
|
||||
@ -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
23
web/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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> = {
|
||||
|
||||
@ -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();
|
||||
|
||||
183
web/src/功能/任务运行/任务订阅.ts
Normal file
183
web/src/功能/任务运行/任务订阅.ts
Normal 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 };
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
346
web/src/功能/任务运行/调用账目.tsx
Normal file
346
web/src/功能/任务运行/调用账目.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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!;
|
||||
},
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 ||
|
||||
|
||||
208
web/src/功能/正文写作/候选缓冲.ts
Normal file
208
web/src/功能/正文写作/候选缓冲.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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("");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
15
web/src/功能/正文写作/路由参数.ts
Normal file
15
web/src/功能/正文写作/路由参数.ts
Normal 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)}`;
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
1245
web/src/接口/生成/类型.ts
1245
web/src/接口/生成/类型.ts
File diff suppressed because it is too large
Load Diff
@ -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 {
|
||||
|
||||
@ -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()}
|
||||
>
|
||||
重做
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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];
|
||||
|
||||
272
web/tests/交互/任务订阅生命周期.test.tsx
Normal file
272
web/tests/交互/任务订阅生命周期.test.tsx
Normal 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);
|
||||
});
|
||||
@ -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([
|
||||
{
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
206
web/tests/交互/候选摘要分页.test.tsx
Normal file
206
web/tests/交互/候选摘要分页.test.tsx
Normal 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();
|
||||
});
|
||||
@ -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(),
|
||||
|
||||
31
web/tests/交互/分析配置.test.ts
Normal file
31
web/tests/交互/分析配置.test.ts
Normal 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: "合成测试",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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" />);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 = [
|
||||
|
||||
215
web/tests/交互/调用账目.test.tsx
Normal file
215
web/tests/交互/调用账目.test.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
@ -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" } },
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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" }, [
|
||||
|
||||
@ -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诊断报告
|
||||
|
||||
15
web/tests/单元/旅程环境.test.ts
Normal file
15
web/tests/单元/旅程环境.test.ts
Normal 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");
|
||||
});
|
||||
32
web/tests/单元/路由参数.test.tsx
Normal file
32
web/tests/单元/路由参数.test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
@ -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!;
|
||||
|
||||
@ -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("浏览器验收必须指定独立后端与口令文件,不使用默认业务连接。");
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
97
web/tests/旅程/分支缓冲与事件收尾.spec.ts
Normal file
97
web/tests/旅程/分支缓冲与事件收尾.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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!;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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!;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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: 保存请求) => {
|
||||
|
||||
110
web/tests/编辑/候选缓冲.test.ts
Normal file
110
web/tests/编辑/候选缓冲.test.ts
Normal 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");
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
59
web/tests/编辑/编辑事务.test.tsx
Normal file
59
web/tests/编辑/编辑事务.test.tsx
Normal 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);
|
||||
});
|
||||
326
web/tests/编辑/编辑热路径.test.tsx
Normal file
326
web/tests/编辑/编辑热路径.test.tsx
Normal 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);
|
||||
});
|
||||
@ -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
31
web/旅程环境.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user