0.2.5-dev: community round 30 and follow-ups

- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
This commit is contained in:
sganggs committed 2026-10-12 14:07:08 +08:00
1 parent bc50cac01f
commit 221e6b8dd2
134 files changed
+4590 -1201

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { Client, ROOT, hasChrome, startRealServer } from '../e2e/client.mjs';
const enabled = process.env.SP_E2E === '1' && hasChrome() && existsSync(`${ROOT}/public/assets`);
test('buying Matterhorn sends two live layer cues through the real client; resync/reload stays silent',
{ skip: !enabled, timeout: 120000 }, async () => {
const P = (await import('puppeteer-core')).default;
const srv = await startRealServer({ fast: { timerScale: 0.1, startRound: 2, kit: 0, shop: ['chess_char_1_02_a'] } });
const c = new Client(P, srv.base, 'bond-audio', { prefix: 'bond-audio' });
try {
await c.open(); await c.enter('叠层音效测试');
await c.click('.mode-card', '独立模拟'); await c.click('.diff-card', '险境');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor(s => !!s.room, 'solo room');
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟');
await c.waitFor(s => s.phase === 'INFO_CHECK', 'briefing');
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor(s => s.phase === 'BAND_DRAFT', 'band draft');
await c.page.click('.dband'); await c.click('.draft-detail__btns .btn--primary', '确认选择');
await c.waitFor(s => s.phase === 'PREP' && !s.ready, 'prep');
await c.page.evaluate(async () => {
const { audio } = await import('/js/audio.js');
const { net } = await import('/js/net.js');
window.__layerCues = []; window.__layerFrames = [];
const play = audio._playBondCue.bind(audio);
audio._playBondCue = (url, volume) => { window.__layerCues.push({ url, volume }); play(url, volume); };
net.on('m.layer', m => window.__layerFrames.push(m));
});
await c.click('.scard', '角峰'); await c.click('.scard', '角峰');
await c.page.waitForFunction(() => window.__layerFrames.length === 2);
const result = await c.page.evaluate(async () => {
const { audio } = await import('/js/audio.js');
// Wait for the actual local asset decoder; then its queued cue continuation starts the source.
await Promise.all([...audio.buffers.values()]); await Promise.resolve();
return { frames: window.__layerFrames, cues: window.__layerCues, playing: !!audio.bondCueNode,
state: audio.ctx?.state, layers: globalThis.__SP__.store.get().match.private.bonds };
});
assert.deepEqual(result.frames.map(m => [m.bondId, m.add]).sort(), [['kjeragShip', 2], ['steadShip', 2]]);
assert.equal(result.cues.length, 2); assert.equal(result.playing, true); assert.equal(result.state, 'running');
assert.ok(result.cues.every(x => /act1autochess_b_ui_buffup/.test(x.url)));
const count = result.cues.length;
// g.watch on own prep asks the real server for state again, without a new gain.
await c.page.evaluate(() => globalThis.__SP__.net.request('g.watch', { fieldId: `n:${globalThis.__SP__.store.get().me.playerId}` }));
assert.equal(await c.page.evaluate(() => window.__layerCues.length), count);
await c.page.reload({ waitUntil: 'networkidle0' });
await c.waitFor(s => s.phase === 'PREP', 'restored prep');
assert.equal(await c.page.evaluate(async () => !!(await import('/js/audio.js')).audio.bondCueNode), false);
assert.equal(c.problems.filter(x => x.startsWith('pageerror:')).length, 0, c.problems.join('\n'));
} finally { await c.close(); await srv.stop(); }
});
+106
View File
@@ -0,0 +1,106 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { AudioManager } from '../../public/js/audio.js';
import { audioEarly, keepEarly } from '../../public/js/screens/game/early.js';
function harness() {
const sources = [];
const a = new AudioManager({ getManifest: () => ({ audio: { sfx: { ui: { bondUp: '/bond.mp3' } } } }) });
a.ctx = {
createGain: () => ({ gain: {}, connect() {}, disconnect() {} }),
createBufferSource: () => {
const s = { started: false, stopped: false, connect() {}, disconnect() {},
start() { this.started = true; }, stop() { this.stopped = true; this.onended?.(); } };
sources.push(s); return s;
},
};
a.sfxGain = {};
a._buffer = async () => ({ duration: 1 });
return { a, sources };
}
const flush = () => new Promise(resolve => queueMicrotask(resolve));
test('a real layer gain plays once regardless of amount; invalid gains are silent', async () => {
const { a, sources } = harness();
for (const n of [0, -1, NaN, Infinity, '1']) a.layerGain('sargonShip', n);
a.layerGain(null, 1); a.layerGain('', 1);
await flush(); assert.equal(sources.length, 0);
a.layerGain('sargonShip', 5); await flush();
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
a.stopBondCue();
});
test('each new layer cue immediately replaces the playing cue, with no cooldown', async () => {
const { a, sources } = harness();
a.layerGain('sargonShip', 1); await flush();
a.handleBattleEvents([['layer', 'p_1', 'sargonShip', 2]]); await flush();
assert.equal(sources.length, 2); assert.equal(sources[0].stopped, true);
assert.equal(sources[1].started, true); assert.equal(sources[1].stopped, false);
sources[0].onended(); assert.equal(a.bondCueNode.src, sources[1], 'late cleanup must not clear the replacement');
a.stopBondCue(); assert.equal(sources[1].stopped, true);
});
test('only the newest request can start after asynchronous decoding', async () => {
const { a, sources } = harness(), resolves = [];
a._buffer = () => new Promise(resolve => resolves.push(resolve));
a.layerGain('kjeragShip', 1); a.layerGain('defend', 2);
resolves[1]({ duration: 1 }); await flush();
resolves[0]({ duration: 1 }); await flush();
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
a.stopBondCue();
});
test('field entry, stopping and muting cancel a cue that is still decoding', async () => {
for (const cancel of [a => a.setFieldUnits([]), a => a.stopBondCue(), a => a.setVolumes({ muted: true }), a => a.setVolumes({ sfx: 0 })]) {
const { a, sources } = harness(); let resolve;
a._buffer = () => new Promise(r => { resolve = r; });
a.layerGain('sargonShip', 1); cancel(a); resolve({ duration: 1 }); await flush();
assert.equal(sources.length, 0);
}
});
test('locked/muted audio and missing assets never queue old layer sounds', async () => {
const { a, sources } = harness(), ctx = a.ctx;
a.ctx = null; a.layerGain('sargonShip', 1); a.ctx = ctx;
a.setVolumes({ muted: true }); a.layerGain('sargonShip', 1); a.setVolumes({ muted: false });
a._buffer = async () => null; a.layerGain('sargonShip', 1); await flush();
a._buffer = async () => { throw new Error('missing'); }; a.layerGain('sargonShip', 1); await flush();
assert.equal(sources.length, 0); assert.equal(a.bondCueNode, null);
});
test('returning to prep cancels old battle audio but preserves a new prep gain received before the phase view', async () => {
const { a, sources } = harness(); let resolve;
a._buffer = () => new Promise(r => { resolve = r; });
a.handleBattleEvents([['layer', 'p_0', 'sargonShip', 1]]);
a.stopBondCue('battle'); resolve({ duration: 1 }); await flush();
assert.equal(sources.length, 0);
a.layerGain('sargonShip', 1);
a.stopBondCue('battle'); resolve({ duration: 1 }); await flush();
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
a.stopBondCue();
});
test('battle layer tuples use the same exclusive channel; late field replay has no layer cue', async () => {
const { a, sources } = harness();
const events = [['layer', 'p_0', 'sargonShip', 1], ['layer', 'p_1', 'kjeragShip', 2]];
a.handleBattleEvents(events); await flush(); assert.equal(sources.length, 1);
a.stopBondCue();
assert.equal(events.some(keepEarly), false);
a.handleBattleEvents(audioEarly(events)); await flush(); assert.equal(sources.length, 1);
});
test('a prep update that adds layers and activates a bond plays one cue, not a cut-off restart', async () => {
const { a, sources } = harness();
a.layerGain('sargonShip', 2);
await flush();
// screens/game.js: the active-bond count rose in the same update — it keeps the layer cue
if (!a.bondCueRecent('prep')) a.sfx('bondUp', { volume: 0.7, scope: 'prep' });
await flush();
assert.equal(sources.length, 1, 'one source');
assert.equal(sources[0].stopped, false, 'the layer cue is not cut off');
// an activation with no layer cue of its own still sounds
const b = harness();
if (!b.a.bondCueRecent('prep')) b.a.sfx('bondUp', { volume: 0.7, scope: 'prep' });
await flush();
assert.equal(b.sources.length, 1);
});
+13 -10
View File
@@ -1,6 +1,6 @@
// Real-server browser E2E of the community reports of 2026-10-06, items 51 / 55 (the boss round's prep). A co-op match
// jumped to the boss round (fastServer SP_START_ROUND=boss), 2 humans (seats 0 and 3) + 2 AI seats, every board placed
// by the jump (SP_AUTO_PLACE), so the pairs are (host, AI 1) and (AI 2, guest) — the guest is the right-hand player.
// by the jump (SP_AUTO_PLACE). Pairing is random; both clients must use the published pair and side.
// * item 51: each human's prep shows its own pieces on its half (the guest's mirrored onto the right half) and its
// partner's pieces on the other half (read-only 'm:<uid>' views from m.private bossMate, facing as the battle will
// place them), the leader standing on the boss field; the team panel frames the pair's two avatars (green, the
@@ -33,6 +33,7 @@ const prepView = (c) => c.page.evaluate(() => {
const pick = (pre) => entries.filter(([k]) => String(k).startsWith(pre)).map(([, x]) => ({ id: x.info?.defId, owner: x.info?.ownerId ?? null, x: Math.round(x.x), y: Math.round(x.y), dir: x.info?.dir ?? null }));
return {
me: s.me.playerId, phase: s.match.public?.phase, camKind: v?.debug?.camKind ?? null, mode: v?.mode ?? null,
pair: s.match.public?.bossPairs?.find(g => g.includes(s.me.playerId)) ?? [],
field: s.match.field ? { fieldId: s.match.field.fieldId, kind: s.match.field.kind, prep: !!s.match.field.prep, side: s.match.field.side ?? null } : null,
own: pick('p:'), mates: pick('m:'), scout: entries.filter(([k]) => typeof k === 'number' || /^\d+$/.test(String(k))).length,
leader: v?.debug?.leader ? { key: v.debug.leader.key, shown: !!v.debug.leader.view?.root?.visible } : null,
@@ -68,6 +69,7 @@ async function coopAtBoss(P, base, prefix) {
if (s.phase !== 'BAND_DRAFT') { picked.add(c.label); continue; }
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 2 : 5 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
picked.add(c.label);
}
await sleep(250);
@@ -91,15 +93,16 @@ describe('the boss round\'s prep: the pair together, the frames, a watcher\'s bo
const gv = await prepView(guest);
await host.shot('prep');
await guest.shot('prep');
// the host holds the left half, its AI partner the right one; the guest the right half, its AI partner the left
assert.equal(hv.camKind, 'bossPrep');
assert.equal(gv.camKind, 'bossPrep');
assert.deepEqual([hv.bossMate?.side, gv.bossMate?.side], ['R', 'L'], JSON.stringify({ hv: hv.bossMate, gv: gv.bossMate }));
assert.ok(hv.own.length >= 4 && hv.own.every((u) => u.x <= 10), `host's pieces on the left half: ${JSON.stringify(hv.own)}`);
assert.ok(gv.own.length >= 4 && gv.own.every((u) => u.x >= 10), `guest's pieces mirrored onto the right half: ${JSON.stringify(gv.own)}`);
assert.ok(hv.mates.length >= 4 && hv.mates.every((u) => u.x >= 10 && u.owner === hv.bossMate.playerId), `the AI partner's pieces on the right half: ${JSON.stringify(hv.mates)}`);
assert.ok(gv.mates.length >= 4 && gv.mates.every((u) => u.x <= 10 && u.owner === gv.bossMate.playerId), `the AI partner's pieces on the left half: ${JSON.stringify(gv.mates)}`);
assert.ok(hv.mates.some((u) => u.dir === 'LEFT'), 'mirrored onto the right half: RIGHT reads LEFT');
for (const v of [hv, gv]) {
assert.equal(v.camKind, 'bossPrep');
assert.equal(v.pair.length, 2);
const right = v.pair.indexOf(v.me) === 1;
assert.equal(v.bossMate?.playerId, v.pair.find(id => id !== v.me));
assert.equal(v.bossMate.side, right ? 'L' : 'R');
assert.ok(v.own.length >= 4 && v.own.every(u => right ? u.x >= 10 : u.x <= 10), `own side: ${JSON.stringify(v)}`);
assert.ok(v.mates.length >= 4 && v.mates.every(u => (right ? u.x <= 10 : u.x >= 10) && u.owner === v.bossMate.playerId));
assert.ok((right ? v.own : v.mates).some(u => u.dir === 'LEFT'), 'the right-half units mirror their facing');
}
assert.ok(hv.leader?.shown && gv.leader?.shown, 'the leader stands on the boss field for both');
assert.deepEqual(hv.framed.sort(), [hv.names[hv.me], hv.names[hv.bossMate.playerId]].sort(), 'the host and its partner framed');
assert.deepEqual(gv.framed.sort(), [gv.names[gv.me], gv.names[gv.bossMate.playerId]].sort(), 'the guest and its partner framed');
+3 -4
View File
@@ -15,7 +15,6 @@ import {
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason, terrainInfo,
} from '../../public/js/ui/gameLogic.js';
import { pairPlayers } from '../../server/match/finalAssault.js';
import { PHASE, GEO } from '../../shared/constants.js';
import { setLang, setMessages } from '../../shared/i18n.js';
@@ -762,16 +761,16 @@ describe('prepCamera (research 09 §1.2: the Final Assault prep on the own half
assert.equal(prepCamera(null, 'a').kind, 'prep');
assert.equal(prepCamera({ round: 14, bossRound: null, hiddenRound: null, players: [] }, 'a').kind, 'prep');
});
test('boss / hidden round prep: bossPrep on the side of the server pairing (alive players by seat, two by two)', () => {
test('older servers without bossPairs keep the legacy seat-order prep camera fallback', () => {
const players = [pl('d', 3), pl('a', 0), pl('c', 2), pl('b', 1)];
for (const round of [14, 15]) {
const sides = Object.fromEntries(players.map((p) => [p.playerId, prepCamera(pub(round, players), p.playerId)]));
assert.deepEqual(Object.fromEntries(Object.entries(sides).map(([k, v]) => [k, `${v.kind}:${v.opts.side}`])),
{ a: 'bossPrep:L', b: 'bossPrep:R', c: 'bossPrep:L', d: 'bossPrep:R' });
}
// somebody left (eliminated): the pairs close up exactly like the server's (Match._quit re-plans the boss waves)
// Legacy servers closed the ordered pairs up when somebody left; new servers publish bossPairs explicitly.
const left = [pl('a', 0), pl('b', 1, false), pl('c', 2), pl('d', 3)];
const groups = pairPlayers(left.filter((p) => p.alive).map((p) => ({ ...p })));
const groups = [[left[0], left[2]], [left[3]]];
for (const g of groups) g.forEach((p, j) => assert.equal(prepCamera(pub(14, left), p.playerId).opts.side, j === 1 ? 'R' : 'L', p.playerId));
assert.equal(prepCamera(pub(14, left), 'c').opts.side, 'R', 'c moves up to the right half');
assert.equal(prepCamera(pub(14, left), 'b').kind, 'prep', 'an eliminated player has no half');
+168
View File
@@ -0,0 +1,168 @@
// The existing stronghold.loadout export carries every operator's potential/cultivation, including DIY-only ones.
// Real server + headless Chrome, normal UI input only; no storage/store/network mutation. No art is required.
// SP_E2E=1 node --test test/ui/loadout-ops-export.e2e.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { Client, hasChrome, startRealServer } from '../e2e/client.mjs';
const KALTS = 'char_003_kalts';
const INSIDE = 'chess_char_1_01_a';
const SLOT = 'chess_char_6_diy1_a';
const slot = `.diy-slot[data-slot="${SLOT}"]`;
const picker = '[data-testid="diy-picker"]';
const io = '[data-testid="loadout-io-text"]';
describe('loadout export includes DIY-only cultivation (real server, headless Chrome)', {
skip: !(process.env.SP_E2E === '1' && hasChrome()) && 'set SP_E2E=1 (and have Chrome)',
}, () => {
let srv, P;
before(async () => { srv = await startRealServer(); P = (await import('puppeteer-core')).default; });
after(async () => { await srv?.stop(); });
async function open(label) {
const c = new Client(P, srv.base, label, { prefix: 'loadout-ops-export', w: 1280, h: 800 });
try {
await c.open();
await c.enter('调配回归');
await c.click('.lobby-screen [data-testid="loadout-open"]');
await c.page.waitForSelector('.lo-card');
return c;
} catch (e) { await c.close(); throw e; }
}
const tab = (c, name) => c.click(`.lo-tab[data-tab="${name}"]`);
const disabled = (c, id) => c.page.$eval(`[data-testid="${id}"]`, e => e.disabled);
const saved = (c) => c.page.evaluate(() => {
const p = JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}');
return { entries: p.entries || {}, ops: p.ops || {} };
});
async function chooseKalts(c) {
await tab(c, 'diy');
await c.click(`${slot} :is(.diy-slot__fill, [data-testid="diy-change"])`);
await c.page.waitForSelector(picker);
await c.click(`${picker} input[type="search"]`);
await c.page.keyboard.type('凯尔希');
await c.click(`${picker} .diy-opt[data-char="${KALTS}"]`);
await c.click('[data-testid="diy-confirm"]');
await c.page.waitForSelector(picker, { hidden: true });
}
async function setKalts(c, patch) {
await tab(c, 'diy');
for (const [key, value] of Object.entries(patch)) await c.page.select(`${slot} select[data-cult="${key}"]`, String(value));
}
async function changeNormal(c) {
await tab(c, 'loadout');
await c.click(`.lo-card[data-chess="${INSIDE}"] .lo-q--skill[data-skill="0"]`);
await c.page.select(`.lo-card[data-chess="${INSIDE}"] select[data-cult="potential"]`, '2');
}
async function exportText(c, id = 'loadout-export') {
assert.equal(await disabled(c, id), false, `${id} must allow the saved settings to be exported`);
await c.click(`[data-testid="${id}"]`);
await c.page.waitForSelector(io);
const text = await c.page.$eval(io, e => e.value);
await c.page.keyboard.press('Escape');
await c.page.waitForSelector(io, { hidden: true });
return text;
}
async function importText(c, text, id = 'loadout-import') {
await c.click(`[data-testid="${id}"]`);
await c.page.waitForSelector(io);
await c.click(io);
await c.page.keyboard.type(text);
await c.click('[data-testid="loadout-io-apply"]');
await c.page.waitForSelector(io, { hidden: true });
}
for (const [name, normal, patch] of [
['DIY potential only', false, { potential: 1 }],
['DIY cultivation only', false, { cultivate: 0 }],
['DIY potential and cultivation', false, { potential: 1, cultivate: 0 }],
['normal only', true, null],
['normal and DIY', true, { potential: 1, cultivate: 0 }],
]) {
test(`${name}: export then import in a fresh browser preserves entries and ops`, { timeout: 90000 }, async () => {
let c, target, diyText;
try {
c = await open(name);
if (patch) {
await chooseKalts(c);
await setKalts(c, patch);
await c.shot(`${name.replaceAll(' ', '-')}-settings`);
diyText = await exportText(c, 'diy-export');
const diy = JSON.parse(diyText);
assert.equal(diy.kind, 'stronghold.diy');
assert.equal('ops' in diy, false, 'the DIY roster format stays unchanged');
assert.equal(diy.picks[SLOT].charId, KALTS);
}
if (normal) await changeNormal(c);
await tab(c, 'loadout');
const expected = await saved(c);
if (patch) assert.deepEqual(expected.ops[KALTS], patch);
if (!normal) {
assert.deepEqual(expected.entries, {});
assert.match(await c.page.$eval('.lo-top__right', e => e.textContent), /已调整\s*0/);
assert.equal(await c.page.$eval('.lo-top__right button:last-child', e => e.disabled), true,
'normal reset still covers only its roster');
}
console.log(JSON.stringify({ scenario: name, saved: expected, exportDisabled: await disabled(c, 'loadout-export') }));
await c.shot(`${name.replaceAll(' ', '-')}-before-export`);
const text = await exportText(c);
const payload = JSON.parse(text);
assert.equal(payload.kind, 'stronghold.loadout');
assert.equal(payload.v, 1);
assert.equal(payload.count, normal ? 1 : 0, 'count retains its existing entry semantics');
assert.deepEqual({ entries: payload.entries, ops: payload.ops }, expected);
await c.shot(`${name.replaceAll(' ', '-')}-exported`);
target = await open(`${name} import`);
if (diyText) {
await tab(target, 'diy');
await importText(target, diyText, 'diy-import');
await tab(target, 'loadout');
}
await importText(target, text);
assert.deepEqual(await saved(target), expected, 'a fresh browser keeps the complete exported loadout');
await target.page.reload({ waitUntil: 'domcontentloaded' });
await target.page.waitForSelector('.lobby-screen');
assert.deepEqual(await saved(target), expected, 'the imported loadout persists across reload');
if (patch) {
assert.deepEqual(await target.page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.diy')).picks), JSON.parse(diyText).picks);
await target.click('.lobby-screen [data-testid="loadout-open"]');
await tab(target, 'diy');
await target.page.waitForSelector(`${slot} select[data-cult]`);
assert.deepEqual(await target.page.$$eval(`${slot} select[data-cult]`, els => Object.fromEntries(els.map(e => [e.dataset.cult, Number(e.value)]))),
{ potential: patch.potential ?? 6, cultivate: patch.cultivate ?? 3 }, 'the restored pick displays its restored settings');
await target.shot(`${name.replaceAll(' ', '-')}-imported`);
}
assert.deepEqual([...c.problems, ...target.problems].filter(p => p.startsWith('pageerror:')), []);
console.log(JSON.stringify({ scenario: name, payload, imported: await saved(target), resourceProblems: c.problems.length + target.problems.length }));
} finally { await target?.close(); await c?.close(); }
});
}
test('defaults, clearing a pick and normal reset preserve the existing scopes', { timeout: 90000 }, async () => {
let c;
try {
c = await open('reset');
assert.equal(await disabled(c, 'loadout-export'), true);
await chooseKalts(c);
await tab(c, 'loadout');
assert.equal(await disabled(c, 'loadout-export'), true, 'a roster choice alone is not loadout settings');
await setKalts(c, { potential: 1, cultivate: 0 });
await changeNormal(c);
await c.click('.lo-top__right button', '全部恢复默认');
await c.click('.modal button', '恢复默认');
await c.page.waitForSelector('.modal', { hidden: true });
assert.deepEqual(await saved(c), { entries: {}, ops: { [KALTS]: { potential: 1, cultivate: 0 } } });
assert.equal(await disabled(c, 'loadout-export'), false, 'normal reset retains exportable DIY settings');
await tab(c, 'diy');
await c.click(`${slot} [data-testid="diy-clear"]`);
await tab(c, 'loadout');
assert.equal(await disabled(c, 'loadout-export'), false, 'clearing a pick retains its per-operator settings');
await chooseKalts(c);
await setKalts(c, { potential: 6, cultivate: 3 });
await tab(c, 'loadout');
assert.deepEqual(await saved(c), { entries: {}, ops: {} });
assert.equal(await disabled(c, 'loadout-export'), true, 'back to defaults: nothing to export');
assert.deepEqual(c.problems.filter(p => p.startsWith('pageerror:')), []);
} finally { await c?.close(); }
});
});
+7 -1
View File
@@ -200,7 +200,13 @@ describe('GitHub issue #8 item 1: 本局信息 in the strategy draft (real serve
const arcane = await centre(page, '.minfo-dlg .brief-bond[data-bond="arcaneShip"] .bond__disc');
await page.mouse.move(arcane.x, arcane.y);
await page.waitForSelector('.tooltip.is-shown', { visible: true, timeout: 3000 });
assert.equal(await page.$eval('.tooltip.is-shown', (el) => el.textContent), '奥术:本局禁用(该盟约不会激活)');
assert.equal(await page.$eval('.tooltip.is-shown .tooltip__main', (el) => el.textContent), '奥术:本局禁用(该盟约不会激活)');
const listed = await page.$$eval('.tooltip--aside .brief-bantip__txt b', els => els.map(e => e.textContent));
const arcaneNames = await page.evaluate(() => {
const { store, data } = globalThis.__SP__;
return store.get().match.public.bannedChess.map(id => data.lookup('chess', id)).filter(c => c?.bonds?.includes('arcaneShip')).map(c => c.name).sort();
});
assert.deepEqual(listed.sort(), arcaneNames, 'the aside shows precisely this bond\'s banned members');
const status = await page.$eval('[data-testid="match-info-status"]', (el) => el.textContent.trim());
assert.equal(status, '轮到你决策', 'solo: untimed — no seconds');
const box = await page.$eval('.minfo-dlg', (el) => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right }; });
+12
View File
@@ -106,6 +106,18 @@ test('MatchBondRow: greyed by state (✕ disc), the banned-member badge, the red
assert.equal(badge('deputShip'), undefined);
assert.equal(tipOf('deputShip').props.text, '助力');
assert.equal(disc('deputShip').props.tier, DATA.bonds.deputShip.thresholds.length);
// the box beside the tip: one line per banned member (avatar, name, its bonds with this one highlighted); none ⇒ null
const aside = tipOf('preciShip').props.aside;
const ops = [...walk(aside)].filter((v) => hasClass(v, 'brief-bantip__op'));
const bannedPreci = DATA.bonds.preciShip.visibleMembers.filter((id) => PUB.bannedChess.includes(id));
assert.deepEqual(ops.map((v) => v.props['data-chess']), bannedPreci);
for (const op of ops) {
const c = DATA.chess[op.props['data-chess']];
assert.ok([...walk(op)].some((v) => v.type === UnitThumb && v.props.id === c.chessId));
assert.match(textOf(op), new RegExp('^' + c.name));
assert.ok([...walk(op)].some((v) => hasClass(v, 'is-this') && textOf(v) === '精准'));
}
assert.equal(tipOf('deputShip').props.aside, null);
});
test('MatchLegend: the grey and the badge; "或本模式禁用" only when the mode switches bonds off', () => {
+44
View File
@@ -910,6 +910,50 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
});
}
test('session replacement: recovery action stays clickable above the AI away overlay', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
await page.click('.gtop__exit');
await page.waitForSelector('.modal');
const buttons = await page.$$('.modal__actions .btn');
await buttons[1].click(); // 暂离
await page.waitForSelector('.awayov[aria-label="AI 托管中"]', { visible: true });
await page.evaluate(async () => {
const { net } = await import('/js/net.js');
globalThis.__RECOVERY_CONNECTS__ = 0;
net.connect = () => { globalThis.__RECOVERY_CONNECTS__++; };
globalThis.__MOCK__.store.set({ connection: { status: 'closed', ping: 0, attempt: 0, retryAt: 0, lastError: { code: 'REPLACED' }, everOnline: true } });
});
await page.waitForSelector('.conn-banner .btn', { visible: true });
await sleep(300);
assert.deepEqual(await page.evaluate(() => {
const conn = globalThis.__MOCK__.store.get().connection;
return [conn.status, conn.lastError.code];
}), ['closed', 'REPLACED']);
assert.ok(await page.$('.awayov'), 'the away overlay remains during recovery');
assert.match(await page.$eval('.conn-banner', (el) => el.textContent), /该身份已在其他页面登录/);
assert.equal(await page.$eval('.conn-banner .btn', (el) => el.textContent.trim()), '在此页面继续');
const center = await page.$eval('.conn-banner .btn', (btn) => {
const r = btn.getBoundingClientRect();
const x = r.left + r.width / 2, y = r.top + r.height / 2;
return { x, y, hit: btn.contains(document.elementFromPoint(x, y)) };
});
assert.ok(center.hit, 'the recovery button receives pointer input above the away overlay');
await page.screenshot({ path: path.join(OUT, 'ui-recovery-above-away.png') });
await page.evaluate(async () => {
const { confirmDialog } = await import('/js/ui/components.js');
void confirmDialog({ title: 'Recovery layering check', text: 'Global dialog stays above connection recovery.' });
});
await page.waitForSelector('.modal');
assert.equal(await page.$eval('.conn-banner .btn', btn => {
const r = btn.getBoundingClientRect(); return btn.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2));
}), false, 'the connection banner does not steal input from a global modal');
await page.keyboard.press('Escape'); await page.waitForSelector('.modal', { hidden: true });
await page.mouse.click(center.x, center.y);
assert.equal(await page.evaluate(() => globalThis.__RECOVERY_CONNECTS__), 1, 'the pointer click invokes the recovery action');
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
await page.close();
});
test('watch: the other pair of the Final Assault is refused and the eye stays put', async () => {
const { page, problems } = await open('phase=FINAL_ASSAULT');
const rows = await page.$$('.team__row');
+6 -6
View File
@@ -1,5 +1,5 @@
// Regression tests (Node) of user playtest #3, HUD / UI items:
// 2 the own LP drops live while the own battle's enemies enter the blue gate — pendingLoss / liveLp (the settle
// 2 the own LP's pending loss is marked live while the own battle's enemies enter the blue gate — pendingLoss / liveLp (the settle
// rule min(lpCapPerRound, counted leaks), reset when the settled m.private lands, 联防中 during 联防), the team
// panel rows (rowLp: own live value, teammates' m.public players[].pendingLp), the LP tower's −N tick, and the
// server's pendingLp (server/match/match/views.js _pendingLpView)
@@ -59,7 +59,7 @@ const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.
// ---- 2: live LP ----------------------------------------------------------------------------------------------------
describe('2: the own LP drops live while the battle runs', () => {
describe("2: the own LP's pending loss is marked live while the battle runs", () => {
test('pendingLoss is the settle rule: min(lpCapPerRound, counted leaks), never negative', () => {
assert.equal(pendingLoss(0), 0);
assert.equal(pendingLoss(3), 3);
@@ -150,7 +150,7 @@ describe('2: the own LP drops live while the battle runs', () => {
assert.deepEqual(rowLp({ lp: 30 }, { phase: PHASE.FINAL_ASSAULT }, { lp: 99, pending: 5 }), { lp: 30, pending: 0, unite: false, left: null }, 'boss rounds: the row keeps its own share');
});
test('LP tower: lp − pending in red with a −N tick keyed by the pending value (it pops again on every leak), the 联防中 tag', () => {
test('LP tower: the settled LP in red with a −N tick keyed by the pending value (it pops again on every leak) — the official keeps the current value until settlement (GitHub #479), the 联防中 tag', () => {
const plain = LpTower({ value: 24, size: 'lg' });
assert.ok(!hasClass(plain, 'is-pending'));
assert.equal(textOf(plain), '24');
@@ -160,11 +160,11 @@ describe('2: the own LP drops live while the battle runs', () => {
assert.equal(pend.key, 3, 'keyed: a new count re-mounts the tick (its pop animation plays again)');
assert.equal(textOf(pend), '−3');
const val = [...walk(v)].find((n) => hasClass(n, 'lp__val'));
assert.equal(textOf(val), '21');
assert.equal(textOf(val), '24', 'the number keeps the settled LP; the −3 tick carries the pending loss (GitHub #479)');
assert.equal(v.props.title, 'x');
const unite = LpTower({ value: 5, pending: 9, note: '联防中' });
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__val'))), '0', 'clamped');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__pend'))), '−5');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__val'))), '5');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__pend'))), '−5', 'the tick clamps at the LP left');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__note'))), '联防中');
assert.equal(textOf(LpTower({ value: null, pending: 3 })), '--');
});
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { prepCamera, deployFieldOf, teamFrameIds } from '../../public/js/ui/gameLogic/camera.js';
import { makeMatch } from '../match/harness.js';
import { PHASE } from '../../shared/constants.js';
for (const humans of [2, 3, 4]) test(`random ${humans}-player boss preparation: client camera, placement and partner frames match server`, () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans, seed: 31, fake: true }).start();
try {
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 14);
const pub = h.m.publicView();
assert.deepEqual(pub.bossPairs, h.m.bossPairs);
for (const p of h.m.alivePlayers()) {
const expected = h.m.bossGroupOf(p);
assert.equal(prepCamera(pub, p.playerId).opts.side, expected.side);
assert.equal(deployFieldOf(pub, p.playerId), h.m.deployFieldOf(p));
assert.deepEqual([...teamFrameIds(pub, p.playerId)], expected.players.length > 1 ? expected.players : []);
}
h.m.onLeave(h.m.bossPairs[0][0]);
const after = h.m.publicView();
for (const p of h.m.alivePlayers()) assert.equal(deployFieldOf(after, p.playerId), h.m.deployFieldOf(p));
} finally { h.m.dispose(); }
});
test('unknown/new pairing is not guessed from seats; older servers still have their ordered fallback', () => {
const pub = { phase: PHASE.PREP, round: 14, bossRound: 14, players: [{ playerId: 'a', seat: 0 }, { playerId: 'b', seat: 1 }] };
assert.equal(prepCamera(pub, 'b').opts.side, 'R');
assert.equal(prepCamera({ ...pub, bossPairs: null }, 'b').kind, 'prep');
assert.equal(prepCamera({ ...pub, bossPairs: [['b', 'a']] }, 'b').opts.side, 'L');
assert.deepEqual([...teamFrameIds({ ...pub, bossPairs: [['b', 'a']] }, 'a')], ['b', 'a']);
});
+84
View File
@@ -0,0 +1,84 @@
// test/ui/refresh-tap.e2e.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」只发送一次请求. Through the mock
// harness (public/dev/game-mock.html) in headless Chrome, with the mock's reply to g.refresh delayed: the first tap
// sends one request and dims the button, a spam tap and the R key while it is on the wire send nothing, the shop pays
// once, and the next tap after the reply refreshes again. Opt-in: SP_E2E=1 (needs Chrome; CHROME_PATH overrides).
//
// SP_E2E=1 node --test test/ui/refresh-tap.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);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const BTN = '.shopbar__tools .toolbtn--amber';
describe('the shop refresh single-flights (GitHub #501, mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
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 });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('a tap under a lagging shop pays once: the button dims, the extra tap / R key are dropped, the next tap works', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
await page.goto(`${base}/dev/game-mock.html?shot=1&phase=PREP&render=fallback`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
// the mock answers instantly; give g.refresh a high RTT, and count what the client actually sends (gameActions.js
// looks net.request up at call time; the mock's own request record only sees the request after the delay)
await page.evaluate(async () => {
const { net } = await import('/js/net.js');
const orig = net.request.bind(net);
globalThis.__REFRESHES__ = 0;
net.request = (t, ...rest) => {
if (t === 'g.refresh') globalThis.__REFRESHES__ += 1;
return new Promise((res, rej) => {
setTimeout(() => orig(t, ...rest).then(res, rej), t === 'g.refresh' ? 700 : 0);
});
};
});
const btnState = () => page.evaluate(() => {
const b = document.querySelector('.shopbar__tools .toolbtn--amber');
return { disabled: !!b?.disabled, title: b?.title || null };
});
const refreshes = () => page.evaluate(() => globalThis.__REFRESHES__);
const funds = () => page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
assert.equal((await btnState()).disabled, false, 'the 刷新 button starts enabled');
const n0 = await refreshes();
const f0 = await funds();
await page.click(BTN);
await sleep(150);
assert.equal(await refreshes(), n0 + 1, 'the tap sent one g.refresh');
assert.deepEqual(await btnState(), { disabled: true, title: '正在刷新…' }, 'the button dims and says it is waiting');
// the spam tap (the disabled button swallows clicks) and the R hotkey while pending: neither reaches the socket
await page.evaluate(() => document.querySelector('.shopbar__tools .toolbtn--amber')?.click());
await page.keyboard.press('r');
await sleep(200);
assert.equal(await refreshes(), n0 + 1, 'nothing else was sent');
await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 });
const back = await btnState();
assert.equal(back.disabled, false, 'the button is enabled again after the reply');
assert.match(back.title, /刷新商店/, 'the tooltip is back to the hotkey hint');
assert.equal(await funds(), f0 - 1, 'the shop paid exactly one refresh');
await page.click(BTN);
await sleep(150);
assert.equal(await refreshes(), n0 + 2, 'a refresh after the reply is sent again');
await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 });
assert.equal(await funds(), f0 - 2, 'and paid once more');
await page.close();
});
});
+87
View File
@@ -0,0 +1,87 @@
// test/ui/refresh-tap.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」会重复扣费. The intent layer now
// single-flights it (ui/gameActions.js): a tap while the previous g.refresh is still on the wire is dropped — the
// button dims through onRefreshBusy (game.js → ui/shopBar.js) and the R hotkey (the same actions.refresh) stays inert
// until the reply lands. The browser run of the same case is test/ui/refresh-tap.e2e.test.js (SP_E2E=1).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const { net } = await import('../../public/js/net.js');
const { actions, onRefreshBusy } = await import('../../public/js/ui/gameActions.js');
describe('the shop refresh single-flights (GitHub #501)', () => {
test('a refresh while the previous one is on the wire sends nothing; the busy signal brackets the request; a later one sends again', async () => {
const sent = [];
let release;
const prev = net.request;
net.request = (t) => { sent.push(t); return new Promise((r) => { release = () => r({ ok: true }); }); };
const seen = [];
const off = onRefreshBusy((b) => seen.push(b));
try {
assert.deepEqual(seen, [false], 'subscribing fires once with the current (idle) state');
const first = actions.refresh();
assert.equal(sent.length, 1);
assert.equal(seen.at(-1), true, 'busy from the moment of the send');
assert.equal(await actions.refresh(), false, 'the duplicate resolves false without waiting');
assert.equal(sent.length, 1, 'and never reaches the socket');
release();
assert.equal(await first, true);
assert.deepEqual(seen, [false, true, false], 'the reply clears the busy state');
const again = actions.refresh();
assert.equal(sent.length, 2, 'a refresh after the reply is sent again');
release();
assert.equal(await again, true);
assert.deepEqual(seen, [false, true, false, true, false]);
} finally {
off();
net.request = prev;
}
});
test('an unsubscribe stops updates, and a remount receives the in-flight state', async () => {
const seen = [];
const off = onRefreshBusy((b) => seen.push(b));
off();
const prev = net.request;
let release;
net.request = () => new Promise(resolve => { release = resolve; });
let offNew;
try {
const pending = actions.refresh();
const remounted = [];
offNew = onRefreshBusy(b => remounted.push(b));
assert.deepEqual(remounted, [true]);
release({ ok: true }); await pending;
assert.deepEqual(remounted, [true, false]); assert.deepEqual(seen, [false]);
} finally { offNew?.(); net.request = prev; }
});
for (const code of ['OFFLINE', 'DISCONNECTED', 'TIMEOUT', 'NOT_ENOUGH_FUNDS']) test(`${code} clears the pending lock without sending the duplicate`, async () => {
const prev = net.request, states = []; let reject, sent = 0;
net.request = () => { sent++; return new Promise((_, fail) => { reject = fail; }); };
const off = onRefreshBusy(b => states.push(b));
try {
const first = actions.refresh();
assert.equal(await actions.refresh(), false); assert.equal(sent, 1);
reject(Object.assign(new Error(code), { code }));
assert.equal(await first, false); assert.deepEqual(states, [false, true, false]);
net.request = async () => { sent++; return { ok: true }; };
assert.equal(await actions.refresh(), true); assert.equal(sent, 2);
} finally { off(); net.request = prev; }
});
test('the wiring: game.js subscribes and hands the state to the bar; the button dims and says what it waits for', () => {
const game = read('public/js/screens/game.js');
assert.match(game, /onRefreshBusy\(setRefreshBusy\)/, 'the game screen subscribes');
assert.match(game, /<\$\{ShopBar\}[\s\S]*?refreshBusy=\$\{refreshBusy\}/, 'and the bar receives it');
const bar = read('public/js/ui/shopBar.js');
assert.match(bar, /disabled=\$\{!!refReason \|\| refreshBusy\}/, 'busy disables the 刷新 button');
assert.match(bar, /t\('正在刷新…'\)/, 'and its tooltip says what it is waiting for');
});
});
+120
View File
@@ -0,0 +1,120 @@
// Isolated component/tool fixtures, with production modules and CSS in a real browser.
import { test, before, after, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { CHROME, hasChrome } from '../e2e/client.mjs';
describe('round30 tool and compound-tooltip browser boundaries', { skip: !(process.env.SP_E2E === '1' && hasChrome()) }, () => {
let srv, browser, page;
before(async () => {
const { startServer } = await import('../../server/index.js');
srv = await startServer({ host: '127.0.0.1', port: 0, quiet: true });
const P = (await import('puppeteer-core')).default;
browser = await P.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-background-timer-throttling'] });
page = await browser.newPage();
page.on('pageerror', e => console.error('fixture page error:', e.message));
});
after(async () => { await browser?.close(); await srv?.close(); });
async function blank(query = '') {
await page.goto(`http://127.0.0.1:${srv.port}/dev/simcheck/ui.js${query}`);
await page.setContent('<html><head><link rel="stylesheet" href="/css/theme.css"><link rel="stylesheet" href="/css/components.css"></head><body><main id="fixture"></main></body></html>');
}
async function tooltip(width, height, anchor = '50%', touch = false) {
await page.setViewport({ width, height, hasTouch: touch }); await blank();
await page.evaluate(async (anchor) => {
const { h, render } = await import('/vendor/preact.module.js');
const { Tooltip, TooltipLayer } = await import('/js/ui/components.js');
document.documentElement.style.cssText = 'font-size:100px;--t:130px';
const root = document.getElementById('fixture');
root.style.cssText = `position:fixed;top:45%;left:${anchor};font-size:16px`;
window.changeTip = (names = 'A', aside = true) => render(h('div', {},
h(Tooltip, { text: 'same count: 12', aside: aside ? h('div', {}, ...Array.from({ length: 12 }, (_, i) => h('div', {}, `${names}-${i} Long operator name`))) : null }, h('button', { id: 'anchor' }, 'bond')),
h(TooltipLayer)), root);
window.changeTip();
}, anchor);
await page.hover('#anchor'); await page.waitForSelector('.tooltip.is-shown');
}
for (const [w, h] of [[1440, 900], [390, 844], [844, 390]]) test(`compound tooltip fits ${w}×${h}, all anchor positions`, async () => {
for (const anchor of ['1%', '50%', '90%']) {
await tooltip(w, h, anchor);
const box = await page.$eval('.tooltip', e => {
const a = e.getBoundingClientRect(), main = e.querySelector('.tooltip__main').getBoundingClientRect(), side = e.querySelector('.tooltip--aside').getBoundingClientRect();
return { left: a.left, right: a.right, top: a.top, bottom: a.bottom, overlap: main.left < side.right && side.left < main.right && main.top < side.bottom && side.top < main.bottom,
overflow: e.scrollHeight > e.clientHeight, canScroll: getComputedStyle(e).overflowY === 'auto' && getComputedStyle(e).pointerEvents === 'auto' };
});
assert.ok(box.left >= 7 && box.right <= w - 7 && box.top >= 3 && box.bottom <= h - 3, JSON.stringify(box));
assert.equal(box.overlap, false); if (box.overflow) assert.equal(box.canScroll, true);
await page.keyboard.press('Escape'); await page.waitForSelector('.tooltip', { hidden: true });
}
});
test('visible tooltip updates equal-count member lists and removes an absent aside', async () => {
await tooltip(800, 600);
await page.evaluate(() => window.changeTip('B'));
await page.waitForFunction(() => document.querySelector('.tooltip--aside')?.textContent.includes('B-11'));
assert.equal(await page.$eval('.tooltip', e => e.textContent.includes('A-')), false);
await page.evaluate(() => window.changeTip('B', false));
await page.waitForSelector('.tooltip--aside', { hidden: true });
await page.mouse.move(1, 1); await page.hover('#anchor'); await page.waitForSelector('.tooltip.is-shown');
assert.equal(await page.$eval('.tooltip', e => getComputedStyle(e).pointerEvents), 'none');
});
test('touch long press keeps the long list scrollable and outside tap closes it', async () => {
await tooltip(390, 260, '40%', true);
await page.keyboard.press('Escape'); await page.$eval('#anchor', e => e.blur());
const rect = await page.$eval('#anchor', e => { const r = e.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });
await page.touchscreen.touchStart(rect.x, rect.y);
await page.waitForSelector('.tooltip.is-shown'); await page.touchscreen.touchEnd();
await new Promise(r => setTimeout(r, 1750));
assert.ok(await page.$('.tooltip.is-shown'));
await page.focus('.tooltip'); await page.keyboard.press('End');
await page.waitForFunction(() => document.querySelector('.tooltip')?.scrollTop > 0);
await page.touchscreen.tap(2, 2); await page.waitForSelector('.tooltip', { hidden: true });
});
const toolHtml = readFileSync(new URL('../../public/dev/sim-check.html', import.meta.url), 'utf8').replace(/<script[\s\S]*?<\/script>/g, '');
async function tool({ mode = '', query = '', count = 1 } = {}) {
await blank(query); await page.setContent(toolHtml);
await page.evaluate(async ({ mode, count }) => {
const { mountCheck } = await import('/dev/simcheck/ui.js');
const { trace } = await import('/dev/simcheck/core.js');
const scenarios = Array.from({ length: count }, (_, i) => ({ id: `case-${i}`, family: 'fast' }));
window.failAt = mode;
const adapter = {
start: () => { if (window.failAt === 'start') throw Error('start failure'); return { tick: 0 }; },
step: r => { if (window.failAt === 'step') throw Error('step failure'); return ++r.tick <= 2; },
state: r => [r.tick + (window.failAt === 'diff' ? 1 : 0)],
result: () => { if (window.failAt === 'result') throw Error('result failure'); return 'ok'; },
};
const saved = window.failAt; window.failAt = '';
const reference = { every: 1, scenarios: Object.fromEntries(scenarios.map(s => [s.id, trace(adapter, s, { every: 1 })])) };
window.failAt = saved;
mountCheck(document.getElementById('app'), { project: 'test', title: 'check', subtitle: '<img src=x onerror="window.injected=true">', adapter, scenarios,
subsets: [{ id: 'fast', label: 'fast', pick: () => true }], reference });
}, { mode, count });
}
for (const mode of ['start', 'step', 'result']) test(`sim check recovers from ${mode} exceptions and allows rerun`, async () => {
await tool({ mode }); await page.click('.sc-run');
await page.waitForFunction(() => window.__simcheck?.status === 'error');
assert.equal(await page.$eval('.sc-run', b => b.disabled), false);
assert.equal(await page.evaluate(() => !!window.injected), false);
await page.evaluate(() => { window.failAt = ''; }); await page.click('.sc-run');
await page.waitForFunction(() => window.__simcheck?.status === 'done');
assert.deepEqual(await page.evaluate(() => [window.__simcheck.expected, window.__simcheck.completed, window.__simcheck.summary.ok]), [1, 1, 1]);
});
for (const query of ['?only=typo', '?subset=typo']) test(`sim check rejects empty or unknown selection ${query}`, async () => {
await tool({ query }); await page.click('.sc-run');
await page.waitForFunction(() => window.__simcheck?.status === 'error');
assert.equal(await page.$('.sc-allok'), null);
});
test('long difference list scrolls to its report; a failed dump restores its button', async () => {
await page.setViewport({ width: 390, height: 400 }); await tool({ mode: 'diff', count: 30 });
await page.click('.sc-run'); await page.waitForFunction(() => window.__simcheck?.status === 'done');
assert.equal(await page.$$eval('.sc-list li', rows => rows.length), 30);
await page.evaluate(() => { window.failAt = 'start'; }); await page.click('.sc-list button');
await page.waitForFunction(() => document.querySelector('.sc-status').textContent.includes('start failure'));
assert.equal(await page.$eval('.sc-list button', b => b.disabled), false);
await page.$eval('.sc-report', e => e.scrollIntoView());
assert.ok(await page.evaluate(() => window.scrollY > 0));
assert.ok(await page.$eval('.sc-report', e => e.getBoundingClientRect().top < window.innerHeight));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
});
});