fix: 高延迟下连按「刷新」只发送一次请求(GitHub #501)
This commit is contained in:
1 parent
bc50cac01f
commit
289a454fa1
10 files changed
+186
-8
No files matched your search
@@ -0,0 +1,84 @@
|
||||
// test/ui/refresh-tap.e2e.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」只发送一次请求. Through the mock
|
||||
// harness (public/dev/game-mock.html) in headless Chrome, with the mock's reply to g.refresh delayed: the first tap
|
||||
// sends one request and dims the button, a spam tap and the R key while it is on the wire send nothing, the shop pays
|
||||
// once, and the next tap after the reply refreshes again. Opt-in: SP_E2E=1 (needs Chrome; CHROME_PATH overrides).
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/refresh-tap.e2e.test.js
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const BTN = '.shopbar__tools .toolbtn--amber';
|
||||
|
||||
describe('the shop refresh single-flights (GitHub #501, mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
test('a tap under a lagging shop pays once: the button dims, the extra tap / R key are dropped, the next tap works', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
|
||||
await page.goto(`${base}/dev/game-mock.html?shot=1&phase=PREP&render=fallback`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
|
||||
// the mock answers instantly; give g.refresh a high RTT, and count what the client actually sends (gameActions.js
|
||||
// looks net.request up at call time; the mock's own request record only sees the request after the delay)
|
||||
await page.evaluate(async () => {
|
||||
const { net } = await import('/js/net.js');
|
||||
const orig = net.request.bind(net);
|
||||
globalThis.__REFRESHES__ = 0;
|
||||
net.request = (t, ...rest) => {
|
||||
if (t === 'g.refresh') globalThis.__REFRESHES__ += 1;
|
||||
return new Promise((res, rej) => {
|
||||
setTimeout(() => orig(t, ...rest).then(res, rej), t === 'g.refresh' ? 700 : 0);
|
||||
});
|
||||
};
|
||||
});
|
||||
const btnState = () => page.evaluate(() => {
|
||||
const b = document.querySelector('.shopbar__tools .toolbtn--amber');
|
||||
return { disabled: !!b?.disabled, title: b?.title || null };
|
||||
});
|
||||
const refreshes = () => page.evaluate(() => globalThis.__REFRESHES__);
|
||||
const funds = () => page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
|
||||
|
||||
assert.equal((await btnState()).disabled, false, 'the 刷新 button starts enabled');
|
||||
const n0 = await refreshes();
|
||||
const f0 = await funds();
|
||||
await page.click(BTN);
|
||||
await sleep(150);
|
||||
assert.equal(await refreshes(), n0 + 1, 'the tap sent one g.refresh');
|
||||
assert.deepEqual(await btnState(), { disabled: true, title: '正在刷新…' }, 'the button dims and says it is waiting');
|
||||
// the spam tap (the disabled button swallows clicks) and the R hotkey while pending: neither reaches the socket
|
||||
await page.evaluate(() => document.querySelector('.shopbar__tools .toolbtn--amber')?.click());
|
||||
await page.keyboard.press('r');
|
||||
await sleep(200);
|
||||
assert.equal(await refreshes(), n0 + 1, 'nothing else was sent');
|
||||
await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 });
|
||||
const back = await btnState();
|
||||
assert.equal(back.disabled, false, 'the button is enabled again after the reply');
|
||||
assert.match(back.title, /刷新商店/, 'the tooltip is back to the hotkey hint');
|
||||
assert.equal(await funds(), f0 - 1, 'the shop paid exactly one refresh');
|
||||
await page.click(BTN);
|
||||
await sleep(150);
|
||||
assert.equal(await refreshes(), n0 + 2, 'a refresh after the reply is sent again');
|
||||
await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 });
|
||||
assert.equal(await funds(), f0 - 2, 'and paid once more');
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// test/ui/refresh-tap.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」会重复扣费. The intent layer now
|
||||
// single-flights it (ui/gameActions.js): a tap while the previous g.refresh is still on the wire is dropped — the
|
||||
// button dims through onRefreshBusy (game.js → ui/shopBar.js) and the R hotkey (the same actions.refresh) stays inert
|
||||
// until the reply lands. The browser run of the same case is test/ui/refresh-tap.e2e.test.js (SP_E2E=1).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
|
||||
const { net } = await import('../../public/js/net.js');
|
||||
const { actions, onRefreshBusy } = await import('../../public/js/ui/gameActions.js');
|
||||
|
||||
describe('the shop refresh single-flights (GitHub #501)', () => {
|
||||
test('a refresh while the previous one is on the wire sends nothing; the busy signal brackets the request; a later one sends again', async () => {
|
||||
const sent = [];
|
||||
let release;
|
||||
const prev = net.request;
|
||||
net.request = (t) => { sent.push(t); return new Promise((r) => { release = () => r({ ok: true }); }); };
|
||||
const seen = [];
|
||||
const off = onRefreshBusy((b) => seen.push(b));
|
||||
try {
|
||||
assert.deepEqual(seen, [false], 'subscribing fires once with the current (idle) state');
|
||||
const first = actions.refresh();
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(seen.at(-1), true, 'busy from the moment of the send');
|
||||
assert.equal(await actions.refresh(), false, 'the duplicate resolves false without waiting');
|
||||
assert.equal(sent.length, 1, 'and never reaches the socket');
|
||||
release();
|
||||
assert.equal(await first, true);
|
||||
assert.deepEqual(seen, [false, true, false], 'the reply clears the busy state');
|
||||
const again = actions.refresh();
|
||||
assert.equal(sent.length, 2, 'a refresh after the reply is sent again');
|
||||
release();
|
||||
assert.equal(await again, true);
|
||||
assert.deepEqual(seen, [false, true, false, true, false]);
|
||||
} finally {
|
||||
off();
|
||||
net.request = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test('an unsubscribe stops the updates', () => {
|
||||
const seen = [];
|
||||
const off = onRefreshBusy((b) => seen.push(b));
|
||||
off();
|
||||
assert.deepEqual(seen, [false]);
|
||||
});
|
||||
|
||||
test('the wiring: game.js subscribes and hands the state to the bar; the button dims and says what it waits for', () => {
|
||||
const game = read('public/js/screens/game.js');
|
||||
assert.match(game, /onRefreshBusy\(setRefreshBusy\)/, 'the game screen subscribes');
|
||||
assert.match(game, /<\$\{ShopBar\}[\s\S]*?refreshBusy=\$\{refreshBusy\}/, 'and the bar receives it');
|
||||
const bar = read('public/js/ui/shopBar.js');
|
||||
assert.match(bar, /disabled=\$\{!!refReason \|\| refreshBusy\}/, 'busy disables the 刷新 button');
|
||||
assert.match(bar, /t\('正在刷新…'\)/, 'and its tooltip says what it is waiting for');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user