diff --git a/PERFORMANCE.md b/PERFORMANCE.md index d30884a..d496dfe 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -94,3 +94,11 @@ 改动将 members 同步改为局部状态和串行事务队列;自动保存暂缓,其他元素本地编辑继续,成功响应与持久化基线做三方合并后再保存编辑。Core PR #16 增加源画布局部坐标的 positions 字段及 member_positions 能力,坐标和归属在一个事务保存,支持时省掉前置 PATCH。版本及幂等保护保留。DOM/Hook 延迟响应、丢失响应和版本冲突场景已验证;尚未将模拟的一次 POST 当作线上耗时改善。 Core PR #16 已由用户合并为 2207c85;push #946、PR #947、主线 #949、部署 #950 均成功。前端 a240507 已在 main。23:13:30(Asia/Shanghai)开始安排指定父图 icon 拖放的真实验收;需要刷新以重新检测能力,实测结果待记录核对后补充。 + +## 首次上传及能力协商复核 + +23:15–23:16 的真实日志中,新 JPEG 原图 393,301 字节、1280×2276,上传约 8.5 秒;其预览 125,545 字节,首响应等待 5,913.9ms,正文传输 748.1ms,总计 6,662ms。同期 profile/workspaces/小快照也出现约 5–7 秒等待首响应,不能将全部时间归因于图片处理。随后无认证公网健康探测也复现秒级等待:curl 约 1.73/4.24 秒;Node 新建连接 DNS 20ms、TCP 累计 200.4ms、TLS 累计 3,574.6ms、总计 4,808.3ms,同连接复用请求仅 275.4/187.1ms。ECS 内部健康请求约 120.35/13.92/1.81ms。该对照表明冷连接/公网路径存在明显成本,但不是历史每个请求的同期分段追踪,不能声称已定位全部 8.5 秒或修复公网丢包。 + +能力协商 2 秒截止与约 1,989ms 的服务端路由耗时接近,且真实三次移动前仍有仅 position 变化的 PATCH。现移除短截止并禁止把网络错误永久缓存为“不支持”;慢响应和失败预取回归均保持原子成员移动。后端三笔事务各移动 1 个节点,总数、非位置内容与版本递增均正确。 + +首次上传改为本地即时预览 + 后台原图上传,服务端确认后保存引用,远端预览加载完才切换显示。该改动消除空画布等待,不将即时显示误报为网络上传已完成或服务端延迟已消失。实际交互复测待用户操作后补充。 diff --git a/README.md b/README.md index d1cbc3a..e957b14 100644 --- a/README.md +++ b/README.md @@ -134,3 +134,5 @@ Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVA 拖放后元素保持落位,旁边显示局部同步状态;其他元素仍可编辑和拖动。移动中的节点及分区几何暂时受保护,归属事务排队使用最新版本。期间的本地编辑在响应到达后合并,再自动保存。响应丢失时重试复用同一请求和幂等键;明确版本冲突则先重读版本,避免重复移动或覆盖其他编辑。 Core `subgraphs.member_positions` 能力启用时,最终落点(源画布局部坐标)与归属通过一次 members POST 原子保存,单纯拖放不再先 PATCH 坐标。旧后端仍使用先保存坐标再迁移的兼容流程,但不显示整屏等待遮罩。Core 配套 PR #16 已合并并部署为 2207c85;刷新页面后会重新检测并启用新接口能力。 + +上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。 diff --git a/VALIDATION.md b/VALIDATION.md index b5d2f78..1f41e2c 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -62,3 +62,9 @@ - TypeScript 和 83 项单元测试通过。新增 DOM/Hook 场景覆盖:原子成员移动一次 POST/无前置 PATCH、延迟响应期间编辑和拖动其他节点、快照合并保留编辑、撤销/重做保留单一归属、丢失响应以相同 body/key 重试、412 重读版本后新命令重试、连续跨分区移动排队、旧后端兼容。 - 实际 ReactFlow DOM 手势验证 icon 跨分区后出现元素旁同步提示,无整屏同步遮罩;其他元素可继续拖动,服务器确认后位置不回退。既有 AI、连线、框、图片回归通过。无浏览器访问(沿用用户要求)。 - 最终隔离生产构建通过。Core push #946、PR #947、主线 #949、部署 #950 均成功,包含 Go 测试/构建、数据库迁移和 PostgreSQL 集成测试;PR #16 已合并为 2207c85 并部署。Core 不在本机编译。真实 icon 拖放验收仍待用户操作后的记录核对,不将发布成功当作实际性能实测。 + +## 慢能力响应与首次上传 + +生产复核发现 capabilities 请求约 1,989ms,原先 2 秒客户端截止可能误将慢响应缓存为不支持。已删除该短截止,网络错误清除缓存并重试;只有成功响应明确未声明能力才走旧流程。DOM 回归增加 2.1 秒能力响应、预取失败后重试的场景,均保持原子移动、无前置 PATCH。 + +上传 DOM 回归覆盖:网络未确认前本地图片已显示、可添加其他内容、快照没有 blob URL、原始附件引用与 assetId 正确、远端预览加载后释放本地 URL、失败保留文件、同键重试只创建一个节点、取消/卸载清理与原图比例。TypeScript、83 项单元测试与隔离生产构建通过。生产新上传的交互体验及修正后的能力协商仍需用户刷新后实测。 diff --git a/src/components/canvas/canvas-image.tsx b/src/components/canvas/canvas-image.tsx new file mode 100644 index 0000000..1bb4cb9 --- /dev/null +++ b/src/components/canvas/canvas-image.tsx @@ -0,0 +1,9 @@ +"use client"; +import { createContext, useContext } from "react"; +export const ImagePreviews = createContext<{ sources: Map; loaded: (id: string) => void }>({ sources: new Map(), loaded: () => {} }); +export function CanvasImage({ id, src, title, className }: { id: string; src?: string; title: string; className: string }) { + const previews = useContext(ImagePreviews), local = previews.sources.get(id); + const remote = {title} previews.loaded(id)} />; + if (!local || local === src) return remote; + return
{remote}
; +} diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index 5cd5177..845f488 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -1,5 +1,7 @@ "use client"; -import { uploadCanvasImage } from "@/lib/canvas-assets"; +import { useImageUploads } from "./use-image-uploads"; +import { ImagePreviews } from "./canvas-image"; +import { displayID } from "@/lib/canvas-tree"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { @@ -59,7 +61,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap const [tool, setTool] = useState<"select" | "hand">("select"); const [error, setError] = useState(initial.error); const [branchRequest, setBranchRequest] = useState(null); - const [imageUploading, setImageUploading] = useState(false); + const [demoImageUploading, setImageUploading] = useState(false); const [iconPicker, setIconPicker] = useState(false); const [resizing, setResizing] = useState(null); const [rotating, setRotating] = useState(null); @@ -82,6 +84,16 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree); const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } }; const treeRef = useRef(tree); treeRef.current = tree; + const uploads = useImageUploads(board.workspace_id, (job, asset) => { + const current = treeRef.current, document = current.current.current.get(job.owner); + if (!document || current.busy || current.rootDocument.schema_version < document.schema_version) throw new Error("canvas_busy"); + if (document.nodes.length >= 500) throw new Error("当前画布已达到 500 个节点。"); + if (job.edge && !document.nodes.some((node) => node.id === job.edge!.source)) throw new Error("源节点已移动或删除,请重新添加图片。"); + const node = { ...job.node, data: { ...job.node.data, src: asset.url, ...(document.schema_version === 2 ? { assetId: asset.id } : {}) } }; + current.dispatch({ type: "replace", document: { ...document, nodes: [...document.nodes, node], edges: [...document.edges, ...(job.edge ? [job.edge] : [])] } }, job.owner); + if (!current.current.current.get(job.owner)?.nodes.some((item) => item.id === node.id)) throw new Error("canvas_busy"); + }); + const imageUploading = demoImageUploading || uploads.active; function dispatch(action: CanvasAction) { if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; } treeRef.current.dispatch(action); @@ -93,7 +105,14 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap return () => input?.removeEventListener("cancel", cancel); }, []); const { nodes: displayNodes, edges } = state.present; - 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 pendingUploadNodes = uploads.jobs.filter((job) => job.status !== "uploaded").flatMap((job) => { + const ref = tree.views.boards.get(job.owner); if (!ref) return []; + return [{ ...job.node, id: displayID(board.id,job.owner,job.node.id), position: { x: ref.origin.x+job.node.position.x*ref.scale,y:ref.origin.y+job.node.position.y*ref.scale }, width:job.node.width!*ref.scale,height:job.node.height!*ref.scale,style:{...job.node.style,width:job.node.width!*ref.scale,height:job.node.height!*ref.scale},selected:false,draggable:false,selectable:false,connectable:false,deletable:false }]; + }); + 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; }), ...pendingUploadNodes]; + const uploadDisplayID = (job: typeof uploads.jobs[number]) => job.status === "uploaded" ? [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id) : displayID(board.id,job.owner,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); } }; + const frameFeedback = useMemo(() => { const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title])); const counts = new Map(); @@ -292,10 +311,23 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap } const request = pendingImage.current; pendingImage.current = null; const kind = imageKind.current; + if (snapshot) { + try { uploads.start(file, (src) => { + const current = treeRef.current; + if (request) { + const ref = current.views.nodeRefs.get(request.id); if (!ref) throw new Error("源节点已移动或删除,请重新添加图片。"); + const draft = branchCanvasNode(current.current.current.get(ref.boardId)!,ref.id,request.direction,"image",src,request.at ? {x:(request.at.x-ref.origin.x)/ref.scale,y:(request.at.y-ref.origin.y)/ref.scale}:undefined); + return {owner:ref.boardId,node:draft.nodes.at(-1)!,edge:draft.edges.at(-1)}; + } + if(current.rootDocument.nodes.length + uploads.jobs.filter((job)=>job.owner===board.id&&job.status!=="uploaded").length>=500)throw new Error("当前画布已达到 500 个节点。"); + const node=makeCanvasNode(kind,center(),src);if(kind==='plain-image')node.data.title=file.name.slice(0,300); + return {owner:board.id,node}; + });setTool("select"); } catch(error) {setError(errorText(error));} + return; + } setImageUploading(true); try { - const uploaded = snapshot ? await uploadCanvasImage(api, board.workspace_id, file, file.name) : null; - const src = uploaded?.url || await new Promise((resolve, reject) => { + const src = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error("无法读取图片。")); @@ -305,11 +337,9 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap if (kind === "plain-image" && !request) { size = await new Promise((resolve, reject) => { const image = new window.Image(); - const localSource = uploaded ? URL.createObjectURL(file) : src; - const clean = () => { if (uploaded) URL.revokeObjectURL(localSource); }; - image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); clean(); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); }; - image.onerror = () => { clean(); reject(new Error("无法读取图片尺寸。")); }; - image.src = localSource; + image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); }; + image.onerror = () => reject(new Error("无法读取图片尺寸。")); + image.src = src; }); } if (request) createBranch(request, "image", src); else addNode(kind, src, size, kind === "plain-image" ? file.name : undefined); @@ -362,12 +392,14 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap return (
+ ai.activeEdges.has(ref.boardId + "/" + ref.id)).map(([id]) => id)) }}> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} onBeforeDelete={async ({ nodes: removing }) => { if (tree.busy || tree.transactionBusy) return false; + if (removing.some((node) => { const ref=tree.views.nodeRefs.get(node.id);return uploads.jobs.some((job)=>job.status!=="uploaded"&&(node.data.subgraph?.boardId===job.owner||ref?.boardId===job.owner&&ref?.id===job.edge?.source)); })) return false; const linked = removing.filter((node) => node.data.subgraph); const parents = subgraphParents(tree.current.current); const referenced = removing.filter((node) => { @@ -435,6 +467,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap +
dispatch({ type: "undo" })} /> @@ -479,7 +512,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap

⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。

可通过右上角导出 JSON 备份。

- {imageUploading &&

正在上传图片…

} + {demoImageUploading &&

正在读取图片…

} + {uploads.jobs.filter((job)=>job.status!=="uploaded").map((job)=>
{job.status==="failed"?<>上传未完成 :"上传中…"}
)} {iconPicker && setIconPicker(false)} choose={(name, label) => { if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; } diff --git a/src/components/canvas/material-node.tsx b/src/components/canvas/material-node.tsx index 12fb013..167b7c4 100644 --- a/src/components/canvas/material-node.tsx +++ b/src/components/canvas/material-node.tsx @@ -1,4 +1,5 @@ "use client"; +import { CanvasImage } from "./canvas-image"; import { managedImageSource } from "@/lib/canvas-v2"; import { useContext, useRef } from "react"; @@ -26,7 +27,7 @@ export function MaterialNode(props: NodeProps) {
- {data.kind === "image" ? {data.title} : ( + {data.kind === "image" ? : ( )} diff --git a/src/components/canvas/plain-image-node.tsx b/src/components/canvas/plain-image-node.tsx index 9474a6f..0fa42db 100644 --- a/src/components/canvas/plain-image-node.tsx +++ b/src/components/canvas/plain-image-node.tsx @@ -1,4 +1,5 @@ "use client"; +import { CanvasImage } from "./canvas-image"; import { managedImageSource } from "@/lib/canvas-v2"; import { useViewport, type NodeProps } from "@xyflow/react"; import type { CanvasNode } from "@/lib/canvas"; @@ -7,5 +8,5 @@ import styles from "./canvas.module.css"; export function PlainImageNode(props: NodeProps) { const { zoom } = useViewport(); const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024; - return {props.data.title}; + return ; } diff --git a/src/components/canvas/use-canvas-tree.ts b/src/components/canvas/use-canvas-tree.ts index 483eddf..0854e43 100644 --- a/src/components/canvas/use-canvas-tree.ts +++ b/src/components/canvas/use-canvas-tree.ts @@ -117,8 +117,18 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo for (const [boardId, document] of current.current) update(boardId, { type: "nodes", changes: document.nodes.map((node) => ({ type: "select", id: node.id, selected: view.current.nodeRefs.get(id)?.boardId === boardId && view.current.nodeRefs.get(id)?.id === node.id })) }); }; const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal })); + function supportsMemberPositions(): Promise { + if (!snapshot) return Promise.resolve(false); + if (!memberCapability.current) { + const capability: Promise = api<{ subgraphs?: { member_positions?: boolean } }>(canvasPath(workspace, root) + "/capabilities", { headers: { Accept: CANVAS_ACCEPT } }) + .then((value) => value.subgraphs?.member_positions === true) + .catch((error) => { if (memberCapability.current === capability) memberCapability.current = null; throw error; }); + memberCapability.current = capability; + } + return memberCapability.current; + } useEffect(() => { - memberCapability.current = snapshot ? api<{ subgraphs?: { member_positions?: boolean } }>(canvasPath(workspace, root) + "/capabilities", { headers: { Accept: CANVAS_ACCEPT }, signal: AbortSignal.timeout(2000) }).then((value) => value.subgraphs?.member_positions === true).catch(() => false) : Promise.resolve(false); + void supportsMemberPositions().catch(() => { /* A failed prefetch is retried on demand, never cached as unsupported. */ }); }, [root, workspace, !!snapshot]); async function ensure(boardId: string, recursive = false, ancestors = new Set()): Promise { if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle"); @@ -266,7 +276,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo for (const [id, remote] of refreshed) install(id, remote); publish(next); job.refresh = false; } - const atomic = await memberCapability.current; + const atomic = await supportsMemberPositions(); await flush(atomic === true); const bases = new Map(job.participants.map((id) => [id, writers.current.get(id)!.document])); const source = current.current.get(job.source)!; diff --git a/src/components/canvas/use-image-uploads.ts b/src/components/canvas/use-image-uploads.ts new file mode 100644 index 0000000..6efd431 --- /dev/null +++ b/src/components/canvas/use-image-uploads.ts @@ -0,0 +1,46 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { api, errorText } from "@/lib/api"; +import { uploadCanvasImage, type CanvasAsset } from "@/lib/canvas-assets"; +import type { CanvasDocument, CanvasNode } from "@/lib/canvas"; +export type ImageUploadDraft = { owner: string; node: CanvasNode; edge?: CanvasDocument["edges"][number] }; +export type ImageUploadJob = ImageUploadDraft & { file: File; localURL: string; key: string; status: "uploading" | "failed" | "uploaded"; error?: string; controller: AbortController; ready?: Promise }; + +/** Local previews never enter the canvas document or an autosave payload. */ +export function useImageUploads(workspace: string, commit: (draft: ImageUploadDraft, asset: CanvasAsset) => void) { + const pending = useRef(new Map()); + const [jobs, setJobs] = useState([]); + const mounted = useRef(true), onCommit = useRef(commit); onCommit.current = commit; + const changed = () => { if (mounted.current) setJobs([...pending.current.values()]); }; + function remove(id: string) { const job = pending.current.get(id); if (!job) return; job.controller.abort(); URL.revokeObjectURL(job.localURL); pending.current.delete(id); changed(); } + useEffect(() => { mounted.current = true; return () => { mounted.current = false; for (const job of pending.current.values()) { job.controller.abort(); URL.revokeObjectURL(job.localURL); } pending.current.clear(); }; }, []); + async function run(job: ImageUploadJob) { + if (job.status === "uploading") return; + job.status = "uploading"; job.error = undefined; job.controller = new AbortController(); changed(); + try { + const [asset] = await Promise.all([uploadCanvasImage(api, workspace, job.file, job.file.name, job.controller.signal, job.key), job.ready || Promise.resolve()]); + if (!mounted.current || pending.current.get(job.node.id) !== job || job.controller.signal.aborted) return; + onCommit.current(job, asset); job.status = "uploaded"; changed(); + } catch (error) { if (mounted.current && pending.current.get(job.node.id) === job && !job.controller.signal.aborted) { job.status = "failed"; job.error = errorText(error); changed(); } } + } + function start(file: File, draft: (localURL: string) => ImageUploadDraft) { + const localURL = URL.createObjectURL(file); + let value: ImageUploadDraft; + try { value = draft(localURL); } catch (error) { URL.revokeObjectURL(localURL); throw error; } + const job: ImageUploadJob = { ...value, file, localURL, key: crypto.randomUUID(), status: "failed", controller: new AbortController() }; + pending.current.set(job.node.id, job); changed(); + // Browser decoding runs locally, concurrently with the original-byte upload. + if (job.node.data.kind === "plain-image") { + job.ready = new Promise((resolve,reject) => { + const image = new window.Image(); image.onload = () => { + if (pending.current.get(job.node.id) !== job) { resolve(); return; } + const scale = Math.min(1,480/image.naturalWidth,480/image.naturalHeight); + const size = { width: Math.max(24,Math.round(image.naturalWidth*scale)), height: Math.max(24,Math.round(image.naturalHeight*scale)) }; + job.node = { ...job.node, ...size, style: { ...job.node.style, ...size } }; changed(); resolve(); + }; image.onerror = () => reject(new Error("无法读取图片尺寸。")); image.src = localURL; + }); + } + void run(job); + } + return { jobs, active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } }; +} diff --git a/src/lib/canvas-assets.ts b/src/lib/canvas-assets.ts index 57042ac..076ba66 100644 --- a/src/lib/canvas-assets.ts +++ b/src/lib/canvas-assets.ts @@ -1,10 +1,9 @@ type Request = (path: string, options?: RequestInit) => Promise; export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string }; export const ASSET_LIMIT = 20 * 1024 * 1024; -export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal): Promise { +export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal, key = crypto.randomUUID()): Promise { if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large"); const form = new FormData(); form.append("image", file, name); - const key = crypto.randomUUID(); const send = () => request(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal }); let asset: CanvasAsset; try { asset = await send(); } catch (error) { diff --git a/tests/canvas-member-scenario.mjs b/tests/canvas-member-scenario.mjs index a8a972c..87c55eb 100644 --- a/tests/canvas-member-scenario.mjs +++ b/tests/canvas-member-scenario.mjs @@ -5,18 +5,22 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='frame';frame.data.subgraph={boardId:child}; const icon=makeCanvasNode('icon',{x:10,y:20});icon.id='moving'; const other=makeCanvasNode('text',{x:950,y:100});other.id='other'; - let records,tree,posts,saves,release,mode,receipt,key,body,capability=true; + let records,tree,posts,saves,release,mode,receipt,key,body,capability=true,capMode="",capCalls=0; const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}); function Fixture(){tree=useCanvasTree(parent,'w',records.get(parent).document,records.get(parent));return null;} const reset=async(nextMode='success')=>{ await act(async()=>{root.render(null);await tick();});localStorage.clear(); records=new Map([[parent,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(frame),structuredClone(other)]}}],[child,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(icon)]}}]]); - posts=[];saves=[];release=null;mode=nextMode;receipt=null;key=null;body=null; + posts=[];saves=[];release=null;mode=nextMode;receipt=null;key=null;body=null;capMode=nextMode;capCalls=0; if(mode==='queued'){const second=structuredClone(icon);second.id='moving-two';second.position={x:100,y:20};records.get(child).document.nodes.push(second);mode='delayed';} mock(async(url,options={})=>{ const parts=new URL(url,'http://localhost').pathname.split('/'),id=parts[6],suffix=parts.slice(8).join('/'),method=options.method||'GET'; if(suffix==='ai/runs')return json({runs:[]}); - if(suffix==='capabilities')return json({subgraphs:{member_positions:capability}}); + if(suffix==='capabilities'){ + capCalls++;if(capMode==='slowcap'){await new Promise(r=>setTimeout(r,2100));if(options.signal?.aborted)throw new DOMException('Aborted','AbortError');} + if(capMode==='cap-failure'&&capCalls===1)throw new TypeError('network unavailable'); + return json({subgraphs:{member_positions:capability}}); + } if(!suffix&&method==='GET')return json(structuredClone(records.get(id))); if(!suffix&&method==='PATCH'){ assert.equal(options.headers['If-Match'],`"${records.get(id).version}"`); @@ -40,7 +44,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN }); await act(async()=>{root.render(h(Fixture));await tick();await tick();}); }; - const wait=async(check,label)=>{for(let i=0;i<60;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);}; + const wait=async(check,label)=>{for(let i=0;i<120;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);}; const start=async()=>{ await act(async()=>{tree.select(child+'::moving');tree.setDragging(true);tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:950,y:200},dragging:false}]);tree.setDragging(false);void tree.moveMembers(parent,'frame',child,parent,['moving'],[parent,child]);await tick();}); await wait(()=>posts.length,'move admission'); @@ -78,6 +82,9 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN await act(async()=>{release();await tick();});await wait(()=>!tree.memberJobs.length,'queue drained'); assert.equal(saves.length,0);assert.equal(tree.rootDocument.nodes.filter(n=>n.id.startsWith('moving')).length,2);assert.equal(tree.current.current.get(child).nodes.length,0); console.log('PASS member queue: unrelated cross-frame drops serialize versions without extra coordinate PATCH'); + await reset('slowcap');await start();await wait(()=>!tree.memberJobs.length,'slow capability move');assert.equal(saves.length,0,'A slow capability response must not trigger legacy PATCH fallback'); + await reset('cap-failure');await start();await wait(()=>!tree.memberJobs.length,'capability retry');assert.equal(capCalls,2);assert.equal(saves.length,0,'A failed prefetch is retried rather than cached as unsupported'); + console.log('PASS member capabilities: responses slower than two seconds and transient prefetch errors retain atomic moves'); capability=false;await reset();await start();await wait(()=>!tree.memberJobs.length,'legacy move');assert.equal(saves.length,1,'Old backend retains the coordinate save');assert.equal(JSON.parse(posts[0].body).positions,undefined); console.log('PASS member compatibility: capability-gated positions and legacy two-request fallback'); capability=true;await reset('delayed'); diff --git a/tests/canvas-ui-check.mjs b/tests/canvas-ui-check.mjs index 77defb9..bd434eb 100644 --- a/tests/canvas-ui-check.mjs +++ b/tests/canvas-ui-check.mjs @@ -216,6 +216,8 @@ try { } const { canvasMemberScenario } = await import("./canvas-member-scenario.mjs"); await canvasMemberScenario({ root, act, h, useCanvasTree, GraphCanvas, event, window, makeCanvasNode, emptyCanvas, tick, mock: (handler) => { apiMock = handler; } }); + const { canvasUploadScenario } = await import("./canvas-upload-scenario.mjs"); + await canvasUploadScenario({ root, act, h, GraphCanvas, window, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } }); await act(async () => { root.unmount(); await tick(); }); } finally { await rm(folder, { recursive: true, force: true }); window.close(); stop(); diff --git a/tests/canvas-upload-scenario.mjs b/tests/canvas-upload-scenario.mjs new file mode 100644 index 0000000..faff2ca --- /dev/null +++ b/tests/canvas-upload-scenario.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyCanvas,event,tick,mock}) { + const parent='cccccccc-cccc-4ccc-8ccc-cccccccccccc',assetID='dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + const asset={id:assetID,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${assetID}`,width:640,height:1280,bytes:4,mime_type:'image/png'}; + let snapshot,uploads,saves,release,mode; + const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}); + const revoked=[],originalRevoke=URL.revokeObjectURL;URL.revokeObjectURL=(url)=>{revoked.push(url);originalRevoke(url);}; + const NativeImage=window.Image; + const wait=async(check,label)=>{for(let i=0;i<100;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);}; + async function reset(nextMode='delayed'){ + await act(async()=>{root.render(null);await tick();});localStorage.clear();snapshot={version:1,document:{...emptyCanvas(),schema_version:2}};uploads=[];saves=[];release=null;mode=nextMode; + mock(async(url,options={})=>{ + const parts=new URL(url,'http://localhost').pathname.split('/'),suffix=parts.slice(8).join('/'),method=options.method||'GET'; + if(parts.at(-1)==='assets'&&method==='POST'){ + const file=options.body.get('image');uploads.push({key:options.headers['Idempotency-Key'],bytes:Buffer.from(await file.arrayBuffer()),signal:options.signal}); + if(mode==='failed')return json({error:'forbidden'},403); + if(mode==='delayed')await new Promise(resolve=>{release=resolve;}); + return json(asset,201); + } + if(suffix==='capabilities')return json({subgraphs:{member_positions:true}}); + if(suffix==='ai/runs')return json({runs:[]}); + if(!suffix&&method==='PATCH'){ + assert.ok(!options.body.includes('blob:'),'Local object URLs must never be saved');assert.equal(options.headers['If-Match'],`"${snapshot.version}"`); + snapshot.document=JSON.parse(options.body).document;snapshot.version++;saves.push(structuredClone(snapshot.document));return json({version:snapshot.version}); + } + if(!suffix)return json(snapshot); + throw new Error('Unhandled upload request '+url); + }); + await act(async()=>{root.render(h(GraphCanvas,{board:{id:parent,workspace_id:'w',kind:'board',title:'Upload test',version:1},snapshot:structuredClone(snapshot)}));await tick();}); + } + async function choose(label='添加图片'){ + await event(document.querySelector(`[aria-label="${label}"]`),'click'); + const input=document.querySelector('input[type="file"][accept^="image/"]'); + Object.defineProperty(input,'files',{configurable:true,value:[new File(['test'],'local.png',{type:'image/png'})]}); + await act(async()=>{input.dispatchEvent(new window.Event('change',{bubbles:true}));await tick();}); + await wait(()=>uploads.length,'upload started'); + } + try { + await reset();await choose(); + const local=document.querySelector('.react-flow__node img[src^="blob:"]');assert.ok(local,'Selected file immediately appears on the canvas before upload acknowledgement');const localURL=local.getAttribute('src'); + assert.ok(document.querySelector('[aria-label="图片上传状态"]'));assert.equal(snapshot.document.nodes.length,0);assert.equal(saves.length,0);assert.deepEqual(uploads[0].bytes,Buffer.from('test')); + await event(document.querySelector('[aria-label="添加文本卡片"]'),'click');assert.equal(document.querySelectorAll('.react-flow__node').length,2,'Other content can be added during upload'); + await act(async()=>{release();await tick();});await wait(()=>document.querySelector('.react-flow__node img[src$="?variant=preview"]'),'managed image rendered'); + assert.ok(document.querySelector(`img[src="${localURL}"]`),'Local image remains visible while remote preview loads'); + const remote=document.querySelector('.react-flow__node img[src$="?variant=preview"]');await act(async()=>{remote.dispatchEvent(new window.Event('load'));await tick();}); + assert.ok(!document.querySelector(`img[src="${localURL}"]`));assert.ok(revoked.includes(localURL));await wait(()=>snapshot.document.nodes.some(n=>n.data.src===asset.url),'original reference saved'); + assert.equal(snapshot.document.nodes.filter(n=>n.data.src===asset.url).length,1);assert.equal(snapshot.document.nodes.find(n=>n.data.src===asset.url).data.assetId,assetID); + console.log('PASS uploads: immediate local preview, unrelated editing, original-byte attachment and no blob URL persistence'); + await reset('failed');await choose();await wait(()=>document.querySelector('[aria-label="图片上传状态"]')?.textContent.includes('上传未完成'),'visible upload failure'); + assert.ok(document.querySelector('img[src^="blob:"]'));assert.equal(saves.length,0);const key=uploads[0].key;mode='delayed'; + await event([...document.querySelectorAll('[aria-label="图片上传状态"] button')].find(n=>n.textContent==='重试'),'click');assert.equal(uploads.length,2);assert.equal(uploads[1].key,key,'Manual retry retains the original idempotency key'); + await act(async()=>{release();await tick();});await wait(()=>snapshot.document.nodes.length===1,'retry saves exactly one image');assert.equal(snapshot.document.nodes[0].data.src,asset.url); + console.log('PASS upload retry: retained file/preview, stable idempotency key and exactly one persisted node'); + await reset('failed');await choose();await wait(()=>document.querySelector('[aria-label="图片上传状态"]')?.textContent.includes('上传未完成'),'failed cancellation'); + const cancelledURL=document.querySelector('img[src^="blob:"]').getAttribute('src');await event([...document.querySelectorAll('[aria-label="图片上传状态"] button')].find(n=>n.textContent==='取消'),'click');assert.equal(document.querySelector('img[src^="blob:"]'),null);assert.ok(revoked.includes(cancelledURL));assert.equal(saves.length,0); + window.Image=class {naturalWidth=640;naturalHeight=1280;set src(_){setTimeout(()=>this.onload?.(),0);}}; + await reset();await choose('添加纯图片');assert.equal(document.querySelector('.react-flow__node').style.width,'240px');assert.equal(document.querySelector('.react-flow__node').style.height,'480px'); + const unmountedURL=document.querySelector('img[src^="blob:"]').getAttribute('src');await act(async()=>{root.render(null);await tick();});assert.ok(uploads[0].signal.aborted);assert.ok(revoked.includes(unmountedURL));release();await tick();assert.equal(saves.length,0); + console.log('PASS upload cleanup: cancellation/unmount revoke previews, abort uploads and retain plain-image aspect ratio'); + } finally {window.Image=NativeImage;URL.revokeObjectURL=originalRevoke;await act(async()=>{root.render(null);await tick();});} +}