import { describe, expect, test } from 'vitest' import { buildPreviewHTML, buildProbeGroups, deriveAppearance, findColorMixCalls, formatSRGBColor, mapThemeVariables, replaceColorMix, resolveLightDark, selectVariableNames } from './palette' describe('buildPreviewHTML', () => { const baseUrl = 'file:///themes/acme/' const baseStyleSheetURLs = [ 'file:///pkgs/@inkdropapp/css/reset.css', 'file:///pkgs/@inkdropapp/base-ui-theme/styles/theme.css' ] test('emits the base href so relative theme links resolve', () => { const html = buildPreviewHTML({ name: 'acme' }, baseUrl, []) expect(html).toContain(``) }) test('links each resolved Inkdrop base stylesheet as an absolute URL', () => { const html = buildPreviewHTML({ name: 'acme' }, baseUrl, baseStyleSheetURLs) for (const href of baseStyleSheetURLs) { expect(html).toContain(``) } }) test('links each declared theme stylesheet under styles/', () => { const html = buildPreviewHTML( { name: 'acme', styleSheets: ['base.css', 'syntax.css'] }, baseUrl, [] ) expect(html).toContain('') expect(html).toContain('') }) test('omits theme stylesheet links when none are declared', () => { const html = buildPreviewHTML({ name: 'acme' }, baseUrl, []) expect(html).not.toContain('href="styles/') }) test('sets the body class to the theme name', () => { const html = buildPreviewHTML({ name: 'acme-dark' }, baseUrl, []) expect(html).toContain('') }) test('appends an appearance-mode class when an appearance is forced', () => { const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [], 'dark') expect(html).toContain('') }) test('adds no appearance class when appearance is omitted', () => { const html = buildPreviewHTML({ name: 'acme' }, baseUrl, []) expect(html).not.toContain('-mode') }) test('renders .cm-editor and .mde-preview elements so scoped tokens can be probed off them', () => { const html = buildPreviewHTML({ name: 'acme' }, baseUrl, []) expect(html).toContain('
') expect(html).toContain('
') }) }) describe('deriveAppearance', () => { test('is dark when the name contains "dark"', () => { expect(deriveAppearance({ name: 'solarized-dark-ui' })).toBe('dark') }) test('is dark when themeAppearance is "dark"', () => { expect(deriveAppearance({ name: 'acme-ui', themeAppearance: 'dark' })).toBe('dark') }) test('is light otherwise', () => { expect(deriveAppearance({ name: 'acme-light-ui' })).toBe('light') expect(deriveAppearance({ name: 'acme-ui', themeAppearance: 'light' })).toBe('light') expect(deriveAppearance({})).toBe('light') }) }) describe('resolveLightDark', () => { test('keeps the first branch for light, the second for dark', () => { expect(resolveLightDark('light-dark(red, blue)', 'light')).toBe('red') expect(resolveLightDark('light-dark(red, blue)', 'dark')).toBe('blue') }) test('leaves values without light-dark untouched', () => { expect(resolveLightDark('hsl(192deg 100% 5%)', 'dark')).toBe('hsl(192deg 100% 5%)') }) test('ignores commas nested inside the branches', () => { const value = 'light-dark(hsl(215deg 14% 34%), hsl(218deg 11% 65% / 40%))' expect(resolveLightDark(value, 'dark')).toBe('hsl(218deg 11% 65% / 40%)') expect(resolveLightDark(value, 'light')).toBe('hsl(215deg 14% 34%)') }) test('resolves nested light-dark within the chosen branch', () => { const value = 'light-dark(light-dark(a, b), c)' expect(resolveLightDark(value, 'light')).toBe('a') expect(resolveLightDark(value, 'dark')).toBe('c') }) test('resolves light-dark embedded in a larger value', () => { const value = '0px 1px 2px 0 light-dark(rgba(0, 0, 0, 0.1), hsl(0deg 0% 0% / 20%)) inset' expect(resolveLightDark(value, 'dark')).toBe('0px 1px 2px 0 hsl(0deg 0% 0% / 20%) inset') }) test('resolves multiple light-dark occurrences in one value', () => { const value = 'light-dark(a, b), light-dark(c, d)' expect(resolveLightDark(value, 'light')).toBe('a, c') expect(resolveLightDark(value, 'dark')).toBe('b, d') }) }) describe('findColorMixCalls', () => { test('returns nothing for values without color-mix', () => { expect(findColorMixCalls('hsl(192deg 100% 5%)')).toEqual([]) }) test('finds a call embedded in a larger value', () => { const value = '0 1px 2px color-mix(in srgb, hsl(200deg 50% 50%) 20%, white) inset' expect(findColorMixCalls(value)).toEqual(['color-mix(in srgb, hsl(200deg 50% 50%) 20%, white)']) }) test('lists only the outermost call when calls are nested', () => { const value = 'color-mix(in srgb, color-mix(in srgb, red, blue), white)' expect(findColorMixCalls(value)).toEqual([value]) }) test('finds multiple sibling calls in order', () => { const value = 'color-mix(in srgb, red, white), color-mix(in oklab, blue, black)' expect(findColorMixCalls(value)).toEqual([ 'color-mix(in srgb, red, white)', 'color-mix(in oklab, blue, black)' ]) }) test('skips an unbalanced call', () => { expect(findColorMixCalls('color-mix(in srgb, red, white')).toEqual([]) }) }) describe('replaceColorMix', () => { test('splices resolved colours in place of their calls', () => { const value = '1px solid color-mix(in srgb, red, white)' expect(replaceColorMix(value, { 'color-mix(in srgb, red, white)': '#ff8080' })).toBe( '1px solid #ff8080' ) }) test('leaves unresolved calls untouched', () => { const value = 'color-mix(in srgb, bogus, white)' expect(replaceColorMix(value, {})).toBe(value) }) }) describe('formatSRGBColor', () => { test('formats an opaque colour as hex', () => { expect(formatSRGBColor('color(srgb 0.3 0 0.7)')).toBe('#4d00b3') }) test('formats a translucent colour as rgba', () => { expect(formatSRGBColor('color(srgb 1 1 1 / 0.2)')).toBe('rgba(255, 255, 255, 0.2)') }) test('clamps out-of-gamut channels', () => { expect(formatSRGBColor('color(srgb -0.3 1.2 0.5)')).toBe('#00ff80') }) test('treats none as zero', () => { expect(formatSRGBColor('color(srgb none 1 none)')).toBe('#00ff00') }) test('rejects values that are not sRGB color()', () => { expect(formatSRGBColor('rgb(0, 0, 0)')).toBeUndefined() expect(formatSRGBColor('color(display-p3 1 0 0)')).toBeUndefined() }) }) describe('selectVariableNames', () => { const manifest = { ui: ['--font-name', '--primary-color'], status: ['--note-status-active'], tags: ['--tag-red-text-color'], 'task-progress': ['--task-progress-view-border-color'], markdown: ['--gfm-alert-note'], mermaid: ['--mermaid-text-color'], syntax: ['--syntax-comment-color'] } test('ui covers ui, status, tags, and task-progress in declaration order', () => { expect(selectVariableNames(manifest, 'ui')).toEqual([ '--font-name', '--primary-color', '--note-status-active', '--tag-red-text-color', '--task-progress-view-border-color' ]) }) test('syntax covers only the syntax category', () => { expect(selectVariableNames(manifest, 'syntax')).toEqual(['--syntax-comment-color']) }) test('preview covers the markdown and mermaid categories in declaration order', () => { expect(selectVariableNames(manifest, 'preview')).toEqual([ '--gfm-alert-note', '--mermaid-text-color' ]) }) test('skips categories absent from the manifest', () => { expect(selectVariableNames({ ui: ['--a'] }, 'ui')).toEqual(['--a']) }) }) describe('buildProbeGroups', () => { const manifest = { ui: ['--primary-color'], status: ['--note-status-active'], tags: ['--tag-red-text-color'], 'task-progress': ['--task-progress-view-border-color'], markdown: ['--gfm-alert-note'], mermaid: ['--mermaid-text-color'], syntax: ['--syntax-comment-color'] } test('produces one group per surface in ui, syntax, preview merge order', () => { expect(buildProbeGroups(manifest).map((group) => group.type)).toEqual([ 'ui', 'syntax', 'preview' ]) }) test('pairs each surface with the element its tokens are scoped under', () => { const probesByType = Object.fromEntries( buildProbeGroups(manifest).map((group) => [group.type, group.probeSelector]) ) expect(probesByType).toEqual({ ui: 'body', syntax: '.cm-editor', preview: '.mde-preview' }) }) test("collects each surface's variable names from its categories", () => { const namesByType = Object.fromEntries( buildProbeGroups(manifest).map((group) => [group.type, group.variableNames]) ) expect(namesByType.ui).toEqual([ '--primary-color', '--note-status-active', '--tag-red-text-color', '--task-progress-view-border-color' ]) expect(namesByType.syntax).toEqual(['--syntax-comment-color']) expect(namesByType.preview).toEqual(['--gfm-alert-note', '--mermaid-text-color']) }) }) describe('mapThemeVariables', () => { test('maps each declared name to its computed value, ignoring extras', () => { const result = mapThemeVariables(['--accent', '--bg'], { '--accent': '#f00', '--bg': '#fff', '--extra': '#000' }) expect(result).toEqual({ '--accent': '#f00', '--bg': '#fff' }) }) test('preserves the declaration order of the variable names', () => { const result = mapThemeVariables(['--b', '--a'], { '--a': '1', '--b': '2' }) expect(Object.keys(result)).toEqual(['--b', '--a']) }) test('leaves variables absent from the computed set undefined', () => { const result = mapThemeVariables(['--missing'], { '--accent': '#f00' }) expect(result['--missing']).toBeUndefined() }) test('returns an empty object when no names are declared', () => { expect(mapThemeVariables([], { '--accent': '#f00' })).toEqual({}) }) })