diff --git a/tests/apps/Makefile.wasm b/tests/apps/Makefile.wasm index 2fe3ebc..3ba1aa2 100644 --- a/tests/apps/Makefile.wasm +++ b/tests/apps/Makefile.wasm @@ -193,6 +193,7 @@ all: minimal_test.html \ $(S)/maximize/maximize_test.html \ $(S)/earlysize/earlysize_test.html \ $(S)/selectheight/selectheight_test.html \ + $(S)/uipolish/uipolish_test.html \ $(S)/threadpool/threadpool_test.html \ $(S)/logerror/logerror_test.html \ $(S)/retinascale/retinascale_test.html \ @@ -815,6 +816,16 @@ selectheight: $(S)/selectheight/selectheight_test.html threadpool: $(S)/threadpool/threadpool_test.html logerror: $(S)/logerror/logerror_test.html +# UI polish regression guards (wasm-ui-polish: DC clip box, blit origin, +# mask alpha, checkbox/statbmp best size) +$(S)/uipolish/uipolish_test.o: $(S)/uipolish/uipolish_test.cpp + $(CXX) -c $(CXXFLAGS) $< -o $@ + +$(S)/uipolish/uipolish_test.html: $(S)/uipolish/uipolish_test.o $(WX_CORE_LIB) $(JS_FILES) + $(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@ + +uipolish: $(S)/uipolish/uipolish_test.html + # Retina/HiDPI scaling test $(S)/retinascale/retinascale_test.o: $(S)/retinascale/retinascale_test.cpp $(CXX) -c $(CXXFLAGS) $< -o $@ @@ -835,7 +846,7 @@ clean: rm -f $(S)/*/*_test*.html $(S)/*/*_test*.js $(S)/*/*_test*.wasm rm -f $(S)/*/*_repro*.html $(S)/*/*_repro*.js $(S)/*/*_repro*.wasm -.PHONY: all clean menu contextmenu scrollbar clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize selectheight threadpool logerror retinascale coroutine coroutine-nested asyncify-races notebook radiogroups collapse-relayout +.PHONY: all clean menu contextmenu scrollbar clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize selectheight uipolish threadpool logerror retinascale coroutine coroutine-nested asyncify-races notebook radiogroups collapse-relayout # === Coroutine pthread variant — reproduces the KiCad Asyncify-fiber x pthreads crash === # Same modal-free harness as `coroutine`, but compiled/linked with pthreads to match diff --git a/tests/apps/standalone/uipolish/uipolish_test.cpp b/tests/apps/standalone/uipolish/uipolish_test.cpp new file mode 100644 index 0000000..ee6ad2a --- /dev/null +++ b/tests/apps/standalone/uipolish/uipolish_test.cpp @@ -0,0 +1,313 @@ +// UI Polish Test - Regression guards for the wasm-ui-polish fixes +// (wxwidgets wasm-port commit 4a02a39c07). +// +// Each check is independent and logs one line: +// [UIPOLISH_TEST] : PASS +// [UIPOLISH_TEST] : FAIL +// followed by a summary line the spec polls for: +// [UIPOLISH_TEST] done checks= pass= +// +// Checks: +// clip-clear DoSetClippingRegion reaches the canvas: dc.Clear() under a +// cell clip only wipes the clip rect. Pre-fix the port read +// the legacy logical m_clipX* fields (always zero in this wx +// fork — the base stores the box in device m_devClip*), the +// JS layer expanded the "empty" rect to the full context, and +// Clear() wiped everything (the collapsed wire-properties- +// panel bug: KiCad's color-swatch cell renderer Clear()s +// trusting the cell clip). +// clip-empty A clip fully outside the DC is EMPTY: nothing paints. +// Pre-fix the JS empty→full fallback made it paint all. +// clip-box GetClippingBox round-trips the rect just set. +// blit-origin Blit honors the source DC's device origin (the +// wxBufferedDC::UnMask contract: source position is +// -GetDeviceOrigin()). Pre-fix raw logical coords were +// passed to the canvas and the copy came from the wrong +// rows (displaced propgrid content). +// mask-alpha ConvertToImage() bakes wxMask transparency into alpha. +// Pre-fix the mask was dropped and mask-XPM art (e.g. the +// infobar close button) flattened to an opaque block. +// scaled-dims ConvertToImage() of a scale-factor-2 bitmap returns the +// physical pixel size, not a logical-sized top-left crop. +// checkbox-floor wxCheckBox::DoGetBestSize() floors the height to +// GetCharHeight()+8 (same guard as the wxChoice fix, see +// selectheight test) — queried before Show()/layout. +// statbmp-best wxStaticBitmap::DoGetBestSize() reports the bundle's +// LOGICAL default size. Pre-fix the base implementation's +// FromPhys path inflated it by the DPI scale (32 instead of +// 16 on a devicePixelRatio>=1.5 display), which is what made +// 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. + +#include "wx/wxprec.h" + +#ifndef WX_PRECOMP + #include "wx/wx.h" +#endif + +#include "wx/dcmemory.h" +#include "wx/bmpbndl.h" +#include "wx/statbmp.h" + +#ifdef __EMSCRIPTEN__ +#include +#endif + +static int s_checks = 0; +static int s_passed = 0; + +static void Report(const char* check, bool ok, const wxString& detail) +{ + s_checks++; + if (ok) + s_passed++; + +#ifdef __EMSCRIPTEN__ + const wxString line = ok + ? wxString::Format("[UIPOLISH_TEST] %s: PASS", check) + : wxString::Format("[UIPOLISH_TEST] %s: FAIL %s", check, detail); + EM_ASM({ + if ($1) console.log(UTF8ToString($0)); + else console.error(UTF8ToString($0)); + }, (const char*)line.utf8_str(), ok ? 1 : 0); +#else + wxLogMessage("%s: %s %s", check, ok ? "PASS" : "FAIL", detail); +#endif +} + +static bool PixelIs(const wxImage& img, int x, int y, + unsigned char r, unsigned char g, unsigned char b) +{ + return img.GetRed(x, y) == r && img.GetGreen(x, y) == g && img.GetBlue(x, y) == b; +} + +static wxString PixelStr(const wxImage& img, int x, int y) +{ + return wxString::Format("(%d,%d)=%d,%d,%d", x, y, + img.GetRed(x, y), img.GetGreen(x, y), img.GetBlue(x, y)); +} + +// dc.Clear() under a clip must only wipe the clip rect. +static void CheckClipClear() +{ + wxBitmap bmp(100, 100, 24); + { + wxMemoryDC dc(bmp); + dc.SetBrush(*wxRED_BRUSH); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, 100, 100); + + dc.SetBackground(*wxWHITE_BRUSH); + dc.SetClippingRegion(10, 10, 20, 20); + dc.Clear(); + dc.DestroyClippingRegion(); + dc.SelectObject(wxNullBitmap); + } + + wxImage img = bmp.ConvertToImage(); + const bool insideWhite = PixelIs(img, 15, 15, 255, 255, 255); + const bool outsideRed1 = PixelIs(img, 5, 5, 255, 0, 0); + const bool outsideRed2 = PixelIs(img, 60, 60, 255, 0, 0); + + Report("clip-clear", insideWhite && outsideRed1 && outsideRed2, + wxString::Format("inside %s outside %s / %s", + PixelStr(img, 15, 15), PixelStr(img, 5, 5), PixelStr(img, 60, 60))); +} + +// A clip entirely outside the DC surface is empty: painting is fully clipped out. +static void CheckClipEmpty() +{ + wxBitmap bmp(100, 100, 24); + { + wxMemoryDC dc(bmp); + dc.SetBrush(*wxRED_BRUSH); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, 100, 100); + + dc.SetBackground(*wxWHITE_BRUSH); + dc.SetClippingRegion(200, 200, 10, 10); // outside the 100x100 surface + dc.Clear(); + dc.DestroyClippingRegion(); + dc.SelectObject(wxNullBitmap); + } + + wxImage img = bmp.ConvertToImage(); + const bool untouched = PixelIs(img, 5, 5, 255, 0, 0) && PixelIs(img, 95, 95, 255, 0, 0); + + Report("clip-empty", untouched, + wxString::Format("%s / %s", PixelStr(img, 5, 5), PixelStr(img, 95, 95))); +} + +// GetClippingBox must round-trip the rect just set. +static void CheckClipBox() +{ + wxBitmap bmp(100, 100, 24); + wxMemoryDC dc(bmp); + dc.SetClippingRegion(10, 20, 30, 40); + + wxRect box; + dc.GetClippingBox(box); + dc.DestroyClippingRegion(); + + const bool ok = box == wxRect(10, 20, 30, 40); + Report("clip-box", ok, + wxString::Format("got (%d,%d %dx%d)", box.x, box.y, box.width, box.height)); +} + +// Blit must honor the source DC's device origin (wxBufferedDC::UnMask passes +// -GetDeviceOrigin() as the source position). +static void CheckBlitOrigin() +{ + wxBitmap src(50, 50, 24); + wxBitmap dst(50, 25, 24); + { + wxMemoryDC sdc(src); + sdc.SetPen(*wxTRANSPARENT_PEN); + sdc.SetBrush(*wxGREEN_BRUSH); + sdc.DrawRectangle(0, 0, 50, 25); // top half green + sdc.SetBrush(*wxBLUE_BRUSH); + sdc.DrawRectangle(0, 25, 50, 25); // bottom half blue + + // Logical (0,0) now maps to physical (0,25): copying logical rows + // 0..24 must fetch the BLUE bottom half. + sdc.SetDeviceOrigin(0, 25); + + wxMemoryDC ddc(dst); + ddc.SetPen(*wxTRANSPARENT_PEN); + ddc.SetBrush(*wxBLACK_BRUSH); + ddc.DrawRectangle(0, 0, 50, 25); + + ddc.Blit(0, 0, 50, 25, &sdc, 0, 0); + + ddc.SelectObject(wxNullBitmap); + sdc.SelectObject(wxNullBitmap); + } + + wxImage img = dst.ConvertToImage(); + const bool blue = PixelIs(img, 10, 10, 0, 0, 255) && PixelIs(img, 40, 20, 0, 0, 255); + + Report("blit-origin", blue, + wxString::Format("%s / %s", PixelStr(img, 10, 10), PixelStr(img, 40, 20))); +} + +// ConvertToImage must carry wxMask transparency as alpha. +static void CheckMaskAlpha() +{ + wxImage in(8, 8); + in.SetRGB(wxRect(0, 0, 8, 8), 255, 0, 0); + in.SetRGB(0, 0, 255, 0, 255); // one magenta pixel... + in.SetMaskColour(255, 0, 255); // ...masked out + + wxBitmap bmp(in); + wxImage out = bmp.ConvertToImage(); + + const bool hasAlpha = out.HasAlpha(); + const bool maskedOut = hasAlpha && out.GetAlpha(0, 0) == 0; + const bool restOpaque = hasAlpha && out.GetAlpha(3, 3) == 255 && PixelIs(out, 3, 3, 255, 0, 0); + + Report("mask-alpha", hasAlpha && maskedOut && restOpaque, + wxString::Format("hasAlpha=%d a(0,0)=%d a(3,3)=%d %s", + hasAlpha ? 1 : 0, + hasAlpha ? out.GetAlpha(0, 0) : -1, + hasAlpha ? out.GetAlpha(3, 3) : -1, + PixelStr(out, 3, 3))); +} + +// ConvertToImage of a scale-2 bitmap must return physical pixels, not a +// logical-sized top-left crop. +static void CheckScaledDims() +{ + wxBitmap bmp; + bmp.CreateScaled(16, 16, 32, 2.0); + + wxImage img = bmp.ConvertToImage(); + const bool ok = img.GetWidth() == 32 && img.GetHeight() == 32; + + Report("scaled-dims", ok, + wxString::Format("got %dx%d", img.GetWidth(), img.GetHeight())); +} + +class UiPolishFrame : public wxFrame +{ +public: + UiPolishFrame() + : wxFrame(nullptr, wxID_ANY, "UI Polish Test", + wxDefaultPosition, wxSize(420, 260)) + { + wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL); + sizer->Add(new wxStaticText(this, wxID_ANY, + "UI Polish Test\n\n" + "Self-asserting regression guards for the wasm-ui-polish DC,\n" + "checkbox and static-bitmap fixes. Results go to the console."), + 0, wxALL, 10); + + // checkbox-floor: best size queried BEFORE Show()/layout, where the + // DOM measure can be degenerate. + wxCheckBox* cb = new wxCheckBox(this, wxID_ANY, "Guard checkbox"); + const wxSize cbBest = cb->GetBestSize(); + const int minHeight = GetCharHeight() + 8; + Report("checkbox-floor", cbBest.y >= minHeight, + wxString::Format("best=%dx%d min=%d", cbBest.x, cbBest.y, minHeight)); + sizer->Add(cb, 0, wxALL, 10); + + // statbmp-best: a 16px + 32px bundle must lay out at the LOGICAL 16px. + // Fill both resolutions so the DOM ships real content (like a + // real icon bundle would). + wxBitmap b16(16, 16, 32); + wxBitmap b32(32, 32, 32); + { + wxMemoryDC dc16(b16); + dc16.SetBackground(*wxRED_BRUSH); + dc16.Clear(); + dc16.SelectObject(wxNullBitmap); + wxMemoryDC dc32(b32); + dc32.SetBackground(*wxBLUE_BRUSH); + dc32.Clear(); + dc32.SelectObject(wxNullBitmap); + } + wxStaticBitmap* sb = new wxStaticBitmap(this, wxID_ANY, + wxBitmapBundle::FromBitmaps(b16, b32)); + const wxSize sbBest = sb->GetBestSize(); + Report("statbmp-best", sbBest == wxSize(16, 16), + wxString::Format("best=%dx%d dpiScale=%.1f", + sbBest.x, sbBest.y, GetDPIScaleFactor())); + sizer->Add(sb, 0, wxALL, 10); + + SetSizer(sizer); + } +}; + +class UiPolishApp : public wxApp +{ +public: + virtual bool OnInit() override + { + if (!wxApp::OnInit()) + return false; + + // The DOM port serves wxStaticBitmap content as a PNG data URL; + // encoding needs the PNG handler (KiCad registers it, a bare test app + // must do it itself). + wxInitAllImageHandlers(); + + CheckClipClear(); + CheckClipEmpty(); + CheckClipBox(); + CheckBlitOrigin(); + CheckMaskAlpha(); + CheckScaledDims(); + + UiPolishFrame* frame = new UiPolishFrame(); + frame->Show(true); + +#ifdef __EMSCRIPTEN__ + EM_ASM({ + console.log('[UIPOLISH_TEST] done checks=' + $0 + ' pass=' + $1); + }, s_checks, s_passed); +#endif + return true; + } +}; + +wxIMPLEMENT_APP(UiPolishApp); diff --git a/tests/e2e/uipolish.spec.ts b/tests/e2e/uipolish.spec.ts new file mode 100644 index 0000000..a0e9d17 --- /dev/null +++ b/tests/e2e/uipolish.spec.ts @@ -0,0 +1,106 @@ +// UI Polish Test - Regression guards for the wasm-ui-polish fixes +// (wxwidgets wasm-port commit 4a02a39c07). +// +// The C++ app (tests/apps/standalone/uipolish/uipolish_test.cpp) runs +// self-asserting checks at boot and logs one "[UIPOLISH_TEST] : PASS/ +// FAIL" line each plus a "done checks=N pass=N" summary: +// clip-clear / clip-empty / clip-box DC clip box reaches the canvas +// (pre-fix every clip was empty and the JS layer disabled clipping — +// the collapsed wire-properties-panel bug) +// blit-origin Blit honors the source DC device origin (wxBufferedDC) +// mask-alpha ConvertToImage carries wxMask as alpha (infobar close btn) +// 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 +// +// 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 +// deviceScaleFactor: 2 and additionally asserts the ships the 32px +// asset at 16 CSS px — the crisp-layer-eyes contract. +// +// Determinism: no waitForTimeout; readiness via waitForWxApp plus polling for +// the app's own "done" summary line. +import { test, expect, waitForWxApp } from './utils/fixtures'; +import { stableShot } from './utils/element-tracker'; +import { Page } from '@playwright/test'; + +const CHECKS = [ + 'clip-clear', + 'clip-empty', + 'clip-box', + 'blit-origin', + 'mask-alpha', + 'scaled-dims', + 'checkbox-floor', + 'statbmp-best', +]; + +async function waitForDone(page: Page, logs: string[]) { + await expect.poll( + () => logs.some((l) => l.includes('[UIPOLISH_TEST] done checks=')), + { message: 'app should log its check summary' }, + ).toBe(true); +} + +function collectUipolishLogs(page: Page): string[] { + const logs: string[] = []; + page.on('console', (msg) => { + const text = msg.text(); + if (text.includes('[UIPOLISH_TEST]')) logs.push(text); + }); + return logs; +} + +function assertChecks(logs: string[]) { + for (const check of CHECKS) { + const pass = logs.some((l) => l.includes(`[UIPOLISH_TEST] ${check}: PASS`)); + const fail = logs.find((l) => l.includes(`[UIPOLISH_TEST] ${check}: FAIL`)); + expect(fail, `${check} must not FAIL (${fail ?? ''})`).toBeUndefined(); + expect(pass, `${check} must PASS`).toBe(true); + } +} + +test.describe('UI Polish regression guards', () => { + test('all DC/bestsize checks pass at default DPR', async ({ page }) => { + const logs = collectUipolishLogs(page); + await page.goto('/standalone/uipolish/uipolish_test.html'); + await waitForWxApp(page); + await waitForDone(page, logs); + + await stableShot(page, 'uipolish-01-default-dpr.png', { fullPage: true }); + + assertChecks(logs); + }); +}); + +test.describe('UI Polish regression guards @2x', () => { + // statbmp-best is only a regression guard on a hi-DPI display: pre-fix the + // base DoGetBestSize inflated the layout box to physical pixels (32) once + // GetDPIScaleFactor() >= 1.5. + test.use({ deviceScaleFactor: 2 }); + + test('statbmp lays out at logical size and ships the 2x asset', async ({ page }) => { + const logs = collectUipolishLogs(page); + await page.goto('/standalone/uipolish/uipolish_test.html'); + await waitForWxApp(page); + await waitForDone(page, logs); + + await stableShot(page, 'uipolish-02-hidpi.png', { fullPage: true }); + + assertChecks(logs); + + // The frame's wxStaticBitmap is the only control in the app: it must + // ship the hi-res 32px asset but occupy the LOGICAL 16 CSS px. + const img = await page.evaluate(() => { + const el = Array.from(document.querySelectorAll('img.wx-dom-control')) + .map((e) => e as HTMLImageElement) + .find((e) => e.getBoundingClientRect().width > 0); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { naturalWidth: el.naturalWidth, cssWidth: Math.round(r.width) }; + }); + expect(img, 'the statbmp should exist').not.toBeNull(); + expect(img!.naturalWidth, 'should ship the 2x (32px) asset').toBe(32); + expect(img!.cssWidth, 'should occupy the logical 16 CSS px').toBe(16); + }); +}); diff --git a/wxwidgets b/wxwidgets index 4a02a39..f6774c4 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit 4a02a39c073126fc03ecf3921386489a97032e9e +Subproject commit f6774c4559c6aa17c45c1c0ce2ce9cdcdd18d46d