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