- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
226 lines
14 KiB
JavaScript
226 lines
14 KiB
JavaScript
// Real browser / server checks for local identity recovery, per-operator voice, install offers and large text.
|
|
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('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
|
|
let server, browser, base;
|
|
before(async () => {
|
|
const { startServer } = await import('../../server/index.js');
|
|
const puppeteer = (await import('puppeteer-core')).default;
|
|
server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
|
base = `http://127.0.0.1:${server.port}`;
|
|
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
|
|
});
|
|
after(async () => { await browser?.close(); await server?.close(); });
|
|
async function pageIn(ctx, phone = false, random = null) {
|
|
const page = await ctx.newPage();
|
|
await page.setViewport({ width: phone ? 844 : 1440, height: phone ? 390 : 900 });
|
|
if (random != null) await page.evaluateOnNewDocument((value) => { Math.random = () => value; }, random);
|
|
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery Test'); sessionStorage.setItem('sp.entered', '1'); });
|
|
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && document.querySelector('.lobby-screen'));
|
|
return page;
|
|
}
|
|
test('closed second window resumes the same server player and match through the recovery UI', async () => {
|
|
const ctx = await browser.createBrowserContext();
|
|
try {
|
|
const a = await pageIn(ctx), b = await pageIn(ctx);
|
|
const before = await b.evaluate(async () => {
|
|
const { net, store } = globalThis.__SP__;
|
|
await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
|
|
await net.request('room.start', {});
|
|
return { id: store.get().me.playerId, code: store.get().room.code };
|
|
});
|
|
await b.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
|
|
await a.waitForSelector('[data-testid="resume-local-match"]');
|
|
const refused = await a.evaluate(async () => { const { identity } = await import('/js/net.js'); return identity.resume(identity.recoverable()[0].id); });
|
|
assert.equal(refused, false, 'live window is not displaced');
|
|
assert.equal(await b.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online');
|
|
await b.close();
|
|
await a.click('[data-testid="resume-local-match"]');
|
|
await a.waitForSelector('.modal .btn--block');
|
|
await a.evaluate(() => { globalThis.recoveryDocument = true; });
|
|
await a.click('.modal .btn--block');
|
|
await a.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
|
|
const after = await a.evaluate(() => { const s = globalThis.__SP__.store.get(); return { id: s.me.playerId, code: s.room.code }; });
|
|
assert.deepEqual(after, before);
|
|
assert.equal(await a.evaluate(() => globalThis.recoveryDocument), true, 'welcome arrives in the same document');
|
|
} finally { await ctx.close(); }
|
|
});
|
|
test('a refreshing match window keeps its seat while its init is waiting and another window clicks recovery', async () => {
|
|
const ctx = await browser.createBrowserContext();
|
|
try {
|
|
const guest = await pageIn(ctx, false, 0.01), owner = await pageIn(ctx, false, 0.99);
|
|
const before = await owner.evaluate(async () => {
|
|
const { net, store } = globalThis.__SP__;
|
|
await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
|
|
await net.request('room.start', {});
|
|
return { id: store.get().me.playerId, code: store.get().room.code, token: sessionStorage.getItem('sp.token') };
|
|
});
|
|
const guestId = await guest.evaluate(() => globalThis.__SP__.store.get().me.playerId);
|
|
await owner.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
|
|
await guest.waitForSelector('[data-testid="resume-local-match"]');
|
|
await owner.evaluateOnNewDocument(() => {
|
|
// Hold precisely the timeout scheduled immediately after the real identity query.
|
|
// Messages still use Chrome's BroadcastChannel; only init's completion is controlled.
|
|
const Channel = globalThis.BroadcastChannel, timer = globalThis.setTimeout;
|
|
let hold = false;
|
|
globalThis.BroadcastChannel = class extends Channel {
|
|
constructor(name) { super(name); this.identityChannel = name === 'sp.identity'; }
|
|
postMessage(message) {
|
|
super.postMessage(message);
|
|
if (this.identityChannel && message.type === 'who') hold = true;
|
|
}
|
|
};
|
|
globalThis.setTimeout = (fn, ms, ...args) => {
|
|
if (hold) {
|
|
hold = false;
|
|
globalThis.finishIdentityInit = () => fn(...args);
|
|
return 0;
|
|
}
|
|
return timer(fn, ms, ...args);
|
|
};
|
|
});
|
|
await owner.reload({ waitUntil: 'domcontentloaded' });
|
|
await owner.waitForFunction(() => typeof globalThis.finishIdentityInit === 'function');
|
|
await guest.bringToFront();
|
|
await guest.click('[data-testid="resume-local-match"]');
|
|
await guest.waitForSelector('.modal .btn--block');
|
|
await guest.click('.modal .btn--block');
|
|
await guest.waitForFunction(() => document.body.textContent.includes('此对局仍在其他窗口中'));
|
|
assert.equal(await owner.evaluate(() => sessionStorage.getItem('sp.token')), before.token);
|
|
await owner.evaluate(() => globalThis.finishIdentityInit());
|
|
await owner.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
|
|
const after = await owner.evaluate(() => {
|
|
const s = globalThis.__SP__.store.get();
|
|
return { id: s.me.playerId, code: s.room.code, token: sessionStorage.getItem('sp.token') };
|
|
});
|
|
assert.deepEqual(after, before);
|
|
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().me.playerId), guestId);
|
|
for (const page of [guest, owner]) {
|
|
const pong = await page.evaluate(() => globalThis.__SP__.net.request('ping', { c: Date.now() }));
|
|
assert.equal(pong.t, 'pong');
|
|
assert.equal(await page.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online');
|
|
}
|
|
} finally { await ctx.close(); }
|
|
});
|
|
test('co-op opening reroll shows a unanimous vote, clears stale readiness and can be rejected', { timeout: 45000 }, async () => {
|
|
const ctx = await browser.createBrowserContext();
|
|
try {
|
|
const host = await pageIn(ctx), guest = await pageIn(ctx);
|
|
const code = await host.evaluate(async () => {
|
|
const { net, store } = globalThis.__SP__;
|
|
await net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' });
|
|
return store.get().room.code;
|
|
});
|
|
await guest.evaluate(async (code) => {
|
|
const { net } = globalThis.__SP__;
|
|
await net.request('room.join', { code }); await net.request('room.ready', { ready: true });
|
|
}, code);
|
|
await host.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
|
|
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK', { polling: 50 });
|
|
await guest.evaluate(() => globalThis.__SP__.net.request('g.infoReady', { setupRevision: 0 }));
|
|
await host.waitForSelector('.brief-reroll button');
|
|
await host.bringToFront();
|
|
await host.click('.brief-reroll button');
|
|
await guest.waitForSelector('.brief-reroll .btn--primary');
|
|
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().match.public.deadline), 0);
|
|
await guest.bringToFront();
|
|
// The host is now a background tab: WebSocket/store updates continue, but animation-frame polling can stop.
|
|
// Arm its state wait before the vote so the regression covers a result arriving after the initial check.
|
|
await Promise.all([
|
|
host.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1, { polling: 50 }),
|
|
guest.click('.brief-reroll .btn--primary'),
|
|
]);
|
|
await guest.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1, { polling: 50 });
|
|
const state = await guest.evaluate(() => globalThis.__SP__.store.get().match.public);
|
|
assert.equal(state.rerollVote, null);
|
|
assert.ok(state.players.filter((p) => !p.isBot).every((p) => !p.ready));
|
|
await host.bringToFront();
|
|
await host.waitForFunction(() => !!document.querySelector('.brief-reroll button:not(:disabled)'));
|
|
// Wait out the documented server anti-burst interval before a second request.
|
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
await host.click('.brief-reroll button');
|
|
await guest.waitForSelector('.brief-reroll .btn--danger');
|
|
await guest.bringToFront();
|
|
await guest.click('.brief-reroll .btn--danger');
|
|
await host.waitForFunction(() => !globalThis.__SP__.store.get().match.public.rerollVote, { polling: 50 });
|
|
assert.equal(await host.evaluate(() => globalThis.__SP__.store.get().match.public.setupRevision), 1);
|
|
} finally { await ctx.close(); }
|
|
});
|
|
test('operator voice selection persists across reload and follows global after reset', async () => {
|
|
const ctx = await browser.createBrowserContext();
|
|
try {
|
|
const page = await pageIn(ctx, true);
|
|
const open = async () => { await page.evaluate(async () => (await import('/js/ui/loadoutSync.js')).openLoadout()); await page.waitForSelector('.lo-card__pick'); await page.click('.lo-card__pick'); await page.waitForSelector('[data-voice-char] select', { visible: true }); };
|
|
await open();
|
|
const id = await page.$eval('[data-voice-char]', (e) => e.dataset.voiceChar);
|
|
await page.select('[data-voice-char] select', 'jp');
|
|
assert.equal(await page.evaluate(async (id) => (await import('/js/audio.js')).audio.voiceOverrides[id], id), 'jp');
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online');
|
|
await open();
|
|
assert.equal(await page.$eval('[data-voice-char] select', (e) => e.value), 'jp');
|
|
await page.select('[data-voice-char] select', '');
|
|
assert.equal(await page.evaluate((id) => JSON.parse(localStorage.getItem('sp.pref.settings')).voiceOverrides[id], id), undefined);
|
|
} finally { await ctx.close(); }
|
|
});
|
|
test('install manifest is eligible and a dismissed offer is consumed through the actual button', async () => {
|
|
const ctx = browser.defaultBrowserContext();
|
|
let page;
|
|
try {
|
|
page = await pageIn(ctx);
|
|
const client = await page.createCDPSession();
|
|
const manifest = await client.send('Page.getAppManifest');
|
|
assert.deepEqual(manifest.errors, []);
|
|
const icons = await page.evaluate(async () => {
|
|
const m = await (await fetch('/manifest.json')).json();
|
|
const entries = [...m.icons, ...[...document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]')]
|
|
.map((link) => ({ src: link.getAttribute('href'), purpose: link.rel }))];
|
|
return Promise.all(entries.map(async (entry) => {
|
|
const response = await fetch(entry.src);
|
|
if (!response.ok) throw new Error(`${entry.src}: ${response.status}`);
|
|
const img = new Image(); img.src = entry.src; await img.decode();
|
|
return { purpose: entry.purpose, width: img.naturalWidth, height: img.naturalHeight };
|
|
}));
|
|
});
|
|
assert.deepEqual(icons.filter((i) => i.purpose === 'maskable').map((i) => i.width).sort((a, b) => a - b), [192, 512]);
|
|
assert.ok(icons.every((i) => i.width > 0 && i.width === i.height));
|
|
assert.ok(icons.some((i) => i.purpose === 'apple-touch-icon' && i.width === 180));
|
|
const install = await client.send('Page.getInstallabilityErrors');
|
|
assert.deepEqual(install.installabilityErrors, []);
|
|
await page.evaluate(() => {
|
|
const e = new Event('beforeinstallprompt', { cancelable: true });
|
|
window.promptCalls = 0;
|
|
e.prompt = async () => { window.promptCalls++; return { outcome: 'dismissed' }; };
|
|
window.dispatchEvent(e);
|
|
});
|
|
await page.waitForSelector('[data-testid="pwa-install"]');
|
|
await page.click('[data-testid="pwa-install"]');
|
|
await page.waitForFunction(() => !document.querySelector('[data-testid="pwa-install"]'));
|
|
assert.equal(await page.evaluate(() => window.promptCalls), 1);
|
|
} finally { await page?.close(); }
|
|
});
|
|
test('extra-large settings stay within a landscape phone in every shipped interface language', async () => {
|
|
const ctx = await browser.createBrowserContext();
|
|
try {
|
|
const page = await pageIn(ctx, true);
|
|
for (const lang of ['zh-CN', 'en', 'ja', 'ko', 'zh-TW']) {
|
|
await page.evaluate(async (lang) => {
|
|
await (await import('/js/ui/lang.js')).switchLang(lang);
|
|
(await import('/js/ui/settings.js')).updateSettings({ textSize: 'xl' });
|
|
}, lang);
|
|
await page.click('[data-testid="settings-btn"]');
|
|
await page.waitForSelector('.modal .set-list');
|
|
const overflow = await page.$eval('.modal__body', (e) => e.scrollWidth - e.clientWidth);
|
|
const excess = await page.$eval('.modal__body', (e) => { const right = e.getBoundingClientRect().right; return [...e.querySelectorAll('*')].filter((x) => x.getBoundingClientRect().right > right + 1).map((x) => [x.className, x.textContent.slice(0, 50), Math.round(x.getBoundingClientRect().width)]); });
|
|
assert.ok(overflow <= 1, `${lang}: settings overflow ${overflow} ${JSON.stringify(excess)}`);
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForFunction(() => !document.querySelector('.modal .set-list'));
|
|
}
|
|
} finally { await ctx.close(); }
|
|
});
|
|
});
|