yukey

ip-drive.ts

// Drives the yk-infopop gallery entry with TRUSTED input via CDP: keys via
// Input.dispatchKeyEvent and real mouse movement via Input.dispatchMouseEvent
// (synthetic pointerover can't prove real hover).  Covers: keyboard i on every
// row flavor (yk-button's own claim, the plain-row/slider/aux data-info
// fallback), the i toggle (second i on the same anchor dismisses; scope is one
// keypress), hover raising the SAME content, pin precedence over hover (incl.
// i upgrading a hover popup to pinned), dismissal (nav keys close AND navigate,
// pointerdown closes, no resurrect), aria reflection wiring, edge clamping,
// and the bottom flip (short-viewport emulation).

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

// State through the shadow roots: the popup's mode/content/position, the demo
// column's focused row and log, and the aria reflection on the current anchor.
const STATE_JS = `
(() => {
  const shell = document.querySelector("yk-gallery").shadowRoot;
  const pop = shell.querySelector("yk-infopop");
  const col = shell.querySelector("ykg-infopop-column")?.shadowRoot;
  const rect = pop.getBoundingClientRect();
  const anchorRect = pop.anchor?.getBoundingClientRect() ?? null;
  return JSON.stringify({
    mode: pop.mode,
    text: pop.shadowRoot.textContent.trim(),
    visible: pop.style.visibility === "visible",
    ariaHidden: pop.getAttribute("aria-hidden"),
    popRect: {x: rect.x, y: rect.y, w: rect.width, h: rect.height},
    anchorRect: anchorRect ? {x: anchorRect.x, y: anchorRect.y, w: anchorRect.width, h: anchorRect.height} : null,
    anchorAriaWired: pop.anchor ? (pop.anchor.ariaDescribedByElements?.[0] === pop) : null,
    rowFocused: col?.querySelector(".mc-row-focused")?.dataset?.mcRow ?? null,
    log: col?.querySelector(".log")?.textContent ?? "",
    viewport: {w: window.innerWidth, h: window.innerHeight},
  });
})()
`;

interface Rect {
	x: number;
	y: number;
	w: number;
	h: number;
}

interface IpState {
	mode: string;
	text: string;
	visible: boolean;
	ariaHidden: string | null;
	popRect: Rect;
	anchorRect: Rect | null;
	anchorAriaWired: boolean | null;
	rowFocused: string | null;
	log: string;
	viewport: { w: number; h: number };
}

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<IpState> => JSON.parse((await cdp.js(STATE_JS)) as string);
	const centerOf = async (jsElExpr: string): Promise<{ x: number; y: number }> => {
		const box = await cdp.js(`
		(() => {
		  const el = ${jsElExpr};
		  const r = el.getBoundingClientRect();
		  return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2});
		})()
		`);
		return JSON.parse(box as string);
	};

	const COL = "document.querySelector('yk-gallery').shadowRoot.querySelector('ykg-infopop-column').shadowRoot";

	await openGallery(
		ctx,
		"#yk-infopop",
		"document.readyState === 'complete' && !!document.querySelector('yk-gallery')?.shadowRoot?.querySelector('ykg-infopop-column')",
	);

	// Park the mouse in dead space so no hover state lingers between phases.
	await cdp.mouseMove(5, 5);

	// --- keyboard: i on every row flavor ---
	let landed = false;
	for (let i = 0; i < 12; i++) {
		await cdp.key("Tab");
		const focused = await cdp.js(
			`${COL}.querySelector('[data-mc-container]').classList.contains('mc-focus-within')`,
		);
		if (focused === true) {
			landed = true;
			break;
		}
	}
	let s = await st();
	check("tab-into-column", landed && s.rowFocused === "dock", JSON.stringify(s.rowFocused));

	await cdp.key("i");
	s = await st();
	check("i-on-yk-button-pins", s.mode === "pinned" && s.visible, JSON.stringify(s));
	check("i-on-yk-button-content", s.text.includes("a yk-button answers i itself"), s.text);
	check("aria-reflection-wired", s.anchorAriaWired === true, JSON.stringify(s.anchorAriaWired));
	check(
		"popup-below-anchor",
		s.anchorRect !== null && s.popRect.y > s.anchorRect.y,
		JSON.stringify(s.popRect),
	);
	await shot(cdp, "kb-pinned.png");

	// The toggle: i again on the same anchor dismisses (no blink-reopen)...
	await cdp.key("i");
	s = await st();
	check("i-toggles-off-yk-button", s.mode === "closed", JSON.stringify(s.mode));
	// ... and a fresh press raises it right back (suppression is one keypress).
	await cdp.key("i");
	s = await st();
	check(
		"i-reopens-yk-button",
		s.mode === "pinned" && s.text.includes("a yk-button answers i itself"),
		s.text,
	);

	// Navigation closes the popup AND still navigates (never eats keys).
	await cdp.key("ArrowDown");
	s = await st();
	check("nav-key-closes", s.mode === "closed" && s.ariaHidden === "true", JSON.stringify(s.mode));
	check("nav-key-still-navigates", s.rowFocused === "survey", JSON.stringify(s.rowFocused));

	// Plain text row: the controller's data-info fallback answers.
	await cdp.key("i");
	s = await st();
	check("i-on-plain-row", s.mode === "pinned" && s.text.includes("plain text row"), s.text);

	// The toggle again, through the other producer (the controller fallback).
	await cdp.key("i");
	s = await st();
	check("i-toggles-off-plain-row", s.mode === "closed", JSON.stringify(s.mode));
	await cdp.key("i");
	s = await st();
	check("i-reopens-plain-row", s.mode === "pinned" && s.text.includes("plain text row"), s.text);

	// One keypress closes the old popup and raises the next (i on the next row).
	await cdp.key("ArrowDown");
	await cdp.key("i");
	s = await st();
	check("i-moves-with-focus", s.mode === "pinned" && s.text.includes("slider row"), s.text);

	// The slider still works under/after the popup: left/right adjust.
	await cdp.key("ArrowRight");
	s = await st();
	check(
		"slider-key-closes-and-acts",
		s.mode === "closed" && s.log === "reactor power 45%",
		JSON.stringify({ mode: s.mode, log: s.log }),
	);

	// The aux x carries its own story, distinct from its row's.
	await cdp.key("ArrowDown");
	await cdp.key("i");
	s = await st();
	check("i-on-two-control-main", s.text.includes("probe telemetry feed"), s.text);
	await cdp.key("ArrowRight");
	await cdp.key("i");
	s = await st();
	check("i-on-aux-x", s.text.includes("the x's own story"), s.text);

	// Escape closes; nothing resurrects.
	await cdp.key("Escape");
	await sleep(800);
	s = await st();
	check("escape-closes-no-resurrect", s.mode === "closed", JSON.stringify(s.mode));

	// --- hover: the same content, raised by the mouse ---
	let pt = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`);
	await cdp.mouseMove(pt.x, pt.y);
	await sleep(150);
	s = await st();
	check("hover-waits-out-the-delay", s.mode === "closed", JSON.stringify(s.mode));
	await sleep(500);
	s = await st();
	check(
		"hover-raises-same-content",
		s.mode === "hover" && s.text.includes("plain text row"),
		s.text,
	);
	await shot(cdp, "hover-same-content.png");

	// Hover follows to the aux x (nearest carrier wins over the row's).
	pt = await centerOf(`${COL}.querySelector('[data-mc-aux]')`);
	await cdp.mouseMove(pt.x, pt.y);
	await sleep(500);
	s = await st();
	check(
		"hover-nearest-carrier",
		s.mode === "hover" && s.text.includes("the x's own story"),
		s.text,
	);

	// Leaving all carriers closes.
	await cdp.mouseMove(5, 5);
	await sleep(100);
	s = await st();
	check("hover-leave-closes", s.mode === "closed", JSON.stringify(s.mode));

	// --- precedence: a pinned popup outranks hover ---
	// (Real focus is still on the column from the keyboard phase.)
	await cdp.key("Home");
	await cdp.key("i");
	s = await st();
	check(
		"pin-for-precedence",
		s.mode === "pinned" && s.text.includes("a yk-button answers i itself"),
		s.text,
	);
	pt = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`);
	await cdp.mouseMove(pt.x, pt.y);
	await sleep(600);
	s = await st();
	check(
		"hover-never-displaces-pin",
		s.mode === "pinned" && s.text.includes("a yk-button answers i itself"),
		s.text,
	);
	await shot(cdp, "pin-outranks-hover.png");
	// After the pin closes, the parked hover does not resurrect...
	await cdp.key("Escape");
	await sleep(600);
	s = await st();
	check("no-hover-resurrect-after-pin", s.mode === "closed", JSON.stringify(s.mode));
	// ... but a fresh pointer move re-arms normally.
	await cdp.mouseMove(pt.x, pt.y + 1);
	await cdp.mouseMove(5, 5);
	await sleep(50);
	const pt2 = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`);
	await cdp.mouseMove(pt2.x, pt2.y);
	await sleep(500);
	s = await st();
	check("fresh-hover-re-arms", s.mode === "hover", JSON.stringify(s.mode));

	// i on the element the mouse is hovering PINS the hover popup (only pinned
	// popups toggle off).  Focus must sit on the same row for i to answer.
	await cdp.key("ArrowDown"); // focus the survey row; the keydown closes the hover popup
	await cdp.mouseMove(5, 5);
	await sleep(50);
	await cdp.mouseMove(pt2.x, pt2.y); // a fresh move re-raises hover on survey
	await sleep(500);
	s = await st();
	check("hover-up-for-upgrade", s.mode === "hover", JSON.stringify(s.mode));
	await cdp.key("i");
	s = await st();
	check(
		"i-upgrades-hover-to-pin",
		s.mode === "pinned" && s.text.includes("plain text row"),
		s.text,
	);
	// And the freshly pinned popup toggles off like any other; reopen so the
	// pointerdown check below still closes something real.
	await cdp.key("i");
	s = await st();
	check("upgraded-pin-toggles-off", s.mode === "closed", JSON.stringify(s.mode));
	await cdp.key("i");
	s = await st();
	check("i-reopens-before-click", s.mode === "pinned", JSON.stringify(s.mode));

	// Pointerdown closes (trusted click in dead space).
	await cdp.click(5, 5);
	s = await st();
	check("pointerdown-closes", s.mode === "closed", JSON.stringify(s.mode));

	// --- edges: clamping and the flip ---
	const EDGES = "document.querySelector('yk-gallery').shadowRoot.querySelector('ykg-infopop-edges').shadowRoot";
	pt = await centerOf(`${EDGES}.querySelectorAll('yk-button')[1]`); // far right
	await cdp.mouseMove(pt.x, pt.y);
	await sleep(500);
	s = await st();
	const anchor = s.anchorRect as Rect;
	const anchorCenter = anchor.x + anchor.w / 2;
	const centeredX = anchorCenter - s.popRect.w / 2;
	check(
		"right-edge-clamps",
		s.mode === "hover" && s.popRect.x + s.popRect.w <= s.viewport.w && s.popRect.x < centeredX - 1,
		JSON.stringify({ pop: s.popRect, viewport: s.viewport }),
	);
	await shot(cdp, "edge-clamp.png");
	await cdp.mouseMove(5, 5);

	// The flip: shrink the viewport so the edges row sits near the bottom,
	// then hover it -- the popup must open ABOVE the anchor.
	const targetH = await cdp.js(`
	(() => {
	  const el = ${EDGES}.querySelectorAll('yk-button')[0];
	  return Math.round(el.getBoundingClientRect().bottom + 20);
	})()
	`);
	await cdp.cmd("Emulation.setDeviceMetricsOverride", {
		width: 1280,
		height: Number(targetH),
		deviceScaleFactor: 1,
		mobile: false,
	});
	await sleep(200);
	pt = await centerOf(`${EDGES}.querySelectorAll('yk-button')[0]`); // far left
	await cdp.mouseMove(pt.x, pt.y);
	await sleep(500);
	s = await st();
	check(
		"bottom-edge-flips-above",
		s.mode === "hover" && s.anchorRect !== null && s.popRect.y + s.popRect.h <= s.anchorRect.y,
		JSON.stringify({ pop: s.popRect, anchor: s.anchorRect }),
	);
	check("left-edge-clamps", s.popRect.x >= 0, JSON.stringify(s.popRect));
	await shot(cdp, "edge-flip.png");
	await cdp.cmd("Emulation.clearDeviceMetricsOverride");
	await cdp.mouseMove(5, 5);
}

export const ipDrive: Scenario = { name: "ip", run };

packages/e2e/scenarios/ip-drive.ts · 11.3 KiB · e5e792966c