commit
2519a23a55
24 files changed
+343
-50
No files matched your search
@@ -992,6 +992,28 @@ describe('screen helpers', () => {
|
||||
assert.equal(difficultyInfo('coop', 'BOGUS').rounds, 14);
|
||||
});
|
||||
|
||||
// Regression: `onClick=${handler}` hands Preact's click EVENT as the first argument, and a default parameter only
|
||||
// applies to `undefined` — so `normalizeCode(eventTarget)` produced "[object HTMLElement]" → "OBJE", and every
|
||||
// 观战 click sent the same nonsense key no matter what was typed in the field. codeArg() is the guard for that.
|
||||
test('lobby: a click event is never mistaken for an alliance key (codeArg)', async () => {
|
||||
const { codeArg, normalizeCode } = await mod('screens/lobby.js');
|
||||
// a DOM element stringifies to "[object HTMLElement]" — the shape that caused the bug
|
||||
const element = { toString: () => '[object HTMLElement]' };
|
||||
assert.equal(normalizeCode(element), 'OBJE', 'the old bug: the element normalises into a fake 4-letter code');
|
||||
assert.equal(codeArg(element, 'NJBU'), 'NJBU', 'a non-string argument falls back to the input field');
|
||||
// plain event-like objects and other non-strings behave the same
|
||||
for (const weird of [{}, [], 42, true, null, undefined, Symbol('x'), () => {}]) {
|
||||
assert.equal(codeArg(weird, 'NJBU'), 'NJBU', `falls back for ${String(weird)}`);
|
||||
}
|
||||
// an explicit string always wins (the full-room prompt passes the code it asked for)
|
||||
assert.equal(codeArg('ab cd', 'NJBU'), 'ABCD');
|
||||
// no usable code at all
|
||||
assert.equal(codeArg(element, ''), null);
|
||||
assert.equal(codeArg(element, 'AB'), null);
|
||||
assert.equal(codeArg(undefined, undefined), null);
|
||||
assert.equal(codeArg('ZZZ QQQ', ''), 'ZZZQ', 'a real string is still truncated to ROOM_CODE_LEN');
|
||||
});
|
||||
|
||||
test('lobby: battlefield note per difficulty (标准 fixed 战场#01, 险境 8 / 绝境·终极 7 random) matches config.json modes[].stages', async () => {
|
||||
const { difficultyInfo, stageNote, stageLabel, STAGE_POOL } = await mod('screens/lobby.js');
|
||||
const cfg = JSON.parse(readFileSync(path.join(ROOT, 'data/config.json'), 'utf8'));
|
||||
|
||||
@@ -232,6 +232,17 @@ test('缇缇 S2 封护: no attacks; she and the lowest-HP op in range sleep (inv
|
||||
approx(dream.at(-1).amount, u.s.atk * t0.damage_atk_scale * bb.talent_scale, 'T1 ×talent_scale');
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 30));
|
||||
assert.equal(during(h, u, attacks(h, u)).length, 0, 'no attacks during the skill');
|
||||
// …and every 凝固的时光 tick healed a 咒愈师 ally: the official trait buff (`titi_tr`) is ON_AFTER_OUTPUT_DAMAGE, so
|
||||
// the heal follows ANY damage she deals. The sim ran the trait from the attack path only and she does not attack at
|
||||
// all while this skill runs, so those ticks healed nothing (the same hook gap that left 隐德来希 S2 without heals).
|
||||
const dreams = during(h, u, dealt(h, u, (c) => (c.dmg?.tags || []).includes('titiDream')));
|
||||
const incHeals = during(h, u, heals(h, u, (c) => (c.opts?.tags || []).includes('incantation')));
|
||||
assert.ok(dreams.length > 0, `${dreams.length} 凝固的时光 ticks`);
|
||||
assert.equal(incHeals.length, dreams.length, 'one trait heal per tick');
|
||||
for (const c of incHeals) {
|
||||
approx(c.amount, dreams[0].amount * u.profile.healRatio, 'heal = 50 % of the tick');
|
||||
assert.ok([hurt, fine, u].includes(c.target), 'on an ally in her range');
|
||||
}
|
||||
assert.ok(!u.findBuff('titi:ward') && !hurt.findBuff('titi:ward'), 'both wake at the end');
|
||||
done(h);
|
||||
}
|
||||
@@ -417,6 +428,51 @@ test('隐德来希 S2 绯红壁合: no attacks; blood sickles on her and on the
|
||||
}
|
||||
});
|
||||
|
||||
test('隐德来希 S2 绯红壁合: every 血镰 cut heals the 收割者 trait (每攻击到一个敌人回复自身50生命)', () => {
|
||||
for (const id of pair('06')) {
|
||||
const h = run({
|
||||
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
|
||||
units: [entry(id, 'skchr_etlchi_2', { row: 10, col: 4 })],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
});
|
||||
const u = sel(h, id, 'skchr_etlchi_2');
|
||||
h.step();
|
||||
cast(h, u);
|
||||
u.hp = u.s.maxHp * 0.4; // wounded (and above 重盈's 25 %), so every heal shows
|
||||
const hp0 = u.hp, t0 = h.b.time;
|
||||
h.run(3);
|
||||
const cuts = tagged(h, u, 'bloodSickle').filter((c) => c.t > t0 + 1e-9);
|
||||
const healed = heals(h, u, (c) => c.target === u && c.t > t0 + 1e-9);
|
||||
assert.ok(cuts.length >= 5, `${cuts.length} cuts`);
|
||||
assert.equal(healed.length, cuts.length, 'one trait heal per cut (the trait fires on every damage she outputs)');
|
||||
for (const c of healed) approx(c.amount, u.profile.selfHeal, 'per-hit heal = the trait 生命值');
|
||||
approx(u.hp - hp0, healed.length * u.profile.selfHeal, 'HP restored');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('隐德来希 S2 绯红壁合: the 收割者 heal is capped by the block count per cut (最大生效数等于阻挡数)', () => {
|
||||
const h = run({
|
||||
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
|
||||
units: [entry('chess_char_5_06_a', 'skchr_etlchi_2', { row: 10, col: 4 })],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [9, 4] }, { key: 'enemy_dummy', pos: [11, 4] }],
|
||||
});
|
||||
const u = sel(h, 'chess_char_5_06_a', 'skchr_etlchi_2');
|
||||
h.step();
|
||||
cast(h, u);
|
||||
u.hp = u.s.maxHp * 0.4;
|
||||
const hp0 = u.hp, t0 = h.b.time;
|
||||
h.run(3);
|
||||
const cuts = tagged(h, u, 'bloodSickle').filter((c) => c.t > t0 + 1e-9);
|
||||
const healed = heals(h, u, (c) => c.target === u && c.t > t0 + 1e-9);
|
||||
const ticks = cuts.length / 3; // three enemies stand in the sickle's ring
|
||||
assert.ok(ticks >= 5 && Number.isInteger(ticks), `${cuts.length} cuts over ${ticks} ticks`);
|
||||
assert.equal(healed.length, ticks * Math.max(1, u.s.blockCnt), `${healed.length} heals (阻挡数 ${u.s.blockCnt})`);
|
||||
for (const c of healed) approx(c.amount, u.profile.selfHeal, 'per-hit heal');
|
||||
approx(u.hp - hp0, healed.length * u.profile.selfHeal, 'HP restored');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('隐德来希 module REA-Y (玫瑰色故事集): ASPD +12 with ≥ 2 enemies in range', () => {
|
||||
const h = run({
|
||||
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
|
||||
|
||||
@@ -348,6 +348,16 @@ test('incantation medics heal an ally for 50 % of damage dealt; wandermedics cle
|
||||
h.unit('t_tank').hp = 1000;
|
||||
h.run(0.5);
|
||||
approx(h.unit('t_tank').hp, 1000 + 500, 1e-6);
|
||||
// …and not only for attacks: the official trait buff is ON_AFTER_OUTPUT_DAMAGE (`vendla_tr` / `reed2_tr` / `titi_tr`),
|
||||
// so ANY damage the 咒愈师 deals heals. The sim used to run the trait from the attack path (`profile.afterHit`) only,
|
||||
// which is why 缇缇's 凝固的时光 ticks — and every 咒愈师 skill that damages without an attack — healed nothing.
|
||||
h.unit('t_tank').hp = 1000;
|
||||
h.b.dealDamage(h.unit('t_inc'), h.enemy('enemy_dummy'), { amount: 400, type: 'arts', isSkill: true });
|
||||
approx(h.unit('t_tank').hp, 1000 + 200, 1e-6, 'skill damage heals 50 %');
|
||||
// a gauge fill removes no HP: it is not "伤害" for the trait
|
||||
h.unit('t_tank').hp = 1000;
|
||||
h.b.emit?.('damaged', { source: h.unit('t_inc'), target: h.enemy('enemy_dummy'), amount: 400, type: 'element', dmg: null });
|
||||
approx(h.unit('t_tank').hp, 1000, 1e-6, 'element 损伤 does not heal');
|
||||
const wm = chessRec({ id: 't_wm', profession: 'MEDIC', subProfessionId: 'wandermedic', attackKind: 'heal', dmgType: 'heal', rangeGrid: R3, skill: null, stats: { atk: 400 } });
|
||||
const h2 = makeBattle({ defs: { chess: { t_wm: wm, t_tank: tank } }, units: [{ chessId: 't_wm', row: 10, col: 4 }, { chessId: 't_tank', row: 10, col: 5 }], content: 'none' });
|
||||
h2.step();
|
||||
|
||||
+10
-1
@@ -30,7 +30,8 @@ describe('bgm selection', () => {
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.PREP }), 'prep');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.SP_DRAFT }), 'prep');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.COMBAT }), 'combat');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.UNITE }), 'combat');
|
||||
// 联防 has its own track: the official escaped_single / escaped_multi levels declare bgmEvent = corrosion
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.UNITE }), 'unite');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.FINAL_ASSAULT, bossId: 'boss_4' }), 'boss:boss_4');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.FINAL_ASSAULT }), 'boss');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.HIDDEN_CORE, bossId: 'boss_1', hiddenBossId: 'boss_9' }), 'boss:boss_9');
|
||||
@@ -44,6 +45,14 @@ describe('bgm selection', () => {
|
||||
assert.equal(b4.loop, manifest.audio.bossBgm.boss_4.loop);
|
||||
assert.equal(resolveBgm(manifest, 'boss:nope').loop, manifest.audio.bgm.boss.loop);
|
||||
assert.equal(resolveBgm(manifest, 'prep').intro, manifest.audio.bgm.prep.intro ?? null);
|
||||
// 联防's own track (bgm.unite = corrosion, the official escaped levels' bgmEvent), and the fallback for an older
|
||||
// manifest that has no `unite` entry (the music must not go silent)
|
||||
const unite = manifest.audio.bgm.unite;
|
||||
assert.ok(unite && typeof unite.loop === 'string', 'the manifest carries 联防’s own track');
|
||||
assert.equal(resolveBgm(manifest, 'unite').loop, unite.loop);
|
||||
assert.equal(resolveBgm(manifest, 'unite').intro, unite.intro ?? null);
|
||||
assert.notEqual(unite.loop, manifest.audio.bgm.combat.loop, 'not the shop / default combat loop');
|
||||
assert.equal(resolveBgm({ audio: { bgm: { combat: { loop: '/shop.mp3' } } } }, 'unite').loop, '/shop.mp3');
|
||||
assert.equal(resolveBgm(null, 'lobby'), null);
|
||||
assert.equal(resolveBgm(manifest, null), null);
|
||||
assert.equal(resolveBgm(manifest, 'nope'), null);
|
||||
|
||||
@@ -18,7 +18,17 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const HAS_ART = existsSync(path.join(ROOT, 'public/assets/local/emoticon/fooldoctor'));
|
||||
// The official pictures reach the wheel through a two-link chain (data.js artUrls): the local-client extraction
|
||||
// first (tools/local-extract → data/local-assets.json), then the copies tools/fetch-assets.mjs downloads into
|
||||
// public/assets/ui/emoticon (GitHub #42). A deployment has one link or the other; the assertions below check that
|
||||
// the pictures LOAD, never which link served them, so either one is enough to run the suite.
|
||||
const HAS_ART = existsSync(path.join(ROOT, 'public/assets/local/emoticon/fooldoctor'))
|
||||
|| existsSync(path.join(ROOT, 'public/assets/ui/emoticon/fooldoctor'));
|
||||
// The WHEEL's own chrome is a different story: emoji_cell_bkg / emoji_bkg / emoji_btn come from the local client's
|
||||
// ui/battle atlas (ui/emotes.js emoteUiSprite → localAsset), which tools/fetch-assets.mjs does NOT carry — no dump,
|
||||
// no official cell, and the wheel draws its own. One assertion below is about that official chrome, so it asks for it
|
||||
// separately instead of quietly failing on a fetch-assets-only deployment.
|
||||
const HAS_UI = existsSync(path.join(ROOT, 'public/assets/local/ui/battle/emoji_cell_bkg.png'));
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
@@ -77,7 +87,7 @@ describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (an
|
||||
};
|
||||
}, root);
|
||||
|
||||
test('UI kit: pager per theme, picture-only, swipe / keys / dots, cooldown, remembered theme, 3 s bubble', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
test('UI kit: pager per theme, picture-only, swipe / keys / dots, cooldown, remembered theme, 3 s bubble', { skip: !HAS_ART && 'no official emote art (neither the local extraction nor the fetched copies)' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
@@ -160,7 +170,7 @@ describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (an
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('wheel: one trackpad swipe (momentum included) turns one page, a mouse-wheel notch turns one page', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
test('wheel: one trackpad swipe (momentum included) turns one page, a mouse-wheel notch turns one page', { skip: !HAS_ART && 'no official emote art (neither the local extraction nor the fetched copies)' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
@@ -196,12 +206,19 @@ describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (an
|
||||
const hover = await page.$eval('#emo-stage .ewheel__item:nth-child(2)', (el) => ({
|
||||
sprite: el.closest('.ewheel__panel').classList.contains('has-cell'), bg: getComputedStyle(el).backgroundColor, filter: getComputedStyle(el).filter,
|
||||
}));
|
||||
assert.deepEqual(hover, { sprite: true, bg: 'rgba(0, 0, 0, 0)', filter: 'brightness(1.35)' });
|
||||
if (HAS_UI) {
|
||||
assert.deepEqual(hover, { sprite: true, bg: 'rgba(0, 0, 0, 0)', filter: 'brightness(1.35)' }, 'the official emoji_cell_bkg cell');
|
||||
} else {
|
||||
// no local client dump: the wheel draws its own cell and the hover is a drawn fill — the pictures are what this
|
||||
// test is about on such a deployment, so only pin that it is NOT pretending to be the official sprite cell
|
||||
assert.equal(hover.sprite, false, 'without ui/battle/emoji_cell_bkg the cell is drawn, not the official sprite');
|
||||
assert.ok(hover.bg !== '' && hover.filter !== '', `the drawn cell is still styled: ${JSON.stringify(hover)}`);
|
||||
}
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('the cooldown survives a remount of the wheel; a bubble mounted late resumes its timeline', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
test('the cooldown survives a remount of the wheel; a bubble mounted late resumes its timeline', { skip: !HAS_ART && 'no official emote art (neither the local extraction nor the fetched copies)' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.waitForSelector('#emo-stage .ewheel__panel');
|
||||
await page.evaluate(async () => {
|
||||
@@ -251,7 +268,7 @@ describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (an
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('touch: a horizontal swipe on the pager turns the page, a tap sends', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
test('touch: a horizontal swipe on the pager turns the page, a tap sends', { skip: !HAS_ART && 'no official emote art (neither the local extraction nor the fetched copies)' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.setViewport({ width: 1280, height: 720, hasTouch: true, isMobile: false });
|
||||
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
|
||||
@@ -302,7 +319,7 @@ describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (an
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('in-match mock: bubbles beside the senders\' avatars, wheel above 交流', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
test('in-match mock: bubbles beside the senders\' avatars, wheel above 交流', { skip: !HAS_ART && 'no official emote art (neither the local extraction nor the fetched copies)' }, async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?shot=1&render=fallback&phase=PREP&variant=emote', { w: 1920, h: 1080 });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
|
||||
await page.waitForSelector('.team__bubble');
|
||||
|
||||
@@ -494,6 +494,20 @@ describe('keyboard & settings', () => {
|
||||
assert.equal(shortcutFor({ key: 'r', code: 'KeyR' }), 'refresh');
|
||||
assert.equal(shortcutFor({ key: 'F', code: 'KeyF' }), 'freeze');
|
||||
assert.equal(shortcutFor({ key: 'd' }), 'levelUp');
|
||||
assert.equal(shortcutFor({ key: 'q', code: 'KeyQ' }), 'retreat');
|
||||
assert.equal(shortcutFor({ key: 'Q' }), 'retreat');
|
||||
assert.equal(shortcutFor({ code: 'KeyX' }), 'sell');
|
||||
assert.equal(shortcutFor({ key: 'X' }), 'sell');
|
||||
for (const key of ['q', 'x']) {
|
||||
assert.equal(shortcutFor({ key, repeat: true }), null);
|
||||
assert.equal(shortcutFor({ key, ctrlKey: true }), null);
|
||||
assert.equal(shortcutFor({ key, metaKey: true }), null);
|
||||
assert.equal(shortcutFor({ key, altKey: true }), null);
|
||||
for (const tagName of ['INPUT', 'TEXTAREA', 'SELECT']) {
|
||||
assert.equal(shortcutFor({ key, target: { tagName } }), null);
|
||||
}
|
||||
assert.equal(shortcutFor({ key, target: { isContentEditable: true } }), null);
|
||||
}
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space' }), 'ready');
|
||||
assert.equal(shortcutFor({ key: 'Escape' }), 'escape');
|
||||
assert.equal(shortcutFor({ key: 'r', ctrlKey: true }), null);
|
||||
@@ -503,7 +517,7 @@ describe('keyboard & settings', () => {
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space', target: { tagName: 'BUTTON' } }), 'ready', 'space readies even with a HUD button focused');
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space', target: { tagName: 'TEXTAREA' } }), null);
|
||||
assert.equal(shortcutFor({ key: 'd', target: { tagName: 'DIV', isContentEditable: true } }), null);
|
||||
assert.equal(shortcutFor({ key: 'x' }), null);
|
||||
assert.equal(shortcutFor({ key: 'z' }), null);
|
||||
assert.equal(shortcutFor(null), null);
|
||||
});
|
||||
test('sanitizeSettings', () => {
|
||||
|
||||
@@ -149,6 +149,48 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('prep: Q retreats and X sells only the selected operator', async () => {
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
const initial = await mockState(page);
|
||||
await page.keyboard.press('KeyQ');
|
||||
await page.keyboard.press('KeyX');
|
||||
assert.deepEqual(await mockState(page), initial, 'no selection means no action');
|
||||
const unit = initial.board.find((piece) => piece.kind === 'chess' && !piece.golden);
|
||||
await page.click(`.ff-piece[data-uid="${unit.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--retreat');
|
||||
await page.evaluate(() => {
|
||||
const input = document.createElement('input');
|
||||
input.id = 'shortcut-test-input';
|
||||
document.body.append(input);
|
||||
input.focus();
|
||||
});
|
||||
await page.keyboard.press('KeyQ');
|
||||
await page.keyboard.press('KeyX');
|
||||
assert.deepEqual(await mockState(page), initial, 'typing must not retreat or sell');
|
||||
await page.evaluate(() => document.getElementById('shortcut-test-input').remove());
|
||||
await page.keyboard.press('KeyQ');
|
||||
await page.waitForFunction((uid) => globalThis.__MOCK__.S().priv.hand.some((piece) => piece?.uid === uid), {}, unit.uid);
|
||||
const retreated = await mockState(page);
|
||||
assert.ok(!retreated.board.some((piece) => piece.uid === unit.uid));
|
||||
assert.equal(retreated.funds, initial.funds, 'retreat does not sell');
|
||||
await page.click(`.ff-piece[data-uid="${unit.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--sell');
|
||||
assert.equal(await page.$('.uframe__btn--retreat'), null, 'bench operator cannot retreat');
|
||||
await page.keyboard.press('KeyQ');
|
||||
assert.deepEqual(await mockState(page), retreated, 'Q on the bench does nothing');
|
||||
await page.keyboard.press('KeyX');
|
||||
await page.waitForFunction((uid) => !globalThis.__MOCK__.S().priv.hand.some((piece) => piece?.uid === uid), {}, unit.uid);
|
||||
assert.ok((await mockState(page)).funds > retreated.funds, 'X sells the selected operator');
|
||||
const item = (await mockState(page)).hand.find((piece) => piece?.kind === 'item');
|
||||
await page.click(`.ff-piece[data-uid="${item.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--destroy');
|
||||
await page.keyboard.press('KeyX');
|
||||
assert.ok((await mockState(page)).hand.some((piece) => piece?.uid === item.uid), 'X must not destroy an item');
|
||||
assert.equal(await page.$('.modal'), null, 'no destroy confirmation');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('prep: drag & drop — tap-sell (no sell zone), hand → board + wheel, item → unit (equip)', async () => {
|
||||
// DOM drag targets only exist in the fallback view (the Pixi engine's own drag is tested by the render module)
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
|
||||
@@ -115,6 +115,8 @@ describe('8: the detail card never covers the selected unit\'s underframe', () =
|
||||
assert.doesNotMatch(css, /\.gm__bonds > \* \{ pointer-events: auto; \}/);
|
||||
const dev = readFileSync(new URL('../../public/css/devices.css', import.meta.url), 'utf8');
|
||||
assert.match(dev, /\.gm__hud > \.uframe \{ margin-left: calc\(-1 \* var\(--sa-l\)\); margin-top: calc\(-1 \* var\(--sa-t\)\); \}/);
|
||||
const panels = readFileSync(new URL('../../public/css/screens/game-panels.css', import.meta.url), 'utf8');
|
||||
assert.match(panels, /\.uframe__label\s*\{[^}]*white-space:\s*nowrap\s*;/, 'shortcut labels stay on one line');
|
||||
});
|
||||
|
||||
test('underframeRect covers the diamond and its buttons', () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('暂离 (AI 托管) is scoped to one match', () => {
|
||||
|
||||
describe('game shortcuts behind overlays', () => {
|
||||
test('the 本局信息 / 敌方情报 drawer swallows R / F / D / Space, Esc still closes it', () => {
|
||||
for (const act of ['refresh', 'freeze', 'levelUp', 'ready']) {
|
||||
for (const act of ['refresh', 'freeze', 'levelUp', 'retreat', 'sell', 'ready']) {
|
||||
assert.equal(shortcutBlocked(act, { drawer: true }), true, act);
|
||||
assert.equal(shortcutBlocked(act, {}), false, act);
|
||||
assert.equal(shortcutBlocked(act, { modal: true }), true, act);
|
||||
|
||||
Reference in new issue
Block a user