diff --git a/tests/kicad/grid-editors-typing.spec.ts b/tests/kicad/grid-editors-typing.spec.ts new file mode 100644 index 0000000..c8cf3be --- /dev/null +++ b/tests/kicad/grid-editors-typing.spec.ts @@ -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 + * 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 { + 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 }).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 { + 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 { + 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 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", + ); + }); +}); diff --git a/wxwidgets b/wxwidgets index fa46f18..d32535f 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit fa46f1822c24e36ba02b73c35b0b0520df9dab9e +Subproject commit d32535fefb0b3cc5858ac1f55d57739e9ace3c7e