open-design/apps/daemon/tests/plugins-token-map.test.ts
Cursor Agent bda5c0e3ed
feat(plugins): token-map atom impl (Phase 6/7 entry slice)
Plan P1 / spec §10 / §21.3.1.

apps/daemon/src/plugins/atoms/token-map.ts ships the daemon-side
crosswalk behind the SKILL.md fragment landed in §3.M3. Given a
project cwd that already has a token bag (`code/tokens.json` from
design-extract OR `figma/tokens.json` from figma-extract) plus the
active design system's token vocabulary, the runner produces:

  <cwd>/token-map/colors.json
  <cwd>/token-map/typography.json
  <cwd>/token-map/spacing.json
  <cwd>/token-map/radius.json
  <cwd>/token-map/shadow.json
  <cwd>/token-map/unmatched.json     [{ source, sourceName?, kind, reason, hint? }]
  <cwd>/token-map/meta.json          { sourceKind, generatedAt,
                                       atomDigest, designSystemId?,
                                       targetTokenCount,
                                       sourceTokenCount,
                                       matchedTokenCount }

Match strategies (deterministic, first-win):

  1. Exact value match  (#5b8def === #5b8def).
  2. Normalised hex     (#abc → #aabbcc, ignores case).
  3. Fuzzy name         (--color-primary → --ds-color-primary; strips
                         leading -- / ds- / odds- / theme- prefixes
                         and lower-cases).

Anything else lands unmatched[] with one of:

  - 'no-target-equivalent'  no target with same value/name
  - 'target-collision'      multiple sources mapped to one target
                            (first wins; subsequent listed unmatched
                             with hint pointing at the first claimant)
  - 'invalid-source'        malformed source value

Strict mode aborts on any unmatched; soft mode (default) populates
unmatched.json + continues so the human can audit.

Helper:

  parseDesignSystemTokens(body) → DesignSystemToken[]

  Heuristic DESIGN.md token extraction. Lifts CSS custom properties
  (`--ds-color-fg: #111`) and markdown table rows that look like
  token declarations. Daemon callers may pass a hand-curated bag
  instead of relying on this parser.

Existing patch-edit typecheck noise fixed in passing:
  - PatchStepRecord now extends Omit<RewriteStep, 'rationale'> so
    optional override is sound under exactOptionalPropertyTypes.
  - rationale assignment guarded against undefined.

Daemon tests: 1588 → 1600 (+12 cases on plugins-token-map: exact
match, normalised hex, fuzzy name, no-target-equivalent, target-
collision, strict mode abort, multi-kind crosswalk, code+figma
disk inputs round-trip + bucket persistence + missing-input error,
DESIGN.md CSS custom property + markdown table parsers).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:24:22 +00:00

194 lines
7.5 KiB
TypeScript

// Phase 6/7 entry slice — token-map atom impl.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
parseDesignSystemTokens,
runTokenMap,
type DesignSystemTokenBag,
} from '../src/plugins/atoms/token-map.js';
import type { DesignExtractReport } from '../src/plugins/atoms/design-extract.js';
let cwd: string;
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), 'od-token-map-'));
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
});
const ds: DesignSystemTokenBag = {
id: 'fixture-ds',
tokens: [
{ name: '--ds-color-primary', value: '#5b8def', kind: 'color' },
{ name: '--ds-color-fg', value: '#111111', kind: 'color' },
{ name: '--ds-radius-md', value: '12px', kind: 'radius' },
{ name: 'ds-spacing-2', value: '8px', kind: 'spacing' },
{ name: 'ds-font-body', value: 'Inter', kind: 'typography' },
],
};
const codeTokens = (over: Partial<DesignExtractReport> = {}): DesignExtractReport => ({
colors: [],
typography: [],
spacing: [],
radius: [],
shadow: [],
scannedFiles: [],
warnings: [],
endedAt: new Date().toISOString(),
...over,
});
describe('runTokenMap — match strategies', () => {
it('matches by exact value', async () => {
const source = codeTokens({
colors: [{ kind: 'color', value: '#5b8def', sources: ['Button.tsx:3'], usage: ['Button.tsx'] }],
});
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds });
expect(report.colors).toHaveLength(1);
expect(report.colors[0]?.target).toBe('--ds-color-primary');
expect(report.colors[0]?.via).toBe('exact');
expect(report.colors[0]?.sources).toEqual(['Button.tsx:3']);
expect(report.unmatched).toEqual([]);
});
it('matches by normalised hex (#abc → #aabbcc)', async () => {
const dsShort: DesignSystemTokenBag = {
tokens: [{ name: '--ds-fg', value: '#abc', kind: 'color' }],
};
const source = codeTokens({
colors: [{ kind: 'color', value: '#aabbcc', sources: [], usage: [] }],
});
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: dsShort });
expect(report.colors[0]?.via).toBe('normalised-hex');
expect(report.colors[0]?.target).toBe('--ds-fg');
});
it('matches by fuzzy name when source carries a name', async () => {
const source = codeTokens({
colors: [{ kind: 'color', name: '--color-primary', value: '#999999', sources: [], usage: [] }],
});
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds });
// Falls back to name match because the value doesn't match exact / normalised.
expect(report.colors[0]?.via).toBe('name');
expect(report.colors[0]?.target).toBe('--ds-color-primary');
});
it("records unmatched tokens with reason='no-target-equivalent'", async () => {
const source = codeTokens({
colors: [{ kind: 'color', value: '#abc999', sources: ['x.css:1'], usage: ['x.css'] }],
});
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds });
expect(report.colors).toEqual([]);
expect(report.unmatched).toHaveLength(1);
expect(report.unmatched[0]).toMatchObject({
source: '#abc999',
kind: 'color',
reason: 'no-target-equivalent',
});
});
it("records target collisions when multiple sources map to the same target", async () => {
const source = codeTokens({
colors: [
{ kind: 'color', value: '#5B8DEF', sources: ['a.css:1'], usage: ['a.css'] },
{ kind: 'color', value: '#5b8def', sources: ['b.css:1'], usage: ['b.css'] },
],
});
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds });
expect(report.colors).toHaveLength(1);
expect(report.unmatched).toHaveLength(1);
expect(report.unmatched[0]).toMatchObject({
reason: 'target-collision',
hint: expect.stringMatching(/already mapped/),
});
});
it('throws under strict mode when any source is unmatched', async () => {
const source = codeTokens({
colors: [{ kind: 'color', value: '#deadbe', sources: [], usage: [] }],
});
await expect(runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds, strict: true }))
.rejects.toThrow(/strict/);
});
});
describe('runTokenMap — multi-kind matching', () => {
it('crosswalks spacing / radius / typography buckets', async () => {
const source = codeTokens({
spacing: [{ kind: 'spacing', value: '8px', sources: [], usage: [] }],
radius: [{ kind: 'radius', value: '12px', sources: [], usage: [] }],
typography: [{ kind: 'typography', value: 'Inter', sources: [], usage: [] }],
});
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds });
expect(report.spacing[0]?.target).toBe('ds-spacing-2');
expect(report.radius[0]?.target).toBe('--ds-radius-md');
expect(report.typography[0]?.target).toBe('ds-font-body');
});
});
describe('runTokenMap — disk inputs + outputs', () => {
it('reads code/tokens.json when source is omitted + persists every bucket file', async () => {
await mkdir(path.join(cwd, 'code'), { recursive: true });
const onDisk = codeTokens({
colors: [{ kind: 'color', value: '#111111', sources: ['x:1'], usage: ['x'] }],
});
await writeFile(path.join(cwd, 'code', 'tokens.json'), JSON.stringify(onDisk));
await runTokenMap({ cwd, designSystem: ds });
const colors = JSON.parse(await readFile(path.join(cwd, 'token-map', 'colors.json'), 'utf8'));
const meta = JSON.parse(await readFile(path.join(cwd, 'token-map', 'meta.json'), 'utf8'));
const unmatched = JSON.parse(await readFile(path.join(cwd, 'token-map', 'unmatched.json'), 'utf8'));
expect(colors[0]?.target).toBe('--ds-color-fg');
expect(meta.sourceKind).toBe('code');
expect(meta.designSystemId).toBe('fixture-ds');
expect(meta.atomDigest.length).toBe(40);
expect(unmatched).toEqual([]);
});
it('falls back to figma/tokens.json when code/tokens.json is missing', async () => {
await mkdir(path.join(cwd, 'figma'), { recursive: true });
const onDisk = codeTokens({
colors: [{ kind: 'color', value: '#111111', sources: [], usage: [] }],
});
await writeFile(path.join(cwd, 'figma', 'tokens.json'), JSON.stringify(onDisk));
const report = await runTokenMap({ cwd, designSystem: ds });
expect(report.meta.sourceKind).toBe('figma');
});
it('throws a clear error when neither input exists', async () => {
await expect(runTokenMap({ cwd, designSystem: ds }))
.rejects.toThrow(/run design-extract or figma-extract first/);
});
});
describe('parseDesignSystemTokens', () => {
it('extracts CSS custom properties from a DESIGN.md body', () => {
const body = `
# Design system
\`\`\`css
:root {
--ds-color-primary: #5b8def;
--ds-radius-md: 12px;
}
\`\`\`
`;
const out = parseDesignSystemTokens(body);
expect(out.find((t) => t.name === '--ds-color-primary')?.value).toBe('#5b8def');
expect(out.find((t) => t.name === '--ds-radius-md')?.kind).toBe('radius');
});
it('extracts markdown table rows', () => {
const body = `
| ds-bg | #ffffff |
| ds-fg | #111111 |
`;
const out = parseDesignSystemTokens(body);
expect(out.map((t) => t.name)).toEqual(expect.arrayContaining(['ds-bg', 'ds-fg']));
});
});