From eebbb37118a8ac5d22b559fcd27fa8a8965befab Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 1 Jun 2026 02:30:28 +0800 Subject: [PATCH] test(runtime): lock focus activeId + screen-reader coverage, mirroring Ink (#113) Round-2 test-only locks (behaviors already at parity): - focus (Ink test/focus.tsx): activeId resets to null on Esc, updates on programmatic focus(id), resets on unmount of the focused item, is null initially then Tab lands on first; Esc does NOT clear focus while focus management is disabled; focus(id) targets a deactivated (isActive=false) item (membership-only, ignoring isActive). - screen-reader (Ink test/screen-reader.tsx) via the live renderToString component path: aria-label-only Text/Box, display:none subtree skipped, column>row space-join, single-box aria-states (checked/selected/multiselectable + multi-state ', '-join), role-only button, Transform accessibilityLabel replaces children. Codex-reviewed GENUINE (expected strings match vue output + Ink intent). Co-authored-by: Claude Opus 4.8 (1M context) --- .../accessibility/screen-reader.test.tsx | 148 ++++++++++ .../integration/focus/focus-manager.test.tsx | 254 +++++++++++++++++- 2 files changed, 401 insertions(+), 1 deletion(-) diff --git a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx index 960451c..bd9ea17 100644 --- a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx +++ b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx @@ -1125,3 +1125,151 @@ describe("screen reader enabled mode", () => { expect(output).toBe("Alpha\nBeta"); }); }); + +// Ink-parity LOCKS for the component (renderToString) SR path. Each mirrors an +// assertion in Ink's test/screen-reader.tsx (v7.0.4 @40b3a75) that the existing +// suite above did not already cover. +describe("screen reader: Ink test/screen-reader.tsx parity (component path)", () => { + // Ink screen-reader.tsx:78-84 — aria-label-only (no children) emits the + // label. Component path: Text.ts substitutes ariaLabel for an absent default slot. + test("aria-label-only Text (no children) emits the label", () => { + const output = renderToString( + defineComponent(() => () => ), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("Screen-reader only"); + }); + + // Ink screen-reader.tsx:86-92 — aria-label-only (no children) emits the + // label. Component path: Box.ts builds a label text node when SR + ariaLabel and + // there is no default slot. + test("aria-label-only Box (no children) emits the label", () => { + const output = renderToString( + defineComponent(() => () => ), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("Screen-reader only"); + }); + + // Ink screen-reader.tsx:110-122 — a display:none subtree is skipped in SR + // output via the LIVE component path (renderToString runs a real yoga layout, so + // the DISPLAY_NONE check in screen-reader.ts is exercised against live yoga, not + // a hand-built fixture). + test("display:none subtree is skipped (live component path)", () => { + const output = renderToString( + defineComponent(() => () => ( + + + Hidden + + Visible + + )), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("Visible"); + }); + + // Ink screen-reader.tsx:320-334 ("render nested row") — a COLUMN parent whose + // child is a ROW joins the grandchildren with a SPACE (the row separator), + // distinct from the column case at :304-318 which joins with "\n". + test("column parent containing a row child joins grandchildren with a space", () => { + const output = renderToString( + defineComponent(() => () => ( + + + Line 1 + Line 2 + + + )), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("Line 1 Line 2"); + }); + + // Ink screen-reader.tsx:186-196 — single box, checkbox role + checked state. + test("single box checkbox + checked", () => { + const output = renderToString( + defineComponent(() => () => ( + + Accept terms + + )), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("checkbox: (checked) Accept terms"); + }); + + // Ink screen-reader.tsx:277-287 — single box, option role + selected state. + test("single box option + selected", () => { + const output = renderToString( + defineComponent(() => () => ( + + Blue + + )), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("option: (selected) Blue"); + }); + + // Ink screen-reader.tsx:238-248 — single box, listbox role + multiselectable. + test("single box listbox + multiselectable", () => { + const output = renderToString( + defineComponent(() => () => ( + + Options + + )), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("listbox: (multiselectable) Options"); + }); + + // Multiple truthy aria-state keys join with ", " (screen-reader.ts:216). Insertion + // order is preserved (Object.keys), so checked precedes disabled. This pins the + // multi-state join format, which Ink derives the same way from its aria-state object. + test("multiple truthy aria-state keys join with comma-space", () => { + const output = renderToString( + defineComponent(() => () => ( + + X + + )), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("checkbox: (checked, disabled) X"); + }); + + // Ink screen-reader.tsx:32-43 — role-only box (no state) via the component path + // (the existing suite only covered this through the hand-built unit fixture). + test("role-only box (button) via component path", () => { + const output = renderToString( + defineComponent(() => () => ( + + Click me + + )), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("button: Click me"); + }); + + // Ink Transform.tsx:37-39 — in SR mode, accessibilityLabel REPLACES the children, + // and because the label is substituted as the transform node's content, the + // transform's OWN fn is NOT applied at the top level (screen-reader.ts leaves the + // top-level transform fn to its caller). So a lowercase child + an uppercasing + // transform still yields the bare label, NOT "X" uppercased nor "LOWERCASE". + test("Transform accessibilityLabel replaces children; transform not applied at top level", () => { + const output = renderToString( + defineComponent(() => () => ( + s.toUpperCase()} accessibilityLabel="X"> + lowercase + + )), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("X"); + }); +}); diff --git a/packages/runtime-tests/integration/focus/focus-manager.test.tsx b/packages/runtime-tests/integration/focus/focus-manager.test.tsx index 53065b0..3535901 100644 --- a/packages/runtime-tests/integration/focus/focus-manager.test.tsx +++ b/packages/runtime-tests/integration/focus/focus-manager.test.tsx @@ -1,4 +1,4 @@ -import { defineComponent } from "vue"; +import { defineComponent, shallowRef } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; import { Box, Text, useFocus, useFocusManager } from "@vue-tui/runtime"; @@ -62,3 +62,255 @@ test("useFocusManager().activeId is null when nothing is focused", async () => { expect(activeId.value).toBeNull(); }); + +// LOCK: Esc resets activeId. Mirrors Ink focus.tsx:621-646 ("activeId resets to +// undefined on Esc"), with vue's sentinel `null` where Ink uses `undefined`. +test("useFocusManager().activeId resets to null on Esc", async () => { + let activeId!: ReturnType["activeId"]; + + const Item = defineComponent({ + props: { id: { type: String, required: true } }, + setup(props) { + const { isFocused } = useFocus({ id: props.id, autoFocus: props.id === "first" }); + return () => ( + + {isFocused.value ? "▶ " : " "} + {props.id} + + ); + }, + }); + + const App = defineComponent(() => { + const manager = useFocusManager(); + activeId = manager.activeId; + return () => ( + + + + ); + }); + + const { stdin } = await render(App); + + expect(activeId.value).toBe("first"); + + // Bare Esc (\x1b) — the harness write() waits out the pending-escape flush. + await stdin.write("\x1b"); + expect(activeId.value).toBeNull(); +}); + +// LOCK: programmatic focus(id) updates activeId. Mirrors Ink focus.tsx:670-706 +// ("activeId updates when focus is changed programmatically"). +test("useFocusManager().activeId updates on programmatic focus(id)", async () => { + let activeId!: ReturnType["activeId"]; + let focus!: ReturnType["focus"]; + + const Item = defineComponent({ + props: { id: { type: String, required: true } }, + setup(props) { + const { isFocused } = useFocus({ id: props.id }); + return () => ( + + {isFocused.value ? "▶ " : " "} + {props.id} + + ); + }, + }); + + const App = defineComponent(() => { + const manager = useFocusManager(); + activeId = manager.activeId; + focus = manager.focus; + return () => ( + + + + + ); + }); + + await render(App); + + // No autoFocus → nothing active initially. + expect(activeId.value).toBeNull(); + + focus("second"); + expect(activeId.value).toBe("second"); + + focus("first"); + expect(activeId.value).toBe("first"); +}); + +// LOCK: unmounting the focused item resets activeId. Mirrors Ink focus.tsx:708-742 +// ("activeId resets to undefined when focused component unmounts"). Vue uses a +// v-if (`show`) toggle in place of Ink's rerender-without-the-child. +test("useFocusManager().activeId resets to null when the focused item unmounts", async () => { + let activeId!: ReturnType["activeId"]; + const showFirst = shallowRef(true); + + const Item = defineComponent({ + props: { id: { type: String, required: true }, autoFocus: Boolean }, + setup(props) { + const { isFocused } = useFocus({ id: props.id, autoFocus: props.autoFocus }); + return () => ( + + {isFocused.value ? "▶ " : " "} + {props.id} + + ); + }, + }); + + const App = defineComponent(() => { + const manager = useFocusManager(); + activeId = manager.activeId; + return () => ( + + {showFirst.value ? : null} + + + ); + }); + + const { waitUntilRenderFlush } = await render(App); + + expect(activeId.value).toBe("first"); + + // Unmount the focused item — remove() clears activeId when it was active. + showFirst.value = false; + await waitUntilRenderFlush(); + + expect(activeId.value).toBeNull(); +}); + +// LOCK: initial activeId is null and Tab from no-focus lands on the first +// focusable, then advances. Mirrors Ink focus.tsx:591-619 ("activeId from +// useFocusManager reflects currently focused component"); vue's empty sentinel +// is `null` where Ink starts at `undefined`. +test("initial activeId is null; Tab from no focus lands on the first focusable then advances", async () => { + let activeId!: ReturnType["activeId"]; + + const Item = defineComponent({ + props: { id: { type: String, required: true } }, + setup(props) { + const { isFocused } = useFocus({ id: props.id }); + return () => ( + + {isFocused.value ? "▶ " : " "} + {props.id} + + ); + }, + }); + + const App = defineComponent(() => { + const manager = useFocusManager(); + activeId = manager.activeId; + return () => ( + + + + + ); + }); + + const { stdin } = await render(App); + + // No autoFocus → nothing active. + expect(activeId.value).toBeNull(); + + await stdin.write("\t"); + expect(activeId.value).toBe("a"); + + await stdin.write("\t"); + expect(activeId.value).toBe("b"); +}); + +// LOCK: Esc must NOT clear focus while focus management is disabled. Mirrors Ink +// App.tsx:250-252 — the Esc reset is gated on `isFocusEnabled`. After +// disableFocus(), the bare Esc handler is a no-op and the focused item stays +// focused. +test("Esc does not clear focus while focus management is disabled", async () => { + let activeId!: ReturnType["activeId"]; + let disableFocus!: ReturnType["disableFocus"]; + + const Item = defineComponent({ + props: { id: { type: String, required: true } }, + setup(props) { + const { isFocused } = useFocus({ id: props.id, autoFocus: props.id === "first" }); + return () => ( + + {isFocused.value ? "▶ " : " "} + {props.id} + + ); + }, + }); + + const App = defineComponent(() => { + const manager = useFocusManager(); + activeId = manager.activeId; + disableFocus = manager.disableFocus; + return () => ( + + + + ); + }); + + const { stdin } = await render(App); + + expect(activeId.value).toBe("first"); + + disableFocus(); + await stdin.write("\x1b"); + + // Focus is disabled → Esc is a no-op → focus is STILL shown. + expect(activeId.value).toBe("first"); +}); + +// LOCK: focus(id) targets a DEACTIVATED (isActive=false) item. Mirrors Ink +// App.tsx:519-531 — `focus` only checks membership (`hasFocusableId`), NOT +// isActive, so a programmatically-focused item that is currently inactive still +// becomes the active focus. (Tab navigation, by contrast, skips inactive items.) +test("focus(id) targets a deactivated (isActive=false) item", async () => { + let activeId!: ReturnType["activeId"]; + let focus!: ReturnType["focus"]; + + const Item = defineComponent({ + props: { id: { type: String, required: true }, active: { type: Boolean, default: true } }, + setup(props) { + const { isFocused } = useFocus({ id: props.id, isActive: () => props.active }); + return () => ( + + {isFocused.value ? "▶ " : " "} + {props.id} + + ); + }, + }); + + const App = defineComponent(() => { + const manager = useFocusManager(); + activeId = manager.activeId; + focus = manager.focus; + return () => ( + + + + + + ); + }); + + await render(App); + + // Nothing active initially. + expect(activeId.value).toBeNull(); + + // focus() ignores isActive — the deactivated middle item still becomes active. + focus("middle"); + expect(activeId.value).toBe("middle"); +});