From b1c34a50543dab65898002d39dde2e4dd240718d Mon Sep 17 00:00:00 2001 From: PAVEL TKACHEV Date: Sun, 30 Aug 2026 00:36:51 +0300 Subject: [PATCH 1/6] test(fig): compare instance swap targets by path --- .../io/fig/roundtrip/exhaustive.test.ts | 10 +++ tests/engine/io/fig/roundtrip/helpers.ts | 78 ++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/tests/engine/io/fig/roundtrip/exhaustive.test.ts b/tests/engine/io/fig/roundtrip/exhaustive.test.ts index 02cb99738..714068fe1 100644 --- a/tests/engine/io/fig/roundtrip/exhaustive.test.ts +++ b/tests/engine/io/fig/roundtrip/exhaustive.test.ts @@ -18,6 +18,8 @@ import { type FixtureSpec, type CompareOptions, type Verifier, + buildComponentPropertyDefinitionIndex, + buildNodePathIndex, isColorObj, SCENE_VERIFIERS, RAW_VERIFIERS @@ -230,6 +232,10 @@ function compareSceneProps( bNodes, aGraph, bGraph, + aNodePaths: buildNodePathIndex(aNodes), + bNodePaths: buildNodePathIndex(bNodes), + aComponentPropertyDefinitions: buildComponentPropertyDefinitionIndex(aGraph), + bComponentPropertyDefinitions: buildComponentPropertyDefinitionIndex(bGraph), errors, fixture, verifiers, @@ -265,6 +271,10 @@ function compareRawNodeFields( bNodes, aGraph, bGraph, + aNodePaths: buildNodePathIndex(aNodes), + bNodePaths: buildNodePathIndex(bNodes), + aComponentPropertyDefinitions: buildComponentPropertyDefinitionIndex(aGraph), + bComponentPropertyDefinitions: buildComponentPropertyDefinitionIndex(bGraph), errors, fixture, verifiers, diff --git a/tests/engine/io/fig/roundtrip/helpers.ts b/tests/engine/io/fig/roundtrip/helpers.ts index 23bfea462..e012632a5 100644 --- a/tests/engine/io/fig/roundtrip/helpers.ts +++ b/tests/engine/io/fig/roundtrip/helpers.ts @@ -1,4 +1,5 @@ import type { SceneGraph, SceneNode } from '@open-pencil/core' +import type { ComponentPropertyDefinition } from '@open-pencil/scene-graph' import type { JSONObject } from '@open-pencil/scene-graph/primitives' import { verifyComponentPropDefs, verifyDerivedTextData } from './raw-verifiers/helpers' @@ -33,6 +34,10 @@ export interface VerifierContext { bNodes: Map aGraph: SceneGraph bGraph: SceneGraph + aNodePaths: ReadonlyMap + bNodePaths: ReadonlyMap + aComponentPropertyDefinitions: ReadonlyMap + bComponentPropertyDefinitions: ReadonlyMap errors: Mismatch[] fixture: FixtureSpec label: string @@ -60,6 +65,65 @@ export function isColorObj(v: unknown): v is Record { ) } +export function buildNodePathIndex(nodes: ReadonlyMap): Map { + return new Map([...nodes].map(([path, node]) => [node.id, path])) +} + +export function buildComponentPropertyDefinitionIndex( + graph: SceneGraph +): Map { + const definitions = new Map() + for (const node of graph.getAllNodes()) { + for (const definition of node.componentPropertyDefinitions) { + if (!definitions.has(definition.id)) definitions.set(definition.id, definition) + } + } + return definitions +} + +function sameNodeReference(ctx: VerifierContext, a: unknown, b: unknown): boolean { + if (typeof a !== 'string' || typeof b !== 'string') return false + const aPath = ctx.aNodePaths.get(a) + const bPath = ctx.bNodePaths.get(b) + return aPath !== undefined && aPath === bPath +} + +function componentPropertyDefinitionAt( + ctx: VerifierContext, + side: 'a' | 'b' +): ComponentPropertyDefinition | undefined { + const match = /componentPropertyDefinitions\[(\d+)\]\.defaultValue$/.exec(ctx.key) + if (!match) return undefined + const index = Number.parseInt(match[1], 10) + const node = side === 'a' ? ctx.aNodes.get(ctx.path) : ctx.bNodes.get(ctx.path) + return node?.componentPropertyDefinitions[index] +} + +function isStringRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + return Object.values(value).every((entry) => typeof entry === 'string') +} + +function verifyComponentPropertyAssignments(ctx: VerifierContext): boolean { + if (!isStringRecord(ctx.a) || !isStringRecord(ctx.b)) return false + + const aAssignments = ctx.a + const bAssignments = ctx.b + const propertyIds = new Set([...Object.keys(aAssignments), ...Object.keys(bAssignments)]) + for (const propertyId of propertyIds) { + const a = aAssignments[propertyId] + const b = bAssignments[propertyId] + if (a === b) continue + const aDefinition = ctx.aComponentPropertyDefinitions.get(propertyId) + const bDefinition = ctx.bComponentPropertyDefinitions.get(propertyId) + if (aDefinition?.type !== 'INSTANCE_SWAP' || bDefinition?.type !== 'INSTANCE_SWAP') { + return false + } + if (!sameNodeReference(ctx, a, b)) return false + } + return true +} + export const SCENE_VERIFIERS = new Map([ [ 'pluginData', @@ -93,7 +157,19 @@ export const SCENE_VERIFIERS = new Map([ if (!ctx.key.includes('componentPropertyDefinitions')) return false return ctx.a === 'VARIANT' && ctx.b === 'TEXT' } - ] + ], + [ + 'defaultValue', + (ctx) => { + const aDefinition = componentPropertyDefinitionAt(ctx, 'a') + const bDefinition = componentPropertyDefinitionAt(ctx, 'b') + if (aDefinition?.type !== 'INSTANCE_SWAP' || bDefinition?.type !== 'INSTANCE_SWAP') { + return false + } + return sameNodeReference(ctx, ctx.a, ctx.b) + } + ], + ['componentPropertyAssignments', verifyComponentPropertyAssignments] ]) function verifyAEntries( From 81454fe1f726ab03b329b83532d11947850c0c9c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 16:24:54 +0300 Subject: [PATCH 2/6] fix(fig): compare component properties structurally --- tests/engine/io/fig/roundtrip/helpers.ts | 53 +++++++++++------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/tests/engine/io/fig/roundtrip/helpers.ts b/tests/engine/io/fig/roundtrip/helpers.ts index e012632a5..7f12a2ca1 100644 --- a/tests/engine/io/fig/roundtrip/helpers.ts +++ b/tests/engine/io/fig/roundtrip/helpers.ts @@ -81,39 +81,44 @@ export function buildComponentPropertyDefinitionIndex( return definitions } -function sameNodeReference(ctx: VerifierContext, a: unknown, b: unknown): boolean { - if (typeof a !== 'string' || typeof b !== 'string') return false +function sameNodeReference(ctx: VerifierContext, a: string, b: string): boolean { const aPath = ctx.aNodePaths.get(a) const bPath = ctx.bNodePaths.get(b) return aPath !== undefined && aPath === bPath } -function componentPropertyDefinitionAt( - ctx: VerifierContext, - side: 'a' | 'b' -): ComponentPropertyDefinition | undefined { - const match = /componentPropertyDefinitions\[(\d+)\]\.defaultValue$/.exec(ctx.key) - if (!match) return undefined - const index = Number.parseInt(match[1], 10) - const node = side === 'a' ? ctx.aNodes.get(ctx.path) : ctx.bNodes.get(ctx.path) - return node?.componentPropertyDefinitions[index] -} +function verifyComponentPropertyDefinitions(ctx: VerifierContext): boolean { + if (!Array.isArray(ctx.a) || !Array.isArray(ctx.b)) return false + if (ctx.a.length !== ctx.b.length) return false -function isStringRecord(value: unknown): value is Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false - return Object.values(value).every((entry) => typeof entry === 'string') + return ctx.a.every((value, index) => { + const definition = value as ComponentPropertyDefinition + const other = ctx.b[index] as ComponentPropertyDefinition | undefined + if (!other || definition.type !== other.type || definition.name !== other.name) return false + const { defaultValue, ...rest } = definition + const { defaultValue: otherDefaultValue, ...otherRest } = other + if (JSON.stringify(rest) !== JSON.stringify(otherRest)) return false + if (definition.type !== 'INSTANCE_SWAP') return defaultValue === otherDefaultValue + return ( + typeof defaultValue === 'string' && + typeof otherDefaultValue === 'string' && + sameNodeReference(ctx, defaultValue, otherDefaultValue) + ) + }) } function verifyComponentPropertyAssignments(ctx: VerifierContext): boolean { - if (!isStringRecord(ctx.a) || !isStringRecord(ctx.b)) return false + if (!ctx.a || typeof ctx.a !== 'object' || Array.isArray(ctx.a)) return false + if (!ctx.b || typeof ctx.b !== 'object' || Array.isArray(ctx.b)) return false - const aAssignments = ctx.a - const bAssignments = ctx.b + const aAssignments = ctx.a as Record + const bAssignments = ctx.b as Record const propertyIds = new Set([...Object.keys(aAssignments), ...Object.keys(bAssignments)]) for (const propertyId of propertyIds) { const a = aAssignments[propertyId] const b = bAssignments[propertyId] if (a === b) continue + if (typeof a !== 'string' || typeof b !== 'string') return false const aDefinition = ctx.aComponentPropertyDefinitions.get(propertyId) const bDefinition = ctx.bComponentPropertyDefinitions.get(propertyId) if (aDefinition?.type !== 'INSTANCE_SWAP' || bDefinition?.type !== 'INSTANCE_SWAP') { @@ -158,17 +163,7 @@ export const SCENE_VERIFIERS = new Map([ return ctx.a === 'VARIANT' && ctx.b === 'TEXT' } ], - [ - 'defaultValue', - (ctx) => { - const aDefinition = componentPropertyDefinitionAt(ctx, 'a') - const bDefinition = componentPropertyDefinitionAt(ctx, 'b') - if (aDefinition?.type !== 'INSTANCE_SWAP' || bDefinition?.type !== 'INSTANCE_SWAP') { - return false - } - return sameNodeReference(ctx, ctx.a, ctx.b) - } - ], + ['componentPropertyDefinitions', verifyComponentPropertyDefinitions], ['componentPropertyAssignments', verifyComponentPropertyAssignments] ]) From 9da04f03ce927bf42ba98a99cc124de0a4c43baf Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 16:51:08 +0300 Subject: [PATCH 3/6] fix(fig): use named assignment type guard --- tests/engine/io/fig/roundtrip/helpers.ts | 25 +++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/engine/io/fig/roundtrip/helpers.ts b/tests/engine/io/fig/roundtrip/helpers.ts index 7f12a2ca1..c7d2d5605 100644 --- a/tests/engine/io/fig/roundtrip/helpers.ts +++ b/tests/engine/io/fig/roundtrip/helpers.ts @@ -88,12 +88,13 @@ function sameNodeReference(ctx: VerifierContext, a: string, b: string): boolean } function verifyComponentPropertyDefinitions(ctx: VerifierContext): boolean { - if (!Array.isArray(ctx.a) || !Array.isArray(ctx.b)) return false - if (ctx.a.length !== ctx.b.length) return false + const aDefinitions = ctx.a + const bDefinitions = ctx.b + if (aDefinitions.length !== bDefinitions.length) return false - return ctx.a.every((value, index) => { + return aDefinitions.every((value, index) => { const definition = value as ComponentPropertyDefinition - const other = ctx.b[index] as ComponentPropertyDefinition | undefined + const other = bDefinitions[index] as ComponentPropertyDefinition | undefined if (!other || definition.type !== other.type || definition.name !== other.name) return false const { defaultValue, ...rest } = definition const { defaultValue: otherDefaultValue, ...otherRest } = other @@ -107,12 +108,18 @@ function verifyComponentPropertyDefinitions(ctx: VerifierContext): boolean { }) } -function verifyComponentPropertyAssignments(ctx: VerifierContext): boolean { - if (!ctx.a || typeof ctx.a !== 'object' || Array.isArray(ctx.a)) return false - if (!ctx.b || typeof ctx.b !== 'object' || Array.isArray(ctx.b)) return false +function isComponentPropertyAssignments(value: unknown): value is ComponentPropertyAssignments { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + return Object.values(value).every((entry) => typeof entry === 'string') +} - const aAssignments = ctx.a as Record - const bAssignments = ctx.b as Record +function verifyComponentPropertyAssignments(ctx: VerifierContext): boolean { + if (!isComponentPropertyAssignments(ctx.a) || !isComponentPropertyAssignments(ctx.b)) { + return false + } + + const aAssignments = ctx.a + const bAssignments = ctx.b const propertyIds = new Set([...Object.keys(aAssignments), ...Object.keys(bAssignments)]) for (const propertyId of propertyIds) { const a = aAssignments[propertyId] From e3fed2374f2fd0e529263434915542625c325b24 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 18:56:23 +0300 Subject: [PATCH 4/6] fix(fig): validate component property definitions --- tests/engine/io/fig/roundtrip/helpers.ts | 27 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/engine/io/fig/roundtrip/helpers.ts b/tests/engine/io/fig/roundtrip/helpers.ts index c7d2d5605..b6ac7ec27 100644 --- a/tests/engine/io/fig/roundtrip/helpers.ts +++ b/tests/engine/io/fig/roundtrip/helpers.ts @@ -45,9 +45,7 @@ export interface VerifierContext { generation: number } -export interface CompareOptions extends Omit { - verifiers: Map -} +type ComponentPropertyAssignments = SceneNode['componentPropertyAssignments'] /** G1→G2 must be exactly equal (idempotent export). G0→G1 allows semantic equivalence. */ const isIdempotent = (ctx: VerifierContext): boolean => ctx.generation === 1 @@ -87,14 +85,33 @@ function sameNodeReference(ctx: VerifierContext, a: string, b: string): boolean return aPath !== undefined && aPath === bPath } +function isComponentPropertyDefinitions(value: unknown): value is ComponentPropertyDefinition[] { + if (!Array.isArray(value)) return false + return value.every( + (entry) => + typeof entry === 'object' && + entry !== null && + typeof entry.id === 'string' && + typeof entry.name === 'string' && + (entry.type === 'VARIANT' || + entry.type === 'TEXT' || + entry.type === 'BOOLEAN' || + entry.type === 'INSTANCE_SWAP') && + typeof entry.defaultValue === 'string' + ) +} + function verifyComponentPropertyDefinitions(ctx: VerifierContext): boolean { + if (!isComponentPropertyDefinitions(ctx.a) || !isComponentPropertyDefinitions(ctx.b)) { + return false + } const aDefinitions = ctx.a const bDefinitions = ctx.b if (aDefinitions.length !== bDefinitions.length) return false return aDefinitions.every((value, index) => { - const definition = value as ComponentPropertyDefinition - const other = bDefinitions[index] as ComponentPropertyDefinition | undefined + const definition = value + const other = bDefinitions[index] if (!other || definition.type !== other.type || definition.name !== other.name) return false const { defaultValue, ...rest } = definition const { defaultValue: otherDefaultValue, ...otherRest } = other From e5f440d8bebacba480fc7d2210cce72eb15741a2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 19:20:03 +0300 Subject: [PATCH 5/6] fix(fig): guard component property definitions --- tests/engine/io/fig/roundtrip/helpers.ts | 25 +++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/engine/io/fig/roundtrip/helpers.ts b/tests/engine/io/fig/roundtrip/helpers.ts index b6ac7ec27..79e592efb 100644 --- a/tests/engine/io/fig/roundtrip/helpers.ts +++ b/tests/engine/io/fig/roundtrip/helpers.ts @@ -87,17 +87,20 @@ function sameNodeReference(ctx: VerifierContext, a: string, b: string): boolean function isComponentPropertyDefinitions(value: unknown): value is ComponentPropertyDefinition[] { if (!Array.isArray(value)) return false - return value.every( - (entry) => - typeof entry === 'object' && - entry !== null && - typeof entry.id === 'string' && - typeof entry.name === 'string' && - (entry.type === 'VARIANT' || - entry.type === 'TEXT' || - entry.type === 'BOOLEAN' || - entry.type === 'INSTANCE_SWAP') && - typeof entry.defaultValue === 'string' + return value.every(isComponentPropertyDefinition) +} + +function isComponentPropertyDefinition(value: unknown): value is ComponentPropertyDefinition { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return ( + typeof candidate.id === 'string' && + typeof candidate.name === 'string' && + (candidate.type === 'VARIANT' || + candidate.type === 'TEXT' || + candidate.type === 'BOOLEAN' || + candidate.type === 'INSTANCE_SWAP') && + typeof candidate.defaultValue === 'string' ) } From 24647675adef38a8bdd1138ac93b0d7c65a58cff Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 20:31:25 +0300 Subject: [PATCH 6/6] fix(fig): preserve roundtrip options type --- tests/engine/io/fig/roundtrip/helpers.ts | 35 ++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/tests/engine/io/fig/roundtrip/helpers.ts b/tests/engine/io/fig/roundtrip/helpers.ts index 79e592efb..e3e60921c 100644 --- a/tests/engine/io/fig/roundtrip/helpers.ts +++ b/tests/engine/io/fig/roundtrip/helpers.ts @@ -45,7 +45,9 @@ export interface VerifierContext { generation: number } -type ComponentPropertyAssignments = SceneNode['componentPropertyAssignments'] +export interface CompareOptions extends Omit { + verifiers: Map +} /** G1→G2 must be exactly equal (idempotent export). G0→G1 allows semantic equivalence. */ const isIdempotent = (ctx: VerifierContext): boolean => ctx.generation === 1 @@ -85,6 +87,20 @@ function sameNodeReference(ctx: VerifierContext, a: string, b: string): boolean return aPath !== undefined && aPath === bPath } +function sameNodeReferences( + ctx: VerifierContext, + a: string[] | undefined, + b: string[] | undefined +): boolean { + if (a === undefined || b === undefined) return a === b + return ( + a.length === b.length && + a.every((value, index) => { + const other = b[index] + return other !== undefined && sameNodeReference(ctx, value, other) + }) + ) +} function isComponentPropertyDefinitions(value: unknown): value is ComponentPropertyDefinition[] { if (!Array.isArray(value)) return false return value.every(isComponentPropertyDefinition) @@ -115,15 +131,24 @@ function verifyComponentPropertyDefinitions(ctx: VerifierContext): boolean { return aDefinitions.every((value, index) => { const definition = value const other = bDefinitions[index] - if (!other || definition.type !== other.type || definition.name !== other.name) return false - const { defaultValue, ...rest } = definition - const { defaultValue: otherDefaultValue, ...otherRest } = other + if (!other || definition.type !== other.type || definition.name !== other.name) { + return false + } + + const { defaultValue, preferredValues, ...rest } = definition + const { + defaultValue: otherDefaultValue, + preferredValues: otherPreferredValues, + ...otherRest + } = other if (JSON.stringify(rest) !== JSON.stringify(otherRest)) return false + if (definition.type !== 'INSTANCE_SWAP') return defaultValue === otherDefaultValue return ( typeof defaultValue === 'string' && typeof otherDefaultValue === 'string' && - sameNodeReference(ctx, defaultValue, otherDefaultValue) + sameNodeReference(ctx, defaultValue, otherDefaultValue) && + sameNodeReferences(ctx, preferredValues, otherPreferredValues) ) }) }