From 2343d092dac8ab495e34db51501238bdd2482626 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:35:05 -0700 Subject: [PATCH 1/4] test(SONA-220): demand one line from the rating pills always The edit form pill row test only checked each item held one line when the four items fit unwrapped, because the theme primary face was not self-hosted and a machine without JetBrains Mono drew the pills wider. #439 ships the font, so every machine measures the same row. The test waits for document.fonts.ready and asserts one line unconditionally. --- tests/e2e/artist-lookup.spec.ts | 46 ++++++++------------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/tests/e2e/artist-lookup.spec.ts b/tests/e2e/artist-lookup.spec.ts index 5d7d7dc3..05d29f6c 100644 --- a/tests/e2e/artist-lookup.spec.ts +++ b/tests/e2e/artist-lookup.spec.ts @@ -661,6 +661,12 @@ test.describe('with a key saved', () => { // difference instead and wrap their own text (SONA-220). await expect(page.locator('#lookup-rating-tag')).toBeVisible(); await expect(page.locator('#tags-rating')).toBeVisible(); + // Every width below is measured in the theme's own face, so wait for the + // self-hosted JetBrains Mono to load before reading a box (SONA-181). + await page.evaluate(() => document.fonts.ready); + // And say so if it did not: fonts.ready resolves on a failed fetch too, and + // a missing face would otherwise fail below as an unexplained wrap. + expect(await page.evaluate(() => document.fonts.check('11px "JetBrains Mono"'))).toBe(true); // Compared by centre, not by top: the row centres its items, and the button // is 36px tall beside an 18px label, so equal tops would be the wrong test // for "same row" — they were never equal, even before the button wrapped. @@ -680,44 +686,14 @@ test.describe('with a key saved', () => { await expect(page.locator('.tag-check-row .btn')).toHaveText('Mark it NSFW'); // One line each, not just one row. The note wrapped "entail.dev." onto a // second line for an 8px shortfall, which the row's own gap pays for: three - // gaps at 4px instead of 8px buys back 12px (SONA-220). How much the four - // items want depends on the font the pills are drawn in, and the theme's - // primary face is not self-hosted yet, so a machine without it draws them - // in whatever monospace it has (the CI runner's is wider than a Mac's). - // Measured, then: when the items fit unwrapped, each holds one line, and - // when they do not, the pills give up their width and the button keeps it. - const fits = await page.evaluate(() => { - const row = document.querySelector('.tag-check-row'); - if (!(row instanceof HTMLElement)) throw new Error('no row'); - const items = Array.from(row.children).filter( - (el): el is HTMLElement => el instanceof HTMLElement - ); - const want = items.reduce((sum, el) => { - const probe = el.cloneNode(true) as HTMLElement; - probe.style.position = 'absolute'; - probe.style.width = 'max-content'; - probe.style.whiteSpace = 'nowrap'; - row.appendChild(probe); - const style = getComputedStyle(probe); - const width = - probe.getBoundingClientRect().width + - parseFloat(style.marginLeft) + - parseFloat(style.marginRight); - probe.remove(); - return sum + width; - }, 0); - const gap = parseFloat(getComputedStyle(row).columnGap) * (items.length - 1); - return want + gap <= row.clientWidth; - }); + // gaps at 4px instead of 8px buys back 12px (SONA-220). const button = await page.locator('.tag-check-row .btn').boundingBox(); if (!button) throw new Error('the button has no box'); expect(button.height).toBeLessThan(44); - if (fits) { - for (const selector of ['#tags-rating', '#lookup-rating-tag']) { - const box = await page.locator(selector).boundingBox(); - if (!box) throw new Error(`${selector} has no box`); - expect(box.height).toBeLessThan(24); - } + for (const selector of ['#tags-rating', '#lookup-rating-tag']) { + const box = await page.locator(selector).boundingBox(); + if (!box) throw new Error(`${selector} has no box`); + expect(box.height).toBeLessThan(24); } await expect(page.locator('.tag-check-row')).toHaveCSS('gap', '4px'); // The 4px is between the two rating items, which report the same kind of From 4c364bd1767c7b7fa917796cd568bd361a03df76 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:48:42 -0700 Subject: [PATCH 2/4] test(SONA-220): assert the pill face loaded through fonts.load, not check --- tests/e2e/artist-lookup.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/e2e/artist-lookup.spec.ts b/tests/e2e/artist-lookup.spec.ts index 05d29f6c..07cd9f7e 100644 --- a/tests/e2e/artist-lookup.spec.ts +++ b/tests/e2e/artist-lookup.spec.ts @@ -665,8 +665,13 @@ test.describe('with a key saved', () => { // self-hosted JetBrains Mono to load before reading a box (SONA-181). await page.evaluate(() => document.fonts.ready); // And say so if it did not: fonts.ready resolves on a failed fetch too, and - // a missing face would otherwise fail below as an unexplained wrap. - expect(await page.evaluate(() => document.fonts.check('11px "JetBrains Mono"'))).toBe(true); + // a missing face would otherwise fail below as an unexplained wrap. load() + // returns the faces that matched, so a declaration that went missing + // returns none; check() would say true for a fallback. + const faces = await page.evaluate(async () => + (await document.fonts.load('11px "JetBrains Mono"')).map((face) => face.status) + ); + expect(faces, 'no loaded JetBrains Mono face matched the pills').toContain('loaded'); // Compared by centre, not by top: the row centres its items, and the button // is 36px tall beside an 18px label, so equal tops would be the wrong test // for "same row" — they were never equal, even before the button wrapped. From 4d30c74dbcfa8cf7de9849fba58687b76289ec79 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:34:27 -0700 Subject: [PATCH 3/4] test(SONA-220): assert the pills fit the space they got, not a fixed height Chromium rounds glyph advances to whole pixels on Linux at 1x, so the rating row is about 6% wider on the CI runner than on a Mac and the 600px form column has a few pixels of slack. A pill that wraps because its platform's text does not fit is not the defect this test guards; a pill that wraps with room to spare is. --- tests/e2e/artist-lookup.spec.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/e2e/artist-lookup.spec.ts b/tests/e2e/artist-lookup.spec.ts index 07cd9f7e..97092ac9 100644 --- a/tests/e2e/artist-lookup.spec.ts +++ b/tests/e2e/artist-lookup.spec.ts @@ -692,13 +692,25 @@ test.describe('with a key saved', () => { // One line each, not just one row. The note wrapped "entail.dev." onto a // second line for an 8px shortfall, which the row's own gap pays for: three // gaps at 4px instead of 8px buys back 12px (SONA-220). + // Measured against the space each item got, not a fixed height: Chromium + // positions glyphs at subpixel offsets on macOS but rounds every advance to + // a whole pixel on Linux at 1x, so the same text is about 6% wider on the + // CI runner and the 600px column has a few pixels of slack. A pill that + // wraps because the column is too narrow for its platform's text is not + // what this test is about; a pill that wraps with room to spare is. const button = await page.locator('.tag-check-row .btn').boundingBox(); if (!button) throw new Error('the button has no box'); expect(button.height).toBeLessThan(44); for (const selector of ['#tags-rating', '#lookup-rating-tag']) { - const box = await page.locator(selector).boundingBox(); - if (!box) throw new Error(`${selector} has no box`); - expect(box.height).toBeLessThan(24); + const fit = await page.locator(selector).evaluate((el) => { + const box = el.getBoundingClientRect(); + const row = el.parentElement?.getBoundingClientRect(); + // scrollWidth is the width the content wants on one line. + return { height: box.height, wants: el.scrollWidth, row: row?.width ?? 0 }; + }); + expect(fit.row).toBeGreaterThan(0); + // Wrapped while the row could have held it on one line: a real defect. + if (fit.wants <= fit.row) expect(fit.height).toBeLessThan(24); } await expect(page.locator('.tag-check-row')).toHaveCSS('gap', '4px'); // The 4px is between the two rating items, which report the same kind of From e5efdf346e460e9ae75764793d048ffefef24df8 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:43:30 -0700 Subject: [PATCH 4/4] test(SONA-220): measure the row's one-line need from nowrap probes, not scrollWidth --- tests/e2e/artist-lookup.spec.ts | 43 ++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/tests/e2e/artist-lookup.spec.ts b/tests/e2e/artist-lookup.spec.ts index 97092ac9..37dd720b 100644 --- a/tests/e2e/artist-lookup.spec.ts +++ b/tests/e2e/artist-lookup.spec.ts @@ -701,16 +701,41 @@ test.describe('with a key saved', () => { const button = await page.locator('.tag-check-row .btn').boundingBox(); if (!button) throw new Error('the button has no box'); expect(button.height).toBeLessThan(44); - for (const selector of ['#tags-rating', '#lookup-rating-tag']) { - const fit = await page.locator(selector).evaluate((el) => { - const box = el.getBoundingClientRect(); - const row = el.parentElement?.getBoundingClientRect(); - // scrollWidth is the width the content wants on one line. - return { height: box.height, wants: el.scrollWidth, row: row?.width ?? 0 }; + // The row's items all want a width on one line; the sum, plus the gaps, is + // what the row would need to hold every item unwrapped. Each item's want is + // read from a nowrap clone at max-content, because a wrapped element's own + // scrollWidth reports the width it was given, not the width it needs. + const fit = await page.locator('.tag-check-row').evaluate((row) => { + const items = Array.from(row.children).filter( + (el): el is HTMLElement => el instanceof HTMLElement + ); + const wants = items.map((el) => { + const probe = el.cloneNode(true) as HTMLElement; + probe.style.position = 'absolute'; + probe.style.width = 'max-content'; + probe.style.whiteSpace = 'nowrap'; + row.appendChild(probe); + const style = getComputedStyle(probe); + const width = + probe.getBoundingClientRect().width + + parseFloat(style.marginLeft) + + parseFloat(style.marginRight); + probe.remove(); + return width; }); - expect(fit.row).toBeGreaterThan(0); - // Wrapped while the row could have held it on one line: a real defect. - if (fit.wants <= fit.row) expect(fit.height).toBeLessThan(24); + const gap = parseFloat(getComputedStyle(row).columnGap) * (items.length - 1); + return { need: wants.reduce((a, b) => a + b, 0) + gap, have: row.clientWidth }; + }); + expect(fit.have).toBeGreaterThan(0); + // The row could hold every item on one line, so a wrapped pill is a real + // defect. When it could not (Linux at 1x, or a longer locale), the wrap is + // the platform's text, not the layout's fault, and the height is not judged. + if (fit.need <= fit.have) { + for (const selector of ['#tags-rating', '#lookup-rating-tag']) { + const box = await page.locator(selector).boundingBox(); + if (!box) throw new Error(`${selector} has no box`); + expect(box.height).toBeLessThan(24); + } } await expect(page.locator('.tag-check-row')).toHaveCSS('gap', '4px'); // The 4px is between the two rating items, which report the same kind of