openpencil/tests/engine/io/fig/import/schema-coverage.test.ts
2026-08-10 05:09:03 -03:00

425 lines
9.3 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import { FIGMA_RAW_NODE_FIELD_KEYS } from '@open-pencil/fig/node-change'
import { parseSchema } from '@open-pencil/kiwi/schema-runtime'
interface SchemaField {
name: string
type: string | null
}
type SchemaCoverageBucket =
| 'modeled'
| 'rawPreserved'
| 'schemaTag'
| 'styleLibraryMetadata'
| 'componentInstanceMetadata'
| 'textMetadata'
| 'visualGeometryMetadata'
| 'layoutMetadata'
| 'prototypeConnectorMetadata'
| 'widgetMetadata'
| 'slideFigjamMetadata'
| 'variableDevMetadata'
| 'codeCmsAiMetadata'
| 'mediaMotionMetadata'
| 'internalBookkeeping'
const SCHEMA_PATH = 'packages/kiwi/src/fig/schema/fig.kiwi'
const CODEC_PATH = 'packages/kiwi/src/fig/codec.ts'
function nodeChangeSchemaFields(): SchemaField[] {
const schema = parseSchema(readFileSync(SCHEMA_PATH, 'utf8'))
const nodeChange = schema.definitions.find((definition) => definition.name === 'NodeChange')
if (!nodeChange) throw new Error('NodeChange message is missing from fig.kiwi')
return nodeChange.fields.map((field) => ({ name: field.name, type: field.type }))
}
function modeledNodeChangeFields(): Set<string> {
const source = ts.createSourceFile(
CODEC_PATH,
readFileSync(CODEC_PATH, 'utf8'),
ts.ScriptTarget.Latest,
true
)
const fields = new Set<string>()
function visit(node: ts.Node): void {
if (ts.isInterfaceDeclaration(node) && node.name.text === 'NodeChange') {
for (const member of node.members) {
if (ts.isPropertySignature(member) && member.name && ts.isIdentifier(member.name)) {
fields.add(member.name.text)
}
}
}
ts.forEachChild(node, visit)
}
visit(source)
return fields
}
function includesAny(value: string, parts: string[]): boolean {
return parts.some((part) => value.includes(part))
}
function classifySchemaOnlyField(
field: SchemaField
): Exclude<SchemaCoverageBucket, 'modeled' | 'rawPreserved'> | null {
const name = field.name
const lower = name.toLowerCase()
const type = field.type ?? ''
if (name.endsWith('Tag')) return 'schemaTag'
if (
includesAny(lower, [
'style',
'library',
'publish',
'asset',
'nonupdateable',
'ojans',
'sevmoonlitlily'
])
) {
return 'styleLibraryMetadata'
}
if (
includesAny(lower, [
'symbol',
'component',
'variant',
'override',
'slot',
'module',
'guidpath',
'unflattening',
'bubble'
])
) {
return 'componentInstanceMetadata'
}
if (
includesAny(lower, [
'font',
'opentype',
'otfeatures',
'text',
'paragraph',
'lineheight',
'letterspacing',
'ligature',
'numeric',
'caps',
'leadingtrim',
'hanging',
'glyph',
'hyperlink',
'mention',
'semantic',
'responsive',
'list'
])
) {
return 'textMetadata'
}
if (
includesAny(lower, [
'fill',
'stroke',
'paint',
'effect',
'mask',
'background',
'corner',
'transform',
'rectangle',
'constraint',
'proportion',
'border',
'geometry',
'scene3d',
'vector',
'arc',
'handlemirroring',
'starinnerscale',
'pathtrim',
'brush',
'dash',
'miter',
'opacity',
'blendmode',
'size',
'visible',
'locked',
'lockmode',
'rotation',
'absolutebounds'
])
) {
return 'visualGeometryMetadata'
}
if (
includesAny(lower, [
'stack',
'grid',
'layout',
'scroll',
'container',
'resize',
'child',
'fixedchildren',
'breakpoint',
'contracted',
'readingdirection',
'readingindex'
])
) {
return 'layoutMetadata'
}
if (
includesAny(lower, [
'prototype',
'transition',
'interaction',
'connection',
'connector',
'annotation',
'measurement',
'overlay',
'destination',
'easing',
'navigation',
'keytrigger',
'voiceevent'
])
) {
return 'prototypeConnectorMetadata'
}
if (lower.includes('widget')) return 'widgetMetadata'
if (
includesAny(lower, [
'slide',
'figjam',
'shape',
'section',
'stamp',
'table',
'snakegame',
'diagram',
'flapp',
'agenda',
'page',
'presentation',
'theme'
])
) {
return 'slideFigjamMetadata'
}
if (
includesAny(lower, [
'variable',
'collection',
'timing',
'parameter',
'dev',
'handoff',
'aria',
'accessible',
'htmltag',
'decorativeimage'
])
) {
return 'variableDevMetadata'
}
if (
includesAny(lower, [
'code',
'jsx',
'cms',
'repeater',
'ai',
'generation',
'chat',
'buzz',
'cooper',
'draft',
'make',
'spec',
'hub',
'managedstring',
'thumbnail',
'placeholder',
'customtool',
'sourcecontrol',
'imageimport',
'backingnode',
'mime',
'blobref',
'artifact'
])
) {
return 'codeCmsAiMetadata'
}
if (
includesAny(lower, [
'video',
'media',
'syncedstate',
'embed',
'motion',
'timeline',
'keyframe',
'interpolation',
'bezier',
'clip',
'playback',
'animation',
'tools',
'customeffects',
'smartanimate'
])
) {
return 'mediaMotionMetadata'
}
if (
includesAny(lower, [
'guid',
'phase',
'parentindex',
'type',
'name',
'description',
'version',
'sortposition',
'key',
'internal',
'autorename',
'count',
'export',
'paste',
'deleted',
'editinfo',
'colorprofile',
'stability',
'migration',
'ancestorpathbeforedeletion',
'root',
'belongs',
'isprimary',
'multieditglueid',
'autofork',
'manuallyrenamed',
'isc2',
'behavior',
'linkpreview',
'editscope',
'simplifyinstancepanels',
'source',
'file',
'disablejitdst'
]) ||
type.startsWith('Internal')
) {
return 'internalBookkeeping'
}
return null
}
function classifyField(
field: SchemaField,
modeled: Set<string>,
rawPreserved: Set<string>
): SchemaCoverageBucket | null {
if (modeled.has(field.name)) return 'modeled'
if (rawPreserved.has(field.name)) return 'rawPreserved'
return classifySchemaOnlyField(field)
}
describe('Figma Kiwi schema coverage', () => {
test('classifies every NodeChange schema field', () => {
const modeled = modeledNodeChangeFields()
const rawPreserved = new Set<string>(FIGMA_RAW_NODE_FIELD_KEYS)
const fields = nodeChangeSchemaFields()
const buckets = new Map<SchemaCoverageBucket, string[]>()
const unclassified: SchemaField[] = []
for (const field of fields) {
const bucket = classifyField(field, modeled, rawPreserved)
if (!bucket) {
unclassified.push(field)
continue
}
buckets.set(bucket, [...(buckets.get(bucket) ?? []), field.name])
}
expect(unclassified).toEqual([])
expect(
Object.fromEntries([...buckets].map(([bucket, items]) => [bucket, items.length]))
).toEqual({
modeled: 116,
schemaTag: 60,
internalBookkeeping: 17,
rawPreserved: 49,
styleLibraryMetadata: 39,
componentInstanceMetadata: 33,
textMetadata: 23,
slideFigjamMetadata: 39,
visualGeometryMetadata: 38,
layoutMetadata: 29,
prototypeConnectorMetadata: 32,
variableDevMetadata: 14,
widgetMetadata: 11,
codeCmsAiMetadata: 67,
mediaMotionMetadata: 23
})
})
test('keeps high-value schema fields modeled or raw-preserved', () => {
const modeled = modeledNodeChangeFields()
const rawPreserved = new Set<string>(FIGMA_RAW_NODE_FIELD_KEYS)
const covered = (name: string) => modeled.has(name) || rawPreserved.has(name)
expect(covered('fillPaints')).toBe(true)
expect(covered('strokePaints')).toBe(true)
expect(covered('effects')).toBe(true)
expect(covered('vectorData')).toBe(true)
expect(covered('derivedTextData')).toBe(true)
expect(covered('leadingTrim')).toBe(true)
expect(covered('textDecorationStyle')).toBe(true)
expect(covered('semanticWeight')).toBe(true)
expect(covered('semanticItalic')).toBe(true)
expect(covered('fontVariantDiscretionaryLigatures')).toBe(true)
expect(covered('fontVariantNumericFigure')).toBe(true)
expect(covered('fontVariantCaps')).toBe(true)
expect(covered('toggledOnOTFeatures')).toBe(true)
expect(covered('toggledOffOTFeatures')).toBe(true)
expect(covered('textDecorationFillPaints')).toBe(true)
expect(covered('textUnderlineOffset')).toBe(true)
expect(covered('textDecorationThickness')).toBe(true)
expect(covered('mask')).toBe(true)
expect(covered('maskType')).toBe(true)
expect(covered('maskIsOutline')).toBe(true)
expect(covered('layoutGrids')).toBe(true)
expect(covered('gridChildVerticalAlign')).toBe(true)
expect(covered('gridChildHorizontalAlign')).toBe(true)
expect(covered('slideThemeMap')).toBe(true)
})
})