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

327 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { 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);
});