import { describe, test, expect } from 'bun:test' import { importNodeChanges, type NodeChange } from '@open-pencil/core' import { expectDefined } from '#tests/helpers/assert' const ID = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } const SIZE = { x: 100, y: 100 } function nc( sessionID: number, localID: number, type: string, parentSessionID: number, parentLocalID: number, name: string, extra: Record = {} ): NodeChange { return { guid: { sessionID, localID }, parentIndex: { guid: { sessionID: parentSessionID, localID: parentLocalID }, position: String.fromCharCode(33 + localID) }, type, name, visible: true, opacity: 1, phase: 'CREATED', size: SIZE, transform: ID, ...extra } as NodeChange } function guid(s: number, l: number) { return { sessionID: s, localID: l } } /** * Build a minimal fig structure for testing instance swap overrides: * * Document (0:0) * ├── InternalPage (0:1) [internalOnly] * │ ├── IconA COMPONENT (0:10) — children: VectorA (0:11) * │ ├── IconB COMPONENT (0:20) — children: VectorB (0:21) * │ └── Button COMPONENT (0:30) — children: Icon INSTANCE→IconA (0:31) * └── Page1 (0:2) * └── ButtonInstance INSTANCE→Button (0:40) * symbolOverride: swap Icon to IconB */ function swapOverrideFixture(opts?: { customName?: string }): NodeChange[] { return [ nc(0, 0, 'DOCUMENT', 0, 0, 'Document'), nc(0, 1, 'CANVAS', 0, 0, 'InternalPage', { internalOnly: true }), nc(0, 2, 'CANVAS', 0, 0, 'Page1'), // IconA component with a vector child nc(0, 10, 'COMPONENT', 0, 1, 'IconA'), nc(0, 11, 'VECTOR', 0, 10, 'VectorA'), // IconB component with a vector child nc(0, 20, 'COMPONENT', 0, 1, 'IconB'), nc(0, 21, 'VECTOR', 0, 20, 'VectorB'), // Button component containing an Icon instance pointing to IconA nc(0, 30, 'COMPONENT', 0, 1, 'Button'), nc(0, 31, 'INSTANCE', 0, 30, opts?.customName ?? 'IconA', { symbolData: { symbolID: guid(0, 10) } }), // Visible-page instance of Button with a swap override: Icon→IconB nc(0, 40, 'INSTANCE', 0, 2, 'ButtonInstance', { symbolData: { symbolID: guid(0, 30), symbolOverrides: [ { guidPath: { guids: [guid(0, 31)] }, overriddenSymbolID: guid(0, 20) } ] } }) ] } describe('instance swap overrides', () => { test('swap override renames icon and reclones children', () => { const graph = importNodeChanges(swapOverrideFixture()) const page = expectDefined( graph.getPages().find((p) => p.name === 'Page1'), 'Page1' ) const button = graph.getChildren(page.id)[0] expect(button.name).toBe('ButtonInstance') const icon = graph.getChildren(button.id)[0] expect(icon.name).toBe('IconB') expect(icon.type).toBe('INSTANCE') const iconChildren = graph.getChildren(icon.id) expect(iconChildren.length).toBe(1) expect(iconChildren[0].name).toBe('VectorB') }) test('swap override preserves user-given name', () => { const graph = importNodeChanges(swapOverrideFixture({ customName: 'MyCustomIcon' })) const page = expectDefined( graph.getPages().find((p) => p.name === 'Page1'), 'Page1' ) const button = graph.getChildren(page.id)[0] const icon = graph.getChildren(button.id)[0] // Name was "MyCustomIcon" which doesn't match root component "IconA", // so it should NOT be renamed to "IconB" expect(icon.name).toBe('MyCustomIcon') // Children should still be swapped to IconB's children const iconChildren = graph.getChildren(icon.id) expect(iconChildren.length).toBe(1) expect(iconChildren[0].name).toBe('VectorB') }) test('swap propagates transitively through 2-level clone chain', () => { // Add a second instance that clones the first button instance const nodes = [ ...swapOverrideFixture(), // Wrapper COMPONENT on internal page that contains a Button instance nc(0, 50, 'COMPONENT', 0, 1, 'Wrapper'), nc(0, 51, 'INSTANCE', 0, 50, 'ButtonInstance', { symbolData: { symbolID: guid(0, 30) } }), // WrapperInstance on visible page with swap override on the button's icon nc(0, 60, 'INSTANCE', 0, 2, 'WrapperInstance', { symbolData: { symbolID: guid(0, 50), symbolOverrides: [ { guidPath: { guids: [guid(0, 51), guid(0, 31)] }, overriddenSymbolID: guid(0, 20) } ] } }) ] const graph = importNodeChanges(nodes) const page = expectDefined( graph.getPages().find((p) => p.name === 'Page1'), 'Page1' ) const children = graph.getChildren(page.id) const wrapper = expectDefined( children.find((c) => c.name === 'WrapperInstance'), 'wrapper instance' ) // WrapperInstance > ButtonInstance > icon const wrapperButton = graph.getChildren(wrapper.id)[0] expect(wrapperButton.name).toBe('ButtonInstance') const icon = graph.getChildren(wrapperButton.id)[0] expect(icon.name).toBe('IconB') const iconChildren = graph.getChildren(icon.id) expect(iconChildren.length).toBe(1) expect(iconChildren[0].name).toBe('VectorB') }) test('swap propagates to sibling clone at same level', () => { // Two Button instances on the visible page, both cloning the same component. // Only one has a swap override — the other should keep the default icon. const nodes: NodeChange[] = [ ...swapOverrideFixture(), // Second ButtonInstance with NO swap override nc(0, 41, 'INSTANCE', 0, 2, 'ButtonDefault', { symbolData: { symbolID: guid(0, 30) } }) ] const graph = importNodeChanges(nodes) const page = expectDefined( graph.getPages().find((p) => p.name === 'Page1'), 'Page1' ) const children = graph.getChildren(page.id) const swapped = expectDefined( children.find((c) => c.name === 'ButtonInstance'), 'swapped button instance' ) const swappedIcon = graph.getChildren(swapped.id)[0] expect(swappedIcon.name).toBe('IconB') const defaultBtn = expectDefined( children.find((c) => c.name === 'ButtonDefault'), 'default button instance' ) const defaultIcon = graph.getChildren(defaultBtn.id)[0] expect(defaultIcon.name).toBe('IconA') }) test('transitive propagation: clone of swapped clone gets swap', () => { // ButtonInstance (swap Icon→IconB) on internal page, // then ButtonClone on visible page clones ButtonInstance. // ButtonClone's icon should also be IconB. const nodes: NodeChange[] = [ nc(0, 0, 'DOCUMENT', 0, 0, 'Document'), nc(0, 1, 'CANVAS', 0, 0, 'InternalPage', { internalOnly: true }), nc(0, 2, 'CANVAS', 0, 0, 'Page1'), nc(0, 10, 'COMPONENT', 0, 1, 'IconA'), nc(0, 11, 'VECTOR', 0, 10, 'VectorA'), nc(0, 20, 'COMPONENT', 0, 1, 'IconB'), nc(0, 21, 'VECTOR', 0, 20, 'VectorB'), // Button component: children = Icon INSTANCE→IconA nc(0, 30, 'COMPONENT', 0, 1, 'Button'), nc(0, 31, 'INSTANCE', 0, 30, 'IconA', { symbolData: { symbolID: guid(0, 10) } }), // ButtonSwapped: instance of Button on internal page with swap nc(0, 40, 'INSTANCE', 0, 1, 'ButtonSwapped', { symbolData: { symbolID: guid(0, 30), symbolOverrides: [ { guidPath: { guids: [guid(0, 31)] }, overriddenSymbolID: guid(0, 20) } ] } }), // Container component on internal page wrapping ButtonSwapped nc(0, 50, 'COMPONENT', 0, 1, 'Container'), nc(0, 51, 'INSTANCE', 0, 50, 'ButtonSwapped', { symbolData: { symbolID: guid(0, 40) } }), // Visible-page instance of Container nc(0, 60, 'INSTANCE', 0, 2, 'ContainerInstance', { symbolData: { symbolID: guid(0, 50) } }) ] const graph = importNodeChanges(nodes) const page = expectDefined( graph.getPages().find((p) => p.name === 'Page1'), 'Page1' ) const container = graph.getChildren(page.id)[0] // ContainerInstance > ButtonSwapped > icon const button = graph.getChildren(container.id)[0] const icon = graph.getChildren(button.id)[0] expect(icon.name).toBe('IconB') const iconChildren = graph.getChildren(icon.id) expect(iconChildren.length).toBe(1) expect(iconChildren[0].name).toBe('VectorB') }) })