yukey

mc-drive.ts

// Drives the megacolumn gallery demo with TRUSTED key events via CDP,
// reading focus/aria state between steps.  Synthetic JS KeyboardEvents can't
// exercise real Tab traversal or real typing; these can.

import type { Scenario, ScenarioContext } from "yukey-testkit/checks";
import { sleep } from "yukey-testkit/sleep";
import { openGallery } from "../gallery.ts";

// Reads the interesting state through the shadow roots: which demo element holds
// real focus, its aria-activedescendant, ring positions, text mode, and the log line.
const STATE_JS = `
(() => {
  const shell = document.querySelector("yk-gallery").shadowRoot;
  const demos = [...shell.querySelectorAll("ykg-megacolumn-demo")];
  const active = shell.activeElement;
  const out = { realFocus: null, columns: [] };
  for (const demo of demos) {
    const sr = demo.shadowRoot;
    const container = sr.querySelector("[data-mc-container]");
    const label = demo.getAttribute("label");
    if (demo === active) {
      const inner = sr.activeElement;
      out.realFocus = label + "/" + (inner === container ? "container" : (inner?.tagName ?? "host").toLowerCase());
    }
    out.columns.push({
      label,
      aria: container.getAttribute("aria-activedescendant"),
      ringOn: sr.querySelector(".mc-focused")?.textContent?.trim()?.slice(0, 24) ?? null,
      rowFocused: sr.querySelector(".mc-row-focused")?.dataset?.mcRow ?? null,
      textMode: container.classList.contains("mc-text-mode"),
      focusWithin: container.classList.contains("mc-focus-within"),
      log: sr.querySelector(".log")?.textContent ?? "",
      inputValue: sr.querySelector("[data-mc-input]")?.value ?? null,
      cargoCount: sr.querySelectorAll("[data-mc-aux]").length,
    });
  }
  return JSON.stringify(out);
})()
`;

interface McColumn {
	label: string;
	aria: string | null;
	ringOn: string | null;
	rowFocused: string | null;
	textMode: boolean;
	focusWithin: boolean;
	log: string;
	inputValue: string | null;
	cargoCount: number;
}

interface McState {
	realFocus: string | null;
	columns: McColumn[];
}

// State of the nested-scroller demo: the focused row, the box's own scroll
// position, and the page scrollers that must NOT move.
const SCROLL_JS = `
(() => {
  const shell = document.querySelector("yk-gallery").shadowRoot;
  const demo = shell.querySelector("ykg-megacolumn-scroll-demo");
  const sr = demo.shadowRoot;
  const box = sr.querySelector("[data-mc-container]");
  const focused = sr.querySelector(".mc-row-focused");
  const b = box.getBoundingClientRect();
  const r = focused?.getBoundingClientRect();
  return JSON.stringify({
    hasFocus: shell.activeElement === demo,
    rowFocused: focused?.dataset?.mcRow ?? null,
    boxTop: box.scrollTop,
    rowInBox: r ? r.top >= b.top - 1 && r.bottom <= b.bottom + 1 : null,
    mainTop: shell.querySelector("main").scrollTop,
    windowY: window.scrollY,
    log: sr.querySelector(".log")?.textContent ?? "",
  });
})()
`;

interface McScrollState {
	hasFocus: boolean;
	rowFocused: string | null;
	boxTop: number;
	rowInBox: boolean | null;
	mainTop: number;
	windowY: number;
	log: string;
}

async function run(ctx: ScenarioContext): Promise<void> {
	const { cdp, checks } = ctx;
	const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail);
	const st = async (): Promise<McState> => JSON.parse((await cdp.js(STATE_JS)) as string);

	// Fresh demo state every run: navigate to the entry, wait for the demo element.
	await openGallery(
		ctx,
		"#megacolumn",
		"document.readyState === 'complete' && !!document.querySelector('yk-gallery')?.shadowRoot?.querySelector('ykg-megacolumn-demo')",
	);

	// The gallery seats initial focus in the entry's first tab stop on open:
	// after a reload of #megacolumn that is the port container, no click, no Tab.
	// (Seating happens a frame after render; poll briefly.)
	let seated: string | null = null;
	for (let i = 0; i < 20; i++) {
		const s = await st();
		seated = s.realFocus;
		if (seated === "port console/container") break;
		await sleep(50);
	}
	check(
		"initial-focus-seated-on-open",
		seated === "port console/container",
		JSON.stringify(seated),
	);

	// Start clean: blur the DEEP active element (document.activeElement is just
	// the shadow host; blurring the host does not drop inner focus), then Tab
	// until real focus lands back in the first container.
	await cdp.js(
		"(() => { let a = document.activeElement; while (a && a.shadowRoot && a.shadowRoot.activeElement) a = a.shadowRoot.activeElement; if (a) a.blur(); window.scrollTo(0,0); })()",
	);
	let landed = false;
	let s = await st();
	// The cap must exceed the containment cycle's full length: every gallery
	// nav link plus the demo's stops.  Hand-counting rotted once already (a cap
	// of 12 = "six nav links" broke when the gallery grew); count the nav links
	// live and add the demo's own stops with margin.
	const navLinks = (await cdp.js(
		"document.querySelector('yk-gallery').shadowRoot.querySelectorAll('nav a').length",
	)) as number;
	for (let i = 0; i < navLinks + 12; i++) {
		await cdp.key("Tab");
		s = await st();
		if (s.realFocus === "port console/container") {
			landed = true;
			break;
		}
	}
	check("tab-reaches-port-container", landed, JSON.stringify(s.realFocus));
	let port = s.columns[0] as McColumn;
	check(
		"initial-focus-first-row",
		port.rowFocused === "beacon" && port.aria === "mc-beacon",
		JSON.stringify(port),
	);

	// Tab again: ONE tab stop per column -> straight to starboard.
	await cdp.key("Tab");
	s = await st();
	check(
		"tab-skips-rows-to-starboard",
		s.realFocus === "starboard console/container",
		JSON.stringify(s.realFocus),
	);
	check(
		"port-memory-visible-when-blurred",
		s.columns[0]?.rowFocused === "beacon" && s.columns[0]?.focusWithin === false,
		JSON.stringify(s.columns[0]),
	);

	// Back to port; navigate: down x2 -> reactor slider.
	await cdp.key("Tab", { shift: true });
	await cdp.key("ArrowDown");
	await cdp.key("ArrowDown");
	s = await st();
	port = s.columns[0] as McColumn;
	check("arrows-move-row-focus", port.rowFocused === "power", JSON.stringify(port));

	// Slider consumes left/right.
	await cdp.key("ArrowRight");
	await cdp.key("ArrowRight");
	await cdp.key("ArrowLeft");
	s = await st();
	port = s.columns[0] as McColumn;
	check("slider-eats-arrows", port.log.includes("reactor power 45%"), port.log);
	check("slider-row-unmoved", port.rowFocused === "power", JSON.stringify(port));

	// Down into cargo: two-control rows.  Right reaches the aux x; Enter jettisons.
	await cdp.key("ArrowDown");
	s = await st();
	port = s.columns[0] as McColumn;
	check("gap-jump-into-cargo", port.rowFocused === "crate-ore", JSON.stringify(port));
	await cdp.key("ArrowRight");
	s = await st();
	port = s.columns[0] as McColumn;
	check("right-reaches-aux", port.ringOn === "\u00d7", JSON.stringify(port));
	await cdp.key("Enter");
	s = await st();
	port = s.columns[0] as McColumn;
	check(
		"enter-on-aux-removes",
		port.log.includes("ore crate jettisoned") && port.cargoCount === 2,
		JSON.stringify(port),
	);
	check("vanished-row-falls-down", port.rowFocused === "crate-struts", JSON.stringify(port));

	// x hotkey removes from main position too.
	await cdp.key("x");
	s = await st();
	port = s.columns[0] as McColumn;
	check(
		"x-hotkey-removes",
		port.log.includes("strut bundle jettisoned") && port.cargoCount === 1,
		JSON.stringify(port),
	);

	// Enter on main inspects, does NOT remove.
	await cdp.key("Enter");
	s = await st();
	port = s.columns[0] as McColumn;
	check(
		"enter-on-main-inspects",
		port.log.includes("fuel cells inspected") && port.cargoCount === 1,
		JSON.stringify(port),
	);

	// Down to the hail row; Enter dives into the input; type; every key goes in.
	await cdp.key("ArrowDown");
	await cdp.key("Enter");
	s = await st();
	port = s.columns[0] as McColumn;
	check(
		"text-mode-entered",
		port.textMode && s.realFocus === "port console/input",
		JSON.stringify({ f: s.realFocus, tm: port.textMode }),
	);
	for (const ch of "sos x") {
		await cdp.key(ch);
	}
	s = await st();
	port = s.columns[0] as McColumn;
	check("typing-goes-to-input", port.inputValue === "sos x", JSON.stringify(port.inputValue));
	check("x-did-not-jettison-while-typing", port.cargoCount === 1, String(port.cargoCount));

	// Escape exits back to the container; row focus stays on hail.
	await cdp.key("Escape");
	s = await st();
	port = s.columns[0] as McColumn;
	check(
		"escape-exits-text",
		!port.textMode && s.realFocus === "port console/container",
		JSON.stringify({ f: s.realFocus, tm: port.textMode }),
	);
	check("row-focus-resumed-on-hail", port.rowFocused === "hail", JSON.stringify(port));

	// Down reaches the offline encrypted row: visible-but-inactive rows are
	// nav stops (i must be able to reach them; stride counts stay stable).
	await cdp.key("ArrowDown");
	s = await st();
	port = s.columns[0] as McColumn;
	check("offline-row-is-a-stop", port.rowFocused === "encrypted", JSON.stringify(port));
	// ... but activation is dead there: Enter changes nothing.
	const beforeLog = port.log;
	await cdp.key("Enter");
	s = await st();
	port = s.columns[0] as McColumn;
	check("offline-activation-dead", port.log === beforeLog, port.log);

	// Home jumps to the first row.
	await cdp.key("Home");
	s = await st();
	port = s.columns[0] as McColumn;
	check("home-jumps-to-first", port.rowFocused === "beacon", JSON.stringify(port));

	// Enter activates the beacon row (plain activatable).
	await cdp.key("Enter");
	s = await st();
	port = s.columns[0] as McColumn;
	check("enter-activates-row", port.log.includes("beacon cycled (1)"), port.log);
	await cdp.key(" ");
	s = await st();
	port = s.columns[0] as McColumn;
	check("space-mirrors-enter", port.log.includes("beacon cycled (2)"), port.log);

	// Hotkey jumps: the gold letters move focus, Enter commits (see-before-commit).
	// g leaps from the top section to the bottom one; the log must NOT change on
	// the jump itself.
	await cdp.key("g");
	s = await st();
	port = s.columns[0] as McColumn;
	check("hotkey-jumps-to-gyros", port.rowFocused === "gyros", JSON.stringify(port));
	check("jump-does-not-activate", port.log.includes("beacon cycled (2)"), port.log);
	await cdp.key("Enter");
	s = await st();
	port = s.columns[0] as McColumn;
	check("enter-commits-after-jump", port.log.includes("gyros recalibrated"), port.log);
	// The offline row is a jump target too: e lands there (i can ask why it's off).
	await cdp.key("e");
	s = await st();
	port = s.columns[0] as McColumn;
	check("jump-reaches-offline-row", port.rowFocused === "encrypted", JSON.stringify(port));
	// ... and b jumps back up to the beacon row for the phases below.
	await cdp.key("b");
	s = await st();
	port = s.columns[0] as McColumn;
	check("jump-back-to-beacon", port.rowFocused === "beacon", JSON.stringify(port));

	// Tab away and back: focus memory.
	await cdp.key("ArrowDown"); // -> dock, a memorable non-default row
	await cdp.key("Tab");
	await cdp.key("Tab", { shift: true });
	s = await st();
	port = s.columns[0] as McColumn;
	check(
		"focus-memory-across-tab",
		port.rowFocused === "dock" && s.realFocus === "port console/container",
		JSON.stringify({ f: s.realFocus, r: port.rowFocused }),
	);

	// Starboard is untouched by all of this.
	const sb = s.columns[1] as McColumn;
	check(
		"starboard-independent",
		sb.rowFocused === "beacon" && sb.cargoCount === 3 && sb.log === "",
		JSON.stringify(sb),
	);

	// --- pointer activation: a trusted CLICK is the same event as Enter ---
	// Plain rows have no widget: their activation is the controller's synthesized
	// megacolumn-key.  A tap produces a click, so this is also the touch story.
	const portCenter = async (selector: string): Promise<{ x: number; y: number }> => {
		const v = await cdp.js(`
		(() => { const el = document.querySelector("yk-gallery").shadowRoot
		     .querySelector("ykg-megacolumn-demo").shadowRoot.querySelector('${selector}');
		     el.scrollIntoView({block: "nearest", behavior: "instant"});
		     const r = el.getBoundingClientRect();
		     return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })()
		`);
		return JSON.parse(v as string);
	};

	// Click the beacon row (a plain activatable row: earlier Enter and Space
	// left its counter at 2, so a working click reads 3).
	let pt = await portCenter("[data-mc-row=\"beacon\"]");
	await cdp.click(pt.x, pt.y);
	s = await st();
	port = s.columns[0] as McColumn;
	check("click-activates-plain-row", port.log.includes("beacon cycled (3)"), port.log);
	check("click-focuses-clicked-row", port.rowFocused === "beacon", JSON.stringify(port));

	// Click the surviving crate's aux ×: the synthesis carries the aux position,
	// so the click removes exactly as Enter-on-aux does.
	pt = await portCenter("[data-mc-row=\"crate-cells\"] [data-mc-aux]");
	await cdp.click(pt.x, pt.y);
	s = await st();
	port = s.columns[0] as McColumn;
	check(
		"click-on-aux-removes",
		port.log.includes("fuel cells jettisoned") && port.cargoCount === 0,
		JSON.stringify(port),
	);

	// --- nested scroller: the relay-bank variant (28 static rows in a 240px
	// overflow-y box).  The controller's scrollIntoView(block:"nearest") must
	// scroll the BOX, never the page (the gallery's page scroller is the
	// shell's <main>; window never scrolls in this layout, asserted anyway). ---
	const sst = async (): Promise<McScrollState> => JSON.parse((await cdp.js(SCROLL_JS)) as string);

	// Tab from port down to the relay bank (starboard and the ops console are
	// tab stops on the way; hunt, don't count).  Tabbing in seats row focus on
	// relay-01 and MAY scroll the page scroller to reveal the demo -- so the
	// page-scroll baseline is recorded after landing, not before.
	landed = false;
	let ss = await sst();
	for (let i = 0; i < 8; i++) {
		await cdp.key("Tab");
		ss = await sst();
		if (ss.hasFocus) {
			landed = true;
			break;
		}
	}
	check(
		"nested-tab-reaches-relay-bank",
		landed && ss.rowFocused === "relay-01" && ss.boxTop === 0,
		JSON.stringify(ss),
	);
	const pageBefore = [ss.mainTop, ss.windowY] as const;
	const pageSame = (v: McScrollState) => v.mainTop === pageBefore[0] && v.windowY === pageBefore[1];

	// A hotkey jump across the scroller: 2 lands on relay-20, far off-screen
	// from the top -- the box must scroll, the page must not.
	await cdp.key("2");
	ss = await sst();
	check("nested-jump-lands-on-relay-20", ss.rowFocused === "relay-20", JSON.stringify(ss));
	check("nested-jump-scrolls-the-box", ss.boxTop > 0, JSON.stringify(ss));
	check(
		"nested-jump-leaves-page-alone",
		pageSame(ss),
		JSON.stringify([pageBefore, ss.mainTop, ss.windowY]),
	);
	check("nested-jump-row-visible-in-box", ss.rowInBox === true, JSON.stringify(ss));

	// End slams to the last relay; Home slams back and the box must return
	// exactly to the top (relay-01 flush at scrollTop 0).
	await cdp.key("End");
	ss = await sst();
	check(
		"nested-end-reaches-last-in-view",
		ss.rowFocused === "relay-28" && ss.rowInBox === true,
		JSON.stringify(ss),
	);
	await cdp.key("Home");
	ss = await sst();
	check(
		"nested-home-returns-box-to-top",
		ss.rowFocused === "relay-01" && ss.boxTop === 0 && ss.rowInBox === true,
		JSON.stringify(ss),
	);
	check(
		"nested-slams-leave-page-alone",
		pageSame(ss),
		JSON.stringify([pageBefore, ss.mainTop, ss.windowY]),
	);
}

export const mcDrive: Scenario = { name: "mc", run };

packages/e2e/scenarios/mc-drive.ts · 15.1 KiB · 19cdc86d8c