Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea102b9949 | |||
| efcec4d603 | |||
| e75b9ed5e4 | |||
| 74748d6e23 | |||
| 3da19e288a | |||
| 47e318682e | |||
| 1c832988a2 | |||
| 95e8a57019 |
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 旧版主画布的界面装配与交互控制。useCanvasTree拥有旧快照、writer、历史和子图事务,组件把其投影交给ReactFlow。
|
||||||
|
* 本地state管理工具、模式、菜单与提示,refs保存拖动/连线起点及文件选择上下文;真实上传与演示Data URL分别处理。
|
||||||
|
* 节点/连线组件通过Context调用适配动作,不直接修改投影;子图归属、端口连线和解散优先使用服务端能力与事务。
|
||||||
|
* 迁移门闩暂停旧编辑和创作入口,给迁移协调组件提供检查、保存排空与备份;本文件不自行切换到新版engine。
|
||||||
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
import { useImageUploads } from "./use-image-uploads";
|
import { useImageUploads } from "./use-image-uploads";
|
||||||
import {LegacyCanvasMigration} from "./legacy-canvas-migration";
|
import {LegacyCanvasMigration} from "./legacy-canvas-migration";
|
||||||
@@ -41,15 +47,21 @@ import type { NodeTransform } from "@/lib/canvas-geometry";
|
|||||||
import { MaterialNode, NodeEditing } from "./material-node";
|
import { MaterialNode, NodeEditing } from "./material-node";
|
||||||
import styles from "./canvas.module.css";
|
import styles from "./canvas.module.css";
|
||||||
|
|
||||||
|
/** 旧边统一使用可编辑贝塞尔组件,节点按旧type名称分派;类型映射本身不持有元素实例数据。 */
|
||||||
const edgeTypes = { default: CanvasEdge };
|
const edgeTypes = { default: CanvasEdge };
|
||||||
|
/** 图标面板延迟在客户端加载,避免首屏加载目录面板及其交互代码;加载中提供可访问状态。 */
|
||||||
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
|
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
|
||||||
|
/** ReactFlow type到节点展示组件的固定表,须与makeCanvasNode/投影生成的type保持一致。 */
|
||||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
|
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
|
||||||
|
|
||||||
|
/** 每个挂载对应一个board身份,初始快照/草稿只在useState初始化时采用;切画布应由上层key重挂载。 */
|
||||||
function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
||||||
const flow = useReactFlow<CanvasNode>();
|
const flow = useReactFlow<CanvasNode>();
|
||||||
const { zoom } = useViewport();
|
const { zoom } = useViewport();
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
const storageKey = canvasStorageKey(board.workspace_id, board.id);
|
const storageKey = canvasStorageKey(board.workspace_id, board.id);
|
||||||
|
// 服务端快照优先,另有不同本地内容则等待恢复选择;无快照可直接用草稿。
|
||||||
|
// 视口独立读本地键并限制范围,损坏视口不阻断文档;草稿解码失败回退快照/空文档并提示。
|
||||||
const [initial] = useState(() => {
|
const [initial] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const local = readLocalRecovery(localStorage, storageKey, snapshot);
|
const local = readLocalRecovery(localStorage, storageKey, snapshot);
|
||||||
@@ -62,6 +74,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
|
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 工具与变换模式是界面状态,不放入文档;draggedIds仅驱动成员预览,membershipNotice短暂展示结果。
|
||||||
const [tool, setTool] = useState<"select" | "hand">("select");
|
const [tool, setTool] = useState<"select" | "hand">("select");
|
||||||
const [error, setError] = useState(initial.error);
|
const [error, setError] = useState(initial.error);
|
||||||
const feedback = useContext(FeedbackContext);
|
const feedback = useContext(FeedbackContext);
|
||||||
@@ -72,26 +85,35 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
const [rotating, setRotating] = useState<string | null>(null);
|
const [rotating, setRotating] = useState<string | null>(null);
|
||||||
const [draggedIds, setDraggedIds] = useState<string[]>([]);
|
const [draggedIds, setDraggedIds] = useState<string[]>([]);
|
||||||
const [membershipNotice, setMembershipNotice] = useState("");
|
const [membershipNotice, setMembershipNotice] = useState("");
|
||||||
|
// 防节点拖动与多选拖动回调重复进入;起点按显示ID保存,连线屏幕起点用于区分点击与拉出新分支。
|
||||||
const dragSession = useRef<"idle" | "active" | "finishing">("idle");
|
const dragSession = useRef<"idle" | "active" | "finishing">("idle");
|
||||||
const dragOrigins = useRef(new Map<string, XYPosition>());
|
const dragOrigins = useRef(new Map<string, XYPosition>());
|
||||||
const draggingFrame = useRef(false);
|
const draggingFrame = useRef(false);
|
||||||
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
|
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
|
||||||
|
// 图片分支先保留源节点/方向/位置,等文件选择返回;cancel事件要清它,不能沿用上次创建意图。
|
||||||
const pendingImage = useRef<BranchRequest | null>(null);
|
const pendingImage = useRef<BranchRequest | null>(null);
|
||||||
|
/** 稳定关闭回调供Portal菜单与画布事件共用,避免菜单因每次渲染重新安装监听。 */
|
||||||
const closeBranch = useCallback(() => setBranchRequest(null), []);
|
const closeBranch = useCallback(() => setBranchRequest(null), []);
|
||||||
const imageKind = useRef<"image" | "plain-image">("image");
|
const imageKind = useRef<"image" | "plain-image">("image");
|
||||||
const imageInput = useRef<HTMLInputElement>(null);
|
const imageInput = useRef<HTMLInputElement>(null);
|
||||||
const projectInput = useRef<HTMLInputElement>(null);
|
const projectInput = useRef<HTMLInputElement>(null);
|
||||||
const [recovery, setRecovery] = useState(initial.recovery);
|
const [recovery, setRecovery] = useState(initial.recovery);
|
||||||
|
// 根恢复待决时暂停保存;tree负责真实owner文档,UI显示节点来自其树投影。子图恢复按Map首项逐个处理。
|
||||||
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
|
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
|
||||||
const childRecovery = [...tree.recoveries][0];
|
const childRecovery = [...tree.recoveries][0];
|
||||||
|
/** 下载单份内存文档JSON备份,稍后释放Blob URL;不修改草稿选择,也不保证包含所有子图。 */
|
||||||
const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
|
const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
|
||||||
const sync = tree;
|
const sync = tree;
|
||||||
|
// snapshot存在才执行真实AI;state.present组合根元数据与整树显示节点/边,不能把它当单board持久快照。
|
||||||
const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree);
|
const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree);
|
||||||
const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } };
|
const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } };
|
||||||
const treeRef = useRef(tree); treeRef.current = tree;
|
const treeRef = useRef(tree); treeRef.current = tree;
|
||||||
|
// 上传完成回调通过treeRef定位当前归属,并更新当前文档与历史;job.owner可能已不再是最新owner。
|
||||||
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
|
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
|
||||||
const imageUploading = demoImageUploading;
|
const imageUploading = demoImageUploading;
|
||||||
|
// 文件导入等待期间的迁移检查标记;这不是通用导入队列,也不提供并发文件事务锁。
|
||||||
const ioActive=useRef(false);
|
const ioActive=useRef(false);
|
||||||
|
// 上传任务与文档都可能变化:按pendingUpload重新找owner,补资源或纯图片默认尺寸,避免移动/撤销后遗漏占位。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const current = treeRef.current;
|
const current = treeRef.current;
|
||||||
for (const job of uploads.jobs) {
|
for (const job of uploads.jobs) {
|
||||||
@@ -102,38 +124,48 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
if (node.data.kind==="plain-image"&&node.width===320&&node.height===240&&(job.node.width!==320||job.node.height!==240)) current.resolveImageUpload(job.node.id,undefined,{width:job.node.width!,height:job.node.height!});
|
if (node.data.kind==="plain-image"&&node.width===320&&node.height===240&&(job.node.width!==320||job.node.height!==240)) current.resolveImageUpload(job.node.id,undefined,{width:job.node.width!,height:job.node.height!});
|
||||||
}
|
}
|
||||||
}, [uploads.jobs,tree.documents]);
|
}, [uploads.jobs,tree.documents]);
|
||||||
|
/** undo/redo走异步树历史接口并显示失败,其余动作走根document reducer;不把服务端事务撤销当普通reducer动作。 */
|
||||||
function dispatch(action: CanvasAction) {
|
function dispatch(action: CanvasAction) {
|
||||||
if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; }
|
if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; }
|
||||||
treeRef.current.dispatch(action);
|
treeRef.current.dispatch(action);
|
||||||
}
|
}
|
||||||
|
// 浏览器文件选择取消不触发普通onChange,单独监听cancel清理待建图片分支;卸载拆监听。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const input = imageInput.current;
|
const input = imageInput.current;
|
||||||
|
/** 取消本次选择意图,不删除已经存在的图片节点或中止已启动上传。 */
|
||||||
const cancel = () => { pendingImage.current = null; };
|
const cancel = () => { pendingImage.current = null; };
|
||||||
input?.addEventListener("cancel", cancel);
|
input?.addEventListener("cancel", cancel);
|
||||||
return () => input?.removeEventListener("cancel", cancel);
|
return () => input?.removeEventListener("cancel", cancel);
|
||||||
}, []);
|
}, []);
|
||||||
const { nodes: displayNodes, edges } = state.present;
|
const { nodes: displayNodes, edges } = state.present;
|
||||||
|
// 对成员任务保护节点关闭拖动/删除/连接;tree.update内部仍保护相应数据,界面禁用不是唯一护栏。
|
||||||
const nodes = displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; });
|
const nodes = displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; });
|
||||||
|
/** 上传任务的本地nodeID映射到当前场景ID;尚无投影时按根/原owner构造兼容显示ID。 */
|
||||||
const uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id);
|
const uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id);
|
||||||
|
// 预览URL按显示ID提供;正式图片onLoad再映射回job,上传完成的任务才回收本地URL。
|
||||||
const imagePreviews = { sources: new Map(uploads.jobs.map((job) => [uploadDisplayID(job),job.localURL])), loaded: (id: string) => { const job = uploads.jobs.find((job) => uploadDisplayID(job)===id);if(job) uploads.loaded(job.node.id); } };
|
const imagePreviews = { sources: new Map(uploads.jobs.map((job) => [uploadDisplayID(job),job.localURL])), loaded: (id: string) => { const job = uploads.jobs.find((job) => uploadDisplayID(job)===id);if(job) uploads.loaded(job.node.id); } };
|
||||||
|
|
||||||
|
// 从场景节点派生分区名称、已有frameId计数与本次拖入/拖出候选,只作显示,不在此改变owner。
|
||||||
const frameFeedback = useMemo(() => {
|
const frameFeedback = useMemo(() => {
|
||||||
const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title]));
|
const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title]));
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const node of nodes) if (node.data.frameId) counts.set(node.data.frameId, (counts.get(node.data.frameId) || 0) + 1);
|
for (const node of nodes) if (node.data.frameId) counts.set(node.data.frameId, (counts.get(node.data.frameId) || 0) + 1);
|
||||||
return { names, counts, drops: frameDropFeedback(nodes, draggedIds) };
|
return { names, counts, drops: frameDropFeedback(nodes, draggedIds) };
|
||||||
}, [nodes, draggedIds]);
|
}, [nodes, draggedIds]);
|
||||||
|
// 成员提示四秒后清理;重新提示重置计时器,卸载取消。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!membershipNotice) return;
|
if (!membershipNotice) return;
|
||||||
const timer = setTimeout(() => setMembershipNotice(""), 4000);
|
const timer = setTimeout(() => setMembershipNotice(""), 4000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [membershipNotice]);
|
}, [membershipNotice]);
|
||||||
|
// 全局反馈可用时把本地错误转成稳定ID通知并清本地error;无Provider时保留底部内联提示。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!feedback || !error) return;
|
if (!feedback || !error) return;
|
||||||
const id = `canvas:${board.id}:error`;
|
const id = `canvas:${board.id}:error`;
|
||||||
notify({ id, level: "error", title: error });
|
notify({ id, level: "error", title: error });
|
||||||
setError("");
|
setError("");
|
||||||
}, [feedback, error, board.id]);
|
}, [feedback, error, board.id]);
|
||||||
|
// 成员通知使用board级稳定ID,内容变化替换旧通知,清理时显式dismiss。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!feedback || !membershipNotice) return;
|
if (!feedback || !membershipNotice) return;
|
||||||
const id = `canvas:${board.id}:membership`;
|
const id = `canvas:${board.id}:membership`;
|
||||||
@@ -141,29 +173,40 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
return () => dismissNotification(id);
|
return () => dismissNotification(id);
|
||||||
}, [feedback, membershipNotice, board.id]);
|
}, [feedback, membershipNotice, board.id]);
|
||||||
const duration = reduceMotion ? 0 : 180;
|
const duration = reduceMotion ? 0 : 180;
|
||||||
|
/** 视口派发给根并独立写本地键;存储失败不打断浏览,内容writer自行判断是否需要服务端保存。 */
|
||||||
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
|
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
|
||||||
treeRef.current.dispatch({ type: "viewport", viewport });
|
treeRef.current.dispatch({ type: "viewport", viewport });
|
||||||
try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ }
|
try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ }
|
||||||
};
|
};
|
||||||
|
/** 工具栏视口动画完成后保存实际视口;错误转界面提示,不提前记录尚未完成的目标值。 */
|
||||||
const changeView = (operation: () => Promise<boolean>) => {
|
const changeView = (operation: () => Promise<boolean>) => {
|
||||||
void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message));
|
void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// latest保留当前显示几何,拖动结束基于最新节点计算成员反馈,避免只使用开始时的闭包。
|
||||||
const latest = useRef(state.present);
|
const latest = useRef(state.present);
|
||||||
latest.current = state.present;
|
latest.current = state.present;
|
||||||
|
|
||||||
|
/** 显示ID的选择交tree解析owner;稳定回调通过treeRef避免每次文档更新重建编辑上下文。 */
|
||||||
const select = useCallback((id: string) => treeRef.current.select(id), []);
|
const select = useCallback((id: string) => treeRef.current.select(id), []);
|
||||||
|
/** 对目标owner建立checkpoint,把后续连续编辑归到同一撤销起点;缺目标时使用根board。 */
|
||||||
const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]);
|
const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]);
|
||||||
|
/** 正文动作按投影ID定位原节点,reducer处理文本值与历史。 */
|
||||||
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
|
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
|
||||||
|
/** 打开分支类型菜单并退出变换模式,point用于屏幕定位。 */
|
||||||
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { setBranchRequest({ id, direction, point }); setRotating(null); setResizing(null); }, []);
|
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { setBranchRequest({ id, direction, point }); setRotating(null); setResizing(null); }, []);
|
||||||
|
/** 将场景位置减owner原点再除scale,宽高也除scale;旋转等其余字段直接交旧transform动作。 */
|
||||||
const transform = useCallback((id: string, transform: NodeTransform) => {
|
const transform = useCallback((id: string, transform: NodeTransform) => {
|
||||||
const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return;
|
const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return;
|
||||||
treeRef.current.nodeAction(id, (id) => ({ type: "transform", id, transform: { ...transform,
|
treeRef.current.nodeAction(id, (id) => ({ type: "transform", id, transform: { ...transform,
|
||||||
...(transform.position ? { position: { x: (transform.position.x - ref.origin.x) / ref.scale, y: (transform.position.y - ref.origin.y) / ref.scale } } : {}),
|
...(transform.position ? { position: { x: (transform.position.x - ref.origin.x) / ref.scale, y: (transform.position.y - ref.origin.y) / ref.scale } } : {}),
|
||||||
...(transform.width !== undefined ? { width: transform.width / ref.scale, height: transform.height! / ref.scale } : {}) } }));
|
...(transform.width !== undefined ? { width: transform.width / ref.scale, height: transform.height! / ref.scale } : {}) } }));
|
||||||
}, []);
|
}, []);
|
||||||
|
/** 样式适配只构造旧style动作,具体字段合并由reducer处理。 */
|
||||||
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => treeRef.current.nodeAction(id, (id) => ({ type: "style", id, style, fontSize })), []);
|
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => treeRef.current.nodeAction(id, (id) => ({ type: "style", id, style, fontSize })), []);
|
||||||
|
/** 节点名称通过owner映射更新,避免将显示复合ID当持久ID。 */
|
||||||
const rename = useCallback((id: string, title: string) => treeRef.current.nodeAction(id, (id) => ({ type: "rename-node", id, title })), []);
|
const rename = useCallback((id: string, title: string) => treeRef.current.nodeAction(id, (id) => ({ type: "rename-node", id, title })), []);
|
||||||
|
/** 分区属性以replace提交;折叠切换从已保存expanded/collapsedSize或默认值恢复尺寸,并写node与style。 */
|
||||||
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {
|
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {
|
||||||
const current = treeRef.current, ref = current.views.nodeRefs.get(id); if (!ref) return;
|
const current = treeRef.current, ref = current.views.nodeRefs.get(id); if (!ref) return;
|
||||||
const document = current.documents.get(ref.boardId)!;
|
const document = current.documents.get(ref.boardId)!;
|
||||||
@@ -174,7 +217,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
return { ...node, ...(size ? { ...size, style: { ...node.style, ...size } } : {}), data: { ...node.data, ...patch } };
|
return { ...node, ...(size ? { ...size, style: { ...node.style, ...size } } : {}), data: { ...node.data, ...patch } };
|
||||||
}) } }, ref.boardId);
|
}) } }, ref.boardId);
|
||||||
}, []);
|
}, []);
|
||||||
|
// 注入节点动作及本地旋转/拉伸模式,两模式互斥;旧版接口没有新版手势token租约。
|
||||||
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, workspace: board.workspace_id, schema: 2 as const, rotating, resizing, resize: (id: string | null) => { setResizing(id); setRotating(null); }, rotate: (id: string | null) => { setRotating(id); setResizing(null); } }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating, resizing, board.workspace_id, state.present.schema_version]);
|
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, workspace: board.workspace_id, schema: 2 as const, rotating, resizing, resize: (id: string | null) => { setResizing(id); setRotating(null); }, rotate: (id: string | null) => { setRotating(id); setResizing(null); } }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating, resizing, board.workspace_id, state.present.schema_version]);
|
||||||
|
/** 在源owner文档创建节点与连线,at从场景坐标转局部;发布后尝试把视口移向新节点,异常留作提示。 */
|
||||||
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
|
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
|
||||||
try {
|
try {
|
||||||
const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined);
|
const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined);
|
||||||
@@ -184,11 +229,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
setBranchRequest(null);
|
setBranchRequest(null);
|
||||||
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
||||||
}
|
}
|
||||||
|
/** 图片分支需要文件选择才能创建,先保存request并关菜单;文本/脚本立即创建。 */
|
||||||
function chooseBranch(kind: "text" | "image" | "script") {
|
function chooseBranch(kind: "text" | "image" | "script") {
|
||||||
if (!branchRequest) return;
|
if (!branchRequest) return;
|
||||||
if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); }
|
if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); }
|
||||||
else createBranch(branchRequest, kind);
|
else createBranch(branchRequest, kind);
|
||||||
}
|
}
|
||||||
|
/** 无有效目标的连接拖动若移动至少5px并落到空白pane,且源仍选中,则在落点打开分支菜单。 */
|
||||||
const connectionEnd: OnConnectEnd = (event, connection) => {
|
const connectionEnd: OnConnectEnd = (event, connection) => {
|
||||||
const start = connectionStart.current;
|
const start = connectionStart.current;
|
||||||
connectionStart.current = null;
|
connectionStart.current = null;
|
||||||
@@ -201,11 +248,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at });
|
setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 按窗口中心转换为场景点并加六档偏移,减少连续添加完全重叠;不是按编辑器容器中心计算。 */
|
||||||
const center = () => {
|
const center = () => {
|
||||||
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
||||||
const offset = (nodes.length % 6) * 18;
|
const offset = (nodes.length % 6) * 18;
|
||||||
return { x: point.x - 130 + offset, y: point.y - 80 + offset };
|
return { x: point.x - 130 + offset, y: point.y - 80 + offset };
|
||||||
};
|
};
|
||||||
|
/** 根节点上限500;真实分区走subgraphs命令创建子画布,其它节点本地加入,演示frame也走本地模型。 */
|
||||||
function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
|
function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
|
||||||
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
|
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
|
||||||
const node = makeCanvasNode(kind, center(), src);
|
const node = makeCanvasNode(kind, center(), src);
|
||||||
@@ -218,6 +267,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
dispatch({ type: "nodes", changes: [{ type: "add", item: node }] });
|
dispatch({ type: "nodes", changes: [{ type: "add", item: node }] });
|
||||||
setTool("select");
|
setTool("select");
|
||||||
}
|
}
|
||||||
|
/** 一次replace插入三张预填卡片和两条连线,预留三个节点容量并切回选择工具;不触发AI生成。 */
|
||||||
function addTemplate() {
|
function addTemplate() {
|
||||||
if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
|
if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
|
||||||
const position = center();
|
const position = center();
|
||||||
@@ -233,6 +283,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
] } });
|
] } });
|
||||||
setTool("select");
|
setTool("select");
|
||||||
}
|
}
|
||||||
|
/** idle才建立checkpoint与拖动起点;有frame参与则不显示普通拖入预览,防止移动分区误收纳覆盖内容。 */
|
||||||
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||||
if (dragSession.current !== "idle") return; dragSession.current = "active"; tree.setDragging(true);
|
if (dragSession.current !== "idle") return; dragSession.current = "active"; tree.setDragging(true);
|
||||||
beginEdit(node.id); closeBranch(); setRotating(null); setResizing(null);
|
beginEdit(node.id); closeBranch(); setRotating(null); setResizing(null);
|
||||||
@@ -242,6 +293,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]);
|
setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]);
|
||||||
setMembershipNotice("");
|
setMembershipNotice("");
|
||||||
}
|
}
|
||||||
|
/** 只结束active拖动,先清拖动/保存暂停标记,再按真实位移计算归属;真实子图与旧frameId归属分路处理。
|
||||||
|
* 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */
|
||||||
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||||
if (dragSession.current !== "active") return;
|
if (dragSession.current !== "active") return;
|
||||||
const origins = new Map(dragOrigins.current);
|
const origins = new Map(dragOrigins.current);
|
||||||
@@ -252,19 +305,23 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
const positions = new Map(items.map((item) => [item.id, item.position]));
|
const positions = new Map(items.map((item) => [item.id, item.position]));
|
||||||
const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item);
|
const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item);
|
||||||
|
// 先生成“加入/移出”展示文案;实际owner由下面的真实子图路径重新判定,成功后才显示结果通知。
|
||||||
const changed = frameDropFeedback(dropped, ids).filter((item) => item.from !== item.to);
|
const changed = frameDropFeedback(dropped, ids).filter((item) => item.from !== item.to);
|
||||||
const messages = changed.map((item) => item.to ? `已加入「${frameFeedback.names.get(item.to) || "分区"}」` : `已移出「${frameFeedback.names.get(item.from!) || "分区"}」`);
|
const messages = changed.map((item) => item.to ? `已加入「${frameFeedback.names.get(item.to) || "分区"}」` : `已移出「${frameFeedback.names.get(item.from!) || "分区"}」`);
|
||||||
const notice = messages.length ? `${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}` : "";
|
const notice = messages.length ? `${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}` : "";
|
||||||
if (snapshot && tree.rootDocument.schema_version === 2) {
|
if (snapshot && tree.rootDocument.schema_version === 2) {
|
||||||
try {
|
try {
|
||||||
|
// 按source/target分组,同组节点一起迁移;候选分区按面积升序,优先最小包围分区。
|
||||||
const byOwner = new Map<string, { ids: string[]; target: string }>();
|
const byOwner = new Map<string, { ids: string[]; target: string }>();
|
||||||
const parents = subgraphParents(tree.current.current);
|
const parents = subgraphParents(tree.current.current);
|
||||||
for (const item of items.filter((item) => ids.includes(item.id))) {
|
for (const item of items.filter((item) => ids.includes(item.id))) {
|
||||||
const ref = tree.views.nodeRefs.get(item.id); if (!ref) continue;
|
const ref = tree.views.nodeRefs.get(item.id); if (!ref) continue;
|
||||||
const center = { x: item.position.x + (item.width || 260) / 2, y: item.position.y + (item.height || 200) / 2 };
|
const center = { x: item.position.x + (item.width || 260) / 2, y: item.position.y + (item.height || 200) / 2 };
|
||||||
|
/** 沿子图父链防环检查,不能把分区入口移动进自己的后代,形成结构循环。 */
|
||||||
const inMovingSubtree = (child: string) => { const seen = new Set<string>(); let cursor: string | undefined = child; while (cursor && !seen.has(cursor)) { if (cursor === item.data.subgraph?.boardId) return true; seen.add(cursor); cursor = parents.get(cursor)?.boardId; } return false; };
|
const inMovingSubtree = (child: string) => { const seen = new Set<string>(); let cursor: string | undefined = child; while (cursor && !seen.has(cursor)) { if (cursor === item.data.subgraph?.boardId) return true; seen.add(cursor); cursor = parents.get(cursor)?.boardId; } return false; };
|
||||||
const candidates = dropped.filter((candidate) => candidate.id !== item.id && candidate.data.subgraph && !inMovingSubtree(candidate.data.subgraph.boardId) && center.x >= candidate.position.x && center.y >= candidate.position.y && center.x <= candidate.position.x + (candidate.width || 640) && center.y <= candidate.position.y + (candidate.height || 400)).sort((a, b) => (a.width || 640) * (a.height || 400) - (b.width || 640) * (b.height || 400));
|
const candidates = dropped.filter((candidate) => candidate.id !== item.id && candidate.data.subgraph && !inMovingSubtree(candidate.data.subgraph.boardId) && center.x >= candidate.position.x && center.y >= candidate.position.y && center.x <= candidate.position.x + (candidate.width || 640) && center.y <= candidate.position.y + (candidate.height || 400)).sort((a, b) => (a.width || 640) * (a.height || 400) - (b.width || 640) * (b.height || 400));
|
||||||
const target = candidates[0]?.data.subgraph?.boardId || (ref.boardId === board.id ? board.id : parents.get(ref.boardId)?.boardId) || board.id;
|
const target = candidates[0]?.data.subgraph?.boardId || (ref.boardId === board.id ? board.id : parents.get(ref.boardId)?.boardId) || board.id;
|
||||||
|
// 未上传且满足条件的占位图片先走本地迁移;其它节点需要服务端成员事务。
|
||||||
if (target !== ref.boardId && tree.current.current.get(ref.boardId)?.nodes.find((node)=>node.id===ref.id)?.pendingUpload) {
|
if (target !== ref.boardId && tree.current.current.get(ref.boardId)?.nodes.find((node)=>node.id===ref.id)?.pendingUpload) {
|
||||||
tree.movePendingImage(ref.boardId,target,ref.id);continue;
|
tree.movePendingImage(ref.boardId,target,ref.id);continue;
|
||||||
}
|
}
|
||||||
@@ -272,6 +329,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group);
|
const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 不同owner组并行提出任务,每组沿父子路径逐跳等待;tree内部队列仍串行发送成员请求。
|
||||||
await Promise.all([...byOwner].map(async ([key, group]) => {
|
await Promise.all([...byOwner].map(async ([key, group]) => {
|
||||||
let source = key.split("/")[0];
|
let source = key.split("/")[0];
|
||||||
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
|
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
|
||||||
@@ -287,10 +345,12 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 先把入口端口解析成实际内部节点;同owner本地加边,跨owner优先服务端connections事务,缺能力时尝试暴露端口适配。 */
|
||||||
async function connect(connection: Connection) {
|
async function connect(connection: Connection) {
|
||||||
const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target);
|
const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target);
|
||||||
if (!source || !target) return;
|
if (!source || !target) return;
|
||||||
try {
|
try {
|
||||||
|
/** 按深度计数0—8解析子图入口,每层必须找到明确port映射;仍未到实际节点或缺端口则拒绝。 */
|
||||||
const resolve = async (ref: typeof source, handle: string | null | undefined): Promise<{ boardId: string; id: string; handle: string }> => {
|
const resolve = async (ref: typeof source, handle: string | null | undefined): Promise<{ boardId: string; id: string; handle: string }> => {
|
||||||
let endpoint = { boardId: ref!.boardId, id: ref!.id, handle: handle || "right" };
|
let endpoint = { boardId: ref!.boardId, id: ref!.id, handle: handle || "right" };
|
||||||
for (let depth = 0; depth <= 8; depth++) {
|
for (let depth = 0; depth <= 8; depth++) {
|
||||||
@@ -303,6 +363,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
throw new Error("subgraph_cycle");
|
throw new Error("subgraph_cycle");
|
||||||
};
|
};
|
||||||
const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left");
|
const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left");
|
||||||
|
// 同一canvas快照可直接加边;跨canvas需要共同上层/边界端口关系,不能把显示端点直接写进任一子快照。
|
||||||
if (realSource.boardId === realTarget.boardId) {
|
if (realSource.boardId === realTarget.boardId) {
|
||||||
const document = tree.current.current.get(realSource.boardId)!;
|
const document = tree.current.current.get(realSource.boardId)!;
|
||||||
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ id: crypto.randomUUID(), source: realSource.id, target: realTarget.id, sourceHandle: realSource.handle, targetHandle: realTarget.handle }, document.edges) } }, realSource.boardId);
|
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ id: crypto.randomUUID(), source: realSource.id, target: realTarget.id, sourceHandle: realSource.handle, targetHandle: realTarget.handle }, document.edges) } }, realSource.boardId);
|
||||||
@@ -316,11 +377,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 兼容路径由exposedConnection提供可寻址端点与owner,失败提示跨画布连接暂不可用。
|
||||||
const exposed = exposedConnection(tree.current.current, realSource, realTarget);
|
const exposed = exposedConnection(tree.current.current, realSource, realTarget);
|
||||||
const document = tree.current.current.get(exposed.boardId)!;
|
const document = tree.current.current.get(exposed.boardId)!;
|
||||||
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ ...connection, source: exposed.source.id, target: exposed.target.id, sourceHandle: exposed.source.handle, targetHandle: exposed.target.handle, id: crypto.randomUUID() }, document.edges) } }, exposed.boardId);
|
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ ...connection, source: exposed.source.id, target: exposed.target.id, sourceHandle: exposed.source.handle, targetHandle: exposed.target.handle, id: crypto.randomUUID() }, document.edges) } }, exposed.boardId);
|
||||||
} catch (error) { setError(error instanceof Error && error.message === "boundary_port_required" ? "跨画布连接暂不可用,请稍后重试。" : errorText(error)); }
|
} catch (error) { setError(error instanceof Error && error.message === "boundary_port_required" ? "跨画布连接暂不可用,请稍后重试。" : errorText(error)); }
|
||||||
}
|
}
|
||||||
|
/** 校验MIME与大小并清文件input;迁移期间拒绝。真实画布通过上传占位,演示以Data URL直接加入。 */
|
||||||
async function readImage(event: ChangeEvent<HTMLInputElement>) {
|
async function readImage(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
@@ -331,6 +394,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
}
|
}
|
||||||
const request = pendingImage.current; pendingImage.current = null;
|
const request = pendingImage.current; pendingImage.current = null;
|
||||||
const kind = imageKind.current;
|
const kind = imageKind.current;
|
||||||
|
// 真实图片上传先取得本地Blob预览并构造draft,随后把pendingUpload节点及可选分支边发布到owner。
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
try { const job = uploads.start(file, (src) => {
|
try { const job = uploads.start(file, (src) => {
|
||||||
const current = treeRef.current;
|
const current = treeRef.current;
|
||||||
@@ -343,12 +407,14 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
const node=makeCanvasNode(kind,center(),src);if(kind==='plain-image')node.data.title=file.name.slice(0,300);
|
const node=makeCanvasNode(kind,center(),src);if(kind==='plain-image')node.data.title=file.name.slice(0,300);
|
||||||
return {owner:board.id,node};
|
return {owner:board.id,node};
|
||||||
});
|
});
|
||||||
|
// pendingUpload以node.id标记,后续资源完成/跨区移动/历史恢复都按这个身份找到占位。
|
||||||
const current=treeRef.current,doc=current.current.current.get(job.owner)!;
|
const current=treeRef.current,doc=current.current.current.get(job.owner)!;
|
||||||
const node={...job.node,pendingUpload:job.node.id};
|
const node={...job.node,pendingUpload:job.node.id};
|
||||||
current.dispatch({type:"replace",document:{...doc,nodes:[...doc.nodes,node],edges:[...doc.edges,...(job.edge?[job.edge]:[])]}},job.owner);
|
current.dispatch({type:"replace",document:{...doc,nodes:[...doc.nodes,node],edges:[...doc.edges,...(job.edge?[job.edge]:[])]}},job.owner);
|
||||||
setTool("select"); } catch(error) {setError(errorText(error));}
|
setTool("select"); } catch(error) {setError(errorText(error));}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 演示不上传服务端,FileReader读取全部字节为Data URL;纯图片另解码初始尺寸,finally释放读取中UI状态。
|
||||||
setImageUploading(true);
|
setImageUploading(true);
|
||||||
try {
|
try {
|
||||||
const src = await new Promise<string>((resolve, reject) => {
|
const src = await new Promise<string>((resolve, reject) => {
|
||||||
@@ -370,6 +436,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
} catch (error) { pendingImage.current = null; setError(errorText(error)); }
|
} catch (error) { pendingImage.current = null; setError(errorText(error)); }
|
||||||
finally { setImageUploading(false); }
|
finally { setImageUploading(false); }
|
||||||
}
|
}
|
||||||
|
/** 旧版导入最多5MB并全量读取解码,替换当前根快照;任一方含真实子图时拒绝,避免破坏跨board关系。
|
||||||
|
* ioActive用于阻止迁移交接;这条路径不是新版渐进导入,也不会自动重建bundle子图。 */
|
||||||
async function importProject(event: ChangeEvent<HTMLInputElement>) {
|
async function importProject(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
@@ -387,6 +455,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
|
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
|
||||||
finally{ioActive.current=false;}
|
finally{ioActive.current=false;}
|
||||||
}
|
}
|
||||||
|
/** 有上传占位则先等待用户完成;树导出完整可达文档,多board写bundle,单board写当前视口。
|
||||||
|
* 下载名清理文件名非法字符,Blob URL延迟释放;不额外打包File/Blob,已有演示Data URL仍随src导出。 */
|
||||||
async function exportProject() {
|
async function exportProject() {
|
||||||
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
|
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
|
||||||
try { if([...tree.current.current.values()].some((doc)=>doc.nodes.some((node)=>node.pendingUpload))) throw new Error("图片仍在上传,完成后即可导出。"); documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
|
try { if([...tree.current.current.values()].some((doc)=>doc.nodes.some((node)=>node.pendingUpload))) throw new Error("图片仍在上传,完成后即可导出。"); documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
|
||||||
@@ -399,7 +469,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 旧版全局快捷键:跳过输入框、文本编辑、对话框和打开的popover,支持撤销/重做与V/H;卸载移除。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
/** 按DOM目标过滤后处理键;此旧路径没有新版routeCanvasKey的组合输入/认领优先级模型。 */
|
||||||
const keydown = (event: KeyboardEvent) => {
|
const keydown = (event: KeyboardEvent) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof HTMLElement)) return;
|
||||||
@@ -417,6 +489,11 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
return () => window.removeEventListener("keydown", keydown);
|
return () => window.removeEventListener("keydown", keydown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。
|
||||||
|
// ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。
|
||||||
|
// 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。
|
||||||
|
// 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。
|
||||||
|
// 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
||||||
<ImagePreviews.Provider value={imagePreviews}>
|
<ImagePreviews.Provider value={imagePreviews}>
|
||||||
@@ -427,6 +504,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
|
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
|
||||||
onBeforeDelete={async ({ nodes: removing }) => {
|
onBeforeDelete={async ({ nodes: removing }) => {
|
||||||
if (tree.busy || tree.transactionBusy) return false;
|
if (tree.busy || tree.transactionBusy) return false;
|
||||||
|
// 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。
|
||||||
const linked = removing.filter((node) => node.data.subgraph);
|
const linked = removing.filter((node) => node.data.subgraph);
|
||||||
const parents = subgraphParents(tree.current.current);
|
const parents = subgraphParents(tree.current.current);
|
||||||
const referenced = removing.filter((node) => {
|
const referenced = removing.filter((node) => {
|
||||||
@@ -436,6 +514,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
});
|
});
|
||||||
if (!linked.length && !referenced.length) return true;
|
if (!linked.length && !referenced.length) return true;
|
||||||
try {
|
try {
|
||||||
|
// 分区逐个事务解散,参与者包含父、子、孙及需要调整引用的祖先;并非一批选择全体原子操作。
|
||||||
for (const node of linked) {
|
for (const node of linked) {
|
||||||
const ref = tree.views.nodeRefs.get(node.id)!;
|
const ref = tree.views.nodeRefs.get(node.id)!;
|
||||||
await tree.ensure(node.data.subgraph!.boardId);
|
await tree.ensure(node.data.subgraph!.boardId);
|
||||||
@@ -445,6 +524,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
if (ancestor && subgraphMoveParticipants(tree.current.current, ref.boardId, node.data.subgraph!.boardId, ref.boardId, child.nodes.map((item) => item.id)).includes(ancestor)) participants.add(ancestor);
|
if (ancestor && subgraphMoveParticipants(tree.current.current, ref.boardId, node.data.subgraph!.boardId, ref.boardId, child.nodes.map((item) => item.id)).includes(ancestor)) participants.add(ancestor);
|
||||||
await tree.command(ref.boardId, `subgraphs/${ref.id}/dissolve`, {}, [...participants]);
|
await tree.command(ref.boardId, `subgraphs/${ref.id}/dissolve`, {}, [...participants]);
|
||||||
}
|
}
|
||||||
|
// 解散后owner可能变化,按原ID重新查唯一归属;找不到唯一目标就跳过,不误删同ID的其它节点。
|
||||||
for (const node of removing.filter((node) => !node.data.subgraph)) {
|
for (const node of removing.filter((node) => !node.data.subgraph)) {
|
||||||
const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue;
|
const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue;
|
||||||
let owner = ref.boardId;
|
let owner = ref.boardId;
|
||||||
@@ -461,6 +541,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
}
|
}
|
||||||
if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留");
|
if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留");
|
||||||
} catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); }
|
} catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); }
|
||||||
|
// 结构分支已自行执行删除/解散,阻止ReactFlow再按旧投影默认删一遍。
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
onNodesChange={tree.nodesChanged}
|
onNodesChange={tree.nodesChanged}
|
||||||
@@ -486,6 +567,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
|
defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
|
||||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
|
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
|
||||||
{ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => {
|
{ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => {
|
||||||
|
// AIPanel收到owner原始节点,显示ID仅用于NodeToolbar定位;save仍经tree.nodeAction回写owner。
|
||||||
const ref = tree.views.nodeRefs.get(node.id)!;
|
const ref = tree.views.nodeRefs.get(node.id)!;
|
||||||
const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.id);
|
const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.id);
|
||||||
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
|
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
|
||||||
@@ -558,6 +640,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
|
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
|
||||||
</div>}
|
</div>}
|
||||||
{tree.memberJobs.map((job, index) => job.ids.map((id) => {
|
{tree.memberJobs.map((job, index) => job.ids.map((id) => {
|
||||||
|
// 成员任务提示跟随当前source/target中的场景节点,队首同步、后续等待,失败通过retryMembers继续。
|
||||||
const node = [...tree.views.nodeRefs].find(([, ref]) => ref.id === id && [job.source, job.target].includes(ref.boardId));
|
const node = [...tree.views.nodeRefs].find(([, ref]) => ref.id === id && [job.source, job.target].includes(ref.boardId));
|
||||||
return node && <NodeToolbar key={`${job.source}/${id}`} nodeId={node[0]} isVisible position={Position.Top}>
|
return node && <NodeToolbar key={`${job.source}/${id}`} nodeId={node[0]} isVisible position={Position.Top}>
|
||||||
<div className={`${styles.memberSync} nodrag nopan`} role="status" aria-label="元素同步状态" data-node-id={node[0]}>
|
<div className={`${styles.memberSync} nodrag nopan`} role="status" aria-label="元素同步状态" data-node-id={node[0]}>
|
||||||
@@ -577,6 +660,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 为单个旧编辑器建立独立ReactFlow store;board/snapshot身份切换的重挂载由路由调用方负责。 */
|
||||||
export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
||||||
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>;
|
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 新版主画布的React/ReactFlow装配。engine拥有文档、历史和运行事实,sessions拥有每视图选择/工具/手势;组件只保存显示缓存与面板状态。
|
||||||
|
* 文档提交先推进场景索引,再按事件类型立即或逐帧发布;几何预览通过session进入投影,提交才产生文档修改。
|
||||||
|
* 画布适配层负责DOM测量、命中、裁剪、焦点和资源显示,core不读取DOM;渲染组件通过动作上下文调用editor/runtime。
|
||||||
|
* 外层只借用项目engine:挂载attach视图,卸载detach,不关闭工作区持有的项目或持久任务。
|
||||||
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
|
||||||
@@ -40,9 +46,13 @@ import { UnsupportedNode, UnsupportedEdge } from "./unsupported-elements";
|
|||||||
import { ImagePreviews } from "./canvas-image";
|
import { ImagePreviews } from "./canvas-image";
|
||||||
import styles from "./canvas.module.css";
|
import styles from "./canvas.module.css";
|
||||||
|
|
||||||
|
/** 把适配层样式等可序列化值规范化成JSON Data,去掉undefined等JSON不保留的属性;不处理循环或任意非JSON对象。 */
|
||||||
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
|
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
|
||||||
|
/** 已attach视图的内容组件;需要外层ReactFlowProvider与CanvasProvider。root/session是发布快照,current供回调读取最新投影。 */
|
||||||
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
|
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
|
||||||
|
// 场景索引按engine+view复用,提交订阅先advance变更,再通知显示,避免每次渲染重建整图索引。
|
||||||
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
|
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
|
||||||
|
/** 用户字段修改/替换删除及undo/redo立即发布,保证受控输入及时回显;批量创建和后台修改允许合并到下一帧。 */
|
||||||
const subscribe = useCallback((notify: (immediate?:boolean) => void) => {
|
const subscribe = useCallback((notify: (immediate?:boolean) => void) => {
|
||||||
let before=engine.getSnapshot().document;
|
let before=engine.getSnapshot().document;
|
||||||
return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document;
|
return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document;
|
||||||
@@ -52,16 +62,20 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
notify(immediate);
|
notify(immediate);
|
||||||
});
|
});
|
||||||
}, [engine,sceneCache]);
|
}, [engine,sceneCache]);
|
||||||
|
// rootFrames只调度呈现通知,不延迟editor真实提交;sessionFrames独立合并手势/选择等视图变化。
|
||||||
const rootFrames=useMemo(()=>createFrameSource({subscribe,getSnapshot:engine.getSnapshot},{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine,subscribe]);
|
const rootFrames=useMemo(()=>createFrameSource({subscribe,getSnapshot:engine.getSnapshot},{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine,subscribe]);
|
||||||
const root = useSyncExternalStore(rootFrames.subscribe, rootFrames.getSnapshot, rootFrames.getSnapshot);
|
const root = useSyncExternalStore(rootFrames.subscribe, rootFrames.getSnapshot, rootFrames.getSnapshot);
|
||||||
const sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]);
|
const sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]);
|
||||||
const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot);
|
const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot);
|
||||||
|
// 视图被detach后保留最近view供本轮Hook计算,最终返回“画布已关闭”;不以lastView恢复已关闭会话。
|
||||||
const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
|
const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
|
||||||
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
|
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
|
||||||
|
|
||||||
if (liveView) lastView.current = liveView;
|
if (liveView) lastView.current = liveView;
|
||||||
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
|
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
|
||||||
|
// 完整加载后允许普通创建;加载中要求loading版本支持且当前canvas覆盖完整,最终命令层仍再校验准入。
|
||||||
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
|
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
|
||||||
|
// 加载预览首次适配只取当前canvas直接节点,避免展开后代把视口拉得过远;有交接视口时不再自动fit。
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return;
|
if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return;
|
||||||
// Expanded descendants can extend far beyond their partition entry. Initial
|
// Expanded descendants can extend far beyond their partition entry. Initial
|
||||||
@@ -70,7 +84,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
if(!nodes.length)return;
|
if(!nodes.length)return;
|
||||||
needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1});
|
needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1});
|
||||||
},[engine,flow,root.document,view.canvasId]);
|
},[engine,flow,root.document,view.canvasId]);
|
||||||
|
// 复制准备任务来自runtime状态;copying防本地重复点击,两者共同禁用复制入口。
|
||||||
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
|
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
|
||||||
|
// 焦点与epoch用于保留活动输入和排除迟到blur;切engine/view复位,移除renderer未触发blur时另检查isConnected。
|
||||||
const [focusedEditor, setFocusedEditor] = useState<HTMLElement | null>(null), focusEpoch = useRef(0);
|
const [focusedEditor, setFocusedEditor] = useState<HTMLElement | null>(null), focusEpoch = useRef(0);
|
||||||
useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]);
|
useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]);
|
||||||
// Removing an active renderer need not dispatch a native blur. Retire only
|
// Removing an active renderer need not dispatch a native blur. Retire only
|
||||||
@@ -78,19 +94,26 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);});
|
useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);});
|
||||||
const [copying, setCopying] = useState(false);
|
const [copying, setCopying] = useState(false);
|
||||||
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
|
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
|
||||||
|
// 选择转成id/incarnation后查询深复制可用性;渲染时的可用性仅用于UI,点击时再次捕获实际选择交runtime。
|
||||||
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
|
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
|
||||||
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
|
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
// resource显示状态带engine/view身份,旧显示器迟到回调不能为当前画布提供错误图片;sources不是文档资源表。
|
||||||
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
|
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
|
||||||
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null);
|
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null);
|
||||||
|
// 拖动保存起始场景坐标、指针ID与会话token,positions只含顶层选中节点,防分区与成员重复位移。
|
||||||
const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
|
const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
|
||||||
|
// 复用投影对象与测量结果,避免ReactFlow把纯元数据变化误当节点卸载,影响输入及连接点。
|
||||||
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
|
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
|
||||||
projectionCache.current ??= createProjectionCache();
|
projectionCache.current ??= createProjectionCache();
|
||||||
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
|
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
|
||||||
|
// groups按元素生命周期保存一次文本编辑的history分组ID,元素重建后清旧分组,不能跨incarnation合并编辑。
|
||||||
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id,{incarnation:number;groupId:Id}>());
|
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id,{incarnation:number;groupId:Id}>());
|
||||||
useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]);
|
useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]);
|
||||||
|
// 加载期使用受限renderer catalog,完整后回到正常定义;sceneCache把文档、会话预览与查询组成显示场景。
|
||||||
const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
|
const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
|
||||||
const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document));
|
const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document));
|
||||||
|
// 仅在尺寸一致且投影缓存确认身份/变换允许时复用measured;离开场景的节点清测量条目。
|
||||||
const projection = useMemo(() => {
|
const projection = useMemo(() => {
|
||||||
const result = scene;
|
const result = scene;
|
||||||
// Preserve renderer measurements across metadata/preview updates. Dropping them
|
// Preserve renderer measurements across metadata/preview updates. Dropping them
|
||||||
@@ -100,36 +123,50 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; }
|
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; }
|
||||||
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
|
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
|
||||||
}, [engine, root.document, scene,view.canvasId]);
|
}, [engine, root.document, scene,view.canvasId]);
|
||||||
|
// 每个view独立OverlayIndex,Portal控件通过索引贡献边界与焦点;不会把浮层几何存入项目文档。
|
||||||
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
|
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
|
||||||
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
|
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
|
||||||
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
|
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
|
||||||
const surfaceBounds=surface.current?.getBoundingClientRect();
|
const surfaceBounds=surface.current?.getBoundingClientRect();
|
||||||
|
|
||||||
|
// 绘制范围索引用于可见性筛选;sceneViewport把ReactFlow平移/缩放反算为场景矩形。
|
||||||
const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]);
|
const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]);
|
||||||
const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]);
|
const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]);
|
||||||
const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
|
const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
|
||||||
const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom};
|
const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom};
|
||||||
|
// 过滤元素生命周期已变的浮层;缺surface矩形时只按身份保守保留,不凭缺失测量断言不可见。
|
||||||
const visibleOverlays=overlaySnapshot.length?(surfaceBounds?overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation):overlaySnapshot.filter(entry=>root.document.elements.get(entry.target.id)?.incarnation===entry.target.incarnation)):[];
|
const visibleOverlays=overlaySnapshot.length?(surfaceBounds?overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation):overlaySnapshot.filter(entry=>root.document.elements.get(entry.target.id)?.incarnation===entry.target.incarnation)):[];
|
||||||
|
// 未知绘制范围必须保留,加上矩形查询命中;不能把unknown当成屏幕外。
|
||||||
const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
|
const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
|
||||||
const nativeNodes=useStore(state=>state.nodes);
|
const nativeNodes=useStore(state=>state.nodes);
|
||||||
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
|
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
|
||||||
|
// 连线范围依赖真实Handle测量:新版/未知连线使用measuredConnector,兼容节点使用旧几何适配。
|
||||||
|
// 订阅nativeNodes让测量更新后重算连线路径范围,不把DOM测量要求下放core。
|
||||||
const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]);
|
const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]);
|
||||||
// Exhausted geometry work cannot prove any edge invisible. Retain the edge
|
// Exhausted geometry work cannot prove any edge invisible. Retain the edge
|
||||||
// layer and its endpoints, not unrelated nodes or a synthetic sentinel owner.
|
// layer and its endpoints, not unrelated nodes or a synthetic sentinel owner.
|
||||||
const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
|
const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
|
||||||
|
// 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。
|
||||||
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
||||||
|
// 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。
|
||||||
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
|
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
|
||||||
|
/** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */
|
||||||
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
|
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
|
||||||
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
|
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
|
||||||
}, []);
|
}, []);
|
||||||
|
/** 发命令前读取当前incarnation,元素缺失即抛错;渲染侧更早的身份检查由useScopedNodeEditing等负责。 */
|
||||||
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
|
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
|
||||||
|
/** 统一元素data补丁入口,可携history groupId;schema、能力和事务合法性留editor验证。 */
|
||||||
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
|
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
|
||||||
|
/** 把ReactFlow选择变化合到session当前Set,成员完全相同则不重复发布;不把selected写入文档。 */
|
||||||
function selectChanges(changes: readonly { id: Id; selected: boolean }[]) {
|
function selectChanges(changes: readonly { id: Id; selected: boolean }[]) {
|
||||||
const current = engine.sessions.getSnapshot().views.get(viewId)!;
|
const current = engine.sessions.getSnapshot().views.get(viewId)!;
|
||||||
const ids = new Set(current.selection);
|
const ids = new Set(current.selection);
|
||||||
for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id);
|
for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id);
|
||||||
if (ids.size !== current.selection.size || [...ids].some(id => !current.selection.has(id))) engine.sessions.select(viewId, [...ids]);
|
if (ids.size !== current.selection.size || [...ids].some(id => !current.selection.has(id))) engine.sessions.select(viewId, [...ids]);
|
||||||
}
|
}
|
||||||
|
/** 空白点击补充core投影命中:12px容差、预览目标及实际Handle/layer适配;只选择confirmed命中。
|
||||||
|
* 命中计算后再次比较document/session快照身份,过期帧不能选中新incarnation;抓手模式点击清选择。 */
|
||||||
function selectAtPane(event: {clientX:number;clientY:number}) {
|
function selectAtPane(event: {clientX:number;clientY:number}) {
|
||||||
const captured=current.current;
|
const captured=current.current;
|
||||||
if(captured.view.tool!=='select'){engine.sessions.select(viewId,[]);return;}
|
if(captured.view.tool!=='select'){engine.sessions.select(viewId,[]);return;}
|
||||||
@@ -149,12 +186,16 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
const hit=result.hits[0];
|
const hit=result.hits[0];
|
||||||
engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]);
|
engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]);
|
||||||
}
|
}
|
||||||
|
/** session.begin成功后捕获gesture token,以guardEditingLease约束后续preview/commit/cancel只作用于该交互。 */
|
||||||
function beginTransform(id: Id) {
|
function beginTransform(id: Id) {
|
||||||
const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false;
|
const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false;
|
||||||
const token = engine.sessions.getSnapshot().gesture!.token;
|
const token = engine.sessions.getSnapshot().gesture!.token;
|
||||||
|
/** 每次租约动作实时核对token,旧pointerup不能结束后来新建的手势。 */
|
||||||
const current = () => engine.sessions.getSnapshot().gesture?.token === token;
|
const current = () => engine.sessions.getSnapshot().gesture?.token === token;
|
||||||
return guardEditingLease(current, { preview: (change: Parameters<typeof transform>[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
return guardEditingLease(current, { preview: (change: Parameters<typeof transform>[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
||||||
}
|
}
|
||||||
|
/** 将显示空间位置/尺寸转成owner局部Geometry:尺寸除scale,以中心toLocal后再求左上角,旋转减去父旋转。
|
||||||
|
* 这里只更新session预览,不直接dispatch文档;后续commit统一形成history与同步修改。 */
|
||||||
function transform(id: Id, change: { position?: { x: number; y: number }; width?: number; height?: number; rotation?: number; fontSize?: number }) {
|
function transform(id: Id, change: { position?: { x: number; y: number }; width?: number; height?: number; rotation?: number; fontSize?: number }) {
|
||||||
const { root, projection } = current.current, element = root.document.elements.get(id);
|
const { root, projection } = current.current, element = root.document.elements.get(id);
|
||||||
if (element?.structure !== 'node') return;
|
if (element?.structure !== 'node') return;
|
||||||
@@ -165,16 +206,21 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
|
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
|
||||||
report(engine.sessions.update(viewId, target(id), patch));
|
report(engine.sessions.update(viewId, target(id), patch));
|
||||||
}
|
}
|
||||||
|
/** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。
|
||||||
|
* 默认位置从首个data-headless-canvas的DOM矩形取屏幕中心再转场景坐标,at可显式覆盖。 */
|
||||||
function add(kind: 'text' | 'plain-text' | 'image' | 'frame', ref?: { id: Id; src: string }, tx?: Parameters<Parameters<typeof engine.editor.transaction>[0]>[0], at?: { x: number; y: number }) {
|
function add(kind: 'text' | 'plain-text' | 'image' | 'frame', ref?: { id: Id; src: string }, tx?: Parameters<Parameters<typeof engine.editor.transaction>[0]>[0], at?: { x: number; y: number }) {
|
||||||
const id = crypto.randomUUID(), canvasId = current.current.view.canvasId;
|
const id = crypto.randomUUID(), canvasId = current.current.view.canvasId;
|
||||||
const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();
|
const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();
|
||||||
const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 });
|
const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 });
|
||||||
|
// 各类型使用固定默认尺寸及初始incarnation/dataVersion;资源引用由已stage/adopt的ref注入,不在这里上传。
|
||||||
const size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 };
|
const size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 };
|
||||||
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
|
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
|
||||||
|
/** tx内创建元素及可选canvas关系;无外部tx时包装为一个Add element事务。 */
|
||||||
const create = (transaction: NonNullable<typeof tx>) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); };
|
const create = (transaction: NonNullable<typeof tx>) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); };
|
||||||
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
|
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
/** 选择工具才可建边;拒绝unsupported端点,将owner设为两端所属canvas的共同上级,端口ID来自连接事件。 */
|
||||||
function connect(connection: Connection) {
|
function connect(connection: Connection) {
|
||||||
if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return;
|
if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return;
|
||||||
const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target);
|
const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target);
|
||||||
@@ -182,21 +228,30 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;}
|
if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;}
|
||||||
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
|
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
|
||||||
}
|
}
|
||||||
|
// 用节点ID+assetId序列作为显示订阅键,资源分辨率/URL交资源视图决定;组件不把Blob URL写入元素data。
|
||||||
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
|
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
|
||||||
|
// 每个engine/view建立资源显示器,回调只接受当前实例;清理dispose解除订阅及显示资源占用。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!liveView) return;
|
if (!liveView) return;
|
||||||
const view=createResourceView({resolve:(id,signal)=>engine.displayResource(id,{signal}),onChange:(sources,failures)=>{if(resourceView.current===view)setDisplayState({engine,viewId,sources,failures});},onError:error=>{if(resourceView.current===view)setError(errorText(error));}});
|
const view=createResourceView({resolve:(id,signal)=>engine.displayResource(id,{signal}),onChange:(sources,failures)=>{if(resourceView.current===view)setDisplayState({engine,viewId,sources,failures});},onError:error=>{if(resourceView.current===view)setError(errorText(error));}});
|
||||||
resourceView.current=view;
|
resourceView.current=view;
|
||||||
return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();};
|
return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();};
|
||||||
}, [engine,viewId,Boolean(liveView)]);
|
}, [engine,viewId,Boolean(liveView)]);
|
||||||
|
// 资源目标集合变化才sync,映射失败在本面板报错,不擅自修改文档中的assetId。
|
||||||
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
|
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
|
||||||
|
// 再按engine/view及当前assetId过滤结果,节点切换资源时不显示上一个资源的迟到URL。
|
||||||
const sources=useMemo(()=>{if(displayState.engine!==engine||displayState.viewId!==viewId)return new Map<Id,string>();const wanted=new Map(JSON.parse(resourceKey) as [Id,Id][]);return new Map([...displayState.sources].filter(([id,source])=>wanted.get(id)===source.resourceId).map(([id,source])=>[id,source.url]));},[displayState,engine,viewId,resourceKey]);
|
const sources=useMemo(()=>{if(displayState.engine!==engine||displayState.viewId!==viewId)return new Map<Id,string>();const wanted=new Map(JSON.parse(resourceKey) as [Id,Id][]);return new Map([...displayState.sources].filter(([id,source])=>wanted.get(id)===source.resourceId).map(([id,source])=>[id,source.url]));},[displayState,engine,viewId,resourceKey]);
|
||||||
|
/** 一次事务删除当前选择中仍存在的元素;关联结构处理由editor命令实现,不循环修改投影数组。 */
|
||||||
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
|
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
|
||||||
|
// 同步显示来自ledger:未resolved算待处理;waiting_digest单独等待不算阻断,无定时重试的错误和conflicts要提示处理。
|
||||||
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
|
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
|
||||||
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
|
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
|
||||||
|
// 全局失焦或Escape仅取消本视图拥有的节点/连接手势;不干扰同engine其它视图的交互。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
/** 核对viewId后取消session并清本地拖动记录;window blur不要求指针事件。 */
|
||||||
const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; };
|
const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; };
|
||||||
// Bubble after the target/React handlers: an input owner can claim Escape first.
|
// Bubble after the target/React handlers: an input owner can claim Escape first.
|
||||||
|
/** 冒泡阶段让输入控件优先认领Escape,再按统一路由决定是否取消手势。 */
|
||||||
const escape = (event: KeyboardEvent) => {
|
const escape = (event: KeyboardEvent) => {
|
||||||
if(event.key!=='Escape')return;
|
if(event.key!=='Escape')return;
|
||||||
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{gesture:()=>{const state=engine.sessions.getSnapshot();if(state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;cancel();return true;}});
|
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{gesture:()=>{const state=engine.sessions.getSnapshot();if(state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;cancel();return true;}});
|
||||||
@@ -208,10 +263,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
const modes = view.modes;
|
const modes = view.modes;
|
||||||
// Retain explicit owners in the native renderer, leaving the full graph in its
|
// Retain explicit owners in the native renderer, leaving the full graph in its
|
||||||
// lookup for edges/queries. No synthetic dragging or altered geometry is used.
|
// lookup for edges/queries. No synthetic dragging or altered geometry is used.
|
||||||
|
// 保留模式目标、可见绘制对象、浮层目标和本视图手势;完整图仍留ReactFlow查询表,不以伪造拖动避裁剪。
|
||||||
const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]);
|
const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]);
|
||||||
for(const entry of visibleOverlays)retained.add(entry.target.id);
|
for(const entry of visibleOverlays)retained.add(entry.target.id);
|
||||||
if(sessions.gesture?.viewId===viewId)for(const target of sessions.gesture.targets)retained.add(target.id);
|
if(sessions.gesture?.viewId===viewId)for(const target of sessions.gesture.targets)retained.add(target.id);
|
||||||
if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
|
if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
|
||||||
|
// 活动输入可来自节点、连线、Portal浮层或独立UI;无法证明其归属时保守关闭可见性裁剪。
|
||||||
const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
|
const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
|
||||||
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
|
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
|
||||||
const focusedUI=activeEditor?.closest<HTMLElement>('[data-canvas-ui-view]');
|
const focusedUI=activeEditor?.closest<HTMLElement>('[data-canvas-ui-view]');
|
||||||
@@ -221,10 +278,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
if(focusedId)retained.add(focusedId);
|
if(focusedId)retained.add(focusedId);
|
||||||
// Retained connections also keep their measured endpoint owners alive.
|
// Retained connections also keep their measured endpoint owners alive.
|
||||||
for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}}
|
for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}}
|
||||||
|
// 所有保留对象仍须存在于文档,且视口尺寸已知,才启用native onlyRenderVisibleElements。
|
||||||
const scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id));
|
const scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id));
|
||||||
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
|
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
|
||||||
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
|
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
|
||||||
const rendered=retainedProjection(projection.nodes,projection.edges,retained);
|
const rendered=retainedProjection(projection.nodes,projection.edges,retained);
|
||||||
|
// 单选时才启用图片适配/AI工具;图片要求已知组件、resize能力和声明的assetId资源字段,不能猜同名业务字段。
|
||||||
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
|
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
|
||||||
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
|
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
|
||||||
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
|
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
|
||||||
@@ -233,15 +292,19 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
// Actions use current.current / engine snapshots instead of captured document projections.
|
// Actions use current.current / engine snapshots instead of captured document projections.
|
||||||
const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null;
|
const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null;
|
||||||
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
|
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
|
||||||
|
// 新版资源URL寿命由resourceView管理,CanvasImage的loaded回调这里无需删除旧版上传任务。
|
||||||
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
|
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
|
||||||
|
// 给旧节点renderer注入新版动作:begin/end维护文本history分组与editing mode,正文按同incarnation使用groupId。
|
||||||
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
|
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
|
||||||
begin: id => { if (id) { groups.current.set(id,{incarnation:target(id).incarnation,groupId:crypto.randomUUID()}); engine.sessions.mode(viewId, id, 'editing'); } },
|
begin: id => { if (id) { groups.current.set(id,{incarnation:target(id).incarnation,groupId:crypto.randomUUID()}); engine.sessions.mode(viewId, id, 'editing'); } },
|
||||||
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
|
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
|
||||||
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
|
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
|
||||||
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||||||
|
// 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。
|
||||||
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
|
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
|
||||||
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
|
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
|
||||||
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
|
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
|
||||||
|
// 分支直接在当前canvas按方向偏移创建文本,再同一事务创建连线;与旧版弹类型菜单的行为不同。
|
||||||
branch: (id, direction) => {
|
branch: (id, direction) => {
|
||||||
const node = projectionCache.current!.getNode(id); if (!node) return;
|
const node = projectionCache.current!.getNode(id); if (!node) return;
|
||||||
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
|
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
|
||||||
@@ -249,19 +312,23 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
},
|
},
|
||||||
|
|
||||||
}),[engine,viewId,board.workspace_id,flow,report,rotating,resizing]);
|
}),[engine,viewId,board.workspace_id,flow,report,rotating,resizing]);
|
||||||
|
// 曲线编辑同样捕获connectionGesture token;active暂为空集合,连线renderer在此不从run状态获取执行动画。
|
||||||
const edgeEditing = useMemo<ContextType<typeof EdgeEditing>>(() => ({ active: new Set(), begin: id => {
|
const edgeEditing = useMemo<ContextType<typeof EdgeEditing>>(() => ({ active: new Set(), begin: id => {
|
||||||
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
|
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
|
||||||
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
|
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
|
||||||
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
||||||
|
/** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */
|
||||||
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
|
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
|
||||||
return guardEditingLease(ownsGesture, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
return guardEditingLease(ownsGesture, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
||||||
},
|
},
|
||||||
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||||||
|
// 临时path只更新session;正式修改把data和path分开命令,在同一个事务提交,避免把路径写进业务data。
|
||||||
patch: (id, value, transient) => {
|
patch: (id, value, transient) => {
|
||||||
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
|
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
|
||||||
const t = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
const t = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
||||||
const path = transformPath((value.path ?? null) as Json, t, true);
|
const path = transformPath((value.path ?? null) as Json, t, true);
|
||||||
const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path;
|
const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path;
|
||||||
|
// 工具栏可能传回整个显示data;只有显式含path且与显示路径不同才提交connection.path,避免无意重写。
|
||||||
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
|
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
|
||||||
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
|
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
|
||||||
const { path: ignored, ...rest } = value; void ignored;
|
const { path: ignored, ...rest } = value; void ignored;
|
||||||
@@ -269,8 +336,15 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
},
|
},
|
||||||
|
|
||||||
}),[engine,viewId,report]);
|
}),[engine,viewId,report]);
|
||||||
|
// 自定义renderer的dispatch/transaction也经过editor,并统一report拒绝;选项只传label/groupId。
|
||||||
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
|
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
|
||||||
|
// 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。
|
||||||
if (!liveView) return <p role="status">画布已关闭</p>;
|
if (!liveView) return <p role="status">画布已关闭</p>;
|
||||||
|
// surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。
|
||||||
|
// 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。
|
||||||
|
// ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。
|
||||||
|
// 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。
|
||||||
|
// 文件选择调用stageFiles,引用与图片节点在回调事务里一起创建;导入/AI/资源/恢复面板按加载与选择条件挂载。
|
||||||
return <div ref={surface} className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} tabIndex={-1}
|
return <div ref={surface} className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} tabIndex={-1}
|
||||||
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }}
|
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }}
|
||||||
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setFocusedEditor(null); }); }}
|
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setFocusedEditor(null); }); }}
|
||||||
@@ -296,6 +370,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
|
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
|
||||||
onNodeDragStart={(event, node, nodes) => {
|
onNodeDragStart={(event, node, nodes) => {
|
||||||
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
|
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
|
||||||
|
// 同时选中分区入口与后代时只对最上层目标启动手势,后代位置由结构投影带动。
|
||||||
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
|
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
|
||||||
const point = 'clientX' in event ? event : event.changedTouches[0];
|
const point = 'clientX' in event ? event : event.changedTouches[0];
|
||||||
drag.current = { token: 0,pointerId:pointerStart.current?.pointerId, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) };
|
drag.current = { token: 0,pointerId:pointerStart.current?.pointerId, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) };
|
||||||
@@ -305,6 +380,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))}
|
onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))}
|
||||||
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
|
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
|
||||||
onNodeDragStop={event => {
|
onNodeDragStop={event => {
|
||||||
|
// 仅结束匹配本视图和token的拖动,迟到dragStop不提交当前其它手势。
|
||||||
const saved = drag.current; drag.current = null; pointerStart.current = null;
|
const saved = drag.current; drag.current = null; pointerStart.current = null;
|
||||||
if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return;
|
if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return;
|
||||||
const point = 'clientX' in event ? event : event.changedTouches[0], end = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
|
const point = 'clientX' in event ? event : event.changedTouches[0], end = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
|
||||||
@@ -318,6 +394,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
<div data-canvas-ui-view={viewId} className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
<div data-canvas-ui-view={viewId} className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||||
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
|
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
|
||||||
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
|
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
|
||||||
|
// 深复制点击时捕获选择身份,异步完成只更新同engine/view的面板;runtime负责进度与已加入内容。
|
||||||
const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation}));
|
const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation}));
|
||||||
setCopying(true); setError("");
|
setCopying(true); setError("");
|
||||||
void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); });
|
void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); });
|
||||||
@@ -339,9 +416,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
|
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
/** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。
|
||||||
|
* 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */
|
||||||
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
|
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
|
||||||
const [handoffViewport]=useState(initialViewport);
|
const [handoffViewport]=useState(initialViewport);
|
||||||
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
||||||
|
// attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。
|
||||||
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
|
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
|
||||||
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 核心编辑器的组合回归测试。用内存文档与内置/自定义registry验证事务发布、history、组件兼容和临时手势,不连接服务端。
|
||||||
|
* 多数失败断言同时检查根对象身份,确保“拒绝”不仅返回错误,还没有发布中间文档/历史/运行元数据。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createDocument, createEditor, createRegistry, builtinComponents, defineComponent, defineFeature, editable, movable, resizable, rotatable, migrateElement, commonCanvas } from '../../src/lib/canvas-core/index.ts';
|
import { createDocument, createEditor, createRegistry, builtinComponents, defineComponent, defineFeature, editable, movable, resizable, rotatable, migrateElement, commonCanvas } from '../../src/lib/canvas-core/index.ts';
|
||||||
|
|
||||||
|
/** 共用合法矩形几何,节点构造器复用该输入;createDocument/editor负责取得自己的不可变数据。 */
|
||||||
const geometry = { x: 10, y: 20, width: 100, height: 80, rotation: 0 };
|
const geometry = { x: 10, y: 20, width: 100, height: 80, rotation: 0 };
|
||||||
|
/** 默认text节点在root,可替换owner/component构造子图或未知类型。 */
|
||||||
const node = (id, ownerCanvasId = 'root', componentId = 'text') => ({ id, incarnation: 1, ownerCanvasId, componentId, dataVersion: 1, structure: 'node', geometry, data: { title: id, text: '' } });
|
const node = (id, ownerCanvasId = 'root', componentId = 'text') => ({ id, incarnation: 1, ownerCanvasId, componentId, dataVersion: 1, structure: 'node', geometry, data: { title: id, text: '' } });
|
||||||
|
/** 固定第一生命周期目标;删除恢复后继续使用它应被拒绝。 */
|
||||||
const target = id => ({ id, incarnation: 1 });
|
const target = id => ({ id, incarnation: 1 });
|
||||||
|
/** 每个用例隔离editor并使用确定性操作ID;options可覆盖registry/document/policy等依赖。 */
|
||||||
function setup(elements = [node('a'), node('b')], options = {}) {
|
function setup(elements = [node('a'), node('b')], options = {}) {
|
||||||
let next = 0;
|
let next = 0;
|
||||||
return createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], elements), registry: createRegistry(builtinComponents()), createId: () => `op-${++next}`, ...options });
|
return createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], elements), registry: createRegistry(builtinComponents()), createId: () => `op-${++next}`, ...options });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 让reduceRuntime在特定标题下抛错,检查根完全不变;正常提交后区分rootRevision与文档revision,旧修订号更新须拒绝。
|
||||||
test('runtime preparation shares the document publication boundary; metadata has a separate root revision', () => {
|
test('runtime preparation shares the document publication boundary; metadata has a separate root revision', () => {
|
||||||
const editor = setup(undefined, { runtime: Object.freeze({ ids: [] }), reduceRuntime(previous, next, commit) {
|
const editor = setup(undefined, { runtime: Object.freeze({ ids: [] }), reduceRuntime(previous, next, commit) {
|
||||||
if (next.document.elements.get('a').data.title === 'reject-ledger') throw new Error('ledger_failed');
|
if (next.document.elements.get('a').data.title === 'reject-ledger') throw new Error('ledger_failed');
|
||||||
@@ -29,6 +38,7 @@ test('runtime preparation shares the document publication boundary; metadata has
|
|||||||
assert.equal(editor.getSnapshot().operations.size, 1); assert.equal(editor.getSnapshot().history.undo.length, 1);
|
assert.equal(editor.getSnapshot().operations.size, 1); assert.equal(editor.getSnapshot().history.undo.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 事务首条合法、第二条目标缺失:订阅计数与根身份共同证明没有泄露首条命令的部分结果。
|
||||||
test('a rejected second command publishes no document, history, operation or notification', () => {
|
test('a rejected second command publishes no document, history, operation or notification', () => {
|
||||||
const editor = setup(), before = editor.getSnapshot(); let notifications = 0;
|
const editor = setup(), before = editor.getSnapshot(); let notifications = 0;
|
||||||
editor.subscribe(() => notifications++);
|
editor.subscribe(() => notifications++);
|
||||||
@@ -39,6 +49,7 @@ test('a rejected second command publishes no document, history, operation or not
|
|||||||
assert.equal(result.status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before); assert.equal(notifications, 0);
|
assert.equal(result.status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before); assert.equal(notifications, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// frame允许移动而不允许旋转;can只查能力,不消耗createId,也不发布根。
|
||||||
test('can is pure and command handlers come from the component capability table', () => {
|
test('can is pure and command handlers come from the component capability table', () => {
|
||||||
let ids = 0;
|
let ids = 0;
|
||||||
const frame = node('a', 'root', 'frame'), editor = setup([frame], { createId: () => String(++ids) });
|
const frame = node('a', 'root', 'frame'), editor = setup([frame], { createId: () => String(++ids) });
|
||||||
@@ -48,6 +59,7 @@ test('can is pure and command handlers come from the component capability table'
|
|||||||
assert.equal(ids, 0); assert.strictEqual(editor.getSnapshot(), before);
|
assert.equal(ids, 0); assert.strictEqual(editor.getSnapshot(), before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 跨父子端点的边归root,操作覆盖两canvas;带函数的非JSON元素包络必须拒绝,不能序列化时悄悄丢字段。
|
||||||
test('connection creation guards endpoint canvases and non-JSON envelopes reject without silent truncation',()=>{
|
test('connection creation guards endpoint canvases and non-JSON envelopes reject without silent truncation',()=>{
|
||||||
const document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[node('a'),node('f','root','frame'),node('b','child')]);
|
const document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[node('a'),node('f','root','frame'),node('b','child')]);
|
||||||
const editor=createEditor({registry:createRegistry(builtinComponents()),document});
|
const editor=createEditor({registry:createRegistry(builtinComponents()),document});
|
||||||
@@ -56,6 +68,7 @@ test('connection creation guards endpoint canvases and non-JSON envelopes reject
|
|||||||
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.create',element:{...node('invalid'),unserializable:()=>42}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);
|
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.create',element:{...node('invalid'),unserializable:()=>42}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 未知组件的constructor/__proto__原样保留,但对可编辑对象注入危险字段被拒绝,并核查原型未污染。
|
||||||
test('opaque JSON keys survive unrelated edits while unsafe field setters remain rejected',()=>{
|
test('opaque JSON keys survive unrelated edits while unsafe field setters remain rejected',()=>{
|
||||||
const future={...node('opaque'),componentId:'future.plugin',dataVersion:99,data:JSON.parse('{"constructor":{"retain":true},"__proto__":{"ordinary":"json data"}}')};
|
const future={...node('opaque'),componentId:'future.plugin',dataVersion:99,data:JSON.parse('{"constructor":{"retain":true},"__proto__":{"ordinary":"json data"}}')};
|
||||||
const editor=setup([node('a'),future]);editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});
|
const editor=setup([node('a'),future]);editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});
|
||||||
@@ -63,6 +76,7 @@ test('opaque JSON keys survive unrelated edits while unsafe field setters remain
|
|||||||
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:JSON.parse('{"__proto__":{"polluted":true}}')}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(Object.prototype.polluted,undefined);
|
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:JSON.parse('{"__proto__":{"polluted":true}}')}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(Object.prototype.polluted,undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 先写null再undo/redo检查字段存在性,随后删除选中节点并dispose会话,验证选择/视图清理。
|
||||||
test('absent and explicit null are distinct history values; view selection follows element deletion', async () => {
|
test('absent and explicit null are distinct history values; view selection follows element deletion', async () => {
|
||||||
const editor = setup();
|
const editor = setup();
|
||||||
editor.dispatch({ type: 'element.data', target: target('a'), patch: { optional: null } });
|
editor.dispatch({ type: 'element.data', target: target('a'), patch: { optional: null } });
|
||||||
@@ -76,6 +90,7 @@ test('absent and explicit null are distinct history values; view selection follo
|
|||||||
assert.equal(sessions.getSnapshot().views.size, 0);
|
assert.equal(sessions.getSnapshot().views.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 动态policy先允许预览,再通过runtime发布触发权限重查;取消原因和下一次begin都必须反映forbidden。
|
||||||
test('permission changes cancel geometry previews and prevent new gestures', async () => {
|
test('permission changes cancel geometry previews and prevent new gestures', async () => {
|
||||||
let permitted = true;
|
let permitted = true;
|
||||||
const editor = setup(undefined, { policy: () => permitted ? { enabled: true } : { enabled: false, code: 'forbidden', targets: ['a'] } });
|
const editor = setup(undefined, { policy: () => permitted ? { enabled: true } : { enabled: false, code: 'forbidden', targets: ['a'] } });
|
||||||
@@ -88,6 +103,7 @@ test('permission changes cancel geometry previews and prevent new gestures', asy
|
|||||||
assert.equal(sessions.begin('view', [target('a')]).code, 'forbidden'); sessions.dispose();
|
assert.equal(sessions.begin('view', [target('a')]).code, 'forbidden'); sessions.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 把标题和位置放进一个事务,再注入不记历史的资源ACK;undo/redo只能改原编辑字段,不能抹掉资源结果。
|
||||||
test('several commands are one history unit; data undo preserves a later resource acknowledgment', () => {
|
test('several commands are one history unit; data undo preserves a later resource acknowledgment', () => {
|
||||||
const editor = setup();
|
const editor = setup();
|
||||||
editor.transaction(tx => {
|
editor.transaction(tx => {
|
||||||
@@ -103,6 +119,7 @@ test('several commands are one history unit; data undo preserves a later resourc
|
|||||||
assert.equal(editor.getSnapshot().document.elements.get('a').data.asset, 'stored-asset');
|
assert.equal(editor.getSnapshot().document.elements.get('a').data.asset, 'stored-asset');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 远端后来改同一标题,撤销本地旧值必须拒绝,并保持当前根和栈不变。
|
||||||
test('same-field conflict does not pop history or overwrite remote edits', () => {
|
test('same-field conflict does not pop history or overwrite remote edits', () => {
|
||||||
const editor = setup();
|
const editor = setup();
|
||||||
editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'local' } });
|
editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'local' } });
|
||||||
@@ -111,6 +128,7 @@ test('same-field conflict does not pop history or overwrite remote edits', () =>
|
|||||||
assert.equal(editor.history.undo().status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before);
|
assert.equal(editor.history.undo().status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 删除连带移除边,恢复带回边且incarnation递增;旧目标命令不能修改恢复后的新生命周期。
|
||||||
test('delete undo restores a new incarnation and the original connections; stale targets remain invalid', () => {
|
test('delete undo restores a new incarnation and the original connections; stale targets remain invalid', () => {
|
||||||
const edge = { id: 'e', incarnation: 1, ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, structure: 'connection', source: { elementId: 'a' }, target: { elementId: 'b' }, path: null };
|
const edge = { id: 'e', incarnation: 1, ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, structure: 'connection', source: { elementId: 'a' }, target: { elementId: 'b' }, path: null };
|
||||||
const editor = setup([node('a'), node('b'), edge]);
|
const editor = setup([node('a'), node('b'), edge]);
|
||||||
@@ -125,6 +143,7 @@ test('delete undo restores a new incarnation and the original connections; stale
|
|||||||
assert.equal(editor.getSnapshot().document.elements.get('a').incarnation, 3);
|
assert.equal(editor.getSnapshot().document.elements.get('a').incarnation, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// historyLimit=2检查淘汰;撤销后新分支清redo,同值编辑保持根身份,避免空操作占历史。
|
||||||
test('new user edit clears redo, bounded history evicts old entries, no-op creates no operation', () => {
|
test('new user edit clears redo, bounded history evicts old entries, no-op creates no operation', () => {
|
||||||
const editor = setup(undefined, { historyLimit: 2 });
|
const editor = setup(undefined, { historyLimit: 2 });
|
||||||
for (const title of ['one', 'two', 'three']) editor.dispatch({ type: 'element.data', target: target('a'), patch: { title } });
|
for (const title of ['one', 'two', 'three']) editor.dispatch({ type: 'element.data', target: target('a'), patch: { title } });
|
||||||
@@ -137,6 +156,7 @@ test('new user edit clears redo, bounded history evicts old entries, no-op creat
|
|||||||
assert.strictEqual(editor.getSnapshot(), before);
|
assert.strictEqual(editor.getSnapshot(), before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 异步事务在await前后都有写入尝试;等待微任务后根仍不变,捕获的tx也已关闭。
|
||||||
test('async transactions reject, close their draft, and never publish the asynchronous tail', async () => {
|
test('async transactions reject, close their draft, and never publish the asynchronous tail', async () => {
|
||||||
const editor = setup(), before = editor.getSnapshot(); let captured;
|
const editor = setup(), before = editor.getSnapshot(); let captured;
|
||||||
const result = editor.transaction(async tx => {
|
const result = editor.transaction(async tx => {
|
||||||
@@ -150,6 +170,7 @@ test('async transactions reject, close their draft, and never publish the asynch
|
|||||||
assert.throws(() => captured.dispatch({ type: 'element.remove', target: target('a') }), /transaction_closed/);
|
assert.throws(() => captured.dispatch({ type: 'element.remove', target: target('a') }), /transaction_closed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 编辑已知节点不改未知记录;未知组件自身无法借用未安装行为写入。
|
||||||
test('unknown and future components survive known-object editing but cannot use uninstalled behavior', () => {
|
test('unknown and future components survive known-object editing but cannot use uninstalled behavior', () => {
|
||||||
const unknown = { ...node('future', 'root', 'future/audio'), dataVersion: 99, data: { novel: ['keep', { every: 'field' }] } };
|
const unknown = { ...node('future', 'root', 'future/audio'), dataVersion: 99, data: { novel: ['keep', { every: 'field' }] } };
|
||||||
const editor = setup([node('a'), unknown]);
|
const editor = setup([node('a'), unknown]);
|
||||||
@@ -158,6 +179,7 @@ test('unknown and future components survive known-object editing but cannot use
|
|||||||
assert.equal(editor.dispatch({ type: 'element.data', target: target('future'), patch: { title: 'overwrite' } }).status, 'rejected');
|
assert.equal(editor.dispatch({ type: 'element.data', target: target('future'), patch: { title: 'overwrite' } }).status, 'rejected');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 重复feature在registry装配时报错;自定义aspect缩放仅改width也应计算相应height。
|
||||||
test('capability conflicts are configuration errors; custom resize handlers can use a different constraint', () => {
|
test('capability conflicts are configuration errors; custom resize handlers can use a different constraint', () => {
|
||||||
assert.throws(() => createRegistry([defineComponent({ id: 'bad', dataVersion: 1, structure: 'node', validate: () => true, features: [movable(), movable()] })]), /duplicate_feature/);
|
assert.throws(() => createRegistry([defineComponent({ id: 'bad', dataVersion: 1, structure: 'node', validate: () => true, features: [movable(), movable()] })]), /duplicate_feature/);
|
||||||
const registry = createRegistry([defineComponent({ id: 'ratio', dataVersion: 1, structure: 'node', validate: () => true, features: [resizable({ aspect: true })] })]);
|
const registry = createRegistry([defineComponent({ id: 'ratio', dataVersion: 1, structure: 'node', validate: () => true, features: [resizable({ aspect: true })] })]);
|
||||||
@@ -166,6 +188,7 @@ test('capability conflicts are configuration errors; custom resize handlers can
|
|||||||
assert.equal(editor.getSnapshot().document.elements.get('a').geometry.height, 200);
|
assert.equal(editor.getSnapshot().document.elements.get('a').geometry.height, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 从v1 title迁为v2 heading,身份/owner稳定且原对象版本仍为1,证明迁移处理副本。
|
||||||
test('migration validates a staged copy and retains stable identity and ownership', () => {
|
test('migration validates a staged copy and retains stable identity and ownership', () => {
|
||||||
const registry = createRegistry([defineComponent({ id: 'text', dataVersion: 2, structure: 'node', features: [editable()], validate: data => typeof data.heading === 'string', migrations: { 1: data => ({ heading: data.title }) } })]);
|
const registry = createRegistry([defineComponent({ id: 'text', dataVersion: 2, structure: 'node', features: [editable()], validate: data => typeof data.heading === 'string', migrations: { 1: data => ({ heading: data.title }) } })]);
|
||||||
const original = node('a');
|
const original = node('a');
|
||||||
@@ -174,6 +197,7 @@ test('migration validates a staged copy and retains stable identity and ownershi
|
|||||||
assert.equal(original.dataVersion, 1);
|
assert.equal(original.dataVersion, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 分别构造重复component与不同feature抢同能力,确认配置拒绝;成功装配表也不暴露set。
|
||||||
test('distinct features cannot install the same capability or overwrite an existing component',()=>{
|
test('distinct features cannot install the same capability or overwrite an existing component',()=>{
|
||||||
const handler=()=>[],a=defineFeature('first',{'content.edit':handler}),b=defineFeature('second',{'content.edit':handler});
|
const handler=()=>[],a=defineFeature('first',{'content.edit':handler}),b=defineFeature('second',{'content.edit':handler});
|
||||||
const definition=defineComponent({id:'plugin',dataVersion:1,structure:'node',validate:()=>true,features:[a]});
|
const definition=defineComponent({id:'plugin',dataVersion:1,structure:'node',validate:()=>true,features:[a]});
|
||||||
@@ -183,6 +207,7 @@ test('distinct features cannot install the same capability or overwrite an exist
|
|||||||
assert.equal(registry.components.set,undefined);assert.equal(registry.handlers.get('plugin').set,undefined);
|
assert.equal(registry.components.set,undefined);assert.equal(registry.handlers.get('plugin').set,undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 遍历缺步骤、抛错、修改冻结输入和非法JSON输出;检查原始值、根、索引及通知全未变,并覆盖未来版本透传与getter只读一次。
|
||||||
test('failed schema or reference migrations retain the original data, version and identity without partial publication',()=>{
|
test('failed schema or reference migrations retain the original data, version and identity without partial publication',()=>{
|
||||||
const original={...node('a'),data:{title:'Original',links:['b'],nested:{retain:true}}},saved=structuredClone(original);
|
const original={...node('a'),data:{title:'Original',links:['b'],nested:{retain:true}}},saved=structuredClone(original);
|
||||||
for(const migrations of[
|
for(const migrations of[
|
||||||
@@ -207,6 +232,7 @@ test('failed schema or reference migrations retain the original data, version an
|
|||||||
const migrated=migrateElement(original,captured);assert.equal(reads,1);assert.equal(migrated.data.heading,'Captured once');assert.deepEqual(original,saved);assert.ok(Object.isFrozen(migrated.data));
|
const migrated=migrateElement(original,captured);assert.equal(reads,1);assert.equal(migrated.data.heading,'Captured once');assert.deepEqual(original,saved);assert.ok(Object.isFrozen(migrated.data));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 事务内部尝试dispatch/transaction/undo/runtime/reconcile,全部重入拒绝;外层空事务也不得触发reduceRuntime或通知。
|
||||||
test('reentrant mutation entry points reject without publishing document, history, runtime, indexes or operations',()=>{
|
test('reentrant mutation entry points reject without publishing document, history, runtime, indexes or operations',()=>{
|
||||||
for(const attempt of[
|
for(const attempt of[
|
||||||
editor=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'nested'}}),
|
editor=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'nested'}}),
|
||||||
@@ -221,6 +247,7 @@ test('reentrant mutation entry points reject without publishing document, histor
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 两兄弟canvas共同上级为root;把root挂到其后代会成环,须拒绝且保持根不变。
|
||||||
test('cross-child connection belongs to the common ancestor; cyclic canvas ownership rejects atomically', () => {
|
test('cross-child connection belongs to the common ancestor; cyclic canvas ownership rejects atomically', () => {
|
||||||
const elements = [node('f'), node('g'), node('a', 'child'), node('b', 'sibling')];
|
const elements = [node('f'), node('g'), node('a', 'child'), node('b', 'sibling')];
|
||||||
const canvases = [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'f' } }, { id: 'sibling', parent: { canvasId: 'root', entryElementId: 'g' } }];
|
const canvases = [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'f' } }, { id: 'sibling', parent: { canvasId: 'root', entryElementId: 'g' } }];
|
||||||
@@ -232,6 +259,7 @@ test('cross-child connection belongs to the common ancestor; cyclic canvas owner
|
|||||||
assert.strictEqual(editor.getSnapshot(), before);
|
assert.strictEqual(editor.getSnapshot(), before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 快照集合无set,订阅者抛错也不能回滚已发布的合法提交;观察通知与提交成功边界分开。
|
||||||
test('snapshot collections expose no mutation methods and observer failures do not roll back commits', () => {
|
test('snapshot collections expose no mutation methods and observer failures do not roll back commits', () => {
|
||||||
const editor = setup();
|
const editor = setup();
|
||||||
assert.equal(editor.getSnapshot().document.elements.set, undefined);
|
assert.equal(editor.getSnapshot().document.elements.set, undefined);
|
||||||
@@ -240,6 +268,7 @@ test('snapshot collections expose no mutation methods and observer failures do n
|
|||||||
assert.equal(editor.getSnapshot().document.elements.get('a').data.title, 'committed');
|
assert.equal(editor.getSnapshot().document.elements.get('a').data.title, 'committed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 连续预览不改根;期间资源ACK正常合入,取消只撤预览。第二场手势提交才产生一个history条目。
|
||||||
test('gesture preview is transient, cancel preserves an upload ACK, commit creates one history entry', async () => {
|
test('gesture preview is transient, cancel preserves an upload ACK, commit creates one history entry', async () => {
|
||||||
const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
|
const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
|
||||||
const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view');
|
const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view');
|
||||||
@@ -257,6 +286,7 @@ test('gesture preview is transient, cancel preserves an upload ACK, commit creat
|
|||||||
detach(); sessions.dispose();
|
detach(); sessions.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 远端几何覆盖导致预览取消;之后detach再次取消手势,文档保持远端已提交位置。
|
||||||
test('geometry conflict and view detach cancel only that gesture, without deleting model content', async () => {
|
test('geometry conflict and view detach cancel only that gesture, without deleting model content', async () => {
|
||||||
const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
|
const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
|
||||||
const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view');
|
const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view');
|
||||||
@@ -268,12 +298,14 @@ test('geometry conflict and view detach cancel only that gesture, without deleti
|
|||||||
sessions.dispose();
|
sessions.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// prepareIntent伪造操作ID并清changes,必须在发布前拒绝,不允许传输准备重写业务事实。
|
||||||
test('transport preparation cannot rewrite a committed document change or its identity',()=>{
|
test('transport preparation cannot rewrite a committed document change or its identity',()=>{
|
||||||
const editor=setup(undefined,{prepareIntent:intent=>({...intent,id:'another-operation',changes:[]})});
|
const editor=setup(undefined,{prepareIntent:intent=>({...intent,id:'another-operation',changes:[]})});
|
||||||
const before=editor.getSnapshot(),result=editor.dispatch({type:'element.data',target:target('a'),patch:{text:'must not publish'}});
|
const before=editor.getSnapshot(),result=editor.dispatch({type:'element.data',target:target('a'),patch:{text:'must not publish'}});
|
||||||
assert.equal(result.code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before);
|
assert.equal(result.code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 300次同组修改形成大历史,假时钟迫使编码让步;编码期间再编辑,旧镜像仍只含捕获的300项,当前undo仍正确。
|
||||||
test('growing grouped history can yield during recovery encoding without altering undo data',async()=>{
|
test('growing grouped history can yield during recovery encoding without altering undo data',async()=>{
|
||||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||||
const editor=setup();
|
const editor=setup();
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 批量创建草稿的不可变前缀与工作量测试。以1000条底表检查逐条stage不重复复制整表,旧前缀不随追加变化。
|
||||||
|
* 策略、schema和runtime拒绝仍必须整次事务不发布;计数/替换迭代器只测本路径,不声称所有创建成本固定。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createEditor,createRegistry,builtinComponents,isFactoryDocument} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createEditor,createRegistry,builtinComponents,isFactoryDocument} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {stageCreation,materializeCreations} from '../../src/lib/canvas-core/creation-draft.ts';
|
import {stageCreation,materializeCreations} from '../../src/lib/canvas-core/creation-draft.ts';
|
||||||
|
/** 构造1000个既有节点的文档;每个用例新建,registry可共享,只读前缀用于后续对照。 */
|
||||||
const node=id=>({id,incarnation:1,ownerCanvasId:'root',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:''}}),document=()=>createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node('old-'+i))),registry=createRegistry(builtinComponents());
|
const node=id=>({id,incarnation:1,ownerCanvasId:'root',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:''}}),document=()=>createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node('old-'+i))),registry=createRegistry(builtinComponents());
|
||||||
|
// 连续追加130条跨过窗口边界,保存第1/64/65个前缀;检查顺序、只读集合、forEach this与materialize结果及重复ID拒绝。
|
||||||
test('creation drafts avoid reenumerating the base per item, preserve held prefixes and fold bounded append windows in order',()=>{
|
test('creation drafts avoid reenumerating the base per item, preserve held prefixes and fold bounded append windows in order',()=>{
|
||||||
const base=document();let reads=0;const elements={...base.elements,[Symbol.iterator](){reads++;return base.elements[Symbol.iterator]();}},source={...base,elements};let draft=source;const held=[];
|
const base=document();let reads=0;const elements={...base.elements,[Symbol.iterator](){reads++;return base.elements[Symbol.iterator]();}},source={...base,elements};let draft=source;const held=[];
|
||||||
for(let i=0;i<130;i++){draft=stageCreation(draft,{kind:'element',id:'new-'+i,before:null,after:node('new-'+i)});if([0,63,64].includes(i))held.push(draft);if(i===31)assert.equal(reads,0);}
|
for(let i=0;i<130;i++){draft=stageCreation(draft,{kind:'element',id:'new-'+i,before:null,after:node('new-'+i)});if([0,63,64].includes(i))held.push(draft);if(i===31)assert.equal(reads,0);}
|
||||||
assert.ok(reads<=1);assert.deepEqual(held.map(d=>d.elements.size),[1001,1064,1065]);assert.equal(held[0].elements.has('new-1'),false);assert.equal(draft.elements.get('old-10').id,'old-10');assert.equal(draft.elements.get('new-129').id,'new-129');assert.equal(draft.elements.set,undefined);const ids=[...draft.elements.keys()];assert.deepEqual(ids.slice(-130),Array.from({length:130},(_,i)=>'new-'+i));assert.equal([...draft.elements.values()].length,1130);const visits=[];draft.elements.forEach(function(value,key,map){assert.equal(this.tag,true);assert.strictEqual(map,draft.elements);visits.push(key);},{tag:true});assert.deepEqual(visits,ids);const materialized=materializeCreations(draft);assert.ok(isFactoryDocument(materialized));assert.equal(materialized.elements.size,1130);assert.equal(base.elements.size,1000);assert.throws(()=>stageCreation(draft,{kind:'element',id:'new-0',before:null,after:node('new-0')}),/duplicate_identity/);
|
assert.ok(reads<=1);assert.deepEqual(held.map(d=>d.elements.size),[1001,1064,1065]);assert.equal(held[0].elements.has('new-1'),false);assert.equal(draft.elements.get('old-10').id,'old-10');assert.equal(draft.elements.get('new-129').id,'new-129');assert.equal(draft.elements.set,undefined);const ids=[...draft.elements.keys()];assert.deepEqual(ids.slice(-130),Array.from({length:130},(_,i)=>'new-'+i));assert.equal([...draft.elements.values()].length,1130);const visits=[];draft.elements.forEach(function(value,key,map){assert.equal(this.tag,true);assert.strictEqual(map,draft.elements);visits.push(key);},{tag:true});assert.deepEqual(visits,ids);const materialized=materializeCreations(draft);assert.ok(isFactoryDocument(materialized));assert.equal(materialized.elements.size,1130);assert.equal(base.elements.size,1000);assert.throws(()=>stageCreation(draft,{kind:'element',id:'new-0',before:null,after:node('new-0')}),/duplicate_identity/);
|
||||||
});
|
});
|
||||||
|
// 策略按命令顺序看到1000/1001/1002前缀,持有前缀也固定;创建与中间编辑一起只发布一次、产生一个操作/历史单元。
|
||||||
test('ordered command policies see stable prior prefixes; one owned publication has one history unit and round-trips undo/redo',()=>{
|
test('ordered command policies see stable prior prefixes; one owned publication has one history unit and round-trips undo/redo',()=>{
|
||||||
const held=[],seen=[];let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy(command,doc){held.push(doc);seen.push([command.type,doc.elements.size,doc.canvases.size]);return{enabled:true};}});let publications=0;editor.subscribe(()=>publications++);const before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});const prefix=tx.getDocument();tx.dispatch({type:'element.create',element:node('b')});assert.equal(prefix.elements.has('b'),false);tx.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'edited'}});tx.dispatch({type:'element.create',element:node('c')});});assert.equal(result.status,'applied');assert.deepEqual(seen.map(row=>row[1]),[1000,1001,1002,1002]);assert.deepEqual(held.map(d=>d.elements.size),[1000,1001,1002,1002]);assert.equal(publications,1);const after=editor.getSnapshot();assert.ok(isFactoryDocument(after.document));assert.equal(after.history.undo.length,1);assert.equal(after.operations.size,1);assert.equal(after.document.elements.get('a').data.title,'edited');assert.equal(before.document.elements.size,1000);assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.elements.size,1000);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('a').data.title,'edited');
|
const held=[],seen=[];let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy(command,doc){held.push(doc);seen.push([command.type,doc.elements.size,doc.canvases.size]);return{enabled:true};}});let publications=0;editor.subscribe(()=>publications++);const before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});const prefix=tx.getDocument();tx.dispatch({type:'element.create',element:node('b')});assert.equal(prefix.elements.has('b'),false);tx.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'edited'}});tx.dispatch({type:'element.create',element:node('c')});});assert.equal(result.status,'applied');assert.deepEqual(seen.map(row=>row[1]),[1000,1001,1002,1002]);assert.deepEqual(held.map(d=>d.elements.size),[1000,1001,1002,1002]);assert.equal(publications,1);const after=editor.getSnapshot();assert.ok(isFactoryDocument(after.document));assert.equal(after.history.undo.length,1);assert.equal(after.operations.size,1);assert.equal(after.document.elements.get('a').data.title,'edited');assert.equal(before.document.elements.size,1000);assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.elements.size,1000);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('a').data.title,'edited');
|
||||||
});
|
});
|
||||||
|
// 四种失败分别发生在创建流水线不同位置,结果均rejected且根、history、operations保持原状。
|
||||||
test('policy, schema, duplicate and runtime failures never publish staged creations',()=>{
|
test('policy, schema, duplicate and runtime failures never publish staged creations',()=>{
|
||||||
for(const failure of ['policy','schema','duplicate','runtime']){let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy:(command)=>failure==='policy'&&command.type==='element.create'&&command.element.id==='b'?{enabled:false,code:'denied',targets:['b']}:{enabled:true},reduceRuntime(){if(failure==='runtime')throw Error('runtime_failed');}}),before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});tx.dispatch({type:'element.create',element:failure==='duplicate'?node('a'):failure==='schema'?{...node('b'),geometry:{...node('b').geometry,width:-1}}:node('b')});});assert.equal(result.status,'rejected',failure);assert.strictEqual(editor.getSnapshot(),before);assert.equal(editor.getSnapshot().history.undo.length,0);assert.equal(editor.getSnapshot().operations.size,0);}
|
for(const failure of ['policy','schema','duplicate','runtime']){let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy:(command)=>failure==='policy'&&command.type==='element.create'&&command.element.id==='b'?{enabled:false,code:'denied',targets:['b']}:{enabled:true},reduceRuntime(){if(failure==='runtime')throw Error('runtime_failed');}}),before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});tx.dispatch({type:'element.create',element:failure==='duplicate'?node('a'):failure==='schema'?{...node('b'),geometry:{...node('b').geometry,width:-1}}:node('b')});});assert.equal(result.status,'rejected',failure);assert.strictEqual(editor.getSnapshot(),before);assert.equal(editor.getSnapshot().history.undo.length,0);assert.equal(editor.getSnapshot().operations.size,0);}
|
||||||
});
|
});
|
||||||
|
// 先创建入口,再canvas,再内部节点;旧前缀不见后来结构,undo移除整组,redo恢复入口关系。
|
||||||
test('canvas and element creation drafts compose a tree while held canvas prefixes and base snapshots remain fixed',()=>{
|
test('canvas and element creation drafts compose a tree while held canvas prefixes and base snapshots remain fixed',()=>{
|
||||||
const editor=createEditor({document:document(),registry});let first,second;const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('entry'),componentId:'frame'}});first=tx.getDocument();tx.dispatch({type:'canvas.create',canvas:{id:'child',parent:{canvasId:'root',entryElementId:'entry'}}});second=tx.getDocument();tx.dispatch({type:'element.create',element:{...node('nested'),ownerCanvasId:'child'}});assert.equal(first.canvases.has('child'),false);assert.equal(second.elements.has('nested'),false);assert.equal(tx.getDocument().canvases.get('child').parent.entryElementId,'entry');});assert.equal(result.status,'applied');assert.ok(isFactoryDocument(editor.getSnapshot().document));assert.equal(editor.getSnapshot().document.elements.get('nested').ownerCanvasId,'child');assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.has('child'),false);assert.equal(editor.getSnapshot().document.elements.has('entry'),false);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.get('child').parent.entryElementId,'entry');
|
const editor=createEditor({document:document(),registry});let first,second;const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('entry'),componentId:'frame'}});first=tx.getDocument();tx.dispatch({type:'canvas.create',canvas:{id:'child',parent:{canvasId:'root',entryElementId:'entry'}}});second=tx.getDocument();tx.dispatch({type:'element.create',element:{...node('nested'),ownerCanvasId:'child'}});assert.equal(first.canvases.has('child'),false);assert.equal(second.elements.has('nested'),false);assert.equal(tx.getDocument().canvases.get('child').parent.entryElementId,'entry');});assert.equal(result.status,'applied');assert.ok(isFactoryDocument(editor.getSnapshot().document));assert.equal(editor.getSnapshot().document.elements.get('nested').ownerCanvasId,'child');assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.has('child'),false);assert.equal(editor.getSnapshot().document.elements.has('entry'),false);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.get('child').parent.entryElementId,'entry');
|
||||||
});
|
});
|
||||||
|
// 事务内暂替Map迭代器,禁止大表遍历;32次创建仍获准且单一历史,finally恢复全局原型方法。
|
||||||
test('editor creation staging does not enumerate an existing thousand-element table for each command',()=>{
|
test('editor creation staging does not enumerate an existing thousand-element table for each command',()=>{
|
||||||
const editor=createEditor({document:document(),registry});const iterator=Map.prototype[Symbol.iterator];let admissions=0;const result=editor.transaction(tx=>{try{Map.prototype[Symbol.iterator]=function(){if(this.size>=1000)throw Error('full table copied during creation staging');return iterator.call(this);};for(let i=0;i<32;i++){assert.equal(tx.dispatch({type:'element.create',element:node('batch-'+i)}).enabled,true);admissions++;}}finally{Map.prototype[Symbol.iterator]=iterator;}});assert.equal(result.status,'applied');assert.equal(admissions,32);assert.equal(editor.getSnapshot().document.elements.size,1032);assert.equal(editor.getSnapshot().history.undo.length,1);
|
const editor=createEditor({document:document(),registry});const iterator=Map.prototype[Symbol.iterator];let admissions=0;const result=editor.transaction(tx=>{try{Map.prototype[Symbol.iterator]=function(){if(this.size>=1000)throw Error('full table copied during creation staging');return iterator.call(this);};for(let i=0;i<32;i++){assert.equal(tx.dispatch({type:'element.create',element:node('batch-'+i)}).enabled,true);admissions++;}}finally{Map.prototype[Symbol.iterator]=iterator;}});assert.equal(result.status,'applied');assert.equal(admissions,32);assert.equal(editor.getSnapshot().document.elements.size,1032);assert.equal(editor.getSnapshot().history.undo.length,1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 异步获知图片默认尺寸后修正创建历史的规划测试。plan只生成候选document/history,不能修改已发intent或凭空添加撤销单元。
|
||||||
|
* 区分用户显式resize、单位换算、移动和redo分支,检查整个undo/redo路径仍可恢复,不以当前尺寸碰巧相同判定未改过。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createDocument, createEditor, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
|
import { createDocument, createEditor, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
|
||||||
@@ -6,7 +10,9 @@ import { applyChanges, invert } from '../../src/lib/canvas-core/document.ts';
|
|||||||
import { describeHistoryTransition, applyHistoryTransition } from '../../src/lib/canvas-core/history-transition.ts';
|
import { describeHistoryTransition, applyHistoryTransition } from '../../src/lib/canvas-core/history-transition.ts';
|
||||||
|
|
||||||
const registry = createRegistry(builtinComponents());
|
const registry = createRegistry(builtinComponents());
|
||||||
|
/** 固定image第一生命周期,删除恢复后这个异步任务目标应失效。 */
|
||||||
const target = { id: 'image', incarnation: 1 };
|
const target = { id: 'image', incarnation: 1 };
|
||||||
|
/** 先创建260×200图片并记录创建history entry,propose统一尝试改默认值为260×130,允许覆盖editor依赖。 */
|
||||||
function setup(options = {}) {
|
function setup(options = {}) {
|
||||||
let id = 0;
|
let id = 0;
|
||||||
const editor = createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], []), registry, createId: () => `id-${++id}`, ...options });
|
const editor = createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], []), registry, createId: () => `id-${++id}`, ...options });
|
||||||
@@ -15,6 +21,7 @@ function setup(options = {}) {
|
|||||||
const entry = editor.getSnapshot().history.undo[0];
|
const entry = editor.getSnapshot().history.undo[0];
|
||||||
return { editor, entry, propose() { const { document, history } = editor.getSnapshot(); return planCreationSize(document, history, registry, entry, target, { width: 260, height: 130 }); } };
|
return { editor, entry, propose() { const { document, history } = editor.getSnapshot(); return planCreationSize(document, history, registry, entry, target, { width: 260, height: 130 }); } };
|
||||||
}
|
}
|
||||||
|
/** 从plan当前状态逆序撤销到空,再按undo顺序重做,对照最终图片;只适用于不含额外初始元素的调用。 */
|
||||||
function traverse(plan) {
|
function traverse(plan) {
|
||||||
let document = plan.document;
|
let document = plan.document;
|
||||||
for (const id of [...plan.history.undo].reverse()) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
|
for (const id of [...plan.history.undo].reverse()) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
|
||||||
@@ -23,6 +30,7 @@ function traverse(plan) {
|
|||||||
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
// 创建后移位/旋转/改名,默认尺寸修正保留这些编辑与栈顺序;原editor根和序列化intent均不变。
|
||||||
test('default size enriches the creation unit across move, rotation and title edits without mutating issued intents', () => {
|
test('default size enriches the creation unit across move, rotation and title edits without mutating issued intents', () => {
|
||||||
const { editor, entry, propose } = setup();
|
const { editor, entry, propose } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { x: 80, rotation: 25 } });
|
editor.dispatch({ type: 'element.geometry', target, patch: { x: 80, rotation: 25 } });
|
||||||
@@ -37,6 +45,7 @@ test('default size enriches the creation unit across move, rotation and title ed
|
|||||||
assert.deepEqual([...before.operations.values()].map(value => JSON.stringify(value)), intents);
|
assert.deepEqual([...before.operations.values()].map(value => JSON.stringify(value)), intents);
|
||||||
traverse(plan);
|
traverse(plan);
|
||||||
});
|
});
|
||||||
|
// 位置变化撤销后仍在redo,规划保留redo且当前x不提前跳到未来值;实际重做再得到90与新默认高度。
|
||||||
test('an unrelated redo stack is retained and rebased without becoming the working document', () => {
|
test('an unrelated redo stack is retained and rebased without becoming the working document', () => {
|
||||||
const { editor, propose } = setup();
|
const { editor, propose } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { x: 90 } }); editor.history.undo();
|
editor.dispatch({ type: 'element.geometry', target, patch: { x: 90 } }); editor.history.undo();
|
||||||
@@ -48,6 +57,7 @@ test('an unrelated redo stack is retained and rebased without becoming the worki
|
|||||||
assert.equal(document.elements.get('image').geometry.x, 90);
|
assert.equal(document.elements.get('image').geometry.x, 90);
|
||||||
assert.equal(document.elements.get('image').geometry.height, 130);
|
assert.equal(document.elements.get('image').geometry.height, 130);
|
||||||
});
|
});
|
||||||
|
// resize已撤销时允许修默认基线;redo仍用用户300高度,逆向回到修正后的130。
|
||||||
test('a future explicit resize keeps the chosen dimensions, with a corrected undo baseline', () => {
|
test('a future explicit resize keeps the chosen dimensions, with a corrected undo baseline', () => {
|
||||||
const { editor, propose } = setup();
|
const { editor, propose } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { width: 400, height: 300 } }); editor.history.undo();
|
editor.dispatch({ type: 'element.geometry', target, patch: { width: 400, height: 300 } }); editor.history.undo();
|
||||||
@@ -56,6 +66,7 @@ test('a future explicit resize keeps the chosen dimensions, with a corrected und
|
|||||||
assert.equal(resized.elements.get('image').geometry.height, 300);
|
assert.equal(resized.elements.get('image').geometry.height, 300);
|
||||||
assert.equal(applyChanges(resized, invert(resize.changes)).elements.get('image').geometry.height, 130);
|
assert.equal(applyChanges(resized, invert(resize.changes)).elements.get('image').geometry.height, 130);
|
||||||
});
|
});
|
||||||
|
// 用户先改高400再改回200,仍属于显式调整,不得以最终值相同覆盖其意图。
|
||||||
test('applied resize rejects even after a later resize returns to the initial dimensions', () => {
|
test('applied resize rejects even after a later resize returns to the initial dimensions', () => {
|
||||||
const { editor, propose } = setup();
|
const { editor, propose } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { height: 400 } });
|
editor.dispatch({ type: 'element.geometry', target, patch: { height: 400 } });
|
||||||
@@ -63,15 +74,18 @@ test('applied resize rejects even after a later resize returns to the initial di
|
|||||||
const before = editor.getSnapshot();
|
const before = editor.getSnapshot();
|
||||||
assert.throws(propose, { code: 'creation_size_user_resized' }); assert.strictEqual(editor.getSnapshot(), before);
|
assert.throws(propose, { code: 'creation_size_user_resized' }); assert.strictEqual(editor.getSnapshot(), before);
|
||||||
});
|
});
|
||||||
|
// 创建已撤销,原图片不存在,异步默认尺寸任务以target_changed拒绝。
|
||||||
test('an undone creation cannot be enriched', () => {
|
test('an undone creation cannot be enriched', () => {
|
||||||
const { editor, propose } = setup(); editor.history.undo();
|
const { editor, propose } = setup(); editor.history.undo();
|
||||||
assert.throws(propose, { code: 'creation_size_target_changed' });
|
assert.throws(propose, { code: 'creation_size_target_changed' });
|
||||||
});
|
});
|
||||||
|
// 删除再恢复使生命周期变化,即使ID相同,旧创建尺寸任务也不能修改新对象。
|
||||||
test('a restored element has a new incarnation and rejects the old asynchronous task', () => {
|
test('a restored element has a new incarnation and rejects the old asynchronous task', () => {
|
||||||
const { editor, propose } = setup(); editor.dispatch({ type: 'element.remove', target }); editor.history.undo();
|
const { editor, propose } = setup(); editor.dispatch({ type: 'element.remove', target }); editor.history.undo();
|
||||||
// Undo of removal creates a new incarnation: an old asynchronous task is stale.
|
// Undo of removal creates a new incarnation: an old asynchronous task is stale.
|
||||||
assert.throws(propose, { code: 'creation_size_target_changed' });
|
assert.throws(propose, { code: 'creation_size_target_changed' });
|
||||||
});
|
});
|
||||||
|
// 用已有historyTransition格式描述plan,JSON往返后正反回放仍精确相等,entries不暴露set。
|
||||||
test('the complete history proposal is reversible using the existing transition proof format', () => {
|
test('the complete history proposal is reversible using the existing transition proof format', () => {
|
||||||
const { editor, entry, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { x: 50 } });
|
const { editor, entry, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { x: 50 } });
|
||||||
const before = editor.getSnapshot(), plan = propose();
|
const before = editor.getSnapshot(), plan = propose();
|
||||||
@@ -82,6 +96,7 @@ test('the complete history proposal is reversible using the existing transition
|
|||||||
assert.deepEqual([...restored.entries], [...before.history.entries]);
|
assert.deepEqual([...restored.entries], [...before.history.entries]);
|
||||||
assert.equal(plan.history.entries.set, undefined);
|
assert.equal(plan.history.entries.set, undefined);
|
||||||
});
|
});
|
||||||
|
// 允许raw changes构造单位×2与几何同比变化,修正尺寸按单位换算而非误判成用户resize,保留最新位置并遍历历史。
|
||||||
test('a logical unit conversion preserves size, latest position and the full undo path', () => {
|
test('a logical unit conversion preserves size, latest position and the full undo path', () => {
|
||||||
const { editor, propose } = setup({ allowRawChanges: true });
|
const { editor, propose } = setup({ allowRawChanges: true });
|
||||||
const before = editor.getSnapshot().document.elements.get('image');
|
const before = editor.getSnapshot().document.elements.get('image');
|
||||||
@@ -96,6 +111,7 @@ test('a logical unit conversion preserves size, latest position and the full und
|
|||||||
assert.equal(plan.document.elements.get('image').geometry.x, 45);
|
assert.equal(plan.document.elements.get('image').geometry.x, 45);
|
||||||
traverse(plan);
|
traverse(plan);
|
||||||
});
|
});
|
||||||
|
// 图片移入contentScale=2子图后仍保留owner/位置,默认高度换到局部65;undo只移图片不删初始frame,再重做一致。
|
||||||
test('a real move into a scaled child canvas is retained through enrichment and undo', () => {
|
test('a real move into a scaled child canvas is retained through enrichment and undo', () => {
|
||||||
const frame = { id: 'frame', incarnation: 1, ownerCanvasId: 'root', componentId: 'frame', dataVersion: 1, structure: 'node', data: { title: 'Frame', text: '', presentation: { contentScale: 2 } }, geometry: { x: 0, y: 0, width: 600, height: 400, rotation: 0 } };
|
const frame = { id: 'frame', incarnation: 1, ownerCanvasId: 'root', componentId: 'frame', dataVersion: 1, structure: 'node', data: { title: 'Frame', text: '', presentation: { contentScale: 2 } }, geometry: { x: 0, y: 0, width: 600, height: 400, rotation: 0 } };
|
||||||
const { editor, propose } = setup({ document: createDocument('p', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [frame]) });
|
const { editor, propose } = setup({ document: createDocument('p', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [frame]) });
|
||||||
@@ -110,11 +126,13 @@ test('a real move into a scaled child canvas is retained through enrichment and
|
|||||||
for (const id of plan.history.undo) document = applyChanges(document, plan.history.entries.get(id).changes);
|
for (const id of plan.history.undo) document = applyChanges(document, plan.history.entries.get(id).changes);
|
||||||
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
||||||
});
|
});
|
||||||
|
// historyLimit=1淘汰创建条目后不能修补,也不能为了异步结果补造历史单元。
|
||||||
test('evicted creation history is rejected without synthesizing a new undo entry', () => {
|
test('evicted creation history is rejected without synthesizing a new undo entry', () => {
|
||||||
const { editor, propose } = setup({ historyLimit: 1 });
|
const { editor, propose } = setup({ historyLimit: 1 });
|
||||||
editor.dispatch({ type: 'element.data', target, patch: { title: 'New title' } });
|
editor.dispatch({ type: 'element.data', target, patch: { title: 'New title' } });
|
||||||
assert.throws(propose, { code: 'creation_size_target_changed' });
|
assert.throws(propose, { code: 'creation_size_target_changed' });
|
||||||
});
|
});
|
||||||
|
// resize与移动都撤销后,按redo正确次序重放保留用户300高和x=75,再反向回到plan状态。
|
||||||
test('a future resize followed by movement retains the user size through both redo entries', () => {
|
test('a future resize followed by movement retains the user size through both redo entries', () => {
|
||||||
const { editor, propose } = setup();
|
const { editor, propose } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
|
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
|
||||||
@@ -127,6 +145,7 @@ test('a future resize followed by movement retains the user size through both re
|
|||||||
for (const id of plan.history.redo) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
|
for (const id of plan.history.redo) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
|
||||||
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
||||||
});
|
});
|
||||||
|
// Infinity尺寸拒绝;远端内容已改变使历史基准过期,同样拒绝且不改editor根。
|
||||||
test('invalid sizes and stale document baselines are rejected without mutation', () => {
|
test('invalid sizes and stale document baselines are rejected without mutation', () => {
|
||||||
const { editor, entry } = setup(); const root = editor.getSnapshot();
|
const { editor, entry } = setup(); const root = editor.getSnapshot();
|
||||||
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: Infinity, height: 1 }), { code: 'invalid_creation_size' });
|
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: Infinity, height: 1 }), { code: 'invalid_creation_size' });
|
||||||
@@ -135,10 +154,12 @@ test('invalid sizes and stale document baselines are rejected without mutation',
|
|||||||
assert.throws(() => planCreationSize(stale.document, stale.history, registry, entry, target, { width: 260, height: 130 }), { code: 'element_conflict' });
|
assert.throws(() => planCreationSize(stale.document, stale.history, registry, entry, target, { width: 260, height: 130 }), { code: 'element_conflict' });
|
||||||
assert.strictEqual(editor.getSnapshot(), stale);
|
assert.strictEqual(editor.getSnapshot(), stale);
|
||||||
});
|
});
|
||||||
|
// 0.5高不满足组件尺寸约束,规划必须拒绝,不能因默认值修正绕过普通resize限制。
|
||||||
test('component constraints cannot be bypassed by a default size proposal', () => {
|
test('component constraints cannot be bypassed by a default size proposal', () => {
|
||||||
const { editor, entry } = setup(); const root = editor.getSnapshot();
|
const { editor, entry } = setup(); const root = editor.getSnapshot();
|
||||||
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: 260, height: 0.5 }), { code: 'creation_size_constraint' });
|
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: 260, height: 0.5 }), { code: 'creation_size_constraint' });
|
||||||
});
|
});
|
||||||
|
// 默认宽改520、高130,redo只改高300时应保留新默认宽,避免拿旧整份geometry覆盖未编辑轴。
|
||||||
test('a redo edit to one size axis preserves the corrected default on the other axis', () => {
|
test('a redo edit to one size axis preserves the corrected default on the other axis', () => {
|
||||||
const { editor, entry } = setup();
|
const { editor, entry } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
|
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 按元素ID分桶的文档订阅测试。用lookup计数、加载覆盖及无change列表的ACK替换,检查局部通知不漏更新也不做无关读取。
|
||||||
|
* 模拟ports只实现测试需要的记录查找,不执行真实editor命令或网络请求。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createElementSources} from '../../src/lib/canvas-bindings/element-sources.ts';
|
import {createElementSources} from '../../src/lib/canvas-bindings/element-sources.ts';
|
||||||
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
||||||
|
/** 按count建元素表;lookup区分present/unloaded/absent并记录ID,edit携带field变化,emit可模拟整根替换。 */
|
||||||
function fixture(count=3){
|
function fixture(count=3){
|
||||||
const element=id=>({id,incarnation:1,ownerCanvasId:'root',data:{title:id}}),document={elements:new Map(Array.from({length:count},(_,index)=>{const id=String(index);return[id,element(id)];}))};let root={document,runtime:{acknowledged:document}},throwSubscribe=false;const listeners=new Set(),lookups=[];
|
const element=id=>({id,incarnation:1,ownerCanvasId:'root',data:{title:id}}),document={elements:new Map(Array.from({length:count},(_,index)=>{const id=String(index);return[id,element(id)];}))};let root={document,runtime:{acknowledged:document}},throwSubscribe=false;const listeners=new Set(),lookups=[];
|
||||||
|
// throwSubscribe模拟启动失败,lookups观察实际读过哪些ID;订阅返回的清理函数只移除对应监听。
|
||||||
const ports={getSnapshot:()=>root,subscribe(fn){if(throwSubscribe)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);},lookup(snapshot,id){lookups.push(id);const element=snapshot.document.elements.get(id);return element?{status:'present',element}:{status:snapshot.runtime.loading?'unloaded':'absent'};}};
|
const ports={getSnapshot:()=>root,subscribe(fn){if(throwSubscribe)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);},lookup(snapshot,id){lookups.push(id);const element=snapshot.document.elements.get(id);return element?{status:'present',element}:{status:snapshot.runtime.loading?'unloaded':'absent'};}};
|
||||||
return{ports,lookups,element,get size(){return listeners.size;},root:()=>root,throwSubscribe(value){throwSubscribe=value;},emit(next,changes=[]){root=next;for(const fn of [...listeners])fn({changes},root);},edit(id,title){const elements=new Map(root.document.elements);elements.set(id,{...elements.get(id),data:{title}});this.emit({...root,document:{elements}},[{kind:'field',target:{id,incarnation:1},field:'data'}]);}};
|
return{ports,lookups,element,get size(){return listeners.size;},root:()=>root,throwSubscribe(value){throwSubscribe=value;},emit(next,changes=[]){root=next;for(const fn of [...listeners])fn({changes},root);},edit(id,title){const elements=new Map(root.document.elements);elements.set(id,{...elements.get(id),data:{title}});this.emit({...root,document:{elements}},[{kind:'field',target:{id,incarnation:1},field:'data'}]);}};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1000元素通道共享上游,编辑300只查300/通知300;无关runtime发布不重新lookup任何元素。
|
||||||
test('one ID edit among 1000 subscriptions evaluates only that target; runtime-only cuts do no element work',()=>{
|
test('one ID edit among 1000 subscriptions evaluates only that target; runtime-only cuts do no element work',()=>{
|
||||||
const f=fixture(1000),sources=createElementSources(f.ports),calls=new Map(),notified=new Set(),stops=[];
|
const f=fixture(1000),sources=createElementSources(f.ports),calls=new Map(),notified=new Set(),stops=[];
|
||||||
for(let index=0;index<1000;index++){const id=String(index),store=createSelectorStore(sources.forElement(id),view=>{calls.set(id,(calls.get(id)??0)+1);return view.element.data.title;});stops.push(store.subscribe(()=>notified.add(id)));}
|
for(let index=0;index<1000;index++){const id=String(index),store=createSelectorStore(sources.forElement(id),view=>{calls.set(id,(calls.get(id)??0)+1);return view.element.data.title;});stops.push(store.subscribe(()=>notified.add(id)));}
|
||||||
@@ -14,31 +21,38 @@ test('one ID edit among 1000 subscriptions evaluates only that target; runtime-o
|
|||||||
f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,metadata:1}});assert.equal(f.lookups.length,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,metadata:1}});assert.equal(f.lookups.length,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ACK没有changes清单但替换document:应检查活跃目标,深相等克隆保持0原快照,真正改1仍通知。
|
||||||
test('ACK replacement checks active targets, ignores equal cloned records and delivers unlisted changes',()=>{
|
test('ACK replacement checks active targets, ignores equal cloned records and delivers unlisted changes',()=>{
|
||||||
const f=fixture(),sources=createElementSources(f.ports),seen=[];const a=sources.forElement('0'),b=sources.forElement('1'),stopA=a.subscribe(()=>seen.push('0')),stopB=b.subscribe(()=>seen.push('1')),old=a.getSnapshot();
|
const f=fixture(),sources=createElementSources(f.ports),seen=[];const a=sources.forElement('0'),b=sources.forElement('1'),stopA=a.subscribe(()=>seen.push('0')),stopB=b.subscribe(()=>seen.push('1')),old=a.getSnapshot();
|
||||||
const elements=new Map([...f.root().document.elements].map(([id,row])=>[id,structuredClone(row)]));elements.get('1').data.title='canonical';f.emit({...f.root(),document:{elements}});assert.deepEqual(seen,['1']);assert.strictEqual(a.getSnapshot(),old);assert.equal(b.getSnapshot().element.data.title,'canonical');stopA();stopB();
|
const elements=new Map([...f.root().document.elements].map(([id,row])=>[id,structuredClone(row)]));elements.get('1').data.title='canonical';f.emit({...f.root(),document:{elements}});assert.deepEqual(seen,['1']);assert.strictEqual(a.getSnapshot(),old);assert.equal(b.getSnapshot().element.data.title,'canonical');stopA();stopB();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 加载结束让missing从unloaded变absent,只唤醒缺失目标,present对象不受影响。
|
||||||
test('loading coverage invalidates missing lookups at EOF without waking present targets',()=>{
|
test('loading coverage invalidates missing lookups at EOF without waking present targets',()=>{
|
||||||
const f=fixture(),sources=createElementSources(f.ports);f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6}}});const missing=sources.forElement('later'),present=sources.forElement('0'),seen=[];const a=missing.subscribe(()=>seen.push('missing')),b=present.subscribe(()=>seen.push('present'));assert.equal(missing.getSnapshot().status,'unloaded');f.emit({...f.root(),runtime:{...f.root().runtime,loading:undefined}});assert.deepEqual(seen,['missing']);assert.equal(missing.getSnapshot().status,'absent');a();b();
|
const f=fixture(),sources=createElementSources(f.ports);f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6}}});const missing=sources.forElement('later'),present=sources.forElement('0'),seen=[];const a=missing.subscribe(()=>seen.push('missing')),b=present.subscribe(()=>seen.push('present'));assert.equal(missing.getSnapshot().status,'unloaded');f.emit({...f.root(),runtime:{...f.root().runtime,loading:undefined}});assert.deepEqual(seen,['missing']);assert.equal(missing.getSnapshot().status,'absent');a();b();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 退订期间编辑不通知但getSnapshot仍新;再次订阅从当前基线开始,重复stop不泄漏上游。
|
||||||
test('getSnapshot stays fresh while unsubscribed, repeated cleanup is safe and late subscriptions do not reuse old baselines',()=>{
|
test('getSnapshot stays fresh while unsubscribed, repeated cleanup is safe and late subscriptions do not reuse old baselines',()=>{
|
||||||
const f=fixture(),sources=createElementSources(f.ports),channel=sources.forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++);stop();stop();f.edit('0','unsubscribed');assert.equal(calls,0);assert.equal(channel.getSnapshot().element.data.title,'unsubscribed');const again=channel.subscribe(()=>calls++);f.edit('0','current');assert.equal(calls,1);again();assert.equal(f.size,0);
|
const f=fixture(),sources=createElementSources(f.ports),channel=sources.forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++);stop();stop();f.edit('0','unsubscribed');assert.equal(calls,0);assert.equal(channel.getSnapshot().element.data.title,'unsubscribed');const again=channel.subscribe(()=>calls++);f.edit('0','current');assert.equal(calls,1);again();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 上游启动失败不残留;同次修改0/1时0观察者抛错,1仍收到通知,最后全部清理。
|
||||||
test('observer failures do not starve other buckets and failed upstream subscription retains no listener',()=>{
|
test('observer failures do not starve other buckets and failed upstream subscription retains no listener',()=>{
|
||||||
const f=fixture(),sources=createElementSources(f.ports);f.throwSubscribe(true);assert.throws(()=>sources.forElement('0').subscribe(()=>{}),/subscribe failed/);f.throwSubscribe(false);let b=0;const a=sources.forElement('0').subscribe(()=>{throw Error('observer failed');}),stopB=sources.forElement('1').subscribe(()=>b++),elements=new Map(f.root().document.elements);for(const id of ['0','1'])elements.set(id,{...elements.get(id),data:{title:'new'}});assert.throws(()=>f.emit({...f.root(),document:{elements}},['0','1'].map(id=>({kind:'element',id}))),/observer failed/);assert.equal(b,1);a();stopB();assert.equal(f.size,0);
|
const f=fixture(),sources=createElementSources(f.ports);f.throwSubscribe(true);assert.throws(()=>sources.forElement('0').subscribe(()=>{}),/subscribe failed/);f.throwSubscribe(false);let b=0;const a=sources.forElement('0').subscribe(()=>{throw Error('observer failed');}),stopB=sources.forElement('1').subscribe(()=>b++),elements=new Map(f.root().document.elements);for(const id of ['0','1'])elements.set(id,{...elements.get(id),data:{title:'new'}});assert.throws(()=>f.emit({...f.root(),document:{elements}},['0','1'].map(id=>({kind:'element',id}))),/observer failed/);assert.equal(b,1);a();stopB();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 更早监听先退掉唯一元素订阅,后续捕获的上游回调须安全忽略,不读已清除基线。
|
||||||
test('last unsubscribe before the captured upstream callback runs does not retain or dereference a dead baseline',()=>{
|
test('last unsubscribe before the captured upstream callback runs does not retain or dereference a dead baseline',()=>{
|
||||||
const f=fixture();let stop;const earlier=f.ports.subscribe(()=>stop()),sources=createElementSources(f.ports);let notified=0;stop=sources.forElement('0').subscribe(()=>notified++);assert.doesNotThrow(()=>f.edit('0','after detach'));assert.equal(notified,0);assert.equal(f.size,1);earlier();assert.equal(f.size,0);
|
const f=fixture();let stop;const earlier=f.ports.subscribe(()=>stop()),sources=createElementSources(f.ports);let notified=0;stop=sources.forElement('0').subscribe(()=>notified++);assert.doesNotThrow(()=>f.edit('0','after detach'));assert.equal(notified,0);assert.equal(f.size,1);earlier();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// lookup暂时抛错也需通知,读者重读看到异常;恢复到同一旧值仍要再通知,解除失败状态。
|
||||||
test('lookup failure notifies readers and clearing the failure re-notifies even when the prior value is restored',()=>{
|
test('lookup failure notifies readers and clearing the failure re-notifies even when the prior value is restored',()=>{
|
||||||
const f=fixture(),lookup=f.ports.lookup;let fail=false;f.ports.lookup=(...args)=>{if(fail)throw Error('lookup failed');return lookup(...args);};const channel=createElementSources(f.ports).forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++),before=channel.getSnapshot();fail=true;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,1);assert.throws(channel.getSnapshot,/lookup failed/);fail=false;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,2);assert.strictEqual(channel.getSnapshot(),before);stop();
|
const f=fixture(),lookup=f.ports.lookup;let fail=false;f.ports.lookup=(...args)=>{if(fail)throw Error('lookup failed');return lookup(...args);};const channel=createElementSources(f.ports).forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++),before=channel.getSnapshot();fail=true;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,1);assert.throws(channel.getSnapshot,/lookup failed/);fail=false;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,2);assert.strictEqual(channel.getSnapshot(),before);stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// loadingRead文档未变时ACK前进不改变missing的读取依据;只有捕获读取基线换引用才重新lookup。
|
||||||
test('an advanced ACK does not recheck missing IDs against an unchanged captured loading baseline',()=>{
|
test('an advanced ACK does not recheck missing IDs against an unchanged captured loading baseline',()=>{
|
||||||
const f=fixture(),base=f.root().document;f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6},loadingRead:{document:base}}});const sources=createElementSources(f.ports),stop=sources.forElement('missing').subscribe(()=>{});f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,acknowledged:{...base}}});assert.deepEqual(f.lookups,[]);f.emit({...f.root(),runtime:{...f.root().runtime,loadingRead:{document:{...base}}}});assert.deepEqual(f.lookups,['missing']);stop();
|
const f=fixture(),base=f.root().document;f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6},loadingRead:{document:base}}});const sources=createElementSources(f.ports),stop=sources.forElement('missing').subscribe(()=>{});f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,acknowledged:{...base}}});assert.deepEqual(f.lookups,[]);f.emit({...f.root(),runtime:{...f.root().runtime,loadingRead:{document:{...base}}}});assert.deepEqual(f.lookups,['missing']);stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 元素静态校验缓存的正确性测试。区分框架取得所有权的不可变记录与调用方浅冻结/访问器输入,验证优化不能绕过动态schema或文档拓扑。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,validateElementRecord,validateDocument,validateDocumentSteps,createRegistry,defineComponent,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,validateElementRecord,validateDocument,validateDocumentSteps,createRegistry,defineComponent,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
|
||||||
|
/** probe记录包含合法几何与最小正文;可改字段用来制造尺寸、owner或引用错误。 */
|
||||||
const node=(id='a')=>({id,incarnation:1,ownerCanvasId:'root',componentId:'probe',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{text:'hello'}});
|
const node=(id='a')=>({id,incarnation:1,ownerCanvasId:'root',componentId:'probe',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{text:'hello'}});
|
||||||
|
/** 自定义validator带可切换allowed和调用计数,验证静态证明命中后schema仍按当前状态执行。 */
|
||||||
function setup(){let allowed=true,calls=0;const component=defineComponent({id:'probe',dataVersion:1,structure:'node',features:[],validate:data=>{calls++;return allowed&&typeof data.text==='string';}}),registry=createRegistry([...builtinComponents(),component]),document=createDocument('p',[{id:'root',parent:null}],[node()]);return{registry,document,allow:value=>{allowed=value;},calls:()=>calls};}
|
function setup(){let allowed=true,calls=0;const component=defineComponent({id:'probe',dataVersion:1,structure:'node',features:[],validate:data=>{calls++;return allowed&&typeof data.text==='string';}}),registry=createRegistry([...builtinComponents(),component]),document=createDocument('p',[{id:'root',parent:null}],[node()]);return{registry,document,allow:value=>{allowed=value;},calls:()=>calls};}
|
||||||
|
// 暂替Object.values检测是否重复遍历自有记录,finally恢复全局函数;schema计数、拒绝开关与墓碑仍实时检查。
|
||||||
test('owned record proofs skip repeated static traversal while schema and tombstones remain live checks',()=>{
|
test('owned record proofs skip repeated static traversal while schema and tombstones remain live checks',()=>{
|
||||||
const f=setup(),record=f.document.elements.get('a');validateElementRecord(record,f.registry);const count=f.calls(),values=Object.values;try{Object.values=value=>{if(value===record||value===record.data)throw Error('owned static data enumerated again');return values(value);};validateElementRecord(record,f.registry);}finally{Object.values=values;}assert.equal(f.calls(),count+1);f.allow(false);assert.throws(()=>validateElementRecord(record,f.registry),/invalid_component_data/);f.allow(true);assert.throws(()=>validateElementRecord(record,f.registry,new Map([['a',1]])),/stale_incarnation/);
|
const f=setup(),record=f.document.elements.get('a');validateElementRecord(record,f.registry);const count=f.calls(),values=Object.values;try{Object.values=value=>{if(value===record||value===record.data)throw Error('owned static data enumerated again');return values(value);};validateElementRecord(record,f.registry);}finally{Object.values=values;}assert.equal(f.calls(),count+1);f.allow(false);assert.throws(()=>validateElementRecord(record,f.registry),/invalid_component_data/);f.allow(true);assert.throws(()=>validateElementRecord(record,f.registry,new Map([['a',1]])),/stale_incarnation/);
|
||||||
});
|
});
|
||||||
|
// 先验证合法图,再移走owner或端点;缓存的元素静态证明不能替代新文档关系校验。
|
||||||
test('static record proofs never certify owner membership or connection endpoints',()=>{
|
test('static record proofs never certify owner membership or connection endpoints',()=>{
|
||||||
const f=setup(),b={...node('b'),geometry:{...node().geometry,x:200}},edge={id:'edge',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null},document=createDocument('p',[{id:'root',parent:null}],[node(),b,edge]);validateDocument(document,f.registry);assert.throws(()=>validateDocument({...document,canvases:new Map([['other',{id:'other',parent:null}]])},f.registry),/invalid_element/);assert.throws(()=>validateDocument({...document,elements:new Map([...document.elements].filter(([id])=>id!=='b'))},f.registry),/invalid_endpoint/);
|
const f=setup(),b={...node('b'),geometry:{...node().geometry,x:200}},edge={id:'edge',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null},document=createDocument('p',[{id:'root',parent:null}],[node(),b,edge]);validateDocument(document,f.registry);assert.throws(()=>validateDocument({...document,canvases:new Map([['other',{id:'other',parent:null}]])},f.registry),/invalid_element/);assert.throws(()=>validateDocument({...document,elements:new Map([...document.elements].filter(([id])=>id!=='b'))},f.registry),/invalid_endpoint/);
|
||||||
});
|
});
|
||||||
|
// 浅冻结对象内部几何仍可变,getter也可换返回值;再次校验必须发现非法尺寸,且不得冻结调用方嵌套对象。
|
||||||
test('external shallow-frozen records and getters are revalidated and never frozen on behalf of callers',()=>{
|
test('external shallow-frozen records and getters are revalidated and never frozen on behalf of callers',()=>{
|
||||||
const f=setup(),raw=Object.freeze(node());validateElementRecord(raw,f.registry);raw.geometry.width=0;assert.throws(()=>validateElementRecord(raw,f.registry),/invalid_geometry/);assert.equal(Object.isFrozen(raw.geometry),false);let geometry={...node().geometry};const getter=Object.freeze({...node(),get geometry(){return geometry;}});validateElementRecord(getter,f.registry);geometry={...geometry,width:-1};assert.throws(()=>validateElementRecord(getter,f.registry),/invalid_geometry/);
|
const f=setup(),raw=Object.freeze(node());validateElementRecord(raw,f.registry);raw.geometry.width=0;assert.throws(()=>validateElementRecord(raw,f.registry),/invalid_geometry/);assert.equal(Object.isFrozen(raw.geometry),false);let geometry={...node().geometry};const getter=Object.freeze({...node(),get geometry(){return geometry;}});validateElementRecord(getter,f.registry);geometry={...geometry,width:-1};assert.throws(()=>validateElementRecord(getter,f.registry),/invalid_geometry/);
|
||||||
});
|
});
|
||||||
|
// 创建后破坏外部输入不影响editor记录;跳过schema的一次拓扑检查不能授予以后跳过schema的证明。
|
||||||
test('editor creations detach caller data; topology-only validation cannot bypass later schema checks',()=>{
|
test('editor creations detach caller data; topology-only validation cannot bypass later schema checks',()=>{
|
||||||
const f=setup();let id=0;const editor=createEditor({document:f.document,registry:f.registry,createId:()=>`op-${++id}`}),incoming=node('new');assert.equal(editor.dispatch({type:'element.create',element:incoming}).status,'applied');incoming.geometry.width=-1;incoming.data.text=undefined;const record=editor.getSnapshot().document.elements.get('new');assert.equal(record.geometry.width,100);assert.equal(record.data.text,'hello');const fresh=createDocument('p',[{id:'root',parent:null}],[node('topology')]).elements.get('topology');f.allow(false);const calls=f.calls();validateElementRecord(fresh,f.registry,new Map(),false);assert.equal(f.calls(),calls);assert.throws(()=>validateElementRecord(fresh,f.registry),/invalid_component_data/);editor.dispose();
|
const f=setup();let id=0;const editor=createEditor({document:f.document,registry:f.registry,createId:()=>`op-${++id}`}),incoming=node('new');assert.equal(editor.dispatch({type:'element.create',element:incoming}).status,'applied');incoming.geometry.width=-1;incoming.data.text=undefined;const record=editor.getSnapshot().document.elements.get('new');assert.equal(record.geometry.width,100);assert.equal(record.data.text,'hello');const fresh=createDocument('p',[{id:'root',parent:null}],[node('topology')]).elements.get('topology');f.allow(false);const calls=f.calls();validateElementRecord(fresh,f.registry,new Map(),false);assert.equal(f.calls(),calls);assert.throws(()=>validateElementRecord(fresh,f.registry),/invalid_component_data/);editor.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 用空canvas、墓碑、缺端点及非法几何比较两种遍历结果,再检查合法迭代器的让步步数。
|
||||||
test('synchronous and cooperative validation preserve check order and the same rejection results',()=>{
|
test('synchronous and cooperative validation preserve check order and the same rejection results',()=>{
|
||||||
const f=setup(),base=f.document,edge={id:'edge',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'missing'},path:null},cases=[base,{...base,canvases:new Map()}, {...base,tombstones:new Map([['a',0]])},{...base,tombstones:new Map([['a',1]])},{...base,canvases:new Map([['root',{id:'root',parent:null}],['other',{id:'other',parent:null}]])},createDocument('p',[{id:'root',parent:null}],[node(),edge]),createDocument('p',[{id:'root',parent:null}],[{...node(),geometry:{...node().geometry,width:0}}])];
|
const f=setup(),base=f.document,edge={id:'edge',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'missing'},path:null},cases=[base,{...base,canvases:new Map()}, {...base,tombstones:new Map([['a',0]])},{...base,tombstones:new Map([['a',1]])},{...base,canvases:new Map([['root',{id:'root',parent:null}],['other',{id:'other',parent:null}]])},createDocument('p',[{id:'root',parent:null}],[node(),edge]),createDocument('p',[{id:'root',parent:null}],[{...node(),geometry:{...node().geometry,width:0}}])];
|
||||||
|
/** 将抛错规范为code/targets或valid,比较同步与协作遍历的拒绝结果,而非依赖异常对象引用。 */
|
||||||
const outcome=callback=>{try{callback();return'valid';}catch(error){return{code:error.code,targets:error.targets};}};
|
const outcome=callback=>{try{callback();return'valid';}catch(error){return{code:error.code,targets:error.targets};}};
|
||||||
for(const document of cases)assert.deepEqual(outcome(()=>validateDocument(document,f.registry)),outcome(()=>{for(const _ of validateDocumentSteps(document,f.registry)){};}));
|
for(const document of cases)assert.deepEqual(outcome(()=>validateDocument(document,f.registry)),outcome(()=>{for(const _ of validateDocumentSteps(document,f.registry)){};}));
|
||||||
const iterator=validateDocumentSteps(base,f.registry);assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.equal(iterator.next().done,true);
|
const iterator=validateDocumentSteps(base,f.registry);assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.equal(iterator.next().done,true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 内置数据校验可复用证明,自定义同ID schema仍执行并保留this;失败校验和墓碑不能被缓存成成功。
|
||||||
test('only framework data-only validators can reuse a data proof; wrapped same-ID custom schemas stay live with their receiver',()=>{
|
test('only framework data-only validators can reuse a data proof; wrapped same-ID custom schemas stay live with their receiver',()=>{
|
||||||
const registry=createRegistry(builtinComponents()),input={...node(),componentId:'text',data:{title:'A',text:''}},record=createDocument('p',[{id:'root',parent:null}],[input]).elements.get('a');validateElementRecord(record,registry);const values=Object.values;try{Object.values=value=>{if(value===record.data)throw Error('framework data schema was repeated');return values(value);};validateElementRecord(record,registry);}finally{Object.values=values;}
|
const registry=createRegistry(builtinComponents()),input={...node(),componentId:'text',data:{title:'A',text:''}},record=createDocument('p',[{id:'root',parent:null}],[input]).elements.get('a');validateElementRecord(record,registry);const values=Object.values;try{Object.values=value=>{if(value===record.data)throw Error('framework data schema was repeated');return values(value);};validateElementRecord(record,registry);}finally{Object.values=values;}
|
||||||
let allowed=true,calls=0;const custom=defineComponent({id:'text',dataVersion:1,structure:'node',features:[],validate:function(data){calls++;assert.equal(this.id,'text');return allowed&&typeof data.title==='string';}}),other=createRegistry([custom]);validateElementRecord(record,other);allowed=false;assert.throws(()=>validateElementRecord(record,other),/invalid_component_data/);assert.equal(calls,2);assert.throws(()=>validateElementRecord(record,registry,new Map([['a',1]])),/stale_incarnation/);
|
let allowed=true,calls=0;const custom=defineComponent({id:'text',dataVersion:1,structure:'node',features:[],validate:function(data){calls++;assert.equal(this.id,'text');return allowed&&typeof data.title==='string';}}),other=createRegistry([custom]);validateElementRecord(record,other);allowed=false;assert.throws(()=>validateElementRecord(record,other),/invalid_component_data/);assert.equal(calls,2);assert.throws(()=>validateElementRecord(record,registry,new Map([['a',1]])),/stale_incarnation/);
|
||||||
const malformed=createDocument('p',[{id:'root',parent:null}],[{...input,data:{title:7,text:''}}]).elements.get('a');assert.throws(()=>validateElementRecord(malformed,registry),/invalid_component_data/);assert.throws(()=>validateElementRecord(malformed,registry),/invalid_component_data/);
|
const malformed=createDocument('p',[{id:'root',parent:null}],[{...input,data:{title:7,text:''}}]).elements.get('a');assert.throws(()=>validateElementRecord(malformed,registry),/invalid_component_data/);assert.throws(()=>validateElementRecord(malformed,registry),/invalid_component_data/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 把框架记录重新装进文档可保留引用,外部或浅冻结仿制记录须复制;切动态schema后仍能拒绝。
|
||||||
test('core-owned immutable element data survives document re-enveloping without aliasing caller-owned records',()=>{
|
test('core-owned immutable element data survives document re-enveloping without aliasing caller-owned records',()=>{
|
||||||
const f=setup(),record=f.document.elements.get('a');validateElementRecord(record,f.registry);const next=createDocument('p',[...f.document.canvases.values()],[record]);assert.strictEqual(next.elements.get('a'),record);const external=node('external'),copy=createDocument('p',[{id:'root',parent:null}],[external]);assert.notStrictEqual(copy.elements.get('external'),external);external.data.text='later';assert.equal(copy.elements.get('external').data.text,'hello');const lookalike=Object.freeze({...record});assert.notStrictEqual(createDocument('p',[{id:'root',parent:null}],[lookalike]).elements.get('a'),lookalike);f.allow(false);assert.throws(()=>validateDocument(next,f.registry),/invalid_component_data/);
|
const f=setup(),record=f.document.elements.get('a');validateElementRecord(record,f.registry);const next=createDocument('p',[...f.document.canvases.values()],[record]);assert.strictEqual(next.elements.get('a'),record);const external=node('external'),copy=createDocument('p',[{id:'root',parent:null}],[external]);assert.notStrictEqual(copy.elements.get('external'),external);external.data.text='later';assert.equal(copy.elements.get('external').data.text,'hello');const lookalike=Object.freeze({...record});assert.notStrictEqual(createDocument('p',[{id:'root',parent:null}],[lookalike]).elements.get('a'),lookalike);f.allow(false);assert.throws(()=>validateDocument(next,f.registry),/invalid_component_data/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 呈现帧队列与外部store适配的调度测试。用可手动推进或故意不执行cancel的时钟,验证合并、立即回显、迟到回调及重入隔离。
|
||||||
|
* 延迟的是通知呈现,不是editor提交或getSnapshot中的最新事实;这里不运行真实requestAnimationFrame。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createFrameQueue,createFrameSource} from '../../src/lib/canvas-bindings/frame-queue.ts';
|
import {createFrameQueue,createFrameSource} from '../../src/lib/canvas-bindings/frame-queue.ts';
|
||||||
import {createSessionFrameSource} from '../../src/lib/canvas-bindings/session-frames.ts';
|
import {createSessionFrameSource} from '../../src/lib/canvas-bindings/session-frames.ts';
|
||||||
|
/** Map保存待执行任务,step先取出并清一批再执行,回调中新排的任务留下一帧;size用于检查调度是否泄漏。 */
|
||||||
function clock(){let id=0;const callbacks=new Map();return{request(fn){callbacks.set(++id,fn);return id;},cancel(id){callbacks.delete(id);},step(){const batch=[...callbacks.values()];callbacks.clear();for(const fn of batch)fn();},get size(){return callbacks.size;}};}
|
function clock(){let id=0;const callbacks=new Map();return{request(fn){callbacks.set(++id,fn);return id;},cancel(id){callbacks.delete(id);},step(){const batch=[...callbacks.values()];callbacks.clear();for(const fn of batch)fn();},get size(){return callbacks.size;}};}
|
||||||
|
// 重复a与b合成一个dirty集合,flush期间新增next留后帧;dispose后排队和新invalidate都不能发布。
|
||||||
test('frame dirty Set deduplicates bursts, flushes last invalidation and preserves reentrant work for the next frame',()=>{const c=clock(),batches=[];let q;q=createFrameQueue(c,dirty=>{batches.push([...dirty]);if(dirty.has('a'))q.invalidate(['next']);});q.invalidate(['a','a']);q.invalidate(['b']);assert.equal(c.size,1);q.flush();assert.deepEqual(batches,[['a','b']]);assert.equal(c.size,1);c.step();assert.deepEqual(batches,[['a','b'],['next']]);q.invalidate(['discard']);q.dispose();c.step();q.invalidate(['closed']);assert.equal(batches.length,2);assert.equal(c.size,0);});
|
test('frame dirty Set deduplicates bursts, flushes last invalidation and preserves reentrant work for the next frame',()=>{const c=clock(),batches=[];let q;q=createFrameQueue(c,dirty=>{batches.push([...dirty]);if(dirty.has('a'))q.invalidate(['next']);});q.invalidate(['a','a']);q.invalidate(['b']);assert.equal(c.size,1);q.flush();assert.deepEqual(batches,[['a','b']]);assert.equal(c.size,1);c.step();assert.deepEqual(batches,[['a','b'],['next']]);q.invalidate(['discard']);q.dispose();c.step();q.invalidate(['closed']);assert.equal(batches.length,2);assert.equal(c.size,0);});
|
||||||
|
// 假cancel不移除函数,手动执行旧回调模拟迟到;代次检查必须阻止旧批次或已dispose队列再次通知。
|
||||||
test('canceled late callbacks cannot publish a replaced or disposed frame',()=>{const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},batches=[];const q=createFrameQueue(c,dirty=>batches.push([...dirty]));q.invalidate(['first']);q.flush();q.invalidate(['second']);callbacks[0]();assert.deepEqual(batches,[['first']]);q.dispose();callbacks[1]();assert.equal(batches.length,1);});
|
test('canceled late callbacks cannot publish a replaced or disposed frame',()=>{const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},batches=[];const q=createFrameQueue(c,dirty=>batches.push([...dirty]));q.invalidate(['first']);q.flush();q.invalidate(['second']);callbacks[0]();assert.deepEqual(batches,[['first']]);q.dispose();callbacks[1]();assert.equal(batches.length,1);});
|
||||||
|
// 20次预览只排一帧,但读取立即得到x=19;取消后读取立即变null,退订清理上游及待呈现通知。
|
||||||
test('session presentation coalesces preview bursts but live reads and final cancel remain immediate; unsubscribe drops pending frame',()=>{const c=clock(),listeners=new Set();let snapshot={views:new Map(),gesture:null,connectionGesture:null};const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createSessionFrameSource(source,c);let paints=0;const stop=framed.subscribe(()=>paints++);const emit=next=>{snapshot=next;for(const fn of listeners)fn();};for(let i=0;i<20;i++)emit({...snapshot,gesture:{targets:[{id:'a',incarnation:1}],preview:new Map([['a',{x:i}]])}});assert.equal(c.size,1);assert.equal(paints,0);assert.equal(framed.getSnapshot().gesture.preview.get('a').x,19);c.step();assert.equal(paints,1);emit({...snapshot,gesture:null});assert.equal(framed.getSnapshot().gesture,null);c.step();assert.equal(paints,2);emit({...snapshot,views:new Map([['v',{}]])});stop();c.step();assert.equal(paints,2);assert.equal(listeners.size,0);});
|
test('session presentation coalesces preview bursts but live reads and final cancel remain immediate; unsubscribe drops pending frame',()=>{const c=clock(),listeners=new Set();let snapshot={views:new Map(),gesture:null,connectionGesture:null};const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createSessionFrameSource(source,c);let paints=0;const stop=framed.subscribe(()=>paints++);const emit=next=>{snapshot=next;for(const fn of listeners)fn();};for(let i=0;i<20;i++)emit({...snapshot,gesture:{targets:[{id:'a',incarnation:1}],preview:new Map([['a',{x:i}]])}});assert.equal(c.size,1);assert.equal(paints,0);assert.equal(framed.getSnapshot().gesture.preview.get('a').x,19);c.step();assert.equal(paints,1);emit({...snapshot,gesture:null});assert.equal(framed.getSnapshot().gesture,null);c.step();assert.equal(paints,2);emit({...snapshot,views:new Map([['v',{}]])});stop();c.step();assert.equal(paints,2);assert.equal(listeners.size,0);});
|
||||||
|
|
||||||
|
// 三次编辑已提交且产生三条历史,画面通知合成一次;getSnapshot在帧执行前也返回最新editor根。
|
||||||
test('root presentation batches a burst while commands, history and authoritative reads advance immediately',async()=>{
|
test('root presentation batches a burst while commands, history and authoritative reads advance immediately',async()=>{
|
||||||
const {createDocument,createEditor,createRegistry,builtinComponents}=await import('../../src/lib/canvas-core/index.ts');let id=0;
|
const {createDocument,createEditor,createRegistry,builtinComponents}=await import('../../src/lib/canvas-core/index.ts');let id=0;
|
||||||
const editor=createEditor({document:createDocument('p',[{id:'canvas',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'canvas',componentId:'text',dataVersion:1,structure:'node',data:{title:'before',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}]),registry:createRegistry(builtinComponents()),createId:()=>`op-${++id}`}),c=clock(),commits=[],source={getSnapshot:editor.getSnapshot,subscribe:notify=>editor.subscribe((commit,next)=>{commits.push(next.rootRevision);notify();})},framed=createFrameSource(source,c),painted=[],stop=framed.subscribe(()=>painted.push(framed.getSnapshot()));
|
const editor=createEditor({document:createDocument('p',[{id:'canvas',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'canvas',componentId:'text',dataVersion:1,structure:'node',data:{title:'before',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}]),registry:createRegistry(builtinComponents()),createId:()=>`op-${++id}`}),c=clock(),commits=[],source={getSnapshot:editor.getSnapshot,subscribe:notify=>editor.subscribe((commit,next)=>{commits.push(next.rootRevision);notify();})},framed=createFrameSource(source,c),painted=[],stop=framed.subscribe(()=>painted.push(framed.getSnapshot()));
|
||||||
for(const title of ['first','second','third'])assert.equal(editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title}}).status,'applied');
|
for(const title of ['first','second','third'])assert.equal(editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title}}).status,'applied');
|
||||||
assert.equal(commits.length,3);assert.equal(painted.length,0);assert.equal(c.size,1);assert.strictEqual(framed.getSnapshot(),editor.getSnapshot());assert.equal(framed.getSnapshot().document.elements.get('a').data.title,'third');assert.equal(editor.getSnapshot().history.undo.length,3);c.step();assert.equal(painted.length,1);assert.strictEqual(painted[0],editor.getSnapshot());stop();editor.dispose();
|
assert.equal(commits.length,3);assert.equal(painted.length,0);assert.equal(c.size,1);assert.strictEqual(framed.getSnapshot(),editor.getSnapshot());assert.equal(framed.getSnapshot().document.elements.get('a').data.title,'third');assert.equal(editor.getSnapshot().history.undo.length,3);c.step();assert.equal(painted.length,1);assert.strictEqual(painted[0],editor.getSnapshot());stop();editor.dispose();
|
||||||
});
|
});
|
||||||
|
// A退订后B新订阅,各自回调代次隔离;手动执行A旧函数不能通知B或恢复A。
|
||||||
test('frame source subscriptions own their queues and late callbacks cannot notify an unsubscribed view',()=>{
|
test('frame source subscriptions own their queues and late callbacks cannot notify an unsubscribed view',()=>{
|
||||||
const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},listeners=new Set();let snapshot=0;
|
const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},listeners=new Set();let snapshot=0;
|
||||||
const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createFrameSource(source,c);let a=0,b=0;const stopA=framed.subscribe(()=>a++);snapshot++;for(const fn of listeners)fn();stopA();const stopB=framed.subscribe(()=>b++);snapshot++;for(const fn of listeners)fn();callbacks[0]();assert.equal(a,0);assert.equal(b,0);callbacks[1]();assert.equal(b,1);assert.equal(framed.getSnapshot(),2);stopB();assert.equal(listeners.size,0);
|
const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createFrameSource(source,c);let a=0,b=0;const stopA=framed.subscribe(()=>a++);snapshot++;for(const fn of listeners)fn();stopA();const stopB=framed.subscribe(()=>b++);snapshot++;for(const fn of listeners)fn();callbacks[0]();assert.equal(a,0);assert.equal(b,0);callbacks[1]();assert.equal(b,1);assert.equal(framed.getSnapshot(),2);stopB();assert.equal(listeners.size,0);
|
||||||
});
|
});
|
||||||
|
// 上游subscribe先通知再抛错要清掉任务;正常通知回调重入触发状态2,必须等下一帧而非混入状态1当前批。
|
||||||
test('eager failed subscriptions cancel presentation work and reentrant changes wait for the next frame',()=>{
|
test('eager failed subscriptions cancel presentation work and reentrant changes wait for the next frame',()=>{
|
||||||
const c=clock(),broken=createFrameSource({getSnapshot:()=>0,subscribe(notify){notify();throw Error('failed subscribe');}},c);assert.throws(()=>broken.subscribe(()=>assert.fail('failed subscription painted')),/failed subscribe/);assert.equal(c.size,0);
|
const c=clock(),broken=createFrameSource({getSnapshot:()=>0,subscribe(notify){notify();throw Error('failed subscribe');}},c);assert.throws(()=>broken.subscribe(()=>assert.fail('failed subscription painted')),/failed subscribe/);assert.equal(c.size,0);
|
||||||
let current=0,listener;const source={getSnapshot:()=>current,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>{seen.push(framed.getSnapshot());if(current===1){current=2;listener();}});current=1;listener();c.step();assert.deepEqual(seen,[1]);assert.equal(c.size,1);c.step();assert.deepEqual(seen,[1,2]);stop();
|
let current=0,listener;const source={getSnapshot:()=>current,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>{seen.push(framed.getSnapshot());if(current===1){current=2;listener();}});current=1;listener();c.step();assert.deepEqual(seen,[1]);assert.equal(c.size,1);c.step();assert.deepEqual(seen,[1,2]);stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 后台值1尚待帧时输入值2以immediate通知,直接发布2并取消旧帧;随后值3仍按普通帧更新。
|
||||||
test('an immediate input invalidation publishes the newest Root and consumes any pending background frame',()=>{
|
test('an immediate input invalidation publishes the newest Root and consumes any pending background frame',()=>{
|
||||||
const c=clock();let value=0,listener;const source={getSnapshot:()=>value,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>seen.push(framed.getSnapshot()));value=1;listener();assert.equal(c.size,1);value=2;listener(true);assert.deepEqual(seen,[2]);assert.equal(c.size,0);c.step();assert.deepEqual(seen,[2]);value=3;listener();c.step();assert.deepEqual(seen,[2,3]);stop();
|
const c=clock();let value=0,listener;const source={getSnapshot:()=>value,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>seen.push(framed.getSnapshot()));value=1;listener();assert.equal(c.size,1);value=2;listener(true);assert.deepEqual(seen,[2]);assert.equal(c.size,0);c.step();assert.deepEqual(seen,[2]);value=3;listener();c.step();assert.deepEqual(seen,[2,3]);stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* bindings几何投影缓存测试。用缩放子图与跨层曲线比较缓存投影和直接投影,按计算次数验证局部失效。
|
||||||
|
* 统计用于确定性工作量检查,不是浏览器帧率基准;缓存不能把旧查询、旧生命周期或可变预览当成不变输入。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {createGeometryProjectionCache} from '../../src/lib/canvas-bindings/geometry-cache.ts';
|
import {createGeometryProjectionCache} from '../../src/lib/canvas-bindings/geometry-cache.ts';
|
||||||
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
||||||
|
// 最小节点/连线工厂保留owner和可选path,以同一registry构造对照。
|
||||||
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner,path=null)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:source},target:{elementId:target},path});
|
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner,path=null)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:source},target:{elementId:target},path});
|
||||||
|
// P含缩放入口F,S含A/B;in边归P带路径,local边归S,祖先变化应影响不同几何缓存项。
|
||||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',presentation:{contentScale:2,unitScale:.75}}},node('A','S'),node('B','S'),edge('in','C','A','P',{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:500,y:200},in:{x:10,y:0},out:{x:20,y:0}}]}),edge('local','A','B','S')]);
|
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',presentation:{contentScale:2,unitScale:.75}}},node('A','S'),node('B','S'),edge('in','C','A','P',{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:500,y:200},in:{x:10,y:0},out:{x:20,y:0}}]}),edge('local','A','B','S')]);
|
||||||
|
|
||||||
|
// 每次draw与直接投影深比较;标题、选择与元数据不增计算,A局部变化只重算自身,F移动再影响后代及路径。
|
||||||
test('indexed renderer projection preserves legacy output and avoids unrelated geometry work on title, selection and ACK',()=>{
|
test('indexed renderer projection preserves legacy output and avoids unrelated geometry work on title, selection and ACK',()=>{
|
||||||
let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`,runtime:0}),cache=createGeometryProjectionCache(),owner={},selected=new Set(),draw=()=>{const doc=editor.getSnapshot().document,actual=projectCanvas(doc,'P',registry,selected,()=>undefined,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});assert.deepEqual(actual,projectCanvas(doc,'P',registry,selected,()=>undefined));return actual;};
|
let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`,runtime:0}),cache=createGeometryProjectionCache(),owner={},selected=new Set(),draw=()=>{const doc=editor.getSnapshot().document,actual=projectCanvas(doc,'P',registry,selected,()=>undefined,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});assert.deepEqual(actual,projectCanvas(doc,'P',registry,selected,()=>undefined));return actual;};
|
||||||
draw();const before=cache.stats();assert.deepEqual({nodes:before.nodesComputed,children:before.childrenComputed,edges:before.edgesComputed},{nodes:4,children:1,edges:2});editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'only title'}});draw();assert.deepEqual(cache.stats(),before);selected.add('B');draw();assert.deepEqual(cache.stats(),before);editor.updateRuntime(()=>1);draw();assert.deepEqual(cache.stats(),before);
|
draw();const before=cache.stats();assert.deepEqual({nodes:before.nodesComputed,children:before.childrenComputed,edges:before.edgesComputed},{nodes:4,children:1,edges:2});editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'only title'}});draw();assert.deepEqual(cache.stats(),before);selected.add('B');draw();assert.deepEqual(cache.stats(),before);editor.updateRuntime(()=>1);draw();assert.deepEqual(cache.stats(),before);
|
||||||
@@ -13,11 +20,13 @@ test('indexed renderer projection preserves legacy output and avoids unrelated g
|
|||||||
editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});draw();assert.equal(cache.stats().nodesComputed,before.nodesComputed+4);assert.equal(cache.stats().childrenComputed,before.childrenComputed+1);assert.equal(cache.stats().edgesComputed,before.edgesComputed+1);
|
editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});draw();assert.equal(cache.stats().nodesComputed,before.nodesComputed+4);assert.equal(cache.stats().childrenComputed,before.childrenComputed+1);assert.equal(cache.stats().edgesComputed,before.edgesComputed+1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 折叠清不可见缓存,旧query拒绝;预览进入/退出各重算,删除恢复incarnation=2后重新出现,不能沿用旧记录身份。
|
||||||
test('geometry cache prunes collapsed/deleted data, respects previews/lifecycles and rejects stale query snapshots',()=>{
|
test('geometry cache prunes collapsed/deleted data, respects previews/lifecycles and rejects stale query snapshots',()=>{
|
||||||
let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`}),cache=createGeometryProjectionCache(),owner={},draw=(geometry=()=>undefined)=>{const doc=editor.getSnapshot().document;return projectCanvas(doc,'P',registry,new Set(),geometry,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});};draw();const old=editor.getQueries();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{contentScale:2,unitScale:.75,collapsed:true}}});draw();assert.equal(cache.stats().nodes,2);assert.equal(cache.stats().children,0);assert.equal(cache.stats().edges,1);assert.throws(()=>projectCanvas(editor.getSnapshot().document,'P',registry,new Set(),()=>undefined,null,undefined,{queries:old}),/stale_query_snapshot/);
|
let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`}),cache=createGeometryProjectionCache(),owner={},draw=(geometry=()=>undefined)=>{const doc=editor.getSnapshot().document;return projectCanvas(doc,'P',registry,new Set(),geometry,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});};draw();const old=editor.getQueries();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{contentScale:2,unitScale:.75,collapsed:true}}});draw();assert.equal(cache.stats().nodes,2);assert.equal(cache.stats().children,0);assert.equal(cache.stats().edges,1);assert.throws(()=>projectCanvas(editor.getSnapshot().document,'P',registry,new Set(),()=>undefined,null,undefined,{queries:old}),/stale_query_snapshot/);
|
||||||
editor.history.undo();draw();assert.equal(cache.stats().nodes,4);const before=cache.stats().nodesComputed,preview={...editor.getSnapshot().document.elements.get('A').geometry,x:900};draw(id=>id==='A'?preview:undefined);assert.equal(cache.stats().nodesComputed,before+1);draw();assert.equal(cache.stats().nodesComputed,before+2);editor.dispatch({type:'element.remove',target:{id:'A',incarnation:1}});draw();assert.equal(cache.stats().nodes,3);editor.history.undo();draw();assert.equal(cache.stats().nodes,4);assert.equal(editor.getSnapshot().document.elements.get('A').incarnation,2);
|
editor.history.undo();draw();assert.equal(cache.stats().nodes,4);const before=cache.stats().nodesComputed,preview={...editor.getSnapshot().document.elements.get('A').geometry,x:900};draw(id=>id==='A'?preview:undefined);assert.equal(cache.stats().nodesComputed,before+1);draw();assert.equal(cache.stats().nodesComputed,before+2);editor.dispatch({type:'element.remove',target:{id:'A',incarnation:1}});draw();assert.equal(cache.stats().nodes,3);editor.history.undo();draw();assert.equal(cache.stats().nodes,4);assert.equal(editor.getSnapshot().document.elements.get('A').incarnation,2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 原path后来修改不改变first结果,新输入按scale=2/x偏移1得到61;输出深冻结,换owner清旧边缓存。
|
||||||
test('path cache snapshots mutable preview inputs and geometry cache cannot be corrupted through returned values',()=>{
|
test('path cache snapshots mutable preview inputs and geometry cache cannot be corrupted through returned values',()=>{
|
||||||
const cache=createGeometryProjectionCache(),owner={},t={x:1,y:2,scale:2,rotation:0},path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:0,y:0},out:{x:0,y:0}}]};cache.begin(owner,'project','P');const first=cache.edge('e',1,path,t);path.knots[0].position.x=30;const second=cache.edge('e',1,path,t);assert.notEqual(first,second);assert.equal(second.knots[0].position.x,61);assert.equal(first.knots[0].position.x,21);assert.ok(Object.isFrozen(second.knots[0].position));cache.end();cache.begin({},'project','P');cache.node('A',1,node('A').geometry,t);cache.end();assert.equal(cache.stats().edges,0);
|
const cache=createGeometryProjectionCache(),owner={},t={x:1,y:2,scale:2,rotation:0},path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:0,y:0},out:{x:0,y:0}}]};cache.begin(owner,'project','P');const first=cache.edge('e',1,path,t);path.knots[0].position.x=30;const second=cache.edge('e',1,path,t);assert.notEqual(first,second);assert.equal(second.knots[0].position.x,61);assert.equal(first.knots[0].position.x,21);assert.ok(Object.isFrozen(second.knots[0].position));cache.end();cache.begin({},'project','P');cache.node('A',1,node('A').geometry,t);cache.end();assert.equal(cache.stats().edges,0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 手势坐标上下文与目标可见性的回归。三层canvas加独立子视图,验证祖先位置/缩放/身份变化何时取消预览。
|
||||||
|
* 同时检查owner链读取缓存与未知祖先数据隔离;这里没有DOM指针捕获,测试的是core会话及placement读取器。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createGesturePlacementReader} from '../../src/lib/canvas-core/gesture-placement.ts';
|
import {createGesturePlacementReader} from '../../src/lib/canvas-core/gesture-placement.ts';
|
||||||
import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts';
|
||||||
|
/** 合法100×100节点工厂,可指定owner和frame组件,用来搭建坐标链。 */
|
||||||
const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||||
|
/** 子canvas局部空间中的单个曲线点,连接手势用它验证owner祖先变化会失效路径预览。 */
|
||||||
const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:-10,y:0},out:{x:10,y:0}}]};
|
const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:-10,y:0},out:{x:10,y:0}}]};
|
||||||
|
/** root→child→grand由F/G入口连接,A/D及其边归child,B归grand;同时挂root与child-view比较坐标边界。 */
|
||||||
function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}},{id:'grand',parent:{canvasId:'child',entryElementId:'G'}}],[node('F','root','frame'),node('G','child','frame'),node('A','child'),node('B','grand'),node('D','child'),node('C'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'child',source:{elementId:'A'},target:{elementId:'D'},path,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor);sessions.attach('v','root');sessions.attach('child-view','child');return{editor,sessions};}
|
function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}},{id:'grand',parent:{canvasId:'child',entryElementId:'G'}}],[node('F','root','frame'),node('G','child','frame'),node('A','child'),node('B','grand'),node('D','child'),node('C'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'child',source:{elementId:'A'},target:{elementId:'D'},path,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor);sessions.attach('v','root');sessions.attach('child-view','child');return{editor,sessions};}
|
||||||
|
// 根视图预览B时移动祖先F,手势先取消;迟到commit拒绝且B的持久几何仍为0。
|
||||||
test('moving an ancestor cancels a descendant preview before a late commit can use old coordinates',()=>{const{editor,sessions}=fixture();assert.equal(sessions.begin('v',[{id:'B',incarnation:1}]).enabled,true);sessions.update('v',{id:'B',incarnation:1},{x:50});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);const before=editor.getSnapshot();assert.equal(sessions.commit('v').status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(before.document.elements.get('B').geometry.x,0);});
|
test('moving an ancestor cancels a descendant preview before a late commit can use old coordinates',()=>{const{editor,sessions}=fixture();assert.equal(sessions.begin('v',[{id:'B',incarnation:1}]).enabled,true);sessions.update('v',{id:'B',incarnation:1},{x:50});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);const before=editor.getSnapshot();assert.equal(sessions.commit('v').status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(before.document.elements.get('B').geometry.x,0);});
|
||||||
|
// 折叠F隐藏A,独立child-view也不能编辑根节点C;分别断言target_hidden与target_outside_view。
|
||||||
test('collapsed and outside-view targets cannot acquire a gesture',()=>{const{editor,sessions}=fixture();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'target_hidden');assert.equal(sessions.begin('child-view',[{id:'C',incarnation:1}]).code,'target_outside_view');});
|
test('collapsed and outside-view targets cannot acquire a gesture',()=>{const{editor,sessions}=fixture();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'target_hidden');assert.equal(sessions.begin('child-view',[{id:'C',incarnation:1}]).code,'target_outside_view');});
|
||||||
|
// 对child局部路径预览后移动F,连接手势必须取消,不能把旧路径坐标提交到新上下文。
|
||||||
test('moving a path owner ancestor cancels its connection preview',()=>{const{editor,sessions}=fixture();assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).enabled,true);sessions.updateConnection('v',{...path,knots:[]});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().connectionGesture,null);});
|
test('moving a path owner ancestor cancels its connection preview',()=>{const{editor,sessions}=fixture();assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).enabled,true);sessions.updateConnection('v',{...path,knots:[]});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().connectionGesture,null);});
|
||||||
|
|
||||||
|
// 分别改contentScale/unitScale/collapsed要求坐标失效;仅标签/标题或无关C几何改变则保留同一gesture对象。
|
||||||
test('scale and collapse cancel active descendant gestures but presentation labels do not',()=>{for(const presentation of [{contentScale:2},{unitScale:2},{collapsed:true}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);sessions.update('v',{id:'B',incarnation:1},{x:50});editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation}});assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'coordinate_context_changed');}const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'another title',presentation:{labelMode:'hidden',contentScale:1,unitScale:1}}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);editor.dispatch({type:'element.geometry',target:{id:'C',incarnation:1},patch:{x:80}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);});
|
test('scale and collapse cancel active descendant gestures but presentation labels do not',()=>{for(const presentation of [{contentScale:2},{unitScale:2},{collapsed:true}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);sessions.update('v',{id:'B',incarnation:1},{x:50});editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation}});assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'coordinate_context_changed');}const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'another title',presentation:{labelMode:'hidden',contentScale:1,unitScale:1}}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);editor.dispatch({type:'element.geometry',target:{id:'C',incarnation:1},patch:{x:80}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);});
|
||||||
|
// child-view以child为坐标根,上方F移动不影响本地B预览,commit仍可合法写x=50。
|
||||||
test('ancestor changes above an independently opened view do not alter its local coordinate space',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);sessions.update('child-view',{id:'B',incarnation:1},{x:50});const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);assert.equal(sessions.commit('child-view').status,'applied');assert.equal(editor.getSnapshot().document.elements.get('B').geometry.x,50);});
|
test('ancestor changes above an independently opened view do not alter its local coordinate space',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);sessions.update('child-view',{id:'B',incarnation:1},{x:50});const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);assert.equal(sessions.commit('child-view').status,'applied');assert.equal(editor.getSnapshot().document.elements.get('B').geometry.x,50);});
|
||||||
|
// 祖先生命周期、未来版本或同几何换owner都改变上下文;即使像素位置看似一致,也必须取消旧手势。
|
||||||
test('ancestor restore, future version and equal-geometry reparent invalidate the old context',()=>{for(const patch of [{incarnation:2},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='F'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);}const{editor,sessions}=fixture();editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('H','root','frame'),geometry:{...node('H').geometry,x:-24,y:-36}}});tx.dispatch({type:'canvas.create',canvas:{id:'sibling',parent:{canvasId:'root',entryElementId:'H'}}});});sessions.begin('v',[{id:'B',incarnation:1}]);assert.equal(editor.dispatch({type:'element.move',target:{id:'F',incarnation:1},canvasId:'sibling',geometry:node('F').geometry}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);});
|
test('ancestor restore, future version and equal-geometry reparent invalidate the old context',()=>{for(const patch of [{incarnation:2},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='F'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);}const{editor,sessions}=fixture();editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('H','root','frame'),geometry:{...node('H').geometry,x:-24,y:-36}}});tx.dispatch({type:'canvas.create',canvas:{id:'sibling',parent:{canvasId:'root',entryElementId:'H'}}});});sessions.begin('v',[{id:'B',incarnation:1}]);assert.equal(editor.dispatch({type:'element.move',target:{id:'F',incarnation:1},canvasId:'sibling',geometry:node('F').geometry}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);});
|
||||||
|
// 目标A由text变plain-text后旧预览取消,理由target_changed,不能只凭相同ID保留租约。
|
||||||
test('direct target component changes cannot retain an old geometry lease',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,componentId:'plain-text'}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'target_changed');});
|
test('direct target component changes cannot retain an old geometry lease',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,componentId:'plain-text'}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'target_changed');});
|
||||||
|
// 删除F使child-view回退root,订阅只收到一次同时包含视图变更与手势取消的快照。
|
||||||
test('deleting the viewed canvas publishes its root fallback and cancellation together',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));assert.equal(editor.dispatch({type:'element.remove',target:{id:'F',incarnation:1}}).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('child-view').canvasId,'root');assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'canvas_changed');});
|
test('deleting the viewed canvas publishes its root fallback and cancellation together',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));assert.equal(editor.dispatch({type:'element.remove',target:{id:'F',incarnation:1}}).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('child-view').canvasId,'root');assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'canvas_changed');});
|
||||||
|
|
||||||
|
|
||||||
|
// 用禁止遍历的表包络读取grand链,1000次复用同一结果且仅两次元素get,验证按链而非全图扫描。
|
||||||
test('placement readers share a checked owner chain and never enumerate the project',()=>{const{editor}=fixture(),document=editor.getSnapshot().document;let reads=0;const protectedDocument={...document,canvases:{has:id=>document.canvases.has(id),get:id=>document.canvases.get(id),[Symbol.iterator](){throw Error('project enumeration');}},elements:{get(id){reads++;return document.elements.get(id);},[Symbol.iterator](){throw Error('project enumeration');}}},read=createGesturePlacementReader(protectedDocument,'root',editor.registry),first=read('grand');assert.equal(first.length,2);for(let i=0;i<1000;i++)assert.strictEqual(read('grand'),first);assert.equal(reads,2);});
|
test('placement readers share a checked owner chain and never enumerate the project',()=>{const{editor}=fixture(),document=editor.getSnapshot().document;let reads=0;const protectedDocument={...document,canvases:{has:id=>document.canvases.has(id),get:id=>document.canvases.get(id),[Symbol.iterator](){throw Error('project enumeration');}},elements:{get(id){reads++;return document.elements.get(id);},[Symbol.iterator](){throw Error('project enumeration');}}},read=createGesturePlacementReader(protectedDocument,'root',editor.registry),first=read('grand');assert.equal(first.length,2);for(let i=0;i<1000;i++)assert.strictEqual(read('grand'),first);assert.equal(reads,2);});
|
||||||
|
// 未来版本F的data getter一读就抛错;placement应以target_hidden拒绝且getter读取数为0。
|
||||||
test('future ancestor data is never interpreted to decide visibility',()=>{const{editor}=fixture(),document=editor.getSnapshot().document,frame=document.elements.get('F');let inspected=0;const opaque={...frame,dataVersion:99,get data(){inspected++;throw Error('opaque payload read');}},elements=new Map(document.elements);elements.set('F',opaque);const read=createGesturePlacementReader({...document,elements},'root',editor.registry);assert.throws(()=>read('grand'),/target_hidden/);assert.equal(inspected,0);});
|
test('future ancestor data is never interpreted to decide visibility',()=>{const{editor}=fixture(),document=editor.getSnapshot().document,frame=document.elements.get('F');let inspected=0;const opaque={...frame,dataVersion:99,get data(){inspected++;throw Error('opaque payload read');}},elements=new Map(document.elements);elements.set('F',opaque);const read=createGesturePlacementReader({...document,elements},'root',editor.registry);assert.throws(()=>read('grand'),/target_hidden/);assert.equal(inspected,0);});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 历史栈变更证据的回放与恢复测试。验证每个transition可正反应用、只存增量范围,并在本地恢复/冲突方案中保留可验证顺序。
|
||||||
|
* 内存storage及固定拒绝的persistence制造可重复的冲突环境,不代表真实磁盘崩溃或服务端集成验证。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createEditor,createDocument,createRegistry,builtinComponents,applyHistoryTransition,validateHistoryTransition,historyTransitionChanges,describeHistoryTransition,readMap} from '../../src/lib/canvas-core/index.ts';
|
import {createEditor,createDocument,createRegistry,builtinComponents,applyHistoryTransition,validateHistoryTransition,historyTransitionChanges,describeHistoryTransition,readMap} from '../../src/lib/canvas-core/index.ts';
|
||||||
@@ -6,21 +10,28 @@ import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
|||||||
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
|
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
|
||||||
import {replayHistoryEvidence,sameHistory,validateHistoryReplay} from '../../src/lib/canvas-runtime/history-replay.ts';
|
import {replayHistoryEvidence,sameHistory,validateHistoryReplay} from '../../src/lib/canvas-runtime/history-replay.ts';
|
||||||
import {resourcePins} from '../../src/lib/canvas-runtime/resources.ts';
|
import {resourcePins} from '../../src/lib/canvas-runtime/resources.ts';
|
||||||
|
// 共用只读registry和原始文档,每个editor/engine另建状态,避免测试相互继承历史。
|
||||||
const registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'root',geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument('project',[{id:'root',parent:null}],[node]);
|
const registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'root',geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument('project',[{id:'root',parent:null}],[node]);
|
||||||
|
/** 历史转成可深比较的栈与排序entries数组,忽略Map迭代次序差异,保留条目实际内容。 */
|
||||||
const value=h=>({undo:h.undo,redo:h.redo,entries:[...h.entries].sort(([a],[b])=>a.localeCompare(b))});
|
const value=h=>({undo:h.undo,redo:h.redo,entries:[...h.entries].sort(([a],[b])=>a.localeCompare(b))});
|
||||||
|
/** 对同一第一生命周期节点改正文,settings控制分组或不记历史。 */
|
||||||
const edit=(editor,text,settings)=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text}},settings);
|
const edit=(editor,text,settings)=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text}},settings);
|
||||||
|
|
||||||
|
// 同组输入、undo/redo、切分支及容量淘汰逐项记证据,再按反序退空、正序恢复最终历史。
|
||||||
test('history transition evidence exactly rewinds and replays typing, undo/redo, redo invalidation and capacity eviction',()=>{
|
test('history transition evidence exactly rewinds and replays typing, undo/redo, redo invalidation and capacity eviction',()=>{
|
||||||
const editor=createEditor({registry,document,historyLimit:2}),events=[];
|
const editor=createEditor({registry,document,historyLimit:2}),events=[];
|
||||||
|
/** 每次动作捕获前后历史,检查transition版本/修订/entry身份,并双向重放;events随后用于整段回卷。 */
|
||||||
function run(action){const before=editor.getSnapshot(),result=action();assert.equal(result.status,'applied');const after=editor.getSnapshot(),intent=after.operations.get(result.operationIds[0]),transition=intent.historyTransition;assert.equal(transition.version,1);assert.equal(transition.rootRevision,after.rootRevision);assert.equal(transition.entryId,result.historyEntryId);assert.deepEqual(value(applyHistoryTransition(after.history,transition,'reverse')),value(before.history));assert.deepEqual(value(applyHistoryTransition(before.history,transition,'forward')),value(after.history));events.push(transition);}
|
function run(action){const before=editor.getSnapshot(),result=action();assert.equal(result.status,'applied');const after=editor.getSnapshot(),intent=after.operations.get(result.operationIds[0]),transition=intent.historyTransition;assert.equal(transition.version,1);assert.equal(transition.rootRevision,after.rootRevision);assert.equal(transition.entryId,result.historyEntryId);assert.deepEqual(value(applyHistoryTransition(after.history,transition,'reverse')),value(before.history));assert.deepEqual(value(applyHistoryTransition(before.history,transition,'forward')),value(after.history));events.push(transition);}
|
||||||
run(()=>edit(editor,'one',{groupId:'typing'}));run(()=>edit(editor,'two',{groupId:'typing'}));run(()=>editor.history.undo());run(()=>editor.history.redo());run(()=>editor.history.undo());run(()=>edit(editor,'branch'));run(()=>edit(editor,'third'));run(()=>edit(editor,'fourth'));
|
run(()=>edit(editor,'one',{groupId:'typing'}));run(()=>edit(editor,'two',{groupId:'typing'}));run(()=>editor.history.undo());run(()=>editor.history.redo());run(()=>editor.history.undo());run(()=>edit(editor,'branch'));run(()=>edit(editor,'third'));run(()=>edit(editor,'fourth'));
|
||||||
let history=editor.getSnapshot().history;for(const event of [...events].reverse())history=applyHistoryTransition(history,event,'reverse');assert.equal(history.entries.size,0);for(const event of events)history=applyHistoryTransition(history,event,'forward');assert.deepEqual(value(history),value(editor.getSnapshot().history));
|
let history=editor.getSnapshot().history;for(const event of [...events].reverse())history=applyHistoryTransition(history,event,'reverse');assert.equal(history.entries.size,0);for(const event of events)history=applyHistoryTransition(history,event,'forward');assert.deepEqual(value(history),value(editor.getSnapshot().history));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同组80次编辑后检查最后transition偏移79且只追加一项,防止每次都复制全部累计历史。
|
||||||
test('typing transitions append only new ranges rather than embedding the accumulated entry',()=>{
|
test('typing transitions append only new ranges rather than embedding the accumulated entry',()=>{
|
||||||
const editor=createEditor({registry,document});for(let i=0;i<80;i++)edit(editor,'text '+i,{groupId:'typing'});const intent=[...editor.getSnapshot().operations.values()].at(-1),patch=intent.historyTransition.entries[0];assert.equal(patch.changes.offset,79);assert.equal(patch.changes.before.length,0);assert.equal(patch.changes.after.length,1);assert.equal(patch.operationIds.after.length,1);assert.equal(historyTransitionChanges(intent.historyTransition).length,1);
|
const editor=createEditor({registry,document});for(let i=0;i<80;i++)edit(editor,'text '+i,{groupId:'typing'});const intent=[...editor.getSnapshot().operations.values()].at(-1),patch=intent.historyTransition.entries[0];assert.equal(patch.changes.offset,79);assert.equal(patch.changes.before.length,0);assert.equal(patch.changes.after.length,1);assert.equal(patch.operationIds.after.length,1);assert.equal(historyTransitionChanges(intent.historyTransition).length,1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 篡改栈/条目内容制造回放冲突,非法偏移或条目ID也拒绝;未来解释版本走明确不可解释错误。
|
||||||
test('transition replay is immutable and rejects changed stack or changed replaced range',()=>{
|
test('transition replay is immutable and rejects changed stack or changed replaced range',()=>{
|
||||||
const editor=createEditor({registry,document}),result=edit(editor,'one'),snapshot=editor.getSnapshot(),transition=snapshot.operations.get(result.operationIds[0]).historyTransition,entry=snapshot.history.entries.values().next().value;
|
const editor=createEditor({registry,document}),result=edit(editor,'one'),snapshot=editor.getSnapshot(),transition=snapshot.operations.get(result.operationIds[0]).historyTransition,entry=snapshot.history.entries.values().next().value;
|
||||||
assert.throws(()=>applyHistoryTransition({...snapshot.history,undo:[]},transition,'reverse'),/history_transition_conflict/);
|
assert.throws(()=>applyHistoryTransition({...snapshot.history,undo:[]},transition,'reverse'),/history_transition_conflict/);
|
||||||
@@ -28,15 +39,19 @@ test('transition replay is immutable and rejects changed stack or changed replac
|
|||||||
const invalid=structuredClone(transition);invalid.entries[0].changes.offset=999;assert.throws(()=>validateHistoryTransition(invalid),/invalid_history_transition/);const reserved=structuredClone(transition);reserved.entries[0].after.id='foreign';assert.throws(()=>validateHistoryTransition(reserved),/invalid_history_transition/);assert.throws(()=>applyHistoryTransition(snapshot.history,{...transition,version:2},'reverse'),/history_transition_interpreter_unavailable/);
|
const invalid=structuredClone(transition);invalid.entries[0].changes.offset=999;assert.throws(()=>validateHistoryTransition(invalid),/invalid_history_transition/);const reserved=structuredClone(transition);reserved.entries[0].after.id='foreign';assert.throws(()=>validateHistoryTransition(reserved),/invalid_history_transition/);assert.throws(()=>applyHistoryTransition(snapshot.history,{...transition,version:2},'reverse'),/history_transition_interpreter_unavailable/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 传输准备层伪造空entries不得通过,editor根保持原对象。
|
||||||
test('prepareIntent cannot substitute forged local history evidence',()=>{
|
test('prepareIntent cannot substitute forged local history evidence',()=>{
|
||||||
const editor=createEditor({registry,document,prepareIntent:intent=>({...intent,historyTransition:{...intent.historyTransition,entries:[]}})}),before=editor.getSnapshot();assert.equal(edit(editor,'forged').code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before);
|
const editor=createEditor({registry,document,prepareIntent:intent=>({...intent,historyTransition:{...intent.historyTransition,entries:[]}})}),before=editor.getSnapshot();assert.equal(edit(editor,'forged').code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 内存镜像clone隔离,提交只核对leader;服务端固定not-applied/version_conflict,read返回原始基准。
|
||||||
|
* 返回image读取器用于破坏副本测试decodeRecovery,调用方finally关闭engine。 */
|
||||||
async function fixture(){
|
async function fixture(){
|
||||||
let image,leader;const storage={async claim(scope){return leader={scope,active:true,fence:1,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},close(){}};
|
let image,leader;const storage={async claim(scope){return leader={scope,active:true,fence:1,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},close(){}};
|
||||||
const persistence={compile(intent){return{payload:{changes:intent.changes},canvasIds:intent.canvasIds,expectedVersions:{root:1}};},async submit(){return{outcome:'not-applied',error:'version_conflict'};},async query(){throw Error('unexpected');},async read(){return{document,versions:new Map([['root',1]])};}};
|
const persistence={compile(intent){return{payload:{changes:intent.changes},canvasIds:intent.canvasIds,expectedVersions:{root:1}};},async submit(){return{outcome:'not-applied',error:'version_conflict'};},async query(){throw Error('unexpected');},async read(){return{document,versions:new Map([['root',1]])};}};
|
||||||
const engine=await openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document,versions:new Map([['root',1]]),storage,persistence});return{engine,image:()=>structuredClone(image)};
|
const engine=await openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document,versions:new Map([['root',1]]),storage,persistence});return{engine,image:()=>structuredClone(image)};
|
||||||
}
|
}
|
||||||
|
// 从恢复镜像反推分支前历史;破坏操作证据或修订被判损坏,未来版本需两份同步保留且使资源扫描不完整。
|
||||||
test('durable transition metadata survives orphaned redo entries, validates both copies and keeps future versions opaque',async()=>{
|
test('durable transition metadata survives orphaned redo entries, validates both copies and keeps future versions opaque',async()=>{
|
||||||
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first');await engine.settle();engine.editor.history.undo();await engine.settle();const before=engine.getSnapshot().history;edit(engine.editor,'branch');await engine.settle();const recovered=decodeRecovery(f.image(),registry),last=[...recovered.operations.values()].at(-1);assert.deepEqual(value(applyHistoryTransition(recovered.history,last.historyTransition,'reverse')),value(before));
|
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first');await engine.settle();engine.editor.history.undo();await engine.settle();const before=engine.getSnapshot().history;edit(engine.editor,'branch');await engine.settle();const recovered=decodeRecovery(f.image(),registry),last=[...recovered.operations.values()].at(-1);assert.deepEqual(value(applyHistoryTransition(recovered.history,last.historyTransition,'reverse')),value(before));
|
||||||
const bad=f.image(),id=last.id;bad.payload.operations.find(([key])=>key===id)[1].historyTransition.entries[0].changes.offset=999;assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/);
|
const bad=f.image(),id=last.id;bad.payload.operations.find(([key])=>key===id)[1].historyTransition.entries[0].changes.offset=999;assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/);
|
||||||
@@ -45,17 +60,20 @@ test('durable transition metadata survives orphaned redo entries, validates both
|
|||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 用不会联网的transport只测试compile,服务操作仅有changes/id,不发送本地historyTransition。
|
||||||
test('local transition evidence does not enter the protocol 2 service payload',()=>{
|
test('local transition evidence does not enter the protocol 2 service payload',()=>{
|
||||||
const root='10000000-0000-4000-8000-000000000001',project=createDocument(root,[{id:root,parent:null}],[{...node,ownerCanvasId:root}]),editor=createEditor({registry,document:project}),result=edit(editor,'new'),intent=editor.getSnapshot().operations.get(result.operationIds[0]);const port=createFoundryPersistence({workspaceId:'10000000-0000-4000-8000-000000000002',rootId:root,registry,transport:async()=>{throw Error('unexpected network');}}),request=port.compile(intent,'10000000-0000-4000-8000-000000000003',{...editor.getSnapshot(),runtime:{versions:new Map([[root,1]])}});assert.deepEqual(Object.keys(request.payload.operations[0]).sort(),['changes','id']);assert.equal(JSON.stringify(request.payload).includes('historyTransition'),false);
|
const root='10000000-0000-4000-8000-000000000001',project=createDocument(root,[{id:root,parent:null}],[{...node,ownerCanvasId:root}]),editor=createEditor({registry,document:project}),result=edit(editor,'new'),intent=editor.getSnapshot().operations.get(result.operationIds[0]);const port=createFoundryPersistence({workspaceId:'10000000-0000-4000-8000-000000000002',rootId:root,registry,transport:async()=>{throw Error('unexpected network');}}),request=port.compile(intent,'10000000-0000-4000-8000-000000000003',{...editor.getSnapshot(),runtime:{versions:new Map([[root,1]])}});assert.deepEqual(Object.keys(request.payload.operations[0]).sort(),['changes','id']);assert.equal(JSON.stringify(request.payload).includes('historyTransition'),false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 图片已从文档和现有history移除,早期undo未解决时仍由后续transition pin资源;全部确认后才解除。
|
||||||
test('cleared redo resource pins survive a later confirmed branch while an earlier text undo is unresolved',()=>{
|
test('cleared redo resource pins survive a later confirmed branch while an earlier text undo is unresolved',()=>{
|
||||||
const editor=createEditor({registry,document});edit(editor,'edited');editor.dispatch({type:'element.create',element:{...node,id:'image',componentId:'image',data:{title:'Image',text:'',src:'https://assets.example/image',assetId:'asset'}}});editor.history.undo();const pending=editor.history.undo();edit(editor,'branch');const snapshot=editor.getSnapshot(),last=[...snapshot.operations.values()].at(-1),operations=new Map([...snapshot.operations].map(([id,intent])=>[id,{intent,phase:id===pending.operationIds[0]?'pending':'resolved',blockers:[],basePresence:{}}]));
|
const editor=createEditor({registry,document});edit(editor,'edited');editor.dispatch({type:'element.create',element:{...node,id:'image',componentId:'image',data:{title:'Image',text:'',src:'https://assets.example/image',assetId:'asset'}}});editor.history.undo();const pending=editor.history.undo();edit(editor,'branch');const snapshot=editor.getSnapshot(),last=[...snapshot.operations.values()].at(-1),operations=new Map([...snapshot.operations].map(([id,intent])=>[id,{intent,phase:id===pending.operationIds[0]?'pending':'resolved',blockers:[],basePresence:{}}]));
|
||||||
assert.equal(snapshot.document.elements.has('image'),false);assert.equal([...snapshot.history.entries.values()].some(entry=>entry.changes.some(change=>change.kind==='element'&&change.id==='image')),false);
|
assert.equal(snapshot.document.elements.has('image'),false);assert.equal([...snapshot.history.entries.values()].some(entry=>entry.changes.some(change=>change.kind==='element'&&change.id==='image')),false);
|
||||||
const pins=resourcePins(snapshot,{operations},registry,new Map());assert.deepEqual([...pins.pins.get('asset')],[`history-transition:${last.id}`]);operations.set(pending.operationIds[0],{...operations.get(pending.operationIds[0]),phase:'resolved'});assert.equal(resourcePins(snapshot,{operations},registry,new Map()).pins.has('asset'),false);
|
const pins=resourcePins(snapshot,{operations},registry,new Map());assert.deepEqual([...pins.pins.get('asset')],[`history-transition:${last.id}`]);operations.set(pending.operationIds[0],{...operations.get(pending.operationIds[0]),phase:'resolved'});assert.equal(resourcePins(snapshot,{operations},registry,new Map()).pins.has('asset'),false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// keep-local方案带采用证据,新replacement操作不继承旧transition;保存后解码保留证据,篡改范围被拒绝。
|
||||||
test('conflict recovery records one adoption transition and never copies stale evidence to replacement requests',async()=>{
|
test('conflict recovery records one adoption transition and never copies stale evidence to replacement requests',async()=>{
|
||||||
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'pending');await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([...before.runtime.ledger.operations].map(([id])=>[id,'keep-local'])));assert.ok(plan.audit.historyTransition);assert.deepEqual(value(applyHistoryTransition(plan.history,plan.audit.historyTransition,'reverse')),value(before.history));assert.equal(plan.operations.get(plan.operationIds[0]).historyTransition,undefined);await session.resolve(plan);await engine.settle();const recovered=decodeRecovery(f.image(),registry);assert.deepEqual(recovered.runtime.recoveries.get(plan.id).historyTransition,plan.audit.historyTransition);
|
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'pending');await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([...before.runtime.ledger.operations].map(([id])=>[id,'keep-local'])));assert.ok(plan.audit.historyTransition);assert.deepEqual(value(applyHistoryTransition(plan.history,plan.audit.historyTransition,'reverse')),value(before.history));assert.equal(plan.operations.get(plan.operationIds[0]).historyTransition,undefined);await session.resolve(plan);await engine.settle();const recovered=decodeRecovery(f.image(),registry);assert.deepEqual(recovered.runtime.recoveries.get(plan.id).historyTransition,plan.audit.historyTransition);
|
||||||
const bad=f.image();bad.payload.runtime.recoveries[0][1].historyTransition.entries[0].changes.offset=999;assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/);
|
const bad=f.image();bad.payload.runtime.recoveries[0][1].historyTransition.entries[0].changes.offset=999;assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/);
|
||||||
@@ -63,18 +81,21 @@ test('conflict recovery records one adoption transition and never copies stale e
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 改label时保留未来嵌套元数据,反向应用仍精确恢复before。
|
||||||
test('transition patches preserve opaque history-entry metadata alongside changed fields',()=>{
|
test('transition patches preserve opaque history-entry metadata alongside changed fields',()=>{
|
||||||
const editor=createEditor({registry,document});edit(editor,'one');const history=editor.getSnapshot().history,id=history.undo[0],original={...history.entries.get(id),future:{nested:{keep:true}}},before={...history,entries:new Map([[id,original]])},after={...history,entries:new Map([[id,{...original,label:'New label'}]])},transition=describeHistoryTransition(before,after,10);
|
const editor=createEditor({registry,document});edit(editor,'one');const history=editor.getSnapshot().history,id=history.undo[0],original={...history.entries.get(id),future:{nested:{keep:true}}},before={...history,entries:new Map([[id,original]])},after={...history,entries:new Map([[id,{...original,label:'New label'}]])},transition=describeHistoryTransition(before,after,10);
|
||||||
const result=applyHistoryTransition(before,transition,'forward');assert.deepEqual(result.entries.get(id).future,{nested:{keep:true}});assert.deepEqual(value(applyHistoryTransition(result,transition,'reverse')),value(before));
|
const result=applyHistoryTransition(before,transition,'forward');assert.deepEqual(result.entries.get(id).future,{nested:{keep:true}});assert.deepEqual(value(applyHistoryTransition(result,transition,'reverse')),value(before));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 创建、改名、删除再恢复导致incarnation=2,证据须覆盖多个旧条目并能双向还原映射。
|
||||||
test('restoration evidence reverses incarnation remapping across earlier history entries',()=>{
|
test('restoration evidence reverses incarnation remapping across earlier history entries',()=>{
|
||||||
const editor=createEditor({registry,document});editor.dispatch({type:'element.create',element:{...node,id:'new'}});editor.dispatch({type:'element.data',target:{id:'new',incarnation:1},patch:{title:'edited'}});editor.dispatch({type:'element.remove',target:{id:'new',incarnation:1}});const before=editor.getSnapshot(),result=editor.history.undo(),after=editor.getSnapshot(),transition=after.operations.get(result.operationIds[0]).historyTransition;
|
const editor=createEditor({registry,document});editor.dispatch({type:'element.create',element:{...node,id:'new'}});editor.dispatch({type:'element.data',target:{id:'new',incarnation:1},patch:{title:'edited'}});editor.dispatch({type:'element.remove',target:{id:'new',incarnation:1}});const before=editor.getSnapshot(),result=editor.history.undo(),after=editor.getSnapshot(),transition=after.operations.get(result.operationIds[0]).historyTransition;
|
||||||
assert.equal(after.document.elements.get('new').incarnation,2);assert.ok(transition.entries.length>=3);assert.deepEqual(value(applyHistoryTransition(after.history,transition,'reverse')),value(before.history));assert.deepEqual(value(applyHistoryTransition(before.history,transition,'forward')),value(after.history));
|
assert.equal(after.document.elements.get('new').incarnation,2);assert.ok(transition.entries.length>=3);assert.deepEqual(value(applyHistoryTransition(after.history,transition,'reverse')),value(before.history));assert.deepEqual(value(applyHistoryTransition(before.history,transition,'forward')),value(after.history));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 分别测几何与同组文本:采用锚点+replacement序列重放应等于plan.history;缺事件/缺操作拒绝,未来版本保留opaque。
|
||||||
test('adoption evidence composes the anchor and replacement events into the exact planned history and survives decoding',async()=>{
|
test('adoption evidence composes the anchor and replacement events into the exact planned history and survives decoding',async()=>{
|
||||||
for(const geometry of [false,true]){
|
for(const geometry of [false,true]){
|
||||||
const f=await fixture(),engine=f.engine;try{if(geometry)engine.editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});else edit(engine.editor,'first',{groupId:'typing'});await engine.settle();if(!geometry)edit(engine.editor,'second',{groupId:'typing'});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),selected=new Map([...before.runtime.ledger.operations].filter(([,record])=>record.phase!=='resolved').map(([id])=>[id,'keep-local'])),plan=session.plan(selected),proof=plan.audit.historyReplay;assert.ok(proof);validateHistoryReplay(proof,before.rootRevision+1);assert.ok(sameHistory(replayHistoryEvidence(before.history,proof,id=>plan.operations.has(id)),plan.history));assert.deepEqual(proof.events.filter(event=>event.kind==='operation').map(event=>event.operationId),plan.operationIds);await session.resolve(plan);await engine.settle();const image=f.image(),decoded=decodeRecovery(image,registry);assert.deepEqual(decoded.runtime.recoveries.get(plan.id).historyReplay,proof);
|
const f=await fixture(),engine=f.engine;try{if(geometry)engine.editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});else edit(engine.editor,'first',{groupId:'typing'});await engine.settle();if(!geometry)edit(engine.editor,'second',{groupId:'typing'});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),selected=new Map([...before.runtime.ledger.operations].filter(([,record])=>record.phase!=='resolved').map(([id])=>[id,'keep-local'])),plan=session.plan(selected),proof=plan.audit.historyReplay;assert.ok(proof);validateHistoryReplay(proof,before.rootRevision+1);assert.ok(sameHistory(replayHistoryEvidence(before.history,proof,id=>plan.operations.has(id)),plan.history));assert.deepEqual(proof.events.filter(event=>event.kind==='operation').map(event=>event.operationId),plan.operationIds);await session.resolve(plan);await engine.settle();const image=f.image(),decoded=decodeRecovery(image,registry);assert.deepEqual(decoded.runtime.recoveries.get(plan.id).historyReplay,proof);
|
||||||
@@ -85,20 +106,24 @@ test('adoption evidence composes the anchor and replacement events into the exac
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 几何不是可选data字段,undo/redo不得额外引入presence标记;证据JSON往返及正向回放仍一致。
|
||||||
test('geometry undo and redo keep optional presence markers absent and history transitions serializable',()=>{
|
test('geometry undo and redo keep optional presence markers absent and history transitions serializable',()=>{
|
||||||
const editor=createEditor({registry,document});editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});for(const direction of ['undo','redo']){const before=editor.getSnapshot(),result=editor.history[direction](),after=editor.getSnapshot(),intent=after.operations.get(result.operationIds[0]);assert.equal(Object.hasOwn(intent.changes[0],'beforePresent'),false);assert.equal(Object.hasOwn(intent.changes[0],'afterPresent'),false);assert.deepEqual(JSON.parse(JSON.stringify(intent.historyTransition)),intent.historyTransition);assert.ok(sameHistory(applyHistoryTransition(before.history,intent.historyTransition,'forward'),after.history));}
|
const editor=createEditor({registry,document});editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});for(const direction of ['undo','redo']){const before=editor.getSnapshot(),result=editor.history[direction](),after=editor.getSnapshot(),intent=after.operations.get(result.operationIds[0]);assert.equal(Object.hasOwn(intent.changes[0],'beforePresent'),false);assert.equal(Object.hasOwn(intent.changes[0],'afterPresent'),false);assert.deepEqual(JSON.parse(JSON.stringify(intent.historyTransition)),intent.historyTransition);assert.ok(sameHistory(applyHistoryTransition(before.history,intent.historyTransition,'forward'),after.history));}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// history=false让最终栈为空,仍必须有replacement事件;删掉证据后恢复解码拒绝,不能只凭栈相等接受。
|
||||||
test('history-disabled replacement evidence cannot be omitted merely because the final stacks are unchanged',async()=>{
|
test('history-disabled replacement evidence cannot be omitted merely because the final stacks are unchanged',async()=>{
|
||||||
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'untracked',{history:false});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([...before.runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.history.entries.length,0);assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,1);image.payload.runtime.recoveries[0][1].historyReplay.events=[];assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();}
|
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'untracked',{history:false});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([...before.runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.history.entries.length,0);assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,1);image.payload.runtime.recoveries[0][1].historyReplay.events=[];assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 两次不记历史操作的证据仍有顺序,反转events必须判损坏。
|
||||||
test('adoption evidence preserves replacement sequence even when all history transitions are no-ops',async()=>{
|
test('adoption evidence preserves replacement sequence even when all history transitions are no-ops',async()=>{
|
||||||
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first',{history:false});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'second'}},{history:false});await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map([...engine.getSnapshot().runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,2);image.payload.runtime.recoveries[0][1].historyReplay.events.reverse();assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();}
|
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first',{history:false});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'second'}},{history:false});await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map([...engine.getSnapshot().runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,2);image.payload.runtime.recoveries[0][1].historyReplay.events.reverse();assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 内部不可变transition复用冻结范围数组,但调用方validator仍逐条执行并可拒绝当前schema。
|
||||||
test('resource scans reuse validated immutable transition ranges without skipping caller validation',()=>{
|
test('resource scans reuse validated immutable transition ranges without skipping caller validation',()=>{
|
||||||
const editor=createEditor({registry,document});edit(editor,'cached');
|
const editor=createEditor({registry,document});edit(editor,'cached');
|
||||||
const transition=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
|
const transition=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
|
||||||
@@ -108,6 +133,7 @@ test('resource scans reuse validated immutable transition ranges without skippin
|
|||||||
assert.throws(()=>validateHistoryTransition(transition,()=>{throw Error('current schema rejected');}),/current schema rejected/);
|
assert.throws(()=>validateHistoryTransition(transition,()=>{throw Error('current schema rejected');}),/current schema rejected/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 改变可变/浅冻结/getter输入后扫描须读新值,非法偏移须拒绝,不能套用内部证明缓存。
|
||||||
test('mutable, shallow frozen and accessor transition evidence remains live across resource scans',()=>{
|
test('mutable, shallow frozen and accessor transition evidence remains live across resource scans',()=>{
|
||||||
const editor=createEditor({registry,document});edit(editor,'source');
|
const editor=createEditor({registry,document});edit(editor,'source');
|
||||||
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
|
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
|
||||||
@@ -123,6 +149,7 @@ test('mutable, shallow frozen and accessor transition evidence remains live acro
|
|||||||
assert.equal(historyTransitionChanges(accessor)[0].after,'getter changed');
|
assert.equal(historyTransitionChanges(accessor)[0].after,'getter changed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 非枚举隐藏循环和稀疏数组不能递归死循环,也不能取得可复用的范围缓存资格。
|
||||||
test('hidden frozen cycles and sparse transition data do not qualify for cached ranges',()=>{
|
test('hidden frozen cycles and sparse transition data do not qualify for cached ranges',()=>{
|
||||||
const editor=createEditor({registry,document});edit(editor,'source');
|
const editor=createEditor({registry,document});edit(editor,'source');
|
||||||
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
|
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 键盘输入路由的纯逻辑回归。通过上下文标记和简化closest/composedPath对象,测试输入优先级而不创建真实浏览器DOM。
|
||||||
|
* 回调计数用于确认被文本/IME/浮层认领的事件不会继续触发文档快捷键。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {routeCanvasKey,canvasKeyContext} from '../../src/lib/canvas-bindings/input-router.ts';
|
import {routeCanvasKey,canvasKeyContext} from '../../src/lib/canvas-bindings/input-router.ts';
|
||||||
|
// 逐个设置textEditing/composing/overlay/defaultPrevented,Backspace必须在动作层之前退出,gesture与shortcut调用数都为0。
|
||||||
test('text, IME, overlays and already consumed input never execute document shortcuts',()=>{for(const flags of [{textEditing:true},{composing:true},{overlay:true},{defaultPrevented:true}]){let calls=0;const result=routeCanvasKey({key:'Backspace',...flags},{gesture:()=>{calls++;return true;},shortcut:()=>{calls++;return true;}});assert.equal(calls,0);assert.ok(['text','overlay','unclaimed'].includes(result));}});
|
test('text, IME, overlays and already consumed input never execute document shortcuts',()=>{for(const flags of [{textEditing:true},{composing:true},{overlay:true},{defaultPrevented:true}]){let calls=0;const result=routeCanvasKey({key:'Backspace',...flags},{gesture:()=>{calls++;return true;},shortcut:()=>{calls++;return true;}});assert.equal(calls,0);assert.ok(['text','overlay','unclaimed'].includes(result));}});
|
||||||
|
// 依次让gesture/tool/shortcut成为首个赢家,只运行到认领层;无处理器返回unclaimed。
|
||||||
test('gesture then tool then shortcut claim once; declining handlers fall through',()=>{for(const winner of ['gesture','tool','shortcut']){const calls=[];const handlers=Object.fromEntries(['gesture','tool','shortcut'].map(layer=>[layer,()=>{calls.push(layer);return layer===winner;}]));assert.equal(routeCanvasKey({key:'Escape'},handlers),winner);assert.deepEqual(calls,['gesture','tool','shortcut'].slice(0,['gesture','tool','shortcut'].indexOf(winner)+1));}assert.equal(routeCanvasKey({key:'x'},{}),'unclaimed');});
|
test('gesture then tool then shortcut claim once; declining handlers fall through',()=>{for(const winner of ['gesture','tool','shortcut']){const calls=[];const handlers=Object.fromEntries(['gesture','tool','shortcut'].map(layer=>[layer,()=>{calls.push(layer);return layer===winner;}]));assert.equal(routeCanvasKey({key:'Escape'},handlers),winner);assert.deepEqual(calls,['gesture','tool','shortcut'].slice(0,['gesture','tool','shortcut'].indexOf(winner)+1));}assert.equal(routeCanvasKey({key:'x'},{}),'unclaimed');});
|
||||||
|
|
||||||
|
// 用伪closest模拟select和注册浮层,并传空/无效路径项,确认上下文识别不依赖全局Element。
|
||||||
test('DOM input context recognizes registered overlays and native selects without browser globals',()=>{
|
test('DOM input context recognizes registered overlays and native selects without browser globals',()=>{
|
||||||
const select={closest:selector=>selector.split(',').includes('select')?select:null};
|
const select={closest:selector=>selector.split(',').includes('select')?select:null};
|
||||||
const overlay={closest:selector=>selector.includes('[data-canvas-overlay-view]')?overlay:null};
|
const overlay={closest:selector=>selector.includes('[data-canvas-overlay-view]')?overlay:null};
|
||||||
@@ -10,6 +17,7 @@ test('DOM input context recognizes registered overlays and native selects withou
|
|||||||
assert.deepEqual(canvasKeyContext({target:overlay}),{textEditing:false,overlay:true});
|
assert.deepEqual(canvasKeyContext({target:overlay}),{textEditing:false,overlay:true});
|
||||||
assert.deepEqual(canvasKeyContext({target:null,composedPath:()=>[null,{},42]}),{textEditing:false,overlay:false});
|
assert.deepEqual(canvasKeyContext({target:null,composedPath:()=>[null,{},42]}),{textEditing:false,overlay:false});
|
||||||
});
|
});
|
||||||
|
// 事件target已被Shadow DOM重定向到host,composedPath仍含textarea;Escape必须留给内部文本而非取消画布手势。
|
||||||
test('retargeted shadow events retain their inner text owner and never reach a gesture',()=>{
|
test('retargeted shadow events retain their inner text owner and never reach a gesture',()=>{
|
||||||
const input={closest:selector=>selector.split(',').includes('textarea')?input:null},host={closest:()=>null};
|
const input={closest:selector=>selector.split(',').includes('textarea')?input:null},host={closest:()=>null};
|
||||||
const context=canvasKeyContext({target:host,composedPath:()=>[input,host]});let canceled=false;
|
const context=canvasKeyContext({target:host,composedPath:()=>[input,host]});let canceled=false;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* JSON隔离函数的语义与所有权测试。以日期、toJSON、稀疏数组、特殊键及循环等输入核对JSON转换与深冻结。
|
||||||
|
* 所有权证明只认可函数自己产生的结果,不能因调用方freeze或clone就复用内部证明。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {detachJsonData,hasDetachedJsonData} from '../../src/lib/canvas-core/json-data.ts';
|
import {detachJsonData,hasDetachedJsonData} from '../../src/lib/canvas-core/json-data.ts';
|
||||||
|
|
||||||
|
// 修改外部嵌套对象/日期不得影响结果;数组空洞等变null,特殊键保留,伪造冻结副本无证明,循环/BigInt明确抛错。
|
||||||
test('JSON detachment owns only its copied immutable result and preserves JSON semantics',()=>{
|
test('JSON detachment owns only its copied immutable result and preserves JSON semantics',()=>{
|
||||||
let calls=0;const external={nested:{text:'before'},date:new Date('2026-01-01Z'),custom:{toJSON(){calls++;return{value:2};}},array:[,undefined,NaN],['__proto__']:{safe:true}};
|
let calls=0;const external={nested:{text:'before'},date:new Date('2026-01-01Z'),custom:{toJSON(){calls++;return{value:2};}},array:[,undefined,NaN],['__proto__']:{safe:true}};
|
||||||
const value=detachJsonData(external);assert.equal(calls,1);assert.equal(hasDetachedJsonData(value),true);assert.equal(hasDetachedJsonData(external),false);
|
const value=detachJsonData(external);assert.equal(calls,1);assert.equal(hasDetachedJsonData(value),true);assert.equal(hasDetachedJsonData(external),false);
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 网络准入门的微任务级测试。容量设为1,用手动Promise控制排队顺序,验证拿到名额后仍要核对身份与取消。
|
||||||
|
* 这里不发真实HTTP,也不测试服务端幂等或浏览器网络栈。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createNetworkGate} from '../../src/lib/canvas-runtime/network.ts';
|
import {createNetworkGate} from '../../src/lib/canvas-runtime/network.ts';
|
||||||
|
/** 暴露resolve的手动等待点,让首请求占住名额,测试可在第二请求获准前改变身份或abort。 */
|
||||||
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{resolve,promise};};
|
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{resolve,promise};};
|
||||||
|
// 首请求等待时使身份失效,次请求不执行;恢复身份后第三请求能执行,证明拒绝的名额已释放。
|
||||||
test('transport rechecks identity after waiting for capacity and releases a rejected permit',async()=>{
|
test('transport rechecks identity after waiting for capacity and releases a rejected permit',async()=>{
|
||||||
let current=true,calls=0;const gate=createNetworkGate(1,()=>{if(!current)throw Error('identity_changed');}),hold=deferred(),signal=new AbortController().signal,first=gate.run(signal,async()=>{calls++;await hold.promise;}),second=assert.rejects(gate.run(signal,async()=>{calls++;}),/identity_changed/);await Promise.resolve();current=false;hold.resolve();await first;await second;assert.equal(calls,1);current=true;await gate.run(signal,async()=>{calls++;});assert.equal(calls,2);
|
let current=true,calls=0;const gate=createNetworkGate(1,()=>{if(!current)throw Error('identity_changed');}),hold=deferred(),signal=new AbortController().signal,first=gate.run(signal,async()=>{calls++;await hold.promise;}),second=assert.rejects(gate.run(signal,async()=>{calls++;}),/identity_changed/);await Promise.resolve();current=false;hold.resolve();await first;await second;assert.equal(calls,1);current=true;await gate.run(signal,async()=>{calls++;});assert.equal(calls,2);
|
||||||
});
|
});
|
||||||
|
// 排队请求先abort,随后释放首请求并运行第三个;调用数与护栏计数都只能包含真正获准的两个任务。
|
||||||
test('an aborted queued transport cannot pass the effect guard or consume the next capacity grant',async()=>{
|
test('an aborted queued transport cannot pass the effect guard or consume the next capacity grant',async()=>{
|
||||||
let guards=0,calls=0;const gate=createNetworkGate(1,()=>guards++),hold=deferred(),signal=new AbortController().signal,abort=new AbortController(),first=gate.run(signal,async()=>{calls++;await hold.promise;}),second=assert.rejects(gate.run(abort.signal,async()=>{calls++;}),/cancelled/);await Promise.resolve();abort.abort(Error('cancelled'));await second;hold.resolve();await first;await gate.run(signal,async()=>{calls++;});assert.equal(calls,2);assert.equal(guards,2);
|
let guards=0,calls=0;const gate=createNetworkGate(1,()=>guards++),hold=deferred(),signal=new AbortController().signal,abort=new AbortController(),first=gate.run(signal,async()=>{calls++;await hold.promise;}),second=assert.rejects(gate.run(abort.signal,async()=>{calls++;}),/cancelled/);await Promise.resolve();abort.abort(Error('cancelled'));await second;hold.resolve();await first;await gate.run(signal,async()=>{calls++;});assert.equal(calls,2);assert.equal(guards,2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 节点连接点测量的批处理测试。用任务数组代替调度器,验证大量invalidate合并、每挂载owner租约与store隔离。
|
||||||
|
* 10000节点断言是确定性批次数量检查,不是浏览器布局或帧率性能测试。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createNodeInternalsBatch} from '../../src/lib/canvas-bindings/node-internals.ts';
|
import {createNodeInternalsBatch} from '../../src/lib/canvas-bindings/node-internals.ts';
|
||||||
|
// 每个lease重复invalidate,调度仍只有一个任务,执行一次提交完整ID列表;末尾释放所有owner。
|
||||||
test('10000 mounted nodes coalesce repeated measurement requests into one renderer update',()=>{
|
test('10000 mounted nodes coalesce repeated measurement requests into one renderer update',()=>{
|
||||||
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),leases=Array.from({length:10000},(_,i)=>batch.acquire('n'+i));for(const lease of leases){lease.invalidate();lease.invalidate();}assert.equal(tasks.length,1);assert.equal(calls.length,0);tasks.shift()();assert.deepEqual(calls,[Array.from({length:10000},(_,i)=>'n'+i)]);for(const lease of leases)lease.dispose();
|
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),leases=Array.from({length:10000},(_,i)=>batch.acquire('n'+i));for(const lease of leases){lease.invalidate();lease.invalidate();}assert.equal(tasks.length,1);assert.equal(calls.length,0);tasks.shift()();assert.deepEqual(calls,[Array.from({length:10000},(_,i)=>'n'+i)]);for(const lease of leases)lease.dispose();
|
||||||
});
|
});
|
||||||
|
// A有两个owner、B卸载,旧A释放不取消新A的待测量;最后owner释放后迟到任务不能再次调用renderer。
|
||||||
test('unmounted and replaced owners cannot retain or cancel another lease for the same node',()=>{
|
test('unmounted and replaced owners cannot retain or cancel another lease for the same node',()=>{
|
||||||
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),old=batch.acquire('A'),next=batch.acquire('A'),removed=batch.acquire('B');old.invalidate();next.invalidate();removed.invalidate();old.dispose();removed.dispose();old.invalidate();tasks.shift()();assert.deepEqual(calls,[['A']]);next.invalidate();next.dispose();tasks.shift()();assert.equal(calls.length,1);
|
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),old=batch.acquire('A'),next=batch.acquire('A'),removed=batch.acquire('B');old.invalidate();next.invalidate();removed.invalidate();old.dispose();removed.dispose();old.invalidate();tasks.shift()();assert.deepEqual(calls,[['A']]);next.invalidate();next.dispose();tasks.shift()();assert.equal(calls.length,1);
|
||||||
});
|
});
|
||||||
|
// 两个batch可各自更新同名A;one回调重入invalidate只排下一批,不影响two的任务或顺序。
|
||||||
test('renderer stores and later updates remain independent, including reentrant invalidation',()=>{
|
test('renderer stores and later updates remain independent, including reentrant invalidation',()=>{
|
||||||
const tasks=[],calls=[];let again;const one=createNodeInternalsBatch(ids=>{calls.push(['one',ids]);again?.();},fn=>tasks.push(fn)),two=createNodeInternalsBatch(ids=>calls.push(['two',ids]),fn=>tasks.push(fn)),a=one.acquire('A'),b=two.acquire('A');let reentered=false;again=()=>{if(!reentered){reentered=true;a.invalidate();}};a.invalidate();b.invalidate();assert.equal(tasks.length,2);tasks.shift()();assert.equal(tasks.length,2);tasks.shift()();tasks.shift()();assert.deepEqual(calls,[['one',['A']],['two',['A']],['one',['A']]]);a.dispose();b.dispose();
|
const tasks=[],calls=[];let again;const one=createNodeInternalsBatch(ids=>{calls.push(['one',ids]);again?.();},fn=>tasks.push(fn)),two=createNodeInternalsBatch(ids=>calls.push(['two',ids]),fn=>tasks.push(fn)),a=one.acquire('A'),b=two.acquire('A');let reentered=false;again=()=>{if(!reentered){reentered=true;a.invalidate();}};a.invalidate();b.invalidate();assert.equal(tasks.length,2);tasks.shift()();assert.equal(tasks.length,2);tasks.shift()();tasks.shift()();assert.deepEqual(calls,[['one',['A']],['two',['A']],['one',['A']]]);a.dispose();b.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 浮层索引的作用域、可见性与租约测试。纯屏幕矩形代替DOM测量,检查焦点/组合态保留及旧实例清理不会误删替换实例。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {createOverlayIndex} from '../../src/lib/canvas-bindings/overlays.ts';
|
import {test} from 'node:test';import assert from 'node:assert/strict';import {createOverlayIndex} from '../../src/lib/canvas-bindings/overlays.ts';
|
||||||
|
/** 默认title浮层指向a/incarnation,x可移到视口外;viewport固定200×200供可见性比较。 */
|
||||||
const value=(incarnation=1,x=0)=>({placement:'title',target:{id:'a',incarnation},bounds:{x,y:0,width:100,height:30},focused:false,composing:false}),viewport={x:0,y:0,width:200,height:200};
|
const value=(incarnation=1,x=0)=>({placement:'title',target:{id:'a',incarnation},bounds:{x,y:0,width:100,height:30},focused:false,composing:false}),viewport={x:0,y:0,width:200,height:200};
|
||||||
|
// 两个view可用同portal ID而独立;屏幕外普通项隐藏,focused/composing保留,但身份失效仍必须排除。
|
||||||
test('screen placements are scoped by view, reject stale incarnations and preserve focused or composing offscreen editors',()=>{const a=createOverlayIndex('view-a'),b=createOverlayIndex('view-b');a.register('portal',value());b.register('portal',value(2));assert.equal(a.visible(viewport,target=>target.incarnation===1).length,1);assert.equal(b.visible(viewport,target=>target.incarnation===1).length,0);const lease=a.register('portal',value(1,1000));assert.equal(a.visible(viewport,()=>true).length,0);lease.update({...value(1,1000),composing:true});assert.equal(a.visible(viewport,()=>true).length,1);lease.update({...value(1,1000),focused:true});assert.equal(a.visible(viewport,()=>true).length,1);assert.equal(a.visible(viewport,()=>false).length,0);});
|
test('screen placements are scoped by view, reject stale incarnations and preserve focused or composing offscreen editors',()=>{const a=createOverlayIndex('view-a'),b=createOverlayIndex('view-b');a.register('portal',value());b.register('portal',value(2));assert.equal(a.visible(viewport,target=>target.incarnation===1).length,1);assert.equal(b.visible(viewport,target=>target.incarnation===1).length,0);const lease=a.register('portal',value(1,1000));assert.equal(a.visible(viewport,()=>true).length,0);lease.update({...value(1,1000),composing:true});assert.equal(a.visible(viewport,()=>true).length,1);lease.update({...value(1,1000),focused:true});assert.equal(a.visible(viewport,()=>true).length,1);assert.equal(a.visible(viewport,()=>false).length,0);});
|
||||||
|
// 同注册ID替换后旧lease.release/update无权改新项,相同测量也不换快照或通知,只有fresh释放才移除。
|
||||||
test('late old-instance updates or cleanup cannot remove a replacement overlay; unchanged measurements do not notify',()=>{const index=createOverlayIndex('v');let changes=0;const stop=index.subscribe(()=>changes++),old=index.register('same',value()),fresh=index.register('same',value(2));const snapshot=index.getSnapshot(),baseline=changes;old.release();old.update(value());fresh.update(value(2));assert.strictEqual(index.getSnapshot(),snapshot);assert.equal(changes,baseline);fresh.release();assert.equal(index.getSnapshot().length,0);stop();});
|
test('late old-instance updates or cleanup cannot remove a replacement overlay; unchanged measurements do not notify',()=>{const index=createOverlayIndex('v');let changes=0;const stop=index.subscribe(()=>changes++),old=index.register('same',value()),fresh=index.register('same',value(2));const snapshot=index.getSnapshot(),baseline=changes;old.release();old.update(value());fresh.update(value(2));assert.strictEqual(index.getSnapshot(),snapshot);assert.equal(changes,baseline);fresh.release();assert.equal(index.getSnapshot().length,0);stop();});
|
||||||
|
// NaN矩形替换被拒绝,原快照与可见项保留;显式clear才清空。
|
||||||
test('invalid overlay replacement leaves the prior placement and index intact',()=>{const index=createOverlayIndex('v');index.register('x',value());const before=index.getSnapshot();assert.throws(()=>index.register('x',{...value(),bounds:{x:NaN,y:0,width:1,height:1}}));assert.strictEqual(index.getSnapshot(),before);assert.equal(index.visible(viewport,()=>true).length,1);index.clear();assert.equal(index.visible(viewport,()=>true).length,0);});
|
test('invalid overlay replacement leaves the prior placement and index intact',()=>{const index=createOverlayIndex('v');index.register('x',value());const before=index.getSnapshot();assert.throws(()=>index.register('x',{...value(),bounds:{x:NaN,y:0,width:1,height:1}}));assert.strictEqual(index.getSnapshot(),before);assert.equal(index.visible(viewport,()=>true).length,1);index.clear();assert.equal(index.visible(viewport,()=>true).length,0);});
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 文档查询索引的语义与增量更新回归。用父子canvas、跨层连线及声明引用构造关系图,比较增量维护与完整重建结果。
|
||||||
|
* 空间测试使用确定性几何与计数,不依赖浏览器;complete/opaque覆盖信息必须随同文档发布,不能凭“未查到”推断对象不存在。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,createEditor,createDocumentQueries,advanceDocumentQueries,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,createEditor,createDocumentQueries,advanceDocumentQueries,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {removalChanges} from '../../src/lib/canvas-core/structure.ts';
|
import {removalChanges} from '../../src/lib/canvas-core/structure.ts';
|
||||||
|
// 节点/边工厂允许指定owner及几何;跨层in边归父canvas,local边归子canvas。
|
||||||
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text',geometry={})=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:''},geometry:{x:10,y:20,width:100,height:60,rotation:0,...geometry}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,structure:'connection',ownerCanvasId:owner,componentId:'connection',dataVersion:1,source:{elementId:source},target:{elementId:target},data:{},path:null});
|
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text',geometry={})=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:''},geometry:{x:10,y:20,width:100,height:60,rotation:0,...geometry}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,structure:'connection',ownerCanvasId:owner,componentId:'connection',dataVersion:1,source:{elementId:source},target:{elementId:target},data:{},path:null});
|
||||||
|
// P中F作为S入口,F的ports引用A;用来同时覆盖owner、端点与数组反向引用三类索引。
|
||||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',ports:[{id:'p',nodeId:'A'}]}},node('A','S'),node('B','S'),edge('in','C','A'),edge('local','A','B','S')]);
|
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',ports:[{id:'p',nodeId:'A'}]}},node('A','S'),node('B','S'),edge('in','C','A'),edge('local','A','B','S')]);
|
||||||
|
/** Map<id,Set>规范为排序数组,只消除集合遍历次序差异,不改变实际成员。 */
|
||||||
const sets=map=>[...map].map(([id,ids])=>[id,[...ids].sort()]).sort(([a],[b])=>a.localeCompare(b));
|
const sets=map=>[...map].map(([id,ids])=>[id,[...ids].sort()]).sort(([a],[b])=>a.localeCompare(b));
|
||||||
|
/** 全量比较关系表、入口映射、opaque集合、每canvas成员及空间entries/cells/oversized;不只比查询条数。 */
|
||||||
function parity(actual,expected){for(const key of ['ownerMembers','canvasChildren','incidentEdges','elementReferrers','canvasReferrers'])assert.deepEqual(sets(actual.relations[key]),sets(expected.relations[key]),key);assert.deepEqual([...actual.relations.childCanvasByEntry].sort(),[...expected.relations.childCanvasByEntry].sort());assert.deepEqual([...actual.relations.opaqueReferences].sort(),[...expected.relations.opaqueReferences].sort());for(const id of actual.document.canvases.keys()){assert.deepEqual(actual.members(id),expected.members(id));assert.deepEqual(actual.childCanvases(id),expected.childCanvases(id));assert.deepEqual([...actual.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)),[...expected.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)));assert.deepEqual(sets(actual.spatial.get(id)?.cells??new Map()),sets(expected.spatial.get(id)?.cells??new Map()));assert.deepEqual([...(actual.spatial.get(id)?.oversized??[])].sort(),[...(expected.spatial.get(id)?.oversized??[])].sort());}}
|
function parity(actual,expected){for(const key of ['ownerMembers','canvasChildren','incidentEdges','elementReferrers','canvasReferrers'])assert.deepEqual(sets(actual.relations[key]),sets(expected.relations[key]),key);assert.deepEqual([...actual.relations.childCanvasByEntry].sort(),[...expected.relations.childCanvasByEntry].sort());assert.deepEqual([...actual.relations.opaqueReferences].sort(),[...expected.relations.opaqueReferences].sort());for(const id of actual.document.canvases.keys()){assert.deepEqual(actual.members(id),expected.members(id));assert.deepEqual(actual.childCanvases(id),expected.childCanvases(id));assert.deepEqual([...actual.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)),[...expected.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)));assert.deepEqual(sets(actual.spatial.get(id)?.cells??new Map()),sets(expected.spatial.get(id)?.cells??new Map()));assert.deepEqual([...(actual.spatial.get(id)?.oversized??[])].sort(),[...(expected.spatial.get(id)?.oversized??[])].sort());}}
|
||||||
|
|
||||||
|
// 检查父子成员、入边和port引用,集合无写方法;未知组件标记引用不完整,缺canvas与更换registry明确拒绝。
|
||||||
test('query snapshots index ownership, incoming edges and declared array references without mutable maps',()=>{
|
test('query snapshots index ownership, incoming edges and declared array references without mutable maps',()=>{
|
||||||
const q=createDocumentQueries(document,registry);assert.deepEqual(q.members('P'),['C','F','in']);assert.deepEqual(q.members('S'),['A','B','local']);assert.deepEqual(q.incident('A'),['in','local']);assert.equal(q.relations.childCanvasByEntry.get('F'),'S');assert.deepEqual([...q.relations.elementReferrers.get('A')],['F']);assert.equal(q.relations.referenceComplete,true);assert.equal(q.relations.ownerMembers.set,undefined);assert.equal(q.relations.ownerMembers.get('S').add,undefined);assert.equal(q.spatial.get('S').cells.set,undefined);assert.ok(Object.isFrozen(q.spatial.get('S').entries.get('A')));assert.deepEqual(removalChanges(document,{id:'F',incarnation:1},q),removalChanges(document,{id:'F',incarnation:1}));
|
const q=createDocumentQueries(document,registry);assert.deepEqual(q.members('P'),['C','F','in']);assert.deepEqual(q.members('S'),['A','B','local']);assert.deepEqual(q.incident('A'),['in','local']);assert.equal(q.relations.childCanvasByEntry.get('F'),'S');assert.deepEqual([...q.relations.elementReferrers.get('A')],['F']);assert.equal(q.relations.referenceComplete,true);assert.equal(q.relations.ownerMembers.set,undefined);assert.equal(q.relations.ownerMembers.get('S').add,undefined);assert.equal(q.spatial.get('S').cells.set,undefined);assert.ok(Object.isFrozen(q.spatial.get('S').entries.get('A')));assert.deepEqual(removalChanges(document,{id:'F',incarnation:1},q),removalChanges(document,{id:'F',incarnation:1}));
|
||||||
const opaque=createDocument('project',[...document.canvases.values()],[...document.elements.values(),node('unknown','P','future')]),partial=createDocumentQueries(opaque,registry);assert.equal(partial.relations.referenceComplete,false);assert.deepEqual([...partial.relations.opaqueReferences],['unknown']);assert.throws(()=>q.members('missing'),/canvas_not_loaded/);assert.throws(()=>advanceDocumentQueries(q,document,createRegistry(builtinComponents()),0),/query_registry_changed/);
|
const opaque=createDocument('project',[...document.canvases.values()],[...document.elements.values(),node('unknown','P','future')]),partial=createDocumentQueries(opaque,registry);assert.equal(partial.relations.referenceComplete,false);assert.deepEqual([...partial.relations.opaqueReferences],['unknown']);assert.throws(()=>q.members('missing'),/canvas_not_loaded/);assert.throws(()=>advanceDocumentQueries(q,document,createRegistry(builtinComponents()),0),/query_registry_changed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 标题/运行元数据变化复用空间表;runtime失败时根和索引一起不发布,合法几何只更新对应owner表,undo再与重建比较。
|
||||||
test('title and metadata commits reuse spatial tables; listeners observe matching document/index revisions and rejected commits keep both',()=>{
|
test('title and metadata commits reuse spatial tables; listeners observe matching document/index revisions and rejected commits keep both',()=>{
|
||||||
let fail=false,next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:{tag:0},reduceRuntime:previous=>{if(fail)throw Error('runtime failure');return previous;}}),initial=editor.getQueries(),members=initial.members('S');editor.subscribe((_,snapshot)=>{const q=editor.getQueries();assert.equal(q.document,snapshot.document);assert.equal(q.revision,snapshot.revision);});
|
let fail=false,next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:{tag:0},reduceRuntime:previous=>{if(fail)throw Error('runtime failure');return previous;}}),initial=editor.getQueries(),members=initial.members('S');editor.subscribe((_,snapshot)=>{const q=editor.getQueries();assert.equal(q.document,snapshot.document);assert.equal(q.revision,snapshot.revision);});
|
||||||
assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'renamed'}}).status,'applied');const titled=editor.getQueries();assert.equal(titled.spatial,initial.spatial);assert.equal(titled.relations.ownerMembers,initial.relations.ownerMembers);assert.equal(titled.members('S'),members);assert.equal(editor.getQueries(initial.document),initial);assert.equal(editor.updateRuntime(()=>({tag:1})).status,'applied');assert.equal(editor.getQueries(),titled);fail=true;const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'rejected');assert.equal(editor.getSnapshot(),before);assert.equal(editor.getQueries(),titled);fail=false;
|
assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'renamed'}}).status,'applied');const titled=editor.getQueries();assert.equal(titled.spatial,initial.spatial);assert.equal(titled.relations.ownerMembers,initial.relations.ownerMembers);assert.equal(titled.members('S'),members);assert.equal(editor.getQueries(initial.document),initial);assert.equal(editor.updateRuntime(()=>({tag:1})).status,'applied');assert.equal(editor.getQueries(),titled);fail=true;const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'rejected');assert.equal(editor.getSnapshot(),before);assert.equal(editor.getQueries(),titled);fail=false;
|
||||||
assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'applied');assert.equal(editor.getQueries().spatial.get('P'),initial.spatial.get('P'));assert.notEqual(editor.getQueries().spatial.get('S'),initial.spatial.get('S'));assert.deepEqual(initial.candidateNodes('S',{x:0,y:0,width:150,height:150}),['A','B']);assert.deepEqual(editor.getQueries().candidateNodes('S',{x:0,y:0,width:150,height:150}),['B']);assert.equal(editor.history.undo().status,'applied');parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision));
|
assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'applied');assert.equal(editor.getQueries().spatial.get('P'),initial.spatial.get('P'));assert.notEqual(editor.getQueries().spatial.get('S'),initial.spatial.get('S'));assert.deepEqual(initial.candidateNodes('S',{x:0,y:0,width:150,height:150}),['A','B']);assert.deepEqual(editor.getQueries().candidateNodes('S',{x:0,y:0,width:150,height:150}),['B']);assert.equal(editor.history.undo().status,'applied');parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 40轮几何/port编辑穿插跨owner移动,每步重建对照;删除恢复、ACK顺序重排也保持等价,同内容再协调复用查询对象。
|
||||||
test('incremental queries match rebuilt indexes through transactions, cross-owner repair, deletion/restore and canonical reordering',()=>{
|
test('incremental queries match rebuilt indexes through transactions, cross-owner repair, deletion/restore and canonical reordering',()=>{
|
||||||
let next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:0}),check=()=>parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision));
|
let next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:0}),check=()=>parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision));
|
||||||
for(let i=0;i<40;i++){const target=editor.getSnapshot().document.elements.get(i%2?'A':'B');assert.equal(editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:i*27,rotation:(i*11)%360}});tx.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{ports:[{id:'p',nodeId:target.id}]}});}).status,'applied');check();if(i%5===0){assert.equal(editor.dispatch({type:'element.move',target,canvasId:target.ownerCanvasId==='S'?'P':'S'}).status,'applied');check();}}
|
for(let i=0;i<40;i++){const target=editor.getSnapshot().document.elements.get(i%2?'A':'B');assert.equal(editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:i*27,rotation:(i*11)%360}});tx.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{ports:[{id:'p',nodeId:target.id}]}});}).status,'applied');check();if(i%5===0){assert.equal(editor.dispatch({type:'element.move',target,canvasId:target.ownerCanvasId==='S'?'P':'S'}).status,'applied');check();}}
|
||||||
@@ -25,21 +36,25 @@ test('incremental queries match rebuilt indexes through transactions, cross-owne
|
|||||||
const root=editor.getSnapshot(),canonical=createDocument(root.document.projectId,[...root.document.canvases.values()].reverse(),[...root.document.elements.values()].reverse(),root.document.tombstones);assert.equal(editor.reconcile(canonical,1,root.rootRevision).status,'applied');check();const after=editor.getQueries(),same=createDocument(canonical.projectId,[...canonical.canvases.values()],[...canonical.elements.values()],canonical.tombstones);assert.equal(editor.reconcile(same,2,editor.getSnapshot().rootRevision).status,'applied');assert.equal(editor.getQueries(),after);
|
const root=editor.getSnapshot(),canonical=createDocument(root.document.projectId,[...root.document.canvases.values()].reverse(),[...root.document.elements.values()].reverse(),root.document.tombstones);assert.equal(editor.reconcile(canonical,1,root.rootRevision).status,'applied');check();const after=editor.getQueries(),same=createDocument(canonical.projectId,[...canonical.canvases.values()],[...canonical.elements.values()],canonical.tombstones);assert.equal(editor.reconcile(same,2,editor.getSnapshot().rootRevision).status,'applied');assert.equal(editor.getQueries(),after);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 巨大或溢出几何走oversized保守路径;旋转命中检查内外点与角点,非法查询范围拒绝,不把不完整结果说成完整。
|
||||||
test('owner-local grid conservatively selects rotated/huge nodes and precisely hits corners with finite query budgets',()=>{
|
test('owner-local grid conservatively selects rotated/huge nodes and precisely hits corners with finite query budgets',()=>{
|
||||||
const source=createDocument('project',[{id:'P',parent:null}],[node('rotated','P','text',{x:0,y:0,width:100,height:10,rotation:45}),node('huge','P','text',{x:-1000000,y:-1000000,width:2000000,height:2000000}),node('overflow','P','text',{x:Number.MAX_VALUE,y:0,width:Number.MAX_VALUE,height:100})]),q=createDocumentQueries(source,registry);assert.ok(q.spatial.get('P').oversized.has('huge'));assert.ok(q.spatial.get('P').oversized.has('overflow'));assert.deepEqual(q.hitNodeBounds('P',{x:50,y:5}).ids,['rotated','huge']);assert.equal(q.hitNodeBounds('P',{x:50,y:5}).complete,false);assert.ok(!q.hitNodeBounds('P',{x:10,y:40}).ids.includes('rotated'));assert.ok(q.candidateNodes('P',{x:-1e12,y:-1e12,width:2e12,height:2e12}).includes('rotated'));assert.throws(()=>q.candidateNodes('P',{x:0,y:0,width:-1,height:10}),/invalid_query_bounds/);
|
const source=createDocument('project',[{id:'P',parent:null}],[node('rotated','P','text',{x:0,y:0,width:100,height:10,rotation:45}),node('huge','P','text',{x:-1000000,y:-1000000,width:2000000,height:2000000}),node('overflow','P','text',{x:Number.MAX_VALUE,y:0,width:Number.MAX_VALUE,height:100})]),q=createDocumentQueries(source,registry);assert.ok(q.spatial.get('P').oversized.has('huge'));assert.ok(q.spatial.get('P').oversized.has('overflow'));assert.deepEqual(q.hitNodeBounds('P',{x:50,y:5}).ids,['rotated','huge']);assert.equal(q.hitNodeBounds('P',{x:50,y:5}).complete,false);assert.ok(!q.hitNodeBounds('P',{x:10,y:40}).ids.includes('rotated'));assert.ok(q.candidateNodes('P',{x:-1e12,y:-1e12,width:2e12,height:2e12}).includes('rotated'));assert.throws(()=>q.candidateNodes('P',{x:0,y:0,width:-1,height:10}),/invalid_query_bounds/);
|
||||||
const g=source.elements.get('rotated').geometry,angle=Math.PI/4,x=g.x+g.width/2+(g.width/2*Math.cos(angle)-g.height/2*Math.sin(angle)),y=g.y+g.height/2+(g.width/2*Math.sin(angle)+g.height/2*Math.cos(angle));assert.ok(q.hitNodeBounds('P',{x,y}).ids.includes('rotated'));
|
const g=source.elements.get('rotated').geometry,angle=Math.PI/4,x=g.x+g.width/2+(g.width/2*Math.cos(angle)-g.height/2*Math.sin(angle)),y=g.y+g.height/2+(g.width/2*Math.sin(angle)+g.height/2*Math.cos(angle));assert.ok(q.hitNodeBounds('P',{x,y}).ids.includes('rotated'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 生成100个不同角度/位置矩形,用正向旋转生成内外点;外点默认不命中,6单位容差可覆盖5单位边外位置。
|
||||||
test('rotated rectangles are found across grid cells with forward-generated inside/outside points',()=>{
|
test('rotated rectangles are found across grid cells with forward-generated inside/outside points',()=>{
|
||||||
const nodes=Array.from({length:100},(_,i)=>node(`n-${i}`,'P','text',{x:i*1000,y:(i%5)*700,width:50+i,height:20+i%9,rotation:(i*37)%360})),doc=createDocument('project',[{id:'P',parent:null}],nodes),q=createDocumentQueries(doc,registry);
|
const nodes=Array.from({length:100},(_,i)=>node(`n-${i}`,'P','text',{x:i*1000,y:(i%5)*700,width:50+i,height:20+i%9,rotation:(i*37)%360})),doc=createDocument('project',[{id:'P',parent:null}],nodes),q=createDocumentQueries(doc,registry);
|
||||||
for(const record of nodes){const g=record.geometry,angle=g.rotation*Math.PI/180,world=(x,y)=>({x:g.x+g.width/2+x*Math.cos(angle)-y*Math.sin(angle),y:g.y+g.height/2+x*Math.sin(angle)+y*Math.cos(angle)}),inside=world(g.width*.4,g.height*.4),outside=world(g.width/2+5,0);assert.ok(q.hitNodeBounds('P',inside).ids.includes(record.id));assert.ok(!q.hitNodeBounds('P',outside).ids.includes(record.id));assert.ok(q.hitNodeBounds('P',outside,6).ids.includes(record.id));}
|
for(const record of nodes){const g=record.geometry,angle=g.rotation*Math.PI/180,world=(x,y)=>({x:g.x+g.width/2+x*Math.cos(angle)-y*Math.sin(angle),y:g.y+g.height/2+x*Math.sin(angle)+y*Math.cos(angle)}),inside=world(g.width*.4,g.height*.4),outside=world(g.width/2+5,0);assert.ok(q.hitNodeBounds('P',inside).ids.includes(record.id));assert.ok(!q.hitNodeBounds('P',outside).ids.includes(record.id));assert.ok(q.hitNodeBounds('P',outside,6).ids.includes(record.id));}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 自定义数组引用去重;改canvas引用移除旧反向关系且复用无关表,声明值变非法后清该主体引用并标opaque。
|
||||||
test('reverse element/canvas references remove obsolete subjects and mark malformed or unsupported declarations incomplete',()=>{
|
test('reverse element/canvas references remove obsolete subjects and mark malformed or unsupported declarations incomplete',()=>{
|
||||||
const custom=createRegistry([...builtinComponents(),{id:'refs',dataVersion:1,structure:'node',validate:()=>true,features:[],copy:{references:[{path:['targets'],many:true,target:'element',external:'preserve'},{path:['scopes'],many:true,target:'canvas',external:'preserve'}],omit:[]}}]),r={...node('R','P','refs'),data:{targets:['A','A'],scopes:['S']}},r2={...node('R2','P','refs'),data:{targets:['B'],scopes:['S']}},doc=createDocument('project',[...document.canvases.values()],[...document.elements.values(),r,r2]),q=createDocumentQueries(doc,custom);
|
const custom=createRegistry([...builtinComponents(),{id:'refs',dataVersion:1,structure:'node',validate:()=>true,features:[],copy:{references:[{path:['targets'],many:true,target:'element',external:'preserve'},{path:['scopes'],many:true,target:'canvas',external:'preserve'}],omit:[]}}]),r={...node('R','P','refs'),data:{targets:['A','A'],scopes:['S']}},r2={...node('R2','P','refs'),data:{targets:['B'],scopes:['S']}},doc=createDocument('project',[...document.canvases.values()],[...document.elements.values(),r,r2]),q=createDocumentQueries(doc,custom);
|
||||||
assert.deepEqual([...q.relations.canvasReferrers.get('S')],['R','R2']);assert.deepEqual([...q.relations.elementReferrers.get('A')],['F','R']);const change={kind:'field',target:{id:'R',incarnation:1},field:'data.scopes',before:['S'],after:['P']},next=applyChanges(doc,[change]),updated=advanceDocumentQueries(q,next,custom,1,[change]);assert.deepEqual([...updated.relations.canvasReferrers.get('S')],['R2']);assert.deepEqual([...updated.relations.canvasReferrers.get('P')],['R']);assert.equal(updated.relations.elementReferrers,q.relations.elementReferrers);const invalid={kind:'field',target:{id:'R',incarnation:1},field:'data.targets',before:['A','A'],after:'invalid'},bad=advanceDocumentQueries(updated,applyChanges(next,[invalid]),custom,2,[invalid]);assert.equal(bad.relations.referenceComplete,false);assert.deepEqual([...bad.relations.opaqueReferences],['R']);assert.equal(bad.relations.canvasReferrers.has('P'),false);assert.deepEqual([...bad.relations.elementReferrers.get('A')],['F']);
|
assert.deepEqual([...q.relations.canvasReferrers.get('S')],['R','R2']);assert.deepEqual([...q.relations.elementReferrers.get('A')],['F','R']);const change={kind:'field',target:{id:'R',incarnation:1},field:'data.scopes',before:['S'],after:['P']},next=applyChanges(doc,[change]),updated=advanceDocumentQueries(q,next,custom,1,[change]);assert.deepEqual([...updated.relations.canvasReferrers.get('S')],['R2']);assert.deepEqual([...updated.relations.canvasReferrers.get('P')],['R']);assert.equal(updated.relations.elementReferrers,q.relations.elementReferrers);const invalid={kind:'field',target:{id:'R',incarnation:1},field:'data.targets',before:['A','A'],after:'invalid'},bad=advanceDocumentQueries(updated,applyChanges(next,[invalid]),custom,2,[invalid]);assert.equal(bad.relations.referenceComplete,false);assert.deepEqual([...bad.relations.opaqueReferences],['R']);assert.equal(bad.relations.canvasReferrers.has('P'),false);assert.deepEqual([...bad.relations.elementReferrers.get('A')],['F']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// runtime切完整覆盖时unknown由unloaded变absent,旧查询不变;foreign完整canvas声明被拒绝且根/查询不发布。
|
||||||
test('coverage changes share the metadata publication boundary and invalid coverage cannot partially publish a root',()=>{
|
test('coverage changes share the metadata publication boundary and invalid coverage cannot partially publish a root',()=>{
|
||||||
const editor=createEditor({registry,document,runtime:{complete:false},queryCoverage:snapshot=>({projectComplete:snapshot.runtime.complete,topologyComplete:true,completeCanvasIds:snapshot.runtime.complete?['P','S']:[]})});
|
const editor=createEditor({registry,document,runtime:{complete:false},queryCoverage:snapshot=>({projectComplete:snapshot.runtime.complete,topologyComplete:true,completeCanvasIds:snapshot.runtime.complete?['P','S']:[]})});
|
||||||
const before=editor.getSnapshot(),queries=editor.getQueries();assert.equal(queries.lookupElement('unknown').status,'unloaded');assert.equal(queries.membersResult('P').complete,false);let observed;
|
const before=editor.getSnapshot(),queries=editor.getQueries();assert.equal(queries.lookupElement('unknown').status,'unloaded');assert.equal(queries.membersResult('P').complete,false);let observed;
|
||||||
|
|||||||
@@ -1,50 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 运行事实按资源/操作/作用域计数订阅的测试。用Tracked表记录get/values工作量,对照朴素过滤定义验证索引语义。
|
||||||
|
* 只读document更新不该重扫ledger/resource;请求结果、资源绑定与错误恢复则必须准确通知相关通道。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createRuntimeSources} from '../../src/lib/canvas-bindings/runtime-sources.ts';
|
import {createRuntimeSources} from '../../src/lib/canvas-bindings/runtime-sources.ts';
|
||||||
|
/** Map探针:get记录点查并可注入异常,values记录全表扫描,reset仅清计数;数据仍按普通Map保存。 */
|
||||||
class Tracked extends Map{gets=0;scans=0;broken=false;get(id){this.gets++;if(this.broken)throw Error('table unavailable');return super.get(id);}values(){this.scans++;return super.values();}reset(){this.gets=0;this.scans=0;}}
|
class Tracked extends Map{gets=0;scans=0;broken=false;get(id){this.gets++;if(this.broken)throw Error('table unavailable');return super.get(id);}values(){this.scans++;return super.values();}reset(){this.gets=0;this.scans=0;}}
|
||||||
|
/** 最小操作事实工厂,canvasIds/phase/blockers由用例指定,以覆盖多画布、仅项目及摘要等待情况。 */
|
||||||
const operation=(id,canvasIds=['a'],phase='pending',blockers=[])=>({intent:{id,canvasIds},phase,blockers});
|
const operation=(id,canvasIds=['a'],phase='pending',blockers=[])=>({intent:{id,canvasIds},phase,blockers});
|
||||||
|
/** 构造可替换root及带计数的ledger/resources/assetBindings;broken制造上游订阅失败,emit不额外合成变化清单。 */
|
||||||
function fixture(count=3){let root={document:{},runtime:{ledger:{operations:new Tracked(Array.from({length:count},(_,index)=>[String(index),operation(String(index))])),requests:new Tracked()},resources:new Tracked(Array.from({length:count},(_,index)=>[String(index),{id:String(index),name:'image',operationId:'upload-'+index}])),assetBindings:new Tracked()}},broken=false;const listeners=new Set();const source={getSnapshot:()=>root,subscribe(fn){if(broken)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);}};return{source,get:()=>root,get size(){return listeners.size;},broken(value){broken=value;},emit(next){root=next;for(const fn of [...listeners])fn();}};}
|
function fixture(count=3){let root={document:{},runtime:{ledger:{operations:new Tracked(Array.from({length:count},(_,index)=>[String(index),operation(String(index))])),requests:new Tracked()},resources:new Tracked(Array.from({length:count},(_,index)=>[String(index),{id:String(index),name:'image',operationId:'upload-'+index}])),assetBindings:new Tracked()}},broken=false;const listeners=new Set();const source={getSnapshot:()=>root,subscribe(fn){if(broken)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);}};return{source,get:()=>root,get size(){return listeners.size;},broken(value){broken=value;},emit(next){root=next;for(const fn of [...listeners])fn();}};}
|
||||||
|
|
||||||
|
// 2000通道共用上游,document-only不查三张表;改资源300仅通知asset300,不读取operations/requests。
|
||||||
test('2000 asset/operation subscribers share one listener and unrelated document cuts read no tables',()=>{
|
test('2000 asset/operation subscribers share one listener and unrelated document cuts read no tables',()=>{
|
||||||
const f=fixture(1000),sources=createRuntimeSources(f.source),seen=[],stops=[];for(let index=0;index<1000;index++){const id=String(index);stops.push(sources.asset(id).subscribe(()=>seen.push('asset'+id)),sources.operation(id).subscribe(()=>seen.push('op'+id)));}assert.equal(f.size,1);const {resources,ledger}=f.get().runtime;resources.reset();ledger.operations.reset();ledger.requests.reset();f.emit({...f.get(),document:{edited:true}});assert.equal(resources.gets+ledger.operations.gets+ledger.requests.gets,0);assert.deepEqual(seen,[]);
|
const f=fixture(1000),sources=createRuntimeSources(f.source),seen=[],stops=[];for(let index=0;index<1000;index++){const id=String(index);stops.push(sources.asset(id).subscribe(()=>seen.push('asset'+id)),sources.operation(id).subscribe(()=>seen.push('op'+id)));}assert.equal(f.size,1);const {resources,ledger}=f.get().runtime;resources.reset();ledger.operations.reset();ledger.requests.reset();f.emit({...f.get(),document:{edited:true}});assert.equal(resources.gets+ledger.operations.gets+ledger.requests.gets,0);assert.deepEqual(seen,[]);
|
||||||
const next=new Tracked(resources);next.set('300',{...resources.get('300'),name:'new image'});f.emit({...f.get(),runtime:{...f.get().runtime,resources:next}});assert.deepEqual(seen,['asset300']);assert.equal(ledger.operations.gets+ledger.requests.gets,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
const next=new Tracked(resources);next.set('300',{...resources.get('300'),name:'new image'});f.emit({...f.get(),runtime:{...f.get().runtime,resources:next}});assert.deepEqual(seen,['asset300']);assert.equal(ledger.operations.gets+ledger.requests.gets,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 只改关联request结果/错误也应通知operation0,未关联operation1保持原对象引用。
|
||||||
test('request-only updates notify the linked operation while unrelated operation views stay stable',()=>{
|
test('request-only updates notify the linked operation while unrelated operation views stay stable',()=>{
|
||||||
const f=fixture(),ops=new Tracked(f.get().runtime.ledger.operations),requests=new Tracked([['request',{id:'request',phase:'waiting',outcome:'accepted'}]]);ops.set('0',{...ops.get('0'),requestId:'request'});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests}}});const sources=createRuntimeSources(f.source),a=sources.operation('0'),b=sources.operation('1'),seen=[],before=b.getSnapshot(),stops=[a.subscribe(()=>seen.push('0')),b.subscribe(()=>seen.push('1'))];const changed=new Tracked(requests);changed.set('request',{id:'request',phase:'waiting',outcome:'unresolved',error:'transport'});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests:changed}}});assert.deepEqual(seen,['0']);assert.equal(a.getSnapshot().request.error,'transport');assert.strictEqual(b.getSnapshot(),before);for(const stop of stops)stop();
|
const f=fixture(),ops=new Tracked(f.get().runtime.ledger.operations),requests=new Tracked([['request',{id:'request',phase:'waiting',outcome:'accepted'}]]);ops.set('0',{...ops.get('0'),requestId:'request'});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests}}});const sources=createRuntimeSources(f.source),a=sources.operation('0'),b=sources.operation('1'),seen=[],before=b.getSnapshot(),stops=[a.subscribe(()=>seen.push('0')),b.subscribe(()=>seen.push('1'))];const changed=new Tracked(requests);changed.set('request',{id:'request',phase:'waiting',outcome:'unresolved',error:'transport'});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests:changed}}});assert.deepEqual(seen,['0']);assert.equal(a.getSnapshot().request.error,'transport');assert.strictEqual(b.getSnapshot(),before);for(const stop of stops)stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 较早监听先调用getSnapshot不能吞掉后续通知;新增shared绑定后订阅者仍收到一次更新。
|
||||||
test('early snapshot reads cannot consume a binding notification before other observers receive it',()=>{
|
test('early snapshot reads cannot consume a binding notification before other observers receive it',()=>{
|
||||||
const f=fixture();let channel;const earlier=f.source.subscribe(()=>channel?.getSnapshot()),sources=createRuntimeSources(f.source);channel=sources.asset('shared');let seen=0;const stop=channel.subscribe(()=>seen++),binding={authority:'scope',digest:'a'.repeat(64),asset:{id:'shared'}};f.emit({...f.get(),runtime:{...f.get().runtime,assetBindings:new Tracked([['shared',binding]])}});assert.equal(seen,1);assert.equal(channel.getSnapshot().binding.digest,binding.digest);stop();earlier();assert.equal(f.size,0);
|
const f=fixture();let channel;const earlier=f.source.subscribe(()=>channel?.getSnapshot()),sources=createRuntimeSources(f.source);channel=sources.asset('shared');let seen=0;const stop=channel.subscribe(()=>seen++),binding={authority:'scope',digest:'a'.repeat(64),asset:{id:'shared'}};f.emit({...f.get(),runtime:{...f.get().runtime,assetBindings:new Tracked([['shared',binding]])}});assert.equal(seen,1);assert.equal(channel.getSnapshot().binding.digest,binding.digest);stop();earlier();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 多canvas请求按作用域只数一次,waiting_digest不当普通blocked;1000个空scope只需一次表扫描,loading改变正确重算。
|
||||||
test('sync counts index shared multi-canvas requests once and handles project-only operations',()=>{
|
test('sync counts index shared multi-canvas requests once and handles project-only operations',()=>{
|
||||||
const f=fixture(0),ops=new Tracked([['one',operation('one',['a','b','a'])],['two',operation('two',['b'],'blocked',['permission'])],['digest',operation('digest',['a'],'blocked',['waiting_digest'])],['done',operation('done',['a'],'resolved')],['project',operation('project',[])]]),requests=new Tracked([['r1',{phase:'waiting',outcome:'unresolved',error:'transport',operationIds:['one','two']}],['r2',{phase:'waiting',outcome:'unresolved',error:'old',operationIds:['done']}],['r3',{phase:'waiting',outcome:'unresolved',operationIds:['project']}],['r4',{phase:'waiting',outcome:'unresolved',error:'retry',retryAt:100,operationIds:['digest']}]]);f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests},loading:{version:6}}});const sources=createRuntimeSources(f.source);
|
const f=fixture(0),ops=new Tracked([['one',operation('one',['a','b','a'])],['two',operation('two',['b'],'blocked',['permission'])],['digest',operation('digest',['a'],'blocked',['waiting_digest'])],['done',operation('done',['a'],'resolved')],['project',operation('project',[])]]),requests=new Tracked([['r1',{phase:'waiting',outcome:'unresolved',error:'transport',operationIds:['one','two']}],['r2',{phase:'waiting',outcome:'unresolved',error:'old',operationIds:['done']}],['r3',{phase:'waiting',outcome:'unresolved',operationIds:['project']}],['r4',{phase:'waiting',outcome:'unresolved',error:'retry',retryAt:100,operationIds:['digest']}]]);f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests},loading:{version:6}}});const sources=createRuntimeSources(f.source);
|
||||||
assert.deepEqual(sources.counts().getSnapshot(),{pending:4,blocked:2,unknown:3,loading:true});assert.deepEqual(sources.counts('a').getSnapshot(),{pending:2,blocked:1,unknown:2,loading:true});assert.deepEqual(sources.counts('b').getSnapshot(),{pending:2,blocked:2,unknown:1,loading:true});for(let index=0;index<1000;index++)assert.deepEqual(sources.counts('missing-'+index).getSnapshot(),{pending:0,blocked:0,unknown:0,loading:true});assert.equal(ops.scans,1);assert.equal(requests.scans,1);
|
assert.deepEqual(sources.counts().getSnapshot(),{pending:4,blocked:2,unknown:3,loading:true});assert.deepEqual(sources.counts('a').getSnapshot(),{pending:2,blocked:1,unknown:2,loading:true});assert.deepEqual(sources.counts('b').getSnapshot(),{pending:2,blocked:2,unknown:1,loading:true});for(let index=0;index<1000;index++)assert.deepEqual(sources.counts('missing-'+index).getSnapshot(),{pending:0,blocked:0,unknown:0,loading:true});assert.equal(ops.scans,1);assert.equal(requests.scans,1);
|
||||||
const channel=sources.counts('a'),stop=channel.subscribe(()=>{});ops.reset();requests.reset();f.emit({...f.get(),document:{unrelated:true}});channel.getSnapshot();assert.equal(ops.scans+requests.scans,0);f.emit({...f.get(),runtime:{...f.get().runtime,loading:undefined}});assert.equal(channel.getSnapshot().loading,false);assert.equal(ops.scans,1);assert.equal(requests.scans,1);stop();
|
const channel=sources.counts('a'),stop=channel.subscribe(()=>{});ops.reset();requests.reset();f.emit({...f.get(),document:{unrelated:true}});channel.getSnapshot();assert.equal(ops.scans+requests.scans,0);f.emit({...f.get(),runtime:{...f.get().runtime,loading:undefined}});assert.equal(channel.getSnapshot().loading,false);assert.equal(ops.scans,1);assert.equal(requests.scans,1);stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 资源深相等克隆不通知且复用视图;退订后资源删除,直接读取仍能见record缺失。
|
||||||
test('cloned equal tables preserve view identity and unsubscribed channels still read current facts',()=>{
|
test('cloned equal tables preserve view identity and unsubscribed channels still read current facts',()=>{
|
||||||
const f=fixture(),sources=createRuntimeSources(f.source),channel=sources.asset('0'),before=channel.getSnapshot();let calls=0;const stop=channel.subscribe(()=>calls++);f.emit({...f.get(),runtime:{...f.get().runtime,resources:new Tracked([...f.get().runtime.resources].map(([id,row])=>[id,structuredClone(row)]))}});assert.equal(calls,0);assert.strictEqual(channel.getSnapshot(),before);stop();const resources=new Tracked(f.get().runtime.resources);resources.delete('0');f.emit({...f.get(),runtime:{...f.get().runtime,resources}});assert.equal(channel.getSnapshot().record,undefined);assert.equal(f.size,0);
|
const f=fixture(),sources=createRuntimeSources(f.source),channel=sources.asset('0'),before=channel.getSnapshot();let calls=0;const stop=channel.subscribe(()=>calls++);f.emit({...f.get(),runtime:{...f.get().runtime,resources:new Tracked([...f.get().runtime.resources].map(([id,row])=>[id,structuredClone(row)]))}});assert.equal(calls,0);assert.strictEqual(channel.getSnapshot(),before);stop();const resources=new Tracked(f.get().runtime.resources);resources.delete('0');f.emit({...f.get(),runtime:{...f.get().runtime,resources}});assert.equal(channel.getSnapshot().record,undefined);assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 表get抛错后读者收到异常,解除broken且无关document发布也应重试并通知恢复;stop幂等,上游最终清零。
|
||||||
test('lookup failures retry without poisoning cached facts, and failed subscription retains no live group',()=>{
|
test('lookup failures retry without poisoning cached facts, and failed subscription retains no live group',()=>{
|
||||||
const f=fixture(),sources=createRuntimeSources(f.source),channel=sources.asset('0');f.broken(true);assert.throws(()=>channel.subscribe(()=>{}),/subscribe failed/);f.broken(false);let calls=0;const stop=channel.subscribe(()=>calls++),before=channel.getSnapshot(),resources=new Tracked(f.get().runtime.resources);resources.broken=true;f.emit({...f.get(),runtime:{...f.get().runtime,resources}});assert.equal(calls,1);assert.throws(channel.getSnapshot,/table unavailable/);resources.broken=false;f.emit({...f.get(),document:{metadata:true}});assert.equal(calls,2);assert.strictEqual(channel.getSnapshot(),before);stop();stop();assert.equal(f.size,0);
|
const f=fixture(),sources=createRuntimeSources(f.source),channel=sources.asset('0');f.broken(true);assert.throws(()=>channel.subscribe(()=>{}),/subscribe failed/);f.broken(false);let calls=0;const stop=channel.subscribe(()=>calls++),before=channel.getSnapshot(),resources=new Tracked(f.get().runtime.resources);resources.broken=true;f.emit({...f.get(),runtime:{...f.get().runtime,resources}});assert.equal(calls,1);assert.throws(channel.getSnapshot,/table unavailable/);resources.broken=false;f.emit({...f.get(),document:{metadata:true}});assert.equal(calls,2);assert.strictEqual(channel.getSnapshot(),before);stop();stop();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 资源观察者抛错不饿死操作观察者;更早回调先清计数通道时迟到上游回调也不调用已退订观察者。
|
||||||
test('observer failures do not starve another collection and late captured callbacks after cleanup are safe',()=>{
|
test('observer failures do not starve another collection and late captured callbacks after cleanup are safe',()=>{
|
||||||
const f=fixture(),sources=createRuntimeSources(f.source);let other=0;const a=sources.asset('0').subscribe(()=>{throw Error('observer failed');}),b=sources.operation('0').subscribe(()=>other++),resources=new Tracked(f.get().runtime.resources),ops=new Tracked(f.get().runtime.ledger.operations);resources.set('0',{id:'0',operationId:'upload',name:'changed'});ops.set('0',operation('0',['a'],'blocked',['permission']));assert.throws(()=>f.emit({...f.get(),runtime:{...f.get().runtime,resources,ledger:{...f.get().runtime.ledger,operations:ops}}}),/observer failed/);assert.equal(other,1);a();b();assert.equal(f.size,0);
|
const f=fixture(),sources=createRuntimeSources(f.source);let other=0;const a=sources.asset('0').subscribe(()=>{throw Error('observer failed');}),b=sources.operation('0').subscribe(()=>other++),resources=new Tracked(f.get().runtime.resources),ops=new Tracked(f.get().runtime.ledger.operations);resources.set('0',{id:'0',operationId:'upload',name:'changed'});ops.set('0',operation('0',['a'],'blocked',['permission']));assert.throws(()=>f.emit({...f.get(),runtime:{...f.get().runtime,resources,ledger:{...f.get().runtime.ledger,operations:ops}}}),/observer failed/);assert.equal(other,1);a();b();assert.equal(f.size,0);
|
||||||
let stop;const earlier=f.source.subscribe(()=>stop());stop=sources.counts().subscribe(()=>{throw Error('detached observer');});assert.doesNotThrow(()=>f.emit({...f.get(),runtime:{...f.get().runtime,loading:{}}}));earlier();assert.equal(f.size,0);
|
let stop;const earlier=f.source.subscribe(()=>stop());stop=sources.counts().subscribe(()=>{throw Error('detached observer');});assert.doesNotThrow(()=>f.emit({...f.get(),runtime:{...f.get().runtime,loading:{}}}));earlier();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 关联请求由accepted变unresolved改变unknown/blocked,指向缺失操作的无关请求不影响scope a的计数引用。
|
||||||
test('request status changes update scoped sync counts and unrelated requests preserve cached count identity',()=>{
|
test('request status changes update scoped sync counts and unrelated requests preserve cached count identity',()=>{
|
||||||
const f=fixture(1),request={phase:'waiting',outcome:'accepted',operationIds:['0']};f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests:new Tracked([['r',request]])}}});const sources=createRuntimeSources(f.source),channel=sources.counts('a');let calls=0;const stop=channel.subscribe(()=>calls++);assert.deepEqual(channel.getSnapshot(),{pending:1,blocked:0,unknown:0,loading:false});const requests=new Tracked([['r',{...request,outcome:'unresolved',error:'transport'}]]);f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests}}});assert.equal(calls,1);const counted=channel.getSnapshot();assert.deepEqual(counted,{pending:1,blocked:1,unknown:1,loading:false});const unrelated=new Tracked(requests);unrelated.set('other',{phase:'waiting',outcome:'unresolved',error:'other',operationIds:['missing']});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests:unrelated}}});assert.equal(calls,1);assert.strictEqual(sources.counts('a').getSnapshot(),counted);stop();
|
const f=fixture(1),request={phase:'waiting',outcome:'accepted',operationIds:['0']};f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests:new Tracked([['r',request]])}}});const sources=createRuntimeSources(f.source),channel=sources.counts('a');let calls=0;const stop=channel.subscribe(()=>calls++);assert.deepEqual(channel.getSnapshot(),{pending:1,blocked:0,unknown:0,loading:false});const requests=new Tracked([['r',{...request,outcome:'unresolved',error:'transport'}]]);f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests}}});assert.equal(calls,1);const counted=channel.getSnapshot();assert.deepEqual(counted,{pending:1,blocked:1,unknown:1,loading:false});const unrelated=new Tracked(requests);unrelated.set('other',{phase:'waiting',outcome:'unresolved',error:'other',operationIds:['missing']});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests:unrelated}}});assert.equal(calls,1);assert.strictEqual(sources.counts('a').getSnapshot(),counted);stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 固定种子的40组混合事实,遍历项目/a/b/c/missing作用域,与朴素筛选逐项深比较,保证优化不改原计数定义。
|
||||||
test('indexed counts preserve the previous filter definition over mixed request and operation states',()=>{
|
test('indexed counts preserve the previous filter definition over mixed request and operation states',()=>{
|
||||||
const f=fixture(0),sources=createRuntimeSources(f.source);let seed=7;const random=n=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed%n;};
|
const f=fixture(0),sources=createRuntimeSources(f.source);let seed=7;const random=n=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed%n;};
|
||||||
for(let sample=0;sample<40;sample++){
|
for(let sample=0;sample<40;sample++){
|
||||||
const operations=new Tracked(Array.from({length:24},(_,index)=>{const id='op'+index,canvases=Array.from({length:random(4)},()=>['a','b','c'][random(3)]);return[id,operation(id,canvases,['pending','blocked','frozen','resolved'][random(4)],random(2)?['waiting_digest']:['permission'])];})),requests=new Tracked(Array.from({length:12},(_,index)=>['r'+index,{phase:['prepared','dispatch-marked','waiting','resolved'][random(4)],outcome:[undefined,'unresolved','accepted','applied'][random(4)],error:random(2)?'transport':undefined,retryAt:random(2)?100:undefined,operationIds:Array.from({length:random(5)},()=>'op'+random(26))}]));f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations,requests},loading:sample%2?{}:undefined}});
|
const operations=new Tracked(Array.from({length:24},(_,index)=>{const id='op'+index,canvases=Array.from({length:random(4)},()=>['a','b','c'][random(3)]);return[id,operation(id,canvases,['pending','blocked','frozen','resolved'][random(4)],random(2)?['waiting_digest']:['permission'])];})),requests=new Tracked(Array.from({length:12},(_,index)=>['r'+index,{phase:['prepared','dispatch-marked','waiting','resolved'][random(4)],outcome:[undefined,'unresolved','accepted','applied'][random(4)],error:random(2)?'transport':undefined,retryAt:random(2)?100:undefined,operationIds:Array.from({length:random(5)},()=>'op'+random(26))}]));f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations,requests},loading:sample%2?{}:undefined}});
|
||||||
for(const scope of [undefined,'a','b','c','missing']){
|
for(const scope of [undefined,'a','b','c','missing']){
|
||||||
|
// 朴素实现作为独立对照:先筛scope内未解决操作,再按逻辑ID关联请求,分别计算pending/blocked/unknown。
|
||||||
const selected=[...operations.values()].filter(row=>row.phase!=='resolved'&&(scope===undefined||row.intent.canvasIds.includes(scope))),ids=new Set(selected.map(row=>row.intent.id)),visible=[...requests.values()].filter(row=>row.phase!=='resolved'&&row.operationIds.some(id=>ids.has(id))),expected={pending:selected.length,blocked:selected.filter(row=>row.phase==='blocked'&&row.blockers.some(reason=>reason!=='waiting_digest')).length+visible.filter(row=>row.error&&row.retryAt===undefined).length,unknown:visible.filter(row=>row.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(row.phase)).length,loading:!!f.get().runtime.loading};assert.deepEqual(sources.counts(scope).getSnapshot(),expected,`sample ${sample}, scope ${scope}`);
|
const selected=[...operations.values()].filter(row=>row.phase!=='resolved'&&(scope===undefined||row.intent.canvasIds.includes(scope))),ids=new Set(selected.map(row=>row.intent.id)),visible=[...requests.values()].filter(row=>row.phase!=='resolved'&&row.operationIds.some(id=>ids.has(id))),expected={pending:selected.length,blocked:selected.filter(row=>row.phase==='blocked'&&row.blockers.some(reason=>reason!=='waiting_digest')).length+visible.filter(row=>row.error&&row.retryAt===undefined).length,unknown:visible.filter(row=>row.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(row.phase)).length,loading:!!f.get().runtime.loading};assert.deepEqual(sources.counts(scope).getSnapshot(),expected,`sample ${sample}, scope ${scope}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* selector外部store的缓存与观察者测试。模拟不可变上游快照,核对相等选择复用引用、异常重读及多个订阅独立清理。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
||||||
|
/** 最小store:set替换state并遍历监听副本,size观察退订;没有真实React挂载或渲染。 */
|
||||||
function source(initial){let state=initial;const listeners=new Set();return{getSnapshot:()=>state,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);},set(next){state=next;for(const fn of [...listeners])fn();},get size(){return listeners.size;}};}
|
function source(initial){let state=initial;const listeners=new Set();return{getSnapshot:()=>state,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);},set(next){state=next;for(const fn of [...listeners])fn();},get size(){return listeners.size;}};}
|
||||||
|
// selector只取a,自定义比较器忽略新对象引用;改b不通知,改a才发布,重复读取不重复选择计算。
|
||||||
test('selector store caches immutable reads and notifies only changed selections',()=>{const s=source({a:1,b:1});let selections=0,notifications=0;const store=createSelectorStore(s,value=>{selections++;return{a:value.a};},(a,b)=>a.a===b.a),first=store.getSnapshot();assert.strictEqual(store.getSnapshot(),first);assert.equal(selections,1);const stop=store.subscribe(()=>notifications++);s.set({a:1,b:2});assert.strictEqual(store.getSnapshot(),first);assert.equal(notifications,0);s.set({a:2,b:2});assert.equal(notifications,1);assert.equal(store.getSnapshot().a,2);stop();assert.equal(s.size,0);});
|
test('selector store caches immutable reads and notifies only changed selections',()=>{const s=source({a:1,b:1});let selections=0,notifications=0;const store=createSelectorStore(s,value=>{selections++;return{a:value.a};},(a,b)=>a.a===b.a),first=store.getSnapshot();assert.strictEqual(store.getSnapshot(),first);assert.equal(selections,1);const stop=store.subscribe(()=>notifications++);s.set({a:1,b:2});assert.strictEqual(store.getSnapshot(),first);assert.equal(notifications,0);s.set({a:2,b:2});assert.equal(notifications,1);assert.equal(store.getSnapshot().a,2);stop();assert.equal(s.size,0);});
|
||||||
|
// 负值使selector抛错,仍通知读者重读;恢复2再次通知,不能用旧成功值掩盖错误。
|
||||||
test('selector errors notify React to retry the snapshot and retain no invalid cached value',()=>{const s=source({value:1});const store=createSelectorStore(s,next=>{if(next.value<0)throw Error('invalid');return next.value;});let notifications=0;const stop=store.subscribe(()=>notifications++);s.set({value:-1});assert.equal(notifications,1);assert.throws(store.getSnapshot,/invalid/);s.set({value:2});assert.equal(store.getSnapshot(),2);assert.equal(notifications,2);stop();});
|
test('selector errors notify React to retry the snapshot and retain no invalid cached value',()=>{const s=source({value:1});const store=createSelectorStore(s,next=>{if(next.value<0)throw Error('invalid');return next.value;});let notifications=0;const stop=store.subscribe(()=>notifications++);s.set({value:-1});assert.equal(notifications,1);assert.throws(store.getSnapshot,/invalid/);s.set({value:2});assert.equal(store.getSnapshot(),2);assert.equal(notifications,2);stop();});
|
||||||
|
// A在第一次通知中退订,B仍收到两次更新;最后退订上游无监听残留。
|
||||||
test('distinct subscribers observe changes independently and unsubscribe during notification',()=>{const s=source(0),store=createSelectorStore(s,n=>n);let a=0,b=0,stopA;stopA=store.subscribe(()=>{a++;stopA();});const stopB=store.subscribe(()=>b++);s.set(1);s.set(2);assert.equal(a,1);assert.equal(b,2);stopB();assert.equal(s.size,0);});
|
test('distinct subscribers observe changes independently and unsubscribe during notification',()=>{const s=source(0),store=createSelectorStore(s,n=>n);let a=0,b=0,stopA;stopA=store.subscribe(()=>{a++;stopA();});const stopB=store.subscribe(()=>b++);s.set(1);s.set(2);assert.equal(a,1);assert.equal(b,2);stopB();assert.equal(s.size,0);});
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* 会话按view/元素分桶订阅测试。模拟两视图、mode与多目标gesture,检查一个上游连接下的局部通知及退订生命周期。
|
||||||
|
* 预览可共享给其它视图显示,但active所有权和incarnation必须精确,不因同ID就认领手势。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createSessionSources} from '../../src/lib/canvas-bindings/session-sources.ts';
|
import {createSessionSources} from '../../src/lib/canvas-bindings/session-sources.ts';
|
||||||
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
||||||
|
/** 内存source计数读取与监听,broken模拟订阅失败;emit替换快照,gesture/preview/mode构造最小合法测试变化。 */
|
||||||
function fixture(){let state={views:new Map([['v',{modes:new Map(),viewport:{x:0,y:0,zoom:1}}],['w',{modes:new Map()}]]),gesture:null,connectionGesture:null},reads=0,broken=false;const listeners=new Set();const source={getSnapshot(){reads++;return state;},subscribe(fn){if(broken)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);}};return{source,get size(){return listeners.size;},get reads(){return reads;},reset(){reads=0;},broken(value){broken=value;},get:()=>state,emit(next){state=next;for(const fn of [...listeners])fn();},gesture(id='300',viewId='v',incarnation=1){this.emit({...state,gesture:{token:1,viewId,targets:[{id,incarnation}],preview:new Map(),phase:'active'}});},preview(id,x){this.emit({...state,gesture:{...state.gesture,preview:new Map(state.gesture.preview).set(id,{x,y:0,width:10,height:10,rotation:0})}});},mode(view,id,value){const current=state.views.get(view),modes=new Map(current.modes);if(value)modes.set(id,value);else modes.delete(id);this.emit({...state,views:new Map(state.views).set(view,{...current,modes})});}};}
|
function fixture(){let state={views:new Map([['v',{modes:new Map(),viewport:{x:0,y:0,zoom:1}}],['w',{modes:new Map()}]]),gesture:null,connectionGesture:null},reads=0,broken=false;const listeners=new Set();const source={getSnapshot(){reads++;return state;},subscribe(fn){if(broken)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);}};return{source,get size(){return listeners.size;},get reads(){return reads;},reset(){reads=0;},broken(value){broken=value;},get:()=>state,emit(next){state=next;for(const fn of [...listeners])fn();},gesture(id='300',viewId='v',incarnation=1){this.emit({...state,gesture:{token:1,viewId,targets:[{id,incarnation}],preview:new Map(),phase:'active'}});},preview(id,x){this.emit({...state,gesture:{...state.gesture,preview:new Map(state.gesture.preview).set(id,{x,y:0,width:10,height:10,rotation:0})}});},mode(view,id,value){const current=state.views.get(view),modes=new Map(current.modes);if(value)modes.set(id,value);else modes.delete(id);this.emit({...state,views:new Map(state.views).set(view,{...current,modes})});}};}
|
||||||
|
|
||||||
|
// 2000通道共用一个上游,更新300预览只重新选择gesture300;只改viewport不通知这些mode/gesture通道。
|
||||||
test('1000 gesture and mode subscribers share one upstream and only the moved target is evaluated',()=>{
|
test('1000 gesture and mode subscribers share one upstream and only the moved target is evaluated',()=>{
|
||||||
const f=fixture(),sources=createSessionSources(f.source),calls=new Map(),notified=new Set(),stops=[];
|
const f=fixture(),sources=createSessionSources(f.source),calls=new Map(),notified=new Set(),stops=[];
|
||||||
for(let index=0;index<1000;index++)for(const kind of ['mode','gesture']){const id=String(index),key=kind+id,channel=kind==='mode'?sources.mode('v',id):sources.gesture('v',{id,incarnation:1}),store=createSelectorStore(channel,value=>{calls.set(key,(calls.get(key)??0)+1);return value;});stops.push(store.subscribe(()=>notified.add(key)));}
|
for(let index=0;index<1000;index++)for(const kind of ['mode','gesture']){const id=String(index),key=kind+id,channel=kind==='mode'?sources.mode('v',id):sources.gesture('v',{id,incarnation:1}),store=createSelectorStore(channel,value=>{calls.set(key,(calls.get(key)??0)+1);return value;});stops.push(store.subscribe(()=>notified.add(key)));}
|
||||||
@@ -10,22 +16,27 @@ test('1000 gesture and mode subscribers share one upstream and only the moved ta
|
|||||||
notified.clear();f.reset();f.emit({...f.get(),views:new Map(f.get().views).set('v',{...f.get().views.get('v'),viewport:{x:50,y:40,zoom:2}})});assert.equal(f.reads,1);assert.equal(notified.size,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
notified.clear();f.reset();f.emit({...f.get(),views:new Map(f.get().views).set('v',{...f.get().views.get('v'),viewport:{x:50,y:40,zoom:2}})});assert.equal(f.reads,1);assert.equal(notified.size,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// w:a与v:b分别通知;删除v只清其活动mode,不误清w或唤醒从未有mode的v:a。
|
||||||
test('mode changes route by view and target, and detaching a view resets only its active modes',()=>{
|
test('mode changes route by view and target, and detaching a view resets only its active modes',()=>{
|
||||||
const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.mode('v','a'),b=sources.mode('w','a'),c=sources.mode('v','b'),stops=[a.subscribe(()=>seen.push('v:a')),b.subscribe(()=>seen.push('w:a')),c.subscribe(()=>seen.push('v:b'))];f.mode('w','a','editing');assert.deepEqual(seen,['w:a']);f.mode('v','b','resize');assert.deepEqual(seen,['w:a','v:b']);const views=new Map(f.get().views);views.delete('v');f.emit({...f.get(),views});assert.deepEqual(seen,['w:a','v:b','v:b']);assert.equal(a.getSnapshot(),null);assert.equal(b.getSnapshot(),'editing');for(const stop of stops)stop();
|
const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.mode('v','a'),b=sources.mode('w','a'),c=sources.mode('v','b'),stops=[a.subscribe(()=>seen.push('v:a')),b.subscribe(()=>seen.push('w:a')),c.subscribe(()=>seen.push('v:b'))];f.mode('w','a','editing');assert.deepEqual(seen,['w:a']);f.mode('v','b','resize');assert.deepEqual(seen,['w:a','v:b']);const views=new Map(f.get().views);views.delete('v');f.emit({...f.get(),views});assert.deepEqual(seen,['w:a','v:b','v:b']);assert.equal(a.getSnapshot(),null);assert.equal(b.getSnapshot(),'editing');for(const stop of stops)stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// w看到a的预览但active=false,错误incarnation不见预览;gesture结束两者都回到无活动值。
|
||||||
test('preview is shared across views while active ownership and incarnation remain exact',()=>{
|
test('preview is shared across views while active ownership and incarnation remain exact',()=>{
|
||||||
const f=fixture(),sources=createSessionSources(f.source),v=sources.gesture('v',{id:'a',incarnation:1}),w=sources.gesture('w',{id:'a',incarnation:1}),stale=sources.gesture('v',{id:'a',incarnation:2}),stops=[v.subscribe(()=>{}),w.subscribe(()=>{}),stale.subscribe(()=>{})];f.gesture('a');f.preview('a',77);assert.equal(v.getSnapshot().active,true);assert.equal(w.getSnapshot().active,false);assert.equal(w.getSnapshot().preview.x,77);assert.deepEqual(stale.getSnapshot(),{preview:undefined,active:false});f.emit({...f.get(),gesture:null});assert.deepEqual(v.getSnapshot(),{preview:undefined,active:false});for(const stop of stops)stop();
|
const f=fixture(),sources=createSessionSources(f.source),v=sources.gesture('v',{id:'a',incarnation:1}),w=sources.gesture('w',{id:'a',incarnation:1}),stale=sources.gesture('v',{id:'a',incarnation:2}),stops=[v.subscribe(()=>{}),w.subscribe(()=>{}),stale.subscribe(()=>{})];f.gesture('a');f.preview('a',77);assert.equal(v.getSnapshot().active,true);assert.equal(w.getSnapshot().active,false);assert.equal(w.getSnapshot().preview.x,77);assert.deepEqual(stale.getSnapshot(),{preview:undefined,active:false});f.emit({...f.get(),gesture:null});assert.deepEqual(v.getSnapshot(),{preview:undefined,active:false});for(const stop of stops)stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同一手势含a/b,逐个更新只通知对应目标;无关connectionGesture变化复用a原快照。
|
||||||
test('multi-target preview only notifies the target whose geometry changed',()=>{
|
test('multi-target preview only notifies the target whose geometry changed',()=>{
|
||||||
const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.gesture('v',{id:'a',incarnation:1}),b=sources.gesture('v',{id:'b',incarnation:1}),stops=[a.subscribe(()=>seen.push('a')),b.subscribe(()=>seen.push('b'))];f.emit({...f.get(),gesture:{token:1,viewId:'v',targets:[{id:'a',incarnation:1},{id:'b',incarnation:1}],preview:new Map(),phase:'active'}});seen.length=0;f.preview('a',5);assert.deepEqual(seen,['a']);f.preview('b',8);assert.deepEqual(seen,['a','b']);const before=a.getSnapshot();f.emit({...f.get(),connectionGesture:{target:{id:'edge',incarnation:1}}});assert.strictEqual(a.getSnapshot(),before);for(const stop of stops)stop();
|
const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.gesture('v',{id:'a',incarnation:1}),b=sources.gesture('v',{id:'b',incarnation:1}),stops=[a.subscribe(()=>seen.push('a')),b.subscribe(()=>seen.push('b'))];f.emit({...f.get(),gesture:{token:1,viewId:'v',targets:[{id:'a',incarnation:1},{id:'b',incarnation:1}],preview:new Map(),phase:'active'}});seen.length=0;f.preview('a',5);assert.deepEqual(seen,['a']);f.preview('b',8);assert.deepEqual(seen,['a','b']);const before=a.getSnapshot();f.emit({...f.get(),connectionGesture:{target:{id:'edge',incarnation:1}}});assert.strictEqual(a.getSnapshot(),before);for(const stop of stops)stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 先制造subscribe失败,再让更早监听提前退订当前通道;迟到回调不通知,但直接读取仍最新,重新订阅正常且重复清理安全。
|
||||||
test('unsubscribed reads stay fresh; stale captured upstream callbacks and startup failure clean up safely',()=>{
|
test('unsubscribed reads stay fresh; stale captured upstream callbacks and startup failure clean up safely',()=>{
|
||||||
const f=fixture();let stop;const earlier=f.source.subscribe(()=>stop?.()),sources=createSessionSources(f.source),channel=sources.mode('v','a');f.broken(true);assert.throws(()=>channel.subscribe(()=>{}),/subscribe failed/);f.broken(false);let calls=0;stop=channel.subscribe(()=>calls++);f.mode('v','a','editing');assert.equal(calls,0);assert.equal(channel.getSnapshot(),'editing');stop();earlier();assert.equal(f.size,0);const again=channel.subscribe(()=>calls++);f.mode('v','a','resize');assert.equal(calls,1);again();again();assert.equal(f.size,0);
|
const f=fixture();let stop;const earlier=f.source.subscribe(()=>stop?.()),sources=createSessionSources(f.source),channel=sources.mode('v','a');f.broken(true);assert.throws(()=>channel.subscribe(()=>{}),/subscribe failed/);f.broken(false);let calls=0;stop=channel.subscribe(()=>calls++);f.mode('v','a','editing');assert.equal(calls,0);assert.equal(channel.getSnapshot(),'editing');stop();earlier();assert.equal(f.size,0);const again=channel.subscribe(()=>calls++);f.mode('v','a','resize');assert.equal(calls,1);again();again();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// a回调内读取b必须已更新,即使a抛错b仍被通知,最终才向外抛观察者错误。
|
||||||
test('observer errors do not starve other targets and all values advance before callbacks',()=>{
|
test('observer errors do not starve other targets and all values advance before callbacks',()=>{
|
||||||
const f=fixture(),sources=createSessionSources(f.source),a=sources.mode('v','a'),b=sources.mode('v','b');let calls=0;const stops=[a.subscribe(()=>{assert.equal(b.getSnapshot(),'editing');throw Error('observer failed');}),b.subscribe(()=>calls++)];assert.throws(()=>f.emit({...f.get(),views:new Map(f.get().views).set('v',{modes:new Map([['a','editing'],['b','editing']])})}),/observer failed/);assert.equal(calls,1);for(const stop of stops)stop();
|
const f=fixture(),sources=createSessionSources(f.source),a=sources.mode('v','a'),b=sources.mode('v','b');let calls=0;const stops=[a.subscribe(()=>{assert.equal(b.getSnapshot(),'editing');throw Error('observer failed');}),b.subscribe(()=>calls++)];assert.throws(()=>f.emit({...f.get(),views:new Map(f.get().views).set('v',{modes:new Map([['a','editing'],['b','editing']])})}),/observer failed/);assert.equal(calls,1);for(const stop of stops)stop();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user