diff --git a/tests/board-directory.test.mjs b/tests/board-directory.test.mjs index 3806a76..50083e4 100644 --- a/tests/board-directory.test.mjs +++ b/tests/board-directory.test.mjs @@ -1,7 +1,13 @@ +/** + * board 目录分页、树构建、祖先定位与筛选测试。请求由内存函数提供,不访问实际 workspace 服务。 + * 列表顺序不保证父项先到;孤儿和循环节点仍须可访问,不能递归死循环。 + */ import test from 'node:test'; import assert from 'node:assert/strict'; import { loadBoardDirectory, boardDirectory, activeBoardAncestors, filterBoardDirectory } from '../src/lib/board-directory.ts'; +// 生成带 ID、slug 和 parent_board_id 的最小目录项,默认 title 使用 ID。 const board=(id,parent,title=id)=>({id,parent_board_id:parent,title,workspace_id:'w',kind:'board',version:1,slug:id+'-slug'}); +// 第一页故意子先父后,下一页才给孙项;检查 include_subgraphs、offset 与 URL 编码,再检查层级、slug 祖先链和筛选路径。 test('directory includes subgraphs, traverses all pages and preserves nested ownership',async()=>{ const calls=[];const request=async(path)=>{calls.push(path);const offset=new URLSearchParams(path.split('?')[1]).get('offset');return offset==='0'?{items:[board('child','parent'),board('parent')],next_offset:100}:{items:[board('grandchild','child')],next_offset:null};}; const boards=await loadBoardDirectory(request,'space slug');assert.equal(calls.length,2);assert.ok(calls.every(path=>path.includes('include_subgraphs=true')));assert.ok(calls[1].endsWith('offset=100'));assert.ok(calls[0].includes('space%20slug')); @@ -10,9 +16,11 @@ test('directory includes subgraphs, traverses all pages and preserves nested own assert.deepEqual(filterBoardDirectory(roots,'grandchild')[0].children[0].children.map(item=>item.board.id),['grandchild']); assert.equal(filterBoardDirectory(roots,'parent')[0].children.length,1); }); +// 父项缺失及双节点循环都提升为可见根项,子列表为空,避免丢失入口或无限嵌套。 test('missing parents and corrupt cycles remain accessible without recursive loops',()=>{ const roots=boardDirectory([board('orphan','missing'),board('a','b'),board('b','a')]);assert.deepEqual(roots.map(item=>item.board.id),['orphan','a','b']);assert.ok(roots.every(item=>item.children.length===0)); }); +// 非递增 next_offset 明确拒绝;已经 abort 的信号由请求替身抛回,加载器不得吞掉取消。 test('directory rejects non-advancing pagination and propagates aborts',async()=>{ await assert.rejects(loadBoardDirectory(async()=>({items:[],next_offset:0}),'w'),/invalid_pagination/); const controller=new AbortController();controller.abort();await assert.rejects(loadBoardDirectory(async(_path,options)=>{options.signal.throwIfAborted();},'w',controller.signal),{name:'AbortError'}); diff --git a/tests/canvas-assets.test.mjs b/tests/canvas-assets.test.mjs index f4c9038..5d9f298 100644 --- a/tests/canvas-assets.test.mjs +++ b/tests/canvas-assets.test.mjs @@ -1,10 +1,17 @@ +/** + * 旧画布附件上传助手和显示 URL 转换测试。请求为替身,检查 FormData、错误和资源地址,不是真实上传或图像解码。 + * 这里的上传重试属于 canvas-assets helper,不可等同于 headless ledger 的持久冻结请求恢复。 + */ import test from 'node:test'; import assert from 'node:assert/strict'; import { uploadCanvasImage } from '../src/lib/canvas-assets.ts'; import { managedImageSource } from '../src/lib/canvas-v2.ts'; +// 固定 data URL 仅用来构造与对比二进制前缀,并非完整 PNG 解码样本。 const source='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'; +// 固定资产身份;返回值必须是项目管理的地址,任意外域响应不被当作受管上传结果。 const id='77777777-7777-4777-8777-777777777777'; const asset={id,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${id}`,width:1,height:1,bytes:24,mime_type:'image/png'}; +// 首例直接比对 FormData.image 原字节、上传路径、POST 和幂等键形状,返回值保留服务资产元数据。 test('attachment upload sends the original file bytes',async()=>{ const bytes=Buffer.from(source.split(',')[1],'base64');let calls=0; const result=await uploadCanvasImage(async(path,options)=>{ @@ -13,15 +20,18 @@ test('attachment upload sends the original file bytes',async()=>{ },'w',new Blob([bytes],{type:'image/png'}),'image.png'); assert.equal(calls,1);assert.deepEqual(result,asset); }); +// 权限拒绝直接传播;格式类似但地址不受管理的成功响应也要拒绝,不能把任意 URL 写成上传结果。 test('attachment upload propagates permission failures and rejects unmanaged response URLs',async()=>{ const file=new Blob(['test'],{type:'image/png'}); await assert.rejects(uploadCanvasImage(async()=>{throw new Error('forbidden');},'w',file,'test.png'),/forbidden/); await assert.rejects(uploadCanvasImage(async()=>({...asset,url:'https://invalid.example/a.png'}),'w',file,'test.png'),/invalid_asset_response/); }); +// 模拟第一次连接丢失、第二次成功;此例直接断言两次调用与相同幂等键,文件字节传递由首例单独检查。 test('uncertain upload retries the exact bytes with the same idempotency key',async()=>{ let calls=0,key; await uploadCanvasImage(async(_path,options)=>{calls++;if(!key)key=options.headers['Idempotency-Key'];assert.equal(options.headers['Idempotency-Key'],key);if(calls===1)throw new TypeError('connection lost');return asset;},'w',new Blob(['test'],{type:'image/png'}),'test.png');assert.equal(calls,2); }); +// 受管原始 URL 转同源认证代理,preview 仅加显示变体参数;普通外部图片地址保持原样。 test('preview URLs are renderer-only and do not replace original managed references',()=>{ assert.equal(managedImageSource(asset.url),`/api/foundry/workspaces/w/assets/${id}`);assert.equal(managedImageSource(asset.url,true),`/api/foundry/workspaces/w/assets/${id}?variant=preview`); assert.equal(managedImageSource('https://example.com/photo.png',true),'https://example.com/photo.png'); diff --git a/tests/canvas-frames.test.mjs b/tests/canvas-frames.test.mjs index 1abf1aa..2b4bc58 100644 --- a/tests/canvas-frames.test.mjs +++ b/tests/canvas-frames.test.mjs @@ -1,16 +1,23 @@ +/** + * 旧 frameId 分区成员模型的拖入、随动与恢复测试;不是 headless ownerCanvasId 树或 linked-board 深子 canvas 的全部规则。 + * 成员关系由明确 drop 决定,几何重叠本身不代表已经加入分区。 + */ import { test } from "node:test"; import assert from "node:assert/strict"; import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; import { assignFrameMembership, frameDropFeedback } from "../src/lib/canvas-frames.ts"; +// frame、位于内部但尚未登记的 member,以及外部 icon;可分别检查 drop 准入和已有成员随动。 function seeded() { const frame = makeCanvasNode("frame", { x: 100, y: 100 }); const member = makeCanvasNode("text", { x: 160, y: 160 }); const outsider = makeCanvasNode("icon", { x: 900, y: 120 }); return { frame, member, outsider, document: { ...emptyCanvas(), nodes: [frame, member, outsider] } }; } +// 把给定快照包成空历史结构,保持所有测试通过相同 reducer 入口。 function state(document) { return { past: [], present: document, future: [] }; } +// 多种内容 drop 到重叠 frame 时预览应与最终成员选择一致;较小 frame 优先,移到外部则同时显示和执行 detach。 test("drop previews match actual assignment for every content type, exits and overlapping frames", () => { const { frame } = seeded(); const smaller = makeCanvasNode("frame", { x: 120, y: 120 }); smaller.width = 300; smaller.height = 240; @@ -26,6 +33,7 @@ test("drop previews match actual assignment for every content type, exits and ov } }); +// 只给 dropped IDs 分配成员,拖 frame 或其他节点不能顺手收集原本重叠的内容。 test("only dropped content joins a frame; pre-existing overlapping content stays outside", () => { const { frame, member, outsider, document } = seeded(); assert.equal(document.nodes[1].data.frameId, undefined); @@ -38,6 +46,7 @@ test("only dropped content joins a frame; pre-existing overlapping content stays assert.equal(assignFrameMembership([frame, member, outsider], [member.id])[2].data.frameId, undefined); }); +// 已有成员随 frame 同量移动,旁边 outsider 不改位置和归属;undo 恢复成员坐标与 frameId。 test("frames carry existing members without collecting newly overlapped content and undo restores positions", () => { const { frame, member, outsider, document } = seeded(); member.data.frameId = frame.id; @@ -51,6 +60,7 @@ test("frames carry existing members without collecting newly overlapped content assert.equal(history.present.nodes[1].data.frameId, frame.id); }); +// 已有成员拖出清除 frameId,落进另一 frame 则换归属;以同一位置比较两种节点集合。 test("dragging out detaches content and dragging between frames reassigns membership", () => { const { frame, member } = seeded(); member.data.frameId = frame.id; @@ -60,6 +70,7 @@ test("dragging out detaches content and dragging between frames reassigns member assert.equal(assignFrameMembership([frame, second, member], [member.id])[2].data.frameId, second.id); }); +// 同时上报父 frame 和成员的最终位置时,不能再把父位移叠加到成员,避免双重移动。 test("multi-selection does not move frame members twice", () => { const { frame, member, document } = seeded(); member.data.frameId = frame.id; @@ -70,6 +81,7 @@ test("multi-selection does not move frame members twice", () => { assert.deepEqual(result.present.nodes[1].position, { x: 260, y: 260 }); }); +// 旧模型删除 frame 保留内容并清成员关联,undo 可恢复原 frameId;这不是 headless 删除 owning 子树的产品规则。 test("deleting a frame keeps its content and undo restores membership", () => { const { frame, member, document } = seeded(); member.data.frameId = frame.id; @@ -80,6 +92,7 @@ test("deleting a frame keeps its content and undo restores membership", () => { assert.equal(canvasReducer(removed, { type: "undo" }).present.nodes[1].data.frameId, frame.id); }); +// hidden/fixed/scaled 标签、名称与 frameId 往返保存,frame 渲染层序为 -1,设置历史可撤销。 test("all label modes, frame names and memberships survive save/restore and settings undo", () => { const { frame, member, document } = seeded(); member.data.frameId = frame.id; @@ -95,6 +108,7 @@ test("all label modes, frame names and memberships survive save/restore and sett } }); +// 缺失 frame、frame 作为别的内容成员、非法标签和 frame 旋转均拒绝;限制属于本旧格式。 test("invalid frame references, label modes, frame nesting and rotation are rejected", () => { for (const mutate of [ ({ member }) => { member.data.frameId = "missing"; }, diff --git a/tests/canvas-geometry.test.mjs b/tests/canvas-geometry.test.mjs index 87b4494..f886638 100644 --- a/tests/canvas-geometry.test.mjs +++ b/tests/canvas-geometry.test.mjs @@ -1,12 +1,19 @@ +/** + * 旧 CanvasElement 几何与能力测试,直接验证类、纯函数和旧 reducer;不分派真实 pointer 事件。 + * 多方向/旋转样本用对边锚点及连接端点作为几何不变量,避免只比较宽高变化。 + */ import { test } from "node:test"; import assert from "node:assert/strict"; import { CanvasElement, CardElement, TextCardElement, ImageCardElement, ScriptCardElement, PlainTextElement, elementCapabilities } from "../src/lib/canvas-elements.ts"; import { nodeConnector, connectorBezier, nodeCenter, nodeSize, resizeFromPointer, rotateVector, rotationFromPointer } from "../src/lib/canvas-geometry.ts"; import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas } from "../src/lib/canvas.ts"; +// 浮点误差容差为 1e-5,失败消息保留 actual/expected,供旋转与坐标换算诊断。 function close(actual, expected) { assert(Math.abs(actual - expected) < 0.00001, `${actual} != ${expected}`); } +// 将几何 patch 与旧 node.data 中的 rotation/fontSize 合并,重算中心和端点时使用同一模型形状。 function transformed(node, patch) { return { ...node, ...patch, data: { ...node.data, rotation: patch.rotation ?? node.data.rotation, fontSize: patch.fontSize ?? node.data.fontSize } }; } +// 三种卡片共享变换和 branching;PlainText 只继承公共变换,不能通过错误 ID 或自身 ID 绕过不连线/不分支能力。 test("card types inherit common element transforms and add branching; plain text inherits only common transforms", () => { for (const Type of [TextCardElement, ImageCardElement, ScriptCardElement]) { const element = new Type({ x: 0, y: 0 }); @@ -27,12 +34,14 @@ test("card types inherit common element transforms and add branching; plain text assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [node] }, node.id, "right"), /纯文本/); }); +// 五种旋转角度乘八方向 handle,检查受影响尺寸和相反局部锚点世界坐标保持不变。 test("all eight handles resize cards while retaining the opposite local anchor, including rotated cards", () => { const directions = ["top", "right", "bottom", "left", "top-left", "top-right", "bottom-left", "bottom-right"]; for (const rotation of [0, 37, 90, 180, 270]) { for (const direction of directions) { const node = new TextCardElement({ x: 100, y: 200 }).toNode(); node.data.rotation = rotation; + // hx/hy 表示当前 handle 的局部符号;位移先旋转到世界,再比较缩放前后相反侧锚点的世界位置。 const hx = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0; const hy = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0; const delta = rotateVector({ x: hx * 60, y: hy * 40 }, rotation); @@ -49,6 +58,7 @@ test("all eight handles resize cards while retaining the opposite local anchor, } }); +// 纯文本宽高独立改变,最小值受限但不改变字号;仅拉宽或拉高不应连带另一维度。 test("plain text width and height resize independently without changing the font", () => { const node = new PlainTextElement({ x: 0, y: 0 }).toNode(); const patch = resizeFromPointer(node, "bottom-right", { x: 240, y: 72 }); @@ -61,6 +71,7 @@ test("plain text width and height resize independently without changing the font assert(min.width >= 60); assert(min.height >= 24); assert.equal(min.fontSize, undefined); }); +// 旋转跟随指针绕中心方向,Shift 风格吸附到 15 度,负方向归一为 0..360 范围。 test("rotation follows the pointer, supports 15-degree snapping and normalizes the angle", () => { const node = new TextCardElement({ x: 0, y: 0 }).toNode(); const center = nodeCenter(node); @@ -71,6 +82,7 @@ test("rotation follows the pointer, supports 15-degree snapping and normalizes t close(rotationFromPointer(node, start, { x: center.x, y: center.y - 100 }), 270); }); +// checkpoint 内变换可保存恢复并一次 undo;纯文本 type、宽高、旋转和原字号各自保持正确。 test("geometry and plain-text type survive save/restore and transforms undo as one gesture", () => { const node = new PlainTextElement({ x: 20, y: 30 }).toNode(); let state = { past: [], present: { ...emptyCanvas(), nodes: [node] }, future: [] }; @@ -84,6 +96,7 @@ test("geometry and plain-text type survive save/restore and transforms undo as o close(undo.present.nodes[0].data.rotation, 0); }); +// 旧 material/text 草稿仍解码成卡片且补默认 rotation;负尺寸、超界角度及超大字号逐项拒绝。 test("old text-card drafts remain cards and invalid transform values are rejected", () => { const old = { id: "old", type: "material", position: { x: 0, y: 0 }, data: { kind: "text", title: "Old", text: "Keep" } }; const document = { ...emptyCanvas(), nodes: [old] }; @@ -94,6 +107,7 @@ test("old text-card drafts remain cards and invalid transform values are rejecte } }); +// 对 0/90/180 度检查四个边中心和单位法线,再验证缩放后的实际 right/bottom 锚点。 test("connection endpoints stay on the exact border centers after resize and rotation", () => { const node = new TextCardElement({ x: 20, y: 30 }).toNode(); const expected = { @@ -115,6 +129,7 @@ test("connection endpoints stay on the exact border centers after resize and rot assert.deepEqual(nodeConnector(resized, "bottom").point, { x: 200, y: 280 }); }); +// Bezier 字符串必须从精确 source anchor 开始到 target anchor 结束;selected 仅改变 UI,不改变几何路径。 test("Bezier paths include both exact card anchors, independent of selected handle sizes", () => { const source = new TextCardElement({ x: 20, y: 30 }).toNode(); const target = new TextCardElement({ x: 500, y: 180 }).toNode(); diff --git a/tests/canvas-icons.test.mjs b/tests/canvas-icons.test.mjs index b2d804a..587863f 100644 --- a/tests/canvas-icons.test.mjs +++ b/tests/canvas-icons.test.mjs @@ -1,3 +1,7 @@ +/** + * 本地图标目录/分片和共享 ResourceLoader 测试。直接读仓库生成资产,校验本地哈希和引用,不访问图标 CDN。 + * loader 的活跃并发与缓存容量受限,不能据此推断待处理队列本身有长度上限。 + */ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; @@ -8,10 +12,12 @@ import { iconAsset, validateIconShard } from "../src/lib/canvas-icon-loader.ts"; import { decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; import { createResourceLoader } from "../src/lib/resource-loader.ts"; +// 核对目录数量、ID 唯一性、分片文件哈希/体积/合法 SVG 数据、每个图标和分类存在;选取三个 ID 再验证旧快照往返。 test("all Lucide and experimental Tabler IDs map to valid local shards and survive snapshots", async () => { assert.equal(catalog.filter((icon) => icon.source === "lucide").length, 2313); assert.equal(catalog.filter((icon) => icon.source === "tabler").length, 24); assert.equal(new Set(catalog.map((icon) => icon.name)).size, catalog.length); + // 按 manifest 逐分片读取本地 JSON;文件名哈希取 trimEnd 后文本的 SHA-256 前 12 位,单片小于 20000 字节。 const loaded = new Map(); for (const asset of Object.values(manifest.assets)) { const content = await readFile(`public${manifest.root}/${asset}`, "utf8"); @@ -30,13 +36,16 @@ test("all Lucide and experimental Tabler IDs map to valid local shards and survi } }); +// 对 script、onload、外部 paint URL 和 use/href 的样例逐个拒绝;这是数据校验例,不是完整浏览器 SVG 安全审计。 test("icon data rejects executable shapes, events and external paint references", () => { for (const node of [["script", {}], ["path", { onload: "alert(1)" }], ["path", { fill: "url(https://example.com)" }], ["use", { href: "bad" }]]) { assert.throws(() => validateIconShard({ Star: [node] })); } }); +// 让 setImmediate 推进到调度任务阶段;用于观察尚未开始、排队及已释放容量的加载。 const tick = () => new Promise((resolve) => setImmediate(resolve)); +// request 后立即 abort,尚未进入调度时不能启动网络加载,也不能把未完成值留入缓存。 test("an immediately unmounted icon does not start a network request", async () => { let calls = 0; const loader = createResourceLoader(async () => { calls++; return "unused"; }); @@ -47,8 +56,10 @@ test("an immediately unmounted icon does not start a network request", async () assert.equal(calls, 0); assert.equal(loader.peek("unused"), undefined); }); +// 同 key a 两个订阅只加载一次,容量二时 c 等待;完成后缓存命中不新增调用,峰值为二。 test("loader bounds concurrency, deduplicates shared shards and reuses cache", async () => { let active = 0, peak = 0, calls = 0; + // 手动完成 active 加载,用于在容量为二时观察第三项等待及共享 key 去重。 const finish = []; const loader = createResourceLoader(async (key) => { active++; peak = Math.max(peak, active); calls++; @@ -62,6 +73,7 @@ test("loader bounds concurrency, deduplicates shared shards and reuses cache", a assert.equal(await loader.request("a"), "a"); assert.equal(calls, 3); assert.equal(peak, 2); }); +// 取消排队 b 后它永不启动;取消 a 的一个订阅不能中止另一个订阅共享的 active a。 test("unused queued loads never start; a remaining subscriber keeps a shared load alive", async () => { const seen = []; let release; const loader = createResourceLoader(async (key) => { seen.push(key); if (key === "a") await new Promise((resolve) => release = resolve); return key; }, 1); @@ -73,6 +85,7 @@ test("unused queued loads never start; a remaining subscriber keeps a shared loa await rejected; assert.equal(await shared, "a"); await tick(); assert.deepEqual(seen, ["a"]); }); +// active abort 后释放容量,失败 key 可重新加载;容量二的 LRU 在加入 a/b 后淘汰更早 fail,保留仍在缓存的 a。 test("active aborts and failed requests can retry; least recently used cache is bounded", async () => { let fail = true; const loader = createResourceLoader(async (key, signal) => { diff --git a/tests/canvas-media.test.mjs b/tests/canvas-media.test.mjs index 29ad5d3..dab3aac 100644 --- a/tests/canvas-media.test.mjs +++ b/tests/canvas-media.test.mjs @@ -1,3 +1,7 @@ +/** + * 旧画布 CanvasElement 类体系的纯图片能力及图标搜索测试,与 headless component registry 的模型定义分开。 + * 只操作节点数据、几何和序列化,不挂载 renderer 或读取图片字节。 + */ import { test } from "node:test"; import assert from "node:assert/strict"; import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; @@ -5,6 +9,7 @@ import { CanvasElement, CardElement, PlainImageElement } from "../src/lib/canvas import { resizeFromPointer } from "../src/lib/canvas-geometry.ts"; import { iconCatalog, iconsById, searchIcons } from "../src/components/canvas/icon-catalog.ts"; +// PlainImageElement 继承通用变换但不继承 CardElement,比例缩放、名称和 src 可恢复,重命名可撤销。 test("plain images inherit transforms without card connections and preserve source/name on restore", () => { const image = new PlainImageElement({ x: 0, y: 0 }, "", "data:image/png;base64,YQ=="); assert(image instanceof CanvasElement); assert.equal(image instanceof CardElement, false); @@ -18,6 +23,7 @@ test("plain images inherit transforms without card connections and preserve sour assert.equal(canvasReducer(renamed, { type: "undo" }).present.nodes[0].data.title, "图片"); }); +// 纯图片不能作为卡片连线目标;删除连线后 javascript: src 仍必须被解码器拒绝。 test("plain image sources are required and pure images cannot acquire card connections", () => { const image = makeCanvasNode("plain-image", { x: 0, y: 0 }, "data:image/png;base64,YQ=="); const card = makeCanvasNode("text", { x: 300, y: 0 }); @@ -27,6 +33,7 @@ test("plain image sources are required and pure images cannot acquire card conne assert.throws(() => decodeCanvas(JSON.stringify(document))); }); +// 图标 ID 唯一并符合后端字符形状,中文类别与英文词可搜索;仅验证当前本地目录,不请求后端搜索。 test("the complete Lucide catalog has unique backend-compatible identifiers and searchable names", () => { assert(iconCatalog.length > 2000); assert.equal(iconsById.size, iconCatalog.length); diff --git a/tests/canvas-response.test.mjs b/tests/canvas-response.test.mjs index 544ffd9..3df3edc 100644 --- a/tests/canvas-response.test.mjs +++ b/tests/canvas-response.test.mjs @@ -1,9 +1,15 @@ +/** + * 响应头、body 总传输与空闲三类截止时间的组合测试。用 Response/ReadableStream 和 fetch 替身验证读取、取消与诊断。 + * HTTP 状态 200 不代表 body 可用:传输中断、非法 JSON、后端错误和 204 在 api 层需要分别表达。 + */ import test from 'node:test'; import assert from 'node:assert/strict'; import { fetchCanvasResponse } from '../src/lib/canvas-response.ts'; import { api, ApiError } from '../src/lib/api.ts'; +// 延迟第二个真实流块,使传输超过 header 截止但仍在 body/idle 截止内;不是网络服务计时。 const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); +// header 返回后取消其截止约束,两个 UTF-8 块继续读完;ETag、完整字节数、chunk 数和 transfer_ms 报告均要保留。 test('snapshot body outlives the header deadline and reports transfer timing and bytes',async()=>{ const reports=[]; const bytes=new TextEncoder().encode('{"version":210,"document":{"title":"测试"}}'); const upstream=new Response(new ReadableStream({async start(c){ c.enqueue(bytes.slice(0,10));await wait(50);c.enqueue(bytes.slice(10));c.close();}}),{headers:{ETag:'"210"'}}); @@ -11,15 +17,18 @@ test('snapshot body outlives the header deadline and reports transfer timing and assert.equal(response.headers.get('etag'),'"210"');assert.equal((await response.json()).version,210); assert.equal(reports.length,1);assert.equal(reports[0].outcome,'complete');assert.equal(reports[0].bytes,bytes.length);assert.ok(reports[0].transfer_ms>=30);assert.equal(reports[0].chunks,2); }); +// 首块后不再产生数据,触发 idle 超时;consumer 必须收到 TimeoutError,上游 cancel 被调用,不能返回空快照。 test('snapshot idle timeout aborts unfinished bodies instead of returning a valid empty snapshot',async()=>{ const reports=[];let cancelled=false; const upstream=new Response(new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{"version":'));},cancel(){cancelled=true;}})); const response=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:15},async()=>upstream,r=>reports.push(r)); await assert.rejects(response.json(),{name:'TimeoutError'});assert.ok(cancelled);assert.equal(reports[0].outcome,'error');assert.equal(reports[0].error,'TimeoutError'); }); +// fetch 尚未返回头时监听 AbortSignal,header 超时应以 TimeoutError 拒绝,而不是等待 body 读取。 test('header deadline rejects a server that has not responded',async()=>{ await assert.rejects(fetchCanvasResponse('https://example.test',{}, {headersMs:10,bodyMs:500,idleMs:100},(_url,options)=>new Promise((_resolve,reject)=>options.signal.addEventListener('abort',()=>reject(options.signal.reason)))),{name:'TimeoutError'}); }); +// 调用方 abort 和 consumer 直接 cancel 两条路径都释放上游读取,分别核对 AbortError 和 cancel 总次数。 test('disconnect and consumer cancellation release the upstream read',async()=>{ const abort=new AbortController();let cancelled=0; const make=()=>new Response(new ReadableStream({cancel(){cancelled++;}})); @@ -27,6 +36,7 @@ test('disconnect and consumer cancellation release the upstream read',async()=>{ abort.abort();await assert.rejects(response.text(),{name:'AbortError'}); const second=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:100},async()=>make());await second.body.cancel();assert.equal(cancelled,2); }); +// 临时替换全局 fetch,检查 interrupted/invalid JSON/server error/null 四种结果;finally 恢复原 fetch,避免污染其他用例。 test('client distinguishes a failed 200 body transfer, malformed JSON, server errors and 204',async()=>{ const original=globalThis.fetch; try { diff --git a/tests/canvas-style.test.mjs b/tests/canvas-style.test.mjs index d2520cc..280875a 100644 --- a/tests/canvas-style.test.mjs +++ b/tests/canvas-style.test.mjs @@ -1,11 +1,16 @@ +/** + * 旧快照画布的分支创建、文字样式、图标变换与恢复校验。这里只验证 legacy reducer/schema,不代表 headless 增量事务实现。 + */ import { test } from "node:test"; import assert from "node:assert/strict"; import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; import { CanvasElement, CardElement, IconElement } from "../src/lib/canvas-elements.ts"; import { resizeFromPointer } from "../src/lib/canvas-geometry.ts"; +// 每次返回一个新文本节点,避免样式/分支变更在不同测试之间共享可变实例。 function seeded() { return { ...emptyCanvas(), nodes: [makeCanvasNode("text", { x: 100, y: 200 })] }; } +// 从同一父节点分别创建 text/script/image,检查连线端口、图片源、快照往返,再用一次 replace 历史整体撤销。 test("branch type selection creates the chosen text, script or uploaded image and keeps the link in one undo step", () => { for (const kind of ["text", "script", "image"]) { const original = seeded(); @@ -22,6 +27,7 @@ test("branch type selection creates the chosen text, script or uploaded image an } }); +// 连线释放在任意画布坐标时,新 script 直接落在该 release 点,并只增加一条边。 test("a connection released on the canvas creates the chosen node at the release point", () => { const original = seeded(); const at = { x: -300, y: 700 }; @@ -30,6 +36,7 @@ test("a connection released on the canvas creates the chosen node at the release assert.equal(result.edges.length, 1); }); +// 颜色/背景/字体/行高等样式与字号可保存和遍历历史;部分样式清除只改目标键,保留未涉及颜色。 test("all text styles and font size survive serialization, undo and redo", () => { const original = seeded(); const style = { color: "#123456", background: "#fedcba", bold: true, italic: true, underline: true, align: "right", fontFamily: "serif", lineHeight: 1.8 }; @@ -45,6 +52,7 @@ test("all text styles and font size survive serialization, undo and redo", () => assert.equal(decodeCanvas(JSON.stringify(cleared.present)).nodes[0].data.textStyle.bold, false); }); +// 逐个非法样式/类型和可执行样式片段均拒绝;非法 icon ID 也不能通过旧快照恢复。 test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a restored canvas", () => { for (const style of [{ color: "url(https://example.com)" }, { background: "red" }, { fontFamily: "custom" }, { lineHeight: Infinity }, { bold: "true" }, { align: "justify" }, { align: ["left"] }, { fontFamily: ["sans"] }, { position: "fixed" }, []]) { const doc = seeded(); doc.nodes[0].data.textStyle = style; @@ -55,6 +63,7 @@ test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a res assert.throws(() => decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [icon] })), /图标/); }); +// IconElement 继承通用变换而非 CardElement,ID、颜色、旋转可恢复;等比缩放有最小尺寸且不能分支。 test("icon elements inherit transforms without card branching, preserve their identity and resize proportionally", () => { const element = new IconElement({ x: 10, y: 20 }); assert(element instanceof CanvasElement); diff --git a/tests/canvas-sync.test.mjs b/tests/canvas-sync.test.mjs index bf328af..597fcef 100644 --- a/tests/canvas-sync.test.mjs +++ b/tests/canvas-sync.test.mjs @@ -1,12 +1,20 @@ +/** + * 旧 CanvasSync 完整快照自动保存及旧加载/草稿恢复测试。正在发送的请求串行,排队文档只保留最新份。 + * 显式 retry 可以发送最新文档,与 headless 冻结请求的未知结果查询/原字节重试是不同契约,不可相互替代。 + */ import { test } from "node:test"; import assert from "node:assert/strict"; import { CanvasSync, canvasPath, canvasPayload, readLocalRecovery } from "../src/lib/canvas-sync.ts"; import { emptyCanvas, makeCanvasNode, canvasReducer } from "../src/lib/canvas.ts"; import { loadBoardCanvas } from "../src/lib/canvas-loader.ts"; +// 返回固定 text ID 的新快照,便于只改变内容而不被随机 ID 差异触发多余保存。 function document(text = "") { const doc = emptyCanvas(); const node = makeCanvasNode("text", { x: 0, y: 0 }); node.id = "text"; node.data.text = text; doc.nodes.push(node); return doc; } +// 手动完成首个请求以检查在途期间版本和队列合并,reject 也供故障场景使用。 function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } +// setImmediate 让保存泵进入 request,再添加后续文档,避免只测同一同步调用窗口。 const tick = () => new Promise((resolve) => setImmediate(resolve)); +// A 请求挂起时排队 B/C 不并发发送;A ACK=2 后只发送 C,并使用连续 If-Match 1→2。 test("serialized autosaves use successive versions and coalesce queued documents", async () => { const calls = [], first = deferred(); const writer = new CanvasSync(async (path, options) => { @@ -23,6 +31,7 @@ test("serialized autosaves use successive versions and coalesce queued documents assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "C"); assert.equal(writer.version, 3); }); +// 首次 version_conflict 停止自动写,后续 B 不再发送,旧服务版本和错误保留。 test("version conflicts stop automatic writes and preserve the latest pending content", async () => { const calls = []; const writer = new CanvasSync(async (_path, options) => { calls.push(options); throw new Error("version_conflict"); }, "canvas", { version: 7, document: emptyCanvas() }, () => {}); @@ -30,6 +39,7 @@ test("version conflicts stop automatic writes and preserve the latest pending co assert.equal(calls.length, 1); assert.equal(writer.error, "version_conflict"); assert.equal(writer.version, 7); }); +// 故障后手动 retry 发送当前最新 B,成功清错误;没有承诺保留最初 A 的冻结字节。 test("explicit retry sends the newest document after a failed write", async () => { const calls = []; const writer = new CanvasSync(async (_path, options) => { calls.push(options); if (calls.length === 1) throw new Error("upstream_unavailable"); return { version: 2 }; }, "canvas", { version: 1, document: emptyCanvas() }, () => {}); @@ -37,6 +47,7 @@ test("explicit retry sends the newest document after a failed write", async () = assert.equal(calls.length, 2); assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "B"); assert.equal(writer.error, ""); }); +// selected/measured 的变更不产生保存,payload 去 UI 字段;真正内容变化才调用一次接口。 test("selection and measured dimensions do not trigger redundant writes or leak UI fields", async () => { const initial = document("keep"); let calls = 0; const writer = new CanvasSync(async () => { calls++; return { version: 2 }; }, "canvas", { version: 1, document: initial }, () => {}); @@ -48,6 +59,7 @@ test("selection and measured dimensions do not trigger redundant writes or leak assert.equal(calls, 1); }); +// workspace/board 的 ID 与 slug 四组合都得到 canonical board 身份及保存快照,路径仍按传入引用构造。 test("all ID/slug combinations load the same canonical board and snapshot", async () => { for (const workspace of ["space-id", "creative-space"]) for (const board of ["board-id", "my-board"]) { const calls = []; @@ -57,11 +69,13 @@ test("all ID/slug combinations load the same canonical board and snapshot", asyn } }); +// 快照读取失败或 board 身份不符直接拒绝,不以空文档允许用户覆盖远端。 test("failed snapshot loads and mismatched boards prevent editing rather than creating an empty replacement", async () => { await assert.rejects(loadBoardCanvas(async (path) => { if (path.endsWith("/canvas")) throw new Error("upstream_unavailable"); return { id: "board", workspace_id: "space", kind: "board" }; }, "space", "board"), /upstream/); await assert.rejects(loadBoardCanvas(async (path) => path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "other", workspace_id: "space", kind: "board" }, "space", "board"), /invalid_reference/); }); +// dirty 及旧草稿可提供显式恢复候选;clean 备份或与远端相同内容返回 null,不能静默覆盖服务。 test("dirty local recovery and legacy drafts are offered for explicit migration; clean backups never replace server data", () => { const remote = { version: 3, document: document("server") }; const local = document("local"); @@ -72,6 +86,7 @@ test("dirty local recovery and legacy drafts are offered for explicit migration; assert.equal(readLocalRecovery(storage({ "key:recovery": JSON.stringify({ document: remote.document, dirty: true }) }), "key", remote), null); }); +// 指纹按内容规范化键序,textStyle 属性顺序不同不触发保存,也不被当作未保存草稿差异。 test("canonical fingerprints ignore server JSON key ordering", async () => { const remote = document("same"), local = document("same"); remote.nodes[0].data.textStyle = { bold: true, color: "#123456" }; @@ -85,6 +100,7 @@ test("canonical fingerprints ignore server JSON key ordering", async () => { }); +// 先挂起两种读取再检查 started=2,metadata/snapshot 必须并发启动。 test("board metadata and snapshot begin concurrently without a request waterfall", async () => { const started = []; const releases = []; const loading = loadBoardCanvas((path) => { started.push(path); return new Promise((resolve) => releases.push(() => resolve(path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "board", workspace_id: "space", kind: "board" }))); }, "space", "board"); @@ -92,6 +108,7 @@ test("board metadata and snapshot begin concurrently without a request waterfall }); +// 微小 viewport 浮点误差保持同一 state,正常 viewport 也不单独保存;随后内容变化才写一次。 test("viewport restoration roundoff does not create an autosave", async () => { const doc=document("keep");doc.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392}; const state={past:[],present:doc,future:[]}; @@ -104,6 +121,7 @@ test("viewport restoration roundoff does not create an autosave", async () => { }); +// pendingUpload/Blob 节点和触边只在本地可编辑;传输排除它们,确认后的新 src、最新坐标和连线才一起进入快照。 test("editable local uploads and touching edges never enter transport snapshots",async()=>{ const pending={...makeCanvasNode("image",{x:30,y:40},"blob:local-file"),pendingUpload:"upload-one"};pending.id="image-local"; const initial=emptyCanvas(),calls=[]; diff --git a/tests/canvas-v2.test.mjs b/tests/canvas-v2.test.mjs index 3a53c15..bed8515 100644 --- a/tests/canvas-v2.test.mjs +++ b/tests/canvas-v2.test.mjs @@ -1,3 +1,7 @@ +/** + * 旧 schema_version=2 linked-board 快照的兼容与投影测试。schema_version、headless 文档格式和增量协议版本不是同一编号。 + * 检查展开分区、曲线/样式/执行配置、owner 投影与最低共同祖先;不把任意远程 AI 请求实际执行。 + */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { decodeCanvas, emptyCanvas, makeCanvasNode } from '../src/lib/canvas.ts'; @@ -5,7 +9,9 @@ import { curveSegments, cubicPoint, insertCurveKnot } from '../src/lib/canvas-cu import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData } from '../src/lib/canvas-tree.ts'; import { resizeFromPointer } from '../src/lib/canvas-geometry.ts'; import { managedImageSource } from '../src/lib/canvas-v2.ts'; +// 固定子 board 身份,投影时与符号节点 ID 组合,避免把 renderer ID 当作持久节点身份。 const childId = '11111111-1111-4111-8111-111111111111'; +// 大尺寸 frame 引用子 board 和输出 port,父文字含 AI 配置,边包含完整曲线/动画/marker 数据。 function fixture() { const frame = makeCanvasNode('frame', {x:100,y:100}); frame.id='frame'; frame.width=5000; frame.data={...frame.data,subgraph:{boardId:childId},presentation:{collapsed:false,contentScale:.2,expandedSize:{width:5000,height:400},collapsedSize:{width:240,height:140}},ports:[{id:'output',direction:'output',nodeId:'inside',handle:'right'}]}; @@ -14,33 +20,40 @@ function fixture() { const edge={id:'edge',source:'frame',target:'outside',sourceHandle:'port:output',targetHandle:'left',data:{appearance:{color:'#123456',width:2,line:'dashed',head:{type:'triangle',filled:false,size:12},tail:{type:'circle',size:8}},animation:{mode:'executing',effect:'flow',direction:'forward',durationMs:1200,color:'#ffffff'},path:{type:'bezier-chain',knots:[{id:'k',position:{x:700,y:150},in:{x:-30,y:0},out:{x:30,y:0},mode:'smooth'}]},execution:{enabled:true}}}; return {frame,text,inside,root:{...emptyCanvas(),schema_version:2,nodes:[frame,text],edges:[edge]},child:{...emptyCanvas(),schema_version:2,nodes:[inside]}}; } +// V2 JSON 往返保留 subgraph、AI 与全部边 data,且已解码对象再次解码结果稳定。 test('V2 roundtrip preserves linked frames, all curve/animation/marker data and AI config',()=>{ const {root}=fixture(); const decoded=decodeCanvas(JSON.stringify(root)); assert.deepEqual(decoded.edges[0].data,root.edges[0].data); assert.deepEqual(decoded.nodes[0].data.subgraph,root.nodes[0].data.subgraph); assert.deepEqual(decoded.nodes[1].data.ai,root.nodes[1].data.ai);assert.equal(decoded.nodes[0].width,5000); assert.deepEqual(decodeCanvas(JSON.stringify(decoded)),decoded); }); +// 逐项破坏平滑控制向量、动画、线宽、端口方向、普通元素尺寸或 AI 参数容器,解码均失败。 test('V2 rejects bad control data, wrong port directions and oversized ordinary elements',()=>{ for(const mutate of [d=>d.edges[0].data.path.knots[0].out.x=-30,d=>d.edges[0].data.animation.mode='random',d=>d.edges[0].data.appearance.width=100,d=>d.nodes[0].data.ports[0].direction='input',d=>d.nodes[1].width=5000,d=>d.nodes[1].data.ai.parameters=[]]){ const {root}=fixture();mutate(root);assert.throws(()=>decodeCanvas(JSON.stringify(root))); } }); +// 展开时子节点只投影一次且边解析到真实内部端点;折叠改画到 frame,不改父子原文档或子局部坐标。 test('expanded child nodes are projected once and boundary edges resolve to their actual nodes',()=>{ const {root,child}=fixture(), documents=new Map([['root',root],[childId,child]]);const view=projectCanvasTree('root',documents); assert.equal(view.nodes.length,3);const projected=view.nodes.find(n=>n.id===childId+'::inside');assert.deepEqual(projected.position,{x:126,y:140});assert.equal(projected.width,52);assert.equal(view.nodeRefs.get(projected.id).boardId,childId);assert.equal(view.edges[0].source,projected.id);assert.equal(root.nodes.length,2);assert.equal(child.nodes.length,1); root.nodes[0].data.presentation.collapsed=true;const collapsed=projectCanvasTree('root',documents);assert.equal(collapsed.nodes.length,2);assert.equal(collapsed.edges[0].source,'frame');assert.deepEqual(child.nodes[0].position,{x:10,y:20}); }); +// 边的点与向量经 owner→projected→owner 转换应精确返回原 data。 test('curve coordinates and vectors invert exactly between owner and projected canvases',()=>{ const {root}=fixture();const ref={boardId:'b',id:'e',origin:{x:100,y:-40},scale:.2};assert.deepEqual(transformEdgeData(transformEdgeData(root.edges[0].data,ref),ref,true),root.edges[0].data); }); +// 插入中点把一段 cubic 拆为两段;101 个参数样本与原曲线距离小于 1e-8,验证形状而非仅端点。 test('adding a curve point preserves both halves of the original cubic',()=>{ const from={point:{x:0,y:0},normal:{x:1,y:0}},to={point:{x:500,y:200},normal:{x:-1,y:0}}; const old=curveSegments(from,to)[0], split=insertCurveKnot(from,to,undefined), next=curveSegments(from,to,split.path); assert.equal(next.length,2);for(let i=0;i<=100;i++){const t=i/100,a=cubicPoint(old,t),b=cubicPoint(next[t<=.5?0:1],t<=.5?t*2:(t-.5)*2);assert.ok(Math.hypot(a.x-b.x,a.y-b.y)<1e-8);} }); +// 远程生成文字可与本地几何三方合并,但同字段本地文字冲突必须抛 version_conflict。 test('AI remote output merges with local geometry but conflicts with locally edited text',()=>{ const base=fixture().child,local=structuredClone(base),remote=structuredClone(base);local.nodes[0].position={x:200,y:300};remote.nodes[0].data.text='generated'; const merged=mergeCanvasDocuments(base,local,remote);assert.deepEqual(merged.nodes[0].position,{x:200,y:300});assert.equal(merged.nodes[0].data.text,'generated');local.nodes[0].data.text='user typing';assert.throws(()=>mergeCanvasDocuments(base,local,remote),/version_conflict/); }); +// 五类节点乘八方向在显式比例锁定时保持原比例;自由模式仅右侧拉伸不改图高,frame 可大于普通卡片上限。 test('eight-direction resize is free by default in resize mode and Shift locks the aspect',()=>{ for(const kind of ['plain-text','plain-image','text','image','frame']){ const node=makeCanvasNode(kind,{x:10,y:20});const oldRatio=node.width/node.height; @@ -51,10 +64,12 @@ test('eight-direction resize is free by default in resize mode and Shift locks t const image=makeCanvasNode('plain-image',{x:0,y:0});assert.equal(resizeFromPointer(image,'right',{x:100,y:0},false).height,image.height); const frame=makeCanvasNode('frame',{x:0,y:0});assert.ok(resizeFromPointer(frame,'right',{x:10000,y:0},false).width>3000); }); +// 受管资产显示转认证同源代理,普通外部 URL 保留;这里只检查地址转换,不验证实际认证请求。 test('managed assets render through the authenticated same-origin proxy',()=>{ assert.equal(managedImageSource('https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/a'),'/api/foundry/workspaces/w/assets/a');assert.equal(managedImageSource('https://example.com/a.png'),'https://example.com/a.png'); }); +// 连线持久 owner 是最低共同祖先,事务参与者恰为两条 owner 路径的并集;祖先本身不需要重复登记。 test('connection plan uses the lowest common ancestor and exactly both ownership paths',async()=>{ const {connectionTransactionPlan}=await import('../src/lib/canvas-subgraphs.ts');const {root,child}=fixture(); const nested='33333333-3333-4333-8333-333333333333';const frame=makeCanvasNode('frame',{x:0,y:0});frame.data.subgraph={boardId:nested};child.nodes.push(frame); @@ -63,10 +78,12 @@ test('connection plan uses the lowest common ancestor and exactly both ownership assert.equal(result.boardId,'root');assert.deepEqual(new Set(result.participants),new Set(['root',childId,nested]));assert.equal(result.source.node_id,'a'); const inner=connectionTransactionPlan(docs,{boardId:nested,id:'a',handle:'bottom'},{boardId:childId,id:'b',handle:'top'});assert.equal(inner.boardId,childId);assert.deepEqual(new Set(inner.participants),new Set([childId,nested])); }); +// 旧 schema 或 subgraph owner 绑定不同的草稿不能恢复到已迁移 V2;等价深拷贝才可作为候选。 test('incompatible drafts cannot overwrite migrated V2 ownership',async()=>{ const {canRestoreCanvasDraft}=await import('../src/lib/canvas-sync.ts');const {root}=fixture();const old={...structuredClone(root),schema_version:1};assert.equal(canRestoreCanvasDraft(old,root),false); const changed=structuredClone(root);changed.nodes[0].data.subgraph.boardId='55555555-5555-4555-8555-555555555555';assert.equal(canRestoreCanvasDraft(changed,root),false);assert.equal(canRestoreCanvasDraft(structuredClone(root),root),true); }); +// 同时拖选父 frame 与 child 的投影结果,只应用共同位移一次,反推 owner 局部坐标保持原值。 test('selecting both parent frame and child applies a shared drag once',async()=>{ const {referenceAfterMoves}=await import('../src/lib/canvas-tree.ts');const {root,child}=fixture();const docs=new Map([['root',root],[childId,child]]),view=projectCanvasTree('root',docs); const projected=view.nodes.find(node=>node.id===childId+'::inside');const delta={x:50,y:80};const moved={x:projected.position.x+delta.x,y:projected.position.y+delta.y}; diff --git a/tests/canvas.test.mjs b/tests/canvas.test.mjs index cf96e69..3e98e49 100644 --- a/tests/canvas.test.mjs +++ b/tests/canvas.test.mjs @@ -1,7 +1,12 @@ +/** + * 旧画布快照 reducer 的基本历史、节点/边、序列化与分支规则测试。past/present/future 是旧版完整快照结构。 + * 这里的 checkpoint/replace 不能当作 headless 操作账本、条件增量请求或项目恢复协议的验证。 + */ import { test } from "node:test"; import assert from "node:assert/strict"; import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; +// 构造两张不同卡片及一条边,返回空 past/future 的独立历史,用于比较编辑前后内容和 viewport。 function seeded() { const document = emptyCanvas(); document.nodes = [makeCanvasNode("text", { x: 20, y: 30 }), makeCanvasNode("script", { x: 400, y: 30 })]; @@ -9,6 +14,7 @@ function seeded() { return { past: [], present: document, future: [] }; } +// checkpoint 后拖动形成一个历史起点;undo/redo 改内容位置但保留当前 viewport,视图导航不跟着旧快照回退。 test("node dragging updates its position; undo and redo retain the current viewport", () => { let state = seeded(); const id = state.present.nodes[0].id; @@ -23,6 +29,7 @@ test("node dragging updates its position; undo and redo retain the current viewp assert.deepEqual(state.present.nodes[0].position, { x: 220, y: -30 }); }); +// 删节点连同附边一起删除,随后重复边删除通知不应再追加历史;一次 undo 恢复节点与边。 test("deleting a node removes attached edges and undo restores both", () => { const initial = seeded(); const removed = canvasReducer(initial, { type: "nodes", changes: [{ id: initial.present.nodes[0].id, type: "remove" }] }); @@ -35,6 +42,7 @@ test("deleting a node removes attached edges and undo restores both", () => { assert.equal(restored.present.edges.length, 1); }); +// 同一 checkpoint 内多次文字修改只留一个 past,undo 回到开始编辑前文字。 test("a text editing session is undoable as one operation", () => { let state = seeded(); const id = state.present.nodes[0].id; @@ -45,6 +53,7 @@ test("a text editing session is undoable as one operation", () => { assert.equal(canvasReducer(state, { type: "undo" }).present.nodes[0].data.text, ""); }); +// JSON 往返保留文本、图片、连线与 viewport,selected 作为临时 UI 字段不恢复。 test("export/import retains nodes, edges, text, images and the viewport", () => { const document = seeded().present; const image = makeCanvasNode("image", { x: 80, y: 90 }, "data:image/png;base64,YQ=="); @@ -60,6 +69,7 @@ test("export/import retains nodes, edges, text, images and the viewport", () => assert.deepEqual(restored.viewport, document.viewport); }); +// 悬空端点、重复 ID、不安全图片 URL 和非法 zoom 分别构造非法输入,任一项都使整个 decode 失败。 test("import rejects dangling connections, duplicate nodes, unsafe images and invalid zoom", () => { for (const mutate of [ (doc) => { doc.edges[0].target = "missing"; }, @@ -73,11 +83,13 @@ test("import rejects dangling connections, duplicate nodes, unsafe images and in } }); +// 旧草稿 key 包含 workspace 与 board,不能因同名图谱跨作用域覆盖。 test("draft storage is isolated by workspace and graph", () => { assert.notEqual(canvasStorageKey("workspace-a", "board"), canvasStorageKey("workspace-b", "board")); assert.notEqual(canvasStorageKey("workspace", "board-a"), canvasStorageKey("workspace", "board-b")); }); +// 50 次 replace 后 past 受限为 40;undo 产生 redo,新 add 必须清空 redo。 test("undo history is bounded and new edits clear the redo stack", () => { let state = seeded(); for (let i = 0; i < 50; i++) state = canvasReducer(state, { type: "replace", document: { ...state.present, edges: [] } }); @@ -89,6 +101,7 @@ test("undo history is bounded and new edits clear the redo stack", () => { }); +// 四方向分支检查相对位置、选中切换、source/target ID 和相反 handle;快照可恢复,整组可撤销。 test("selected nodes can branch in all four directions with matching connection handles", () => { const opposite = { top: "bottom", right: "left", bottom: "top", left: "right" }; for (const direction of Object.keys(opposite)) { @@ -114,6 +127,7 @@ test("selected nodes can branch in all four directions with matching connection } }); +// 同侧重复添加要避开已有节点,第二个新节点不能占据第一次相同位置。 test("repeated additions on the same side avoid existing nodes", () => { const document = emptyCanvas(); const parent = makeCanvasNode("text", { x: 0, y: 0 }); diff --git a/tests/session-cookie.test.mjs b/tests/session-cookie.test.mjs index 1242324..e1846ff 100644 --- a/tests/session-cookie.test.mjs +++ b/tests/session-cookie.test.mjs @@ -1,7 +1,12 @@ +/** + * 会话 cookie 的期限与属性单元测试。直接调用格式化函数,不连接浏览器 cookie jar 或真实认证后端。 + * 后端剩余期限与前端 30 天上限取有效结果;数值 0 表示立即过期,非法输入回退默认值。 + */ import { test } from "node:test"; import assert from "node:assert/strict"; import { sessionCookie, sessionMaxAge, SESSION_IDLE_SECONDS } from "../src/lib/session-cookie.ts"; +// 核对秒数常量、可解析整数字符串与最大期限;0 不可误回退,负数、非有限值、非整数等使用默认 30 天。 test("session cookies respect backend remaining lifetime and a 30-day maximum", () => { assert.equal(SESSION_IDLE_SECONDS, 2592000); assert.equal(sessionMaxAge(2592000), 2592000); @@ -12,6 +17,7 @@ test("session cookies respect backend remaining lifetime and a 30-day maximum", for (const invalid of [Infinity, "garbage", "1.5", NaN, {}, null]) assert.equal(sessionMaxAge(invalid), 2592000); }); +// 只检查生成的 Set-Cookie 文本:后端期限、HttpOnly、SameSite=Lax、可选 Secure 和 Max-Age=0;不代表端到端会话续期已验证。 test("renewed cookies retain HttpOnly/SameSite and use the backend-capped deadline", () => { const cookie = sessionCookie("session-token", 42, true); assert.match(cookie, /Max-Age=42;/); assert.match(cookie, /HttpOnly/); assert.match(cookie, /SameSite=Lax/); assert.match(cookie, /; Secure$/); diff --git a/tests/workspace.test.mjs b/tests/workspace.test.mjs index 1079820..b542f77 100644 --- a/tests/workspace.test.mjs +++ b/tests/workspace.test.mjs @@ -1,7 +1,12 @@ +/** + * workspace 首页、board 创建/重命名与 URL 的请求契约单元测试。request 替身记录调用次序和 payload,不运行 HTTP 或数据库。 + * 首次创建 workspace 与 board 不是一个服务端事务:workspace 成功后需先通知调用者保留身份,即使后续 board 失败。 + */ import { test } from "node:test"; import assert from "node:assert/strict"; import { boardHref, createBoard, renameBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome } from "../src/lib/workspace.ts"; +// 把两个请求都挂起后检查启动记录,证明 profile/workspaces 无串行瀑布;分别释放再检查结果整合和头像。 test("profile and workspace reads both start before either completes", async () => { const calls = []; const resolve = {}; @@ -20,6 +25,7 @@ test("profile and workspace reads both start before either completes", async () assert.equal(result.workspaces[0].id, "space"); }); +// 没有 workspace 时先创建个人空间并回调选中,再提交 board;标题去首尾空白,不能在通知调用者前继续第二步。 test("a first board creates its personal workspace before posting the board", async () => { const calls = []; let selected = ""; @@ -44,6 +50,7 @@ test("a first board creates its personal workspace before posting the board", as assert.equal(calls[1][1].title, "周末的灵感"); }); +// 第一次 board 失败仍保留回调给出的 workspace ID;重试只创建 board,个人空间计数仍为一。 test("a board failure retains the new workspace, so retry creates no duplicate space", async () => { let selected = ""; let spaces = 0; @@ -68,6 +75,7 @@ test("a board failure retains the new workspace, so retry creates no duplicate s assert.equal(spaces, 1); }); +// 已有 workspace 直接向其 materials 提交,创建空间回调不得执行。 test("an existing workspace posts a board directly", async () => { const calls = []; await createBoard( @@ -83,6 +91,7 @@ test("an existing workspace posts a board directly", async () => { }); +// 空白名称回退默认图谱标题,payload 明确 kind/content schema;返回身份采用服务分配 ID,不能另造本地替代 ID。 test("new graphs use a default name and retain the Foundry-assigned identity", async () => { const created = await createBoard(async (path, options) => { assert.equal(path, "workspaces/space/materials"); @@ -97,12 +106,14 @@ test("new graphs use a default name and retain the Foundry-assigned identity", a assert.equal(boardHref(created.workspace_id, created.id), "/dashboard/space/server-generated-id"); }); +// workspace 和 board 分段编码,特殊斜线/问号不能改变路由结构;demo 查询参数单独追加。 test("graph URLs preserve workspace identity and safely encode route segments", () => { assert.equal(boardHref("space/a", "id?b"), "/dashboard/space%2Fa/id%3Fb"); assert.equal(boardHref("demo", "board", true), "/dashboard/demo/board?demo=1"); }); +// 重命名前读取最新版本,以 If-Match=7 PATCH,保留 description/content;响应为空时 helper 合成标题及 version=8 返回值,非服务端实际版本证明。 test("renaming reads the latest version and preserves description and content", async () => { const calls = []; const content = { schema_version: 1, preserved: { key: "value" } }; @@ -119,12 +130,14 @@ test("renaming reads the latest version and preserves description and content", assert.equal(renamed.version, 8); }); +// 空白或超过 100 字符的标题在请求前拒绝;用 assert.fail 确保没有任何 API 调用。 test("rename rejects empty or oversized names without any API calls", async () => { for (const name of [" ", "字".repeat(101)]) { await assert.rejects(renameBoard(() => assert.fail("unexpected API call"), "space", "board", name), /invalid_title/); } }); +// PATCH 版本冲突原样抛出且只调用一次,不能自动重试覆盖新内容。 test("rename surfaces a version conflict without retrying or overwriting", async () => { let patches = 0; await assert.rejects(renameBoard(async (_path, options) => {