From aa2658fcd1b9fff010f833e5916f7acebcb11eb1 Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 29 Apr 2026 09:50:31 +0800 Subject: [PATCH] feat(scripts): smoke test for v1 system mode + design system ripple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates full pipeline: createEmptyDocument → applySemanticPalette → buildHeadingV1/buildSettingRowV1/etc. (theme:'system') → resolveNodeForCanvas Light/Dark. Surfaces 2 known gaps in the resolver (fontWeight not resolved; DEFAULT_PALETTE_FALLBACK unreachable for empty-vars docs). --- scripts/smoke-v1-pipeline.ts | 557 +++++++++++++++++++++++++++++++++++ 1 file changed, 557 insertions(+) create mode 100644 scripts/smoke-v1-pipeline.ts diff --git a/scripts/smoke-v1-pipeline.ts b/scripts/smoke-v1-pipeline.ts new file mode 100644 index 000000000..417e60566 --- /dev/null +++ b/scripts/smoke-v1-pipeline.ts @@ -0,0 +1,557 @@ +/** + * Smoke test: v1 system mode + design system ripple + * + * Validates the full pipeline: + * createEmptyDocument + * → applySemanticPalette (seeds 56 tokens + Light/Dark theme axis) + * → buildHeadingV1/buildSettingRowV1/etc. with theme:'system' (emit $X refs) + * → resolveNodeForCanvas(doc, { Mode: 'Light' }) (resolve to light hex) + * → resolveNodeForCanvas(doc, { Mode: 'Dark' }) (resolve to dark hex) + * + * Run: bun scripts/smoke-v1-pipeline.ts + * + * Known gaps surfaced by this test (do NOT fix here — report to controller): + * GAP-1: resolveNodeForCanvas does not resolve `fontWeight` on text nodes + * (only fontSize/lineHeight/letterSpacing are in the key list at resolve.ts:281) + * GAP-2: resolveNodeForCanvas early-exits when `variables === {}` (line 217), + * so DEFAULT_PALETTE_FALLBACK is unreachable for un-seeded documents + */ + +import { + createEmptyDocument, + applySemanticPalette, + resolveNodeForCanvas, + buildHeadingV1, + buildBodyTextV1, + buildSectionHeaderV1, + buildSettingRowV1, +} from '@zseven-w/pen-core'; +import type { PenNode } from '@zseven-w/pen-types'; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +type Result = 'PASS' | 'FAIL' | 'KNOWN_GAP'; + +interface Assertion { + name: string; + result: Result; + detail?: string; +} + +const assertions: Assertion[] = []; + +function assert(name: string, condition: boolean, detail?: string): void { + assertions.push({ name, result: condition ? 'PASS' : 'FAIL', detail }); +} + +/** Document a known gap without failing the test. */ +function knownGap(name: string, detail: string): void { + assertions.push({ name, result: 'KNOWN_GAP', detail }); +} + +/** Get all fill entries from the top-level `fill` array of a node (not recursive). */ +function getTopFills(node: Record): Array> { + const fills = node.fill as Array> | undefined; + if (!Array.isArray(fills)) return []; + return fills; +} + +/** Recursively find a named child by role. */ +function findByRole( + node: Record, + role: string, +): Record | undefined { + if (node.role === role) return node; + const children = node.children as Array> | undefined; + if (!Array.isArray(children)) return undefined; + for (const child of children) { + const found = findByRole(child, role); + if (found) return found; + } + return undefined; +} + +/** Walk all string values in a node tree; return any that start with '$'. */ +function collectRefs(node: Record): string[] { + const refs: string[] = []; + for (const val of Object.values(node)) { + if (typeof val === 'string' && val.startsWith('$')) refs.push(val); + if (Array.isArray(val)) { + for (const item of val) { + if (item && typeof item === 'object') + refs.push(...collectRefs(item as Record)); + if (typeof item === 'string' && item.startsWith('$')) refs.push(item); + } + } + if (val && typeof val === 'object' && !Array.isArray(val)) { + refs.push(...collectRefs(val as Record)); + } + } + return refs; +} + +function hasNoRefs(node: Record): boolean { + return collectRefs(node).length === 0; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Step 1: Build v1 system-mode nodes +// ───────────────────────────────────────────────────────────────────────────── + +console.log('\n══════════════════════════════════════════════════════'); +console.log(' STEP 1: Build v1 nodes with theme:"system"'); +console.log('══════════════════════════════════════════════════════'); + +const sectionHeader = buildSectionHeaderV1({ + title: 'Notifications', + theme: 'system', +}) as unknown as Record; +const heading = buildHeadingV1({ + content: 'Account', + level: 'h2', + theme: 'system', +}) as unknown as Record; +const bodyText = buildBodyTextV1({ + content: 'Manage your account preferences', + theme: 'system', +}) as unknown as Record; + +// settingRow with switch (on:true) — no leading_icon so switch is the first child with a fill +const settingRow1 = buildSettingRowV1({ + title: 'Email', + trailing: { kind: 'switch', on: true }, + theme: 'system', +}) as unknown as Record; + +// settingRow with value trailing +const settingRow3 = buildSettingRowV1({ + title: 'SMS', + trailing: { kind: 'value', value: 'Off' }, + theme: 'system', +}) as unknown as Record; + +// settingRow with leading_icon (for breadth of testing) +const settingRow4 = buildSettingRowV1({ + title: 'Marketing', + leading_icon: 'mail', + trailing: { kind: 'chevron' }, + theme: 'system', +}) as unknown as Record; + +const settingRow2 = buildSettingRowV1({ + title: 'Push Notifications', + trailing: { kind: 'switch', on: false }, + theme: 'system', +}) as unknown as Record; + +const allV1Nodes = [ + sectionHeader, + settingRow1, + settingRow2, + settingRow3, + settingRow4, + heading, + bodyText, +]; + +// Inspect raw v1 output using role-based search (precise, not depth-first first-fill) +const headingFill = getTopFills(heading); +const switchNode = findByRole(settingRow1, 'setting-row-switch'); +const switchFill = switchNode ? getTopFills(switchNode) : []; +const valueFill = findByRole(settingRow3, 'setting-row-value'); + +console.log('\nRaw heading node:'); +console.log(` fontSize: ${String(heading.fontSize)}`); +console.log(` fontWeight: ${String(heading.fontWeight)}`); +console.log(` lineHeight: ${String(heading.lineHeight)}`); +console.log(` fill: ${JSON.stringify(headingFill)}`); + +console.log('\nRaw settingRow1 switch fill (role=setting-row-switch):'); +console.log(` fill: ${JSON.stringify(switchFill)}`); + +console.log('\nRaw settingRow3 trailing value fill (role=setting-row-value):'); +console.log(` fill: ${JSON.stringify(getTopFills(valueFill ?? {}))}`); + +// Collect all $refs across v1 nodes +const allRefs = allV1Nodes.flatMap((n) => collectRefs(n)); +const uniqueRefs = [...new Set(allRefs)].sort(); +console.log(`\nAll unique $refs found across v1 nodes (${uniqueRefs.length}):`); +for (const ref of uniqueRefs) console.log(` ${ref}`); + +// ── Assertions: Step 1 ────────────────────────────────────────────────────── + +assert( + 'heading.fontSize is $type-h2-size ref (system mode)', + heading.fontSize === '$type-h2-size', + `got: ${String(heading.fontSize)}`, +); + +assert( + 'heading.fontWeight is $type-h2-weight ref (system mode)', + heading.fontWeight === '$type-h2-weight', + `got: ${String(heading.fontWeight)}`, +); + +assert( + 'heading.lineHeight is $type-h2-line-height ref (system mode)', + heading.lineHeight === '$type-h2-line-height', + `got: ${String(heading.lineHeight)}`, +); + +const headingFillColor = headingFill[0]?.color as string | undefined; +assert( + 'heading fill[0].color is $color-text-primary ref', + headingFillColor === '$color-text-primary', + `got: ${String(headingFillColor)}`, +); + +const switchFillColor = switchFill[0]?.color as string | undefined; +assert( + 'settingRow1 switch fill[0].color is $color-accent ref (on:true)', + switchFillColor === '$color-accent', + `got: ${String(switchFillColor)}`, +); + +const valueFillColor = valueFill + ? (getTopFills(valueFill)[0]?.color as string | undefined) + : undefined; +assert( + 'settingRow3 trailing value fill[0].color is $color-text-muted ref', + valueFillColor === '$color-text-muted', + `got: ${String(valueFillColor)}`, +); + +assert( + 'v1 nodes contain multiple $refs (system mode active)', + allRefs.length > 5, + `found ${allRefs.length} total refs`, +); + +assert( + 'bodyText.fontSize is literal number 16 (body not yet using system typography tokens)', + typeof bodyText.fontSize === 'number' && bodyText.fontSize === 16, + `got: ${typeof bodyText.fontSize} ${String(bodyText.fontSize)}`, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Step 2: Mount into doc + seed palette +// ───────────────────────────────────────────────────────────────────────────── + +console.log('\n══════════════════════════════════════════════════════'); +console.log(' STEP 2: Mount nodes into doc + applySemanticPalette'); +console.log('══════════════════════════════════════════════════════'); + +const rawDoc = createEmptyDocument(); +const seededDoc = applySemanticPalette(rawDoc); + +// Inject nodes into the active page's root frame children +const activePage = seededDoc.pages?.[0]; +if (!activePage) throw new Error('No active page in document'); +const rootFrame = activePage.children[0] as Record; +rootFrame.children = allV1Nodes; + +console.log(`\nDoc themes after applySemanticPalette: ${JSON.stringify(seededDoc.themes)}`); +console.log(`Doc variables count: ${Object.keys(seededDoc.variables ?? {}).length}`); + +assert( + 'doc has Mode theme axis after applySemanticPalette', + Array.isArray(seededDoc.themes?.Mode) && + (seededDoc.themes?.Mode as string[]).includes('Light') && + (seededDoc.themes?.Mode as string[]).includes('Dark'), + `got: ${JSON.stringify(seededDoc.themes)}`, +); + +assert( + 'doc has 56 semantic palette variables', + Object.keys(seededDoc.variables ?? {}).length === 56, + `got: ${Object.keys(seededDoc.variables ?? {}).length}`, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Step 3: Resolve in Light theme +// ───────────────────────────────────────────────────────────────────────────── + +console.log('\n══════════════════════════════════════════════════════'); +console.log(' STEP 3: Resolve in Light theme'); +console.log('══════════════════════════════════════════════════════'); + +const vars = seededDoc.variables ?? {}; +const lightTheme = { Mode: 'Light' }; +const darkTheme = { Mode: 'Dark' }; + +const lightHeading = resolveNodeForCanvas( + heading as unknown as PenNode, + vars, + lightTheme, +) as unknown as Record; +const lightRow1 = resolveNodeForCanvas( + settingRow1 as unknown as PenNode, + vars, + lightTheme, +) as unknown as Record; + +const lightHeadingFontSize = lightHeading.fontSize; +const lightHeadingFill = getTopFills(lightHeading)[0]?.color as string | undefined; +const lightSwitchNode = findByRole(lightRow1, 'setting-row-switch'); +const lightSwitchFill = lightSwitchNode + ? (getTopFills(lightSwitchNode)[0]?.color as string | undefined) + : undefined; + +console.log(`\nLight-resolved heading.fontSize: ${String(lightHeadingFontSize)}`); +console.log(`Light-resolved heading.fontWeight: ${String(lightHeading.fontWeight)}`); +console.log(`Light-resolved heading.lineHeight: ${String(lightHeading.lineHeight)}`); +console.log(`Light-resolved heading fill[0].color: ${String(lightHeadingFill)}`); +console.log(`Light-resolved switch fill[0].color: ${String(lightSwitchFill)}`); + +assert( + 'Light: heading.fontSize resolves to number 20 ($type-h2-size)', + typeof lightHeadingFontSize === 'number' && lightHeadingFontSize === 20, + `got: ${typeof lightHeadingFontSize} ${String(lightHeadingFontSize)}`, +); + +assert( + 'Light: heading.lineHeight resolves to number 1.25 ($type-h2-line-height)', + typeof lightHeading.lineHeight === 'number' && lightHeading.lineHeight === 1.25, + `got: ${typeof lightHeading.lineHeight} ${String(lightHeading.lineHeight)}`, +); + +assert( + 'Light: heading fill[0].color resolves to #0F172A (light text-primary)', + lightHeadingFill === '#0F172A', + `got: ${String(lightHeadingFill)}`, +); + +assert( + 'Light: switch fill[0].color resolves to #2563EB (light accent)', + lightSwitchFill === '#2563EB', + `got: ${String(lightSwitchFill)}`, +); + +// GAP-1: fontWeight not resolved — still a $ref after resolveNodeForCanvas +const fontWeightAfterLight = lightHeading.fontWeight; +knownGap( + 'GAP-1: heading.fontWeight not resolved by resolveNodeForCanvas (still a $ref)', + `fontWeight after resolve = ${String(fontWeightAfterLight)}; ` + + 'fix: add "fontWeight" to key list in resolve.ts:281', +); + +// The heading still has $type-h2-weight unresolved → refs remain +const lightHeadingRefs = collectRefs(lightHeading); +console.log(`\nRemaining $refs in light-resolved heading: ${JSON.stringify(lightHeadingRefs)}`); +assert( + 'Light: fill and fontSize refs resolved (no $color-* or $type-h*-size refs remain)', + !lightHeadingRefs.some((r) => r.startsWith('$color-') || r === '$type-h2-size'), + `remaining: ${JSON.stringify(lightHeadingRefs)}`, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Step 4: Resolve in Dark theme +// ───────────────────────────────────────────────────────────────────────────── + +console.log('\n══════════════════════════════════════════════════════'); +console.log(' STEP 4: Resolve in Dark theme'); +console.log('══════════════════════════════════════════════════════'); + +const darkHeading = resolveNodeForCanvas( + heading as unknown as PenNode, + vars, + darkTheme, +) as unknown as Record; +const darkRow1 = resolveNodeForCanvas( + settingRow1 as unknown as PenNode, + vars, + darkTheme, +) as unknown as Record; + +const darkHeadingFontSize = darkHeading.fontSize; +const darkHeadingFill = getTopFills(darkHeading)[0]?.color as string | undefined; +const darkSwitchNode = findByRole(darkRow1, 'setting-row-switch'); +const darkSwitchFill = darkSwitchNode + ? (getTopFills(darkSwitchNode)[0]?.color as string | undefined) + : undefined; + +console.log(`\nDark-resolved heading.fontSize: ${String(darkHeadingFontSize)}`); +console.log(`Dark-resolved heading fill[0].color: ${String(darkHeadingFill)}`); +console.log(`Dark-resolved switch fill[0].color: ${String(darkSwitchFill)}`); + +// Side-by-side comparison table +console.log('\n┌─────────────────────────────┬──────────────────┬──────────────────┐'); +console.log('│ Field │ Light │ Dark │'); +console.log('├─────────────────────────────┼──────────────────┼──────────────────┤'); +console.log( + `│ heading.fontSize │ ${String(lightHeadingFontSize).padEnd(16)} │ ${String(darkHeadingFontSize).padEnd(16)} │`, +); +console.log( + `│ heading fill (text-primary) │ ${String(lightHeadingFill).padEnd(16)} │ ${String(darkHeadingFill).padEnd(16)} │`, +); +console.log( + `│ switch fill (accent) │ ${String(lightSwitchFill).padEnd(16)} │ ${String(darkSwitchFill).padEnd(16)} │`, +); +console.log('└─────────────────────────────┴──────────────────┴──────────────────┘'); + +assert( + 'Dark: heading.fontSize resolves to number 20 (typography is theme-agnostic)', + typeof darkHeadingFontSize === 'number' && darkHeadingFontSize === 20, + `got: ${typeof darkHeadingFontSize} ${String(darkHeadingFontSize)}`, +); + +assert( + 'Dark: heading fill[0].color resolves to #F1F5F9 (dark text-primary)', + darkHeadingFill === '#F1F5F9', + `got: ${String(darkHeadingFill)}`, +); + +assert( + 'Dark: switch fill[0].color resolves to #60A5FA (dark accent)', + darkSwitchFill === '#60A5FA', + `got: ${String(darkSwitchFill)}`, +); + +assert( + 'Light vs Dark heading fill.color are DIFFERENT (color theme ripple works)', + lightHeadingFill !== darkHeadingFill, + `light=${String(lightHeadingFill)}, dark=${String(darkHeadingFill)}`, +); + +assert( + 'Light vs Dark heading.fontSize are SAME (numeric is theme-agnostic)', + lightHeadingFontSize === darkHeadingFontSize, + `light=${String(lightHeadingFontSize)}, dark=${String(darkHeadingFontSize)}`, +); + +assert( + 'Light vs Dark switch fill.color are DIFFERENT (accent ripple works)', + lightSwitchFill !== darkSwitchFill, + `light=${String(lightSwitchFill)}, dark=${String(darkSwitchFill)}`, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Step 5: Fallback path (un-seeded doc) +// ───────────────────────────────────────────────────────────────────────────── + +console.log('\n══════════════════════════════════════════════════════'); +console.log(' STEP 5: Fallback path — un-seeded doc (no palette)'); +console.log('══════════════════════════════════════════════════════'); + +// GAP-2: resolveNodeForCanvas exits early when variables === {} (line 217) +// The DEFAULT_PALETTE_FALLBACK is never reached. We verify this is the actual +// behavior and document it as a known gap. +const unseededDoc = createEmptyDocument(); +const unseededVars = unseededDoc.variables ?? {}; + +const fallbackLightHeading = resolveNodeForCanvas(heading as unknown as PenNode, unseededVars, { + Mode: 'Light', +}) as unknown as Record; +const fallbackLightFill = getTopFills(fallbackLightHeading)[0]?.color as string | undefined; +const fallbackLightSize = fallbackLightHeading.fontSize; + +console.log(`\nUn-seeded vars count: ${Object.keys(unseededVars).length}`); +console.log(`Fallback Light heading fill[0].color: ${String(fallbackLightFill)}`); +console.log(`Fallback Light heading.fontSize: ${String(fallbackLightSize)}`); +console.log(`(Expected: early exit → $refs remain unresolved due to GAP-2)`); + +// Verify the early-exit behavior: empty vars → node returned unchanged +assert( + 'Un-seeded doc with empty vars returns node unchanged (early-exit confirmed)', + fallbackLightFill === '$color-text-primary' && fallbackLightSize === '$type-h2-size', + `fill=${String(fallbackLightFill)}, fontSize=${String(fallbackLightSize)}`, +); + +knownGap( + 'GAP-2: DEFAULT_PALETTE_FALLBACK unreachable when doc.variables is {} (resolveNodeForCanvas line 217 early exit)', + 'Fix: change guard to allow empty-vars path when DEFAULT_PALETTE_FALLBACK has the key, ' + + 'OR pre-populate createEmptyDocument() vars with palette defaults', +); + +// Demonstrate that fallback DOES work when vars has at least one non-palette entry +// (proving the code path itself works — the guard is the only issue) +const artificialVars = { dummy: { type: 'number' as const, value: 1 } }; +const artificialFallbackHeading = resolveNodeForCanvas( + heading as unknown as PenNode, + artificialVars, + { Mode: 'Light' }, +) as unknown as Record; +const artificialFallbackFill = getTopFills(artificialFallbackHeading)[0]?.color as + | string + | undefined; +const artificialFallbackSize = artificialFallbackHeading.fontSize; + +console.log(`\nArtificial non-empty vars (triggers fallback path):`); +console.log(` heading fill[0].color: ${String(artificialFallbackFill)}`); +console.log(` heading.fontSize: ${String(artificialFallbackSize)}`); + +assert( + 'DEFAULT_PALETTE_FALLBACK works when vars is non-empty (fill → #0F172A)', + artificialFallbackFill === '#0F172A', + `got: ${String(artificialFallbackFill)}`, +); + +assert( + 'DEFAULT_PALETTE_FALLBACK works when vars is non-empty (fontSize → 20)', + typeof artificialFallbackSize === 'number' && artificialFallbackSize === 20, + `got: ${typeof artificialFallbackSize} ${String(artificialFallbackSize)}`, +); + +// Fallback comparison table +console.log('\n┌─────────────────────────────┬──────────────────┬──────────────────┐'); +console.log('│ Field │ Seeded │ Artificial-FB │'); +console.log('├─────────────────────────────┼──────────────────┼──────────────────┤'); +console.log( + `│ Light heading fill.color │ ${String(lightHeadingFill).padEnd(16)} │ ${String(artificialFallbackFill).padEnd(16)} │`, +); +console.log( + `│ Light heading.fontSize │ ${String(lightHeadingFontSize).padEnd(16)} │ ${String(artificialFallbackSize).padEnd(16)} │`, +); +console.log('└─────────────────────────────┴──────────────────┴──────────────────┘'); + +assert( + 'Seeded == Fallback for Light fill (fallback byte-equiv to seeded)', + lightHeadingFill === artificialFallbackFill, + `seeded=${String(lightHeadingFill)}, fallback=${String(artificialFallbackFill)}`, +); + +assert( + 'Seeded == Fallback for Light fontSize (fallback byte-equiv to seeded)', + lightHeadingFontSize === artificialFallbackSize, + `seeded=${String(lightHeadingFontSize)}, fallback=${String(artificialFallbackSize)}`, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Summary +// ───────────────────────────────────────────────────────────────────────────── + +console.log('\n══════════════════════════════════════════════════════'); +console.log(' ASSERTION SUMMARY'); +console.log('══════════════════════════════════════════════════════'); + +let passed = 0; +let failed = 0; +let gaps = 0; +for (const a of assertions) { + let icon: string; + if (a.result === 'PASS') { + icon = '✓'; + passed++; + } else if (a.result === 'FAIL') { + icon = '✗'; + failed++; + } else { + icon = '⚠'; + gaps++; + } + const detail = a.detail ? ` (${a.detail})` : ''; + console.log(`${icon} [${a.result}] ${a.name}${detail}`); +} + +console.log( + `\n${passed}/${assertions.length - gaps} assertions passed, ${failed} failed, ${gaps} known gap(s) documented`, +); + +if (failed > 0) { + console.error('\n[SMOKE TEST FAILED] — see FAIL assertions above'); + process.exit(1); +} else { + console.log('\n[SMOKE TEST PASSED] Full pipeline OK (known gaps documented above)'); +}