From b972c1fc9ef58ed51ab772442c4e1e5d317665f8 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 23:44:02 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feedback7=20R22-E2EFIX:=20an=20Esc=20presse?= =?UTF-8?q?d=20the=20moment=20the=20=E8=87=AA=E9=80=89=20picker=20opens=20?= =?UTF-8?q?closes=20it=20(its=20listener=20comes=20with=20the=20picker's?= =?UTF-8?q?=20commit);=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.2.2 full browser pass failed diy.e2e 「5阶 / 6阶 slots: Esc cancels the picker…」 (GitHub #284's test) at the wait for the picker to close after Esc. The picker does open on a real click (the suite's other three tests open it with real mouse clicks; all green). The cause is a race in the UI: DiyPicker attached its keydown listener in a plain effect, which runs a frame after the picker is in the DOM, while the 干员调配 overlay's own Esc handler skips Esc as soon as `.diy-pick` exists — an Esc pressed in between was swallowed and the picker stayed open. Reproduced every time by dispatching Esc right after the picker renders; on a loaded machine the test's own Esc hit the window. Now a layout effect: the listener is live in the picker's first commit. - Test: diy.e2e (the Esc test) adds that immediate Esc — it fails on the parent with the full pass's error and passes now; the whole suite 4/4 (SP_E2E=1), test/ui unit tests 699 pass. --- public/js/screens/diy.js | 8 +++++--- test/ui/diy.e2e.test.js | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/public/js/screens/diy.js b/public/js/screens/diy.js index 97542f94..489cf3f4 100644 --- a/public/js/screens/diy.js +++ b/public/js/screens/diy.js @@ -9,7 +9,7 @@ // the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*). An owned pick's card carries its operator's 潜能 / // 练度 selects (0.2.2, screens/cultivation.js — the same per-operator settings as 干员调配, `ops`); a prototype has neither. -import { useEffect, useState } from '../../vendor/hooks.module.js'; +import { useLayoutEffect, useState } from '../../vendor/hooks.module.js'; import { html, Icon, Button, TierChip } from '../ui/components.js'; import { Img, RichText, BondGlyph } from '../ui/gameComponents.js'; import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js'; @@ -146,8 +146,10 @@ export function DiyPicker(props) { const [query, setQuery] = useState(''); const [draft, setDraft] = useState(cur ? { ...cur } : null); // Esc cancels only the picker, like its 取消 (GitHub #284, idea from PR #286); the 干员调配 overlay's own Esc skips - // while a picker is open, and a dialog over the picker (导入) still takes Esc first - useEffect(() => { + // while a picker is open, and a dialog over the picker (导入) still takes Esc first. A layout effect: the listener is + // attached in the same commit as the picker (a plain effect waits for the next frame, and an Esc pressed in between + // was swallowed — the overlay skipped it, the picker did not hear it yet; the 0.2.2 full browser pass) + useLayoutEffect(() => { const onKey = (e) => { if (e.key !== 'Escape' || e.ctrlKey || e.metaKey || e.altKey || document.querySelector('.modal')) return; e.preventDefault(); diff --git a/test/ui/diy.e2e.test.js b/test/ui/diy.e2e.test.js index 00127367..da026989 100644 --- a/test/ui/diy.e2e.test.js +++ b/test/ui/diy.e2e.test.js @@ -134,6 +134,19 @@ describe('自选编队 picker: Esc closes only the picker (real server, no art n assert.ok(await c.page.$('.lo'), `${slot}: Esc keeps the 干员调配 overlay${search ? ' (from the search field)' : ''}`); assert.equal((await c.page.$$('.diy-slot')).length, 4, `${slot}: the four slots stay`); } + // an Esc pressed the moment the picker appears — before the next animation frame — closes it too: its listener comes + // with the picker's own commit (a plain effect attached it a frame later and the overlay skipped the key: the + // picker stayed open — the 0.2.2 full browser pass) + const opened = await c.page.evaluate(async (slot, sel) => { + document.querySelector(`.diy-slot[data-slot="${slot}"] .diy-slot__fill`).click(); + for (let k = 0; k < 50 && !document.querySelector(sel); k++) await Promise.resolve(); + const open = !!document.querySelector(sel); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + return open; + }, SLOT, picker); + assert.ok(opened, 'the picker opened'); + await c.page.waitForSelector(picker, { hidden: true, timeout: 3000 }); + assert.ok(await c.page.$('.lo'), 'an immediate Esc keeps the 干员调配 overlay'); assert.equal(await saved(), before, 'cancelling a picker does not change the saved picks'); await c.page.keyboard.press('Escape'); await c.page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 }); From 267a826b58d79feccf024372fcaa12b233d63fd8 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 23:44:02 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feedback7=20R22-E2EFIX:=20loadout-battle=20?= =?UTF-8?q?/=20teammate-loadout=20e2e=20choose=20=E9=87=8E=E9=AC=83's=20S1?= =?UTF-8?q?=20+=20=E4=B8=8D=E8=A3=85=E5=A4=87=20in=20the=20row's=20quick?= =?UTF-8?q?=20choices=20and=20expect=20the=20default=20=E7=BB=83=E5=BA=A6?= =?UTF-8?q?=20on=20the=20detail=20card;=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.2.2 干员调配 is one list: a row carries the skills and the elite's modules, and the detail (now opening with 潜能与练度 and 特质) has its module cards below the fold at 1920 × 1080, where the Client's real-mouse click (no scrolling) found nothing clickable for `.lo-detail .lo-mod[data-module="none"]`. Both suites now tap the row's `.lo-q--skill[data-skill="0"]` / `.lo-q--mod[data-module="none"]` and wait for the row (aria-pressed) and the detail (is-on) to agree before the sync; what they assert is unchanged (m.private.loadout → b.start spec → the local sim runs S1 without the module; the host's card of the guest's unit reads S1 已调配 / 未装备模组). loadout-battle's detail card check then met 0.2.2's other change: the card's base ATK carries the player's 练度 — the default 精英2 Lv.60, ×1.1 (576 for the record's 524, effects.json aceffect_char_4); the unit's def keeps 524. - Tests (SP_E2E=1): loadout-battle 2/2, teammate-loadout 1/1. --- test/ui/loadout-battle.e2e.test.js | 17 ++++++++++++----- test/ui/teammate-loadout.e2e.test.js | 9 +++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/test/ui/loadout-battle.e2e.test.js b/test/ui/loadout-battle.e2e.test.js index bb2b63f4..6bf0be26 100644 --- a/test/ui/loadout-battle.e2e.test.js +++ b/test/ui/loadout-battle.e2e.test.js @@ -53,9 +53,13 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc await c.page.keyboard.type('野鬃'); await c.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 }); await c.click('.lo-card__pick'); - await c.click('.lo-detail .lo-skill[data-skill="0"]'); - await c.click('.lo-detail .lo-mod[data-module="none"]'); - await c.page.waitForFunction(() => document.querySelector('.lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-mod.is-on[data-module="none"]'), { timeout: 3000 }); + // the row's quick choices (0.2.2: the roster is one list, a row per operator with its skills and the elite's + // modules; the detail's own module cards sit below the fold) — the row and the detail agree on S1 + 不装备 + await c.click('.lo-card .lo-q--skill[data-skill="0"]'); + await c.click('.lo-card .lo-q--mod[data-module="none"]'); + await c.page.waitForFunction(() => document.querySelector('.lo-card .lo-q--skill[data-skill="0"]')?.getAttribute('aria-pressed') === 'true' + && document.querySelector('.lo-card .lo-q--mod[data-module="none"]')?.getAttribute('aria-pressed') === 'true' + && document.querySelector('.lo-detail .lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-detail .lo-mod.is-on[data-module="none"]'), { timeout: 3000 }); await c.page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 }); await c.shot('overlay'); await c.page.keyboard.press('Escape'); @@ -116,8 +120,11 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc assert.ok(detail, 'the detail card opens for the placed elite'); assert.ok(detail.skill.includes(s1.name) && detail.tag, `detail card: ${s1.name} 已调配 (${JSON.stringify(detail)})`); assert.ok(detail.none && detail.module.includes('未装备模组'), `detail card: 未装备模组 (${JSON.stringify(detail)})`); - // …and the stats / 特性 the unit fights with: no module (ATK 524, interval 1.00 s, "获得1点") — not the default module's - assert.equal(detail.stats['攻击'], String(rec.statsBase.atk), `detail card ATK without the module (${JSON.stringify(detail.stats)})`); + // …and the stats / 特性 the unit fights with: no module (ATK 524, interval 1.00 s, "获得1点") — not the default module's; + // 0.2.2: the card's base value carries the player's 练度, here the default 精英2 Lv.60 (effects.json aceffect_char_4, + // ×1.1 ATK — a multiplier of its own, the unit's def keeps the record's 524) + const t4 = JSON.parse(readFileSync(path.join(ROOT, 'data/effects.json'), 'utf8')).aceffect_char_4.buffs.find((b) => b.key === 'char_attribute_mul').bb; + assert.equal(detail.stats['攻击'], String(Math.round(rec.statsBase.atk * t4.atk)), `detail card ATK without the module, at the default 练度 (${JSON.stringify(detail.stats)})`); assert.equal(detail.stats['攻击间隔'], '1.00s', 'detail card interval: ASPD 100'); assert.match(detail.trait, /获得1点部署费用/, `detail card 特性 without the module (${detail.trait})`); await c.shot('detail'); diff --git a/test/ui/teammate-loadout.e2e.test.js b/test/ui/teammate-loadout.e2e.test.js index 385c668f..b8d40b2c 100644 --- a/test/ui/teammate-loadout.e2e.test.js +++ b/test/ui/teammate-loadout.e2e.test.js @@ -59,8 +59,13 @@ describe('DESIGN §16 — a teammate\'s unit shows its owner\'s loadout (real se await guest.page.keyboard.type('野鬃'); await guest.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 }); await guest.click('.lo-card__pick'); - await guest.click('.lo-detail .lo-skill[data-skill="0"]'); - await guest.click('.lo-detail .lo-mod[data-module="none"]'); + // the row's quick choices (0.2.2: the roster is one list, a row per operator with its skills and the elite's + // modules; the detail's own module cards sit below the fold) — the row and the detail agree on S1 + 不装备 + await guest.click('.lo-card .lo-q--skill[data-skill="0"]'); + await guest.click('.lo-card .lo-q--mod[data-module="none"]'); + await guest.page.waitForFunction(() => document.querySelector('.lo-card .lo-q--skill[data-skill="0"]')?.getAttribute('aria-pressed') === 'true' + && document.querySelector('.lo-card .lo-q--mod[data-module="none"]')?.getAttribute('aria-pressed') === 'true' + && document.querySelector('.lo-detail .lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-detail .lo-mod.is-on[data-module="none"]'), { timeout: 3000 }); await guest.page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 }); await guest.page.keyboard.press('Escape'); await guest.page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 }); From 917e89240c2aff0deab726c4450807e8964babc8 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 23:44:02 +0800 Subject: [PATCH 3/3] feedback7 R22-E2EFIX: loadout e2e's pickChess waits for the detail to show the picked chess at the top before clicking in it; golden: unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadout.e2e 「局内数值 (desktop)…」 failed with 'Node is either not clickable or not an Element' on the 局内数值 精锐 tab right after picking 信仰搅拌机. pickChess returned as soon as a stats cell was visible — the previous chess's — and the detail resets its scroll for a new chess after paint (screens/loadout.js), so puppeteer scrolled the tab into view and the reset then moved it below the viewport before the click (measured: scrollTop 366 → 0, the tab at y 1201 of 1080). The 0.2.2 detail opens with 潜能与练度 and 特质, so 局内数值 / 模组 sit below the fold and need that scroll. pickChess now waits for the detail's name and its body back at scrollTop 0. A player is not affected (the reset is the intended behaviour); the assertions are unchanged. - Tests (SP_E2E=1): loadout.e2e 11/11. --- test/ui/loadout.e2e.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/ui/loadout.e2e.test.js b/test/ui/loadout.e2e.test.js index 00bd59b5..4736b5fb 100644 --- a/test/ui/loadout.e2e.test.js +++ b/test/ui/loadout.e2e.test.js @@ -455,6 +455,10 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED await page.type('.lo-search input', name); await page.waitForFunction((n) => document.querySelectorAll('.lo-card').length === 1 && document.querySelector('.lo-card .lo-card__name')?.textContent === n, { timeout: 5000 }, name); await page.click('.lo-card__pick'); + // the detail shows the picked chess and its body is back at the top: screens/loadout.js resets the scroll for a new + // chess after paint, and a click that puppeteer scrolled into view before that reset lands outside the viewport (the + // 0.2.2 detail opens with 潜能与练度 and 特质, so 局内数值 / 模组 sit below the fold at 1920 × 1080) + await page.waitForFunction((n) => document.querySelector('.lo-dhead__name')?.textContent === n && document.querySelector('.lo-detail__body')?.scrollTop === 0, { timeout: 5000 }, name); await page.waitForSelector('.lo-sec--stats .dstat', { visible: true }); }