You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
171 lines
7.3 KiB
171 lines
7.3 KiB
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import { compileMorfo } from '../morfo/compile'
|
|
import { dialogMorfo } from '../morfo/components/dialog'
|
|
import { iconMorfo } from '../morfo/components/icon'
|
|
import { toggleMorfo } from '../morfo/components/toggle'
|
|
import { lintEidosCss } from './lint'
|
|
|
|
function readEidos(name: string): string {
|
|
return readFileSync(resolve(`src/uix/eidos/components/${name}/${name}.css`), 'utf-8')
|
|
}
|
|
|
|
/** Concat the per-component recipe CSS with the generated foundation
|
|
* blocks that target the same component. After TSC v2, some recipes
|
|
* (notably toggle's data-color cascade) live in `generated/base.css`
|
|
* because the config now owns the palette overrides. The lint contract
|
|
* is "the eidos layer reacts to morfo's attrs" — we include both files. */
|
|
function readEidosWithGenerated(name: string): string {
|
|
const recipe = readEidos(name)
|
|
const generated = readFileSync(resolve('src/uix/eidos/generated/base.css'), 'utf-8')
|
|
// Extract only the rules targeting this component to keep the lint
|
|
// scope narrow.
|
|
const componentRulePattern = new RegExp(`\\[data-${name}\\][^{]*\\{[^}]*\\}`, 'g')
|
|
const generatedComponentRules = (generated.match(componentRulePattern) ?? []).join('\n')
|
|
return `${recipe}\n${generatedComponentRules}`
|
|
}
|
|
|
|
function listEidosCssComponents(): string[] {
|
|
const eidosDir = resolve('src/uix/eidos/components')
|
|
return readdirSync(eidosDir, { withFileTypes: true }).flatMap((entry) => {
|
|
if (!entry.isDirectory()) return []
|
|
|
|
const cssPath = resolve(eidosDir, entry.name, `${entry.name}.css`)
|
|
return existsSync(cssPath) ? [entry.name] : []
|
|
})
|
|
}
|
|
|
|
function listRootCssFiles(): string[] {
|
|
const eidosDir = resolve('src/uix/eidos/components')
|
|
return readdirSync(eidosDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.css'))
|
|
.map((entry) => entry.name)
|
|
.sort()
|
|
}
|
|
|
|
describe('eidos component contract coverage', () => {
|
|
it('keeps component CSS inside component subdirectories', () => {
|
|
expect(listRootCssFiles()).toEqual([])
|
|
})
|
|
|
|
it('has a morfo file for every eidos CSS component', () => {
|
|
// Morfos live under `src/uix/morfo/components/{name}.ts` for public
|
|
// components, OR under `src/uix/morfo/internal/{name}.ts` for
|
|
// internal primitives like `picker-shell` that ship eidos chrome
|
|
// but are not standalone public components (see audit-codex P1 #5).
|
|
const missing = listEidosCssComponents().filter(
|
|
(name) =>
|
|
!existsSync(resolve(`src/uix/morfo/components/${name}.ts`)) &&
|
|
!existsSync(resolve(`src/uix/morfo/internal/${name}.ts`))
|
|
)
|
|
|
|
expect(missing).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('lintEidosCss — toggle (clean baseline)', () => {
|
|
const compiled = compileMorfo(toggleMorfo)
|
|
// Toggle's data-color cascade now lives in `generated/base.css` (config-
|
|
// owned, TSC v2). The lint must consider both the recipe file and the
|
|
// generated rules that target [data-toggle] to validate the full
|
|
// reaction surface.
|
|
const report = lintEidosCss(readEidosWithGenerated('toggle'), compiled)
|
|
|
|
it('finds no invalid selectors', () => {
|
|
expect(report.counts.invalid).toBe(0)
|
|
})
|
|
|
|
it('classifies the canonical morfo-backed selectors', () => {
|
|
const rules = report.findings.filter((f) => f.status === 'morfo-backed').map((f) => f.rule)
|
|
expect(rules).toContain("[data-toggle][data-state='on']")
|
|
expect(rules).toContain('[data-toggle][data-disabled]')
|
|
expect(rules).toContain("[data-toggle][aria-pressed='true']")
|
|
})
|
|
|
|
it("classifies layout-only design tokens (data-size, data-variant) as eidos-only", () => {
|
|
// data-color is morfo-backed since toggle declares it in the morfo
|
|
// (the resolved visual-color from intent + color). Only the
|
|
// pure-layout tokens (size, variant, block, icon-only) are eidos-only.
|
|
const rules = report.findings.filter((f) => f.status === 'eidos-only').map((f) => f.rule)
|
|
expect(rules).toContain("[data-toggle][data-size='sm']")
|
|
expect(rules).toContain("[data-toggle][data-variant='ghost']")
|
|
})
|
|
|
|
it('classifies data-color as morfo-backed (declared in toggleMorfo)', () => {
|
|
// data-color is part of the unified semantic+hierarchy color system
|
|
// (8 system tokens, 6 in toggle's subset). Toggle morfo declares it
|
|
// with the 6 valid values; eidos CSS reacts via [data-color='X'].
|
|
const rules = report.findings.filter((f) => f.status === 'morfo-backed').map((f) => f.rule)
|
|
expect(rules).toContain("[data-toggle][data-color='primary']")
|
|
expect(rules).toContain("[data-toggle][data-color='affirm']")
|
|
expect(rules).toContain("[data-toggle][data-color='threat']")
|
|
})
|
|
|
|
it('reports the unused state values from the contract', () => {
|
|
// Morfo declares data-state ∈ {on, off}; eidos only styles 'on'.
|
|
expect(report.unusedFromContract).toContain("[data-toggle][data-state='off']")
|
|
})
|
|
})
|
|
|
|
describe('lintEidosCss — dialog (drift detector)', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
const report = lintEidosCss(readEidos('dialog'), compiled)
|
|
|
|
it('catches eidos selectors for parts that no longer exist on the morfo', () => {
|
|
// The morfo has Provider, Trigger, Content, Overlay, Title,
|
|
// Description, Close — no Header, no Footer. Eidos still styles them.
|
|
const rules = report.findings.filter((f) => f.status === 'eidos-only').map((f) => f.rule)
|
|
expect(rules).toContain('[data-dialog-header]')
|
|
expect(rules).toContain('[data-dialog-footer]')
|
|
})
|
|
|
|
it('detects dead `[data-disabled]` on parts where the morfo never writes it', () => {
|
|
// Morfo only declares data-disabled on Provider — runtime won't write
|
|
// it on Trigger or Close, so the eidos rule is dead.
|
|
const rules = report.findings.filter((f) => f.status === 'eidos-only').map((f) => f.rule)
|
|
expect(rules).toContain('[data-dialog-trigger][data-disabled]')
|
|
expect(rules).toContain('[data-dialog-close][data-disabled]')
|
|
})
|
|
|
|
it('reports unused contract selectors so eidos authors know what is on offer', () => {
|
|
// Morfo promises the Presence-layer markers and provider-level state,
|
|
// but eidos hasn't picked them up.
|
|
expect(report.unusedFromContract).toContain('[data-dialog-content][data-starting-style]')
|
|
expect(report.unusedFromContract).toContain('[data-dialog-content][data-ending-style]')
|
|
expect(report.unusedFromContract).toContain("[data-dialog][data-state='open']")
|
|
})
|
|
|
|
it('finds no invalid (out-of-enum) values', () => {
|
|
// `data-last-action` values match the morfo enum exactly (saved,
|
|
// failed, cancelled, dismissed, dismissed-outside).
|
|
expect(report.counts.invalid).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('lintEidosCss — icon (contract baseline)', () => {
|
|
const compiled = compileMorfo(iconMorfo)
|
|
const report = lintEidosCss(readEidos('icon'), compiled)
|
|
|
|
it('finds no invalid selectors', () => {
|
|
expect(report.counts.invalid).toBe(0)
|
|
})
|
|
|
|
it('classifies the SVG marker as morfo-backed', () => {
|
|
const rules = report.findings.filter((f) => f.status === 'morfo-backed').map((f) => f.rule)
|
|
expect(rules).toContain('[data-icon]')
|
|
})
|
|
})
|
|
|
|
describe('lintEidosCss — synthetic invalid case', () => {
|
|
it('flags a value that is outside the declared enum', () => {
|
|
const compiled = compileMorfo(toggleMorfo)
|
|
const css = `[data-toggle][data-state='maybe'] { color: red; }`
|
|
const report = lintEidosCss(css, compiled)
|
|
expect(report.counts.invalid).toBe(1)
|
|
expect(report.findings[0].reason).toContain("'maybe'")
|
|
expect(report.findings[0].reason).toContain("'on'")
|
|
})
|
|
})
|