Skip to content

Commit f8a502d

Browse files
test(ui): stand up the mounted-DOM interaction test harness (#3243)
Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
1 parent 25bda83 commit f8a502d

24 files changed

Lines changed: 1304 additions & 169 deletions

npm/ui/core/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
"dev": "vp pack --watch",
9090
"lint:sfc": "vp exec node scripts/lint-sfc.ts src",
9191
"pretest": "vp pack && pnpm check:size",
92-
"test": "vp exec node --test 'src/**/*.test.ts'",
92+
"test": "vp test run",
9393
"check": "vp check src scripts vite.config.ts",
9494
"check:fix": "vp check --fix src scripts vite.config.ts",
9595
"check:size": "node scripts/check-size.mjs",
@@ -101,6 +101,8 @@
101101
"@vitejs/plugin-vue": "catalog:vite-stack",
102102
"@vizejs/native": "workspace:*",
103103
"@vizejs/ui-tooling": "workspace:*",
104+
"@vue/test-utils": "catalog:vue-stable",
105+
"happy-dom": "catalog:testing",
104106
"typescript": "catalog:typescript",
105107
"vite": "catalog:vite-stack",
106108
"vite-plus": "catalog:vite-stack",

npm/ui/core/src/ActionButton.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { PrimitiveAs, PrimitiveElement } from "./primitive.ts";
77
88
const {
99
as = "button",
10-
native,
10+
native = undefined,
1111
type = "button",
1212
disabled = false,
1313
loading = false,

npm/ui/core/src/CheckboxControl.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getCheckboxState } from "./checkbox-state.ts";
55
import { useControllableState } from "./controllable-state.ts";
66
77
const {
8-
modelValue,
8+
modelValue = undefined,
99
defaultChecked = false,
1010
indeterminate = false,
1111
disabled = false,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import assert from "node:assert/strict";
2+
// Paths are resolved from the package cwd: the runner virtualizes import.meta.url.
3+
import path from "node:path";
4+
5+
import {
6+
auditComponentAuthoring,
7+
formatAuthoringViolations,
8+
} from "@vizejs/ui-tooling/authoring-gate";
9+
import { test } from "vite-plus/test";
10+
11+
test("every component ships a behavior table, interaction tests, and no source-regex behavior assertions", async () => {
12+
const violations = await auditComponentAuthoring(path.resolve("src"));
13+
assert.equal(formatAuthoringViolations(violations), "");
14+
assert.deepEqual(violations, []);
15+
});

npm/ui/core/src/button.behavior.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Button behavior contract
2+
3+
Normative state × input → outcome table for `ActionButton.vue` (`@vizejs/ui/button`).
4+
Every row is proven by the named mounted-DOM test in `src/button.test.ts`; a row
5+
without a passing test is a contract violation.
6+
7+
| # | State | Input | Outcome | Proven by |
8+
| --- | -------------------- | --------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
9+
| B1 | idle, native | render | `role=button`, accessible name from slot, `type="button"`, `data-state="idle"` | `renders a native button with an accessible name` |
10+
| B2 | idle | Tab | control receives focus | `joins the tab order and focuses programmatically` |
11+
| B3 | idle | exposed `focus()` | control receives focus | `joins the tab order and focuses programmatically` |
12+
| B4 | idle | pointer click | exactly one `press` carrying the `MouseEvent` | `pointer click fires exactly one press` |
13+
| B5 | idle, native | Enter | exactly one `press` (activation on keydown) | `Enter and Space each fire exactly one press on a native button` |
14+
| B6 | idle, native | Space | exactly one `press` (activation on keyup) | `Enter and Space each fire exactly one press on a native button` |
15+
| B7 | idle, non-native | Enter | the component itself clicks the element; exactly one `press` | `Enter and Space activate a non-native button through its own handlers` |
16+
| B8 | idle, non-native | Space | keydown canceled (no page scroll); one `press` on keyup | `Enter and Space activate a non-native button through its own handlers` |
17+
| B9 | idle, non-native | Escape | ignored: not canceled, no `press` | `Enter and Space activate a non-native button through its own handlers` |
18+
| B10 | idle | click, Enter, Space | three `press` emits in dispatch order, each indistinguishably a `MouseEvent` | `keyboard and pointer presses are indistinguishable MouseEvents in order` |
19+
| B11 | disabled, native | render | native `disabled` attribute, no `aria-disabled` mirror, `data-state="disabled"` | `disabled native button removes activation and keeps native semantics` |
20+
| B12 | disabled, native | click / Enter / Space | no `press` | `disabled native button removes activation and keeps native semantics` |
21+
| B13 | disabled, native | Tab | skipped by the tab order | `disabled native button removes activation and keeps native semantics` |
22+
| B14 | disabled, non-native | render | `tabindex="-1"`, `aria-disabled="true"` | `disabled non-native button leaves the tab order and announces aria-disabled` |
23+
| B15 | disabled, non-native | click / Enter / Space / Tab | no `press`; skipped by the tab order | `disabled non-native button leaves the tab order and announces aria-disabled` |
24+
| B16 | loading | render | `aria-busy="true"`, `aria-disabled="true"`, `data-state="loading"`, focusable | `loading button announces busy, stays focusable, and suppresses press` |
25+
| B17 | loading | `focus()` | focus is accepted and retained | `loading button announces busy, stays focusable, and suppresses press` |
26+
| B18 | loading | click / Enter / Space | no `press` | `loading button announces busy, stays focusable, and suppresses press` |
27+
| B19 | any | default slot render | slot receives live `disabled`, `loading`, `unavailable` state | `exposes disabled, loading, and unavailable to the default slot` |
28+
29+
Keyboard activation timing (`Enter` on keydown, `Space` on keyup, `Space` keydown
30+
canceled) is additionally pinned as pure logic by `matches native keyboard
31+
activation timing`.

npm/ui/core/src/button.test.ts

Lines changed: 173 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,187 @@
11
import assert from "node:assert/strict";
2-
import { readFile } from "node:fs/promises";
3-
import { test } from "node:test";
42

5-
import { getButtonKeyboardAction } from "./button-keyboard.ts";
3+
import { test } from "vite-plus/test";
64

7-
const source = await readFile(new URL("./ActionButton.vue", import.meta.url), "utf8");
5+
import ActionButton from "./ActionButton.vue";
6+
import { getButtonKeyboardAction } from "./button-keyboard.ts";
7+
import { mountInteraction } from "./testing/mount.ts";
88

9-
void test("matches native keyboard activation timing", () => {
9+
test("matches native keyboard activation timing", () => {
1010
assert.equal(getButtonKeyboardAction("Enter", "keydown"), "activate");
1111
assert.equal(getButtonKeyboardAction("Enter", "keyup"), "ignore");
1212
assert.equal(getButtonKeyboardAction(" ", "keydown"), "prevent");
1313
assert.equal(getButtonKeyboardAction(" ", "keyup"), "activate");
1414
assert.equal(getButtonKeyboardAction("Escape", "keydown"), "ignore");
1515
});
1616

17-
void test("ships typed disabled and loading semantics in an explicit SFC", () => {
18-
assert.match(source, /defineEmits<\{[\s\S]*press: \[event: MouseEvent\]/);
19-
assert.match(source, /:disabled="isNativeButton \? disabled : undefined"/);
20-
assert.match(source, /:aria-disabled=/);
21-
assert.match(source, /:aria-busy=/);
22-
assert.match(source, /:tabindex="tabIndex"/);
23-
assert.doesNotMatch(source, /\bh\s*\(/);
24-
assert.doesNotMatch(source, /defineOptions|withDefaults|interface (?:Props|Emits)/);
17+
test("renders a native button with an accessible name", () => {
18+
const handle = mountInteraction(ActionButton, { slots: { default: "Save" } });
19+
const button = handle.getByRole("button", { name: "Save" });
20+
21+
assert.equal(button.tagName, "BUTTON");
22+
assert.equal(button.getAttribute("type"), "button");
23+
assert.equal(button.getAttribute("data-vize-ui"), "button");
24+
assert.equal(button.getAttribute("data-state"), "idle");
25+
assert.equal(button.getAttribute("aria-disabled"), null);
26+
assert.equal(button.getAttribute("aria-busy"), null);
27+
handle.unmount();
28+
});
29+
30+
test("joins the tab order and focuses programmatically", async () => {
31+
const handle = mountInteraction(ActionButton, { slots: { default: "Save" } });
32+
const button = handle.getByRole("button");
33+
34+
assert.ok((await handle.tab()) === button, "Tab must move focus to the button");
35+
assert.ok(handle.activeElement() === button);
36+
37+
button.blur();
38+
handle.exposes<{ focus: (options?: FocusOptions) => void }>().focus();
39+
assert.ok(handle.activeElement() === button, "exposed focus() must focus the control");
40+
handle.unmount();
41+
});
42+
43+
test("pointer click fires exactly one press", async () => {
44+
const handle = mountInteraction(ActionButton, { slots: { default: "Save" } });
45+
46+
await handle.click(handle.getByRole("button"));
47+
48+
const presses = handle.wrapper.emitted("press");
49+
assert.equal(presses?.length, 1);
50+
assert.ok(presses?.[0]?.[0] instanceof MouseEvent);
51+
handle.unmount();
52+
});
53+
54+
test("Enter and Space each fire exactly one press on a native button", async () => {
55+
const handle = mountInteraction(ActionButton, { slots: { default: "Save" } });
56+
const button = handle.getByRole("button");
57+
button.focus();
58+
59+
const enter = await handle.press(button, "Enter");
60+
assert.equal(enter.activated, true);
61+
assert.equal(handle.wrapper.emitted("press")?.length, 1);
62+
63+
const space = await handle.press(button, " ");
64+
assert.equal(space.activated, true);
65+
assert.equal(handle.wrapper.emitted("press")?.length, 2);
66+
handle.unmount();
67+
});
68+
69+
test("Enter and Space activate a non-native button through its own handlers", async () => {
70+
const handle = mountInteraction(ActionButton, {
71+
props: { as: "div" },
72+
slots: { default: "Save" },
73+
});
74+
const button = handle.getByRole("button", { name: "Save" });
75+
76+
assert.equal(button.tagName, "DIV");
77+
assert.equal(button.getAttribute("role"), "button");
78+
assert.equal(button.getAttribute("tabindex"), "0");
79+
80+
const enter = await handle.press(button, "Enter");
81+
assert.equal(enter.activated, false, "the component, not the harness, must synthesize clicks");
82+
assert.equal(handle.wrapper.emitted("press")?.length, 1);
83+
84+
const space = await handle.press(button, " ");
85+
assert.equal(space.keydownPrevented, true, "Space keydown must be canceled to prevent scrolling");
86+
assert.equal(handle.wrapper.emitted("press")?.length, 2);
87+
88+
const escape = await handle.press(button, "Escape");
89+
assert.equal(escape.keydownPrevented, false);
90+
assert.equal(handle.wrapper.emitted("press")?.length, 2);
91+
handle.unmount();
92+
});
93+
94+
test("keyboard and pointer presses are indistinguishable MouseEvents in order", async () => {
95+
const handle = mountInteraction(ActionButton, {
96+
slots: { default: "Save" },
97+
record: ["press"],
98+
});
99+
const button = handle.getByRole("button");
100+
button.focus();
101+
102+
await handle.click(button);
103+
await handle.press(button, "Enter");
104+
await handle.press(button, " ");
105+
106+
const recorded = handle.recorded();
107+
assert.equal(recorded.length, 3);
108+
for (const emit of recorded) {
109+
assert.equal(emit.event, "press");
110+
assert.ok(emit.payload[0] instanceof MouseEvent);
111+
}
112+
handle.unmount();
113+
});
114+
115+
test("disabled native button removes activation and keeps native semantics", async () => {
116+
const handle = mountInteraction(ActionButton, {
117+
props: { disabled: true },
118+
slots: { default: "Save" },
119+
});
120+
const button = handle.getByRole("button");
121+
122+
assert.ok(button.hasAttribute("disabled"), "native disabled must be forwarded");
123+
assert.equal(button.getAttribute("aria-disabled"), null, "native disabled needs no aria mirror");
124+
assert.equal(button.getAttribute("data-state"), "disabled");
125+
126+
await handle.click(button);
127+
await handle.press(button, "Enter");
128+
await handle.press(button, " ");
129+
assert.equal(handle.wrapper.emitted("press"), undefined);
130+
131+
assert.ok((await handle.tab()) === null, "a disabled button must leave the tab order");
132+
handle.unmount();
133+
});
134+
135+
test("disabled non-native button leaves the tab order and announces aria-disabled", async () => {
136+
const handle = mountInteraction(ActionButton, {
137+
props: { as: "div", disabled: true },
138+
slots: { default: "Save" },
139+
});
140+
const button = handle.getByRole("button");
141+
142+
assert.equal(button.getAttribute("tabindex"), "-1");
143+
assert.equal(button.getAttribute("aria-disabled"), "true");
144+
145+
await handle.click(button);
146+
await handle.press(button, "Enter");
147+
await handle.press(button, " ");
148+
assert.equal(handle.wrapper.emitted("press"), undefined);
149+
assert.ok((await handle.tab()) === null, "tabindex -1 must remove it from the tab order");
150+
handle.unmount();
151+
});
152+
153+
test("loading button announces busy, stays focusable, and suppresses press", async () => {
154+
const handle = mountInteraction(ActionButton, {
155+
props: { loading: true },
156+
slots: { default: "Save" },
157+
});
158+
const button = handle.getByRole("button");
159+
160+
assert.equal(button.getAttribute("aria-busy"), "true");
161+
assert.equal(button.getAttribute("aria-disabled"), "true");
162+
assert.equal(button.getAttribute("data-state"), "loading");
163+
assert.equal(button.hasAttribute("disabled"), false, "loading must not steal focus");
164+
165+
button.focus();
166+
assert.ok(handle.activeElement() === button, "a busy button must keep accepting focus");
167+
168+
await handle.click(button);
169+
await handle.press(button, "Enter");
170+
await handle.press(button, " ");
171+
assert.equal(handle.wrapper.emitted("press"), undefined);
172+
handle.unmount();
25173
});
26174

27-
void test("exposes focus deliberately and provides programmable slot state", () => {
28-
assert.match(source, /defineExpose\(\{ element, focus \}\)/);
29-
assert.match(source, /<slot :disabled :loading :unavailable \/>/);
30-
assert.match(source, /data-vize-ui="button"/);
31-
assert.match(source, /:data-state=/);
175+
test("exposes disabled, loading, and unavailable to the default slot", async () => {
176+
const handle = mountInteraction(ActionButton, {
177+
slots: {
178+
default: (state: { disabled: boolean; loading: boolean; unavailable: boolean }) =>
179+
`disabled:${state.disabled} loading:${state.loading} unavailable:${state.unavailable}`,
180+
},
181+
});
182+
183+
assert.equal(handle.root().textContent, "disabled:false loading:false unavailable:false");
184+
await handle.wrapper.setProps({ loading: true });
185+
assert.equal(handle.root().textContent, "disabled:false loading:true unavailable:true");
186+
handle.unmount();
32187
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Checkbox behavior contract
2+
3+
Normative state × input → outcome table for `CheckboxControl.vue` (`@vizejs/ui/checkbox`).
4+
Every row is proven by the named mounted-DOM test in `src/checkbox.test.ts`; a row
5+
without a passing test is a contract violation.
6+
7+
| # | State | Input | Outcome | Proven by |
8+
| --- | -------------------- | ------------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------------------- |
9+
| C1 | any | render | native `input[type=checkbox]`, accessible name from `ariaLabel` | `renders a native checkbox with an accessible name and focus control` |
10+
| C2 | any | exposed `focus()` | input receives focus | `renders a native checkbox with an accessible name and focus control` |
11+
| C3 | unchecked | render | `aria-checked="false"`, `data-state="unchecked"` | `reports aria-checked across unchecked, checked, and mixed states` |
12+
| C4 | checked | render | `aria-checked="true"`, `data-state="checked"` | `reports aria-checked across unchecked, checked, and mixed states` |
13+
| C5 | indeterminate | render | `aria-checked="mixed"`, `data-state="indeterminate"`, native `.indeterminate` | `reports aria-checked across unchecked, checked, and mixed states` |
14+
| C6 | uncontrolled | pointer click | toggles; emits `update:modelValue` then `change`, in that order | `toggles with a pointer click and emits model before change` |
15+
| C7 | uncontrolled | Space | toggles exactly once like a native checkbox | `toggles with Space like a native checkbox` |
16+
| C8 | wrapped in `<label>` | click on the label | toggles the checkbox (label association) | `clicking the associated label toggles the checkbox` |
17+
| C9 | controlled | pointer click | emits the request; the rendered state reverts to the prop value | `controlled: the parent-provided value always wins` |
18+
| C10 | controlled | prop update | rendered state follows `modelValue` | `controlled: the parent-provided value always wins` |
19+
| C11 | uncontrolled, seeded | render | `defaultChecked` seeds the initial state | `uncontrolled: defaultChecked seeds state and reset restores it` |
20+
| C12 | uncontrolled, seeded | exposed `reset()` | restores the default state | `uncontrolled: defaultChecked seeds state and reset restores it` |
21+
| C13 | indeterminate | pointer click | emits `update:indeterminate` `false` and `change` `true` | `indeterminate announces mixed and requests clearing on toggle` |
22+
| C14 | disabled | click / Space | no toggle, no `change` | `disabled checkbox ignores pointer and keyboard activation` |
23+
| C15 | disabled | Tab | skipped by the tab order | `disabled checkbox ignores pointer and keyboard activation` |
24+
25+
Mixed-state precedence (`indeterminate` wins over `checked`) is additionally
26+
pinned as pure logic by `gives the mixed visual state precedence`.

0 commit comments

Comments
 (0)