import { expect, test } from "@playwright/test"; import { buildOpenClawArtifactProxyUrl, extractOpenClawArtifacts, getOpenClaw3DArtifactVariant, getOpenClaw3DArtifactBundle, isOpenClawInteractive3DArtifact, normalizeOpenClawArtifacts, } from "@/lib/openclaw-chat/artifacts"; import { getOpenClawExplodedOffsetMm, getOpenClawPackagingInteractionParts, getOpenClawSourceNodeOffset, mapOpenClawCadOffsetToGlb, normalizeOpenClawModelInteractionManifest, } from "@/lib/openclaw-chat/model-interaction"; import { isOpenClawProductionDrawingReleased, normalizeOpenClawProductionDrawingManifest, } from "@/lib/openclaw-chat/production-drawing"; import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; import { clearOpenClaw3DViewerSession, readOpenClaw3DViewerSession, writeOpenClaw3DViewerSession, } from "@/lib/openclaw-chat/viewer-session"; function createMemoryStorage() { const values = new Map(); return { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key), }; } const modelFile = { type: "file", filename: "wood-demo-interactive.glb", display_name: "可旋转3D拆件模型", artifact_role: "interactive_3d", display_mode: "interactive", mime_type: "model/gltf-binary", download_url: "/files/wood-demo-interactive.glb", viewer: { type: "model_3d", format: "glb", camera_controls: true, auto_rotate: false, background: "#F7F9FC", fallback_artifact_role: "cad_preview", }, }; const previewFile = { type: "image", filename: "wood-demo-preview.png", artifact_role: "cad_preview", display_mode: "inline", mime_type: "image/png", download_url: "https://files.example.com/wood-demo-preview.png", }; const assembledModelFile = { ...modelFile, filename: "wood-demo-assembled.glb", display_name: "可旋转3D整装成品模型", artifact_role: "assembled_3d", viewer: { ...modelFile.viewer, variant: "assembled", default: true, fallback_artifact_role: "assembled_preview", alternate_artifact_role: "interactive_3d", interaction_manifest_artifact_role: "model_interaction_manifest", part_selection: true, part_dragging: true, reset_parts: true, one_click_explode: true, one_click_assemble: true, explode_action_id: "explode_all", assemble_action_id: "assemble_all", transition_duration_ms: 850, explode_slider: true, }, }; const manifestFile = { type: "file", filename: "wood-demo-model-interaction.json", artifact_role: "model_interaction_manifest", display_mode: "inline_only", mime_type: "application/json", download_url: "https://files.example.com/wood-demo-model-interaction.json", }; const packagingModelFile = { ...modelFile, filename: "wood-demo-packaging.glb", display_name: "可旋转3D包装叠放模型", artifact_role: "packaging_3d", viewer: { ...modelFile.viewer, variant: "packaging", default: false, fallback_artifact_role: "packaging_preview", interaction_manifest_artifact_role: "model_interaction_manifest", }, }; test.describe("OpenClaw 3D artifacts", () => { test("normalizes production drawing release safeguards", () => { const pending = normalizeOpenClawProductionDrawingManifest({ release_status: "draft", automated_validation_status: "production_ready", approval_status: "awaiting_engineering_release", release_blockers: [ "待结构工程师签批", { code: "HOLE_CHECK", message: "孔位仍需复核" }, ], }); expect(pending).toMatchObject({ releaseStatus: "draft", automatedValidationStatus: "production_ready", approvalStatus: "awaiting_engineering_release", releaseBlockers: ["待结构工程师签批", "孔位仍需复核"], }); expect(isOpenClawProductionDrawingReleased(pending)).toBe(false); const released = normalizeOpenClawProductionDrawingManifest({ release: { status: "production_released", version: "V3", approved_by: "结构工程师A", sha256: "abc123", }, }); expect(released).toMatchObject({ releaseStatus: "production_released", version: "V3", approvedBy: "结构工程师A", fileSha256: "abc123", }); expect(isOpenClawProductionDrawingReleased(released)).toBe(true); }); test("normalizes the documented GLB viewer contract", () => { const artifacts = normalizeOpenClawArtifacts([modelFile, previewFile], { baseUrl: "https://claw.example.com", }); expect(artifacts).toHaveLength(2); expect(artifacts[0]).toMatchObject({ artifactRole: "interactive_3d", displayMode: "interactive", displayName: "可旋转3D拆件模型", downloadUrl: "https://claw.example.com/files/wood-demo-interactive.glb", mimeType: "model/gltf-binary", viewer: { type: "model_3d", format: "glb", cameraControls: true, autoRotate: false, background: "#F7F9FC", fallbackArtifactRole: "cad_preview", }, }); expect(isOpenClawInteractive3DArtifact(artifacts[0])).toBe(true); expect(isOpenClawInteractive3DArtifact(artifacts[1])).toBe(false); }); test("keeps the new viewer controls and selects the default assembled bundle", () => { const artifacts = normalizeOpenClawArtifacts( [ modelFile, manifestFile, assembledModelFile, packagingModelFile, { ...previewFile, artifact_role: "assembled_preview" }, { ...previewFile, artifact_role: "packaging_preview" }, ], { baseUrl: "https://claw.example.com" } ); const bundle = getOpenClaw3DArtifactBundle(artifacts); expect(bundle?.model.artifactRole).toBe("assembled_3d"); expect(bundle?.model.viewer).toMatchObject({ variant: "assembled", isDefault: true, alternateArtifactRole: "interactive_3d", interactionManifestArtifactRole: "model_interaction_manifest", partSelection: true, partDragging: true, resetParts: true, oneClickExplode: true, oneClickAssemble: true, explodeActionId: "explode_all", assembleActionId: "assemble_all", transitionDurationMs: 850, explodeSlider: true, }); expect(bundle?.manifestIndex).toBe(1); expect(bundle?.alternateModelIndex).toBe(0); expect(bundle?.previewIndex).toBe(4); expect(bundle?.packagingModelIndex).toBe(3); expect(bundle?.packagingPreviewIndex).toBe(5); expect(getOpenClaw3DArtifactVariant(artifacts[3])).toBe("packaging"); }); test("normalizes v3 packaging models, actions, layers, and carton metrics", () => { const manifest = normalizeOpenClawModelInteractionManifest({ schema: "dalongxia.furniture-model-interaction.v3", coordinates: { scale_mm_to_glb: 0.001 }, capabilities: { one_click_packaging_explode: true, one_click_packaging_pack: true, select_packaging_layer: true, }, actions: [ { id: "packaging_pack", label: "合上包装", to_state: "packaging_packed", target: "packaging_layers", nodes_field: "packaging.packaging_layers", offset_field: "packed_offset_from_exploded_mm", duration_ms: 850, }, { id: "packaging_explode", label: "展开包装", to_state: "packaging_exploded", target: "packaging_layers", nodes_field: "packaging.packaging_layers", }, ], packaging: { shipment_mode: "拆装", packaging_box_style_label: "中开箱", package_count: 1, package_dimensions_mm: { x: 1260, y: 638, z: 162 }, package_dimensions: [ { package_index: 1, x: 1260, y: 638, z: 162 }, ], total_packaging_volume_m3: 0.13, volume_weight_kg: 21.7, packaging_layers: [ { package_index: 1, node_name: "PKG-01-top-001", node_role: "top_cover", name: "上盖", dimensions_mm: { x: 1250, y: 628, z: 8 }, packed_position_mm: { x: 5, y: 5, z: 154 }, exploded_position_mm: { x: 5, y: 5, z: 420 }, packed_offset_from_exploded_mm: { x: 0, y: 0, z: -266 }, }, ], }, }); expect(manifest?.schema).toBe("dalongxia.furniture-model-interaction.v3"); expect(manifest?.actions[0]).toMatchObject({ id: "packaging_pack", toState: "packaging_packed", target: "packaging_layers", nodesField: "packaging.packaging_layers", }); expect(manifest?.packaging).toMatchObject({ shipmentMode: "拆装", boxStyleLabel: "中开箱", packageCount: 1, packageDimensionsMm: { x: 1260, y: 638, z: 162 }, totalPackagingVolumeM3: 0.13, volumeWeightKg: 21.7, }); expect(manifest?.capabilities.oneClickPackagingPack).toBe(true); expect(getOpenClawPackagingInteractionParts(manifest!)[0]).toMatchObject({ nodeName: "PKG-01-top-001", partId: "top_cover", partName: "上盖", assembledPositionMm: { x: 5, y: 5, z: 154 }, explodedPositionMm: { x: 5, y: 5, z: 420 }, explodedOffsetMm: { x: 0, y: 0, z: 266 }, draggable: false, }); }); test("treats legacy interactive-only GLB as exploded source and can restore assembly", () => { const artifacts = normalizeOpenClawArtifacts([modelFile, manifestFile], { baseUrl: "https://claw.example.com", }); const bundle = getOpenClaw3DArtifactBundle(artifacts); expect(bundle?.model.artifactRole).toBe("interactive_3d"); expect(getOpenClaw3DArtifactVariant(bundle!.model)).toBe("exploded"); const manifest = normalizeOpenClawModelInteractionManifest({ schema: "dalongxia.furniture-model-interaction.v2", coordinates: { scale_mm_to_glb: 0.001 }, parts: [ { node_name: "DOOR-001:1", part_id: "DOOR-001", exploded_offset_mm: { x: 2726, y: 120, z: -40 }, }, ], }); expect( getOpenClawSourceNodeOffset( manifest!.parts[0], "exploded", manifest!.scaleMmToGlb ) ).toEqual({ x: -2.726, y: 0.04, z: 0.12 }); }); test("normalizes v2 interaction transforms and CAD-to-GLB coordinates", () => { const manifest = normalizeOpenClawModelInteractionManifest({ schema: "dalongxia.furniture-model-interaction.v2", coordinates: { scale_mm_to_glb: 0.001 }, capabilities: { one_click_explode: true, one_click_assemble: true, animated_transition: true, explode_slider: true, }, actions: [ { id: "explode_all", label: "一键炸开", to_state: "exploded", offset_field: "exploded_offset_mm", duration_ms: 700, easing: "easeInOutCubic", interruptible: true, }, ], drag: { axes: ["x", "y", "z"], snap_distance_mm: 25, max_distance_mm: 1800, }, parts: [ { node_name: "DOOR-001:1", part_id: "DOOR-001", part_name: "门板", dimensions_mm: { x: 578, y: 20, z: 720 }, exploded_offset_mm: { x: 2726, y: 120, z: -40 }, draggable: true, }, ], }); expect(manifest).not.toBeNull(); expect(manifest?.parts[0]).toMatchObject({ nodeName: "DOOR-001:1", partId: "DOOR-001", partName: "门板", draggable: true, }); const offset = getOpenClawExplodedOffsetMm(manifest!.parts[0]); expect(offset).toEqual({ x: 2726, y: 120, z: -40 }); expect(mapOpenClawCadOffsetToGlb(offset!, manifest!.scaleMmToGlb)).toEqual({ x: 2.726, y: -0.04, z: -0.12, }); }); test("derives exploded offsets and default actions for historical v1 manifests", () => { const manifest = normalizeOpenClawModelInteractionManifest({ schema: "dalongxia.furniture-model-interaction.v1", parts: [ { node_name: "TOP-001:1", part_id: "TOP-001", assembled_position_mm: { x: 20, y: 10, z: 40 }, exploded_position_mm: { x: 220, y: -40, z: 140 }, }, ], }); expect(manifest?.actions.map((action) => action.id)).toEqual([ "explode_all", "assemble_all", ]); expect(getOpenClawExplodedOffsetMm(manifest!.parts[0])).toEqual({ x: 200, y: -50, z: 100, }); expect(manifest?.capabilities.explodeSlider).toBe(true); }); test("extracts files from durable task results without duplicating them", () => { const artifacts = extractOpenClawArtifacts( { output: { files: [modelFile, previewFile] }, task: { result: { files: [modelFile] } }, }, { baseUrl: "https://claw.example.com" } ); expect(artifacts).toHaveLength(2); expect(artifacts.map((artifact) => artifact.artifactRole)).toEqual([ "interactive_3d", "cad_preview", ]); }); test("rejects private schemes and keeps the original URL out of proxy links", () => { expect( normalizeOpenClawArtifacts([ { ...modelFile, download_url: "file:///tmp/private.glb" }, { ...modelFile, download_url: "javascript:alert(1)" }, ]) ).toEqual([]); const proxyUrl = buildOpenClawArtifactProxyUrl({ chatId: "11111111-1111-4111-8111-111111111111", messageId: "22222222-2222-4222-8222-222222222222", artifactIndex: 0, download: true, }); expect(proxyUrl).toContain("download=1"); expect(proxyUrl).not.toContain("files.example.com"); }); test("restores persisted artifacts with Claw task metadata", () => { const task = normalizeClawdTaskMetadata({ composerMode: "clawd", source: "openclaw", taskKind: "clawd_chat", status: "completed", userMessageId: "user-message-id", assistantMessageId: "assistant-message-id", processingHint: "", progressEvents: [], artifacts: [ { ...modelFile, download_url: "https://files.example.com/model.glb", }, ], createdAt: 100, updatedAt: 200, }); expect(task?.artifacts).toHaveLength(1); expect(task?.artifacts?.[0].viewer?.format).toBe("glb"); }); test("restores the active viewer and exploded state after a refresh", () => { const storage = createMemoryStorage(); writeOpenClaw3DViewerSession({ storage, chatId: "chat-id", messageId: "message-id", explodeAmount: 1, selectedNodeName: "DOOR-001:1", now: 1_000, }); expect( readOpenClaw3DViewerSession({ storage, chatId: "chat-id", now: 2_000, }) ).toEqual({ messageId: "message-id", viewMode: "product", explodeAmount: 1, packagingExplodeAmount: 1, selectedNodeName: "DOOR-001:1", selectedPackagingNodeName: null, updatedAt: 1_000, }); }); test("restores the packaging tab and packed state after a refresh", () => { const storage = createMemoryStorage(); writeOpenClaw3DViewerSession({ storage, chatId: "chat-id", messageId: "message-id", viewMode: "packaging", explodeAmount: 0.25, packagingExplodeAmount: 0, selectedPackagingNodeName: "PKG-01-top-001", now: 1_000, }); expect( readOpenClaw3DViewerSession({ storage, chatId: "chat-id", now: 2_000, }) ).toMatchObject({ messageId: "message-id", viewMode: "packaging", explodeAmount: 0.25, packagingExplodeAmount: 0, selectedPackagingNodeName: "PKG-01-top-001", }); }); test("only clears the viewer state owned by the matching message", () => { const storage = createMemoryStorage(); const now = Date.now(); writeOpenClaw3DViewerSession({ storage, chatId: "chat-id", messageId: "new-message-id", explodeAmount: 0, now, }); clearOpenClaw3DViewerSession({ storage, chatId: "chat-id", messageId: "old-message-id", }); expect( readOpenClaw3DViewerSession({ storage, chatId: "chat-id", now: now + 1_000, })?.messageId ).toBe("new-message-id"); clearOpenClaw3DViewerSession({ storage, chatId: "chat-id", messageId: "new-message-id", }); expect( readOpenClaw3DViewerSession({ storage, chatId: "chat-id", now: now + 1_000, }) ).toBeNull(); }); });