feedback7: merge fb7-g-ui — PRs #306 #309 #341 #349 #356 #358 #361 applied as-is (Grok coding run; static review p3: MERGE)
- Golden unchanged (union OK); broad node --test 5257 (5256 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
commit
f9969aeeef
15 files changed
+842
-55
No files matched your search
@@ -168,7 +168,10 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
|
||||
const list = options.filter((o) => (filter === 'all' || (filter === 'proto') === o.proto)
|
||||
&& (!q || [o.unit?.name, o.unit?.appellation, o.unit?.subProfessionName, t(PROF_NAME[o.unit?.profession] || ''), ...o.bonds.map(bondName)].some((x) => typeof x === 'string' && x.toLowerCase().includes(q))));
|
||||
const ch = draft ? pickChoices(draft.charId, slot.slotId, D) : null;
|
||||
const sel = (charId) => setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
|
||||
const sel = (charId) => {
|
||||
if (draft?.charId === charId) return;
|
||||
setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
|
||||
};
|
||||
const lk = ch?.locked || null;
|
||||
const skillOn = ch ? (ch.proto ? lk?.skillIndex : draft.skillIndex) : null;
|
||||
const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null;
|
||||
|
||||
+33
-11
@@ -386,7 +386,7 @@ export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUN
|
||||
|
||||
// ---- Modal & dialogs ---------------------------------------------------------------------------
|
||||
|
||||
const modalStack = []; // open modals, topmost last: only the topmost reacts to Escape
|
||||
const modalStack = []; // open modals, topmost last: only the topmost handles keyboard focus / Escape
|
||||
|
||||
/**
|
||||
* Modal dialog (declarative). Esc / backdrop click call onClose.
|
||||
@@ -397,24 +397,46 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid
|
||||
const boxRef = useRef(null);
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const token = {};
|
||||
modalStack.push(token);
|
||||
const prevFocus = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
const box = boxRef.current;
|
||||
// The independent guide can open above SettingsModal (its z-index is higher than --z-modal).
|
||||
const topmost = () => modalStack[modalStack.length - 1] === token && !document.querySelector('.guide');
|
||||
const available = (el) => !el.matches(':disabled') && !el.closest('[inert]') && el.getClientRects().length > 0
|
||||
&& getComputedStyle(el).visibility !== 'hidden';
|
||||
const tabbable = () => [...box.querySelectorAll('button, [href], input, select, textarea, [tabindex], summary')]
|
||||
.filter((el) => el.tabIndex >= 0 && available(el));
|
||||
const focusFirst = () => (tabbable()[0] || box).focus();
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape' || modalStack[modalStack.length - 1] !== token || !closeRef.current) return;
|
||||
e.stopPropagation();
|
||||
closeRef.current();
|
||||
if (!topmost()) return;
|
||||
if (e.key === 'Escape' && closeRef.current) {
|
||||
e.stopPropagation();
|
||||
closeRef.current();
|
||||
} else if (e.key === 'Tab') {
|
||||
const els = tabbable();
|
||||
const first = els[0] || box, last = els[els.length - 1] || box;
|
||||
if (!els.includes(document.activeElement) || document.activeElement === (e.shiftKey ? first : last)) {
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
const onFocus = (e) => {
|
||||
if (topmost() && !box.contains(e.target)) focusFirst();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const t = setTimeout(() => {
|
||||
const el = boxRef.current?.querySelector('[data-autofocus]') || boxRef.current?.querySelector('button, input');
|
||||
el?.focus?.();
|
||||
}, 30);
|
||||
window.addEventListener('focusin', onFocus);
|
||||
const autofocus = box.querySelector('[data-autofocus]');
|
||||
if (topmost()) {
|
||||
if (autofocus && available(autofocus)) autofocus.focus();
|
||||
else focusFirst();
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
clearTimeout(t);
|
||||
window.removeEventListener('focusin', onFocus);
|
||||
const i = modalStack.indexOf(token);
|
||||
if (i >= 0) modalStack.splice(i, 1);
|
||||
prevFocus?.focus?.();
|
||||
@@ -423,7 +445,7 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid
|
||||
if (!open) return null;
|
||||
return html`<div class="modal" role="presentation"
|
||||
onMouseDown=${(e) => { if (closeOnBackdrop && e.target === e.currentTarget && onClose) onClose(); }}>
|
||||
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true"
|
||||
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true" tabindex="-1"
|
||||
style=${width ? `width:${width}` : undefined}>
|
||||
<div class="modal__stripe" aria-hidden="true"></div>
|
||||
${title || micro ? html`<header class="modal__head">
|
||||
|
||||
@@ -111,7 +111,8 @@ export function EquipReplaceDialog({ request, getItem = () => null, busy = false
|
||||
].join('');
|
||||
const confirmIcon = localAsset('ui/battle', 'equip_replace_comfirm_icon');
|
||||
const swapIcon = localAsset('ui/battle', 'equip_replace_replace_icon');
|
||||
return html`<div class="eqr-host" style=${style} onKeyDown=${(e) => { if (e.key === 'Enter' && chosen && !busy) { e.preventDefault(); onConfirm(chosen.uid); } }}>
|
||||
// A focused button owns Enter through its native click, including Cancel and the item options.
|
||||
return html`<div class="eqr-host" style=${style} onKeyDown=${(e) => { if (e.key === 'Enter' && e.target?.tagName !== 'BUTTON' && chosen && !busy) { e.preventDefault(); onConfirm(chosen.uid); } }}>
|
||||
<${Modal} open=${true} tone="red" class="eqr" width="min(7.6rem, 96vw)" micro="EQUIPMENT · REPLACE" title=${t('替换装备')} onClose=${onCancel}
|
||||
actions=${html`
|
||||
<${Button} variant="secondary" icon="close" class="eqr__cancel" onClick=${onCancel}>${t('取消')}<//>
|
||||
|
||||
@@ -301,7 +301,8 @@ function unitAllowed(ctx, piece, row, col, owner = null) {
|
||||
* must be legal on the source tile); from hand/temp the deploy cap applies unless a chess occupant swaps out.
|
||||
* board ← token: legal tile; from the hand the tile must be empty and its summoner deployed; board ↔ board swaps.
|
||||
* hand ← chess from the board: empty slot (withdraw), chess occupant (swap, legal on the source tile), otherwise
|
||||
* it goes to any free slot (HAND_FULL when none). hand ← token from the board: always (back onto its stack).
|
||||
* it uses a free slot or one freed by its own summon stack (HAND_FULL when none). hand ← token from the board:
|
||||
* always (back onto its stack).
|
||||
* hand ← hand/temp piece: move / swap. hand ← EQUIP item onto a chess: equip; onto a non-chess: swap.
|
||||
* board ← EQUIP item: equip the chess on that tile. board ← MAGIC (Arts): used on that tile.
|
||||
* @param {ReturnType<typeof placementContext>} ctx
|
||||
@@ -334,8 +335,11 @@ export function canPlace(ctx, uid, target) {
|
||||
if (!tileAllows(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', t('交换后的单位无法部署在原位置'));
|
||||
return { ok: true, action: 'swap' };
|
||||
}
|
||||
// the withdrawn operator goes to another free slot
|
||||
const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => !ctx.handAt.has(i));
|
||||
// Withdrawal removes the operator's own summon stacks, freeing their hand slots too.
|
||||
const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => {
|
||||
const p = ctx.handAt.get(i)?.piece;
|
||||
return !p || (p.kind === 'token' && p.ownerUid === piece.uid);
|
||||
});
|
||||
return free ? { ok: true, action: 'move' } : no('HAND_FULL', t('整备区已满'));
|
||||
}
|
||||
return { ok: true, action: 'swap' };
|
||||
|
||||
@@ -196,7 +196,7 @@ function LevelCard({ shop, reason, armed = false, onTap }) {
|
||||
export const armKey = (kind, idx, slot) => `${kind}:${idx}:${slot?.id ?? ''}`;
|
||||
|
||||
/**
|
||||
* The slot an armed key names (`c` / `i` shop slots, `r` reward slots), or null ('lv', nothing armed, a stale key).
|
||||
* The slot an armed key names (`c` / `i` shop slots, `r` reward slots), or null (a level card, nothing armed, a stale key).
|
||||
* @param {string|null} key
|
||||
* @param {any[]} slots the shop's slots
|
||||
* @param {any[]|null} rewardSlots the shown reward offer's slots
|
||||
@@ -288,10 +288,12 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
const free = Number(shop.freeRefreshes) || 0;
|
||||
const showReward = !!(reward && Array.isArray(reward.slots) && reward.slots.length);
|
||||
|
||||
// A completed upgrade changes the level, so its confirmation cannot carry into the next upgrade.
|
||||
const levelKey = `lv:${shop.level ?? 1}`;
|
||||
// two-tap: the keys that may stay armed right now
|
||||
const keys = new Set();
|
||||
if (!collapsed) {
|
||||
if (!lvReason) keys.add('lv');
|
||||
if (!lvReason) keys.add(levelKey);
|
||||
slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey(s.kind === 'item' ? 'i' : 'c', i, s)); });
|
||||
if (showReward) reward.slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey('r', i, s)); });
|
||||
}
|
||||
@@ -312,7 +314,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
buy(idx);
|
||||
};
|
||||
const tapLevel = () => {
|
||||
if (armed !== 'lv') { setArmed('lv'); return; }
|
||||
if (armed !== levelKey) { setArmed(levelKey); return; }
|
||||
setArmed(null);
|
||||
onLevel();
|
||||
};
|
||||
@@ -341,7 +343,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
</button>
|
||||
</div>
|
||||
<div class="shopbar__row">
|
||||
<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === 'lv'} onTap=${tapLevel} />
|
||||
<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === levelKey} onTap=${tapLevel} />
|
||||
${showReward ? html`<${RewardCards} offer=${reward} priv=${priv} editable=${editable} onPick=${onReward} onDetail=${onDetail} onLater=${onRewardLater}
|
||||
armed=${armed} onTap=${tapCard} offBonds=${offBonds} />`
|
||||
: html`<div class="shopbar__cards">
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
||||
|
||||
import { PHASE, ERR } from '../../../shared/constants.js';
|
||||
import { boardOrder, pieceDir } from '../board.js';
|
||||
import { snapFrame } from '../fields.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
@@ -238,38 +237,9 @@ export class MatchWatch {
|
||||
}
|
||||
}
|
||||
|
||||
/** Board signature of a prep scout view (board, hand and temp rows: a shop or funds change is not a board change).
|
||||
* Hand / temp entries carry their slot — `prepFieldMeta` draws x from it, so a piece moved to another slot is a
|
||||
* change (review of PR #129). In a boss round's prep the pair partner's board is part of the view (item 51). */
|
||||
_prepScoutSig(ps) {
|
||||
const parts = [];
|
||||
const boardSig = (q) => {
|
||||
for (const { r, c, piece } of boardOrder(q.board)) {
|
||||
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
|
||||
parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`);
|
||||
}
|
||||
};
|
||||
boardSig(ps);
|
||||
const m = this._bossMateOf(ps);
|
||||
if (m) { parts.push(`|mate:${m.mate.playerId}:${m.side}`); boardSig(m.mate); }
|
||||
for (let i = 0; i < ps.hand.length; i++) {
|
||||
const piece = ps.hand[i];
|
||||
if (!piece) continue;
|
||||
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
|
||||
parts.push(`h${i}:${piece.uid}:${piece.id}:${items}`);
|
||||
}
|
||||
for (let i = 0; i < ps.temp.length; i++) {
|
||||
const piece = ps.temp[i];
|
||||
if (!piece) continue;
|
||||
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
|
||||
parts.push(`t${i}:${piece.uid}:${piece.id}:${items}`);
|
||||
}
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current board
|
||||
* (the player who just asked to watch); everyone scouting it receives a new `m.field` only when the board changed.
|
||||
* Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current view
|
||||
* (the player who just asked to watch); everyone scouting it receives a new `m.field` only when that view changed.
|
||||
* A live battle field owns the `n:<pid>` id, so this stays quiet once fields exist.
|
||||
*/
|
||||
_notifyPrepScouts(ps, { to = null } = {}) {
|
||||
@@ -277,12 +247,13 @@ export class MatchWatch {
|
||||
const fid = `n:${ps.playerId}`;
|
||||
const watchers = this.watchersOf(fid);
|
||||
if (!watchers.length) return;
|
||||
const sig = this._prepScoutSig(ps);
|
||||
const meta = this.prepFieldMeta(ps);
|
||||
// Deduplicate the public payload itself: effects and enemy previews can change without any piece moving (#346).
|
||||
const sig = JSON.stringify(meta);
|
||||
const changed = sig !== ps._prepScoutSig;
|
||||
ps._prepScoutSig = sig;
|
||||
const dest = changed ? watchers : (to ? [to] : []);
|
||||
if (!dest.length) return;
|
||||
const meta = this.prepFieldMeta(ps);
|
||||
for (const pid of dest) this.sendTo(pid, meta);
|
||||
}
|
||||
}
|
||||
@@ -209,7 +209,9 @@ export class PlayerEconomy {
|
||||
if (loc.piece.kind !== 'chess') return fail(ERR.BAD_TARGET, loc.piece.kind === 'item' ? 'items cannot be sold' : 'tokens cannot be sold');
|
||||
const piece = loc.piece;
|
||||
// its equipment returns to the hand (overflow temp): refuse rather than destroy it when there is no room
|
||||
const room = this.hand.filter((x) => x == null).length + this.temp.filter((x) => x == null).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0);
|
||||
// Removing the owner's summon stacks frees one slot per stack, regardless of its summon count.
|
||||
const freed = (x) => x == null || (x.kind === 'token' && x.ownerUid === piece.uid);
|
||||
const room = this.hand.filter(freed).length + this.temp.filter(freed).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0);
|
||||
if ((piece.items || []).length > room) return fail(ERR.HAND_FULL, 'no room for the equipment');
|
||||
this._detach(loc);
|
||||
this.removeTokensOf(piece.uid);
|
||||
|
||||
@@ -55,3 +55,93 @@ test('#87 prep watch: seat B moves during prep and seat A\'s view of B changes b
|
||||
assert.equal(h.allTo(S, 'm.private').length, 0);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
for (const clientCombat of [false, true]) {
|
||||
test(`#346 prep watch: a bounty choice refreshes existing teammate and spectator views (clientCombat=${clientCombat})`, (t) => {
|
||||
const S = 's_spec';
|
||||
// Only the earlier battles are fake; the draft, choice, views and notifications are real.
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 1, fake: true, clientCombat, spectators: [S] }).start();
|
||||
const m = h.m;
|
||||
t.after(() => m.dispose());
|
||||
assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT));
|
||||
assert.equal(m.round, 3);
|
||||
assert.equal(m.sp.family, 'bounty');
|
||||
assert.equal(m.spTurn(), 'p_0');
|
||||
assert.equal(m.sp.cards[0].name, '悬赏·持续III');
|
||||
for (const pid of ['p_1', S]) assert.deepEqual(m.handle(pid, { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
|
||||
h.flushAll();
|
||||
const before = h.lastTo(S, 'm.field');
|
||||
const counts = ['p_1', S].map((pid) => [pid, h.allTo(pid, 'm.field').length]);
|
||||
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0 }), { ok: true });
|
||||
h.flushAll();
|
||||
for (const [pid, count] of counts) {
|
||||
const fields = h.allTo(pid, 'm.field');
|
||||
assert.equal(fields.length, count + 1, `${pid}: the choice pushes one updated scout view`);
|
||||
const after = fields.at(-1);
|
||||
assert.deepEqual(after.units, before.units, 'no board, hand or temp piece changed');
|
||||
assert.deepEqual(after.effects.map((e) => e.name), [...before.effects.map((e) => e.name), '悬赏·持续III']);
|
||||
assert.deepEqual(after.nextEnemies.filter((e) => e.tag === 'bounty').map((e) => [e.enemyKey, e.count]), [['enemy_1272_nhtank', 1]]);
|
||||
assert.deepEqual(after.nextEnemies.filter((e) => e.tag !== 'bounty'), before.nextEnemies);
|
||||
assert.deepEqual(after, m.prepFieldMeta(h.ps('p_0')), 'the watcher receives the current public view');
|
||||
assert.equal(Object.hasOwn(after, 'funds'), false);
|
||||
assert.equal(Object.hasOwn(after, 'shop'), false);
|
||||
}
|
||||
|
||||
// Exercise dedup even when the player is dirty, rather than just flushing an empty queue.
|
||||
m.markPrivate(h.ps('p_0'));
|
||||
h.flushAll();
|
||||
h.flushAll();
|
||||
for (const [pid, count] of counts) assert.equal(h.allTo(pid, 'm.field').length, count + 1, `${pid}: unchanged public content is not pushed again`);
|
||||
assert.equal(h.allTo(S, 'm.private').length, 0, 'the spectator never receives private player state');
|
||||
|
||||
assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
|
||||
assert.equal(h.allTo(S, 'm.field').length, counts[1][1] + 2, 'an explicit watch still resends the current view');
|
||||
assert.equal(h.allTo('p_1', 'm.field').length, counts[0][1] + 1, 'the resend does not duplicate the other watcher\'s view');
|
||||
|
||||
// Shop actions are only legal after the draft ends, in PREP.
|
||||
h.toPrep(3);
|
||||
h.flushAll();
|
||||
const prepCounts = ['p_1', S].map((pid) => [pid, h.allTo(pid, 'm.field').length]);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.freeze' }), { ok: true });
|
||||
h.flushAll();
|
||||
for (const [pid, count] of prepCounts) assert.equal(h.allTo(pid, 'm.field').length, count, `${pid}: a shop toggle does not change the scout view`);
|
||||
assert.equal(h.allTo(S, 'm.private').length, 0);
|
||||
assert.equal(m.errorCount, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of ['effects', 'nextEnemies']) {
|
||||
test(`#346 prep watch: ${field} changes alone refresh the existing view`, (t) => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 1, fake: true, spectators: ['s_spec'] }).start();
|
||||
const m = h.m;
|
||||
t.after(() => m.dispose());
|
||||
assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT));
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0 }), { ok: true });
|
||||
assert.deepEqual(m.handle('s_spec', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
|
||||
h.flushAll();
|
||||
const before = h.lastTo('s_spec', 'm.field');
|
||||
const count = h.allTo('s_spec', 'm.field').length;
|
||||
const ps = h.ps('p_0');
|
||||
const bounty = ps.bounties[0];
|
||||
if (field === 'effects') bounty.roundsLeft--;
|
||||
else bounty.card.count++;
|
||||
m.markPrivate(ps);
|
||||
h.flushAll();
|
||||
|
||||
assert.equal(h.allTo('s_spec', 'm.field').length, count + 1);
|
||||
const after = h.lastTo('s_spec', 'm.field');
|
||||
assert.deepEqual(after.units, before.units);
|
||||
if (field === 'effects') {
|
||||
assert.equal(after.effects.at(-1).counter, 1, 'the remaining bounty battles update in place');
|
||||
assert.deepEqual(after.nextEnemies, before.nextEnemies);
|
||||
} else {
|
||||
const enemies = after.nextEnemies.filter((e) => e.tag === 'bounty');
|
||||
assert.ok(enemies.every((e) => e.enemyKey === 'enemy_1272_nhtank'));
|
||||
assert.equal(enemies.reduce((n, e) => n + e.count, 0), 2, 'both bounty enemies reach the preview');
|
||||
assert.deepEqual(after.effects, before.effects);
|
||||
}
|
||||
assert.equal(h.allTo('s_spec', 'm.private').length, 0);
|
||||
assert.equal(m.errorCount, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR } from '../../shared/constants.js';
|
||||
import { makeMatch, legalTileFor, checkInvariants } from './harness.js';
|
||||
|
||||
const SILENCE = 'chess_char_2_02_a';
|
||||
const CATHY = 'chess_char_4_11_a';
|
||||
const EQUIPMENT = 'chess_item_1_02_e_b'; // Elite equipment does not merge.
|
||||
|
||||
function setup(t) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 1, fake: true }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
h.toPrep(1);
|
||||
return { h, m: h.m, ps: h.ps('p_0') };
|
||||
}
|
||||
|
||||
function deploy(m, ps, piece, chessId = piece.id) {
|
||||
const tile = legalTileFor(m, ps, chessId);
|
||||
assert.ok(tile);
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
|
||||
}
|
||||
|
||||
function equip(m, ps, owner, count) {
|
||||
return Array.from({ length: count }, () => {
|
||||
const item = ps.acquireItem(EQUIPMENT, { silent: true });
|
||||
assert.ok(item);
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.equip', itemUid: item.uid, targetUid: owner.uid }), { ok: true });
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function fillStorage(ps) {
|
||||
while ([...ps.hand, ...ps.temp].some((p) => p === null)) assert.ok(ps.acquireItem(EQUIPMENT, { silent: true }));
|
||||
}
|
||||
|
||||
function swapIntoTemp(m, ps, piece) {
|
||||
const idx = ps.hand.indexOf(piece);
|
||||
const filler = ps.temp.find((p) => p?.kind === 'item');
|
||||
assert.ok(idx >= 0 && filler);
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: filler.uid, to: { area: 'hand', idx } }), { ok: true });
|
||||
assert.equal(ps.find(piece.uid).area, 'temp');
|
||||
}
|
||||
|
||||
function snapshot(ps) {
|
||||
return structuredClone({
|
||||
hand: ps.hand, temp: ps.temp, board: ps.board, funds: ps.funds, pendingFunds: ps.pendingFunds,
|
||||
stats: ps.stats, round: ps.round, tempDue: ps._tempDue,
|
||||
});
|
||||
}
|
||||
|
||||
const inventory = (ps) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean);
|
||||
const tokensOf = (ps, owner) => inventory(ps).filter((p) => p.kind === 'token' && p.ownerUid === owner.uid);
|
||||
|
||||
for (const area of ['hand', 'temp']) {
|
||||
for (const itemCount of [1, 2]) {
|
||||
test(`sale uses the freed ${area} summon slot to return ${itemCount} equipment item(s)`, (t) => {
|
||||
const { h, m, ps } = setup(t);
|
||||
const owner = ps.acquireChess(SILENCE, { silent: true });
|
||||
deploy(m, ps, owner);
|
||||
const items = equip(m, ps, owner, itemCount);
|
||||
const other = ps.acquireChess(SILENCE, { silent: true });
|
||||
deploy(m, ps, other);
|
||||
const [stack] = tokensOf(ps, owner);
|
||||
fillStorage(ps);
|
||||
if (area === 'temp') swapIntoTemp(m, ps, stack);
|
||||
assert.equal(ps.find(stack.uid).area, area);
|
||||
// Two items need one existing free slot as well as the slot released by the stack.
|
||||
if (itemCount === 2) {
|
||||
const filler = ps.temp.find((p) => p?.kind === 'item');
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true });
|
||||
}
|
||||
const untouched = structuredClone(inventory(ps).filter((p) => p !== owner && p !== stack));
|
||||
const funds = ps.funds;
|
||||
const poolBefore = m.pool.left(SILENCE);
|
||||
const copies = owner.poolCopies;
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
||||
assert.equal(ps.find(owner.uid), null);
|
||||
assert.deepEqual(tokensOf(ps, owner), []);
|
||||
assert.equal(ps.funds, funds + 1);
|
||||
assert.equal(m.pool.left(SILENCE), poolBefore + copies);
|
||||
assert.deepEqual(owner.items, []);
|
||||
for (const item of items) {
|
||||
assert.equal(ps.find(item.uid).piece, item, 'the original equipment object and UID return');
|
||||
assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area));
|
||||
}
|
||||
assert.equal(ps.find(items[0].uid).area, area, 'hand is preferred, with overflow into temp');
|
||||
for (const piece of untouched) assert.deepEqual(ps.find(piece.uid).piece, piece);
|
||||
assert.equal(inventory(ps).length, untouched.length + items.length);
|
||||
assert.deepEqual(h.logs.warn, []);
|
||||
checkInvariants(m);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { name, ownerId, area, itemCount, stackCount } of [
|
||||
{ name: 'one hand stack cannot hold two equipment items', ownerId: SILENCE, area: 'hand', itemCount: 2, stackCount: 1 },
|
||||
{ name: 'one temp stack cannot hold two equipment items', ownerId: SILENCE, area: 'temp', itemCount: 2, stackCount: 1 },
|
||||
{ name: 'a two-summon stack frees only one slot', ownerId: CATHY, area: 'hand', itemCount: 2, stackCount: 2 },
|
||||
{ name: 'board summons and foreign stacks free no usable slot', ownerId: SILENCE, area: 'board', itemCount: 1, stackCount: 1 },
|
||||
]) {
|
||||
test(`sale rejects atomically when ${name}`, (t) => {
|
||||
const { m, ps } = setup(t);
|
||||
const owner = ps.acquireChess(ownerId, { silent: true });
|
||||
deploy(m, ps, owner);
|
||||
const items = equip(m, ps, owner, itemCount);
|
||||
const other = ps.acquireChess(SILENCE, { silent: true });
|
||||
deploy(m, ps, other);
|
||||
const [stack] = tokensOf(ps, owner);
|
||||
assert.equal(stack.count, stackCount);
|
||||
if (area === 'board') deploy(m, ps, stack, owner.id);
|
||||
fillStorage(ps);
|
||||
if (area === 'temp') swapIntoTemp(m, ps, stack);
|
||||
assert.equal(ps.find(stack.uid).area, area);
|
||||
const before = snapshot(ps);
|
||||
const poolBefore = m.pool.left(ownerId);
|
||||
assert.equal(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }).error, ERR.HAND_FULL);
|
||||
assert.deepEqual(snapshot(ps), before, 'pieces, equipment, funds, and bookkeeping remain unchanged');
|
||||
assert.equal(m.pool.left(ownerId), poolBefore);
|
||||
for (const item of items) assert.equal(ps.find(item.uid).area, 'equipped');
|
||||
checkInvariants(m);
|
||||
// Free exactly one additional slot and retry the same public intent.
|
||||
const filler = ps.temp.find((p) => p?.kind === 'item');
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true });
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
||||
assert.equal(ps.find(owner.uid), null);
|
||||
assert.deepEqual(tokensOf(ps, owner), []);
|
||||
assert.equal(ps.funds, before.funds + 1);
|
||||
for (const item of items) {
|
||||
assert.equal(ps.find(item.uid).piece, item);
|
||||
assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area));
|
||||
}
|
||||
assert.equal(tokensOf(ps, other).length, 1);
|
||||
checkInvariants(m);
|
||||
});
|
||||
}
|
||||
|
||||
for (const area of ['hand', 'temp']) {
|
||||
test(`sale still counts the operator's own ${area} slot`, (t) => {
|
||||
const { m, ps } = setup(t);
|
||||
const owner = ps.acquireChess(SILENCE, { silent: true });
|
||||
const [item] = equip(m, ps, owner, 1);
|
||||
fillStorage(ps);
|
||||
if (area === 'temp') swapIntoTemp(m, ps, owner);
|
||||
assert.equal(ps.find(owner.uid).area, area);
|
||||
const funds = ps.funds;
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
||||
assert.equal(ps.find(owner.uid), null);
|
||||
assert.equal(ps.find(item.uid).piece, item);
|
||||
assert.equal(ps.find(item.uid).area, area);
|
||||
assert.equal(ps.funds, funds + 1);
|
||||
checkInvariants(m);
|
||||
});
|
||||
}
|
||||
@@ -197,6 +197,50 @@ test('diy sync: welcome brings the kit list and sends room.diy; edits are deboun
|
||||
// ---- the tab --------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('自选编队 tab (screens/diy.js)', () => {
|
||||
for (const slotId of [T5A, T6A]) {
|
||||
for (const saved of [null, SIEGE_PICK]) {
|
||||
test(`the picker: ${slotId}, ${saved ? 'saved' : 'empty'} slot retains edits when reselecting its operator`, () => {
|
||||
const picks = saved ? { [slotId]: saved } : {};
|
||||
const done = [];
|
||||
let draft = saved ? { ...saved } : null;
|
||||
let filter = 'all';
|
||||
let query = '';
|
||||
const view = () => expand(DiyPickerView({ m: null, slot: { slotId }, picks, kitted: KIT,
|
||||
onDone: (p) => done.push(p), onClose() {}, filter, query, draft,
|
||||
onFilter: (f) => { filter = f; }, onQuery: (q) => { query = q; }, onDraft: (d) => { draft = d; } }));
|
||||
const click = (key, value) => {
|
||||
const node = [...walk(view())].find((v) => v.props?.[key] === value && typeof v.props.onClick === 'function');
|
||||
assert.ok(node, `${key}=${value} is clickable`);
|
||||
node.props.onClick();
|
||||
};
|
||||
click('data-char', SIEGE);
|
||||
click('data-skill', 0);
|
||||
click('data-module', 'none');
|
||||
const edited = { charId: SIEGE, skillIndex: 0, uniEquipId: null };
|
||||
assert.deepEqual(draft, edited);
|
||||
click('data-char', SIEGE);
|
||||
assert.deepEqual(draft, edited, 'reselecting keeps both the skill and explicit no-module choice');
|
||||
// Filtering/searching the operator out and back in must not change the draft either.
|
||||
filter = 'owned';
|
||||
query = 'no matching operator';
|
||||
assert.ok(![...walk(view())].some((v) => hasClass(v, 'diy-opt')));
|
||||
query = '推进';
|
||||
click('data-char', SIEGE);
|
||||
assert.deepEqual(draft, edited);
|
||||
click('data-testid', 'diy-confirm');
|
||||
assert.deepEqual(done, [edited]);
|
||||
assert.deepEqual(picks, saved ? { [slotId]: saved } : {}, 'the saved picks stay unchanged until the parent saves');
|
||||
// Switching operators still initializes the new pick, and returning restores the saved/default pick.
|
||||
filter = 'all';
|
||||
query = '';
|
||||
click('data-char', SHARP);
|
||||
assert.deepEqual(draft, M.defaultPick(SHARP, slotId, D));
|
||||
click('data-char', SIEGE);
|
||||
assert.deepEqual(draft, saved || M.defaultPick(SIEGE, slotId, D));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('four slots by tier: an empty slot to fill, a filled one shows the operator, its tag, skill and module', () => {
|
||||
const tree = expand(DiyPanelView({ m: data.get('assets'), picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, legal: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, kitted: KIT, onSet() {}, picking: null, onPicking() {} }));
|
||||
const slots = [...walk(tree)].filter((v) => hasClass(v, 'diy-slot'));
|
||||
|
||||
@@ -23,6 +23,88 @@ const SLOT = 'chess_char_5_diy1_a';
|
||||
const SIEGE = 'char_112_siege';
|
||||
const PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
|
||||
|
||||
describe('自选编队 picker: reselecting retains the draft (real server, no art needed)', { skip: !UI_ENABLED && 'set SP_E2E=1 (and have Chrome)' }, () => {
|
||||
for (const slot of [SLOT, 'chess_char_6_diy1_a']) {
|
||||
test(`${slot}: same operator retains skill/module edits; cancel, confirm and reload keep their semantics`, { timeout: 90000 }, async () => {
|
||||
const srv = await startRealServer();
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'diy-reselect', { prefix: `diy-reselect-${slot}`, w: 1280, h: 720 });
|
||||
const picker = '[data-testid="diy-picker"]';
|
||||
const operator = `${picker} .diy-opt[data-char="${SIEGE}"]`;
|
||||
const saved = () => c.page.evaluate((id) => JSON.parse(localStorage.getItem('sp.pref.diy') || 'null')?.picks?.[id] ?? null, slot);
|
||||
const choices = () => c.page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector);
|
||||
return { skill: el?.querySelector('[data-skill][aria-checked="true"]')?.dataset.skill,
|
||||
module: el?.querySelector('[data-module][aria-checked="true"]')?.dataset.module };
|
||||
}, picker);
|
||||
const edited = { charId: SIEGE, skillIndex: 0, uniEquipId: null };
|
||||
const changed = { charId: SIEGE, skillIndex: 1, uniEquipId: 'uniequip_003_siege' };
|
||||
const open = async () => {
|
||||
await c.click(`.diy-slot[data-slot="${slot}"] :is(.diy-slot__fill, [data-testid="diy-change"])`);
|
||||
await c.page.waitForSelector(picker, { visible: true, timeout: 8000 });
|
||||
};
|
||||
const confirm = async (expected) => {
|
||||
await c.click('[data-testid="diy-confirm"]');
|
||||
await c.page.waitForSelector(picker, { hidden: true, timeout: 3000 });
|
||||
assert.deepEqual(await saved(), expected);
|
||||
};
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('自选配置回归');
|
||||
await c.click('.lobby-screen [data-testid="loadout-open"]');
|
||||
await c.click('.lo .lo-tab[data-tab="diy"]');
|
||||
await open();
|
||||
await c.click(operator);
|
||||
await c.click(`${picker} [data-skill="0"]`);
|
||||
await c.click(`${picker} [data-module="none"]`);
|
||||
await c.shot('empty-edited');
|
||||
await c.click(operator);
|
||||
await c.shot('empty-reselected');
|
||||
assert.deepEqual(await choices(), { skill: '0', module: 'none' }, 'an empty slot retains its edited draft');
|
||||
// Hide the current operator, then find and reselect it through the filter/search controls.
|
||||
await c.click(`${picker} [data-filter="proto"]`);
|
||||
assert.equal(await c.page.$(operator), null);
|
||||
await c.click(`${picker} [data-filter="owned"]`);
|
||||
await c.click(`${picker} input[type="search"]`);
|
||||
await c.page.keyboard.type('推进');
|
||||
await c.click(operator);
|
||||
assert.deepEqual(await choices(), { skill: '0', module: 'none' });
|
||||
await confirm(edited);
|
||||
await c.shot('empty-saved');
|
||||
// A saved non-default pick must not overwrite new edits on a repeated operator click.
|
||||
await open();
|
||||
await c.click(`${picker} [data-skill="1"]`);
|
||||
await c.click(`${picker} [data-module="uniequip_003_siege"]`);
|
||||
await c.click(operator);
|
||||
assert.deepEqual(await choices(), { skill: '1', module: 'uniequip_003_siege' });
|
||||
await c.shot('saved-reselected');
|
||||
await c.click(`${picker} .diy-pick__foot button`, '取消');
|
||||
await c.page.waitForSelector(picker, { hidden: true, timeout: 3000 });
|
||||
assert.deepEqual(await saved(), edited, 'cancel leaves the saved pick unchanged');
|
||||
await open();
|
||||
assert.deepEqual(await choices(), { skill: '0', module: 'none' });
|
||||
await c.click(`${picker} [data-skill="1"]`);
|
||||
await c.click(`${picker} [data-module="uniequip_003_siege"]`);
|
||||
await c.click(operator);
|
||||
await confirm(changed);
|
||||
await c.page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await c.page.waitForSelector('.lobby-screen', { timeout: 20000 });
|
||||
await c.click('.lobby-screen [data-testid="loadout-open"]');
|
||||
await c.click('.lo .lo-tab[data-tab="diy"]');
|
||||
await open();
|
||||
assert.deepEqual(await choices(), { skill: '1', module: 'uniequip_003_siege' });
|
||||
assert.deepEqual(await saved(), changed, 'confirm persists the current draft across reload');
|
||||
await c.shot('reload');
|
||||
assert.deepEqual(c.problems.filter((p) => p.startsWith('pageerror:')), []);
|
||||
} finally {
|
||||
console.log(JSON.stringify({ slot, problems: problemsOf([c]) }));
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GitHub #284 (idea from PR #286): Esc in the picker cancels only the picker, like its 取消, also from its search
|
||||
// field — the 干员调配 overlay and its four slots stay, and the saved picks do not change; the next Esc closes the overlay
|
||||
describe('自选编队 picker: Esc closes only the picker (real server, no art needed)', { skip: !UI_ENABLED && 'set SP_E2E=1 (and have Chrome)' }, () => {
|
||||
|
||||
@@ -442,6 +442,58 @@ describe('placement mirror (canPlace)', () => {
|
||||
assert.equal(canPlace(ctx, h0.uid, { area: 'hand', idx: 10 }).ok, false);
|
||||
assert.equal(canPlace(ctx, h0.uid, { area: 'hand', idx: -1 }).ok, false);
|
||||
});
|
||||
test('summoner withdrawal: a full hand accepts a drop onto its own summon stack', () => {
|
||||
const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 };
|
||||
const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 };
|
||||
const ctx = ctxFor(privWith({ board: [b], hand: [...Array.from({ length: 9 }, () => item(EQUIP)), stack] }));
|
||||
const to = { area: 'hand', idx: 9 };
|
||||
assert.deepEqual(canPlace(ctx, b.uid, to), { ok: true, action: 'move' });
|
||||
assert.deepEqual(dropIntent(ctx, b.uid, to), { t: 'g.move', fields: { uid: b.uid, to } });
|
||||
assert.equal(dropFailureReason(ctx, b.uid, to), null);
|
||||
});
|
||||
test('summoner withdrawal: its own stack frees space when dropped onto another item or token', () => {
|
||||
const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 };
|
||||
const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 };
|
||||
const other = { ...stack, uid: ++uid, ownerUid: ++uid };
|
||||
const ctx = ctxFor(privWith({ board: [b], hand: [...Array.from({ length: 8 }, () => item(EQUIP)), other, stack] }));
|
||||
for (const idx of [0, 8]) {
|
||||
const to = { area: 'hand', idx };
|
||||
assert.deepEqual(canPlace(ctx, b.uid, to), { ok: true, action: 'move' });
|
||||
assert.deepEqual(dropIntent(ctx, b.uid, to), { t: 'g.move', fields: { uid: b.uid, to } });
|
||||
assert.equal(dropFailureReason(ctx, b.uid, to), null);
|
||||
}
|
||||
});
|
||||
test('summoner withdrawal: a full hand without its own hand stack still refuses the move', () => {
|
||||
const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 };
|
||||
const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 };
|
||||
const other = { ...stack, uid: ++uid, ownerUid: ++uid };
|
||||
const hand = Array.from({ length: 10 }, () => item(EQUIP));
|
||||
const cases = [
|
||||
privWith({ board: [b], hand }),
|
||||
privWith({ board: [{ ...b, id: MELEE }], hand }),
|
||||
privWith({ board: [b], hand: [...hand.slice(0, 9), other] }),
|
||||
privWith({ board: [b], hand, temp: [stack] }),
|
||||
privWith({ board: [b, { ...stack, row: 9, col: 3 }], hand }),
|
||||
privWith({ board: [b], hand: [...hand.slice(0, 9), { ...item(EQUIP), ownerUid: b.uid }] }),
|
||||
];
|
||||
for (const priv of cases) {
|
||||
const ctx = ctxFor(priv);
|
||||
for (const idx of [0, 9]) {
|
||||
const to = { area: 'hand', idx };
|
||||
assert.equal(canPlace(ctx, b.uid, to).code, 'HAND_FULL');
|
||||
assert.equal(dropIntent(ctx, b.uid, to), null);
|
||||
assert.equal(dropFailureReason(ctx, b.uid, to), '整备区已满');
|
||||
}
|
||||
}
|
||||
});
|
||||
test('summoner withdrawal: freed space does not bypass an illegal chess swap', () => {
|
||||
const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 };
|
||||
const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 };
|
||||
const ctx = ctxFor(privWith({ board: [b], hand: [piece(MELEE), ...Array.from({ length: 8 }, () => item(EQUIP)), stack] }));
|
||||
const to = { area: 'hand', idx: 0 };
|
||||
assert.equal(canPlace(ctx, b.uid, to).code, 'BAD_TILE');
|
||||
assert.equal(dropIntent(ctx, b.uid, to), null);
|
||||
});
|
||||
test('items: equip on chess (board or hand), arts on tiles, not on tokens', () => {
|
||||
const b = { ...piece(MELEE), row: 9, col: 3 };
|
||||
const hChess = piece(RANGED);
|
||||
|
||||
@@ -134,6 +134,64 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('import: Tab stays in the dialog and Enter cannot edit a background module', async () => {
|
||||
const { ctx, page, problems } = await open();
|
||||
try {
|
||||
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo-mod.is-on');
|
||||
const module = () => page.$eval('.lo-mod.is-on', (el) => el.dataset.module);
|
||||
const original = await module();
|
||||
const saved = () => page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}').entries || {});
|
||||
const openImport = async () => {
|
||||
await clickSel(page, '[data-testid="loadout-import"]');
|
||||
await page.waitForSelector('.modal textarea');
|
||||
await page.waitForFunction(() => !document.querySelector('.modal__box').getAnimations().some((a) => a.playState === 'running'));
|
||||
};
|
||||
const focused = () => page.evaluate(() => document.activeElement.getAttribute('data-testid'));
|
||||
await openImport();
|
||||
await page.click('.modal textarea');
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.up('Shift');
|
||||
assert.equal(await focused(), 'loadout-io-pick', 'Shift+Tab wraps past the disabled import and hidden file input');
|
||||
const chooser = page.waitForFileChooser();
|
||||
await page.keyboard.press('Enter');
|
||||
await (await chooser).cancel();
|
||||
assert.ok(await page.$('.modal'), 'Enter acts on the file picker inside the dialog');
|
||||
assert.equal(await module(), original, 'the background module cannot be activated');
|
||||
assert.deepEqual(await saved(), {});
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await focused(), 'loadout-io-text', 'Tab wraps back to the textarea');
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.evaluate(() => document.activeElement.textContent.trim()), '取消');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForSelector('.modal', { hidden: true });
|
||||
assert.equal(await focused(), 'loadout-import', 'cancel restores the opener');
|
||||
assert.equal(await module(), original);
|
||||
assert.deepEqual(await saved(), {});
|
||||
|
||||
await openImport();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForSelector('.modal', { hidden: true });
|
||||
assert.ok(await page.$('.lo'), 'Esc closes only the import');
|
||||
assert.equal(await focused(), 'loadout-import');
|
||||
|
||||
await openImport();
|
||||
await page.click('.modal textarea');
|
||||
await page.keyboard.type(JSON.stringify({ v: 1, kind: 'stronghold.loadout', entries: { [INSIDE]: { skill: 0 } } }));
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.up('Shift');
|
||||
assert.equal(await focused(), 'loadout-io-apply', 'the enabled import becomes the last stop');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForSelector('.modal', { hidden: true });
|
||||
assert.deepEqual(await saved(), { [INSIDE]: { skill: 0 } }, 'keyboard confirmation still imports');
|
||||
assert.equal(await focused(), 'loadout-import');
|
||||
// Optional art / fonts / audio can be absent; this keyboard regression needs no asset pack.
|
||||
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
|
||||
test('desktop: 导出 hands out a versioned payload; 导入 restores it and refuses junk', async () => {
|
||||
const { ctx, page, problems } = await open();
|
||||
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
|
||||
|
||||
@@ -95,6 +95,70 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
|
||||
});
|
||||
}
|
||||
|
||||
test('equip replace: focused buttons keep their native Enter action', async () => {
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
const initial = await mockState(page);
|
||||
const target = initial.board.find((p) => p.kind === 'chess' && p.items?.length === 2);
|
||||
assert.ok(target, 'the fixture has an operator with two equipped items');
|
||||
const item = initial.hand.find((p) => p?.kind === 'item' && !target.items.some((x) => x.id === p.id));
|
||||
assert.ok(item, 'the fixture has a loose equipment item');
|
||||
const requests = () => page.evaluate(() => globalThis.__MOCK__.S().requests.filter(([t]) => t === 'g.equip'));
|
||||
const center = (sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
|
||||
const openReplace = async () => {
|
||||
const from = await center(`.ff-piece[data-uid="${item.uid}"]`);
|
||||
const to = await center(`.ff-piece[data-uid="${target.uid}"]`);
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to.x, to.y, { steps: 8 });
|
||||
await sleep(80);
|
||||
await page.mouse.up();
|
||||
await page.waitForSelector('.eqr__opt');
|
||||
await sleep(500); // let the dialog's mount effect settle before selecting an item
|
||||
await page.click(`.eqr__opt[data-uid="${target.items[1].uid}"]`);
|
||||
await page.waitForSelector('.eqr__opt[aria-checked="true"]');
|
||||
};
|
||||
const tabToCancel = async () => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (await page.evaluate(() => document.activeElement?.matches('.eqr__cancel'))) return;
|
||||
await page.keyboard.press('Tab');
|
||||
}
|
||||
assert.fail('Tab did not focus Cancel');
|
||||
};
|
||||
|
||||
for (const key of ['Space', 'Enter']) {
|
||||
await openReplace();
|
||||
await tabToCancel();
|
||||
await page.keyboard.press(key);
|
||||
await page.waitForFunction(() => !document.querySelector('.eqr'));
|
||||
await sleep(200);
|
||||
assert.deepEqual(await requests(), [], `${key} on Cancel sends no equipment intent`);
|
||||
const state = await mockState(page);
|
||||
assert.deepEqual(state.board.find((p) => p.uid === target.uid).items, target.items, 'both equipped items remain');
|
||||
assert.ok(state.hand.some((p) => p?.uid === item.uid), 'the new item remains in the hand');
|
||||
}
|
||||
|
||||
// Enter on an equipment option toggles its selection; only the confirmation button replaces it.
|
||||
await openReplace();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => !document.querySelector('.eqr__opt[aria-checked="true"]'));
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForSelector('.eqr__opt[aria-checked="true"]');
|
||||
assert.deepEqual(await requests(), [], 'Enter on an option only changes the selected item');
|
||||
await tabToCancel();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.matches('.eqr__ok')), true);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => !document.querySelector('.eqr'));
|
||||
await sleep(200);
|
||||
assert.deepEqual(await requests(), [['g.equip', { itemUid: item.uid, targetUid: target.uid, replaceUid: target.items[1].uid }]]);
|
||||
const replaced = await mockState(page);
|
||||
assert.deepEqual(replaced.board.find((p) => p.uid === target.uid).items.map((x) => x.uid), [target.items[0].uid, item.uid]);
|
||||
assert.ok(!replaced.hand.some((p) => p?.uid === item.uid), 'the confirmed equipment leaves the hand');
|
||||
// This keyboard regression also runs without optional art/fonts; JavaScript exceptions still fail it.
|
||||
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('prep: buy, shortcuts, ready, Esc', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
let s = await mockState(page);
|
||||
@@ -149,6 +213,77 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('prep: a shortcut upgrade clears the previous level confirmation', async () => {
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
try {
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 100; }));
|
||||
const before = await mockState(page);
|
||||
const pressed = () => page.$eval('.shopbar .lvcard', (el) => el.getAttribute('aria-pressed'));
|
||||
await page.click('.shopbar .lvcard');
|
||||
await page.waitForFunction(() => document.querySelector('.lvcard').getAttribute('aria-pressed') === 'true');
|
||||
assert.equal((await mockState(page)).funds, before.funds, 'selecting does not charge');
|
||||
await page.keyboard.press('KeyD');
|
||||
await page.waitForFunction((lv) => globalThis.__MOCK__.S().priv.shop.level === lv + 1, {}, before.shop.level);
|
||||
const upgraded = await mockState(page);
|
||||
assert.equal(upgraded.funds, before.funds - before.shop.upgradePrice);
|
||||
assert.equal(await pressed(), 'false', 'the completed upgrade must disarm its confirmation');
|
||||
await page.click('.shopbar .lvcard');
|
||||
await page.waitForFunction(() => document.querySelector('.lvcard').getAttribute('aria-pressed') === 'true');
|
||||
await sleep(300); // allow a mistaken second upgrade to reach the mock server
|
||||
assert.equal((await mockState(page)).shop.level, upgraded.shop.level, 'the next click only selects');
|
||||
assert.equal((await mockState(page)).funds, upgraded.funds, 'the next click does not charge');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => document.querySelector('.lvcard').getAttribute('aria-pressed') === 'false');
|
||||
await page.keyboard.press('KeyD');
|
||||
await page.waitForFunction((lv) => globalThis.__MOCK__.S().priv.shop.level === lv + 1, {}, upgraded.shop.level);
|
||||
assert.equal((await mockState(page)).funds, upgraded.funds - upgraded.shop.upgradePrice, 'D still upgrades directly');
|
||||
// Optional art / audio may be absent; this state regression needs no asset pack.
|
||||
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('prep: mouse level confirmation survives same-level updates and resets after purchase', async () => {
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
try {
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 100; }));
|
||||
const before = await mockState(page);
|
||||
await page.click('.shopbar .lvcard');
|
||||
await page.waitForFunction(() => document.querySelector('.lvcard').getAttribute('aria-pressed') === 'true');
|
||||
await page.keyboard.press('KeyF');
|
||||
await page.waitForFunction(() => globalThis.__MOCK__.S().priv.shop.frozen);
|
||||
assert.equal(await page.$eval('.lvcard', (el) => el.getAttribute('aria-pressed')), 'true', 'an unrelated shop update keeps the selection');
|
||||
await page.click('.shopbar .lvcard');
|
||||
await page.waitForFunction((lv) => globalThis.__MOCK__.S().priv.shop.level === lv + 1, {}, before.shop.level);
|
||||
const upgraded = await mockState(page);
|
||||
assert.equal(upgraded.funds, before.funds - before.shop.upgradePrice);
|
||||
assert.equal(await page.$eval('.lvcard', (el) => el.getAttribute('aria-pressed')), 'false');
|
||||
await page.click('.shopbar .lvcard');
|
||||
await page.waitForFunction(() => document.querySelector('.lvcard').getAttribute('aria-pressed') === 'true');
|
||||
await sleep(300);
|
||||
assert.equal((await mockState(page)).shop.level, upgraded.shop.level);
|
||||
assert.equal((await mockState(page)).funds, upgraded.funds);
|
||||
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('prep: a level change preserves another shop card selection', async () => {
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
try {
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 100; }));
|
||||
const before = await mockState(page);
|
||||
await page.click('.shopbar__cards .scard:not(.scard--sold)');
|
||||
await page.waitForSelector('.shopbar__cards .scard.is-armed');
|
||||
const selected = await page.$eval('.shopbar__cards .scard.is-armed', (el) => el.getAttribute('aria-label'));
|
||||
await page.keyboard.press('KeyD');
|
||||
await page.waitForFunction((lv) => globalThis.__MOCK__.S().priv.shop.level === lv + 1, {}, before.shop.level);
|
||||
assert.equal(await page.$eval('.shopbar__cards .scard.is-armed', (el) => el.getAttribute('aria-label')), selected);
|
||||
assert.equal(await page.$eval('.lvcard', (el) => el.getAttribute('aria-pressed')), 'false');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForSelector('.scard.is-armed', { hidden: true });
|
||||
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('prep: Q retreats and X sells only the selected operator', async () => {
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
const initial = await mockState(page);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Shared Modal keyboard boundaries, real Chrome. Component fixtures cover nested / empty dialogs; the settings
|
||||
// case uses the real title screen. Opt-in: SP_E2E=1 node --test test/ui/modal.e2e.test.js
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
|
||||
describe('Modal keyboard focus', { skip: !ENABLED && 'set SP_E2E=1 and have Chrome to run' }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--no-proxy-server'] });
|
||||
});
|
||||
after(async () => { await browser?.close(); await srv?.close(); });
|
||||
const active = (page) => page.evaluate(() => document.activeElement.id);
|
||||
const tab = async (page, reverse = false) => {
|
||||
if (reverse) await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('Tab');
|
||||
if (reverse) await page.keyboard.up('Shift');
|
||||
};
|
||||
async function fixture(page) {
|
||||
page.setDefaultTimeout(5000);
|
||||
await page.goto(`${srv.url}/dev/uikit.html`, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(async () => {
|
||||
const { render } = await import('/vendor/preact.module.js');
|
||||
const { useState } = await import('/vendor/hooks.module.js');
|
||||
const { html, Modal, DialogHost, confirmDialog } = await import('/js/ui/components.js');
|
||||
function Fixture() {
|
||||
const [outer, setOuter] = useState(false), [inner, setInner] = useState(false), [empty, setEmpty] = useState(false);
|
||||
return html`<div>
|
||||
<button id="open" onClick=${() => setOuter(true)}>Open</button>
|
||||
<button id="empty-open" onClick=${() => setEmpty(true)}>Empty</button>
|
||||
<button id="confirm" onClick=${async () => { const ok = await confirmDialog({ text: 'Confirm?' }); document.querySelector('#result').textContent = String(ok); }}>Confirm</button>
|
||||
<output id="result"></output>
|
||||
<${Modal} open=${outer} onClose=${() => setOuter(false)}>
|
||||
<textarea id="first"></textarea>
|
||||
<button disabled>Disabled</button><input type="hidden" /><button hidden>Hidden</button>
|
||||
<button style="display:none">Display none</button><button style="visibility:hidden">Invisible</button>
|
||||
<div inert><button>Inert</button></div><button tabindex="-1">Programmatic only</button>
|
||||
<button id="inner-open" onClick=${() => setInner(true)}>Nested</button>
|
||||
<a id="last" href="#inside">Last</a>
|
||||
<//>
|
||||
<${Modal} open=${inner} onClose=${() => setInner(false)}>
|
||||
<button id="inner-first" data-autofocus>First</button><button id="inner-last">Last</button>
|
||||
<//>
|
||||
<${Modal} open=${empty} onClose=${() => setEmpty(false)}>No controls<//>
|
||||
<${DialogHost} />
|
||||
</div>`;
|
||||
}
|
||||
render(html`<${Fixture} />`, document.getElementById('kit'));
|
||||
});
|
||||
}
|
||||
|
||||
test('initial focus, both Tab boundaries, nested priority, recovery and cleanup', async () => {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await fixture(page);
|
||||
await page.click('#open');
|
||||
await page.waitForFunction(() => document.activeElement.id === 'first');
|
||||
await tab(page, true);
|
||||
assert.equal(await active(page), 'last', 'hidden, disabled, inert and negative-tabindex controls are excluded');
|
||||
await tab(page);
|
||||
assert.equal(await active(page), 'first');
|
||||
await tab(page);
|
||||
assert.equal(await active(page), 'inner-open');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => document.activeElement.id === 'inner-first');
|
||||
await tab(page, true);
|
||||
assert.equal(await active(page), 'inner-last', 'only the topmost dialog traps focus');
|
||||
await tab(page);
|
||||
assert.equal(await active(page), 'inner-first');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.modal').length === 1);
|
||||
assert.equal(await active(page), 'inner-open');
|
||||
await page.focus('#open');
|
||||
assert.equal(await active(page), 'first', 'focus escaping to the background returns to the active dialog');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForSelector('.modal', { hidden: true });
|
||||
assert.equal(await active(page), 'open');
|
||||
await tab(page);
|
||||
assert.equal(await active(page), 'empty-open', 'closed dialogs no longer trap focus');
|
||||
await page.click('#open');
|
||||
await page.waitForFunction(() => document.activeElement.id === 'first');
|
||||
await tab(page, true);
|
||||
assert.equal(await active(page), 'last', 'reopening does not leave stale listeners');
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('no controls: focus the dialog itself; imperative confirm retains autofocus and native Enter', async () => {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await fixture(page);
|
||||
await page.click('#empty-open');
|
||||
await page.waitForFunction(() => document.activeElement.matches('.modal__box'));
|
||||
for (const reverse of [false, true]) {
|
||||
await tab(page, reverse);
|
||||
assert.equal(await page.evaluate(() => document.activeElement.matches('.modal__box')), true);
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForSelector('.modal', { hidden: true });
|
||||
assert.equal(await active(page), 'empty-open');
|
||||
await page.click('#confirm');
|
||||
await page.waitForFunction(() => document.activeElement.hasAttribute('data-autofocus'));
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => document.querySelector('#result').textContent === 'true');
|
||||
assert.equal(await active(page), 'confirm');
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('title settings: Tab cycles and leaves hotkey capture; Esc cancels capture before closing', async () => {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.setViewport({ width: 1280, height: 720 });
|
||||
page.setDefaultTimeout(5000);
|
||||
await page.goto(srv.url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.title-settings');
|
||||
await page.click('.title-settings');
|
||||
await page.waitForFunction(() => document.querySelector('.modal__box')?.contains(document.activeElement));
|
||||
const assertInside = async () => assert.equal(await page.evaluate(() => document.querySelector('.modal__box').contains(document.activeElement)), true);
|
||||
for (const reverse of [false, true]) for (let i = 0; i < 25; i++) { await tab(page, reverse); await assertInside(); }
|
||||
await page.focus('.set-key[data-action="retreat"]');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForSelector('.set-key.is-waiting');
|
||||
await tab(page);
|
||||
await page.waitForSelector('.set-key.is-waiting', { hidden: true });
|
||||
await assertInside();
|
||||
assert.equal(await page.$eval('.set-key[data-action="retreat"]', (el) => el.textContent.trim()), 'Q');
|
||||
await page.focus('.set-key[data-action="retreat"]');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForSelector('.set-key.is-waiting');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForSelector('.set-key.is-waiting', { hidden: true });
|
||||
assert.ok(await page.$('.modal'), 'capture consumes the first Esc');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForSelector('.modal', { hidden: true });
|
||||
assert.equal(await page.evaluate(() => document.activeElement.matches('.title-settings')), true);
|
||||
assert.ok(await page.$('.title-screen'), 'the background start button was not activated');
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('settings lets the higher guide keep keyboard focus and resumes after it closes', async () => {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
page.setDefaultTimeout(5000);
|
||||
await page.goto(srv.url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.title-settings');
|
||||
await page.click('.title-settings');
|
||||
await page.waitForSelector('.set-guide');
|
||||
await page.waitForFunction(() => !document.querySelector('.modal__box').getAnimations().some((a) => a.playState === 'running'));
|
||||
await page.click('.set-guide');
|
||||
await page.waitForSelector('.guide');
|
||||
await page.waitForFunction(() => document.querySelector('.guide').contains(document.activeElement));
|
||||
await tab(page);
|
||||
assert.equal(await page.evaluate(() => document.querySelector('.guide').contains(document.activeElement)), true);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForSelector('.guide', { hidden: true });
|
||||
assert.ok(await page.$('.modal'), 'closing the guide keeps settings');
|
||||
await tab(page);
|
||||
assert.equal(await page.evaluate(() => document.querySelector('.modal__box').contains(document.activeElement)), true);
|
||||
await page.click('.modal__actions button:last-child');
|
||||
await page.waitForSelector('.modal', { hidden: true });
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user