From 205bca7a07d32b6f46b2d61d86f80652cd5bda61 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 21:07:23 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feedback5=20WZ:=20the=20browser=20boss-fiel?= =?UTF-8?q?d=20check=20takes=20its=20=E7=98=AB=E7=97=AA=20from=20copies=20?= =?UTF-8?q?of=20the=20four=20captured=20specs=20with=20a=20golden=20?= =?UTF-8?q?=E5=82=AC=E6=B3=AA=E7=93=A6=E6=96=AF=20in=20every=20operator's?= =?UTF-8?q?=20second=20slot,=20compared=20Chrome=20vs=20Node=20like=20the?= =?UTF-8?q?=20originals=20(test/sim/bossfield.browser.test.js=20withTearGa?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed 22's only 麻痹 source was one 5 % 催泪瓦斯 on 洛洛: a single hit at 0.1.4, and since WE1's enemy damage frame (89f730d0, server/sim/ai.js enemyAttack; it re-timed every fight) none of its rolls hits — 0 attacks in the 17 s boss fight, 12 in the hidden one. The item, the status and the fx work: forced to 100 % the hidden field shows 26 麻痹 and 8 瘫痪. Every other check (digest / pool / fx / 限伤 / drones / 奥术) still runs on the unmodified specs. With the copies, 瘫痪 occurs on every field of all 19 seeds (of 1–120) that pass the other checks (seed 22: 4 / 7 / 29 / 13); with the palsy fx removed from the sim the test fails (0,0,0,0). A test fixture fix, no product change; golden unchanged. --- test/sim/bossfield.browser.test.js | 40 +++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/test/sim/bossfield.browser.test.js b/test/sim/bossfield.browser.test.js index dd5224ee..fef482c2 100644 --- a/test/sim/bossfield.browser.test.js +++ b/test/sim/bossfield.browser.test.js @@ -8,6 +8,9 @@ // with the field's LocalBossPool, and must give the same result digest, the same pool and the same fx per kind. // The fields exercise the playtest-6b sim paths: 直接乘算 bonus sums (§20.10), one 奥术 instance per target // (applyStrongest), the drone 2 % links and the 剑 / 锤 / shell kit (§20.13), and 限伤 cancels (fx 'hitCap', §20.12). +// 瘫痪 (fx 'palsy': a 麻痹 stack interrupts an enemy attack) is checked on the same four specs with a golden 催泪瓦斯 in +// every operator's second slot (withTearGas), compared Chrome vs Node like the others: the bots' only 麻痹 had been one +// 5 % 催泪瓦斯 on one operator — a single hit at 0.1.4, none since 0.2.0's enemy damage frame (WE1) re-timed every fight. // // Opt-in (starts Chrome): SIM_E2E=1 node --test test/sim/bossfield.browser.test.js (or RENDER_E2E=1) // Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default. @@ -59,6 +62,21 @@ function captureBossSpecs() { return specs; } +/** + * A copy of `spec` where every operator carries a golden 催泪瓦斯 (攻击时有5%概率使目标获得一层麻痹) in its second slot — + * two slots, as in the game: a 麻痹 source on every field whatever the bots drafted (seed 22's own was a single 5 % roll). + */ +const TEAR_GAS = 'chess_item_5_01_e_b'; +function withTearGas(spec) { + const s = JSON.parse(JSON.stringify(spec)); + for (const p of s.players || []) { + for (const u of p.units || []) { + if (u.kind === 'chess') u.items = [...(u.items || []).filter((id) => !String(id).startsWith('chess_item_5_01_e')).slice(0, 1), TEAR_GAS]; + } + } + return s; +} + /** * Runs a spec to the end (the same code in Node and in the page: `S` = spec module, `ds` = data source) and returns * its digest, the local pool, the fx count per kind and the leader-side checks (奥术 instances, 无来源 HP losses). @@ -100,8 +118,10 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () = test('real boss specs at 999 layers: Chrome and Node give the same digest, pool and fx; 限伤, drones, 瘫痪 and 奥术 all occur', { timeout: 180000 }, async () => { const specs = captureBossSpecs(); assert.deepEqual(specs.map((s) => `${s.kind}:${s.bossId}`).sort(), ['boss:boss_1', 'boss:boss_1', 'hidden:boss_8', 'hidden:boss_8']); + // the four as captured, then the same four with a 麻痹 source on every field (withTearGas) + const runs = [...specs, ...specs.map(withTearGas)]; const ds = new DataSource(DATA, null); - const node = specs.map((spec) => runField({ createBattleFromSpec, resultDigest }, ds, spec, MAX_SECONDS)); + const node = runs.map((spec) => runField({ createBattleFromSpec, resultDigest }, ds, spec, MAX_SECONDS)); const page = await browser.newPage(); const problems = []; @@ -115,12 +135,12 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () = const { spec: S, ds } = await loadBrowserSim(); const run = new Function(`return (${src})`)(); return all.map((spec) => run(S, ds, spec, maxSeconds)); - }, specs, runField.toString(), MAX_SECONDS); + }, runs, runField.toString(), MAX_SECONDS); await page.close(); assert.deepEqual(problems, []); - specs.forEach((spec, i) => { - const at = `${spec.kind} ${spec.fieldId} ${spec.bossId}`; + runs.forEach((spec, i) => { + const at = `${spec.kind} ${spec.fieldId} ${spec.bossId}${i >= specs.length ? ' +催泪瓦斯' : ''}`; assert.equal(node[i].reason, 'cleared', `${at}: the 999-layer pair clears the field`); assert.equal(chrome[i].json, node[i].json, `${at}: browser and Node results are identical`); assert.equal(chrome[i].hash, node[i].hash, at); @@ -129,10 +149,12 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () = assert.equal(chrome[i].hpLoss, node[i].hpLoss, `${at}: same 无来源 HP losses on the leader`); assert.ok(node[i].maxArcane <= 1, `${at}: one 奥术 instance on the leader at a time (${node[i].maxArcane})`); }); - const sum = (k) => node.reduce((a, r) => a + (r.fx[k] || 0), 0); - assert.ok(sum('hitCap') >= 1, `a 999-layer hit reached 300000 and was cancelled (限伤: ${sum('hitCap')})`); - assert.ok(sum('palsy') >= 1, `瘫痪 occurred (${sum('palsy')})`); - assert.ok(node.every((r) => r.hpLoss >= 1), `every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${node.map((r) => r.hpLoss)})`); - assert.ok(node.some((r) => r.maxArcane === 1), 'the leader carried 奥术'); + const real = node.slice(0, specs.length); + const gassed = node.slice(specs.length); + const sum = (rows, k) => rows.reduce((a, r) => a + (r.fx[k] || 0), 0); + assert.ok(sum(real, 'hitCap') >= 1, `a 999-layer hit reached 300000 and was cancelled (限伤: ${sum(real, 'hitCap')})`); + assert.ok(sum(gassed, 'palsy') >= 1, `瘫痪 occurred with the 催泪瓦斯 (${gassed.map((r) => r.fx.palsy || 0)})`); + assert.ok(real.every((r) => r.hpLoss >= 1), `every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${real.map((r) => r.hpLoss)})`); + assert.ok(real.some((r) => r.maxArcane === 1), 'the leader carried 奥术'); }); }); From bb4671371887e449be42699df5c533ef5babe861 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 21:08:33 +0800 Subject: [PATCH 2/4] feedback5 WZ: the waitForFunctionLong slicing test counts slices, not milliseconds (test/e2e/client-wait.test.js slicedPage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its fake predicate turned true 95 ms after the page was made and the test wanted at least 4 slices of 30 ms. On a loaded machine the 30 ms timers fire late and merge the slices: the 0.2.0 candidate's full pass got 'sliced (3 calls)'. A preload that blocks the event loop 9 ms of every 12 reproduces it 5 of 5 times. Now the predicate holds on the 4th call: exactly 4 calls, each with the same predicate, args and polling, each slice 0–30 ms, and the wait lasted the three slices (≥ 85 ms). It passes 5 of 5 under that lag and under 25 ms of every 27. A helper that stops at the first timed-out slice still fails it. A test timing flake, no product change; golden unchanged. --- test/e2e/client-wait.test.js | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/test/e2e/client-wait.test.js b/test/e2e/client-wait.test.js index 09fec6ec..8644777f 100644 --- a/test/e2e/client-wait.test.js +++ b/test/e2e/client-wait.test.js @@ -27,12 +27,34 @@ function fakePage(trueAfter) { }; } +/** + * A page whose predicate holds from its `holdsOn`-th waitForFunction call on; each earlier slice times out after its own + * timeout, like puppeteer's. Counted in calls, not in wall time: on a loaded machine late timers merge time-based slices + * (fakePage(95) with 30 ms slices returned in 3 calls instead of 4 in the 0.2.0 candidate's full pass). + */ +function slicedPage(holdsOn) { + const calls = []; + return { + calls, + waitForFunction(fn, opts, ...args) { + calls.push({ fn, opts, args }); + const holds = calls.length >= holdsOn; + return new Promise((resolve, reject) => { + if (holds) setTimeout(() => resolve({ handle: 'ok', args }), 0); + else setTimeout(() => reject(timeoutError(opts.timeout)), opts.timeout); + }); + }, + }; +} + test('a wait longer than one slice keeps polling the same predicate until it holds', async () => { - const page = fakePage(95); + const page = slicedPage(4); const fn = () => true; - const got = await waitForFunctionLong(page, fn, { timeout: 1000, polling: 200, slice: 30 }, 'a', 2); + const t0 = Date.now(); + const got = await waitForFunctionLong(page, fn, { timeout: 60_000, polling: 200, slice: 30 }, 'a', 2); assert.deepEqual(got, { handle: 'ok', args: ['a', 2] }); - assert.ok(page.calls.length >= 4, `sliced (${page.calls.length} calls)`); + assert.equal(page.calls.length, 4, 'three slices ran out, the fourth held, nothing polled after it'); + assert.ok(Date.now() - t0 >= 85, `the three slices waited their 30 ms each (${Date.now() - t0} ms)`); for (const c of page.calls) { assert.equal(c.fn, fn); assert.deepEqual(c.args, ['a', 2]); From ba4f5e195aa1ab89b0479fda643a4bbfaa1f828a Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 21:31:57 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feedback5=20WZ:=20the=20=E5=B9=B2=E5=91=98?= =?UTF-8?q?=E8=B0=83=E9=85=8D=20overlay's=20top=20bar=20keeps=20its=20righ?= =?UTF-8?q?t=20column=20clear=20of=20the=20tabs=20(public/css/screens/load?= =?UTF-8?q?out.css)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.2.0's third tab 自选编队 (WK 0def7ba, merged in d6fd800e — the toolbar check passes on its parent) widened the centre column of the symmetric grid, and the co-op briefing's countdown + 导出 / 导入 / 全部恢复默认 no longer fit in their half: they ran 4 px into the tabs at 667×375 (loadout.e2e's toolbar check), 63 px at 844×390 (the countdown on 自选编队) and, in English, at every size up to 1920×1080. Not the briefing's 本局禁用干员 lines: the overlap is the overlay's .lo-top. - the grid centres the tabs while both sides fit in equal halves (minmax(max-content, 1fr) minmax(0, auto) minmax(max-content, 1fr)) and shifts them otherwise; the tab strip scrolls only as a last resort, never overlaps; - the countdown caption breaks at its spaces (Loadout / Deadline; 调配截止 stays one line); - tighter tabs at ≤ 1200 px; at ≤ 1100 px the briefing's countdown takes the place of the 已调整 / 同步 chips; - phones in landscape (≤ 1000 px, up to the 932 px ones): no chips, and 返回 / 全部恢复默认 (全部持有, 全部清空 — each confirms first) show their icon, the text kept as their accessible name. Desktop zh looks as before. Checked: zh and en × briefing and lobby × the three tabs × 18 sizes from 667×375 to 2560×1440 — no overflow, overlap or offscreen button, the tabs never scroll. The e2e toolbar check now runs zh and en at 667×375 and 844×390 (and en at 1920×1080) and asserts the three tabs show in full; it fails on the old CSS. Golden unchanged. --- public/css/screens/loadout.css | 28 +++++++++++++++++++------ test/ui/loadout.e2e.test.js | 38 +++++++++++++++++++++++----------- 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/public/css/screens/loadout.css b/public/css/screens/loadout.css index 910842aa..9ea7323f 100644 --- a/public/css/screens/loadout.css +++ b/public/css/screens/loadout.css @@ -32,8 +32,11 @@ .lo > :where(:not(.lo__bg)) { position: relative; } /* ---- top bar ---------------------------------------------------------------------------------------------------- */ +/* The tabs stay centred while both sides fit in equal halves beside them; a wider side keeps its own width and shifts + the tabs instead (0.2.0: three tabs and the co-op countdown ran the right column into the tabs on phones and in + English), and only when nothing else is left does the tab strip scroll (.lo-tabs). */ .lo-top { - flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: .2rem; + flex: none; display: grid; grid-template-columns: minmax(max-content, 1fr) minmax(0, auto) minmax(max-content, 1fr); align-items: center; gap: .2rem; height: max(1rem, 48px); padding: 0 .36rem; background: linear-gradient(180deg, rgba(8, 10, 9, .95), rgba(10, 13, 12, .6)); border-bottom: 1px solid var(--line); @@ -43,7 +46,7 @@ background: linear-gradient(90deg, var(--mint-700), transparent 26%, transparent 74%, var(--mint-700)); } .lo-top__left { justify-self: start; display: flex; align-items: center; gap: .14rem; } -.lo-top__center { display: flex; flex-direction: column; align-items: center; gap: .02rem; } +.lo-top__center { display: flex; flex-direction: column; align-items: center; gap: .02rem; min-width: 0; } .lo-top__title { display: flex; align-items: center; gap: .12rem; margin: 0; font-size: max(.32rem, 16px); font-weight: 900; letter-spacing: .14em; line-height: 1.15; } .lo-top__icon { width: max(.3rem, 16px); height: max(.3rem, 16px); object-fit: contain; color: var(--mint-500); } .lo-top__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; } @@ -51,7 +54,8 @@ .lo-count b { color: var(--mint-400); font-size: max(.22rem, 13px); margin: 0 .02rem 0 .06rem; } /* co-op briefing: time left before the match locks the loadout (the overlay covers the briefing's own countdown) */ .lo-deadline { --cd-h: max(.44rem, 20px); flex: none; } -.lo-deadline .countdown__label { font-size: max(.11rem, 8px); } +/* its caption breaks at its spaces (Loadout / Deadline) rather than widening the column; 调配截止 stays one line */ +.lo-deadline .countdown__label { font-size: max(.11rem, 8px); width: min-content; word-break: keep-all; text-align: right; } .lo-sync { font-size: max(.14rem, 10px); padding: .03rem .1rem; border: 1px solid var(--line-2); color: var(--text-lo); white-space: nowrap; letter-spacing: .04em; } .lo-sync.is-ok { color: var(--mint-400); border-color: var(--mint-800); } .lo-sync.is-busy { color: var(--text-md); } @@ -330,7 +334,7 @@ .lo-locknote .icon { width: .16rem; height: .16rem; } /* ---- tabs: 干员调配 / 干员持有 (0.2.0 补位) ---------------------------------------------------------------------------- */ -.lo-tabs { display: flex; align-items: stretch; gap: .04rem; } +.lo-tabs { display: flex; align-items: stretch; gap: .04rem; max-width: 100%; overflow-x: auto; scrollbar-width: none; } .lo-tab { display: inline-flex; align-items: center; gap: .08rem; padding: .02rem .16rem; font: inherit; font-size: max(.24rem, 13px); font-weight: 900; letter-spacing: .12em; line-height: 1.2; color: var(--text-lo); background: transparent; border: 0; border-bottom: 2px solid transparent; cursor: pointer; @@ -382,7 +386,6 @@ @media (max-width: 760px) { .own { padding: .1rem .16rem .16rem; } .own__lead { display: none; } - .lo-tab { padding: .02rem .08rem; letter-spacing: .04em; } } /* ---- 自选编队 (screens/diy.js): four DIY slots by tier, the slot picker (operator list + skill / module) --------------- */ @@ -502,14 +505,27 @@ .sp-loadout-open .app-root > .screen { visibility: hidden; } /* ---- narrow / short viewports ------------------------------------------------------------------------------------------- */ +/* tighter tabs below 1200 px: the co-op briefing's countdown, chips and buttons beside three English tabs */ +@media (max-width: 1200px) { + .lo-tab { padding: .02rem .08rem; letter-spacing: .04em; } +} @media (max-width: 1100px) { .lo-body { grid-template-columns: minmax(0, 1fr) minmax(0, 6.4rem); gap: .16rem; padding: .12rem .2rem .16rem; } .lo-chip__lbl { display: none; } + /* the co-op briefing's countdown takes the place of the 已调整 / 同步 chips */ + .lo-deadline ~ .lo-sync, .lo-deadline ~ .lo-count { display: none; } +} +/* phones in landscape (up to 932 px wide): the top bar keeps the tabs clear of the right column — no 已调整 / 同步 chips, + and 返回 / 全部恢复默认 (全部持有, 全部清空; each confirms first) show their icon only, the text staying their name */ +@media (max-width: 1000px) { + .lo-count, .lo-sync { display: none; } + .lo-top .btn__icon + .btn__label { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; + } } /* phones (the root scale is clamped at 40 px: less than ~14 rem of width): the detail slides over the roster */ @media (max-width: 760px) { .lo-top { padding: 0 .16rem; } - .lo-count, .lo-sync { display: none; } .lo-body { grid-template-columns: minmax(0, 1fr); } .lo-detail-wrap { display: none; } .lo-body.is-detail .lo-roster { display: none; } diff --git a/test/ui/loadout.e2e.test.js b/test/ui/loadout.e2e.test.js index 5068d3e9..0605833e 100644 --- a/test/ui/loadout.e2e.test.js +++ b/test/ui/loadout.e2e.test.js @@ -243,19 +243,33 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED await page.waitForSelector('.lo-top .lo-deadline', { visible: true, timeout: 3000 }); assert.match(await page.$eval('.lo-top .lo-deadline', (el) => el.getAttribute('aria-label')), /剩余\d+秒/); // review: the two new 导出 / 导入 buttons must fit next to the countdown on a narrow phone in landscape (667×375). + // 0.2.0: the third tab (自选编队) ran the right column into the tabs there, at 844×390, and in English at every size. // Only the size changes (toggling isMobile / hasTouch would make puppeteer reload the page). - await page.setViewport({ width: 667, height: 375 }); - const bar = await page.evaluate(() => { - const top = document.querySelector('.lo-top'); - const right = document.querySelector('.lo-top__right').getBoundingClientRect(); - const center = document.querySelector('.lo-top__center').getBoundingClientRect(); - const buttons = [...document.querySelectorAll('.lo-top__right .btn')].map((b) => Math.round(b.getBoundingClientRect().right)); - return { overflow: top.scrollWidth - top.clientWidth, overlap: right.left < center.right, offscreen: right.right > innerWidth, buttons }; - }); - assert.ok(bar.overflow <= 0, `the top bar does not scroll sideways (${JSON.stringify(bar)})`); - assert.equal(bar.overlap, false, `the right column does not run into the title (${JSON.stringify(bar)})`); - assert.equal(bar.offscreen, false, `every button ends inside the viewport (${JSON.stringify(bar)})`); - await page.screenshot({ path: path.join(OUT, 'loadout-narrow-toolbar.png') }); + const switchLang = async (lang) => { + await page.evaluate((l) => import('/js/ui/lang.js').then((m) => m.switchLang(l)), lang); + await page.waitForFunction((l) => document.documentElement.lang.startsWith(l) && /[一-鿿]/.test(document.querySelector('.lo-tabs').textContent) === (l === 'zh'), { timeout: 5000 }, lang); + }; + for (const [lang, sizes] of [['zh', [[667, 375], [844, 390]]], ['en', [[667, 375], [844, 390], [1920, 1080]]]]) { + if (lang !== 'zh') await switchLang(lang); + for (const [w, h] of sizes) { + await page.setViewport({ width: w, height: h }); + const bar = await page.evaluate(() => { + const top = document.querySelector('.lo-top'); + const tabs = document.querySelector('.lo-tabs'); + const right = document.querySelector('.lo-top__right').getBoundingClientRect(); + const center = document.querySelector('.lo-top__center').getBoundingClientRect(); + const buttons = [...document.querySelectorAll('.lo-top__right .btn')].map((b) => Math.round(b.getBoundingClientRect().right)); + return { overflow: top.scrollWidth - top.clientWidth, overlap: right.left < center.right, offscreen: right.right > innerWidth, tabsCut: tabs.scrollWidth - tabs.clientWidth, buttons }; + }); + const at = `${lang} ${w}×${h} ${JSON.stringify(bar)}`; + assert.ok(bar.overflow <= 0, `the top bar does not scroll sideways (${at})`); + assert.equal(bar.overlap, false, `the right column does not run into the title (${at})`); + assert.equal(bar.offscreen, false, `every button ends inside the viewport (${at})`); + assert.ok(bar.tabsCut <= 0, `the three tabs show in full (${at})`); + if (w === 667) await page.screenshot({ path: path.join(OUT, `loadout-narrow-toolbar${lang === 'zh' ? '' : `-${lang}`}.png`) }); + } + } + await switchLang('zh'); await page.setViewport({ width: 1920, height: 1080 }); await page.type('.lo-search input', '隐现'); await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 }); From 24578cf2cb3dc61a0571f58204fec86faed79d11 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 22:00:26 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feedback5=20WZ:=20the=20real-server=20equip?= =?UTF-8?q?-replace=20check=20waits=20until=20=E5=8F=96=E6=B6=88=20has=20d?= =?UTF-8?q?rawn=20the=20item=20back=20on=20its=20bench=20slot=20before=20i?= =?UTF-8?q?t=20drops=20it=20again=20(test/ui/leftovers.e2e.test.js)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.2.0 candidate's full pass failed at the second drop ('Waiting for selector `.eqr .eqr__opt` failed'). The view keeps a dropped piece on the drop tile for 1.3 s (render/app/tune.js DROP_PENDING_MS, unchanged since the first import) and then flies it back in a frame-timed tween. The test picked the item up again 0.5 s after 取消, where it had been drawn a moment before. Even alone the first try often grabbed nothing (the helper's retry hid it). With few frames (software-rendered headless Chrome on a loaded machine; reproduced with frames ~600 ms apart) the press grabbed the operator under the item, the operator dropped on its own tile opened the direction wheel, and the wheel swallowed the retry. The dialog itself opens and renders as before (补位 / i18n / 自选 not involved). Now 取消 is also checked to draw the item back on its bench slot, and the next drop waits for that; under the same slow frames the drop succeeds at the first try. Alone: 5/5 twice; mock.e2e 32/32, devices.e2e 26/26. A test timing flake, no product change; golden unchanged. --- test/ui/leftovers.e2e.test.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/ui/leftovers.e2e.test.js b/test/ui/leftovers.e2e.test.js index 4381b1ca..6095c964 100644 --- a/test/ui/leftovers.e2e.test.js +++ b/test/ui/leftovers.e2e.test.js @@ -10,7 +10,8 @@ // 4. solo battle: the pause button sends g.pause {on}, m.public.paused shows the overlay and freezes the countdown, // Space resumes; no pause control in co-op or in prep. // Real server (test/e2e/fastServer.mjs, real clicks and canvas drags): -// 1+4. a solo run: equip two items on an operator, drop a third → dialog → 取消 (nothing sent) → pick the OLDER item → +// 1+4. a solo run: equip two items on an operator, drop a third → dialog → 取消 (nothing sent, the item drawn back on its +// bench slot) → pick the OLDER item → // the real engine destroys exactly that one; then ready → the real local battle → pause (the battle clock and the // countdown stand still) → 继续作战; // 3. the server restarts while the briefing is on screen → 「服务器会话已重置,上一局模拟已结束」 and the lobby. @@ -275,6 +276,7 @@ describe('client leftovers — real server', { skip: !ENABLED && 'set SP_E2E=1 ( const equipped = await itemsOf(); assert.deepEqual(equipped.map((x) => x.id), KIT_ITEMS.slice(0, 2)); const third = await handItem(KIT_ITEMS[2]); + const benchSlot = await c.piecePoint(third.uid, 0.5); // where the view draws it in the hand // the third: dialog → 取消 → nothing sent / changed await dropItem(third); @@ -289,6 +291,15 @@ describe('client leftovers — real server', { skip: !ENABLED && 'set SP_E2E=1 ( assert.equal((await c.requests('g.equip')).length, sentBefore, '取消: no g.equip'); assert.deepEqual(await itemsOf(), equipped, '取消: unchanged'); assert.ok(await handItem(KIT_ITEMS[2]), '取消: the item stays in the hand'); + // ... and is drawn back on its bench slot before it is picked up again. The view keeps a dropped piece on the drop + // tile for 1.3 s (render/app/tune.js DROP_PENDING_MS), then flies it back in a frame-timed tween: with few frames + // (software-rendered headless Chrome on a loaded machine) the drag aimed where it stood a moment before grabbed the + // operator under it, whose direction wheel then swallowed the retry (the 0.2.0 candidate's full pass). + const back = await c.page.waitForFunction((uid, x, y) => { + const r = globalThis.__SP_VIEW__?.pieceScreenRect(uid); + return !!r && Math.abs(r.left + r.width / 2 - x) < 2 && Math.abs(r.top + r.height / 2 - y) < 2; + }, { timeout: 15000, polling: 100 }, third.uid, benchSlot.x, benchSlot.y).then(() => true, () => false); + assert.ok(back, '取消: the item is drawn back on its bench slot'); // again → pick the OLDER item (not the server's default either way: explicit replaceUid) → 确认替换 await dropItem(third);