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) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-01 02:30:28 +08:00
committed by GitHub
parent d5c3f39871
commit eebbb37118
2 changed files with 401 additions and 1 deletions
@@ -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 <Text> (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(() => () => <Text aria-label="Screen-reader only" />),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Screen-reader only");
});
// Ink screen-reader.tsx:86-92 — aria-label-only <Box> (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(() => () => <Box aria-label="Screen-reader only" />),
{ 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(() => () => (
<Box>
<Box display="none">
<Text>Hidden</Text>
</Box>
<Text>Visible</Text>
</Box>
)),
{ 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(() => () => (
<Box flexDirection="column">
<Box flexDirection="row">
<Text>Line 1</Text>
<Text>Line 2</Text>
</Box>
</Box>
)),
{ 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(() => () => (
<Box aria-role="checkbox" aria-state={{ checked: true }}>
<Text>Accept terms</Text>
</Box>
)),
{ 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(() => () => (
<Box aria-role="option" aria-state={{ selected: true }}>
<Text>Blue</Text>
</Box>
)),
{ 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(() => () => (
<Box aria-role="listbox" aria-state={{ multiselectable: true }}>
<Text>Options</Text>
</Box>
)),
{ 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(() => () => (
<Box aria-role="checkbox" aria-state={{ checked: true, disabled: true }}>
<Text>X</Text>
</Box>
)),
{ 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(() => () => (
<Box aria-role="button">
<Text>Click me</Text>
</Box>
)),
{ 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(() => () => (
<Transform transform={(s: string) => s.toUpperCase()} accessibilityLabel="X">
<Text>lowercase</Text>
</Transform>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("X");
});
});