feedback7: merge fb7-g-ui — PRs #306 #309 #341 #349 #356 #358 #361 applied as-is (Grok coding run; static review p3: MERGE)

- Golden unchanged (union OK); broad node --test 5257 (5256 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
sganggs committed 2026-10-08 20:14:09 +08:00
commit f9969aeeef
15 files changed
+842 -55

No files matched your search

+44
View File
@@ -197,6 +197,50 @@ test('diy sync: welcome brings the kit list and sends room.diy; edits are deboun
// ---- the tab --------------------------------------------------------------------------------------------------------------
describe('自选编队 tab (screens/diy.js)', () => {
for (const slotId of [T5A, T6A]) {
for (const saved of [null, SIEGE_PICK]) {
test(`the picker: ${slotId}, ${saved ? 'saved' : 'empty'} slot retains edits when reselecting its operator`, () => {
const picks = saved ? { [slotId]: saved } : {};
const done = [];
let draft = saved ? { ...saved } : null;
let filter = 'all';
let query = '';
const view = () => expand(DiyPickerView({ m: null, slot: { slotId }, picks, kitted: KIT,
onDone: (p) => done.push(p), onClose() {}, filter, query, draft,
onFilter: (f) => { filter = f; }, onQuery: (q) => { query = q; }, onDraft: (d) => { draft = d; } }));
const click = (key, value) => {
const node = [...walk(view())].find((v) => v.props?.[key] === value && typeof v.props.onClick === 'function');
assert.ok(node, `${key}=${value} is clickable`);
node.props.onClick();
};
click('data-char', SIEGE);
click('data-skill', 0);
click('data-module', 'none');
const edited = { charId: SIEGE, skillIndex: 0, uniEquipId: null };
assert.deepEqual(draft, edited);
click('data-char', SIEGE);
assert.deepEqual(draft, edited, 'reselecting keeps both the skill and explicit no-module choice');
// Filtering/searching the operator out and back in must not change the draft either.
filter = 'owned';
query = 'no matching operator';
assert.ok(![...walk(view())].some((v) => hasClass(v, 'diy-opt')));
query = '推进';
click('data-char', SIEGE);
assert.deepEqual(draft, edited);
click('data-testid', 'diy-confirm');
assert.deepEqual(done, [edited]);
assert.deepEqual(picks, saved ? { [slotId]: saved } : {}, 'the saved picks stay unchanged until the parent saves');
// Switching operators still initializes the new pick, and returning restores the saved/default pick.
filter = 'all';
query = '';
click('data-char', SHARP);
assert.deepEqual(draft, M.defaultPick(SHARP, slotId, D));
click('data-char', SIEGE);
assert.deepEqual(draft, saved || M.defaultPick(SIEGE, slotId, D));
});
}
}
test('four slots by tier: an empty slot to fill, a filled one shows the operator, its tag, skill and module', () => {
const tree = expand(DiyPanelView({ m: data.get('assets'), picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, legal: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, kitted: KIT, onSet() {}, picking: null, onPicking() {} }));
const slots = [...walk(tree)].filter((v) => hasClass(v, 'diy-slot'));
+82
View File
@@ -23,6 +23,88 @@ const SLOT = 'chess_char_5_diy1_a';
const SIEGE = 'char_112_siege';
const PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
describe('自选编队 picker: reselecting retains the draft (real server, no art needed)', { skip: !UI_ENABLED && 'set SP_E2E=1 (and have Chrome)' }, () => {
for (const slot of [SLOT, 'chess_char_6_diy1_a']) {
test(`${slot}: same operator retains skill/module edits; cancel, confirm and reload keep their semantics`, { timeout: 90000 }, async () => {
const srv = await startRealServer();
const P = (await import('puppeteer-core')).default;
const c = new Client(P, srv.base, 'diy-reselect', { prefix: `diy-reselect-${slot}`, w: 1280, h: 720 });
const picker = '[data-testid="diy-picker"]';
const operator = `${picker} .diy-opt[data-char="${SIEGE}"]`;
const saved = () => c.page.evaluate((id) => JSON.parse(localStorage.getItem('sp.pref.diy') || 'null')?.picks?.[id] ?? null, slot);
const choices = () => c.page.evaluate((selector) => {
const el = document.querySelector(selector);
return { skill: el?.querySelector('[data-skill][aria-checked="true"]')?.dataset.skill,
module: el?.querySelector('[data-module][aria-checked="true"]')?.dataset.module };
}, picker);
const edited = { charId: SIEGE, skillIndex: 0, uniEquipId: null };
const changed = { charId: SIEGE, skillIndex: 1, uniEquipId: 'uniequip_003_siege' };
const open = async () => {
await c.click(`.diy-slot[data-slot="${slot}"] :is(.diy-slot__fill, [data-testid="diy-change"])`);
await c.page.waitForSelector(picker, { visible: true, timeout: 8000 });
};
const confirm = async (expected) => {
await c.click('[data-testid="diy-confirm"]');
await c.page.waitForSelector(picker, { hidden: true, timeout: 3000 });
assert.deepEqual(await saved(), expected);
};
try {
await c.open();
await c.enter('自选配置回归');
await c.click('.lobby-screen [data-testid="loadout-open"]');
await c.click('.lo .lo-tab[data-tab="diy"]');
await open();
await c.click(operator);
await c.click(`${picker} [data-skill="0"]`);
await c.click(`${picker} [data-module="none"]`);
await c.shot('empty-edited');
await c.click(operator);
await c.shot('empty-reselected');
assert.deepEqual(await choices(), { skill: '0', module: 'none' }, 'an empty slot retains its edited draft');
// Hide the current operator, then find and reselect it through the filter/search controls.
await c.click(`${picker} [data-filter="proto"]`);
assert.equal(await c.page.$(operator), null);
await c.click(`${picker} [data-filter="owned"]`);
await c.click(`${picker} input[type="search"]`);
await c.page.keyboard.type('推进');
await c.click(operator);
assert.deepEqual(await choices(), { skill: '0', module: 'none' });
await confirm(edited);
await c.shot('empty-saved');
// A saved non-default pick must not overwrite new edits on a repeated operator click.
await open();
await c.click(`${picker} [data-skill="1"]`);
await c.click(`${picker} [data-module="uniequip_003_siege"]`);
await c.click(operator);
assert.deepEqual(await choices(), { skill: '1', module: 'uniequip_003_siege' });
await c.shot('saved-reselected');
await c.click(`${picker} .diy-pick__foot button`, '取消');
await c.page.waitForSelector(picker, { hidden: true, timeout: 3000 });
assert.deepEqual(await saved(), edited, 'cancel leaves the saved pick unchanged');
await open();
assert.deepEqual(await choices(), { skill: '0', module: 'none' });
await c.click(`${picker} [data-skill="1"]`);
await c.click(`${picker} [data-module="uniequip_003_siege"]`);
await c.click(operator);
await confirm(changed);
await c.page.reload({ waitUntil: 'domcontentloaded' });
await c.page.waitForSelector('.lobby-screen', { timeout: 20000 });
await c.click('.lobby-screen [data-testid="loadout-open"]');
await c.click('.lo .lo-tab[data-tab="diy"]');
await open();
assert.deepEqual(await choices(), { skill: '1', module: 'uniequip_003_siege' });
assert.deepEqual(await saved(), changed, 'confirm persists the current draft across reload');
await c.shot('reload');
assert.deepEqual(c.problems.filter((p) => p.startsWith('pageerror:')), []);
} finally {
console.log(JSON.stringify({ slot, problems: problemsOf([c]) }));
await c.close();
await srv.stop();
}
});
}
});
// GitHub #284 (idea from PR #286): Esc in the picker cancels only the picker, like its 取消, also from its search
// field — the 干员调配 overlay and its four slots stay, and the saved picks do not change; the next Esc closes the overlay
describe('自选编队 picker: Esc closes only the picker (real server, no art needed)', { skip: !UI_ENABLED && 'set SP_E2E=1 (and have Chrome)' }, () => {
+52
View File
@@ -442,6 +442,58 @@ describe('placement mirror (canPlace)', () => {
assert.equal(canPlace(ctx, h0.uid, { area: 'hand', idx: 10 }).ok, false);
assert.equal(canPlace(ctx, h0.uid, { area: 'hand', idx: -1 }).ok, false);
});
test('summoner withdrawal: a full hand accepts a drop onto its own summon stack', () => {
const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 };
const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 };
const ctx = ctxFor(privWith({ board: [b], hand: [...Array.from({ length: 9 }, () => item(EQUIP)), stack] }));
const to = { area: 'hand', idx: 9 };
assert.deepEqual(canPlace(ctx, b.uid, to), { ok: true, action: 'move' });
assert.deepEqual(dropIntent(ctx, b.uid, to), { t: 'g.move', fields: { uid: b.uid, to } });
assert.equal(dropFailureReason(ctx, b.uid, to), null);
});
test('summoner withdrawal: its own stack frees space when dropped onto another item or token', () => {
const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 };
const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 };
const other = { ...stack, uid: ++uid, ownerUid: ++uid };
const ctx = ctxFor(privWith({ board: [b], hand: [...Array.from({ length: 8 }, () => item(EQUIP)), other, stack] }));
for (const idx of [0, 8]) {
const to = { area: 'hand', idx };
assert.deepEqual(canPlace(ctx, b.uid, to), { ok: true, action: 'move' });
assert.deepEqual(dropIntent(ctx, b.uid, to), { t: 'g.move', fields: { uid: b.uid, to } });
assert.equal(dropFailureReason(ctx, b.uid, to), null);
}
});
test('summoner withdrawal: a full hand without its own hand stack still refuses the move', () => {
const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 };
const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 };
const other = { ...stack, uid: ++uid, ownerUid: ++uid };
const hand = Array.from({ length: 10 }, () => item(EQUIP));
const cases = [
privWith({ board: [b], hand }),
privWith({ board: [{ ...b, id: MELEE }], hand }),
privWith({ board: [b], hand: [...hand.slice(0, 9), other] }),
privWith({ board: [b], hand, temp: [stack] }),
privWith({ board: [b, { ...stack, row: 9, col: 3 }], hand }),
privWith({ board: [b], hand: [...hand.slice(0, 9), { ...item(EQUIP), ownerUid: b.uid }] }),
];
for (const priv of cases) {
const ctx = ctxFor(priv);
for (const idx of [0, 9]) {
const to = { area: 'hand', idx };
assert.equal(canPlace(ctx, b.uid, to).code, 'HAND_FULL');
assert.equal(dropIntent(ctx, b.uid, to), null);
assert.equal(dropFailureReason(ctx, b.uid, to), '整备区已满');
}
}
});
test('summoner withdrawal: freed space does not bypass an illegal chess swap', () => {
const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 };
const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 };
const ctx = ctxFor(privWith({ board: [b], hand: [piece(MELEE), ...Array.from({ length: 8 }, () => item(EQUIP)), stack] }));
const to = { area: 'hand', idx: 0 };
assert.equal(canPlace(ctx, b.uid, to).code, 'BAD_TILE');
assert.equal(dropIntent(ctx, b.uid, to), null);
});
test('items: equip on chess (board or hand), arts on tiles, not on tokens', () => {
const b = { ...piece(MELEE), row: 9, col: 3 };
const hChess = piece(RANGED);
+58
View File
@@ -134,6 +134,64 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await ctx.close();
});
test('import: Tab stays in the dialog and Enter cannot edit a background module', async () => {
const { ctx, page, problems } = await open();
try {
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo-mod.is-on');
const module = () => page.$eval('.lo-mod.is-on', (el) => el.dataset.module);
const original = await module();
const saved = () => page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}').entries || {});
const openImport = async () => {
await clickSel(page, '[data-testid="loadout-import"]');
await page.waitForSelector('.modal textarea');
await page.waitForFunction(() => !document.querySelector('.modal__box').getAnimations().some((a) => a.playState === 'running'));
};
const focused = () => page.evaluate(() => document.activeElement.getAttribute('data-testid'));
await openImport();
await page.click('.modal textarea');
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
assert.equal(await focused(), 'loadout-io-pick', 'Shift+Tab wraps past the disabled import and hidden file input');
const chooser = page.waitForFileChooser();
await page.keyboard.press('Enter');
await (await chooser).cancel();
assert.ok(await page.$('.modal'), 'Enter acts on the file picker inside the dialog');
assert.equal(await module(), original, 'the background module cannot be activated');
assert.deepEqual(await saved(), {});
await page.keyboard.press('Tab');
assert.equal(await focused(), 'loadout-io-text', 'Tab wraps back to the textarea');
await page.keyboard.press('Tab');
assert.equal(await page.evaluate(() => document.activeElement.textContent.trim()), '取消');
await page.keyboard.press('Enter');
await page.waitForSelector('.modal', { hidden: true });
assert.equal(await focused(), 'loadout-import', 'cancel restores the opener');
assert.equal(await module(), original);
assert.deepEqual(await saved(), {});
await openImport();
await page.keyboard.press('Escape');
await page.waitForSelector('.modal', { hidden: true });
assert.ok(await page.$('.lo'), 'Esc closes only the import');
assert.equal(await focused(), 'loadout-import');
await openImport();
await page.click('.modal textarea');
await page.keyboard.type(JSON.stringify({ v: 1, kind: 'stronghold.loadout', entries: { [INSIDE]: { skill: 0 } } }));
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
assert.equal(await focused(), 'loadout-io-apply', 'the enabled import becomes the last stop');
await page.keyboard.press('Enter');
await page.waitForSelector('.modal', { hidden: true });
assert.deepEqual(await saved(), { [INSIDE]: { skill: 0 } }, 'keyboard confirmation still imports');
assert.equal(await focused(), 'loadout-import');
// Optional art / fonts / audio can be absent; this keyboard regression needs no asset pack.
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
} finally { await ctx.close(); }
});
test('desktop: 导出 hands out a versioned payload; 导入 restores it and refuses junk', async () => {
const { ctx, page, problems } = await open();
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
+135
View File
@@ -95,6 +95,70 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
});
}
test('equip replace: focused buttons keep their native Enter action', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
const initial = await mockState(page);
const target = initial.board.find((p) => p.kind === 'chess' && p.items?.length === 2);
assert.ok(target, 'the fixture has an operator with two equipped items');
const item = initial.hand.find((p) => p?.kind === 'item' && !target.items.some((x) => x.id === p.id));
assert.ok(item, 'the fixture has a loose equipment item');
const requests = () => page.evaluate(() => globalThis.__MOCK__.S().requests.filter(([t]) => t === 'g.equip'));
const center = (sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const openReplace = async () => {
const from = await center(`.ff-piece[data-uid="${item.uid}"]`);
const to = await center(`.ff-piece[data-uid="${target.uid}"]`);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps: 8 });
await sleep(80);
await page.mouse.up();
await page.waitForSelector('.eqr__opt');
await sleep(500); // let the dialog's mount effect settle before selecting an item
await page.click(`.eqr__opt[data-uid="${target.items[1].uid}"]`);
await page.waitForSelector('.eqr__opt[aria-checked="true"]');
};
const tabToCancel = async () => {
for (let i = 0; i < 8; i++) {
if (await page.evaluate(() => document.activeElement?.matches('.eqr__cancel'))) return;
await page.keyboard.press('Tab');
}
assert.fail('Tab did not focus Cancel');
};
for (const key of ['Space', 'Enter']) {
await openReplace();
await tabToCancel();
await page.keyboard.press(key);
await page.waitForFunction(() => !document.querySelector('.eqr'));
await sleep(200);
assert.deepEqual(await requests(), [], `${key} on Cancel sends no equipment intent`);
const state = await mockState(page);
assert.deepEqual(state.board.find((p) => p.uid === target.uid).items, target.items, 'both equipped items remain');
assert.ok(state.hand.some((p) => p?.uid === item.uid), 'the new item remains in the hand');
}
// Enter on an equipment option toggles its selection; only the confirmation button replaces it.
await openReplace();
await page.keyboard.press('Enter');
await page.waitForFunction(() => !document.querySelector('.eqr__opt[aria-checked="true"]'));
await page.keyboard.press('Enter');
await page.waitForSelector('.eqr__opt[aria-checked="true"]');
assert.deepEqual(await requests(), [], 'Enter on an option only changes the selected item');
await tabToCancel();
await page.keyboard.press('Tab');
assert.equal(await page.evaluate(() => document.activeElement?.matches('.eqr__ok')), true);
await page.keyboard.press('Enter');
await page.waitForFunction(() => !document.querySelector('.eqr'));
await sleep(200);
assert.deepEqual(await requests(), [['g.equip', { itemUid: item.uid, targetUid: target.uid, replaceUid: target.items[1].uid }]]);
const replaced = await mockState(page);
assert.deepEqual(replaced.board.find((p) => p.uid === target.uid).items.map((x) => x.uid), [target.items[0].uid, item.uid]);
assert.ok(!replaced.hand.some((p) => p?.uid === item.uid), 'the confirmed equipment leaves the hand');
// This keyboard regression also runs without optional art/fonts; JavaScript exceptions still fail it.
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
await page.close();
});
test('prep: buy, shortcuts, ready, Esc', async () => {
const { page, problems } = await open('phase=PREP');
let s = await mockState(page);
@@ -149,6 +213,77 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.close();
});
test('prep: a shortcut upgrade clears the previous level confirmation', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
try {
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 100; }));
const before = await mockState(page);
const pressed = () => page.$eval('.shopbar .lvcard', (el) => el.getAttribute('aria-pressed'));
await page.click('.shopbar .lvcard');
await page.waitForFunction(() => document.querySelector('.lvcard').getAttribute('aria-pressed') === 'true');
assert.equal((await mockState(page)).funds, before.funds, 'selecting does not charge');
await page.keyboard.press('KeyD');
await page.waitForFunction((lv) => globalThis.__MOCK__.S().priv.shop.level === lv + 1, {}, before.shop.level);
const upgraded = await mockState(page);
assert.equal(upgraded.funds, before.funds - before.shop.upgradePrice);
assert.equal(await pressed(), 'false', 'the completed upgrade must disarm its confirmation');
await page.click('.shopbar .lvcard');
await page.waitForFunction(() => document.querySelector('.lvcard').getAttribute('aria-pressed') === 'true');
await sleep(300); // allow a mistaken second upgrade to reach the mock server
assert.equal((await mockState(page)).shop.level, upgraded.shop.level, 'the next click only selects');
assert.equal((await mockState(page)).funds, upgraded.funds, 'the next click does not charge');
await page.keyboard.press('Escape');
await page.waitForFunction(() => document.querySelector('.lvcard').getAttribute('aria-pressed') === 'false');
await page.keyboard.press('KeyD');
await page.waitForFunction((lv) => globalThis.__MOCK__.S().priv.shop.level === lv + 1, {}, upgraded.shop.level);
assert.equal((await mockState(page)).funds, upgraded.funds - upgraded.shop.upgradePrice, 'D still upgrades directly');
// Optional art / audio may be absent; this state regression needs no asset pack.
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
} finally { await page.close(); }
});
test('prep: mouse level confirmation survives same-level updates and resets after purchase', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
try {
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 100; }));
const before = await mockState(page);
await page.click('.shopbar .lvcard');
await page.waitForFunction(() => document.querySelector('.lvcard').getAttribute('aria-pressed') === 'true');
await page.keyboard.press('KeyF');
await page.waitForFunction(() => globalThis.__MOCK__.S().priv.shop.frozen);
assert.equal(await page.$eval('.lvcard', (el) => el.getAttribute('aria-pressed')), 'true', 'an unrelated shop update keeps the selection');
await page.click('.shopbar .lvcard');
await page.waitForFunction((lv) => globalThis.__MOCK__.S().priv.shop.level === lv + 1, {}, before.shop.level);
const upgraded = await mockState(page);
assert.equal(upgraded.funds, before.funds - before.shop.upgradePrice);
assert.equal(await page.$eval('.lvcard', (el) => el.getAttribute('aria-pressed')), 'false');
await page.click('.shopbar .lvcard');
await page.waitForFunction(() => document.querySelector('.lvcard').getAttribute('aria-pressed') === 'true');
await sleep(300);
assert.equal((await mockState(page)).shop.level, upgraded.shop.level);
assert.equal((await mockState(page)).funds, upgraded.funds);
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
} finally { await page.close(); }
});
test('prep: a level change preserves another shop card selection', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
try {
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 100; }));
const before = await mockState(page);
await page.click('.shopbar__cards .scard:not(.scard--sold)');
await page.waitForSelector('.shopbar__cards .scard.is-armed');
const selected = await page.$eval('.shopbar__cards .scard.is-armed', (el) => el.getAttribute('aria-label'));
await page.keyboard.press('KeyD');
await page.waitForFunction((lv) => globalThis.__MOCK__.S().priv.shop.level === lv + 1, {}, before.shop.level);
assert.equal(await page.$eval('.shopbar__cards .scard.is-armed', (el) => el.getAttribute('aria-label')), selected);
assert.equal(await page.$eval('.lvcard', (el) => el.getAttribute('aria-pressed')), 'false');
await page.keyboard.press('Escape');
await page.waitForSelector('.scard.is-armed', { hidden: true });
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
} finally { await page.close(); }
});
test('prep: Q retreats and X sells only the selected operator', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
const initial = await mockState(page);
+168
View File
@@ -0,0 +1,168 @@
// Shared Modal keyboard boundaries, real Chrome. Component fixtures cover nested / empty dialogs; the settings
// case uses the real title screen. Opt-in: SP_E2E=1 node --test test/ui/modal.e2e.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
describe('Modal keyboard focus', { skip: !ENABLED && 'set SP_E2E=1 and have Chrome to run' }, () => {
let srv, browser;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--no-proxy-server'] });
});
after(async () => { await browser?.close(); await srv?.close(); });
const active = (page) => page.evaluate(() => document.activeElement.id);
const tab = async (page, reverse = false) => {
if (reverse) await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
if (reverse) await page.keyboard.up('Shift');
};
async function fixture(page) {
page.setDefaultTimeout(5000);
await page.goto(`${srv.url}/dev/uikit.html`, { waitUntil: 'domcontentloaded' });
await page.evaluate(async () => {
const { render } = await import('/vendor/preact.module.js');
const { useState } = await import('/vendor/hooks.module.js');
const { html, Modal, DialogHost, confirmDialog } = await import('/js/ui/components.js');
function Fixture() {
const [outer, setOuter] = useState(false), [inner, setInner] = useState(false), [empty, setEmpty] = useState(false);
return html`<div>
<button id="open" onClick=${() => setOuter(true)}>Open</button>
<button id="empty-open" onClick=${() => setEmpty(true)}>Empty</button>
<button id="confirm" onClick=${async () => { const ok = await confirmDialog({ text: 'Confirm?' }); document.querySelector('#result').textContent = String(ok); }}>Confirm</button>
<output id="result"></output>
<${Modal} open=${outer} onClose=${() => setOuter(false)}>
<textarea id="first"></textarea>
<button disabled>Disabled</button><input type="hidden" /><button hidden>Hidden</button>
<button style="display:none">Display none</button><button style="visibility:hidden">Invisible</button>
<div inert><button>Inert</button></div><button tabindex="-1">Programmatic only</button>
<button id="inner-open" onClick=${() => setInner(true)}>Nested</button>
<a id="last" href="#inside">Last</a>
<//>
<${Modal} open=${inner} onClose=${() => setInner(false)}>
<button id="inner-first" data-autofocus>First</button><button id="inner-last">Last</button>
<//>
<${Modal} open=${empty} onClose=${() => setEmpty(false)}>No controls<//>
<${DialogHost} />
</div>`;
}
render(html`<${Fixture} />`, document.getElementById('kit'));
});
}
test('initial focus, both Tab boundaries, nested priority, recovery and cleanup', async () => {
const page = await browser.newPage();
try {
await fixture(page);
await page.click('#open');
await page.waitForFunction(() => document.activeElement.id === 'first');
await tab(page, true);
assert.equal(await active(page), 'last', 'hidden, disabled, inert and negative-tabindex controls are excluded');
await tab(page);
assert.equal(await active(page), 'first');
await tab(page);
assert.equal(await active(page), 'inner-open');
await page.keyboard.press('Enter');
await page.waitForFunction(() => document.activeElement.id === 'inner-first');
await tab(page, true);
assert.equal(await active(page), 'inner-last', 'only the topmost dialog traps focus');
await tab(page);
assert.equal(await active(page), 'inner-first');
await page.keyboard.press('Escape');
await page.waitForFunction(() => document.querySelectorAll('.modal').length === 1);
assert.equal(await active(page), 'inner-open');
await page.focus('#open');
assert.equal(await active(page), 'first', 'focus escaping to the background returns to the active dialog');
await page.keyboard.press('Escape');
await page.waitForSelector('.modal', { hidden: true });
assert.equal(await active(page), 'open');
await tab(page);
assert.equal(await active(page), 'empty-open', 'closed dialogs no longer trap focus');
await page.click('#open');
await page.waitForFunction(() => document.activeElement.id === 'first');
await tab(page, true);
assert.equal(await active(page), 'last', 'reopening does not leave stale listeners');
} finally { await page.close(); }
});
test('no controls: focus the dialog itself; imperative confirm retains autofocus and native Enter', async () => {
const page = await browser.newPage();
try {
await fixture(page);
await page.click('#empty-open');
await page.waitForFunction(() => document.activeElement.matches('.modal__box'));
for (const reverse of [false, true]) {
await tab(page, reverse);
assert.equal(await page.evaluate(() => document.activeElement.matches('.modal__box')), true);
}
await page.keyboard.press('Escape');
await page.waitForSelector('.modal', { hidden: true });
assert.equal(await active(page), 'empty-open');
await page.click('#confirm');
await page.waitForFunction(() => document.activeElement.hasAttribute('data-autofocus'));
await page.keyboard.press('Enter');
await page.waitForFunction(() => document.querySelector('#result').textContent === 'true');
assert.equal(await active(page), 'confirm');
} finally { await page.close(); }
});
test('title settings: Tab cycles and leaves hotkey capture; Esc cancels capture before closing', async () => {
const page = await browser.newPage();
try {
await page.setViewport({ width: 1280, height: 720 });
page.setDefaultTimeout(5000);
await page.goto(srv.url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.title-settings');
await page.click('.title-settings');
await page.waitForFunction(() => document.querySelector('.modal__box')?.contains(document.activeElement));
const assertInside = async () => assert.equal(await page.evaluate(() => document.querySelector('.modal__box').contains(document.activeElement)), true);
for (const reverse of [false, true]) for (let i = 0; i < 25; i++) { await tab(page, reverse); await assertInside(); }
await page.focus('.set-key[data-action="retreat"]');
await page.keyboard.press('Enter');
await page.waitForSelector('.set-key.is-waiting');
await tab(page);
await page.waitForSelector('.set-key.is-waiting', { hidden: true });
await assertInside();
assert.equal(await page.$eval('.set-key[data-action="retreat"]', (el) => el.textContent.trim()), 'Q');
await page.focus('.set-key[data-action="retreat"]');
await page.keyboard.press('Enter');
await page.waitForSelector('.set-key.is-waiting');
await page.keyboard.press('Escape');
await page.waitForSelector('.set-key.is-waiting', { hidden: true });
assert.ok(await page.$('.modal'), 'capture consumes the first Esc');
await page.keyboard.press('Escape');
await page.waitForSelector('.modal', { hidden: true });
assert.equal(await page.evaluate(() => document.activeElement.matches('.title-settings')), true);
assert.ok(await page.$('.title-screen'), 'the background start button was not activated');
} finally { await page.close(); }
});
test('settings lets the higher guide keep keyboard focus and resumes after it closes', async () => {
const page = await browser.newPage();
try {
page.setDefaultTimeout(5000);
await page.goto(srv.url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.title-settings');
await page.click('.title-settings');
await page.waitForSelector('.set-guide');
await page.waitForFunction(() => !document.querySelector('.modal__box').getAnimations().some((a) => a.playState === 'running'));
await page.click('.set-guide');
await page.waitForSelector('.guide');
await page.waitForFunction(() => document.querySelector('.guide').contains(document.activeElement));
await tab(page);
assert.equal(await page.evaluate(() => document.querySelector('.guide').contains(document.activeElement)), true);
await page.keyboard.press('Escape');
await page.waitForSelector('.guide', { hidden: true });
assert.ok(await page.$('.modal'), 'closing the guide keeps settings');
await tab(page);
assert.equal(await page.evaluate(() => document.querySelector('.modal__box').contains(document.activeElement)), true);
await page.click('.modal__actions button:last-child');
await page.waitForSelector('.modal', { hidden: true });
} finally { await page.close(); }
});
});