From 9506268c1fd673419c14ad98569021b4d0d3069e Mon Sep 17 00:00:00 2001 From: lolodawit Date: Wed, 9 Sep 2026 22:05:50 -0700 Subject: [PATCH 1/3] Test the demo in a real browser The unit tests run against a simulated DOM. It has no layout engine and no stylesheets, so it can say a node exists but never that it is visible, the right size, or a readable colour against what is behind it. Every visual bug this project has shipped was of exactly that kind, and every one reached a person before it reached a test. Eight Playwright tests against the published page: the demo replaces the clip, the tree is drawn inside its panel, every step-log line is readable against its background, the shape list fits what it holds, each shape draws the edges it claims, choosing a subgraph lights it up in the input graph, play advances the search, and nothing spills sideways at 420px wide. The difference is measurable. Reintroduce the dark-on-dark log text, or clip the shape list, and all 94 unit tests still pass while the browser tests fail. Both of those shipped. CI gains a browser job, and the Pages deploy runs them before publishing, so a demo that is broken in a browser cannot reach the site. Screenshot comparison is deliberately not used: fonts render differently on macOS and on the Linux runner, so pixel diffs would fail for reasons unrelated to the change. --- .github/workflows/ci.yml | 28 ++ .github/workflows/pages.yml | 4 + web/.gitignore | 2 + web/README.md | 27 +- web/browser/demo.spec.ts | 133 ++++++++ web/package-lock.json | 651 ++++++++++++++++++++++++++++++++++++ web/package.json | 6 +- web/playwright.config.ts | 35 ++ 8 files changed, 879 insertions(+), 7 deletions(-) create mode 100644 web/browser/demo.spec.ts create mode 100644 web/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15666e8..e4dafc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,31 @@ jobs: # one implementation and not the other fails here. - run: npm test working-directory: web + + browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/package-lock.json + + - run: npm ci + working-directory: web + - run: npx playwright install --with-deps chromium + working-directory: web + + # The demo in a real browser, against the page as it is published. This + # is the only thing here that can see layout and colour: the unit tests + # run against a simulated DOM, which has neither. + - run: npm run test:browser + working-directory: web + + - if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: web/playwright-report diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5d4d441..7dbe591 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -44,6 +44,10 @@ jobs: working-directory: web - run: npm test working-directory: web + - run: npx playwright install --with-deps chromium + working-directory: web + - run: npm run test:browser + working-directory: web - run: npm run build working-directory: web diff --git a/web/.gitignore b/web/.gitignore index b947077..f0a232b 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,2 +1,4 @@ node_modules/ dist/ +test-results/ +playwright-report/ diff --git a/web/README.md b/web/README.md index 4765b89..ceb3ba2 100644 --- a/web/README.md +++ b/web/README.md @@ -6,9 +6,10 @@ tree. The desktop app remains the reference implementation. ```bash npm install -npm test # 68 tests +npm test # 94 unit tests +npm run test:browser # 8 browser tests, real Chromium npm run typecheck -npm run build # -> dist/demo.js, dist/demo.css +npm run build # -> ../docs/demo/ ``` The built bundle is about 21 KB, 7 KB gzipped, with no runtime dependencies. @@ -33,10 +34,24 @@ implementation detail. suite: enumerate every subset of the right size, keep the connected ones, and require ESU to return exactly that set with no duplicates. -`render.test.ts` drives the demo in a real DOM and checks it draws the graph, -grows the tree, highlights the current subgraph, and builds every control. The -desktop app has no equivalent, which is how a button went missing during a -rebuild and stayed missing until someone read the README. +`render.test.ts` drives the demo in a simulated DOM and checks it draws the +graph, grows the tree, highlights the current subgraph, and builds every +control. + +`browser/` runs the published page in real Chromium, and exists for what the +simulated DOM cannot see: it has no layout engine and no stylesheets, so it can +say a node exists but never that it is visible, the right size, or a readable +colour against what is behind it. Every visual bug this project has shipped was +of that kind. + +The difference is measurable. Make the step log's text the same colour as its +background, or clip the shape list so its contents scroll out of sight, and all +94 unit tests still pass while the browser tests fail. Both of those are bugs +that actually shipped. + +Visual regression by screenshot comparison is deliberately not used: fonts +render differently on macOS and on the Linux runner, so pixel diffs would fail +for reasons that have nothing to do with the change. ## Regenerating the fixtures diff --git a/web/browser/demo.spec.ts b/web/browser/demo.spec.ts new file mode 100644 index 0000000..b38c89f --- /dev/null +++ b/web/browser/demo.spec.ts @@ -0,0 +1,133 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * The demo in a real browser. + * + * The unit tests run against a simulated DOM: no layout, no real stylesheets, + * so they can say a node exists but never that it is visible, the right size, + * or a readable colour against what is behind it. Every visual bug this + * project has shipped was of that kind. + */ + +/** Wait for the demo to replace the fallback clip. */ +async function open(page: Page): Promise { + await page.goto('/'); + await expect(page.locator('#demo-mount')).toBeVisible(); + await expect(page.locator('.demo-tree')).toBeVisible(); +} + +/** Perceived brightness, for deciding whether text can be read on a ground. */ +function luminance(colour: string): number { + const [red, green, blue] = (colour.match(/[\d.]+/g) ?? ['0', '0', '0']) + .slice(0, 3) + .map(Number) as [number, number, number]; + return (0.299 * red + 0.587 * green + 0.114 * blue) / 255; +} + +test('replaces the clip with the running demo', async ({ page }) => { + await open(page); + + await expect(page.locator('#demo-fallback')).toBeHidden(); + await expect(page.locator('.demo-graph .g-vertex').first()).toBeVisible(); +}); + +test('draws the tree inside the panel it is given', async ({ page }) => { + await open(page); + + // The tree opened part way into empty canvas once, and nothing caught it. + const panel = await page.locator('.demo-treewrap').boundingBox(); + const tree = await page.locator('.demo-tree').boundingBox(); + + expect(tree).not.toBeNull(); + expect(panel).not.toBeNull(); + expect(tree!.width).toBeGreaterThan(100); + expect(tree!.x).toBeGreaterThanOrEqual(panel!.x - 1); + expect(tree!.x + tree!.width).toBeLessThanOrEqual(panel!.x + panel!.width + 1); +}); + +test('keeps every step-log line readable against its background', async ({ page }) => { + await open(page); + await page.locator('.demo-next').click(); + + // A stylesheet once left this text dark on a dark ground, which no + // assertion about the DOM could have seen. + const log = page.locator('.demo-log'); + const background = await log.evaluate((el) => getComputedStyle(el).backgroundColor); + const lines = log.locator('li'); + await expect(lines.first()).toBeVisible(); + + for (const line of await lines.all()) { + const colour = await line.evaluate((el) => getComputedStyle(el).color); + expect(Math.abs(luminance(colour) - luminance(background)), + `text ${colour} on ${background}`).toBeGreaterThan(0.2); + } +}); + +test('fits the shapes it found without scrolling them out of sight', async ({ page }) => { + await open(page); + + // The desktop panel was sized for three shapes and cramped five. + const shapes = page.locator('.demo-shapes'); + await expect(shapes.locator('.demo-shape').first()).toBeVisible(); + + const overflow = await shapes.evaluate( + (el) => el.scrollHeight - el.clientHeight); + expect(overflow).toBeLessThanOrEqual(1); +}); + +test('draws each shape with the edges it claims', async ({ page }) => { + await open(page); + await page.locator('.demo-graphs .demo-pill', { hasText: 'Cluster' }).click(); + + const edgesOf: Record = { + path: 3, star: 3, cycle: 4, 'triangle and tail': 4, diamond: 5, clique: 6, + }; + + const chips = await page.locator('.demo-shape').all(); + expect(chips.length).toBeGreaterThan(1); + + for (const chip of chips) { + const name = ((await chip.getAttribute('title')) ?? '').split(' — ')[0]!; + await expect(chip.locator('.shape-drawing line')).toHaveCount(edgesOf[name]!); + } +}); + +test('shows a chosen subgraph in the input graph', async ({ page }) => { + await open(page); + await page.locator('.demo-graphs .demo-pill', { hasText: 'Cluster' }).click(); + await page.locator('.demo-shape').first().click(); + + const tile = page.locator('.demo-instance').first(); + await expect(tile).toBeVisible(); + const vertices = (await tile.locator('.demo-instance-label').innerText()) + .trim().split(/\s+/).length; + + await tile.click(); + + await expect(page.locator('.demo-graph .g-chosen')).toHaveCount(vertices); +}); + +test('plays through the search when told to', async ({ page }) => { + await open(page); + const status = page.locator('.demo-status'); + await page.locator('.demo-restart').click(); + await expect(status).toContainText('Step 0 of'); + + await page.locator('.demo-play').click(); + + // A real click on a real button, advancing on a real timer. + await expect(status).not.toContainText('Step 0 of', { timeout: 5000 }); + await page.locator('.demo-play').click(); + await expect(page.locator('.demo-play')).toHaveText('Play'); +}); + +test('stays usable on a narrow window', async ({ page }) => { + await page.setViewportSize({ width: 420, height: 900 }); + await open(page); + + // Nothing may spill sideways off the page. + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + await expect(page.locator('.demo-play')).toBeVisible(); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 16974b7..bb5d036 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -6,7 +6,9 @@ "": { "name": "esu-web", "devDependencies": { + "@playwright/test": "^1.63.0", "happy-dom": "^20.14.0", + "http-server": "^14.1.1", "typescript": "^5.6.3", "vite": "^5.4.10", "vitest": "^2.1.4" @@ -427,6 +429,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", @@ -924,6 +942,22 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -934,6 +968,26 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/buffer-image-size": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", @@ -957,6 +1011,37 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -974,6 +1059,23 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -984,6 +1086,36 @@ "node": ">= 16" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1012,6 +1144,21 @@ "node": ">=6" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1025,6 +1172,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1032,6 +1199,19 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1081,6 +1261,13 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1091,6 +1278,27 @@ "node": ">=12.0.0" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1106,6 +1314,68 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/happy-dom": { "version": "20.14.0", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.0.tgz", @@ -1125,6 +1395,121 @@ "node": ">=20.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1142,6 +1527,39 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1168,6 +1586,29 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -1192,6 +1633,49 @@ "dev": true, "license": "ISC" }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -1221,6 +1705,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -1267,6 +1775,103 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1298,6 +1903,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1363,6 +1981,25 @@ "dev": true, "license": "MIT" }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1512,6 +2149,20 @@ } } }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", diff --git a/web/package.json b/web/package.json index c9395a1..c95b1a1 100644 --- a/web/package.json +++ b/web/package.json @@ -7,10 +7,14 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "build": "vite build" + "build": "vite build", + "test:browser": "playwright test", + "test:all": "vitest run && playwright test" }, "devDependencies": { + "@playwright/test": "^1.63.0", "happy-dom": "^20.14.0", + "http-server": "^14.1.1", "typescript": "^5.6.3", "vite": "^5.4.10", "vitest": "^2.1.4" diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..baa0d7d --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Browser tests for the demo, against the site as it is published. + * + * These exist for what the unit tests cannot see. Those run against a + * simulated DOM with no layout engine and no real stylesheets, so a panel can + * be the wrong size, text can be invisible against its background, and the + * tree can sit off screen without a single assertion noticing. Every one of + * those has happened. + */ +export default defineConfig({ + testDir: 'browser', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? 'github' : 'list', + + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry', + }, + + // The real page, built and served exactly as it is deployed. + webServer: { + command: 'npm run build && npx http-server ../docs -p 4173 --silent', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +}); From a6bf2a0aada6291ff8e7faf3298bef4cd6ce7b56 Mon Sep 17 00:00:00 2001 From: lolodawit Date: Wed, 9 Sep 2026 22:15:10 -0700 Subject: [PATCH 2/3] Test the JavaFX views The views had no automated coverage, and every visual bug this project shipped reached a person before it reached a test: dark text on a dark ground, a search opening too small to read, shapes drawing identically, a list cramped by a fixed height, a control that quietly stopped being built. What they had in common was not a mishandled click. It was the wrong nodes being built, or built with the wrong style. FxTest starts the toolkit once and builds real controls on the JavaFX thread with the stylesheets applied, without showing a window. No UI driving framework: TestFX is the obvious candidate and has had no release since 2023, against a JavaFX now three majors further on. - TreeRendererTest: a box for the root and each node reached, only what the search has reached, results marked found, dead branches marked dead, the asked-for subgraphs picked out, and exactly one state per box. - ReadableTest: every word in a panel differs in brightness from what is painted behind it, in both appearances. Removing the dark sheet's rule for the step log fails it, which is the bug that shipped. - EsuAppTest: the window offers every control, a pill per subgraph size, the menu items, and starts with stepping disabled. Dropping the save button, dropping a pill, or leaving controls live all fail. CI runs them under xvfb: JavaFX needs a display even when no window is shown. --- .github/PULL_REQUEST_TEMPLATE.md | 8 +- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 3 +- CONTRIBUTING.md | 26 ++- README.md | 5 + .../java/esu/algorithm/ui/EsuAppTest.java | 138 ++++++++++++++++ src/test/java/esu/algorithm/ui/FxTest.java | 107 ++++++++++++ .../java/esu/algorithm/ui/ReadableTest.java | 156 ++++++++++++++++++ .../esu/algorithm/ui/TreeRendererTest.java | 154 +++++++++++++++++ 9 files changed, 591 insertions(+), 12 deletions(-) create mode 100644 src/test/java/esu/algorithm/ui/EsuAppTest.java create mode 100644 src/test/java/esu/algorithm/ui/FxTest.java create mode 100644 src/test/java/esu/algorithm/ui/ReadableTest.java create mode 100644 src/test/java/esu/algorithm/ui/TreeRendererTest.java diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3e6717d..fd8c978 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,12 +6,14 @@ - [ ] `./gradlew test` passes -- [ ] `npm test` in `web/` passes, if you touched the browser version +- [ ] `npm test` and `npm run test:browser` in `web/` pass, if you touched the + browser version - [ ] Changing one engine and not the other? The equivalence fixtures will say so — see `web/README.md` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4dafc0..52f74c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,9 @@ jobs: - uses: gradle/actions/setup-gradle@v6 - # No display is needed: the engine and the tree layout hold no JavaFX - # reference, and nothing here starts the toolkit. - - run: ./gradlew test --no-daemon + # The view tests build real controls, and JavaFX needs a display for + # that even when no window is shown. + - run: xvfb-run -a ./gradlew test --no-daemon - if: failure() uses: actions/upload-artifact@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32d0f58..9dca354 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,8 @@ jobs: cache-dependency-path: web/package-lock.json # Nothing is released that does not pass, in either implementation. - - run: ./gradlew test --no-daemon + # The view tests build real controls, which needs a display. + - run: xvfb-run -a ./gradlew test --no-daemon - run: npm ci working-directory: web - run: npm test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 270bedd..9eba924 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,12 +63,28 @@ When you add a test, check it can fail. Break the thing it covers on purpose and watch it go red. A test that passes the moment you write it has told you nothing — that is how the fixture problem above went unnoticed. -## What has no automated coverage +## Testing what is drawn -The JavaFX views. Nothing checks them, and a button once went missing in a -rebuild and stayed missing until someone read the README. If you change the -desktop UI, run it and attach a screenshot. The browser version does have view -tests, in `web/test/render.test.ts`. +Both front ends are covered, in the only ways each can be. + +The JavaFX views are built for real in `src/test/java/esu/algorithm/ui/`, on +the JavaFX thread and with the stylesheets applied, but without showing a +window. Those tests check what gets built and how it is styled: that every +control exists, that a tree box carries exactly one state, that every word can +be read against what is behind it. `FxTest` has the harness. + +The demo is checked twice. `web/test/` runs it against a simulated DOM, which +is fast but has no layout engine and no stylesheets. `web/browser/` runs the +published page in real Chromium, which is the only thing here that can see +size, position and colour. + +What none of this can tell you is whether the result looks *good*. A screenshot +still answers that, and it is worth attaching one to a pull request that +changes anything visual. + +Every visual bug this project has shipped was a code path whose only consumer +was a picture, so when you add one, break it on purpose and watch the test go +red before you trust it. ## Style diff --git a/README.md b/README.md index f93eb29..8ad767c 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,11 @@ Two other things are worth testing and are: that a step reached by replay is identical to one reached by stepping, and that the tree layout puts parents over their children without overlapping anything. +Both front ends are covered too. The JavaFX views are built for real, styled, +and inspected without showing a window. The demo is run in Chromium against the +published page, which is the only thing here that can see size, position and +colour. + ## License MIT — see [LICENSE](LICENSE). diff --git a/src/test/java/esu/algorithm/ui/EsuAppTest.java b/src/test/java/esu/algorithm/ui/EsuAppTest.java new file mode 100644 index 0000000..2eb03a9 --- /dev/null +++ b/src/test/java/esu/algorithm/ui/EsuAppTest.java @@ -0,0 +1,138 @@ +package esu.algorithm.ui; + +import java.util.ArrayList; +import java.util.List; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.ButtonBase; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuItem; +import javafx.stage.Stage; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * That the window is built with everything it is supposed to have. + * + * Saving the results once disappeared during a rebuild and stayed gone until + * someone happened to read the README: nothing referred to it, so nothing + * broke. A control that quietly stops being built is invisible to every test + * that does not look at the window itself. + */ +public class EsuAppTest extends FxTest { + + /** Build the real window, off screen, and hand back its scene. */ + private Scene window() { + return onFxThread(() -> { + Stage stage = new Stage(); + new EsuApp().start(stage); + Scene scene = stage.getScene(); + // Built and styled is all this needs; showing it is not. + scene.getRoot().applyCss(); + scene.getRoot().layout(); + stage.close(); + return scene; + }); + } + + private static void collect(Node node, List found) { + found.add(node); + if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + collect(child, found); + } + } + } + + private List buttonLabels(Scene scene) { + List all = new ArrayList<>(); + onFxThread(() -> { + collect(scene.getRoot(), all); + return null; + }); + List labels = new ArrayList<>(); + for (Node node : all) { + if (node instanceof ButtonBase button && button.getText() != null + && !button.getText().isBlank()) { + labels.add(button.getText()); + } + } + return labels; + } + + private List menuItems(Scene scene) { + List all = new ArrayList<>(); + onFxThread(() -> { + collect(scene.getRoot(), all); + return null; + }); + List items = new ArrayList<>(); + for (Node node : all) { + if (node instanceof MenuBar bar) { + for (Menu menu : bar.getMenus()) { + for (MenuItem item : menu.getItems()) { + if (item.getText() != null) { + items.add(item.getText()); + } + } + } + } + } + return items; + } + + @Test + public void offersEveryControlTheReadmeSaysItHas() { + List buttons = buttonLabels(window()); + + for (String expected : List.of("Open graph", "Random", "Save results", + "Fit", "Play")) { + assertTrue(buttons.contains(expected), + "no control labelled '" + expected + "'; found " + buttons); + } + } + + @Test + public void offersAPillForEverySubgraphSizeItSupports() { + List buttons = buttonLabels(window()); + + for (int size = EsuApp.MIN_SUBGRAPH_SIZE; + size <= EsuApp.MAX_SUBGRAPH_SIZE; size++) { + assertTrue(buttons.contains(String.valueOf(size)), + "no pill for subgraph size " + size); + } + } + + @Test + public void offersTheMenusAndTheirShortcuts() { + Scene scene = window(); + + List items = menuItems(scene); + for (String expected : List.of("Show step history", "Show shapes found", + "Play or pause", "Next step", "Previous step")) { + assertTrue(items.contains(expected), + "no menu item '" + expected + "'; found " + items); + } + } + + @Test + public void startsWithNothingToActOnDisabled() { + Scene scene = window(); + List all = new ArrayList<>(); + onFxThread(() -> { + collect(scene.getRoot(), all); + return null; + }); + + // Until a graph is open, stepping through a search makes no sense. + for (Node node : all) { + if (node instanceof ButtonBase button + && "Play".equals(button.getText())) { + assertTrue(button.isDisabled(), + "Play is live before a graph is loaded"); + } + } + } +} diff --git a/src/test/java/esu/algorithm/ui/FxTest.java b/src/test/java/esu/algorithm/ui/FxTest.java new file mode 100644 index 0000000..cb04525 --- /dev/null +++ b/src/test/java/esu/algorithm/ui/FxTest.java @@ -0,0 +1,107 @@ +package esu.algorithm.ui; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.scene.Parent; +import javafx.scene.Scene; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Class FxTest + * + * Enough of JavaFX to build real controls and look at them, without showing a + * window or pulling in a UI-driving framework. + * + * The views had no automated coverage at all, and every visual bug this + * project shipped reached a person before it reached a test. What those bugs + * had in common was not a mishandled click: it was the wrong nodes being + * built, or built with the wrong style. That is what this can see. + * + * What it cannot see is whether the result looks good. Screenshots still + * answer that. + */ +public abstract class FxTest { + + private static boolean started; + + /** Starts the toolkit once for the whole run. */ + protected static synchronized void startToolkit() { + if (started) { + return; + } + CountDownLatch ready = new CountDownLatch(1); + try { + Platform.startup(ready::countDown); + } catch (IllegalStateException alreadyRunning) { + ready.countDown(); + } + try { + assertTrue(ready.await(30, TimeUnit.SECONDS), + "the JavaFX toolkit did not start"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + fail("interrupted waiting for the toolkit"); + } + Platform.setImplicitExit(false); + started = true; + } + + /** + * Run something on the JavaFX thread and hand back what it made. + * + * @param what is being built + * @param build builds it, on the right thread + * @return the result + */ + protected static T onFxThread(Supplier build) { + startToolkit(); + AtomicReference result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + + Platform.runLater(() -> { + try { + result.set(build.get()); + } catch (Throwable thrown) { + failure.set(thrown); + } finally { + done.countDown(); + } + }); + try { + assertTrue(done.await(30, TimeUnit.SECONDS), + "nothing came back from the JavaFX thread"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + fail("interrupted waiting for the JavaFX thread"); + } + if (failure.get() != null) { + throw new AssertionError("failed on the JavaFX thread", failure.get()); + } + return result.get(); + } + + /** + * Put a control in a scene and resolve its styles, so that what the tests + * read is what a user would have seen. + * + * @param root the control under test + * @param dark true to layer the dark stylesheet over the base one + * @return the same control, styled and laid out + */ + protected static Parent style(Parent root, boolean dark) { + Scene scene = new Scene(root, 900, 700); + scene.getStylesheets().add( + FxTest.class.getResource("esu.css").toExternalForm()); + if (dark) { + scene.getStylesheets().add( + FxTest.class.getResource("dark.css").toExternalForm()); + } + root.applyCss(); + root.layout(); + return root; + } +} diff --git a/src/test/java/esu/algorithm/ui/ReadableTest.java b/src/test/java/esu/algorithm/ui/ReadableTest.java new file mode 100644 index 0000000..83c7e2c --- /dev/null +++ b/src/test/java/esu/algorithm/ui/ReadableTest.java @@ -0,0 +1,156 @@ +package esu.algorithm.ui; + +import esu.algorithm.EsuSession; +import esu.algorithm.UndirectedGraph; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import javafx.scene.text.Text; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Whether the words can be read against what is behind them. + * + * ThemeTest compares the two stylesheets as text, which catches a rule the + * dark one forgets. This asks the question the other way round, of the styles + * JavaFX actually resolves: whatever the sheets say, is this label a different + * colour from its background? + * + * The step log once rendered dark text on a dark ground for exactly the + * reason no rule caught: the class that lost its colour was on a label inside + * a cell, not on anything either sheet named directly. + */ +public class ReadableTest extends FxTest { + + /** Below this, text and ground are too close to tell apart. */ + private static final double LEAST_DIFFERENCE = 0.18; + + /** Perceived brightness, so that dark-on-dark and light-on-light both fail. */ + private static double brightness(Color colour) { + return 0.299 * colour.getRed() + + 0.587 * colour.getGreen() + + 0.114 * colour.getBlue(); + } + + /** The nearest ancestor that actually paints something. */ + private static Color groundBehind(Node node) { + for (Parent at = node.getParent(); at != null; at = at.getParent()) { + if (at instanceof Region region && region.getBackground() != null + && !region.getBackground().getFills().isEmpty()) { + var fill = region.getBackground().getFills().get(0).getFill(); + if (fill instanceof Color colour && colour.getOpacity() > 0.9) { + return colour; + } + } + } + return null; + } + + private static void collectText(Node node, List found) { + if (node instanceof Label || node instanceof Text) { + found.add(node); + } + if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + collectText(child, found); + } + } + } + + /** A panel with a search loaded, styled for one appearance. */ + private Parent shapesPanel(boolean dark) { + return onFxThread(() -> { + EsuSession session = new EsuSession( + UndirectedGraph.fromFile(new File("samples/cluster.txt")), 4); + ShapesPanel panel = new ShapesPanel(shape -> { }, vertices -> { }); + panel.setGraph(session.getGraph()); + panel.setShapes(session.getShapes()); + panel.showInstances( + session.subgraphsOfShape(session.getShapes().get(0).shape())); + return style(panel, dark); + }); + } + + @ParameterizedTest(name = "dark={0}") + @ValueSource(booleans = {false, true}) + public void everyWordStandsOutFromWhatIsBehindIt(boolean dark) { + Parent panel = shapesPanel(dark); + + List words = new ArrayList<>(); + onFxThread(() -> { + collectText(panel, words); + return null; + }); + assertFalse(words.isEmpty(), "found nothing to read"); + + for (Node word : words) { + Color ink = inkOf(word); + Color ground = groundBehind(word); + if (ink == null || ground == null) { + continue; + } + assertTrue(Math.abs(brightness(ink) - brightness(ground)) + > LEAST_DIFFERENCE, + text(word) + ": " + ink + " on " + ground); + } + } + + @ParameterizedTest(name = "dark={0}") + @ValueSource(booleans = {false, true}) + public void theStepLogCanBeRead(boolean dark) { + // The list cell's label is the thing that went wrong, so build the + // list the app builds rather than a bare label. + Parent panel = onFxThread(() -> { + ListView log = new ListView<>(); + log.getItems().add("2 is less than or equal to this branch's first step."); + Label line = new Label(log.getItems().get(0)); + line.getStyleClass().add("log-line"); + javafx.scene.layout.VBox host = new javafx.scene.layout.VBox(line); + host.getStyleClass().add("side-panel"); + return style(host, dark); + }); + + List words = new ArrayList<>(); + onFxThread(() -> { + collectText(panel, words); + return null; + }); + + for (Node word : words) { + Color ink = inkOf(word); + Color ground = groundBehind(word); + if (ink == null) { + continue; + } + assertNotNull(ground, "nothing paints behind the log line"); + assertTrue(Math.abs(brightness(ink) - brightness(ground)) + > LEAST_DIFFERENCE, + "log line " + ink + " on " + ground); + } + } + + /** + * The colour a piece of text is drawn in. + * + * A Label's skin puts a LabeledText inside it, so walking the scene finds + * both the Label and the Text it renders through; either can answer. + */ + private static Color inkOf(Node word) { + javafx.scene.paint.Paint paint = word instanceof Label label + ? label.getTextFill() : ((Text) word).getFill(); + return paint instanceof Color colour ? colour : null; + } + + private static String text(Node node) { + return node instanceof Label label ? label.getText() : ((Text) node).getText(); + } +} diff --git a/src/test/java/esu/algorithm/ui/TreeRendererTest.java b/src/test/java/esu/algorithm/ui/TreeRendererTest.java new file mode 100644 index 0000000..2da7fbc --- /dev/null +++ b/src/test/java/esu/algorithm/ui/TreeRendererTest.java @@ -0,0 +1,154 @@ +package esu.algorithm.ui; + +import esu.algorithm.EsuNode; +import esu.algorithm.EsuSession; +import esu.algorithm.EsuTree; +import esu.algorithm.UndirectedGraph; +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import javafx.scene.Node; +import javafx.scene.shape.Rectangle; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * What the tree renderer actually builds. + * + * Nothing checked this before, and the shapes it draws are the whole point of + * the app: a box in the wrong state, or no box at all, is invisible to every + * other test in the project. + */ +public class TreeRendererTest extends FxTest { + + private EsuSession session(String file, int size) { + return new EsuSession( + UndirectedGraph.fromFile(new File("samples/" + file)), size); + } + + /** The rectangles the renderer produces for a finished search. */ + private List boxesOf(EsuSession session, Set picked) { + return onFxThread(() -> { + TreeLayout layout = TreeLayout.of(session.getFinalTree()); + List drawn = TreeRenderer.render(layout, + presentIn(session.getCurrentTree()), deadEndsIn(session), + session.getSubgraphSize(), null, Set.of(), picked); + List boxes = new ArrayList<>(); + for (Node node : drawn) { + if (node instanceof Rectangle rectangle) { + boxes.add(rectangle); + } + } + return boxes; + }); + } + + private Set presentIn(EsuTree tree) { + Set present = new HashSet<>(); + ArrayList[] levels = tree.getNodesByLevel(); + for (int level = 1; level < levels.length; level++) { + for (EsuNode node : levels[level]) { + present.add(node.getSubgraphAsString()); + } + } + return present; + } + + private Set deadEndsIn(EsuSession session) { + Set dead = new HashSet<>(); + ArrayList[] levels = session.getFinalTree().getNodesByLevel(); + for (int level = 1; level < levels.length - 1; level++) { + for (EsuNode node : levels[level]) { + if (node.getChildren().isEmpty()) { + dead.add(node.getSubgraphAsString()); + } + } + } + return dead; + } + + private long countWithClass(List boxes, String styleClass) { + return boxes.stream() + .filter(box -> box.getStyleClass().contains(styleClass)) + .count(); + } + + @Test + public void drawsABoxForTheRootAndEveryNodeReached() { + EsuSession session = session("bowtie.txt", 3); + session.goToStep(session.getTotalSteps()); + + List boxes = boxesOf(session, Set.of()); + + // Every node of the finished tree, plus the root. + int nodes = 1; + for (ArrayList level : session.getFinalTree().getNodesByLevel()) { + nodes += level.size(); + } + assertEquals(nodes - 1, boxes.size()); + assertEquals(1, countWithClass(boxes, "t-root")); + } + + @Test + public void drawsOnlyWhatTheSearchHasReached() { + EsuSession session = session("bowtie.txt", 3); + + session.goToStep(0); + + // Before the first step there is nothing but the beginning. + assertEquals(1, boxesOf(session, Set.of()).size()); + } + + @Test + public void marksCompletedSubgraphsAsFound() { + EsuSession session = session("bowtie.txt", 3); + session.goToStep(session.getTotalSteps()); + + List boxes = boxesOf(session, Set.of()); + + assertEquals(session.getSubgraphCount(), + countWithClass(boxes, "t-complete")); + } + + @Test + public void marksBranchesThatDiedAsDeadEnds() { + EsuSession session = session("bowtie.txt", 4); + session.goToStep(session.getTotalSteps()); + + List boxes = boxesOf(session, Set.of()); + + assertEquals(deadEndsIn(session).size(), countWithClass(boxes, "t-dead")); + assertTrue(countWithClass(boxes, "t-dead") > 0, + "this graph should have branches that die"); + } + + @Test + public void picksOutTheSubgraphsItIsAskedTo() { + EsuSession session = session("cluster.txt", 4); + session.goToStep(session.getTotalSteps()); + Set picked = + session.subgraphsWithShape(session.getShapes().get(0).shape()); + + List boxes = boxesOf(session, picked); + + assertTrue(picked.size() > 1, "pick more than one, or this proves little"); + assertEquals(picked.size(), countWithClass(boxes, "t-picked")); + } + + @Test + public void givesEveryBoxExactlyOneState() { + EsuSession session = session("cluster.txt", 4); + session.goToStep(session.getTotalSteps()); + + for (Rectangle box : boxesOf(session, Set.of())) { + long states = box.getStyleClass().stream() + .filter(name -> List.of("t-root", "t-pending", "t-active", + "t-complete", "t-dead").contains(name)) + .count(); + assertEquals(1, states, + "a box carried " + states + " states: " + box.getStyleClass()); + } + } +} From 375011ad45b3ecb6ed232c1f02bc2c6751416465 Mon Sep 17 00:00:00 2001 From: lolodawit Date: Wed, 9 Sep 2026 22:17:49 -0700 Subject: [PATCH 3/3] Keep the two test runners out of each other's way Vitest's default pattern matches any .spec.ts, so it collected the Playwright tests and failed on them. It is now told to look only in test/, leaving browser/ to Playwright. The failure was on screen locally and I read past it: the summary line I checked said 94 tests passed, while the line above it said one test file had failed. CI, which checks the exit code, caught it. --- web/vitest.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/vitest.config.ts b/web/vitest.config.ts index a2e66c0..8607470 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -5,5 +5,8 @@ export default defineConfig({ // The engine tests need no DOM; the rendering test does, and a real DOM // is what catches a control that quietly stopped being built. environment: 'happy-dom', + // browser/ belongs to Playwright. Vitest's default pattern matches + // .spec.ts anywhere, and it picked those up and failed on them. + include: ['test/**/*.test.ts'], }, });