test(kiwi): verify component prop roundtrips

- Add semantic raw verifier for componentPropDefs
- Keep derived text raw checks focused on stable metadata
- Split raw verifier helpers to keep roundtrip test utilities small
This commit is contained in:
Danila Poyarkov 2026-05-24 19:39:39 +03:00
parent 35db955cb9
commit 8a10552c94
3 changed files with 221 additions and 108 deletions

View file

@ -588,7 +588,6 @@ function verifyFixture(spec: FixtureSpec): void {
test.todo(
'BUG: corner radius 999 sentinel lost for non-pill nodes on scene import (40 nodes, raw data preserved)'
)
test.todo('BUG: componentPropDefs verifier rejects 9 instances (verifier logic gap)')
test.todo(
'BUG: derivedTextData baseline precision differs from raw (14 instances, font metrics)'
)

View file

@ -1,5 +1,7 @@
import type { SceneGraph, SceneNode } from '@open-pencil/core'
import { verifyComponentPropDefs, verifyDerivedTextData } from './raw-verifiers/helpers'
export interface Mismatch {
path: string
key: string
@ -57,89 +59,6 @@ export function isColorObj(v: unknown): v is Record<string, number> {
)
}
function verifyFontDigest(
amDigest: unknown,
bmDigest: unknown,
i: number,
ctx: VerifierContext
): void {
if (amDigest && bmDigest) {
const amHex =
typeof amDigest === 'string' ? amDigest : Buffer.from(amDigest as Uint8Array).toString('hex')
const bmHex =
typeof bmDigest === 'string' ? bmDigest : Buffer.from(bmDigest as Uint8Array).toString('hex')
if (amHex !== bmHex) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontDigest`,
message: `mismatch`
})
}
}
}
function verifyFontLineHeight(amLH: unknown, bmLH: unknown, i: number, ctx: VerifierContext): void {
const amLineHeight = typeof amLH === 'number' ? amLH : 1.2
const bmLineHeight = typeof bmLH === 'number' ? bmLH : 1.2
if (bmLineHeight !== 1.2 && Math.abs(amLineHeight - bmLineHeight) > 0.05) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontLineHeight`,
message: `${amLineHeight} vs ${bmLineHeight}`
})
}
}
function verifySingleFontMetadata(
am: Record<string, unknown>,
bm: Record<string, unknown>,
i: number,
ctx: VerifierContext
): void {
const amKey = am.key as Record<string, unknown> | undefined
const bmKey = bm.key as Record<string, unknown> | undefined
if (amKey?.family !== bmKey?.family) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].key.family`,
message: `${String(amKey?.family)} vs ${String(bmKey?.family)}`
})
}
if (amKey?.style !== bmKey?.style) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].key.style`,
message: `${String(amKey?.style)} vs ${String(bmKey?.style)}`
})
}
if (am.fontWeight !== bm.fontWeight) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontWeight`,
message: `${String(am.fontWeight)} vs ${String(bm.fontWeight)}`
})
}
if (am.fontStyle !== bm.fontStyle) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontStyle`,
message: `${String(am.fontStyle)} vs ${String(bm.fontStyle)}`
})
}
verifyFontLineHeight(am.fontLineHeight, bm.fontLineHeight, i, ctx)
verifyFontDigest(am.fontDigest, bm.fontDigest, i, ctx)
}
function verifyFontMetadata(
aMeta: Record<string, unknown>[],
bMeta: Record<string, unknown>[],
ctx: VerifierContext
): void {
for (let i = 0; i < aMeta.length; i++) {
verifySingleFontMetadata(aMeta[i], bMeta[i], i, ctx)
}
}
export const SCENE_VERIFIERS = new Map<string, Verifier>([
[
'pluginData',
@ -380,36 +299,14 @@ export const RAW_VERIFIERS = new Map<string, Verifier>([
'componentPropDefs',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a as Record<string, unknown>[] | undefined
const bVal = ctx.b as Record<string, unknown>[] | undefined
if (!aVal && !bVal) return true
if (!aVal || !bVal) return false
if (aVal.length !== bVal.length) return false
return true
return verifyComponentPropDefs(ctx.a, ctx.b)
}
],
[
'derivedTextData',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a as Record<string, unknown> | undefined
const bVal = ctx.b as Record<string, unknown> | undefined
if (!aVal && !bVal) return true
if (!aVal || !bVal) return true
const aMeta = (aVal.fontMetaData as Record<string, unknown>[]) ?? []
const bMeta = (bVal.fontMetaData as Record<string, unknown>[]) ?? []
if (aMeta.length !== bMeta.length) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData`,
message: `length mismatch: ${aMeta.length} vs ${bMeta.length}`
})
} else {
verifyFontMetadata(aMeta, bMeta, ctx)
}
return true
return verifyDerivedTextData(ctx)
}
],
['styleId', defaultEqual(0)],

View file

@ -0,0 +1,217 @@
import type { VerifierContext } from '../helpers'
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}
function verifyFontDigest(
amDigest: unknown,
bmDigest: unknown,
i: number,
ctx: VerifierContext
): void {
if (amDigest && bmDigest) {
const amHex =
typeof amDigest === 'string' ? amDigest : Buffer.from(amDigest as Uint8Array).toString('hex')
const bmHex =
typeof bmDigest === 'string' ? bmDigest : Buffer.from(bmDigest as Uint8Array).toString('hex')
if (amHex !== bmHex) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontDigest`,
message: `mismatch`
})
}
}
}
function verifyFontLineHeight(amLH: unknown, bmLH: unknown, i: number, ctx: VerifierContext): void {
const amLineHeight = typeof amLH === 'number' ? amLH : 1.2
const bmLineHeight = typeof bmLH === 'number' ? bmLH : 1.2
if (bmLineHeight !== 1.2 && Math.abs(amLineHeight - bmLineHeight) > 0.05) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontLineHeight`,
message: `${amLineHeight} vs ${bmLineHeight}`
})
}
}
function verifySingleFontMetadata(
am: Record<string, unknown>,
bm: Record<string, unknown>,
i: number,
ctx: VerifierContext
): void {
const amKey = am.key as Record<string, unknown> | undefined
const bmKey = bm.key as Record<string, unknown> | undefined
if (amKey?.family !== bmKey?.family) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].key.family`,
message: `${String(amKey?.family)} vs ${String(bmKey?.family)}`
})
}
if (amKey?.style !== bmKey?.style) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].key.style`,
message: `${String(amKey?.style)} vs ${String(bmKey?.style)}`
})
}
if (am.fontWeight !== bm.fontWeight) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontWeight`,
message: `${String(am.fontWeight)} vs ${String(bm.fontWeight)}`
})
}
if (am.fontStyle !== bm.fontStyle) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontStyle`,
message: `${String(am.fontStyle)} vs ${String(bm.fontStyle)}`
})
}
verifyFontLineHeight(am.fontLineHeight, bm.fontLineHeight, i, ctx)
verifyFontDigest(am.fontDigest, bm.fontDigest, i, ctx)
}
function verifyFontMetadata(
aMeta: Record<string, unknown>[],
bMeta: Record<string, unknown>[],
ctx: VerifierContext
): void {
for (let i = 0; i < aMeta.length; i++) {
verifySingleFontMetadata(aMeta[i], bMeta[i], i, ctx)
}
}
function verifyVectorSize(
aSize: unknown,
bSize: unknown,
key: string,
ctx: VerifierContext,
tolerance = 1
): void {
const aVec = asRecord(aSize)
const bVec = asRecord(bSize)
for (const axis of ['x', 'y']) {
const aValue = aVec?.[axis]
const bValue = bVec?.[axis]
if (typeof aValue !== 'number' || typeof bValue !== 'number') continue
if (Math.abs(aValue - bValue) > tolerance) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.${key}.${axis}`,
message: `${aValue} vs ${bValue}`
})
}
}
}
export function verifyDerivedTextData(ctx: VerifierContext): boolean {
const aVal = asRecord(ctx.a)
const bVal = asRecord(ctx.b)
if (!aVal && !bVal) return true
if (!aVal || !bVal) return false
verifyVectorSize(aVal.layoutSize, bVal.layoutSize, 'layoutSize', ctx)
for (const key of ['truncationStartIndex', 'truncatedHeight']) {
if (aVal[key] !== bVal[key]) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.${key}`,
message: `${String(aVal[key])} vs ${String(bVal[key])}`
})
}
}
const aGlyphs = (aVal.glyphs as unknown[] | undefined) ?? []
const bGlyphs = (bVal.glyphs as unknown[] | undefined) ?? []
if (aGlyphs.length !== bGlyphs.length) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.glyphs`,
message: `length mismatch: ${aGlyphs.length} vs ${bGlyphs.length}`
})
}
const aMeta = (aVal.fontMetaData as Record<string, unknown>[]) ?? []
const bMeta = (bVal.fontMetaData as Record<string, unknown>[]) ?? []
if (aMeta.length !== bMeta.length) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData`,
message: `length mismatch: ${aMeta.length} vs ${bMeta.length}`
})
} else {
verifyFontMetadata(aMeta, bMeta, ctx)
}
return true
}
function guidToComponentValue(value: unknown): string | undefined {
const guid = asRecord(value)
const sessionID = guid?.sessionID
const localID = guid?.localID
if (typeof sessionID !== 'number' || typeof localID !== 'number') return undefined
return `${sessionID}:${localID}`
}
function textValueCharacters(value: unknown): string | undefined {
const textValue = asRecord(value)
const characters = textValue?.characters
return typeof characters === 'string' ? characters : undefined
}
function componentPropInitialValue(def: Record<string, unknown>): string | undefined {
const initialValue = asRecord(def.initialValue)
const textValue = textValueCharacters(initialValue?.textValue)
if (textValue !== undefined) return textValue
if (typeof initialValue?.boolValue === 'boolean') return String(initialValue.boolValue)
const guidValue = guidToComponentValue(initialValue?.guidValue)
if (guidValue !== undefined) return guidValue
const varValue = asRecord(def.varValue)
const value = asRecord(varValue?.value)
const symbolValue = asRecord(value?.symbolIdValue)
const symbolGuid = guidToComponentValue(symbolValue?.guid)
if (symbolGuid !== undefined) return symbolGuid
const varTextValue = value?.textValue
if (typeof varTextValue === 'string') return varTextValue
if (typeof value?.boolValue === 'boolean') return String(value.boolValue)
return undefined
}
function isComponentPropTypeEquivalent(aType: unknown, bType: unknown): boolean {
return aType === bType || (aType === 'VARIANT' && bType === 'TEXT')
}
function verifySingleComponentPropDef(
aDef: Record<string, unknown>,
bDef: Record<string, unknown>
): boolean {
if (JSON.stringify(aDef.id) !== JSON.stringify(bDef.id)) return false
if (aDef.name !== bDef.name) return false
if (!isComponentPropTypeEquivalent(aDef.type, bDef.type)) return false
const aInitialValue = componentPropInitialValue(aDef)
const bInitialValue = componentPropInitialValue(bDef)
return aInitialValue === bInitialValue
}
export function verifyComponentPropDefs(a: unknown, b: unknown): boolean {
const aDefs = a as Record<string, unknown>[] | undefined
const bDefs = b as Record<string, unknown>[] | undefined
if (!aDefs && !bDefs) return true
if (!aDefs || !bDefs) return false
if (aDefs.length !== bDefs.length) return false
return aDefs.every((aDef, index) => verifySingleComponentPropDef(aDef, bDefs[index]))
}