wx wasm: grid cell editors typeable (wxwidgets → d32535fefb) + gating e2e

tests/kicad/grid-editors-typing.spec.ts: Symbol Properties fields grid —
types into the canvas-drawn STC Value editor and the wxComboCtrl-based
Footprint editor, commits via OK, saves and reads both values back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKyjp6N4BeYb1qZVJemPFN
This commit is contained in:
Gergő Törcsvári 2026-08-27 14:42:37 +02:00
commit c80622a860
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
2 changed files with 333 additions and 1 deletions

View file

@ -0,0 +1,332 @@
/**
* Grid cell editors in a wasm dialog must be typeable the Symbol Properties
* fields grid as the specimen (fields_grid_table.cpp mixes all three editor
* kinds):
*
* - Value GRID_CELL_STC_EDITOR: canvas-drawn wxStyledTextCtrl. Text
* only arrives as wxEVT_CHAR, which this port derives from the
* browser 'keypress'. The canvas keydown callback is a plain
* (non-promising) entry, so its wx job is deferred and the
* callback used to answer preventDefault=true by default
* cancelling keypress and every typed character (app.cpp
* KeyCallback / evtloop.cpp wxWasmRunOnDispatchContext).
* - Footprint GRID_CELL_FPID_EDITOR on wxComboCtrl: its inner <input>
* stayed at the 10px creation size because
* wxComboCtrlBase::PositionTextCtrl bails when
* GetHandle() is NULL, which the wasm port hard-coded.
*
* Both are asserted end-to-end: type, commit, OK, save, read the file back.
*/
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
const SCH = `(kicad_sch
\t(version 20231120)
\t(generator "eeschema")
\t(uuid "cccc0000-0000-0000-0000-000000000011")
\t(paper "A4")
\t(lib_symbols
\t\t(symbol "Device:R"
\t\t\t(pin_numbers (hide yes))
\t\t\t(pin_names (offset 0))
\t\t\t(exclude_from_sim no) (in_bom yes) (on_board yes)
\t\t\t(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
\t\t\t(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
\t\t\t(symbol "R_0_1"
\t\t\t\t(rectangle (start -1.016 -2.54) (end 1.016 2.54)
\t\t\t\t\t(stroke (width 0.254) (type default)) (fill (type none)))
\t\t\t)
\t\t\t(symbol "R_1_1"
\t\t\t\t(pin passive line (at 0 3.81 270) (length 1.27)
\t\t\t\t\t(name "~" (effects (font (size 1.27 1.27))))
\t\t\t\t\t(number "1" (effects (font (size 1.27 1.27)))))
\t\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27)
\t\t\t\t\t(name "~" (effects (font (size 1.27 1.27))))
\t\t\t\t\t(number "2" (effects (font (size 1.27 1.27)))))
\t\t\t)
\t\t)
\t)
\t(symbol
\t\t(lib_id "Device:R")
\t\t(at 100 100 0)
\t\t(unit 1)
\t\t(exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no)
\t\t(uuid "dddd0000-0000-0000-0000-000000000012")
\t\t(property "Reference" "R1" (at 102 98 0) (effects (font (size 1.27 1.27)) (justify left)))
\t\t(property "Value" "10k" (at 102 101 0) (effects (font (size 1.27 1.27)) (justify left)))
\t\t(instances (project "grid-editors" (path "/cccc0000-0000-0000-0000-000000000011" (reference "R1") (unit 1))))
\t)
)`;
const SYMBOL_UUID = "dddd0000-0000-0000-0000-000000000012";
const DIR = "/home/kicad/documents";
const FILE = `${DIR}/grid-editors.kicad_sch`;
type Mod = {
kicadOpenFile(p: string): unknown;
kicadOpenFileBusy(): boolean;
kicadCollabGetPos(id: string): string;
kicadCollabGetViewport(): string;
kicadSaveSchematic(p: string): unknown;
};
type FS = {
mkdirTree(p: string): void;
writeFile(p: string, d: string): void;
readFile(p: string, o: { encoding: "utf8" }): string;
};
async function bootAndOpen(page: Page): Promise<void> {
await page.goto("/kicad/eeschema.html");
await expect(page.locator("#canvas")).toBeVisible({ timeout: 120000 });
await page.waitForFunction(
() => {
const m = (window as unknown as { Module?: Partial<Mod> }).Module;
return (
typeof m?.kicadOpenFile === "function" &&
typeof m?.kicadCollabGetPos === "function" &&
typeof m?.kicadSaveSchematic === "function"
);
},
null,
{ timeout: 120000 },
);
await page.waitForFunction(
() =>
!!window.wxElementRegistry &&
window.wxElementRegistry
.findAll({ visible: true })
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
null,
{ timeout: 120000 },
);
await page.evaluate(
({ sch, dir, file }) => {
const w = window as unknown as { FS: FS; Module: Mod };
try {
w.FS.mkdirTree(dir);
} catch {
/* exists */
}
w.FS.writeFile(file, sch);
w.Module.kicadOpenFile(file);
},
{ sch: SCH, dir: DIR, file: FILE },
);
await expect
.poll(() => page.evaluate(() => (window.Module as unknown as Mod).kicadOpenFileBusy()), {
timeout: 120000,
intervals: [250],
})
.toBe(false);
await expect
.poll(
() =>
page.evaluate(
(id) => (window.Module as unknown as Mod).kicadCollabGetPos(id),
SYMBOL_UUID,
),
{ timeout: 30000, intervals: [250] },
)
.toMatch(/^-?[\d.]+,-?[\d.]+$/);
}
/** Screen-space centre of the fixture symbol (viewport math as
* quasimodal-strand.spec.ts). */
async function symbolScreenPos(page: Page): Promise<{ x: number; y: number }> {
const glId = await page.evaluate(() => {
const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
.map((c) => c as HTMLCanvasElement)
.find(
(c) =>
window.getComputedStyle(c).display !== "none" &&
c.getBoundingClientRect().width > 0,
);
return visible?.id ?? null;
});
expect(glId, "a visible GAL canvas (glcanvas-*)").toBeTruthy();
const box = await page.locator(`#${glId}`).boundingBox();
expect(box, "canvas bounding box").not.toBeNull();
const { vp, pos } = await page.evaluate((id) => {
const m = window.Module as unknown as Mod;
return {
vp: JSON.parse(m.kicadCollabGetViewport()) as {
cx: number;
cy: number;
scale: number;
w: number;
h: number;
},
pos: m.kicadCollabGetPos(id),
};
}, SYMBOL_UUID);
const [wx, wy] = pos.split(",").map(Number);
return {
x: box!.x + (wx - vp.cx) * vp.scale + vp.w / 2,
y: box!.y + (wy - vp.cy) * vp.scale + vp.h / 2,
};
}
const dialogCount = (page: Page) =>
page.evaluate(
() =>
window.wxElementRegistry
.findAll({ visible: true })
.filter((e) => /Dialog/i.test(e.typeName) && e.typeName !== "wxFileDialog").length,
);
type Box = { x: number; y: number; w: number; h: number };
/** Visible wx-dom text inputs (the combo/text grid editors and the
* "Library link" field), with their viewport boxes. */
const visibleTextInputs = (page: Page) =>
page.evaluate(() =>
Array.from(document.querySelectorAll("input.wx-dom-control[type=text]"))
.map((i) => i as HTMLInputElement)
.filter((i) => i.style.display !== "none")
.map((i) => {
const r = i.getBoundingClientRect();
return {
value: i.value,
focused: document.activeElement === i,
box: { x: r.x, y: r.y, w: r.width, h: r.height } as Box,
};
}),
);
/** The fields grid opens with the Reference cell's text editor already up
* (row 0, "Value" column) the one geometry anchor the page exposes for
* the grid's cell layout: same column for every row, and the editor box is
* the cell box (wxGridCellTextEditor::SetSize expands it by 2px each side,
* the cell pitch adds the 1px grid line: pitch = editor height + 1). */
async function valueColumnAnchor(page: Page): Promise<{ cx: number; row0cy: number; pitch: number }> {
let anchor: Box | undefined;
await expect
.poll(
async () => {
const inputs = await visibleTextInputs(page);
anchor = inputs.find((i) => i.value === "R1")?.box;
return anchor ? anchor.w : 0;
},
{ timeout: 20000, intervals: [250] },
)
.toBeGreaterThan(100);
return {
cx: anchor!.x + anchor!.w / 2,
row0cy: anchor!.y + anchor!.h / 2,
pitch: anchor!.h + 1,
};
}
async function clickOk(page: Page): Promise<void> {
const rect = await page.evaluate(() => {
const btn = Array.from(document.querySelectorAll("#window-container button")).find(
(b) => (b.textContent ?? "").trim() === "OK",
);
if (!btn) return null;
const r = (btn as HTMLElement).getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2, w: r.width };
});
expect(rect, "the dialog has an OK button").not.toBeNull();
expect(rect!.w, "OK button has extent").toBeGreaterThan(0);
await page.mouse.click(rect!.x, rect!.y);
await expect.poll(() => dialogCount(page), { timeout: 20000, intervals: [250] }).toBe(0);
}
async function savedProperty(page: Page, name: string): Promise<string | null> {
const text = await page.evaluate((file) => {
const w = window as unknown as { FS: FS; Module: Mod };
w.Module.kicadSaveSchematic(file);
return w.FS.readFile(file, { encoding: "utf8" });
}, FILE);
// The symbol instance's property (the lib_symbols template carries the same
// names — take the LAST occurrence, the instance is serialized after
// lib_symbols).
const re = new RegExp(`\\(property "${name}" "([^"]*)"`, "g");
let last: string | null = null;
for (const m of text.matchAll(re)) last = m[1];
return last;
}
test.describe("grid cell editors are typeable (Symbol Properties)", () => {
test("STC editor (Value) receives typed characters; combo editor (Footprint) is full-width", async ({
page,
}) => {
test.setTimeout(240000);
await bootAndOpen(page);
const { x, y } = await symbolScreenPos(page);
await page.mouse.dblclick(x, y);
await expect.poll(() => dialogCount(page), { timeout: 20000, intervals: [250] }).toBeGreaterThan(0);
const { cx, row0cy, pitch } = await valueColumnAnchor(page);
const valueRowY = row0cy + pitch; // row 1: Value
const footprintRowY = row0cy + 2 * pitch; // row 2: Footprint
// --- Value: canvas-drawn wxStyledTextCtrl editor -----------------------
await page.mouse.dblclick(cx, valueRowY);
await expect
.poll(
() =>
page.evaluate(() =>
window.wxElementRegistry
.findAll({ visible: true })
.filter((e) => e.typeName === "wxStyledTextCtrl").length,
),
{ timeout: 20000, intervals: [250] },
)
.toBeGreaterThan(0);
// BeginEdit selects the whole cell text, but the double-click's trailing
// mouse-up lands in the freshly shown editor and collapses that selection
// to a caret — select explicitly (SCINTILLA_TRICKS handles Ctrl+A).
await page.keyboard.press("Control+a");
await page.keyboard.type("47k");
await page.keyboard.press("Enter"); // GRID_CELL_STC_EDITOR single-line accept
await expect
.poll(
() =>
page.evaluate(() =>
window.wxElementRegistry
.findAll({ visible: true })
.filter((e) => e.typeName === "wxStyledTextCtrl").length,
),
{ timeout: 20000, intervals: [250] },
)
.toBe(0);
// --- Footprint: wxComboCtrl-based editor --------------------------------
await page.mouse.dblclick(cx, footprintRowY);
let combo: { value: string; focused: boolean; box: Box } | undefined;
await expect
.poll(
async () => {
const inputs = await visibleTextInputs(page);
combo = inputs.find((i) => Math.abs(i.box.y + i.box.h / 2 - footprintRowY) < pitch / 2);
return combo ? 1 : 0;
},
{ timeout: 20000, intervals: [250] },
)
.toBe(1);
// The bug: the inner text ctrl stayed at its 10px creation width.
expect(combo!.box.w, "combo editor text ctrl spans the cell").toBeGreaterThan(100);
expect(combo!.focused, "combo editor text ctrl took focus").toBe(true);
// Per-key delay: Firefox reorders characters typed at full speed into a
// wx-dom <input> whose wx side echoes its value back (combo text ctrl) —
// a separate wx-dom echo/ordering issue, not what this spec gates.
await page.keyboard.type("Resistor_SMD:R_0805", { delay: 40 });
await expect
.poll(async () => (await visibleTextInputs(page)).find((i) => i.focused)?.value ?? "", {
timeout: 20000,
intervals: [250],
})
.toBe("Resistor_SMD:R_0805");
// OK commits the still-open cell editor (CommitPendingChanges).
await clickOk(page);
expect(await savedProperty(page, "Value"), "Value typed through the STC editor").toBe("47k");
expect(await savedProperty(page, "Footprint"), "Footprint typed through the combo editor").toBe(
"Resistor_SMD:R_0805",
);
});
});