findings group O: chooser Enter + infobar close fixes (wx → 15e5315244) with repro specs and uipolish guards
- tests/kicad/footprint-chooser-confirm.spec.ts (O-2): typed filter + Enter confirms the chooser and a footprint is placed. - tests/kicad/infobar-dismiss.spec.ts (O-3): real click on the older-version infobar close glyph dismisses it; GAL rect shift logged. - uipolish app/spec: rounded-neg-radius (O-1 guard), enable-propagation (O-3), dom-nav-keys Enter/ArrowDown → CHAR_HOOK with TEXT_ENTER once (O-2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3cyLvFWbfNdCNW7x7C2A
This commit is contained in:
parent
97d4f9657e
commit
931935c70a
5 changed files with 620 additions and 1 deletions
|
|
@ -39,6 +39,14 @@
|
|||
// the layers-panel rows tall and the eye icons blurry. Only
|
||||
// discriminating at DPR>=1.5 — the spec runs a
|
||||
// deviceScaleFactor:2 pass for that.
|
||||
// rounded-neg-radius DrawRoundedRectangle with a negative (fraction) or
|
||||
// oversize radius paints instead of throwing (findings O-1:
|
||||
// the status-bar badge used to reject the whole wx tick).
|
||||
// enable-propagation a DOM control born under a disabled frame is
|
||||
// re-enabled with the frame (findings O-3: infobar close
|
||||
// button was <button disabled> for life).
|
||||
// dom-nav-keys (spec-driven) Enter/ArrowDown on a DOM <input> reach
|
||||
// wxEVT_CHAR_HOOK; TEXT_ENTER still fires once (O-2).
|
||||
|
||||
#include "wx/wxprec.h"
|
||||
|
||||
|
|
@ -49,6 +57,8 @@
|
|||
#include "wx/dcmemory.h"
|
||||
#include "wx/bmpbndl.h"
|
||||
#include "wx/statbmp.h"
|
||||
#include "wx/button.h"
|
||||
#include "wx/textctrl.h"
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <emscripten/emscripten.h>
|
||||
|
|
@ -228,6 +238,45 @@ static void CheckScaledDims()
|
|||
wxString::Format("got %dx%d", img.GetWidth(), img.GetHeight()));
|
||||
}
|
||||
|
||||
// findings O-1: a NEGATIVE DrawRoundedRectangle radius is the wx "fraction of
|
||||
// the shorter side" convention (gtk/dcclient.cpp; KiCad's BITMAP_BUTTON badge
|
||||
// passes -0.25). Pre-fix (wx f56511f965) the wasm DC forwarded it raw into
|
||||
// canvas arcTo(), which throws IndexSizeError — a JS exception that unwinds
|
||||
// the whole wx tick (evtloop.cpp "[wx] top-level tick rejected"). A raw
|
||||
// oversize radius (> half a side) is the same canvas error class. Both must
|
||||
// paint, and paint INSIDE the rect (the corner arcs must not swallow it).
|
||||
static void CheckRoundedNegRadius()
|
||||
{
|
||||
wxBitmap bmp(40, 20, 24);
|
||||
bool threw = false;
|
||||
{
|
||||
wxMemoryDC dc(bmp);
|
||||
dc.SetBackground(*wxWHITE_BRUSH);
|
||||
dc.Clear();
|
||||
dc.SetBrush(*wxRED_BRUSH);
|
||||
dc.SetPen(*wxTRANSPARENT_PEN);
|
||||
try
|
||||
{
|
||||
dc.DrawRoundedRectangle(2, 2, 30, 14, -0.25); // badge convention
|
||||
dc.DrawRoundedRectangle(34, 2, 4, 14, 8.0); // oversize radius
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
threw = true;
|
||||
}
|
||||
dc.SelectObject(wxNullBitmap);
|
||||
}
|
||||
|
||||
wxImage img = bmp.ConvertToImage();
|
||||
const bool centreRed = PixelIs(img, 17, 9, 255, 0, 0);
|
||||
const bool sliverRed = PixelIs(img, 36, 9, 255, 0, 0);
|
||||
const bool outsideWhite = PixelIs(img, 0, 0, 255, 255, 255);
|
||||
|
||||
Report("rounded-neg-radius", !threw && centreRed && sliverRed && outsideWhite,
|
||||
wxString::Format("threw=%d centre %s sliver %s corner %s", threw ? 1 : 0,
|
||||
PixelStr(img, 17, 9), PixelStr(img, 36, 9), PixelStr(img, 0, 0)));
|
||||
}
|
||||
|
||||
class UiPolishFrame : public wxFrame
|
||||
{
|
||||
public:
|
||||
|
|
@ -274,8 +323,62 @@ public:
|
|||
sbBest.x, sbBest.y, GetDPIScaleFactor()));
|
||||
sizer->Add(sb, 0, wxALL, 10);
|
||||
|
||||
// dom-nav-keys (findings O-2): a DOM-backed text ctrl whose
|
||||
// wxEVT_CHAR_HOOK / wxEVT_TEXT_ENTER arrivals the spec observes after
|
||||
// dispatching keydown events on the <input>. The hook Skip()s so a
|
||||
// non-consuming handler still lets TEXT_ENTER through exactly once.
|
||||
wxTextCtrl* nav = new wxTextCtrl(this, wxID_ANY, "", wxDefaultPosition,
|
||||
wxSize(200, -1), wxTE_PROCESS_ENTER,
|
||||
wxDefaultValidator, "navkeys");
|
||||
nav->Bind(wxEVT_CHAR_HOOK, [](wxKeyEvent& e) {
|
||||
#ifdef __EMSCRIPTEN__
|
||||
EM_ASM({ console.log('[UIPOLISH_TEST] charhook key=' + $0); }, e.GetKeyCode());
|
||||
#endif
|
||||
e.Skip();
|
||||
});
|
||||
nav->Bind(wxEVT_TEXT_ENTER, [](wxCommandEvent&) {
|
||||
#ifdef __EMSCRIPTEN__
|
||||
EM_ASM({ console.log('[UIPOLISH_TEST] textenter'); });
|
||||
#endif
|
||||
});
|
||||
sizer->Add(nav, 0, wxALL, 10);
|
||||
#ifdef __EMSCRIPTEN__
|
||||
EM_ASM({ console.log('[UIPOLISH_TEST] navkeys domId=' + $0); }, nav->WasmGetDomId());
|
||||
#endif
|
||||
|
||||
SetSizer(sizer);
|
||||
}
|
||||
|
||||
// enable-propagation (findings O-3): a DOM control created while its
|
||||
// frame is disabled (modal / progress-dialog wxWindowDisabler) must come
|
||||
// back enabled when the frame is. Pre-fix wincmn.cpp treated the wasm
|
||||
// port as having native enabled management, so the frame's Enable(true)
|
||||
// never reached the children and the DOM node stayed <button disabled>.
|
||||
void CheckEnablePropagation()
|
||||
{
|
||||
Enable(false);
|
||||
wxButton* born = new wxButton(this, wxID_ANY, "born-disabled");
|
||||
const int domId = born->WasmGetDomId();
|
||||
bool disabledWhileFrameOff = false;
|
||||
bool enabledAfter = false;
|
||||
#ifdef __EMSCRIPTEN__
|
||||
disabledWhileFrameOff = EM_ASM_INT({
|
||||
var el = document.querySelector('[data-wx-dom-id="' + $0 + '"]');
|
||||
return el && el.disabled ? 1 : 0;
|
||||
}, domId) != 0;
|
||||
#endif
|
||||
Enable(true);
|
||||
#ifdef __EMSCRIPTEN__
|
||||
enabledAfter = EM_ASM_INT({
|
||||
var el = document.querySelector('[data-wx-dom-id="' + $0 + '"]');
|
||||
return el && !el.disabled ? 1 : 0;
|
||||
}, domId) != 0;
|
||||
#endif
|
||||
Report("enable-propagation", disabledWhileFrameOff && enabledAfter && born->IsEnabled(),
|
||||
wxString::Format("domId=%d disabledWhileFrameOff=%d enabledAfter=%d",
|
||||
domId, disabledWhileFrameOff ? 1 : 0, enabledAfter ? 1 : 0));
|
||||
born->Destroy();
|
||||
}
|
||||
};
|
||||
|
||||
class UiPolishApp : public wxApp
|
||||
|
|
@ -297,9 +400,11 @@ public:
|
|||
CheckBlitOrigin();
|
||||
CheckMaskAlpha();
|
||||
CheckScaledDims();
|
||||
CheckRoundedNegRadius();
|
||||
|
||||
UiPolishFrame* frame = new UiPolishFrame();
|
||||
frame->Show(true);
|
||||
frame->CheckEnablePropagation();
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
EM_ASM({
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
// scaled-dims ConvertToImage returns physical size for scaled bitmaps
|
||||
// checkbox-floor wxCheckBox best-height floor (selection-filter density)
|
||||
// statbmp-best wxStaticBitmap best size is the bundle's LOGICAL size
|
||||
// rounded-neg-radius negative/oversize DrawRoundedRectangle radius paints,
|
||||
// never throws (findings O-1 — the badge that killed the wx tick)
|
||||
//
|
||||
// statbmp-best only discriminates at devicePixelRatio >= 1.5 (pre-fix the
|
||||
// FromPhys path inflated 16 -> 32 there), so a second pass runs the app at
|
||||
|
|
@ -33,6 +35,8 @@ const CHECKS = [
|
|||
'scaled-dims',
|
||||
'checkbox-floor',
|
||||
'statbmp-best',
|
||||
'rounded-neg-radius',
|
||||
'enable-propagation',
|
||||
];
|
||||
|
||||
async function waitForDone(page: Page, logs: string[]) {
|
||||
|
|
@ -70,6 +74,31 @@ test.describe('UI Polish regression guards', () => {
|
|||
await stableShot(page, 'uipolish-01-default-dpr.png', { fullPage: true });
|
||||
|
||||
assertChecks(logs);
|
||||
|
||||
// dom-nav-keys (findings O-2): keydown Enter / ArrowDown on the DOM
|
||||
// <input> must surface as wxEVT_CHAR_HOOK (13 = WXK_RETURN, 317 =
|
||||
// WXK_DOWN); the Skip()ing hook lets wxEVT_TEXT_ENTER fire exactly once.
|
||||
const domIdLine = logs.find((l) => l.includes('navkeys domId='));
|
||||
expect(domIdLine, 'navkeys text ctrl announced its DOM id').toBeDefined();
|
||||
const domId = Number(domIdLine!.split('domId=')[1]);
|
||||
const fired = await page.evaluate((id) => {
|
||||
const el = document.querySelector(`[data-wx-dom-id="${id}"]`) as HTMLElement | null;
|
||||
const input = (el?.tagName === 'INPUT' ? el : el?.querySelector('input')) as HTMLInputElement | null;
|
||||
if (!input) return false;
|
||||
input.focus();
|
||||
for (const key of ['Enter', 'ArrowDown']) {
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
||||
}
|
||||
return true;
|
||||
}, domId);
|
||||
expect(fired, 'navkeys <input> found').toBe(true);
|
||||
await expect.poll(() => logs.some((l) => l.includes('charhook key=13')),
|
||||
{ message: 'Enter reached wxEVT_CHAR_HOOK' }).toBe(true);
|
||||
await expect.poll(() => logs.some((l) => l.includes('charhook key=317')),
|
||||
{ message: 'ArrowDown reached wxEVT_CHAR_HOOK' }).toBe(true);
|
||||
await expect.poll(() => logs.filter((l) => l.includes('[UIPOLISH_TEST] textenter')).length,
|
||||
{ message: 'TEXT_ENTER fired once' }).toBe(1);
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
254
tests/kicad/footprint-chooser-confirm.spec.ts
Normal file
254
tests/kicad/footprint-chooser-confirm.spec.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { test, expect, type Page } from './fixtures';
|
||||
import { waitForPcbnew } from './utils/pcbnew-ready';
|
||||
import { injectFromSubmodule } from './utils/fs-inject';
|
||||
import { waitForBoardLoaded } from './utils/board-ready';
|
||||
import { PROJECT_DIR_MEMFS } from './utils/threed-viewer';
|
||||
import { clickMenuBarItem, clickMenuItemByText, waitUntil } from '../e2e/utils/element-tracker';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Repro for findings O-2 (footprint chooser reliability): type a filter in
|
||||
* the chooser's search field, press Enter, and assert the chooser CONFIRMS
|
||||
* (closes with a footprint) and that a footprint is actually placed after
|
||||
* the follow-up canvas click.
|
||||
*
|
||||
* Why Enter is expected to be dead on this port (source read, wx
|
||||
* src/wasm/app.cpp KeyCallback): while a wx-dom <input> owns browser focus,
|
||||
* every key except Escape / Cmd+S returns to the browser WITHOUT any wx
|
||||
* dispatch, so the search field never sees the wxEVT_CHAR_HOOK that
|
||||
* LIB_TREE::onQueryCharHook (common/widgets/lib_tree.cpp) binds for
|
||||
* WXK_RETURN / WXK_UP / WXK_DOWN. The only wx-side echo of Enter is
|
||||
* wxEVT_TEXT_ENTER (textctrl.cpp wxDOM_EVENT_ENTER), which LIB_TREE does not
|
||||
* bind. Same dead path for arrow-key row navigation.
|
||||
*
|
||||
* Board: the ecc83 demo, which ships a PROJECT footprint lib
|
||||
* (${KIPRJMOD}/footprints.pretty) so the chooser tree is populated in this
|
||||
* harness (the global fp-lib-table is empty).
|
||||
*/
|
||||
|
||||
const TRAP =
|
||||
/Aborted\(|index out of bounds|unreachable executed|indirect call signature|null function|memory access out of bounds/;
|
||||
|
||||
const DEMO_DIR = 'kicad/demos/ecc83';
|
||||
const STEM = 'ecc83-pp';
|
||||
const FILTER = 'ECC-83-1';
|
||||
|
||||
function frameCount(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(window.wxElementRegistry?.findAll({ visible: true }) ?? []).filter((e) =>
|
||||
/Frame$/.test(e.typeName || ''),
|
||||
).length,
|
||||
);
|
||||
}
|
||||
|
||||
function itemCount(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const m = (window as any).Module;
|
||||
try { return JSON.parse(m.kicadCollabTestListItems(100000)).length as number; }
|
||||
catch { return -1; }
|
||||
});
|
||||
}
|
||||
|
||||
async function synthClick(page: Page, x: number, y: number): Promise<void> {
|
||||
await page.evaluate(
|
||||
([cx, cy]) => {
|
||||
const c = document.querySelector('#canvas') as HTMLCanvasElement;
|
||||
const opt = (b: number) => ({
|
||||
clientX: cx, clientY: cy, bubbles: true, cancelable: true,
|
||||
view: window, button: 0, buttons: b,
|
||||
});
|
||||
c.dispatchEvent(new MouseEvent('mousemove', opt(0)));
|
||||
c.dispatchEvent(new MouseEvent('mousedown', opt(1)));
|
||||
c.dispatchEvent(new MouseEvent('mouseup', opt(0)));
|
||||
c.dispatchEvent(new MouseEvent('click', opt(0)));
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
/** Visible wx-dom text inputs with viewport boxes (chooser search field is one). */
|
||||
function visibleTextInputs(page: Page) {
|
||||
return page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('input.wx-dom-control'))
|
||||
.map((i) => i as HTMLInputElement)
|
||||
.filter((i) => i.style.display !== 'none' && i.getBoundingClientRect().width > 0)
|
||||
.map((i) => {
|
||||
const r = i.getBoundingClientRect();
|
||||
return { value: i.value, type: i.type, focused: document.activeElement === i,
|
||||
x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadEcc83(page: Page, testLogger: { consoleLogs: string[]; errors: string[] }) {
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const pretty = path.join(root, DEMO_DIR, 'footprints.pretty');
|
||||
for (const f of fs.readdirSync(pretty)) {
|
||||
await injectFromSubmodule(page, `${DEMO_DIR}/footprints.pretty/${f}`,
|
||||
`${PROJECT_DIR_MEMFS}/footprints.pretty/${f}`);
|
||||
}
|
||||
for (const f of ['fp-lib-table', `${STEM}.kicad_pcb`, `${STEM}.kicad_pro`]) {
|
||||
await injectFromSubmodule(page, `${DEMO_DIR}/${f}`, `${PROJECT_DIR_MEMFS}/${f}`);
|
||||
}
|
||||
|
||||
expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true);
|
||||
await clickMenuItemByText(page, 'Open');
|
||||
await page.waitForFunction(() =>
|
||||
!!window.wxElementRegistry && window.wxElementRegistry.findAll({ visible: true })
|
||||
.some((el) => el.typeName === 'wxFileDialog'), null, { timeout: 15000 });
|
||||
await waitUntil(page, () => {
|
||||
const r = window.wxElementRegistry;
|
||||
return !!r && r.findAll({ visible: true }).some((el) => el.typeName === 'wxTextCtrl' && el.name === 'text');
|
||||
}, 'file dialog filename input');
|
||||
const input = await page.evaluate(() => {
|
||||
const t = window.wxElementRegistry!.findAll({ visible: true })
|
||||
.find((el) => el.typeName === 'wxTextCtrl' && el.name === 'text');
|
||||
return t ? { x: t.centerX, y: t.centerY } : null;
|
||||
});
|
||||
if (!input) throw new Error('filename input not found');
|
||||
await page.mouse.click(input.x, input.y);
|
||||
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
|
||||
await page.keyboard.type(`${STEM}.kicad_pcb`);
|
||||
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
|
||||
await page.keyboard.press('Enter');
|
||||
const result = await waitForBoardLoaded(page, testLogger, 60000);
|
||||
console.log(`[TEST] ecc83 board-ready result: ${result}`);
|
||||
}
|
||||
|
||||
test.describe('Footprint chooser confirm (O-2 repro)', () => {
|
||||
test('typed filter + Enter confirms the chooser and a footprint gets placed', async ({
|
||||
page, testLogger,
|
||||
}) => {
|
||||
test.setTimeout(240000);
|
||||
await page.goto('/kicad/pcbnew.html');
|
||||
await waitForPcbnew(page);
|
||||
await loadEcc83(page, testLogger);
|
||||
|
||||
const itemsBefore = await itemCount(page);
|
||||
const framesBefore = await frameCount(page);
|
||||
console.log(`[PROBE] items before=${itemsBefore} frames=${framesBefore}`);
|
||||
|
||||
expect(await clickMenuBarItem(page, 'Place'), 'Place menu findable').toBe(true);
|
||||
await clickMenuItemByText(page, 'Place Footprints');
|
||||
const canvas = await page.locator('#canvas').boundingBox();
|
||||
if (!canvas) throw new Error('canvas not found');
|
||||
const target = { x: Math.round(canvas.width * 0.5), y: Math.round(canvas.height * 0.5) };
|
||||
await synthClick(page, target.x, target.y);
|
||||
|
||||
await page.waitForFunction((n) =>
|
||||
(window.wxElementRegistry?.findAll({ visible: true }) ?? []).filter((e) =>
|
||||
/Frame$/.test(e.typeName || '')).length > n, framesBefore, { timeout: 60000 })
|
||||
// best-effort wait: the frame-count expect right below is the
|
||||
// real assertion; a timeout must reach it, not throw (documented)
|
||||
.catch(() => {});
|
||||
const framesOpen = await frameCount(page);
|
||||
console.log(`[PROBE] frames after Place click: ${framesOpen}`);
|
||||
expect(framesOpen, 'footprint chooser frame opened').toBeGreaterThan(framesBefore);
|
||||
|
||||
// The chooser's search field: a wx-dom <input>. Wait for it to be projected.
|
||||
await expect.poll(async () => (await visibleTextInputs(page)).length,
|
||||
{ timeout: 30000, message: 'chooser search input projected' }).toBeGreaterThan(0);
|
||||
const inputs = await visibleTextInputs(page);
|
||||
console.log(`[PROBE] inputs: ${JSON.stringify(inputs)}`);
|
||||
// Topmost visible input (the search field sits at the top of the chooser).
|
||||
const search = inputs.reduce((a, b) => (b.y < a.y ? b : a));
|
||||
await page.mouse.click(search.x + search.w / 2, search.y + search.h / 2);
|
||||
await page.waitForTimeout(200); // eslint-disable-line -- focus dwell
|
||||
await page.keyboard.type(FILTER, { delay: 30 });
|
||||
await expect.poll(async () =>
|
||||
(await visibleTextInputs(page)).some((i) => i.value.includes(FILTER)),
|
||||
{ timeout: 10000, message: 'filter text landed in the search input' }).toBe(true);
|
||||
// Let the filter/re-select settle (LIB_TREE filters on a timer).
|
||||
await page.waitForTimeout(1500); // eslint-disable-line -- filter timer dwell
|
||||
|
||||
// ── Step 1: Enter in the search field should confirm the selection ──
|
||||
await page.keyboard.press('Enter');
|
||||
const closedByEnter = await page.waitForFunction((n) =>
|
||||
(window.wxElementRegistry?.findAll({ visible: true }) ?? []).filter((e) =>
|
||||
/Frame$/.test(e.typeName || '')).length <= n, framesBefore, { timeout: 8000 })
|
||||
.then(() => true).catch(() => false);
|
||||
console.log(`[PROBE] chooser closed by Enter: ${closedByEnter}`);
|
||||
|
||||
// ── Step 2a (diagnostic): single-click the already-selected match row, the
|
||||
// way a user would before hitting OK (ledger: "clicking the result row
|
||||
// deselects what typing already selected"). A deselect here makes OK take
|
||||
// the DismissModal(false) branch — chooser closes, nothing placed.
|
||||
if (!closedByEnter) {
|
||||
const tree = await page.evaluate(() => {
|
||||
const t = (window.wxElementRegistry?.findAll({ visible: true }) ?? [])
|
||||
.find((e) => /DataView/i.test(e.typeName || ''));
|
||||
return t ? { x: t.screenX, y: t.screenY, w: t.width, h: t.height } : null;
|
||||
});
|
||||
console.log(`[PROBE] tree: ${JSON.stringify(tree)}`);
|
||||
if (tree) {
|
||||
// Screenshot geometry: header ~18px, then the "Footprints" lib row, then the match.
|
||||
await page.mouse.click(tree.x + 80, tree.y + 45);
|
||||
await page.waitForTimeout(600); // eslint-disable-line -- selection settle
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2 (fallback, diagnostic): the OK button — the mouse path ──
|
||||
let closedByOk = false;
|
||||
if (!closedByEnter) {
|
||||
const ok = await page.evaluate(() => {
|
||||
const b = (window.wxElementRegistry?.findAll({ visible: true }) ?? []).find(
|
||||
(e) => /Button/i.test(e.typeName || '') && /^ok$/i.test((e.label || '').replace(/&/g,'').trim()),
|
||||
);
|
||||
return b ? { x: b.centerX, y: b.centerY, type: b.typeName } : null;
|
||||
});
|
||||
console.log(`[PROBE] OK button: ${JSON.stringify(ok)}`);
|
||||
if (ok) {
|
||||
await page.mouse.click(ok.x, ok.y);
|
||||
closedByOk = await page.waitForFunction((n) =>
|
||||
(window.wxElementRegistry?.findAll({ visible: true }) ?? []).filter((e) =>
|
||||
/Frame$/.test(e.typeName || '')).length <= n, framesBefore, { timeout: 8000 })
|
||||
.then(() => true).catch(() => false);
|
||||
}
|
||||
console.log(`[PROBE] chooser closed by OK click: ${closedByOk}`);
|
||||
}
|
||||
|
||||
// ── Step 2b (diagnostic): double-click the selected tree row ──
|
||||
let closedByDbl = false;
|
||||
if (!closedByEnter && !closedByOk) {
|
||||
const tree = await page.evaluate(() => {
|
||||
const t = (window.wxElementRegistry?.findAll({ visible: true }) ?? [])
|
||||
.find((e) => /DataView/i.test(e.typeName || ''));
|
||||
return t ? { x: t.screenX, y: t.screenY, w: t.width, h: t.height } : null;
|
||||
});
|
||||
console.log(`[PROBE] tree: ${JSON.stringify(tree)}`);
|
||||
if (tree) {
|
||||
// Screenshot geometry: header ~20px, "Footprints" lib row, then the match row.
|
||||
for (const dy of [44, 56, 30]) {
|
||||
await page.mouse.dblclick(tree.x + 80, tree.y + dy);
|
||||
closedByDbl = await page.waitForFunction((n) =>
|
||||
(window.wxElementRegistry?.findAll({ visible: true }) ?? []).filter((e) =>
|
||||
/Frame$/.test(e.typeName || '')).length <= n, framesBefore, { timeout: 4000 })
|
||||
.then(() => true).catch(() => false);
|
||||
console.log(`[PROBE] dblclick at dy=${dy}: closed=${closedByDbl}`);
|
||||
if (closedByDbl) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: if the chooser closed, place the footprint with a canvas click ──
|
||||
let itemsAfter = itemsBefore;
|
||||
if (closedByEnter || closedByOk || closedByDbl) {
|
||||
await page.waitForTimeout(1000); // eslint-disable-line -- footprint load dwell
|
||||
await synthClick(page, target.x + 40, target.y + 40);
|
||||
await expect.poll(() => itemCount(page), { timeout: 10000 })
|
||||
.toBeGreaterThan(itemsBefore)
|
||||
// best-effort wait: the placed-count expect at the end is the
|
||||
// real assertion; a timeout must reach it, not throw (documented)
|
||||
.catch(() => {});
|
||||
itemsAfter = await itemCount(page);
|
||||
}
|
||||
console.log(`[PROBE] items after=${itemsAfter} (before=${itemsBefore})`);
|
||||
|
||||
const traps = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => TRAP.test(l));
|
||||
expect(traps, 'no wasm trap').toEqual([]);
|
||||
expect(closedByEnter, 'O-2: Enter in the chooser search field confirms the selection').toBe(true);
|
||||
expect(itemsAfter, 'O-2: a footprint was actually placed').toBeGreaterThan(itemsBefore);
|
||||
});
|
||||
});
|
||||
231
tests/kicad/infobar-dismiss.spec.ts
Normal file
231
tests/kicad/infobar-dismiss.spec.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { test, expect, type Page } from './fixtures';
|
||||
import { waitForPcbnew } from './utils/pcbnew-ready';
|
||||
import { injectFileIntoMemfs } from './utils/fs-inject';
|
||||
import { waitForBoardLoaded } from './utils/board-ready';
|
||||
import { PROJECT_DIR_MEMFS } from './utils/threed-viewer';
|
||||
import { clickMenuBarItem, clickMenuItemByText, waitUntil } from '../e2e/utils/element-tracker';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Repro for findings O-3 (info bar close glyph / dismiss layout shift).
|
||||
*
|
||||
* Loads a board whose (version …) predates the current format so pcbnew shows
|
||||
* the "created by an older version" WX_INFOBAR (pcbnew/files.cpp), then:
|
||||
* (a) dispatches a SYNTHETIC click on #canvas at the close glyph's
|
||||
* coordinates — the way the demo-video probes drove the UI — and
|
||||
* records whether the bar dismissed (expected: no — on this port a
|
||||
* wxBitmapButton is a real wx-dom <button> layered over the canvas, so
|
||||
* a canvas-targeted event never reaches it);
|
||||
* (b) performs a REAL pointer click on the same spot and asserts the bar
|
||||
* dismisses (the product path);
|
||||
* (c) measures the GAL panel's DOM rect before/after the dismiss and logs
|
||||
* the origin shift (the anchor-invalidation half of the finding).
|
||||
*/
|
||||
|
||||
const OLD_PCB = `(kicad_pcb
|
||||
\t(version 20240108)
|
||||
\t(generator "pcbnew")
|
||||
\t(generator_version "8.0")
|
||||
\t(general
|
||||
\t\t(thickness 1.6)
|
||||
\t)
|
||||
\t(paper "A4")
|
||||
\t(layers
|
||||
\t\t(0 "F.Cu" signal)
|
||||
\t\t(31 "B.Cu" signal)
|
||||
\t\t(25 "Edge.Cuts" user)
|
||||
\t)
|
||||
\t(setup)
|
||||
\t(net 0 "")
|
||||
\t(gr_line (start 100 100) (end 120 100) (stroke (width 0.1) (type default)) (layer "Edge.Cuts"))
|
||||
)
|
||||
`;
|
||||
|
||||
const STEM = 'oldboard';
|
||||
|
||||
type Rect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
function glRect(page: Page): Promise<Rect | null> {
|
||||
return page.evaluate(() => {
|
||||
const el = Array.from(document.querySelectorAll('[id^="glcanvas-"]')).find((c) => {
|
||||
const r = (c as HTMLElement).getBoundingClientRect();
|
||||
return getComputedStyle(c as HTMLElement).display !== 'none' && r.width > 0;
|
||||
}) as HTMLElement | undefined;
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
}
|
||||
|
||||
/** The infobar message text element, if the bar is up. */
|
||||
function infobarText(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const e = (window.wxElementRegistry?.findAll({ visible: true }) ?? []).find((el) =>
|
||||
/older version of KiCad/i.test(el.label || ''));
|
||||
return e ? { x: e.screenX, y: e.screenY, w: e.width, h: e.height, cx: e.centerX, cy: e.centerY,
|
||||
type: e.typeName, id: e.id, parentId: e.parentId } : null;
|
||||
});
|
||||
}
|
||||
|
||||
/** wx-dom <button>s that carry an <img> (bitmap buttons), with their viewport boxes. */
|
||||
function domImageButtons(page: Page) {
|
||||
return page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('button.wx-dom-control'))
|
||||
.filter((b) => b.querySelector('img') && (b as HTMLElement).style.display !== 'none')
|
||||
.map((b) => {
|
||||
const r = b.getBoundingClientRect();
|
||||
return { id: b.id, x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
})
|
||||
.filter((r) => r.w > 0 && r.h > 0),
|
||||
);
|
||||
}
|
||||
|
||||
async function synthCanvasClick(page: Page, x: number, y: number): Promise<void> {
|
||||
await page.evaluate(([cx, cy]) => {
|
||||
const c = document.querySelector('#canvas') as HTMLCanvasElement;
|
||||
const opt = (b: number) => ({ clientX: cx, clientY: cy, bubbles: true, cancelable: true,
|
||||
view: window, button: 0, buttons: b });
|
||||
c.dispatchEvent(new MouseEvent('mousemove', opt(0)));
|
||||
c.dispatchEvent(new MouseEvent('mousedown', opt(1)));
|
||||
c.dispatchEvent(new MouseEvent('mouseup', opt(0)));
|
||||
c.dispatchEvent(new MouseEvent('click', opt(0)));
|
||||
}, [x, y]);
|
||||
}
|
||||
|
||||
async function loadOldBoard(page: Page, testLogger: { consoleLogs: string[]; errors: string[] }) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'o3-'));
|
||||
const host = path.join(tmp, `${STEM}.kicad_pcb`);
|
||||
fs.writeFileSync(host, OLD_PCB);
|
||||
await injectFileIntoMemfs(page, host, `${PROJECT_DIR_MEMFS}/${STEM}.kicad_pcb`);
|
||||
|
||||
expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true);
|
||||
await clickMenuItemByText(page, 'Open');
|
||||
await page.waitForFunction(() =>
|
||||
!!window.wxElementRegistry && window.wxElementRegistry.findAll({ visible: true })
|
||||
.some((el) => el.typeName === 'wxFileDialog'), null, { timeout: 15000 });
|
||||
await waitUntil(page, () => {
|
||||
const r = window.wxElementRegistry;
|
||||
return !!r && r.findAll({ visible: true }).some((el) => el.typeName === 'wxTextCtrl' && el.name === 'text');
|
||||
}, 'file dialog filename input');
|
||||
const input = await page.evaluate(() => {
|
||||
const t = window.wxElementRegistry!.findAll({ visible: true })
|
||||
.find((el) => el.typeName === 'wxTextCtrl' && el.name === 'text');
|
||||
return t ? { x: t.centerX, y: t.centerY } : null;
|
||||
});
|
||||
if (!input) throw new Error('filename input not found');
|
||||
await page.mouse.click(input.x, input.y);
|
||||
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
|
||||
await page.keyboard.type(`${STEM}.kicad_pcb`);
|
||||
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
|
||||
await page.keyboard.press('Enter');
|
||||
const result = await waitForBoardLoaded(page, testLogger, 60000);
|
||||
console.log(`[TEST] old board-ready result: ${result}`);
|
||||
}
|
||||
|
||||
test.describe('Info bar dismiss (O-3 repro)', () => {
|
||||
test('older-version infobar: close glyph dismisses on a real click; layout shift measured', async ({
|
||||
page, testLogger,
|
||||
}) => {
|
||||
test.setTimeout(240000);
|
||||
await page.goto('/kicad/pcbnew.html');
|
||||
await waitForPcbnew(page);
|
||||
await loadOldBoard(page, testLogger);
|
||||
|
||||
await expect.poll(() => infobarText(page), { timeout: 30000, message: 'older-version infobar shown' })
|
||||
.not.toBeNull();
|
||||
const text = (await infobarText(page))!;
|
||||
console.log(`[PROBE] infobar text: ${JSON.stringify(text)}`);
|
||||
const rectBefore = await glRect(page);
|
||||
console.log(`[PROBE] gl rect with infobar: ${JSON.stringify(rectBefore)}`);
|
||||
|
||||
// The close glyph: the wx-dom image <button> on the infobar's row (same y band as the text).
|
||||
await expect.poll(async () => (await domImageButtons(page)).length, { timeout: 15000 }).toBeGreaterThan(0);
|
||||
const buttons = await domImageButtons(page);
|
||||
console.log(`[PROBE] image buttons: ${JSON.stringify(buttons)}`);
|
||||
const onRow = buttons.filter((b) => b.y + b.h > text.y && b.y < text.y + text.h);
|
||||
const close = onRow.reduce((a, b) => (a && a.x > b.x ? a : b), onRow[0]);
|
||||
expect(close, 'infobar close <button> found on the message row').toBeTruthy();
|
||||
const cx = close.x + close.w / 2;
|
||||
const cy = close.y + close.h / 2;
|
||||
|
||||
// (a) synthetic canvas-dispatched click at the glyph — the probe-era driver.
|
||||
await synthCanvasClick(page, cx, cy);
|
||||
const gone1 = await page.waitForFunction(() =>
|
||||
!(window.wxElementRegistry?.findAll({ visible: true }) ?? []).some((el) =>
|
||||
/older version of KiCad/i.test(el.label || '')), null, { timeout: 3000 })
|
||||
.then(() => true).catch(() => false);
|
||||
console.log(`[PROBE] dismissed by synthetic #canvas click: ${gone1}`);
|
||||
|
||||
// (b) real pointer click at the same spot — the product path.
|
||||
let gone2 = gone1;
|
||||
if (!gone1) {
|
||||
await page.mouse.click(cx, cy);
|
||||
gone2 = await page.waitForFunction(() =>
|
||||
!(window.wxElementRegistry?.findAll({ visible: true }) ?? []).some((el) =>
|
||||
/older version of KiCad/i.test(el.label || '')), null, { timeout: 8000 })
|
||||
.then(() => true).catch(() => false);
|
||||
console.log(`[PROBE] dismissed by real pointer click: ${gone2}`);
|
||||
}
|
||||
|
||||
const realClickDismissed = gone2;
|
||||
|
||||
// Diagnostics for the real-click failure: what wx and the DOM think the button is.
|
||||
// (Findings 8/28: the wx-dom <button> is `disabled` while wx reports enabled — it was
|
||||
// created while the frame was under the load-time wxWindowDisabler and the DOM enabled
|
||||
// state is only pushed at creation / own DoEnable, never on an ancestor re-enable.)
|
||||
if (!gone2) {
|
||||
const diag = await page.evaluate(([bx, by]) => {
|
||||
const reg = (window.wxElementRegistry?.findAll({}) ?? []).filter((e) =>
|
||||
/Button/i.test(e.typeName || '') && Math.abs(e.centerY - by) < 20 && Math.abs(e.centerX - bx) < 80)
|
||||
.map((e) => ({ type: e.typeName, label: e.label, name: e.name, id: e.id, visible: e.visible,
|
||||
enabled: e.enabled, x: e.screenX, y: e.screenY, w: e.width, h: e.height }));
|
||||
const el = document.elementFromPoint(bx, by) as HTMLElement | null;
|
||||
const btn = el?.closest('button') as HTMLButtonElement | null;
|
||||
return { reg, hit: el ? el.tagName + '#' + el.id + '.' + el.className : null,
|
||||
btn: btn ? { id: btn.id, disabled: btn.disabled, dataset: { ...btn.dataset },
|
||||
html: btn.outerHTML.slice(0, 300) } : null };
|
||||
}, [cx, cy]);
|
||||
console.log(`[PROBE] diag: ${JSON.stringify(diag)}`);
|
||||
const logsBefore = testLogger.consoleLogs.length;
|
||||
// Direct DOM click on the element under the pointer.
|
||||
await page.evaluate(([bx, by]) => {
|
||||
const el = document.elementFromPoint(bx, by) as HTMLElement | null;
|
||||
(el?.closest('button') as HTMLElement | null)?.click();
|
||||
}, [cx, cy]);
|
||||
const gone3 = await page.waitForFunction(() =>
|
||||
!(window.wxElementRegistry?.findAll({ visible: true }) ?? []).some((el) =>
|
||||
/older version of KiCad/i.test(el.label || '')), null, { timeout: 5000 })
|
||||
.then(() => true).catch(() => false);
|
||||
console.log(`[PROBE] dismissed by element.click(): ${gone3}`);
|
||||
// Direct wx_dom_event(domId, CLICK=1) — bypasses the DOM listener entirely.
|
||||
if (!gone3 && diag.btn?.dataset?.wxDomId) {
|
||||
await page.evaluate((domId) => {
|
||||
(window as any).Module.ccall('wx_dom_event', null, ['number', 'number'], [Number(domId), 1]);
|
||||
}, diag.btn.dataset.wxDomId);
|
||||
const gone4 = await page.waitForFunction(() =>
|
||||
!(window.wxElementRegistry?.findAll({ visible: true }) ?? []).some((el) =>
|
||||
/older version of KiCad/i.test(el.label || '')), null, { timeout: 5000 })
|
||||
.then(() => true).catch(() => false);
|
||||
console.log(`[PROBE] dismissed by direct wx_dom_event: ${gone4}`);
|
||||
gone2 = gone4;
|
||||
} else {
|
||||
gone2 = gone3;
|
||||
}
|
||||
const newLogs = testLogger.consoleLogs.slice(logsBefore).filter((l) => /wx|error|dom/i.test(l));
|
||||
console.log(`[PROBE] console since click: ${JSON.stringify(newLogs.slice(0, 20))}`);
|
||||
}
|
||||
|
||||
// (c) layout shift.
|
||||
await page.waitForTimeout(500); // eslint-disable-line -- relayout dwell after dismiss
|
||||
const rectAfter = await glRect(page);
|
||||
console.log(`[PROBE] gl rect after dismiss: ${JSON.stringify(rectAfter)}`);
|
||||
if (rectBefore && rectAfter) {
|
||||
console.log(`[PROBE] GAL origin shift: dx=${rectAfter.x - rectBefore.x} dy=${rectAfter.y - rectBefore.y} ` +
|
||||
`dh=${rectAfter.h - rectBefore.h}`);
|
||||
}
|
||||
|
||||
expect(realClickDismissed, 'O-3: a real click on the close glyph dismisses the infobar').toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit d32535fefb0b3cc5858ac1f55d57739e9ace3c7e
|
||||
Subproject commit 15e53152442a93340779ca5c4733976646ef6009
|
||||
Loading…
Reference in a new issue