402 lines
12 KiB
TypeScript
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)
|
|
}
|
|
]
|
|
])
|