diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 0b3c1468..8efa3a6b 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -35,9 +35,9 @@ Public subpath `index.ts` files are export-only. A component's implementation li sibling file. Support modules and multi-part primitives each keep their own file. The component creation rules live in `packages/turbo-generators/src/component-creation-plan.ts`. -That module owns component-name validation, documentation groups, conformance tiers, and defaults. -Turbo and Plop collect answers and invoke that flow. Keep new creation rules in the plan module so -tests can prove the files, exports, stories, docs, and checks a component needs. +That module owns component-name validation, documentation groups, conformance contracts, and +defaults. Turbo and Plop collect answers and invoke that flow. Keep new creation rules in the plan +module so tests can prove the files, exports, stories, docs, and checks a component needs. The generator creates the component guide's primary `apps/docs/src/examples//basic.tsx` example and references it with `ExampleBlock`. Replace the placeholder content with one focused, diff --git a/docs/TESTING.md b/docs/TESTING.md index f61ed9ed..f0344dcc 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -48,14 +48,21 @@ Do not test RAC's contract. RAC already owns focus management, keyboard navigati semantics, ARIA wiring, validation semantics, and disabled/read-only interaction blocking. Test the composition, prop plumbing, styling hooks, and behaviour that Luke UI adds or deliberately changes. -Each component opts into the applicable tier in the component test manifest: +Each component opts into the contracts it satisfies in the component test manifest. The two +contracts are independent, and a component can hold both: -- **Universal**: documented element, `ref`, `className`, `id`, and `data-*` forwarding. -- **Field-shaped**: object and callback `inputRef`, native `name`/form participation, `onBlur`, and - label, description, and error association. -- **None**: an explicit exception for a component that does not satisfy either contract. +- **DOM**: documented element, `ref`, `className`, `id`, and `data-*` forwarding. +- **Field**: object and callback `inputRef`, native `name`/form participation, `onBlur`, and label, + description, and error association. -The shared conformance helpers test these contracts once. Do not repeat them in individual tests. +A field component often cannot also hold `dom`. React Aria moves `id` onto the control, and Luke UI +fields take `inputRef` instead of a plain `ref`, so no single element receives the full DOM +contract. + +An empty list is an explicit exception for a component that does not satisfy either contract. + +The shared conformance helper tests these contracts once. A component's test file declares its +locators and does not name which contracts run. Do not repeat the contracts in individual tests. Interactive RAC-backed components also register exactly one `testIntegration()` journey. It is an upgrade tripwire: perform one representative real-user workflow and assert only the result Luke UI diff --git a/packages/@luke-ui/react/src/box/box.browser.test.tsx b/packages/@luke-ui/react/src/box/box.browser.test.tsx index 3ea9657c..119c37c1 100644 --- a/packages/@luke-ui/react/src/box/box.browser.test.tsx +++ b/packages/@luke-ui/react/src/box/box.browser.test.tsx @@ -1,10 +1,10 @@ import { afterEach, expect, test } from 'vite-plus/test'; import { page } from 'vite-plus/test/context'; -import { testUniversalConformance } from '../conformance/helpers.js'; +import { testConformance } from '../conformance/helpers.js'; import { render } from '../test-utils/render.js'; import { Box } from './index.js'; -testUniversalConformance({ +testConformance({ path: 'box', getTarget: (result) => { const target = result.container.firstElementChild; diff --git a/packages/@luke-ui/react/src/button/button.browser.test.tsx b/packages/@luke-ui/react/src/button/button.browser.test.tsx index 8b17c8ee..c971f097 100644 --- a/packages/@luke-ui/react/src/button/button.browser.test.tsx +++ b/packages/@luke-ui/react/src/button/button.browser.test.tsx @@ -1,9 +1,9 @@ import { expect } from 'vite-plus/test'; -import { testIntegration, testUniversalConformance } from '../conformance/helpers.js'; +import { testConformance, testIntegration } from '../conformance/helpers.js'; import { render } from '../test-utils/render.js'; import { Button } from './index.js'; -testUniversalConformance({ +testConformance({ path: 'button', getTarget: (result) => { const target = result.locator.getByRole('button').element(); diff --git a/packages/@luke-ui/react/src/checkbox/checkbox.browser.test.tsx b/packages/@luke-ui/react/src/checkbox/checkbox.browser.test.tsx index c3847ad2..2fc2aabb 100644 --- a/packages/@luke-ui/react/src/checkbox/checkbox.browser.test.tsx +++ b/packages/@luke-ui/react/src/checkbox/checkbox.browser.test.tsx @@ -1,10 +1,10 @@ import { expect, test } from 'vite-plus/test'; import { cdp, page } from 'vite-plus/test/context'; -import { testFieldShapedConformance, testIntegration } from '../conformance/helpers.js'; +import { testConformance, testIntegration } from '../conformance/helpers.js'; import { render } from '../test-utils/render.js'; import { Checkbox } from './index.js'; -testFieldShapedConformance({ +testConformance({ path: 'checkbox', getControl: (result) => { const control = result.locator.getByRole('checkbox', { name: 'Terms' }).element(); diff --git a/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx b/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx index 00cb11c2..93eebc9e 100644 --- a/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx +++ b/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx @@ -1,7 +1,7 @@ import { createRef } from 'react'; import { expect, test } from 'vite-plus/test'; import { page, userEvent } from 'vite-plus/test/context'; -import { testFieldShapedConformance, testIntegration } from '../conformance/helpers.js'; +import { testConformance, testIntegration } from '../conformance/helpers.js'; import { ComboboxInputGroup } from '../primitives/combobox/input-group.js'; import { ComboboxInput } from '../primitives/combobox/input.js'; import { ComboboxItem } from '../primitives/combobox/item.js'; @@ -23,7 +23,7 @@ const countryItems: Array = [ const renderCountryItem = (item: CountryItem) => {item.label}; -testFieldShapedConformance({ +testConformance({ path: 'combobox-field', assertAssociation: (result) => { // oxlint-disable-next-line vitest/no-standalone-expect diff --git a/packages/@luke-ui/react/src/conformance/helpers.ts b/packages/@luke-ui/react/src/conformance/helpers.ts index 49eeadef..e752a249 100644 --- a/packages/@luke-ui/react/src/conformance/helpers.ts +++ b/packages/@luke-ui/react/src/conformance/helpers.ts @@ -4,16 +4,11 @@ import { componentTestManifest } from './manifest.js'; type RenderComponent = (props?: Record) => RenderResult; -type UniversalConformanceOptions = { - path: string; - render: RenderComponent; - getTarget: (result: RenderResult) => HTMLElement; -}; - -type FieldConformanceOptions = { +type ConformanceOptions = { assertName?: (result: RenderResult, control: HTMLElement) => void; - getControl: (result: RenderResult) => HTMLElement; + getControl?: (result: RenderResult) => HTMLElement; assertAssociation?: (result: RenderResult) => void; + getTarget?: (result: RenderResult) => HTMLElement; path: string; render: RenderComponent; }; @@ -28,14 +23,26 @@ function assertNativeName(_: RenderResult, control: HTMLElement) { expect(control).toHaveAttribute('name', 'conformance-field'); } -export function testUniversalConformance(options: UniversalConformanceOptions) { - const { getTarget, path, render } = options; - const entry = getManifestEntry(path); - if (entry.conformanceTier !== 'universal') { - throw new Error(`Expected universal conformance for ${path}.`); +function requireTarget(options: ConformanceOptions) { + if (options.getTarget == null) { + throw new Error(`Expected a getTarget locator for ${options.path}.`); } + return options.getTarget; +} - test(`${entry.name} forwards its universal DOM contract`, () => { +function requireControl(options: ConformanceOptions) { + if (options.getControl == null) { + throw new Error(`Expected a getControl locator for ${options.path}.`); + } + return options.getControl; +} + +function testDomContract( + name: string, + getTarget: (result: RenderResult) => HTMLElement, + render: RenderComponent, +) { + test(`${name} forwards its DOM contract`, () => { const ref = { current: null as HTMLElement | null }; const result = render({ className: 'conformance-class', @@ -53,19 +60,14 @@ export function testUniversalConformance(options: UniversalConformanceOptions) { }); } -export function testFieldShapedConformance(options: FieldConformanceOptions) { - const { assertAssociation, assertName, getControl, path, render } = options; - const entry = getManifestEntry(path); - if (entry.conformanceTier !== 'field-shaped') { - throw new Error(`Expected field-shaped conformance for ${path}.`); - } - test(`${entry.name} forwards its field contract`, async () => { +function testFieldContract(options: ConformanceOptions, name: string) { + const { assertAssociation, assertName, render } = options; + const getControl = requireControl(options); + + test(`${name} forwards its field contract`, async () => { const inputRef = { current: null as HTMLElement | null }; let blurred = false; const result = render({ - className: 'conformance-class', - 'data-conformance': 'true', - id: 'conformance-target', inputRef, name: 'conformance-field', onBlur: () => { @@ -106,7 +108,7 @@ export function testFieldShapedConformance(options: FieldConformanceOptions) { // React Aria types `inputRef` as a ref object. Luke UI widens it to accept a // callback so React Hook Form's `field.ref` works without an adapter. - test(`${entry.name} resolves a callback inputRef to the control`, () => { + test(`${name} resolves a callback inputRef to the control`, () => { const resolved: Array = []; const result = render({ inputRef: (node: HTMLElement | null) => { @@ -121,6 +123,18 @@ export function testFieldShapedConformance(options: FieldConformanceOptions) { }); } +export function testConformance(options: ConformanceOptions) { + const { path, render } = options; + const entry = getManifestEntry(path); + + if (entry.conformance.includes('dom')) { + testDomContract(entry.name, requireTarget(options), render); + } + if (entry.conformance.includes('field')) { + testFieldContract(options, entry.name); + } +} + export function testIntegration(path: string, run: () => void | Promise) { const entry = getManifestEntry(path); if (entry.integrationTripwire !== 'required') { diff --git a/packages/@luke-ui/react/src/conformance/manifest.test.ts b/packages/@luke-ui/react/src/conformance/manifest.test.ts index 9e07b1e7..d2c864be 100644 --- a/packages/@luke-ui/react/src/conformance/manifest.test.ts +++ b/packages/@luke-ui/react/src/conformance/manifest.test.ts @@ -11,7 +11,7 @@ const packageRoot = resolve(sourceRoot, '..'); // Public subpaths that are not normal component entrypoints, so they are exempt from the // conformance manifest. `theme` is deliberately absent from this set: it has its own manifest -// entry (conformance tier `none`), so it must keep flowing through the normal check below. Keep +// entry (empty conformance), so it must keep flowing through the normal check below. Keep // this list explicit and named rather than deriving it from the manifest itself, so a public // component entrypoint missing from the manifest fails this test instead of being silently // skipped. @@ -92,19 +92,10 @@ function getBrowserCoverageErrors( ) { const errors: Array = []; for (const entry of manifest) { - if (entry.conformanceTier === 'none' && entry.integrationTripwire === 'none') continue; + if (entry.conformance.length === 0 && entry.integrationTripwire === 'none') continue; const source = readBrowserSource(entry.path); - if ( - entry.conformanceTier !== 'none' && - !hasHelperCall( - source, - entry.conformanceTier === 'universal' - ? 'testUniversalConformance' - : 'testFieldShapedConformance', - entry.path, - ) - ) { - errors.push(`${entry.path} must invoke its ${entry.conformanceTier} conformance helper.`); + if (entry.conformance.length > 0 && !hasHelperCall(source, 'testConformance', entry.path)) { + errors.push(`${entry.path} must invoke its conformance helper.`); } if ( entry.integrationTripwire === 'required' && @@ -147,17 +138,17 @@ test('rejects helper calls for another manifest path and source lookalikes', () if (button == null) throw new Error('Expected the Button manifest entry.'); const wrongPathSource = ` - // testUniversalConformance({ path: 'button' }); + // testConformance({ path: 'button' }); const description = "testIntegration('button', async () => {})"; const fake = \` - testUniversalConformance({ path: 'button' }); + testConformance({ path: 'button' }); testIntegration('button', async () => {}); \`; - testUniversalConformance({ path: 'link' }); + testConformance({ path: 'link' }); testIntegration('link', async () => {}); `; expect(getBrowserCoverageErrors([button], () => wrongPathSource)).toEqual([ - 'button must invoke its universal conformance helper.', + 'button must invoke its conformance helper.', 'button must invoke its integration tripwire.', ]); }); @@ -167,7 +158,7 @@ test('accepts a conformance helper when path is not the first property', () => { if (button == null) throw new Error('Expected the Button manifest entry.'); const source = ` - testUniversalConformance({ + testConformance({ render: () => render(