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:
1 parent
bc50cac01f
commit
221e6b8dd2
134 files changed
+4590
-1201
No files matched your search
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
});
|
||||
@@ -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 }; });
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 })), '--');
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user