import { describe, expect, it } from 'vitest' import { existsSync, readFileSync } from 'node:fs' import { schemas } from '../mock/fixtures' import { describeStyle, effective, fieldForSide, isWrapperSchema, patchFor } from '../src/style/model' import { discoverSpacingVar, measureScale, snap } from '../src/style/tokens' import type { Scale } from '../src/style/tokens' import { computeHandles, dragPx, gapsBetween, previewProperty } from '../src/style/handles' const layout = schemas.layout const fields = describeStyle(layout) const byName = (n: string) => fields.find((f) => f.name === n)! /** The same schema with every x-style removed: what an un-annotated deployment serves. */ const stripped = () => { const s = structuredClone(layout) for (const p of Object.values(s.properties)) delete p['x-style'] for (const p of Object.values(s.$defs.breakpoint.properties)) delete p['x-style'] delete s['x-style'] return s } describe('describeStyle', () => { it('reads x-style annotations: kinds, spacing box/side/overrides, token, breakpoints', () => { expect(fields.map((f) => f.name)).toEqual(['flow', 'columns', 'gap', 'padding', 'paddingTop', 'paddingBottom', 'margin', 'marginTop', 'marginBottom', 'align', 'justify', 'tone', 'border']) expect(byName('paddingTop')).toMatchObject({ kind: 'spacing', box: 'padding', side: 'top', overrides: 'padding', token: '--k6n-sp-{value}', inferred: false }) expect(byName('gap')).toMatchObject({ kind: 'gap', inferred: false }) expect(byName('flow').responsive).toEqual(['mobile', 'tablet', 'desktop']) expect(byName('gap').responsive).toEqual([]) // spacing is not a breakpoint override (the template has no classes for it) expect(isWrapperSchema(layout)).toBe(true) }) it('infers the same fields from names + enums when the schema is not annotated (and says so)', () => { const inferred = describeStyle(stripped()) expect(inferred.map((f) => [f.name, f.kind, f.box, f.side])).toEqual(fields.map((f) => [f.name, f.kind, f.box, f.side])) expect(inferred.every((f) => f.inferred)).toBe(true) expect(inferred.find((f) => f.name === 'marginBottom')).toMatchObject({ overrides: 'margin' }) expect(isWrapperSchema(stripped())).toBe(false) // the wrapper tag is never guessed }) it('does not mistake ordinary fields for style (enum without a scale, unrelated names)', () => { const s = { type: 'object', properties: { padding: { type: 'string' }, gap: { type: 'string', enum: ['a', 'b'] }, title: { type: 'string' }, variant: { enum: ['x', 'y'] } } } expect(describeStyle(s)).toEqual([]) }) it('an annotation wins over naming (any property can be tagged)', () => { const s = { type: 'object', properties: { inset: { type: 'string', enum: ['none', 'xs', 'sm', 'md'], 'x-style': { kind: 'spacing', box: 'padding', side: 'x' } } } } expect(describeStyle(s)).toMatchObject([{ name: 'inset', kind: 'spacing', box: 'padding', side: 'x', inferred: false }]) }) const real = '/home/mats/git.k6n.net/ecommerce/data/k6n/schemas/layout.json' it.skipIf(!existsSync(real))('the real k6n layout schema is annotated and consistent with the mock', () => { const s = JSON.parse(readFileSync(real, 'utf8')) const f = describeStyle(s) expect(f.map((x) => x.name)).toEqual(expect.arrayContaining(fields.map((x) => x.name))) expect(f.map((x) => x.name)).toContain('reveal') expect(f.filter((x) => x.inferred)).toEqual([]) // fully annotated expect(isWrapperSchema(s)).toBe(true) expect(f.find((x) => x.name === 'paddingTop')).toMatchObject({ overrides: 'padding', token: '--k6n-sp-{value}' }) }) }) describe('values', () => { it('effective() falls back from an override to its base field', () => { const d = { padding: 'sm' } expect(effective(d, fields, byName('paddingTop'))).toMatchObject({ value: 'sm', from: { name: 'padding' } }) expect(effective({ padding: 'sm', paddingTop: 'xl' }, fields, byName('paddingTop'))).toMatchObject({ value: 'xl', from: { name: 'paddingTop' } }) expect(effective({}, fields, byName('paddingTop')).value).toBeUndefined() }) it('patchFor: base is flat (undefined clears), breakpoints rebuild responsive and prune empties', () => { expect(patchFor({}, byName('gap'), 'lg')).toEqual({ gap: 'lg' }) expect(patchFor({}, byName('gap'), undefined)).toEqual({ gap: null }) const d = { responsive: { tablet: { columns: 2 }, mobile: { flow: 'stack' } } } expect(patchFor(d, byName('columns'), 3, 'tablet')).toEqual({ responsive: { tablet: { columns: 3 }, mobile: { flow: 'stack' } } }) expect(patchFor(d, byName('columns'), undefined, 'tablet')).toEqual({ responsive: { mobile: { flow: 'stack' } } }) expect(patchFor({ responsive: { tablet: { columns: 2 } } }, byName('columns'), undefined, 'tablet')).toEqual({ responsive: null }) expect(d.responsive.tablet.columns).toBe(2) // input untouched }) it('fieldForSide: exact side, then axis, then all', () => { expect(fieldForSide(fields, 'padding', 'top')!.name).toBe('paddingTop') expect(fieldForSide(fields, 'padding', 'left')!.name).toBe('padding') expect(fieldForSide(fields, 'margin', 'bottom')!.name).toBe('marginBottom') expect(fieldForSide(describeStyle({ type: 'object', properties: { gap: layout.properties.gap } }), 'padding', 'top')).toBeUndefined() }) }) describe('token scales', () => { const scale: Scale = { values: ['none', 'xs', 'sm', 'md', 'lg', 'xl'], px: [0, 8, 16, 32, 64, 96] } it('snaps to the nearest measured size', () => { expect(snap(scale, 'md', 32, 0)).toBe('md') expect(snap(scale, 'md', 32, 12)).toBe('md') // 44 -> md(32) vs lg(64): md expect(snap(scale, 'md', 32, 20)).toBe('lg') // 52 -> lg expect(snap(scale, 'md', 32, -100)).toBe('none') // clamps at 0 expect(snap(scale, undefined, 0, 500)).toBe('xl') }) it('steps ordinally when sizes cannot be measured', () => { const blind: Scale = { values: scale.values, px: scale.values.map(() => null) } expect(snap(blind, 'sm', 0, 0)).toBe('sm') expect(snap(blind, 'sm', 0, 60)).toBe('lg') // 2 steps of 28px expect(snap(blind, 'sm', 0, -500)).toBe('none') }) it('measureScale resolves each value through the field token template', () => { const asked: string[] = [] const sc = measureScale(byName('gap'), (v) => (asked.push(v), asked.length * 10)) expect(asked[0]).toBe('--k6n-sp-none') expect(asked).toContain('--k6n-sp-md') expect(sc.px).toEqual([10, 20, 30, 40, 50, 60]) }) it('discovers the spacing variable prefix from the page stylesheets', () => { const style = { length: 2, 0: '--acme-sp-sm', 1: '--acme-sp-md' } const doc = { styleSheets: [{ cssRules: [{ style }] }] } as unknown as Document expect(discoverSpacingVar(doc)).toBe('--acme-sp-{value}') expect(discoverSpacingVar({ styleSheets: [] } as unknown as Document)).toBeUndefined() }) }) describe('handles', () => { const rect = { x: 100, y: 200, w: 400, h: 300 } const box = (t: number, r: number, b: number, l: number) => ({ top: t, right: r, bottom: b, left: l }) const base = { rect, padding: box(40, 20, 10, 20), margin: box(16, 0, 16, 0), children: [], fields } it('creates a handle per side the schema can express, with the right field, axis and sign', () => { const hs = computeHandles(base) const get = (id: string) => hs.find((h) => h.id === id)! expect(hs.filter((h) => h.kind === 'padding')).toHaveLength(4) expect(get('padding-top')).toMatchObject({ axis: 'y', sign: 1, px: 40, shared: false, field: { name: 'paddingTop' } }) expect(get('padding-bottom')).toMatchObject({ axis: 'y', sign: -1, field: { name: 'paddingBottom' } }) expect(get('padding-left')).toMatchObject({ axis: 'x', sign: 1, shared: true, field: { name: 'padding' } }) // only "all" covers left/right expect(get('padding-right')).toMatchObject({ axis: 'x', sign: -1, shared: true }) expect(get('margin-top')).toMatchObject({ axis: 'y', sign: -1 }) // margin grows outwards expect(get('margin-bottom')).toMatchObject({ axis: 'y', sign: 1 }) }) it('places bands inside the box for padding and outside for margin, grips on the free edge', () => { const hs = computeHandles(base) const pt = hs.find((h) => h.id === 'padding-top')! expect(pt.band).toEqual({ x: 100, y: 200, w: 400, h: 40 }) expect(pt.grip.y + pt.grip.h / 2).toBe(240) // centred on where the 40px ends const mt = hs.find((h) => h.id === 'margin-top')! expect(mt.band).toEqual({ x: 100, y: 184, w: 400, h: 16 }) expect(mt.grip.y + mt.grip.h / 2).toBe(184) }) it('a zero-size value still gets a grabbable grip', () => { const h = computeHandles({ ...base, padding: box(0, 0, 0, 0) }).find((x) => x.id === 'padding-top')! expect(h.band.h).toBe(0) expect(h.grip.h).toBeGreaterThanOrEqual(14) }) it('no spacing fields -> no handles', () => { expect(computeHandles({ ...base, fields: fields.filter((f) => f.kind === 'flow' || f.kind === 'columns') })).toEqual([]) }) it('gap bands come from the space between neighbouring children (rows and columns)', () => { const c = (x: number, y: number) => ({ x, y, w: 100, h: 50 }) // 2x2 grid with a 20px column gap and a 10px row gap const g = gapsBetween([c(0, 0), c(120, 0), c(0, 60), c(120, 60)]) expect(g.filter((x) => x.axis === 'x').map((x) => x.rect.w)).toEqual([20, 20]) expect(g.filter((x) => x.axis === 'y').map((x) => x.rect.h)).toEqual([10, 10]) expect(gapsBetween([c(0, 0)])).toEqual([]) const hs = computeHandles({ ...base, children: [c(0, 0), c(120, 0)] }) expect(hs.filter((h) => h.kind === 'gap')).toMatchObject([{ axis: 'x', px: 20, field: { name: 'gap' } }]) }) it('dragPx follows the sign and never goes negative', () => { expect(dragPx({ axis: 'y', sign: 1, px: 40 }, 0, 25)).toBe(65) expect(dragPx({ axis: 'y', sign: -1, px: 40 }, 0, 25)).toBe(15) expect(dragPx({ axis: 'x', sign: 1, px: 10 }, -50, 99)).toBe(0) }) it('previewProperty maps a field to the CSS property to preview', () => { expect(previewProperty(byName('paddingTop'), 'top')).toBe('padding-top') expect(previewProperty(byName('margin'), 'left')).toBe('margin') expect(previewProperty(byName('gap'), 'gap')).toBe('gap') expect(previewProperty({ ...byName('padding'), side: 'x' }, 'left')).toBe('padding-inline') }) })