openpencil/tests/engine/io/fig/roundtrip/helpers.ts
2026-06-30 10:54:32 +03:00

402 lines
12 KiB
TypeScript

import type { SceneGraph, SceneNode } from '@open-pencil/core'
import type { JsonObject } from '@open-pencil/scene-graph/primitives'
import { verifyComponentPropDefs, verifyDerivedTextData } from './raw-verifiers/helpers'
export interface Mismatch {
path: string
key: string
message: string
}
export interface FixtureSpec {
file: string
fileSize: number
nodeCount: number
nodeTypes: Record<string, number>
schemaSize: number
thumbnailSize: number
thumbnailWidth: number
thumbnailHeight: number
imageCount: number
figKiwiVersion: number
g1ExportSize: number
g2ExportSize: number
}
export interface VerifierContext {
a: unknown
b: unknown
key: string
path: string
aNodes: Map<string, SceneNode>
bNodes: Map<string, SceneNode>
aGraph: SceneGraph
bGraph: SceneGraph
errors: Mismatch[]
fixture: FixtureSpec
label: string
/** Roundtrip generation: 0 for G0→G1 (allows semantic equivalence), 1 for G1→G2 (requires exact match). */
generation: number
}
export interface CompareOptions extends Omit<VerifierContext, 'a' | 'b' | 'key' | 'path'> {
verifiers: Map<string, Verifier>
}
/** G1→G2 must be exactly equal (idempotent export). G0→G1 allows semantic equivalence. */
const isIdempotent = (ctx: VerifierContext): boolean => ctx.generation === 1
export type Verifier = (ctx: VerifierContext) => boolean
export function isColorObj(v: unknown): v is Record<string, number> {
if (!v || typeof v !== 'object') return false
const c = v as Record<string, number>
return (
typeof c.r === 'number' &&
typeof c.g === 'number' &&
typeof c.b === 'number' &&
typeof c.a === 'number'
)
}
export const SCENE_VERIFIERS = new Map<string, Verifier>([
[
'pluginData',
(ctx) => {
const ga = ctx.a as Array<{ pluginId: string; key: string; value: string }>
const gb = ctx.b as Array<{ pluginId: string; key: string; value: string }>
if (!Array.isArray(ga) || !Array.isArray(gb) || ga.length > gb.length) return false
for (const e of ga) {
const found = gb.find((e2) => e2.pluginId === e.pluginId && e2.key === e.key)
if (!found || found.value !== e.value) return false
}
return true
}
],
[
'color',
(ctx) => {
const { a, b } = ctx
if (!isColorObj(a) || !isColorObj(b)) return false
return (
Math.abs(a.r - b.r) <= 0.005 &&
Math.abs(a.g - b.g) <= 0.005 &&
Math.abs(a.b - b.b) <= 0.005 &&
Math.abs(a.a - b.a) <= 0.005
)
}
],
[
'type',
(ctx) => {
if (!ctx.key.includes('componentPropertyDefinitions')) return false
return ctx.a === 'VARIANT' && ctx.b === 'TEXT'
}
]
])
function verifyAEntries(
aEntries: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>,
bEntries: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>,
ctx: VerifierContext
): void {
for (const entryA of aEntries) {
const aliasA = entryA.variableData?.value?.alias
if (aliasA?.guid) {
const found = bEntries.find((entryB) => {
const aliasB = entryB.variableData?.value?.alias
return aliasB?.guid && JSON.stringify(aliasB.guid) === JSON.stringify(aliasA.guid)
})
if (!found) {
ctx.errors.push({
path: ctx.path,
key: ctx.key,
message: `local variable with guid ${JSON.stringify(aliasA.guid)} not preserved in roundtrip`
})
}
}
if (aliasA?.assetRef) {
const found = bEntries.find((entryB) => {
const aliasB = entryB.variableData?.value?.alias
return (
aliasB?.assetRef && JSON.stringify(aliasB.assetRef) === JSON.stringify(aliasA.assetRef)
)
})
if (found && found.variableField !== entryA.variableField) {
ctx.errors.push({
path: ctx.path,
key: ctx.key,
message: `library variable field mismatch: expected ${entryA.variableField}, got ${found.variableField}`
})
}
}
}
}
function verifyBEntries(
aEntries: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>,
bEntries: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>,
ctx: VerifierContext
): void {
for (const entryB of bEntries) {
const aliasB = entryB.variableData?.value?.alias
if (aliasB?.guid) {
const found = aEntries.find((entryA) => {
const aliasA = entryA.variableData?.value?.alias
return aliasA?.guid && JSON.stringify(aliasA.guid) === JSON.stringify(aliasB.guid)
})
if (!found) {
ctx.errors.push({
path: ctx.path,
key: ctx.key,
message: `unexpected local variable with guid ${JSON.stringify(aliasB.guid)} created in roundtrip`
})
}
}
}
}
function verifyVariableConsumption(
ga:
| {
entries?: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>
}
| undefined,
gb:
| {
entries?: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>
}
| undefined,
ctx: VerifierContext
): void {
const aEntries = ga?.entries ?? []
const bEntries = gb?.entries ?? []
verifyAEntries(aEntries, bEntries, ctx)
verifyBEntries(aEntries, bEntries, ctx)
}
interface VariableConsumptionMapShape {
entries?: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>
}
function verifyVarAlias(a: unknown, b: unknown): boolean {
const aVal = a as JsonObject | undefined
const bVal = b as JsonObject | undefined
if (!aVal && !bVal) return true
if (!aVal || !bVal) return false
const aAlias = (aVal.value as JsonObject)?.alias as JsonObject | undefined
const bAlias = (bVal.value as JsonObject)?.alias as JsonObject | undefined
const aGuid = aAlias?.guid
const bGuid = bAlias?.guid
const aRef = aAlias?.assetRef
const bRef = bAlias?.assetRef
if (aGuid && bGuid) return JSON.stringify(aGuid) === JSON.stringify(bGuid)
if ((aRef && bGuid) || (aGuid && bRef)) return true
if (aRef && bRef) return JSON.stringify(aRef) === JSON.stringify(bRef)
return false
}
/** Verifier that defaults undefined values and compares strictly. */
function defaultEqual(defaultVal: unknown): Verifier {
return (ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? defaultVal : ctx.a
const bVal = ctx.b === undefined ? defaultVal : ctx.b
return aVal === bVal
}
}
export const RAW_VERIFIERS = new Map<string, Verifier>([
[
'letterSpacing',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const g1raw = ctx.b as JsonObject | undefined
const node = ctx.aNodes.get(ctx.path)
if (!node || node.fontSize == null) return true
const expected = node.letterSpacing
const actual = g1raw?.value as number | undefined
if (expected != null && actual != null && Math.abs(expected - actual) > 0.05) {
ctx.errors.push({
path: ctx.path,
key: ctx.key,
message: `${expected} (scene) vs ${actual} (raw)`
})
}
return true
}
],
[
'lineHeight',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const g1raw = ctx.b as JsonObject | undefined
const node = ctx.aNodes.get(ctx.path)
if (!node || node.lineHeight == null) return true
const expected = node.lineHeight
const actual = g1raw?.value as number | undefined
return expected == null || actual == null || Number.isFinite(actual)
}
],
[
'variableConsumptionMap',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const ga = ctx.a as VariableConsumptionMapShape | undefined
const gb = ctx.b as VariableConsumptionMapShape | undefined
verifyVariableConsumption(ga, gb, ctx)
return true
}
],
[
'parameterConsumptionMap',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return true
}
],
['borderRightWeight', defaultEqual(0)],
['borderLeftWeight', defaultEqual(0)],
['borderTopWeight', defaultEqual(0)],
['borderBottomWeight', defaultEqual(0)],
[
'componentPropDefs',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return verifyComponentPropDefs(ctx.a, ctx.b)
}
],
[
'derivedTextData',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return verifyDerivedTextData(ctx)
}
],
['styleId', defaultEqual(0)],
[
'indentationLevel',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 0 : ctx.a
const bVal = ctx.b === undefined ? 0 : ctx.b
return aVal === bVal
}
],
[
'sourceDirectionality',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 'AUTO' : ctx.a
const bVal = ctx.b === undefined ? 'AUTO' : ctx.b
return aVal === bVal
}
],
[
'listStartOffset',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 0 : ctx.a
const bVal = ctx.b === undefined ? 0 : ctx.b
return aVal === bVal
}
],
[
'isFirstLineOfList',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? false : ctx.a
const bVal = ctx.b === undefined ? false : ctx.b
return aVal === bVal
}
],
[
'directionality',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 'AUTO' : ctx.a
const bVal = ctx.b === undefined ? 'AUTO' : ctx.b
return aVal === bVal
}
],
[
'directionalityIntent',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 'AUTO' : ctx.a
const bVal = ctx.b === undefined ? 'AUTO' : ctx.b
return aVal === bVal
}
],
[
'fontVersion',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return ctx.b === '' || ctx.a === ctx.b
}
],
[
'postscript',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return ctx.b === '' || ctx.a === ctx.b
}
],
['textExplicitLayoutVersion', defaultEqual(1)],
[
'textUserLayoutVersion',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return (ctx.a === 3 || ctx.a === 4 || ctx.a === 5) && ctx.b === 4
}
],
[
'blendMode',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 'NORMAL' : ctx.a
const bVal = ctx.b === undefined ? 'NORMAL' : ctx.b
return aVal === bVal
}
],
// colorVar and opacityVar: G0 raw paints use alias.assetRef for library
// variables; G1 converts these to alias.guid for local resolution. Both
// reference the same variable — semantically equivalent.
[
'colorVar',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return verifyVarAlias(ctx.a, ctx.b)
}
],
[
'opacityVar',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return verifyVarAlias(ctx.a, ctx.b)
}
]
])