Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion packages/editor/src/lib/scene-clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeDefinition,
type AnyNodeId,
CabinetModuleNode,
type CabinetModuleNode as CabinetModuleNodeType,
CabinetNode,
type CabinetNode as CabinetNodeType,
type LevelNode,
MeasurementNode,
nodeRegistry,
registerNode,
SceneMaterial,
type SceneMaterialId,
useScene,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { z } from 'zod'
import {
copySelectedNodesToEditorClipboard,
getEditorClipboardSnapshot,
Expand Down Expand Up @@ -104,6 +108,12 @@ describe('scene clipboard', () => {
useScene.temporal.getState().clear()
})

// The plugin-kind test registers a definition; drop it so it can't leak into
// any other test's registry lookups.
afterEach(() => {
nodeRegistry._reset()
})

test('copies a selected cabinet run as one subtree instead of independent modules', () => {
const copied = copySelectedNodesToEditorClipboard([runId, leftModuleId, rightModuleId])

Expand Down Expand Up @@ -294,6 +304,64 @@ describe('scene clipboard', () => {
}
})

test('duplicates a plugin-contributed kind that AnyNode cannot describe', () => {
// `AnyNode` is a hand-maintained union of built-in kinds, so a plugin kind
// can never be a member of it — the registry validates those against
// `def.schema` instead. Registering one here reproduces exactly what a
// plugin does at load time; before the registry fallback this copy threw
// `invalid_union / no matching discriminator` and duplicate silently failed
// for every plugin, including the first-party trees pack.
const PluginNode = z.object({
object: z.literal('node').default('node'),
id: z.string(),
type: z.literal('warehouse:pallet').default('warehouse:pallet'),
name: z.string().optional(),
parentId: z.string().nullable().default(null),
visible: z.boolean().optional().default(true),
metadata: z.json().optional().default({}),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
preset: z.string().default('epal-1'),
})
registerNode({
kind: 'warehouse:pallet',
schemaVersion: 1,
schema: PluginNode,
category: 'furnish',
defaults: () => ({}),
capabilities: { duplicable: true, deletable: true },
} as unknown as AnyNodeDefinition)

const pluginNodeId = 'warehouse-pallet_clipboard' as AnyNodeId
const pluginNode = PluginNode.parse({
id: pluginNodeId,
parentId: sourceLevelId,
position: [2, 0, 1],
preset: 'epal-2',
}) as unknown as AnyNode
useScene.setState((state) => ({
nodes: {
...state.nodes,
[sourceLevelId]: makeLevel(sourceLevelId, [pluginNodeId]),
[pluginNodeId]: pluginNode,
},
}))

expect(copySelectedNodesToEditorClipboard([pluginNodeId])).toBe(true)

const result = pasteEditorClipboardToLevel(targetLevelId)
expect(result?.pastedIds).toHaveLength(1)

const pastedId = result?.pastedIds[0]
const pasted = pastedId ? useScene.getState().nodes[pastedId] : undefined
expect(pasted?.type).toBe('warehouse:pallet')
expect(pasted?.id).not.toBe(pluginNodeId)
expect(pasted?.parentId).toBe(targetLevelId)
// The registry schema — not `AnyNode` — is what validated it, so kind-owned
// fields have to survive the round trip.
expect((pasted as unknown as { preset?: string } | undefined)?.preset).toBe('epal-2')
})

test('waits for an in-flight copy before reading the browser clipboard', async () => {
let systemClipboardText = 'older clipboard contents'
let finishWrite!: () => void
Expand Down
31 changes: 29 additions & 2 deletions packages/editor/src/lib/scene-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'

/**
* Validate a node on its way through the clipboard.
*
* `AnyNode` is the hand-maintained union of built-in kinds, so a
* plugin-contributed kind can never be a member of it — the registry validates
* those against `def.schema` at runtime instead. Parsing with `AnyNode` alone
* therefore rejects every plugin node, which makes `capabilities.duplicable`
* unhonourable for plugins including the first-party `pascal:trees` pack.
*
* Built-in behaviour is unchanged: `AnyNode` is still tried first, and a type
* that is neither built-in nor registered still raises the original error.
*/
function parseClipboardNode(candidate: unknown): AnyNode {
const builtin = AnyNode.safeParse(candidate)
if (builtin.success) return builtin.data
const type = (candidate as { type?: unknown } | null)?.type
const definition = typeof type === 'string' ? nodeRegistry.get(type) : undefined
if (definition) return definition.schema.parse(candidate) as AnyNode
throw builtin.error
}

type ClipboardPayload = {
copiedAt: number
materials: SceneMaterial[]
Expand Down Expand Up @@ -250,7 +271,7 @@ function remapNodeReferences(
delete metadata.isTransient
;(clone as Record<string, unknown>).metadata = metadata

return AnyNode.parse(clone)
return parseClipboardNode(clone)
}

function remapSceneMaterialReferences(
Expand Down Expand Up @@ -374,7 +395,13 @@ function parseClipboardPayload(text: string): ClipboardPayload | null {
return null
}

const nodes = candidate.nodes.map((node) => AnyNode.safeParse(node))
const nodes = candidate.nodes.map((node) => {
try {
return { success: true as const, data: parseClipboardNode(node) }
} catch {
return { success: false as const, data: undefined }
}
})
if (nodes.some((result) => !result.success)) return null
const materials = Array.isArray(candidate.materials)
? candidate.materials.map((material) => SceneMaterial.safeParse(material))
Expand Down