yukey

lc-drive.ts

// Drives the sw-list gallery entry (the list region component composed into a
// megacolumn console; sw-live-list under it) with TRUSTED input via CDP.
//
// The demos churn on their own 4s timers; the scenario STOPS those timers up
// front and drives data churn via JS at chosen moments -- data churn is
// programmatic in the real app too, and the thing under test is exactly the
// race between that and trusted keyboard input.  Covers: navigation into and
// through live-list rows, ghost navigability (Enter-on-ghost inspects; aux
// gone), aux removal, x/Delete (each flashing the x as acknowledgment), the
// key-activity pin (holds the FOCUSED ghost through a sub-200ms key burst;
// decays keyPinMs=200 after the last key; an UNFOCUSED ghost collapses on its
// own schedule despite the same typing), the pointerSafety split (click-guard,
// the default: hovering defers nothing and a trusted click on a row displaced
// by an in-flight collapse is swallowed while rows above the motion stay
// clickable; freeze, the opt-in: the global hover pin, pending insertions, and
// the pointer-leave release), the churn lockout (an Enter landing right after
// a fallback move is DROPPED; flows again after the window), instant prepend
// under key burst (p), focus memory across Tab-away, the drain-to-empty +
// revive story, and the LIST NORMS, sw-list's own behavior: the header row
// (a nav stop whose focus washes the whole list region) and the add-row
// (a dives straight into text entry -- the direct marking on a text row;
// Enter commits via the cancelable sw-list-add event; Escape abandons), and
// the LIST-SCOPED KEYS: t (back to the list's header) and n (past the list),
// claimed by the sw-list host element off the bubbling megacolumn-key dispatch
// and answered with megacolumn-focus-request; outside the list's rows the
// letters are nobody's.  Structural row ids are name-prefixed by sw-list
// (name="queue": queue-header, queue-add).
// Ported from the Python original (demoscene.meta screenshots, stage-livecolumn,
// updated in place by stage-list-component).

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

// demo(i): the i-th swg-livecolumn-demo (0 = refill, 1 = drain).
const demo = (i: number) =>
	`document.querySelector('sw-gallery').shadowRoot.querySelectorAll('swg-livecolumn-demo')[${i}]`;

// Full state of one demo column: every rendered rowslot with its phase-visible
// attributes, the machine-visible focus (ring classes + aria), and the log line.
const stateJs = (demoExpr: string) => `
(() => {
  const demo = ${demoExpr};
  const root = demo.shadowRoot;
  const container = root.querySelector('[data-mc-container]');
  const rows = [...root.querySelectorAll('[data-key]')].map(el => ({
    key: el.dataset.key,
    mcRow: el.dataset.mcRow ?? null,
    disabled: el.getAttribute('aria-hidden') === 'true', // "closed": aria-hidden is the one signal now
    hidden: el.getAttribute('aria-hidden'),
    committed: el.querySelector('.task')?.classList.contains('committed') ?? false,
    hasAux: el.querySelector('[data-mc-aux]') !== null,
  }));
  return JSON.stringify({
    rows,
    aria: container.getAttribute('aria-activedescendant'),
    focusWithin: container.classList.contains('mc-focus-within'),
    emptyFocus: container.classList.contains('mc-empty-focus'),
    textMode: container.classList.contains('mc-text-mode'),
    inputValue: root.querySelector('.sw-list-add [data-mc-input]')?.value ?? null,
    inputFocused: root.activeElement === root.querySelector('.sw-list-add [data-mc-input]'),
    // The header-focus region wash: a computed background on the group wrapper
    // (driven by the :has() rule in the demo's stylesheet, nothing script-side).
    groupWashed: (() => {
      const g = root.querySelector('sw-list');
      if (g === null) return null;
      const bg = getComputedStyle(g).backgroundColor;
      return bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
    })(),
    groupOutline: root.querySelector('sw-list') === null ? null
      : getComputedStyle(root.querySelector('sw-list')).outlineStyle,
    rowFocused: root.querySelector('.mc-row-focused')?.dataset?.mcRow
      ?? root.querySelector('.mc-row-focused')?.closest('[data-mc-row]')?.dataset?.mcRow ?? null,
    auxFocused: root.querySelector('[data-mc-aux].mc-focused') !== null,
    log: root.querySelector('.log')?.textContent ?? '',
    idle: root.querySelector('.idle')?.textContent ?? null,
    dataIds: demo.tasks.map(t => t.id),
  });
})()
`;

interface LcRow {
	key: string;
	mcRow: string | null;
	disabled: boolean;
	hidden: string | null;
	committed: boolean;
	hasAux: boolean;
}

interface LcState {
	rows: LcRow[];
	aria: string | null;
	focusWithin: boolean;
	emptyFocus: boolean;
	textMode: boolean;
	inputValue: string | null;
	inputFocused: boolean;
	groupWashed: boolean | null;
	groupOutline: string | null;
	rowFocused: string | null;
	auxFocused: boolean;
	log: string;
	idle: string | null;
	dataIds: string[];
}

async function run(ctx: ScenarioContext): Promise<void> {
	const { cdp, checks } = ctx;
	const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail);

	await openGallery(
		ctx,
		"#sw-list",
		"document.readyState === 'complete' && document.querySelector('sw-gallery')?.shadowRoot?.querySelectorAll('swg-livecolumn-demo')?.length === 2",
	);

	// Determinism: stop both demos' own churn timers; the scenario churns by hand.
	await cdp.js(`clearInterval(${demo(0)}.timer); clearInterval(${demo(1)}.timer)`);

	const st = async (i = 0): Promise<LcState> =>
		JSON.parse((await cdp.js(stateJs(demo(i)))) as string);
	const row = (s: LcState, key: string): LcRow | null => s.rows.find((r) => r.key === key) ?? null;

	// Data-side churn, like the real queue does between renders.
	const dropHead = async (i = 0): Promise<void> => {
		await cdp.js(`const d = ${demo(i)}; d.tasks = d.tasks.slice(1)`);
	};

	await cdp.mouseMove(5, 5); // park the mouse: no hover pin in play unless asked

	// --- phase 1: composition basics -------------------------------------
	let landed = false;
	for (let i = 0; i < 12; i++) {
		await cdp.key("Tab");
		if ((await st()).focusWithin) {
			landed = true;
			break;
		}
	}
	let s = await st();
	check("tab-into-column", landed && s.rowFocused === "prepend", s.rowFocused ?? "");

	const first = s.dataIds[0] as string;
	// One down from the prepend row is the LIST HEADER ROW: a normal nav stop
	// that speaks for the region.  While it holds focus the whole list washes
	// (sw-list's own :has() rule); the focus treatment stays on the header
	// itself -- the sw-list host draws no outline of its own.
	await cdp.key("ArrowDown");
	s = await st();
	check(
		"header-row-is-a-nav-stop",
		s.rowFocused === "queue-header" && s.aria === "mc-queue-header",
		JSON.stringify([s.rowFocused, s.aria]),
	);
	check("header-focus-washes-region", s.groupWashed === true, JSON.stringify(s.groupWashed));
	check("no-perimeter-outline-on-group", s.groupOutline === "none", s.groupOutline ?? "");
	const ring = await cdp.js(`
	  (() => { const h = ${demo(0)}.shadowRoot.querySelector('[data-mc-row="queue-header"]');
	     return getComputedStyle(h).outlineStyle; })()
	`);
	check("ring-stays-on-header-row", ring === "solid", String(ring));
	await shot(cdp, "list-header-focused.png");

	await cdp.key("ArrowDown");
	s = await st();
	check(
		"arrow-into-live-row",
		s.rowFocused === first && s.aria === `mc-${first}`,
		JSON.stringify([s.rowFocused, s.aria]),
	);
	check("wash-follows-header-only", s.groupWashed === false, JSON.stringify(s.groupWashed));

	await cdp.key("ArrowRight");
	s = await st();
	check("right-reaches-aux", s.auxFocused, JSON.stringify(s.rows));

	// Enter on the aux removes the task; the row ghosts in place, sheds its aux,
	// STAYS focused (identity survives), and position snaps back to main.
	// The row also FLASHES (sw-pressed) as instant acknowledgment: the fade to
	// ghost alone was too quiet to confirm the keystroke landed.  The flash is
	// 140ms, so a MutationObserver armed before the keystroke catches it.
	await cdp.js(`
	  (() => {
	    const root = ${demo(0)}.shadowRoot;
	    window.__flashSeen = 0;
	    window.__flashMo = new MutationObserver(muts => {
	      for (const m of muts) if (m.target.classList.contains('sw-pressed')) window.__flashSeen += 1;
	    });
	    window.__flashMo.observe(root.querySelector('sw-live-list'),
	      { attributes: true, attributeFilter: ['class'], subtree: true });
	  })()
	`);
	await cdp.key("Enter");
	s = await st();
	let g = row(s, first);
	const flashes = await cdp.js("window.__flashMo.disconnect(), window.__flashSeen");
	check("aux-enter-removes", s.log.includes("removed") && !s.dataIds.includes(first), s.log);
	check("removal-flashes-the-row", typeof flashes === "number" && flashes >= 1, String(flashes));
	check(
		"ghost-in-place",
		g !== null && g.mcRow === first && g.committed && !g.disabled,
		JSON.stringify(g),
	);
	check("ghost-sheds-aux", g !== null && !g.hasAux, JSON.stringify(g));
	check(
		"ghost-keeps-focus",
		s.rowFocused === first && !s.auxFocused,
		JSON.stringify([s.rowFocused, s.auxFocused]),
	);
	await shot(cdp, "ghost-focused.png");

	// Enter on the ghost still means something: inspect, not error, not removal.
	await cdp.key("Enter");
	s = await st();
	check("enter-on-ghost-inspects", s.log.includes("inspected a completed task"), s.log);

	// --- phase 2: the key-activity pin ------------------------------------
	// The pin defers ONLY the focused row's collapse (the keyboard aims by
	// identity; only the aimed-at row needs holding still), and it decays
	// keyPinMs=200 after the last key -- so holding the ghost takes a burst
	// at sub-200ms cadence, and the moment the fingers stop, the world moves.
	// Focus is on the ghost now; work IT (Enter inspects, focus never leaves)
	// well past holdMs=1200 from the removal above.
	for (let i = 0; i < 16; i++) {
		await cdp.key("Enter");
		await sleep(100);
	}
	s = await st();
	g = row(s, first);
	check("key-pin-holds-focused-ghost", g !== null && !g.disabled, JSON.stringify(g));
	await shot(cdp, "pin-held.png");

	// Stop typing: the pin decays 200ms after the last key, the deferred
	// collapse plays (300ms), and the fallback moves focus off the dying row.
	await sleep(900);
	s = await st();
	check("pin-decay-collapses-ghost", row(s, first) === null, JSON.stringify(s.rows));
	check(
		"fallback-moved-focus",
		s.rowFocused !== null && s.rowFocused !== first,
		s.rowFocused ?? "",
	);
	await shot(cdp, "post-fallback.png");

	// An UNFOCUSED ghost collapses on its natural schedule despite continuous
	// typing (the semantics the focused-only pin buys): x the focused row (the
	// ghost keeps focus), prove it is still a nav stop (arrow off and back on),
	// then move OFF it and keep pressing keys at pin-holding cadence on a
	// neighbor -- left/right cycle within a two-control row, so row focus
	// never returns to the ghost.  It must collapse ~holdMs after its removal
	// even though the pin never once decayed.
	s = await st();
	const bkey = s.rowFocused as string;
	await cdp.key("x");
	s = await st();
	check(
		"x-ghost-keeps-focus",
		!s.dataIds.includes(bkey) && s.rowFocused === bkey,
		JSON.stringify([s.rowFocused, s.log]),
	);
	await cdp.key("ArrowDown");
	await cdp.key("ArrowUp");
	s = await st();
	check("ghost-is-a-nav-stop", s.rowFocused === bkey, s.rowFocused ?? "");
	await cdp.key("ArrowDown"); // leave the ghost; it is nobody's aim from here on
	let aliveEarly: boolean | null = null;
	for (let i = 0; i < 18; i++) {
		// ~1.9s of sub-200ms keys, spanning the ghost's 1.2s schedule
		await cdp.key(i % 2 === 0 ? "ArrowRight" : "ArrowLeft");
		await sleep(100);
		if (i === 6) {
			// ~0.8s in: before the schedule lands, the ghost still holds
			s = await st();
			g = row(s, bkey);
			aliveEarly = g !== null && !g.disabled;
		}
	}
	s = await st();
	g = row(s, bkey);
	const goneLate = g === null || g.disabled;
	check("unfocused-ghost-holds-until-due", aliveEarly === true, JSON.stringify(aliveEarly));
	check("unfocused-ghost-collapses-mid-typing", goneLate, JSON.stringify(g));
	await sleep(600); // let the collapse finish before the next phase

	// --- phase 3: the churn lockout ---------------------------------------
	// Arrange: focus a row, drop it from the data (ghost), then wait for the
	// collapse to START (aria-hidden appears; no keys during the wait, so the
	// pin decays and only holdMs gates it) and immediately fire a trusted
	// Enter: the fallback just moved focus, so the decisive key must be DROPPED.
	s = await st();
	const victim = s.rowFocused as string;
	const logBefore = s.log;
	await dropHead(0); // victim is the head (fallback landed there after phase 2)
	{
		let began = false;
		for (let i = 0; i < 60; i++) {
			s = await st();
			g = row(s, victim);
			if (g?.disabled) {
				began = true;
				break;
			}
			await sleep(40);
		}
		if (!began) throw new Error("victim never began collapsing");
	}
	await cdp.key("Enter");
	s = await st();
	check("lockout-drops-decisive-key", s.log === logBefore, JSON.stringify([s.log, logBefore]));
	check(
		"lockout-fallback-landed",
		s.rowFocused !== null && s.rowFocused !== victim,
		s.rowFocused ?? "",
	);
	// ... and after the 350ms window, decisive keys flow again.
	await sleep(500);
	await cdp.key("Enter");
	s = await st();
	check("post-lockout-key-flows", s.log.includes("inspected") && s.log !== logBefore, s.log);

	// --- phase 4: x / Delete / instant prepend under burst -----------------
	s = await st();
	const target = s.rowFocused as string;
	await cdp.key("x");
	s = await st();
	check("x-removes-from-main", s.log.includes("removed") && !s.dataIds.includes(target), s.log);

	// p is the prepend button's column-scoped hotkey, marked DIRECT (a prepend
	// is cheaply undone, the see-before-commit exception): it fires from ANY
	// row, focus follows to the orders row, and the new head appears instantly
	// (visible, not pending) although the key burst above has the key pin
	// armed right now.
	await cdp.key("p");
	s = await st();
	const newest = s.dataIds[0] as string;
	const fresh = row(s, newest);
	check("p-prepends-from-anywhere", s.log.includes("prepended"), s.log);
	check("p-direct-moves-focus-to-actor", s.rowFocused === "prepend", s.rowFocused ?? "");
	check(
		"prepend-visible-under-pin",
		fresh !== null && fresh.hidden === null && !fresh.disabled,
		JSON.stringify(fresh),
	);

	// Focus landed on the orders row with the direct p; move to the fresh live
	// task for the Delete test (Home = prepend row, two down = past the header
	// to the new data head).
	await cdp.key("Home");
	await cdp.key("ArrowDown");
	await cdp.key("ArrowDown");
	s = await st();
	const deltarget = s.rowFocused as string;
	await cdp.key("Delete");
	s = await st();
	check("delete-removes", s.log.includes("removed") && !s.dataIds.includes(deltarget), s.log);

	// --- phase 5: click-guard, the default pointer story ---------------------
	// Hovering defers NOTHING now: a ghost under the pointer collapses on its
	// own schedule, and an insertion appears immediately with the pointer inside.
	// The protection is target-scoped instead: a trusted click whose target row
	// is displaced by an in-flight motion is swallowed; rows above the motion
	// answer clicks throughout; the guard dies ~50ms after the motion ends.
	s = await st();
	const ghosts = s.rows.filter((r) => r.committed && !r.disabled).map((r) => r.key);
	check("ghost-present-for-pointer-phase", ghosts.length > 0, JSON.stringify(s.rows));
	const gkey = ghosts[0] as string;
	const gbox = JSON.parse(
		(await cdp.js(`
	  (() => { const r = ${demo(0)}.shadowRoot.querySelector('[data-key="${gkey}"]').getBoundingClientRect();
	     return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })()
	`)) as string,
	) as { x: number; y: number };
	await cdp.mouseMove(gbox.x, gbox.y); // pointer parked ON the ghost, pre-expiry
	await sleep(2200); // holdMs + collapse + slack, pointer inside throughout
	s = await st();
	check("hover-does-not-defer-collapse", row(s, gkey) === null, JSON.stringify(s.rows));
	// An insertion under the hovering pointer: immediate, full-size, no pending
	// phase.  Park the pointer at the LIST's own center first (the collapse may
	// have shrunk the list away from where the ghost was).
	const lbox = JSON.parse(
		(await cdp.js(`
	  (() => { const r = ${demo(0)}.shadowRoot.querySelector('sw-live-list').getBoundingClientRect();
	     return JSON.stringify({x: r.x + r.width/2, y: r.y + Math.max(r.height/2, 4)}); })()
	`)) as string,
	) as { x: number; y: number };
	await cdp.mouseMove(lbox.x, lbox.y);
	await cdp.js(`${demo(0)}.prependTask()`);
	await sleep(500); // entrance animation (300ms) settles
	s = await st();
	let pend = s.dataIds[0] as string;
	let prow = row(s, pend);
	let pheight = await cdp.js(`
	  (() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${pend}"]');
	     return el === null ? null : el.getBoundingClientRect().height; })()
	`);
	check(
		"insertion-appears-under-pointer",
		prow !== null && prow.hidden === null,
		JSON.stringify(prow),
	);
	check(
		"inserted-row-holds-full-space",
		typeof pheight === "number" && pheight > 20,
		JSON.stringify(pheight),
	);

	// The trusted-click guard.  Stock the queue so a mid-list collapse has rows
	// both above and below it, and settle all entrances first.
	await cdp.js(`${demo(0)}.prependTask(); ${demo(0)}.prependTask()`);
	await sleep(600);
	await cdp.key("Home"); // focus the prepend row: a stable not-a-task vantage
	s = await st();
	const ids = s.dataIds;
	const [above, cgVictim, below] = [ids[0] as string, ids[1] as string, ids[2] as string];

	const rowCenter = async (k: string): Promise<{ x: number; y: number } | null> => {
		const v = await cdp.js(`
		  (() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${k}"]');
		     if (el === null) return null;
		     const r = el.getBoundingClientRect();
		     return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })()
		`);
		return v === null ? null : JSON.parse(v as string);
	};

	await cdp.js(
		`(() => { const dd = ${demo(0)}; dd.tasks = dd.tasks.filter(t => t.id !== '${cgVictim}') })()`,
	);
	{
		let began = false;
		for (let i = 0; i < 60; i++) {
			// wait out the hold; catch the collapse the frame it starts
			s = await st();
			g = row(s, cgVictim);
			if (g?.disabled) {
				began = true;
				break;
			}
			await sleep(40);
		}
		if (!began) throw new Error("click-guard victim never began collapsing");
	}
	// Mid-collapse (300ms window): the row below the collapse is being pulled
	// upward -- a trusted click on it must be swallowed (not even a focus sync);
	// the row above has not moved and its click lands.
	let pb = (await rowCenter(below)) as { x: number; y: number };
	await cdp.click(pb.x, pb.y);
	s = await st();
	check("mid-collapse-click-below-swallowed", s.rowFocused === "prepend", s.rowFocused ?? "");
	const pa = (await rowCenter(above)) as { x: number; y: number };
	await cdp.click(pa.x, pa.y);
	s = await st();
	check("mid-collapse-click-above-lands", s.rowFocused === above, s.rowFocused ?? "");
	// After the motion (plus the 50ms guard tail), the same below-row click lands.
	await sleep(600);
	pb = (await rowCenter(below)) as { x: number; y: number };
	await cdp.click(pb.x, pb.y);
	s = await st();
	check("post-motion-click-lands", s.rowFocused === below, s.rowFocused ?? "");
	await cdp.mouseMove(5, 5);

	// --- phase 5f: freeze, the opt-in quiescence mode -----------------------
	// pointerSafety is runtime-togglable; raise freeze on the live list and the
	// old global hover pin story must hold: a hovered ghost defers past every
	// timer, an insertion waits as a zero-height pending row, and pointer-leave
	// releases both.
	const LIST = `${demo(0)}.shadowRoot.querySelector('sw-live-list')`;
	await cdp.js(`${LIST}.pointerSafety = 'freeze'`);
	s = await st();
	const fkey = s.dataIds[0] as string;
	const fbox = (await rowCenter(fkey)) as { x: number; y: number };
	await cdp.mouseMove(fbox.x, fbox.y); // pointer onto the head task row
	await cdp.js(
		`(() => { const dd = ${demo(0)}; dd.tasks = dd.tasks.filter(t => t.id !== '${fkey}') })()`,
	);
	await sleep(2200); // far past holdMs + collapse; the hover pin holds
	s = await st();
	g = row(s, fkey);
	check("freeze-hover-pin-holds-ghost", g !== null && !g.disabled, JSON.stringify(s.rows));
	// While the pointer pins the list, an insertion defers as a PENDING row --
	// and a pending row must hold its display position at ZERO height.  This
	// pins the phantom-space state (held-p prepends with the pointer inside
	// painted a blank band at the list top: the demo's [data-mc-row]
	// min-height was clamping closed rowslots open to 24px).
	await cdp.js(`${demo(0)}.prependTask()`);
	await sleep(300);
	s = await st();
	pend = s.dataIds[0] as string;
	prow = row(s, pend);
	pheight = await cdp.js(`
	  (() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${pend}"]');
	     return el === null ? null : el.getBoundingClientRect().height; })()
	`);
	check(
		"freeze-hover-defers-insertion",
		prow !== null && prow.hidden === "true",
		JSON.stringify(prow),
	);
	check("freeze-pending-row-holds-no-space", pheight === 0, JSON.stringify(pheight));
	await cdp.mouseMove(5, 5); // pointer leaves: deferred motion plays
	await sleep(800);
	s = await st();
	check("freeze-pointer-leave-releases", row(s, fkey) === null, JSON.stringify(s.rows));
	prow = row(s, pend);
	check(
		"freeze-pointer-leave-shows-pending",
		prow !== null && prow.hidden === null,
		JSON.stringify(prow),
	);
	await cdp.js(`${LIST}.pointerSafety = 'click-guard'`); // back to the default

	// Re-seat DOM focus on the column container before the Tab phases: a real
	// mouse click natively focuses the list's tabindex=-1 item container (the
	// nearest click-focusable ancestor of a row), and Tab order from there is
	// not what phase 6 is testing.  Arrangement, not assertion.
	await cdp.js(`${demo(0)}.shadowRoot.querySelector('[data-mc-container]').focus()`);

	// --- phase 6: focus memory across Tab-away ------------------------------
	s = await st();
	const resume = s.rowFocused;
	await cdp.key("Tab"); // leaves the refill column (next stop: drain column)
	s = await st();
	check("tab-leaves-column", !s.focusWithin, JSON.stringify(s.focusWithin));
	await cdp.key("Tab", { shift: true });
	s = await st();
	check(
		"focus-memory-on-return",
		s.focusWithin && s.rowFocused === resume,
		JSON.stringify([s.rowFocused, resume]),
	);

	// --- phase 7: the drain column: empty state and revival -----------------
	await cdp.key("Tab");
	let s1 = await st(1);
	check("tab-into-drain-column", s1.focusWithin, JSON.stringify(s1));
	// The queue starts empty (renderEmpty shows), but the COLUMN is not: the
	// prepend row is a focusable stop, so eager focus sits there.
	check(
		"drain-starts-empty",
		s1.idle !== null && s1.rowFocused === "prepend" && s1.aria === "mc-prepend",
		JSON.stringify([s1.idle, s1.rowFocused, s1.aria]),
	);
	await shot(cdp, "drain-empty.png");

	// p prepends a task; focus STAYS on the prepend row (it survives churn;
	// nothing involuntary happened), and the new row is immediately reachable
	// (two downs now: the list header row sits between prepend and the list).
	await cdp.key("p");
	s1 = await st(1);
	const drainNewest = s1.dataIds.length > 0 ? (s1.dataIds[0] as string) : null;
	check(
		"drain-revives",
		drainNewest !== null && s1.rowFocused === "prepend",
		JSON.stringify(s1.dataIds),
	);
	await cdp.key("ArrowDown");
	await cdp.key("ArrowDown");
	s1 = await st(1);
	check(
		"drain-new-row-reachable",
		s1.rowFocused === drainNewest && s1.aria === `mc-${drainNewest}`,
		JSON.stringify([s1.rowFocused, s1.aria]),
	);
	// Kill it with x and let it collapse: focus survives the LAST task dying,
	// falling DOWN to the add-row (the fallback prefers downward, and the
	// add-row is the list's permanent neighbor below); the empty state returns.
	await cdp.key("x");
	s1 = await st(1);
	check(
		"drain-x-removes",
		s1.log.includes("removed") && s1.rowFocused === drainNewest,
		JSON.stringify([s1.log, s1.rowFocused]),
	);
	await sleep(2200); // pin decays (200) + hold (1200) + collapse (300) + slack
	s1 = await st(1);
	check(
		"drain-back-to-empty",
		s1.idle !== null && s1.rowFocused === "queue-add",
		JSON.stringify([s1.idle, s1.rowFocused]),
	);
	await cdp.key("p");
	await cdp.key("ArrowDown");
	await cdp.key("ArrowDown");
	s1 = await st(1);
	check(
		"drain-revives-again",
		s1.dataIds.length === 1 && s1.rowFocused === s1.dataIds[0],
		JSON.stringify(s1.rowFocused),
	);
	await shot(cdp, "drain-revived.png");

	// --- phase 8: the add-row (append as a nav stop; commit/abandon) --------
	// Still in the drain column, one live task.  The add-row is the append
	// norm, and its marked a is DIRECT: one keystroke dives straight into
	// text entry (direct on a text row means what Enter means there), earned
	// under the easy-undo doctrine -- Escape abandons, nothing touches the
	// queue until the Enter commit.  Enter commits, Escape abandons.
	s1 = await st(1);
	const countBefore = s1.dataIds.length;
	await cdp.key("a");
	s1 = await st(1);
	check(
		"a-dives-into-add-row",
		s1.rowFocused === "queue-add" && s1.aria === "mc-queue-add",
		JSON.stringify([s1.rowFocused, s1.aria]),
	);
	check(
		"a-dive-enters-text-mode",
		s1.textMode && s1.inputFocused,
		JSON.stringify([s1.textMode, s1.inputFocused]),
	);
	// The triggering a must not type itself into the input it focused
	// (the dive preventDefaults the keydown, same as Enter's dive).
	check("a-does-not-type-itself", s1.inputValue === "", JSON.stringify(s1.inputValue));
	check("a-dive-appends-nothing", s1.dataIds.length === countBefore, JSON.stringify(s1.dataIds));

	for (const ch of "calibrate the array") {
		await cdp.key(ch);
	}
	s1 = await st(1);
	check(
		"typing-lands-in-add-input",
		s1.inputValue === "calibrate the array",
		JSON.stringify(s1.inputValue),
	);
	await shot(cdp, "add-row-typing.png");

	// Escape ABANDONS: text mode exits, the draft clears, nothing appended.
	// (The trap this pins: change fires on blur-with-changed-value, and the
	// exit blurs the input -- a change-driven commit would append here.)
	const logBefore8 = s1.log;
	await cdp.key("Escape");
	s1 = await st(1);
	check(
		"escape-abandons",
		!s1.textMode && s1.inputValue === "" && s1.dataIds.length === countBefore,
		JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds]),
	);
	check(
		"escape-appends-nothing-to-log",
		s1.log === logBefore8,
		JSON.stringify([s1.log, logBefore8]),
	);

	// Row focus survived the exit on the add-row, so plain Enter re-dives
	// (the non-marked path, unchanged by the direct marking).
	await cdp.key("Enter");
	s1 = await st(1);
	check(
		"enter-redives-from-row-focus",
		s1.textMode && s1.inputFocused,
		JSON.stringify([s1.textMode, s1.inputFocused]),
	);

	// Enter COMMITS: the task appends to the live data (animating in at the
	// tail), the input clears, and row focus stayed on the add-row throughout.
	for (const ch of "survey the relay") {
		await cdp.key(ch);
	}
	await cdp.key("Enter");
	s1 = await st(1);
	check(
		"enter-commits-once",
		s1.dataIds.length === countBefore + 1 && s1.log.includes("survey the relay appended"),
		JSON.stringify([s1.dataIds, s1.log]),
	);
	check(
		"commit-clears-input",
		s1.inputValue === "" && !s1.textMode,
		JSON.stringify([s1.inputValue, s1.textMode]),
	);
	const newId = s1.dataIds[s1.dataIds.length - 1] as string;
	const freshTail = row(s1, newId);
	check(
		"committed-row-visible-at-tail",
		freshTail !== null && freshTail.hidden === null && !freshTail.disabled,
		JSON.stringify(freshTail),
	);
	check("focus-stayed-on-add-row", s1.rowFocused === "queue-add", s1.rowFocused ?? "");
	// ArrowUp from the add-row reaches the appended task: it landed at the tail.
	await cdp.key("ArrowUp");
	s1 = await st(1);
	check("committed-row-reachable-above-add-row", s1.rowFocused === newId, s1.rowFocused ?? "");

	// --- phase 9: the list-scoped keys (t and n) -----------------------------
	// The t/n norm: sw-list wires it itself (one megacolumn-key listener on
	// the host element answering with megacolumn-focus-request), deliberately
	// NOT gold markings.  Scope is DOM containment: within the list's rows t
	// returns to the header and n jumps past the list; outside it the letters
	// are nobody's.  Focus is on the tail task from phase 8 -- deep in the list.
	await cdp.key("t");
	s1 = await st(1);
	check(
		"t-from-task-returns-to-header",
		s1.rowFocused === "queue-header" && s1.aria === "mc-queue-header",
		JSON.stringify([s1.rowFocused, s1.aria]),
	);
	check("t-landing-washes-region", s1.groupWashed === true, JSON.stringify(s1.groupWashed));
	// n from the header (the header counts as within): past the whole list to
	// the telemetry row, no arrowing through the tasks.
	await cdp.key("n");
	s1 = await st(1);
	check(
		"n-jumps-past-the-list",
		s1.rowFocused === "status" && s1.aria === "mc-status",
		JSON.stringify([s1.rowFocused, s1.aria]),
	);
	// The status row sits OUTSIDE the group: t there is unclaimed -- it falls
	// through to normal resolution (no marking answers it) and nothing moves.
	await cdp.key("t");
	s1 = await st(1);
	check("t-outside-group-unclaimed", s1.rowFocused === "status", s1.rowFocused ?? "");
	// The add-row counts as within: t from it returns to the header too.
	await cdp.key("ArrowUp");
	s1 = await st(1);
	check("arrowup-from-status-is-add-row", s1.rowFocused === "queue-add", s1.rowFocused ?? "");
	await cdp.key("t");
	s1 = await st(1);
	check("t-from-add-row-returns-to-header", s1.rowFocused === "queue-header", s1.rowFocused ?? "");
	// ... and n from a mid-list task row (not just the header) jumps past too.
	await cdp.key("ArrowDown");
	s1 = await st(1);
	check("arrowdown-into-list", s1.dataIds.includes(s1.rowFocused ?? ""), s1.rowFocused ?? "");
	await cdp.key("n");
	s1 = await st(1);
	check("n-from-task-jumps-past", s1.rowFocused === "status", s1.rowFocused ?? "");
	// The prepend row is outside the group on the other side: t stays put.
	await cdp.key("Home");
	await cdp.key("t");
	s1 = await st(1);
	check("t-on-prepend-unclaimed", s1.rowFocused === "prepend", s1.rowFocused ?? "");
	// Text mode dominates: while typing in the add-row, t and n are just letters.
	await cdp.key("a"); // the direct marking dives straight into text entry
	await cdp.key("t");
	await cdp.key("n");
	s1 = await st(1);
	check(
		"text-mode-t-n-type",
		s1.textMode && s1.inputValue === "tn" && s1.rowFocused === "queue-add",
		JSON.stringify([s1.textMode, s1.inputValue, s1.rowFocused]),
	);
	await cdp.key("Escape"); // abandon the probe draft
	s1 = await st(1);
	check(
		"text-mode-probe-abandoned",
		!s1.textMode && s1.inputValue === "",
		JSON.stringify([s1.textMode, s1.inputValue]),
	);

	// --- phase 10: the sw-list component surface -----------------------------
	// Newly reachable since the list norms became a component (the
	// list-component stage): the group identity for AT, and the cancelable
	// commit (an owner that preventDefaults sw-list-add refuses the commit;
	// the draft and text mode must survive so the user can fix the text).
	// The demo's own handler never refuses, and a listener on the same element
	// would run after it -- so the refusal is arranged upstream: a one-shot
	// capture listener on the container cancels AND stops propagation (from
	// sw-list's side that IS a refusing owner: sw-list-add came back
	// defaultPrevented and nobody appended).
	await cdp.js(`
	  ${demo(1)}.shadowRoot.querySelector('[data-mc-container]')
	    .addEventListener('sw-list-add', e => { e.preventDefault(); e.stopPropagation(); },
	                      { once: true, capture: true })
	`);
	const role = await cdp.js(`
	  (() => { const l = ${demo(1)}.shadowRoot.querySelector('sw-list');
	     return JSON.stringify([l.getAttribute('role'), l.getAttribute('aria-label')]); })()
	`);
	check(
		"sw-list-group-identity",
		JSON.stringify(JSON.parse(role as string)) === JSON.stringify(["group", "task queue (live)"]),
		String(role),
	);
	const countBefore10 = (await st(1)).dataIds.length;
	// Focus sits ON the add-row after the phase-9 probe, where its own marking
	// is deliberately a no-op (double-tap must not out-power single-tap) --
	// so step off it first, then dive by marking.  Once in text mode every
	// letter (t and n included) types.
	await cdp.key("Home");
	await cdp.key("a");
	for (const ch of "rejected draft") {
		await cdp.key(ch);
	}
	await cdp.key("Enter");
	s1 = await st(1);
	check(
		"refused-commit-keeps-draft-and-text-mode",
		s1.textMode && s1.inputValue === "rejected draft" && s1.dataIds.length === countBefore10,
		JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds.length]),
	);
	// The once-listener is spent: the same Enter now commits.
	await cdp.key("Enter");
	await sleep(100);
	s1 = await st(1);
	check(
		"commit-flows-after-refusal",
		!s1.textMode &&
			s1.inputValue === "" &&
			s1.dataIds.length === countBefore10 + 1 &&
			s1.log.includes("rejected draft appended"),
		JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds, s1.log]),
	);
}

export const lcDrive: Scenario = { name: "lc", run };

packages/e2e/scenarios/lc-drive.ts · 32.7 KiB · 5c002c2529