From 758d9b41ee26a07a375660c5e222b8aaab2ade4d Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Wed, 14 Oct 2026 21:12:30 +0000 Subject: [PATCH 1/7] Add a tolerant pixel expectation for browser tests Co-Authored-By: Claude Opus 5.5 --- tests/fixtures.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/fixtures.ts b/tests/fixtures.ts index ad219400..69f6b893 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -2,6 +2,24 @@ import { test as base, expect, type Page } from "@playwright/test"; export { expect }; +/** + * Expects each channel within `tolerance` of the expected one. GPUs round filtering, `pow`, and + * float16 differently, so pixel checks compare what a person would see, not exact bytes. + */ +export function expectNear( + actual: readonly number[] | undefined, + expected: readonly number[], + tolerance = 3, +) { + expect(actual).toHaveLength(expected.length); + for (const [channel, value] of expected.entries()) { + expect( + Math.abs((actual?.[channel] ?? Number.NaN) - value), + `channel ${channel} of [${actual}] near [${expected}]`, + ).toBeLessThanOrEqual(tolerance); + } +} + /** Opens the test photo and waits for its controls. */ export async function openPhoto(page: Page) { await page.goto("/"); From 9451eed90ae18a2766cd166e9b4f85e4c1a2a37d Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Wed, 14 Oct 2026 21:32:32 +0000 Subject: [PATCH 2/7] Make the brush, paint, and layer browser tests GPU-agnostic and merge shared setups Read content from the export with tolerances, overlays as region means against a baseline, and the moving view as blue or gray; loop parametrized variants in one page. Co-Authored-By: Claude Opus 5.5 --- tests/brush-navigation.e2e.ts | 219 ++++++++++-------- tests/brush.e2e.ts | 219 +++++++++--------- tests/fill.e2e.ts | 8 +- tests/layers.e2e.ts | 76 +++++-- tests/paint.e2e.ts | 403 ++++++++++++++-------------------- tests/tool-shortcuts.e2e.ts | 166 ++++++++------ 6 files changed, 575 insertions(+), 516 deletions(-) diff --git a/tests/brush-navigation.e2e.ts b/tests/brush-navigation.e2e.ts index e591853e..8ab363c8 100644 --- a/tests/brush-navigation.e2e.ts +++ b/tests/brush-navigation.e2e.ts @@ -2,6 +2,14 @@ import { expect, openPhoto, test } from "./fixtures"; import { readImage, readPixel } from "./images"; import { box } from "./pointer"; +/** Tells the fixture's blue square from its gray field, whatever the GPU's rounding and the cursor's tint. */ +function tone(pixel: readonly number[] | undefined) { + const [red, green, blue] = pixel ?? []; + if (blue - red > 40) return "blue"; + if (Math.abs(blue - red) < 16 && Math.abs(green - red) < 16) return "gray"; + return "other"; +} + test(`brush menu owns clicks and slider drags without painting or navigating`, async ({ page, }) => { @@ -65,85 +73,110 @@ test(`brush menu owns clicks and slider drags without painting or navigating`, a ).toHaveValue("90"); }); -for (const { name, shortcut, presses, label } of [ - { name: "mask", shortcut: "b", presses: 1, label: "Brush canvas" }, - { name: "color", shortcut: "b", presses: 2, label: "Paint canvas" }, - { name: "retouch", shortcut: "h", presses: 1, label: "Healing canvas" }, -]) { - test(`${name} brush keeps its screen size across zoom while new strokes scale in the image`, async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 1000 }); - await openPhoto(page); - for (let i = 0; i < presses; i++) await page.keyboard.press(shortcut); - const canvas = page.getByLabel(label, { exact: true }); - await expect(canvas).toBeVisible(); - const size = page.getByRole("textbox", { name: "Size", exact: true }); - await expect(size).toHaveValue("50"); - await canvas.click({ button: "right", position: { x: 300, y: 300 } }); - const menu = page.getByRole("dialog"); - await menu.getByRole("textbox", { name: "Size", exact: true }).fill("80"); - await menu - .getByRole("textbox", { name: "Size", exact: true }) - .press("Enter"); - await page.keyboard.press("Escape"); - await expect(size).toHaveValue("80"); - await canvas.hover(); - await page.mouse.wheel(0, 100); - await expect(size).toHaveValue("103"); - await size.fill("100"); - await size.press("Enter"); - const bounds = await box(canvas); - const cursor = canvas.locator('[data-brush-cursor="true"] ellipse').first(); - /** The cursor's radii in viewport pixels, a circle without perspective. */ - const radii = () => - Promise.all( - ["rx", "ry"].map(async (name) => - Number(await cursor.getAttribute(name)), - ), - ); - const fifty = [expect.closeTo(50, 6), expect.closeTo(50, 6)]; - const strokes = () => - page.evaluate(() => { - const { scene, selectedLayerId } = window.openlight.getState(); - const layer = scene?.layers.find( - (layer) => layer.id === selectedLayerId, +test("brushes keep their screen size across zoom while new strokes scale in the image", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await openPhoto(page); + // The mask and color brushes share their size, so color starts where mask left it. + for (const { name, shortcut, presses, label, initial } of [ + { + name: "mask", + shortcut: "b", + presses: 1, + label: "Brush canvas", + initial: "50", + }, + { + name: "color", + shortcut: "b", + presses: 2, + label: "Paint canvas", + initial: "100", + }, + { + name: "retouch", + shortcut: "h", + presses: 1, + label: "Healing canvas", + initial: "50", + }, + ]) { + await test.step(name, async () => { + for (let i = 0; i < presses; i++) await page.keyboard.press(shortcut); + const canvas = page.getByLabel(label, { exact: true }); + await expect(canvas).toBeVisible(); + const size = page.getByRole("textbox", { name: "Size", exact: true }); + await expect(size).toHaveValue(initial); + await canvas.click({ button: "right", position: { x: 300, y: 300 } }); + const menu = page.getByRole("dialog"); + await menu.getByRole("textbox", { name: "Size", exact: true }).fill("80"); + await menu + .getByRole("textbox", { name: "Size", exact: true }) + .press("Enter"); + await page.keyboard.press("Escape"); + await expect(size).toHaveValue("80"); + await canvas.hover(); + await page.mouse.wheel(0, 100); + await expect(size).toHaveValue("103"); + await size.fill("100"); + await size.press("Enter"); + const bounds = await box(canvas); + const cursor = canvas + .locator('[data-brush-cursor="true"] ellipse') + .first(); + /** The cursor's radii in viewport pixels, a circle without perspective. */ + const radii = () => + Promise.all( + ["rx", "ry"].map(async (name) => + Number(await cursor.getAttribute(name)), + ), ); - if (layer?.kind === "paint") return layer.strokes; - if (layer?.kind === "mask" && layer.mask.kind === "brush") - return layer.mask.strokes; - if (layer?.kind === "heal") - return layer.patches.flatMap((patch) => patch.strokes); - return []; - }); - const x = bounds.x + bounds.width / 2 + 180; - const y = bounds.y + bounds.height / 2 + 120; - await page.mouse.move(x, y); - await expect.poll(radii).toEqual(fifty); - await page.mouse.click(x, y); - await expect.poll(async () => (await strokes()).length).toBe(1); - const first = (await strokes())[0]; - await page.getByRole("button", { name: "Zoom in", exact: true }).click(); - await page.getByRole("button", { name: "Zoom in", exact: true }).click(); - await page.mouse.move(x + 180, y); - await expect.poll(radii).toEqual(fifty); - await expect(size).toHaveValue("100"); - await page.mouse.click(x + 180, y); - await expect.poll(async () => (await strokes()).length).toBe(2); - const recorded = await strokes(); - expect(recorded[0]).toEqual(first); - expect(recorded[1].size).toBeCloseTo(first.size / 1.25 ** 2, 6); - // Navigating does not resize existing content; each stroke remains one undo step. - await page.keyboard.press("ControlOrMeta+z"); - expect(await strokes()).toEqual([first]); - await page.keyboard.press("ControlOrMeta+Shift+z"); - expect(await strokes()).toEqual(recorded); - await page.getByRole("button", { name: /^\d+%$/ }).click(); - await page.mouse.move(x, y); - await expect.poll(radii).toEqual(fifty); - await expect(size).toHaveValue("100"); - }); -} + const fifty = [expect.closeTo(50, 6), expect.closeTo(50, 6)]; + const strokes = () => + page.evaluate(() => { + const { scene, selectedLayerId } = window.openlight.getState(); + const layer = scene?.layers.find( + (layer) => layer.id === selectedLayerId, + ); + if (layer?.kind === "paint") return layer.strokes; + if (layer?.kind === "mask" && layer.mask.kind === "brush") + return layer.mask.strokes; + if (layer?.kind === "heal") + return layer.patches.flatMap((patch) => patch.strokes); + return []; + }); + const x = bounds.x + bounds.width / 2 + 180; + const y = bounds.y + bounds.height / 2 + 120; + await page.mouse.move(x, y); + await expect.poll(radii).toEqual(fifty); + await page.mouse.click(x, y); + await expect.poll(async () => (await strokes()).length).toBe(1); + const first = (await strokes())[0]; + await page.getByRole("button", { name: "Zoom in", exact: true }).click(); + await page.getByRole("button", { name: "Zoom in", exact: true }).click(); + await page.mouse.move(x + 180, y); + await expect.poll(radii).toEqual(fifty); + await expect(size).toHaveValue("100"); + await page.mouse.click(x + 180, y); + await expect.poll(async () => (await strokes()).length).toBe(2); + const recorded = await strokes(); + expect(recorded[0]).toEqual(first); + expect(recorded[1].size).toBeCloseTo(first.size / 1.25 ** 2, 6); + // Navigating does not resize existing content; each stroke remains one undo step. + await page.keyboard.press("ControlOrMeta+z"); + expect(await strokes()).toEqual([first]); + await page.keyboard.press("ControlOrMeta+Shift+z"); + expect(await strokes()).toEqual(recorded); + await page.getByRole("button", { name: /^\d+%$/ }).click(); + await page.mouse.move(x, y); + await expect.poll(radii).toEqual(fifty); + await expect(size).toHaveValue("100"); + await page.keyboard.press("Escape"); + await expect(canvas).toHaveCount(0); + }); + } +}); test(`brush wheel sizes the brush while pinch zoom and Space-drag navigate`, async ({ page, @@ -225,18 +258,16 @@ test(`brush wheel sizes the brush while pinch zoom and Space-drag navigate`, asy zoomBounds.height / 2 + (200 - 400) * zoomScale, ] as const, ); - const edgePixels = async () => - (await readImage(page, await rendered.screenshot(), edgeSamples)).samples; - const expectedEdge = [ - [48, 80, 128, 255], - [128, 128, 128, 255], - ]; - await expect.poll(edgePixels).toEqual(expectedEdge); + const edge = async () => + ( + await readImage(page, await rendered.screenshot(), edgeSamples) + ).samples?.map(tone); + await expect.poll(edge).toEqual(["blue", "gray"]); for (const x of [60, -60]) { await page.mouse.wheel(x, 0); await expect(size).toHaveValue("1000"); await expect(zoom).not.toHaveText(fit); - await expect.poll(edgePixels).toEqual(expectedEdge); + await expect.poll(edge).toEqual(["blue", "gray"]); } await zoom.click(); await expect(zoom).toHaveText(fit); @@ -281,7 +312,9 @@ test(`brush wheel sizes the brush while pinch zoom and Space-drag navigate`, asy x: bounds.x + bounds.width / 2 + (430 - 600) * scale, y: bounds.y + bounds.height / 2 + (150 - 400) * scale, }; - await expect.poll(() => readPixel(page, sample)).toEqual([48, 80, 128, 255]); + await expect + .poll(async () => tone(await readPixel(page, sample))) + .toBe("blue"); await canvas.hover(); await page.keyboard.down("Space"); await expect(canvas).toHaveAttribute("data-pan", "true"); @@ -297,15 +330,19 @@ test(`brush wheel sizes the brush while pinch zoom and Space-drag navigate`, asy ); await page.mouse.up(); await expect - .poll(() => readPixel(page, { ...sample, x: sample.x + 101 })) - .toEqual([48, 80, 128, 255]); + .poll(async () => + tone(await readPixel(page, { ...sample, x: sample.x + 101 })), + ) + .toBe("blue"); await page.keyboard.down("Space"); await expect(center).toHaveCount(0); await page.mouse.wheel(60, 40); await page.keyboard.up("Space"); await expect(size).toHaveValue("128"); await expect - .poll(() => readPixel(page, { x: sample.x + 41, y: sample.y - 40 })) - .toEqual([48, 80, 128, 255]); + .poll(async () => + tone(await readPixel(page, { x: sample.x + 41, y: sample.y - 40 })), + ) + .toBe("blue"); expect(await page.evaluate(() => window.openlight.getState())).toEqual(state); }); diff --git a/tests/brush.e2e.ts b/tests/brush.e2e.ts index 8e290610..0f4973ec 100644 --- a/tests/brush.e2e.ts +++ b/tests/brush.e2e.ts @@ -1,7 +1,47 @@ -import { expect, openPhoto, test } from "./fixtures"; -import { readImage, readPreview } from "./images"; +import type { Page } from "@playwright/test"; +import { expect, expectNear, openPhoto, test } from "./fixtures"; +import { readImage } from "./images"; import { box, drag } from "./pointer"; +function expectPixels( + actual: readonly number[][], + expected: readonly (readonly number[])[], + tolerance?: number, +) { + expect(actual).toHaveLength(expected.length); + for (const [i, pixel] of expected.entries()) { + expectNear(actual[i], pixel, tolerance); + } +} + +/** Mean red over green across a small square at the displayed canvas's center, as the mask overlay tints it. */ +async function redness(page: Page) { + const bytes = await page + .getByRole("region", { name: "Image canvas" }) + .locator("canvas") + .screenshot(); + return page.evaluate( + async (bytes) => { + const image = await createImageBitmap(new Blob([new Uint8Array(bytes)])); + const canvas = new OffscreenCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Cannot read preview pixels."); + context.drawImage(image, 0, 0); + const { data } = context.getImageData( + image.width / 2 - 10, + image.height / 2 - 10, + 21, + 21, + ); + image.close(); + let sum = 0; + for (let i = 0; i < data.length; i += 4) sum += data[i] - data[i + 1]; + return sum / (data.length / 4); + }, + [...bytes], + ); +} + test("paint a brush mask, adjust it in the sidebar, erase, and undo", async ({ page, }) => { @@ -22,10 +62,6 @@ test("paint a brush mask, adjust it in the sidebar, erase, and undo", async ({ await field.fill(value); await field.press("Enter"); } - async function tinted() { - const { center } = await readPreview(page); - return center[0] > center[1] + 30; - } await openPhoto(page); const original = await samples(); const { history } = await state(); @@ -54,7 +90,7 @@ test("paint a brush mask, adjust it in the sidebar, erase, and undo", async ({ await page.keyboard.press("b"); await setField("Size", "200"); await setField("Exposure", "2"); - expect(await samples()).toEqual(original); + expectPixels(await samples(), original, 1); await drag(page, from, to, 16); const current = await state(); expect(current.scene?.layers[1]).toMatchObject({ @@ -67,11 +103,13 @@ test("paint a brush mask, adjust it in the sidebar, erase, and undo", async ({ }); const [inside, above] = await samples(); expect(inside[0]).toBeGreaterThan(original[0][0] + 40); - expect(above).toEqual(original[1]); + expectNear(above, original[1], 1); await page.mouse.move(bounds.x + 20, bounds.y + 20); + const untinted = await redness(page); await page.keyboard.press("o"); - await expect.poll(tinted).toBe(true); + await expect.poll(() => redness(page)).toBeGreaterThan(untinted + 20); await page.keyboard.press("o"); + await expect.poll(() => redness(page)).toBeLessThan(untinted + 5); // The row's thumbnail shows the stroke through the middle. const thumbnail = page .getByRole("region", { name: "Layers", exact: true }) @@ -92,9 +130,9 @@ test("paint a brush mask, adjust it in the sidebar, erase, and undo", async ({ await page.keyboard.down("Alt"); await drag(page, from, to, 16); await page.keyboard.up("Alt"); - expect((await samples())[0]).toEqual(original[0]); + expectNear((await samples())[0], original[0], 1); await page.keyboard.press("ControlOrMeta+z"); - expect(await samples()).toEqual(painted); + expectPixels(await samples(), painted, 1); }); await test.step("Enter leaves one level at a time; selecting the mask returns", async () => { const { selectedLayerId, scene } = await state(); @@ -130,11 +168,11 @@ test("paint a brush mask, adjust it in the sidebar, erase, and undo", async ({ expect((await state()).scene?.layers.at(-1)?.children).toMatchObject([ { operation: "subtract", mask: { kind: "brush" } }, ]); - expect((await samples())[1]).toEqual(original[1]); + expectNear((await samples())[1], original[1], 1); await page.keyboard.down("Alt"); await drag(page, dot, [dot[0], dot[1] + 1], 2); await page.keyboard.up("Alt"); - expect((await samples())[1]).toEqual(lit[1]); + expectNear((await samples())[1], lit[1], 1); }); await test.step("Escape climbs from the child mask to its parent and the image", async () => { const { selectedLayerId, scene } = await state(); @@ -157,7 +195,7 @@ test.describe("on a touch phone", () => { deviceScaleFactor: 2, }); - test("a second finger during a touch stroke cancels it and pinches instead", async ({ + test("a second finger during a touch stroke cancels it and pinches instead, even over a healing patch", async ({ page, context, }) => { @@ -168,98 +206,77 @@ test.describe("on a touch phone", () => { await expect( page.getByRole("slider", { name: "Exposure", exact: true }), ).toHaveAttribute("aria-valuetext", "0.00"); - await page.getByRole("tab", { name: "Brush", exact: true }).click(); - const bounds = await box(page.getByLabel("Brush canvas", { exact: true })); const zoom = page.getByRole("button", { name: /^\d+%$/ }); - const fitted = await zoom.innerText(); - const [x, y] = [bounds.x + bounds.width / 2, bounds.y + bounds.height / 2]; + const fit = await zoom.innerText(); const touch = await context.newCDPSession(page); - const send = ( - type: "touchStart" | "touchMove" | "touchEnd", - at: number[], - ) => - touch.send("Input.dispatchTouchEvent", { - type, - touchPoints: at.map((dx) => ({ x: x + dx, y })), - }); - await send("touchStart", [-40]); - await send("touchMove", [-20]); - await send("touchStart", [-20, 40]); - for (let step = 1; step <= 6; step++) { - await send("touchMove", [-20 - step * 8, 40 + step * 8]); + /** The first finger paints from `start`, the second lands at `land`, offsets from the canvas center; then both spread. */ + async function pinch(canvas: string, start: number, land: number) { + const bounds = await box(page.getByLabel(canvas, { exact: true })); + const [x, y] = [ + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 2, + ]; + const send = ( + type: "touchStart" | "touchMove" | "touchEnd", + at: number[], + ) => + touch.send("Input.dispatchTouchEvent", { + type, + touchPoints: at.map((dx) => ({ x: x + dx, y })), + }); + await send("touchStart", [start]); + await send("touchMove", [start + 10]); + await send("touchStart", [start + 10, land]); + for (let step = 1; step <= 6; step++) { + await send("touchMove", [start + 10 - step * 8, land + step * 8]); + } + await send("touchEnd", []); + await expect(zoom).not.toHaveText(fit); } - await send("touchEnd", []); - await expect(zoom).not.toHaveText(fitted); - expect( - await page.evaluate(() => window.openlight.getState()), - ).toMatchObject({ - scene: { layers: [{}, { mask: { strokes: [] } }] }, - history: { editing: false }, - }); - }); - test("a second finger landing on another patch during a healing stroke pinches instead of selecting it", async ({ - page, - context, - }) => { - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); - await expect( - page.getByRole("slider", { name: "Exposure", exact: true }), - ).toHaveAttribute("aria-valuetext", "0.00"); - // A patch right of the photo's center, where the second finger lands. - await page.evaluate(() => { - const api = window.openlight; - api.addHealPatch( - api.addLayer("heal"), - { - mode: "paint", - size: 160, - feather: 0, - flow: 1, - points: [[900, 400, 1]], - }, - [-300, 0], - ); + await test.step("on a brush mask", async () => { + await page.getByRole("tab", { name: "Brush", exact: true }).click(); + await pinch("Brush canvas", -30, 40); + expect( + await page.evaluate(() => window.openlight.getState()), + ).toMatchObject({ + scene: { layers: [{}, { mask: { strokes: [] } }] }, + history: { editing: false }, + }); }); - const before = await page.evaluate(() => window.openlight.getState()); - await page.getByRole("tab", { name: "Healing", exact: true }).click(); - const canvas = page.getByLabel("Healing canvas", { exact: true }); - const bounds = await box(canvas); - // The fitted photo's pixels per source pixel, inside the viewport's padding. - const scale = Math.min( - (bounds.width - 48) / 1200, - (bounds.height - 48) / 800, - ); - const zoom = page.getByRole("button", { name: /^\d+%$/ }); - const fitted = await zoom.innerText(); - const [x, y] = [bounds.x + bounds.width / 2, bounds.y + bounds.height / 2]; - const patch = 300 * scale; - const touch = await context.newCDPSession(page); - const send = ( - type: "touchStart" | "touchMove" | "touchEnd", - at: number[], - ) => - touch.send("Input.dispatchTouchEvent", { - type, - touchPoints: at.map((dx) => ({ x: x + dx, y })), + + await test.step("with the second finger on another healing patch", async () => { + await zoom.click(); + await expect(zoom).toHaveText(fit); + // A patch right of the photo's center, where the second finger lands. + await page.evaluate(() => { + const api = window.openlight; + api.addHealPatch( + api.addLayer("heal"), + { + mode: "paint", + size: 160, + feather: 0, + flow: 1, + points: [[900, 400, 1]], + }, + [-300, 0], + ); }); - // The first finger paints left of the patch; the second lands on it, then both spread. - await send("touchStart", [-patch]); - await send("touchMove", [-patch + 10]); - await send("touchStart", [-patch + 10, patch]); - for (let step = 1; step <= 6; step++) { - await send("touchMove", [-patch + 10 - step * 8, patch + step * 8]); - } - await send("touchEnd", []); - await expect(zoom).not.toHaveText(fitted); - const after = await page.evaluate(() => window.openlight.getState()); - expect(after.scene).toEqual(before.scene); - expect(after.history).toEqual(before.history); - await expect( - canvas.locator('[data-heal-destination-handle="true"]'), - ).toHaveCount(0); + await page.getByRole("tab", { name: "Healing", exact: true }).click(); + const canvas = page.getByLabel("Healing canvas", { exact: true }); + const before = await page.evaluate(() => window.openlight.getState()); + const bounds = await box(canvas); + // The fitted photo's pixels per source pixel, inside the viewport's padding. + const patch = + 300 * Math.min((bounds.width - 48) / 1200, (bounds.height - 48) / 800); + await pinch("Healing canvas", -patch, patch); + const after = await page.evaluate(() => window.openlight.getState()); + expect(after.scene).toEqual(before.scene); + expect(after.history).toEqual(before.history); + await expect( + canvas.locator('[data-heal-destination-handle="true"]'), + ).toHaveCount(0); + }); }); }); diff --git a/tests/fill.e2e.ts b/tests/fill.e2e.ts index cab12c02..7beb0796 100644 --- a/tests/fill.e2e.ts +++ b/tests/fill.e2e.ts @@ -1,4 +1,4 @@ -import { expect, openPhoto, test } from "./fixtures"; +import { expect, expectNear, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; import { box, choose, drag } from "./pointer"; @@ -21,10 +21,8 @@ test("a color layer paints a brush stroke with Photoshop blends", async ({ ]) ).samples ?? []; const [painted, outside] = await read(); - for (const [channel, expected] of [240, 118, 60].entries()) { - expect(Math.abs(painted[channel] - expected)).toBeLessThanOrEqual(2); - } - expect(outside).toEqual([128, 128, 128, 255]); + expectNear(painted, [240, 118, 60, 255]); + expectNear(outside, [128, 128, 128, 255]); await choose(page, page.getByRole("combobox", { name: "Blend" }), "Multiply"); const [multiplied] = await read(); expect(multiplied[0]).toBeLessThan(128); diff --git a/tests/layers.e2e.ts b/tests/layers.e2e.ts index 5d011f04..4c4cbe5e 100644 --- a/tests/layers.e2e.ts +++ b/tests/layers.e2e.ts @@ -1,11 +1,51 @@ -import { expect, openPhoto, test } from "./fixtures"; -import { readImage, readPreview } from "./images"; +import type { Page } from "@playwright/test"; +import { expect, expectNear, openPhoto, test } from "./fixtures"; +import { readImage } from "./images"; import { box, choose, drag } from "./pointer"; +function expectPixels( + actual: readonly number[][], + expected: readonly (readonly number[])[], + tolerance?: number, +) { + expect(actual).toHaveLength(expected.length); + for (const [i, pixel] of expected.entries()) { + expectNear(actual[i], pixel, tolerance); + } +} + +/** Mean red over green across a small square of the displayed canvas, `offset` from its center, as the mask overlay tints it. */ +async function redness(page: Page, offset: readonly [number, number]) { + const bytes = await page + .getByRole("region", { name: "Image canvas" }) + .locator("canvas") + .screenshot(); + return page.evaluate( + async ([bytes, [x, y]]) => { + const image = await createImageBitmap(new Blob([new Uint8Array(bytes)])); + const canvas = new OffscreenCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Cannot read preview pixels."); + context.drawImage(image, 0, 0); + const { data } = context.getImageData( + image.width / 2 + x - 10, + image.height / 2 + y - 10, + 21, + 21, + ); + image.close(); + let sum = 0; + for (let i = 0; i < data.length; i += 4) sum += data[i] - data[i + 1]; + return sum / (data.length / 4); + }, + [[...bytes], offset] as const, + ); +} + test("draw masks, edit their child effects, reorder layers and undo", async ({ page, }) => { - test.setTimeout(90_000); + test.setTimeout(120_000); await page.setViewportSize({ width: 1440, height: 1000 }); const state = () => page.evaluate(() => window.openlight.getState()); const layers = async () => (await state()).scene?.layers ?? []; @@ -48,7 +88,7 @@ test("draw masks, edit their child effects, reorder layers and undo", async ({ ] as const; const tones = async () => (await readImage(page, undefined, points)).samples?.map(([red]) => red); - expect(await tones()).toEqual([128, 224]); + expectNear(await tones(), [128, 224]); await page.evaluate(() => { const api = window.openlight; api.setAdjustments({ exposure: 2 }); @@ -82,21 +122,19 @@ test("draw masks, edit their child effects, reorder layers and undo", async ({ const to = [center[0], center[1] + 200 * scale]; await test.step("a linear mask shows its overlay until an adjustment changes the image", async () => { // Sample above the center, clear of the guide lines and the move handle. - const tinted = async () => { - const { center } = await readPreview(page, [0, -100 * scale]); - return center[0] > center[1] + 30; - }; + const tint = () => redness(page, [0, -100 * scale]); + const untinted = await tint(); await drag(page, from, to); expect(await layers()).toMatchObject([{}, { mask: { kind: "linear" } }]); - await expect.poll(tinted).toBe(true); + await expect.poll(tint).toBeGreaterThan(untinted + 20); await setField("Exposure", "1"); - await expect.poll(tinted).toBe(false); + await expect.poll(tint).toBeLessThan(untinted + 5); await page.keyboard.press("o"); - await expect.poll(tinted).toBe(true); + await expect.poll(tint).toBeGreaterThan(untinted + 20); await page.keyboard.press("o"); const [top, bottom] = await samples(); expect(top[0]).toBeGreaterThan(170); - expect(bottom).toEqual(original[1]); + expectNear(bottom, original[1], 1); }); await test.step("the move guide is one undo step, Escape cancels it, and Delete removes the mask", async () => { const before = await state(); @@ -124,10 +162,10 @@ test("draw masks, edit their child effects, reorder layers and undo", async ({ const masked = await samples(); await action("Sky", "Subtract from mask", "Linear gradient"); await drag(page, from, to); - expect((await samples())[0]).toEqual(original[0]); + expectNear((await samples())[0], original[0], 1); const operation = page.getByRole("combobox", { name: "Mask operation" }); await choose(page, operation, "Add"); - expect((await samples())[0]).toEqual(masked[0]); + expectNear((await samples())[0], masked[0], 1); await undo(); await undo(); expect(await samples()).toEqual(masked); @@ -140,7 +178,7 @@ test("draw masks, edit their child effects, reorder layers and undo", async ({ await setField("Intensity", "80"); const nested = await samples(); expect(nested[0][0]).toBeLessThan(masked[0][0]); - expect(nested[1]).toEqual(masked[1]); + expectNear(nested[1], masked[1], 1); const row = (name: string) => box( page @@ -174,7 +212,7 @@ test("draw masks, edit their child effects, reorder layers and undo", async ({ await page.getByRole("button", { name: "Sky", exact: true }).click(); const masked = await samples(); await setField("Opacity", "0"); - expect(await samples()).toEqual(original); + expectPixels(await samples(), original, 1); await undo(); // The row's opacity drags to zero and undoes as one edit. const opacity = await box( @@ -182,12 +220,12 @@ test("draw masks, edit their child effects, reorder layers and undo", async ({ ); const y = opacity.y + opacity.height / 2; await drag(page, [opacity.x + opacity.width / 2, y], [opacity.x - 300, y]); - expect(await samples()).toEqual(original); + expectPixels(await samples(), original, 1); await undo(); expect(await samples()).toEqual(masked); const show = page.getByRole("button", { name: "Show Sky", exact: true }); await show.click(); - expect(await samples()).toEqual(original); + expectPixels(await samples(), original, 1); await show.click(); expect(await samples()).toEqual(masked); }); @@ -208,7 +246,7 @@ test("draw masks, edit their child effects, reorder layers and undo", async ({ [center[0] + 40, center[1] + 30], [center[0] + 40 + 300 * scale, center[1] + 30], ); - expect((await readImage(page)).center).toEqual(before.center); + expectNear((await readImage(page)).center, before.center, 1); await page.keyboard.press("ControlOrMeta+z"); const radius = await box(page.getByLabel("Radial right radius")); const [x, y] = [radius.x + radius.width / 2, radius.y + radius.height / 2]; diff --git a/tests/paint.e2e.ts b/tests/paint.e2e.ts index c87c7e59..2e456a54 100644 --- a/tests/paint.e2e.ts +++ b/tests/paint.e2e.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { expect, openPhoto, test } from "./fixtures"; +import { expect, expectNear, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; import { box, choose, drag } from "./pointer"; @@ -31,6 +31,17 @@ async function photoToPage(page: Page) { ]; } +function expectPixels( + actual: readonly number[][], + expected: readonly (readonly number[])[], + tolerance?: number, +) { + expect(actual).toHaveLength(expected.length); + for (const [i, pixel] of expected.entries()) { + expectNear(actual[i], pixel, tolerance); + } +} + /** Export pixels inside the strokes and far above them. */ async function samples(page: Page) { const { samples } = await readImage(page, undefined, [ @@ -64,13 +75,13 @@ test("paint colors on a layer, swap them, erase, blend, and switch to a mask", a await test.step("a stroke paints the primary color", async () => { await page.getByLabel("Primary color").fill("#ff0000"); await drag(page, from, to, 16); - expect(await samples(page)).toEqual([[255, 0, 0, 255], gray]); + expectPixels(await samples(page), [[255, 0, 0, 255], gray]); }); await test.step("X swaps in the secondary color, white", async () => { await page.keyboard.press("x"); await expect(page.getByLabel("Secondary color")).toHaveValue("#ff0000"); await drag(page, from, to, 16); - expect(await samples(page)).toEqual([[255, 255, 255, 255], gray]); + expectPixels(await samples(page), [[255, 255, 255, 255], gray]); }); await test.step("Multiply by white leaves the image as it was", async () => { await choose( @@ -78,16 +89,16 @@ test("paint colors on a layer, swap them, erase, blend, and switch to a mask", a page.getByRole("combobox", { name: "Blend" }), "Multiply", ); - expect(await samples(page)).toEqual([gray, gray]); + expectPixels(await samples(page), [gray, gray]); await page.keyboard.press("ControlOrMeta+z"); }); await test.step("Alt erases the paint, and undo brings it back", async () => { await page.keyboard.down("Alt"); await drag(page, from, to, 16); await page.keyboard.up("Alt"); - expect(await samples(page)).toEqual([gray, gray]); + expectPixels(await samples(page), [gray, gray]); await page.keyboard.press("ControlOrMeta+z"); - expect(await samples(page)).toEqual([[255, 255, 255, 255], gray]); + expectPixels(await samples(page), [[255, 255, 255, 255], gray]); }); await test.step("the mode follows the selected layer", async () => { await mask.click(); @@ -107,51 +118,35 @@ test("paint colors on a layer, swap them, erase, blend, and switch to a mask", a }); }); -/** The red channel's least and most along each displayed row, `rows` in page pixels. */ +/** The red channel's least and most along each exported row, from `left` to `right` in photo pixels. */ async function redRange( page: Page, rows: readonly number[], left: number, right: number, ) { - const canvas = page - .getByRole("region", { name: "Image canvas" }) - .locator("canvas"); - const origin = await box(canvas); - const bytes = await canvas.screenshot(); - return page.evaluate( - async ([bytes, rows, left, right]) => { - const image = await createImageBitmap(new Blob([new Uint8Array(bytes)])); - const canvas = new OffscreenCanvas(image.width, image.height); - const context = canvas.getContext("2d"); - if (!context) throw new Error("Cannot read preview pixels."); - context.drawImage(image, 0, 0); - return rows.map((y) => { - const { data } = context.getImageData(left, y, right - left, 1); - const red = data.filter((_, i) => i % 4 === 0); - return [Math.min(...red), Math.max(...red)]; - }); - }, - [ - [...bytes], - rows.map((y) => Math.round(y - origin.y)), - Math.round(left - origin.x), - Math.round(right - origin.x), - ] as const, - ); + const xs = Array.from({ length: right - left }, (_, i) => left + i); + const points = rows.flatMap((y) => xs.map((x) => [x, y] as const)); + const { samples = [] } = await readImage(page, undefined, points); + return rows.map((_, row) => { + const red = samples + .slice(row * xs.length, (row + 1) * xs.length) + .map(([red]) => red); + return [Math.min(...red), Math.max(...red)]; + }); } -/** Draws a light stroke across the photo in small moves; each row along it must show and stay within a level. */ +/** Draws a light stroke across the photo in small moves; each exported row along it must show and stay within a level or two. */ async function expectSmoothStroke(page: Page, y: number) { await setBrush(page, { Size: "200", Flow: "40" }); const at = await photoToPage(page); // Each small move stamps a dab or two, which used to round to 8 bits apart and leave rings. - await drag(page, at(-300, y), at(300, y), 120); - const rows = [0, 25, 50, 70].map((offset) => at(0, y + offset)[1]); - const ranges = await redRange(page, rows, at(-100, 0)[0], at(100, 0)[0]); + await drag(page, at(-300, y), at(300, y), 40); + const rows = [0, 25, 50, 70].map((offset) => 400 + y + offset); + const ranges = await redRange(page, rows, 500, 700); expect(ranges[0][0]).toBeGreaterThan(gray[0] + 20); for (const [least, most] of ranges) { - expect(most - least).toBeLessThanOrEqual(1); + expect(most - least).toBeLessThanOrEqual(2); } } @@ -177,215 +172,157 @@ test("light strokes drawn a little at a time come out smooth, on a mask and in c }); }); -test("strokes settle into pixels, undo takes them back, and scenes keep them", async ({ +test("strokes on paint and on a brush mask settle into pixels, undo takes them back, and scenes keep them", async ({ page, }) => { test.setTimeout(120_000); - await openPhoto(page); - const layer = () => - page.evaluate(() => { - const [, paint] = window.openlight.getState().scene?.layers ?? []; - return paint?.kind === "paint" - ? { raster: paint.raster, strokes: paint.strokes.length } - : undefined; - }); - // Short strokes down the image, red then blue, the last across the center. - const paint = (from: number, to: number) => - page.evaluate( - ([from, to]) => { - const [, layer] = window.openlight.getState().scene?.layers ?? []; - const id = layer?.id ?? window.openlight.addLayer("paint"); - for (let i = from; i < to; i++) { - const x = i === 99 ? 580 : 100 + (i % 10) * 100; - const y = i === 99 ? 400 : 80 + Math.floor(i / 10) * 60; - window.openlight.addPaintStroke(id, { - mode: "paint", - size: 30, - feather: 0.5, - flow: 1, - color: i === 99 ? "#0000ff" : "#ff0000", - points: [ - [x, y, 1], - [x + 40, y, 1], - ], - }); - } - }, - [from, to] as const, - ); - await paint(0, 99); - expect(await layer()).toEqual({ raster: undefined, strokes: 99 }); - const before = await samples(page); - expect(before[0]).toEqual(gray); + for (const kind of ["paint", "mask"] as const) { + await test.step(kind, async () => { + await openPhoto(page); + const layer = () => + page.evaluate(() => { + const [, layer] = window.openlight.getState().scene?.layers ?? []; + if (layer?.kind === "paint") { + return { raster: layer.raster, strokes: layer.strokes.length }; + } + if (layer?.kind === "mask" && layer.mask.kind === "brush") { + return { + raster: layer.mask.raster, + strokes: layer.mask.strokes.length, + }; + } + }); + // Short strokes down the image, the hundredth across its center; paint is red, and a mask brightens by a stop. + const paint = (from: number, to: number) => + page.evaluate( + ([kind, from, to]) => { + const api = window.openlight; + const [, layer] = api.getState().scene?.layers ?? []; + const id = layer?.id ?? api.addLayer(kind); + const strokes = Array.from({ length: to }, (_, i) => { + const [x, y] = + i === 99 + ? [580, 400] + : [100 + (i % 10) * 100, 80 + Math.floor(i / 10) * 60]; + return { + mode: "paint" as const, + size: 30, + feather: 0.5, + flow: 1, + points: [ + [x, y, 1], + [x + 40, y, 1], + ] as [number, number, number][], + }; + }); + if (kind === "paint") { + for (const stroke of strokes.slice(from)) { + api.addPaintStroke(id, { ...stroke, color: "#ff0000" }); + } + return; + } + api.setLayerMask(id, { kind: "brush", strokes }); + api.setAdjustments({ exposure: 1 }, id); + }, + [kind, from, to] as const, + ); + /** Export pixels inside an early stroke and at the hundredth. */ + const pixels = async () => + ( + await readImage(page, undefined, [ + [620, 80], + [600, 400], + ]) + ).samples ?? []; + await paint(0, 99); + expect(await layer()).toEqual({ raster: undefined, strokes: 99 }); + const before = await pixels(); + expect(before[0][0]).toBeGreaterThan(gray[0] + 20); + expectNear(before[1], gray, 1); - await test.step("the hundredth stroke settles every stroke into pixels", async () => { - await paint(99, 100); - await expect.poll(layer).toMatchObject({ strokes: 0 }); - expect((await layer())?.raster).toEqual(expect.any(String)); - const settled = await samples(page); - expect(settled[0][2]).toBeGreaterThan(200); - expect(settled[1]).toEqual(before[1]); - }); + const raster = + await test.step("the hundredth stroke settles every stroke into pixels", async () => { + await paint(99, 100); + await expect.poll(layer).toMatchObject({ strokes: 0 }); + const { raster } = (await layer()) ?? {}; + expect(raster).toEqual(expect.any(String)); + const [early, last] = await pixels(); + expectNear(early, before[0], 2); + expect(last[0]).toBeGreaterThan(gray[0] + 20); + return raster; + }); + const settled = await pixels(); - await test.step("undo goes back to the strokes, and redo to the pixels", async () => { - const settled = await samples(page); - await page.evaluate(() => window.openlight.undo()); - expect(await layer()).toEqual({ raster: undefined, strokes: 99 }); - expect(await samples(page)).toEqual(before); - await page.evaluate(() => window.openlight.redo()); - expect(await samples(page)).toEqual(settled); - }); + await test.step("undo goes back to the strokes, and redo to the pixels", async () => { + await page.evaluate(() => window.openlight.undo()); + expect(await layer()).toEqual({ raster: undefined, strokes: 99 }); + expectPixels(await pixels(), before, 1); + await page.evaluate(() => window.openlight.redo()); + expect(await layer()).toEqual({ raster, strokes: 0 }); + expectPixels(await pixels(), settled, 1); + }); - await test.step("a stroke over the pixels undoes by loading them again", async () => { - const settled = await samples(page); - await paint(100, 101); - expect((await layer())?.strokes).toBe(1); - await page.evaluate(() => window.openlight.undo()); - expect(await samples(page)).toEqual(settled); - }); + if (kind === "paint") { + await test.step("a stroke over the pixels undoes by loading them again", async () => { + await paint(100, 101); + expect((await layer())?.strokes).toBe(1); + await page.evaluate(() => window.openlight.undo()); + expectPixels(await pixels(), settled, 1); + }); + } - await test.step("a scene file keeps the pixels", async () => { - const settled = await samples(page); - const raster = (await layer())?.raster; - await page.evaluate(async () => { - const scene = await window.openlight.exportScene(); - await window.openlight.openFile(scene); + await test.step("a scene file keeps the pixels", async () => { + await page.evaluate(async () => { + const scene = await window.openlight.exportScene(); + await window.openlight.openFile(scene); + }); + await expect.poll(layer).toEqual({ raster, strokes: 0 }); + expectPixels(await pixels(), settled, 1); + }); }); - await expect.poll(layer).toEqual({ raster, strokes: 0 }); - expect(await samples(page)).toEqual(settled); - }); + } }); -test("a brush mask settles into pixels like paint, and undo and scene files keep them", async ({ +test("settling stays atomic when an edit lands during readback", async ({ page, }) => { - test.setTimeout(120_000); - await openPhoto(page); - const mask = () => - page.evaluate(() => { - const [, layer] = window.openlight.getState().scene?.layers ?? []; - return layer?.kind === "mask" && layer.mask.kind === "brush" - ? { raster: layer.mask.raster, strokes: layer.mask.strokes.length } - : undefined; + await page.goto("/tests/gpu.html"); + for (const [mode, action] of [ + ["paint", "append"], + ["mask", "append"], + ["paint", "undo"], + ["paint", "close"], + ] as const) { + await test.step(`${mode} with ${action}`, async () => { + const result = await page.evaluate( + async ({ mode, action }) => { + const path = "/tests/settle-gpu.ts"; + const { editDuringSettle } = (await import( + path + )) as typeof import("./settle-gpu"); + return editDuringSettle(mode, action); + }, + { mode, action }, + ); + expect(result.errors).toEqual([]); + expect(result.historyUnchanged).toBe(true); + expect(result.accepted).toBe(action === "append"); + if (action !== "close") { + expect(result.error).toBeLessThan(0.002); + } + if (action === "append") { + expect(result.raster).toEqual(expect.any(String)); + expect(result.strokes).toBe(1); + expect(result.stamped).toBe(101); + } else { + expect(result.unchanged).toBe(true); + expect(result.raster).toBeUndefined(); + expect(result.strokes).toBe(action === "undo" ? 99 : 100); + } }); - // Short strokes down the image, through a mask that brightens by a stop. - const paint = (count: number) => - page.evaluate((count) => { - const api = window.openlight; - const [, layer] = api.getState().scene?.layers ?? []; - const id = layer?.id ?? api.addLayer("mask"); - api.setLayerMask(id, { - kind: "brush", - strokes: Array.from({ length: count }, (_, i) => { - const [x, y] = [100 + (i % 10) * 100, 80 + Math.floor(i / 10) * 60]; - return { - mode: "paint" as const, - size: 30, - feather: 0.5, - flow: 1, - points: [ - [x, y, 1], - [x + 40, y, 1], - ] as [number, number, number][], - }; - }), - }); - api.setAdjustments({ exposure: 1 }, id); - }, count); - const inside = async () => - (await readImage(page, undefined, [[620, 80]])).samples?.[0]; - await paint(99); - expect(await mask()).toEqual({ raster: undefined, strokes: 99 }); - const before = await inside(); - expect(before?.[0]).toBeGreaterThan(gray[0] + 20); - await paint(100); - await expect.poll(mask).toMatchObject({ strokes: 0 }); - const raster = (await mask())?.raster; - expect(raster).toEqual(expect.any(String)); - expect(await inside()).toEqual(before); - await page.evaluate(() => window.openlight.undo()); - expect(await mask()).toEqual({ raster: undefined, strokes: 99 }); - await page.evaluate(() => window.openlight.redo()); - expect(await mask()).toEqual({ raster, strokes: 0 }); - expect(await inside()).toEqual(before); - await page.evaluate(async () => { - const scene = await window.openlight.exportScene(); - await window.openlight.openFile(scene); - }); - await expect.poll(mask).toEqual({ raster, strokes: 0 }); - expect(await inside()).toEqual(before); -}); - -test("Photoshop's keys switch the brush, its colors, feather, flow, and opacity", async ({ - page, -}) => { - await openPhoto(page); - const field = (name: string) => brushField(page, name); - const chip = (name: string) => brushChip(page, name); - await page.keyboard.press("b"); - await expect(chip("Mask")).toHaveAttribute("aria-pressed", "true"); - await page.keyboard.press("b"); - await expect(chip("Color")).toHaveAttribute("aria-pressed", "true"); - - const primary = page.getByLabel("Primary color"); - await primary.fill("#ff0000"); - await page.keyboard.press("d"); - await expect(primary).toHaveValue("#000000"); - - await page.keyboard.press("Shift+BracketRight"); - await expect(field("Feather")).toHaveValue("60"); - await page.keyboard.press("5"); - await expect(field("Flow")).toHaveValue("50"); - await page.keyboard.press("Shift+Digit3"); - await expect(field("Opacity")).toHaveValue("30"); - - // On a mask, X paints or erases, as black and white do in Photoshop. - await page.keyboard.press("b"); - await expect(chip("Mask")).toHaveAttribute("aria-pressed", "true"); - await page.keyboard.press("x"); - await expect(chip("Erase")).toHaveAttribute("aria-pressed", "true"); - await page.keyboard.press("x"); - await expect(chip("Paint")).toHaveAttribute("aria-pressed", "true"); + } }); -for (const [mode, action] of [ - ["paint", "append"], - ["mask", "append"], - ["paint", "undo"], - ["paint", "close"], -] as const) { - test(`${mode} settling remains atomic when ${action} happens during readback`, async ({ - page, - }) => { - await page.goto("/tests/gpu.html"); - const result = await page.evaluate( - async ({ mode, action }) => { - const path = "/tests/settle-gpu.ts"; - const { editDuringSettle } = (await import( - path - )) as typeof import("./settle-gpu"); - return editDuringSettle(mode, action); - }, - { mode, action }, - ); - expect(result.errors).toEqual([]); - expect(result.historyUnchanged).toBe(true); - expect(result.accepted).toBe(action === "append"); - if (action !== "close") { - expect(result.error).toBeLessThan(0.002); - } - if (action === "append") { - expect(result.raster).toEqual(expect.any(String)); - expect(result.strokes).toBe(1); - expect(result.stamped).toBe(101); - } else { - expect(result.unchanged).toBe(true); - expect(result.raster).toBeUndefined(); - expect(result.strokes).toBe(action === "undo" ? 99 : 100); - } - }); -} - test("photo exposure, contrast, and curves leave the selected paint color unchanged", async ({ page, }) => { @@ -408,7 +345,7 @@ test("photo exposure, contrast, and curves leave the selected paint color unchan points: [[600, 400, 1]], }); }); - expect((await samples(page))[0]).toEqual([185, 80, 80, 255]); + expectNear((await samples(page))[0], [185, 80, 80, 255]); }); test("deleting a paint layer at the limit lets the brush paint again", async ({ @@ -437,7 +374,7 @@ test("deleting a paint layer at the limit lets the brush paint again", async ({ await page.getByLabel("Primary color").fill("#ff0000"); const at = await photoToPage(page); await drag(page, at(-20, 0), at(20, 0)); - expect((await samples(page))[0]).toEqual([255, 0, 0, 255]); + expectNear((await samples(page))[0], [255, 0, 0, 255]); }); test("at the mask limit, nesting another brush is unavailable", async ({ diff --git a/tests/tool-shortcuts.e2e.ts b/tests/tool-shortcuts.e2e.ts index 736cf24f..8cea71e5 100644 --- a/tests/tool-shortcuts.e2e.ts +++ b/tests/tool-shortcuts.e2e.ts @@ -1,74 +1,106 @@ -import { expect, test } from "./fixtures"; +import { expect, openPhoto, test } from "./fixtures"; -test("Enter leaves retouch after selecting a patch by mouse or keyboard", async ({ +test("tool shortcuts switch the brush and its settings, remember submodes, and Enter leaves retouch", async ({ page, }) => { await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); - await expect( - page.getByRole("textbox", { name: "Exposure", exact: true }), - ).toHaveValue("0.00"); - await page.keyboard.press("h"); - const canvas = page.getByLabel("Healing canvas", { exact: true }); - const size = page.getByRole("textbox", { name: "Size", exact: true }); - await size.fill("20"); - await size.press("Enter"); - await canvas.click({ position: { x: 600, y: 500 } }); - const patch = page.getByRole("button", { - name: "Select patch 1", - exact: true, - }); - await expect(patch).toHaveAttribute("aria-pressed", "true"); - const state = await page.evaluate(() => window.openlight.getState()); - await patch.click(); - await expect(patch).not.toBeFocused(); - await page.keyboard.press("Enter"); - await expect(canvas).toHaveCount(0); - await expect( - page.getByRole("tab", { name: "Adjust", exact: true }), - ).toHaveAttribute("aria-selected", "true"); - expect(await page.evaluate(() => window.openlight.getState())).toEqual(state); - // Explicit keyboard activation still selects a patch and returns control to the canvas. - await patch.focus(); - await patch.press("Enter"); - await expect(canvas).toBeVisible(); - await expect(patch).not.toBeFocused(); - await page.keyboard.press("Enter"); - await expect(canvas).toHaveCount(0); - expect(await page.evaluate(() => window.openlight.getState())).toEqual(state); -}); - -test("tool shortcuts restore remembered submodes and cycle only the active tool", async ({ - page, -}) => { - await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); - await expect( - page.getByRole("textbox", { name: "Exposure", exact: true }), - ).toHaveValue("0.00"); - const modes = page.getByRole("group", { name: "Retouch mode" }); - const remove = modes.getByRole("button", { name: "Remove", exact: true }); + await openPhoto(page); + const brushBar = page.getByRole("group", { name: "Layer options" }); + const field = (name: string) => + brushBar.getByRole("textbox", { name, exact: true }); + const chip = (name: string) => + brushBar.getByRole("button", { name, exact: true }); const mask = page.getByLabel("Brush canvas", { exact: true }); const color = page.getByLabel("Paint canvas", { exact: true }); - await page.keyboard.press("h"); - await remove.click(); - await page.keyboard.press("b"); - await expect(mask).toBeVisible(); - await page.keyboard.press("b"); - await expect(color).toBeVisible(); - await page.keyboard.press("h"); - await expect(remove).toHaveAttribute("aria-pressed", "true"); - await page.keyboard.press("b"); - await expect(color).toBeVisible(); - await page.keyboard.press("b"); - await expect(mask).toBeVisible(); - await page.keyboard.press("c"); - await page.keyboard.press("b"); - await expect(mask).toBeVisible(); + const healing = page.getByLabel("Healing canvas", { exact: true }); + + await test.step("Photoshop's keys switch the brush, its colors, feather, flow, and opacity", async () => { + await page.keyboard.press("b"); + await expect(chip("Mask")).toHaveAttribute("aria-pressed", "true"); + await page.keyboard.press("b"); + await expect(chip("Color")).toHaveAttribute("aria-pressed", "true"); + + const primary = page.getByLabel("Primary color"); + await primary.fill("#ff0000"); + await page.keyboard.press("d"); + await expect(primary).toHaveValue("#000000"); + + await page.keyboard.press("Shift+BracketRight"); + await expect(field("Feather")).toHaveValue("60"); + await page.keyboard.press("5"); + await expect(field("Flow")).toHaveValue("50"); + await page.keyboard.press("Shift+Digit3"); + await expect(field("Opacity")).toHaveValue("30"); + + // On a mask, X paints or erases, as black and white do in Photoshop. + await page.keyboard.press("b"); + await expect(chip("Mask")).toHaveAttribute("aria-pressed", "true"); + await page.keyboard.press("x"); + await expect(chip("Erase")).toHaveAttribute("aria-pressed", "true"); + await page.keyboard.press("x"); + await expect(chip("Paint")).toHaveAttribute("aria-pressed", "true"); + }); + + await test.step("tools restore remembered submodes and cycle only the active tool", async () => { + const remove = page + .getByRole("group", { name: "Retouch mode" }) + .getByRole("button", { name: "Remove", exact: true }); + await page.keyboard.press("h"); + await remove.click(); + await page.keyboard.press("b"); + await expect(mask).toBeVisible(); + await page.keyboard.press("b"); + await expect(color).toBeVisible(); + await page.keyboard.press("h"); + await expect(remove).toHaveAttribute("aria-pressed", "true"); + await page.keyboard.press("b"); + await expect(color).toBeVisible(); + await page.keyboard.press("b"); + await expect(mask).toBeVisible(); + await page.keyboard.press("c"); + await page.keyboard.press("b"); + await expect(mask).toBeVisible(); + }); + + await test.step("Enter leaves retouch after selecting a patch by mouse or keyboard", async () => { + await page.keyboard.press("h"); + await page + .getByRole("group", { name: "Retouch mode" }) + .getByRole("button", { name: "Heal", exact: true }) + .click(); + const size = page.getByRole("textbox", { name: "Size", exact: true }); + await size.fill("20"); + await size.press("Enter"); + await healing.click({ position: { x: 600, y: 500 } }); + const patch = page.getByRole("button", { + name: "Select patch 1", + exact: true, + }); + await expect(patch).toHaveAttribute("aria-pressed", "true"); + // The patch's edit stays open until Heal has found its source. + await expect + .poll(() => page.evaluate(() => window.openlight.getState().history)) + .toMatchObject({ editing: false }); + const state = await page.evaluate(() => window.openlight.getState()); + await patch.click(); + await expect(patch).not.toBeFocused(); + await page.keyboard.press("Enter"); + await expect(healing).toHaveCount(0); + await expect( + page.getByRole("tab", { name: "Adjust", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + expect(await page.evaluate(() => window.openlight.getState())).toEqual( + state, + ); + // Explicit keyboard activation still selects a patch and returns control to the canvas. + await patch.focus(); + await patch.press("Enter"); + await expect(healing).toBeVisible(); + await expect(patch).not.toBeFocused(); + await page.keyboard.press("Enter"); + await expect(healing).toHaveCount(0); + expect(await page.evaluate(() => window.openlight.getState())).toEqual( + state, + ); + }); }); From c881fc97dd0960f201c4a014bc377f90edc34ace Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Wed, 14 Oct 2026 21:25:37 +0000 Subject: [PATCH 3/7] Judge healing and Remove browser tests by outcome Heal and Clone compare region means against the reference and merge into one page; Remove merges its interaction, reconstruction, and reshape checks into three tests with tolerances a solver survives on any GPU. Drop solver counters that Bun tests cover and the unused photo helper. Co-Authored-By: Claude Opus 5.5 --- tests/heal-edit.e2e.ts | 342 +++++++++++++++++++-------------------- tests/heal-gpu.ts | 161 ++++++++---------- tests/heal-settle.e2e.ts | 2 +- tests/heal.e2e.ts | 146 +++++++---------- tests/inpaint-gpu.ts | 122 +------------- tests/inpaint-photo.ts | 89 ---------- tests/inpaint.e2e.ts | 222 ++++++++----------------- 7 files changed, 363 insertions(+), 721 deletions(-) delete mode 100644 tests/inpaint-photo.ts diff --git a/tests/heal-edit.e2e.ts b/tests/heal-edit.e2e.ts index 3a6b0165..487de508 100644 --- a/tests/heal-edit.e2e.ts +++ b/tests/heal-edit.e2e.ts @@ -1,187 +1,177 @@ -import { expect, test } from "./fixtures"; +import { expect, expectNear, test } from "./fixtures"; import { readImage } from "./images"; import { box } from "./pointer"; -for (const mode of ["Remove", "Heal"]) { - test(`${mode} adds and subtracts separate strokes on the selected patch, on release`, async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/spots.svg"); - await expect( - page.getByRole("textbox", { name: "Exposure", exact: true }), - ).toHaveValue("0.00"); - await page.keyboard.press("h"); - await page - .getByRole("group", { name: "Retouch mode" }) - .getByRole("button", { name: mode, exact: true }) - .click(); - const canvas = page.getByLabel("Healing canvas", { exact: true }); - const bounds = await box(canvas); - const scale = Math.min( - (bounds.width - 48) / 1200, - (bounds.height - 48) / 800, - 2, - ); - const point = (x: number, y: number) => - [ - bounds.x + bounds.width / 2 + (x - 600) * scale, - bounds.y + bounds.height / 2 + (y - 400) * scale, - ] as const; - const size = page.getByRole("textbox", { name: "Size", exact: true }); - await size.fill("80"); - await size.press("Enter"); - const feather = page.getByRole("textbox", { name: "Feather", exact: true }); - await feather.fill("0"); - await feather.press("Enter"); - const samples = [ - [350, 160], - [350, 260], - [350, 210], +test("Remove adds and subtracts separate strokes on the selected patch, on release", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto("/"); + await page + .locator('input[type="file"]') + .setInputFiles("tests/fixtures/spots.svg"); + await expect( + page.getByRole("textbox", { name: "Exposure", exact: true }), + ).toHaveValue("0.00"); + await page.keyboard.press("h"); + await page + .getByRole("group", { name: "Retouch mode" }) + .getByRole("button", { name: "Remove", exact: true }) + .click(); + const canvas = page.getByLabel("Healing canvas", { exact: true }); + const bounds = await box(canvas); + const scale = Math.min( + (bounds.width - 48) / 1200, + (bounds.height - 48) / 800, + 2, + ); + const point = (x: number, y: number) => + [ + bounds.x + bounds.width / 2 + (x - 600) * scale, + bounds.y + bounds.height / 2 + (y - 400) * scale, ] as const; - const before = await readImage(page, undefined, samples); - if (mode !== "Remove") { - await page.keyboard.down("Alt"); - await page.mouse.click(...point(350, 560)); - await page.keyboard.up("Alt"); - } - await page.mouse.click(...point(350, 160)); - await expect - .poll(() => - page.evaluate(() => { - const { history } = window.openlight.getState(); - return "editing" in history && history.editing; - }), - ) - .toBe(false); - const patches = () => + const size = page.getByRole("textbox", { name: "Size", exact: true }); + await size.fill("80"); + await size.press("Enter"); + const feather = page.getByRole("textbox", { name: "Feather", exact: true }); + await feather.fill("0"); + await feather.press("Enter"); + const samples = [ + [350, 160], + [350, 260], + [350, 210], + ] as const; + const before = await readImage(page, undefined, samples); + await page.mouse.click(...point(350, 160)); + await expect + .poll(() => page.evaluate(() => { - const layer = window.openlight - .getState() - .scene?.layers.find((layer) => layer.kind === "heal"); - return layer?.kind === "heal" ? layer.patches : []; - }); - const original = (await patches())[0]; - const layerId = await page.evaluate( - () => window.openlight.getState().selectedLayerId, - ); - if (!layerId) throw Error("Healing layer missing."); - const undoCount = await page.evaluate( - () => window.openlight.getState().history.undoCount, - ); - await page.keyboard.down("Shift"); - await page.mouse.move(...point(350, 260)); - await page.mouse.down(); - await page.mouse.move(...point(355, 260)); - const preview = canvas.locator('[data-heal-stroke-preview="true"]'); - await expect(preview).toBeVisible(); - // Feather shortcuts must not discard a modifier stroke that is still being drawn. - await page.keyboard.press("BracketRight"); - await expect(preview).toBeVisible(); - await page.keyboard.press("BracketLeft"); - await expect(feather).toHaveValue("0"); - expect(await patches()).toEqual([original]); - expect( - await page.evaluate(() => window.openlight.getState().history.undoCount), - ).toBe(undoCount); - await page.mouse.up(); - await page.keyboard.up("Shift"); - await expect(preview).toHaveCount(0); - const added = (await patches())[0]; - expect(await patches()).toHaveLength(1); - expect(added).toMatchObject({ - id: original.id, - mode: original.mode, - strokes: [{ mode: "paint" }, { mode: "paint" }], + const { history } = window.openlight.getState(); + return "editing" in history && history.editing; + }), + ) + .toBe(false); + const patches = () => + page.evaluate(() => { + const layer = window.openlight + .getState() + .scene?.layers.find((layer) => layer.kind === "heal"); + return layer?.kind === "heal" ? layer.patches : []; }); - if (original.mode !== "remove") - expect(added).toHaveProperty("offset", original.offset); - expect( - await page.evaluate(() => window.openlight.getState().history.undoCount), - ).toBe(undoCount + 1); - const repaired = await readImage(page, undefined, samples); - for (const index of [0, 1]) { - for (const channel of repaired.samples?.[index].slice(0, 3) ?? []) - expect(Math.abs(channel - 128)).toBeLessThanOrEqual(4); - } - // Separate strokes never draw a connecting segment through the uncovered gap. - expect(repaired.samples?.[2]).toEqual(before.samples?.[2]); - await size.fill("20"); - await size.press("Enter"); - await page.keyboard.down("Alt"); - // A modifier over the anchor paints instead of starting a destination drag. - await page.mouse.move(...point(350, 260)); - await page.mouse.down(); - await page.mouse.move(...point(355, 260)); - await expect(preview).toBeVisible(); - expect((await patches())[0]).toEqual(added); - await page.mouse.up(); - await page.keyboard.up("Alt"); - expect(await patches()).toMatchObject([ - { - id: original.id, - strokes: [{ mode: "paint" }, { mode: "paint" }, { mode: "erase" }], - }, - ]); - const erased = await readImage(page, undefined, samples); - expect(erased.samples?.[1]).toEqual(before.samples?.[1]); - expect(erased.samples?.[0]).toEqual(repaired.samples?.[0]); - await page.keyboard.press("ControlOrMeta+z"); - expect((await patches())[0]).toEqual(added); - expect((await readImage(page, undefined, samples)).samples).toEqual( - repaired.samples, - ); - await page.keyboard.press("ControlOrMeta+Shift+z"); - expect((await readImage(page, undefined, samples)).samples).toEqual( - erased.samples, - ); - for (const cancel of ["Escape", "pointercancel", "b"]) { - await page - .getByRole("button", { name: "Select patch 1", exact: true }) - .click(); - await page.keyboard.down("Shift"); - await page.mouse.move(...point(350, 210)); - await page.mouse.down(); - await expect(preview).toBeVisible(); - if (cancel === "pointercancel") - await canvas.dispatchEvent("pointercancel", { pointerId: 1 }); - else await page.keyboard.press(cancel); - await page.mouse.up(); - await page.keyboard.up("Shift"); - expect((await patches())[0].strokes).toHaveLength(3); - if (cancel === "b") { - await page.keyboard.press("h"); - await page.evaluate((id) => window.openlight.selectLayer(id), layerId); - } - } - await page.mouse.click(...point(800, 500)); - await expect.poll(async () => (await patches()).length).toBe(2); - await expect - .poll(() => - page.evaluate(() => { - const { history } = window.openlight.getState(); - return "editing" in history && history.editing; - }), - ) - .toBe(false); - // Editing a selected earlier patch does not append to the last one. + const original = (await patches())[0]; + const layerId = await page.evaluate( + () => window.openlight.getState().selectedLayerId, + ); + if (!layerId) throw Error("Healing layer missing."); + const undoCount = await page.evaluate( + () => window.openlight.getState().history.undoCount, + ); + await page.keyboard.down("Shift"); + await page.mouse.move(...point(350, 260)); + await page.mouse.down(); + await page.mouse.move(...point(355, 260)); + const preview = canvas.locator('[data-heal-stroke-preview="true"]'); + await expect(preview).toBeVisible(); + // Feather shortcuts must not discard a modifier stroke that is still being drawn. + await page.keyboard.press("BracketRight"); + await expect(preview).toBeVisible(); + await page.keyboard.press("BracketLeft"); + await expect(feather).toHaveValue("0"); + expect(await patches()).toEqual([original]); + expect( + await page.evaluate(() => window.openlight.getState().history.undoCount), + ).toBe(undoCount); + await page.mouse.up(); + await page.keyboard.up("Shift"); + await expect(preview).toHaveCount(0); + const added = (await patches())[0]; + expect(await patches()).toHaveLength(1); + expect(added).toMatchObject({ + id: original.id, + mode: original.mode, + strokes: [{ mode: "paint" }, { mode: "paint" }], + }); + expect( + await page.evaluate(() => window.openlight.getState().history.undoCount), + ).toBe(undoCount + 1); + // Both spots fill with the gray around them. + const gray = [128, 128, 128, 255]; + const repaired = (await readImage(page, undefined, samples)).samples; + expectNear(repaired?.[0], gray, 4); + expectNear(repaired?.[1], gray, 4); + // Separate strokes never draw a connecting segment through the uncovered gap. + expectNear(repaired?.[2], before.samples?.[2] ?? [], 1); + await size.fill("20"); + await size.press("Enter"); + await page.keyboard.down("Alt"); + // A modifier over the anchor paints instead of starting a destination drag. + await page.mouse.move(...point(350, 260)); + await page.mouse.down(); + await page.mouse.move(...point(355, 260)); + await expect(preview).toBeVisible(); + expect((await patches())[0]).toEqual(added); + await page.mouse.up(); + await page.keyboard.up("Alt"); + expect(await patches()).toMatchObject([ + { + id: original.id, + strokes: [{ mode: "paint" }, { mode: "paint" }, { mode: "erase" }], + }, + ]); + // Erasing restores the spot under the eraser, and the rest of the patch stays repaired. + const erased = (await readImage(page, undefined, samples)).samples; + expectNear(erased?.[1], before.samples?.[1] ?? [], 2); + expectNear(erased?.[0], gray, 4); + await page.keyboard.press("ControlOrMeta+z"); + expect((await patches())[0]).toEqual(added); + const undone = (await readImage(page, undefined, samples)).samples; + expectNear(undone?.[1], gray, 4); + await page.keyboard.press("ControlOrMeta+Shift+z"); + const redone = (await readImage(page, undefined, samples)).samples; + expectNear(redone?.[1], before.samples?.[1] ?? [], 2); + for (const cancel of ["Escape", "pointercancel", "b"]) { await page .getByRole("button", { name: "Select patch 1", exact: true }) .click(); await page.keyboard.down("Shift"); - await page.mouse.click(...point(390, 260)); + await page.mouse.move(...point(350, 210)); + await page.mouse.down(); + await expect(preview).toBeVisible(); + if (cancel === "pointercancel") + await canvas.dispatchEvent("pointercancel", { pointerId: 1 }); + else await page.keyboard.press(cancel); + await page.mouse.up(); await page.keyboard.up("Shift"); - expect((await patches()).map((patch) => patch.strokes.length)).toEqual([ - 4, 1, - ]); - await page - .getByRole("button", { name: "Select patch 2", exact: true }) - .click(); - await page.mouse.click(...point(350, 260)); - // Erased holes do not intercept clicks with the earlier painted footprint. - await expect.poll(async () => (await patches()).length).toBe(3); - }); -} + expect((await patches())[0].strokes).toHaveLength(3); + if (cancel === "b") { + await page.keyboard.press("h"); + await page.evaluate((id) => window.openlight.selectLayer(id), layerId); + } + } + await page.mouse.click(...point(800, 500)); + await expect.poll(async () => (await patches()).length).toBe(2); + await expect + .poll(() => + page.evaluate(() => { + const { history } = window.openlight.getState(); + return "editing" in history && history.editing; + }), + ) + .toBe(false); + // Editing a selected earlier patch does not append to the last one. + await page + .getByRole("button", { name: "Select patch 1", exact: true }) + .click(); + await page.keyboard.down("Shift"); + await page.mouse.click(...point(390, 260)); + await page.keyboard.up("Shift"); + expect((await patches()).map((patch) => patch.strokes.length)).toEqual([ + 4, 1, + ]); + await page + .getByRole("button", { name: "Select patch 2", exact: true }) + .click(); + await page.mouse.click(...point(350, 260)); + // Erased holes do not intercept clicks with the earlier painted footprint. + await expect.poll(async () => (await patches()).length).toBe(3); +}); diff --git a/tests/heal-gpu.ts b/tests/heal-gpu.ts index 8986d513..9642a634 100644 --- a/tests/heal-gpu.ts +++ b/tests/heal-gpu.ts @@ -5,14 +5,25 @@ import type { HealMode, Scene } from "@/core/document"; import { createImageSource } from "@/core/image"; import { imageFrame } from "@/core/image/frame"; -/** A dark spot crosses a luminance edge on an HDR gradient. Its donor crosses the same edge. */ -export async function renderHealReference({ - mode = "heal", - opacity = 1, -}: { - mode?: Exclude; - opacity?: number; -} = {}) { +/** Document regions as `[x, y, width, height]`, aligned to the quarter-size proxy's pixels. */ +const regions = { + left: [120, 92, 8, 8], + right: [128, 92, 8, 8], + edge: [96, 92, 8, 8], + outside: [56, 56, 16, 16], +} as const; + +/** + * A dark spot crosses a luminance edge on an HDR gradient; its donor, 60 pixels below, crosses the + * same edge. Each render reports the mean RGBA of each region, at full size and as a proxy. + */ +export async function renderHealReference( + cases: readonly { + mode: Exclude; + opacity: number; + feather: number; + }[], +) { const gpu = await init(); const errors: string[] = []; gpu.onError((error) => errors.push(error.message)); @@ -29,104 +40,62 @@ export async function renderHealReference({ } `, ); - const scene: Scene = { - frame: imageFrame(image.size), - layers: [ - createImageLayer("source", "Reference"), - { - ...createLayer("heal"), - patches: [ - { - id: "spot", - mode, - feather: 0.25, - opacity, - strokes: [ - { - mode: "paint", - size: 56, - feather: 0, - flow: 1, - points: [[128, 96, 1]], - }, - ], - offset: [0, 60], - }, - ], - }, - ], - }; try { frame(gpu, (frame) => frame.pass(image, shader)); const results = []; - const healing = scene.layers[1]; - if (healing.kind !== "heal") throw Error("Healing layer missing."); - for (const feather of [0, 0.75]) { - const renderScene: Scene = { - ...scene, + for (const { mode, opacity, feather } of cases) { + const scene: Scene = { + frame: imageFrame(image.size), layers: [ - scene.layers[0], + createImageLayer("source", "Reference"), { - ...healing, - patches: healing.patches.map((patch) => ({ - ...patch, - feather, - })), + ...createLayer("heal"), + patches: [ + { + id: "spot", + mode, + feather, + opacity, + strokes: [ + { + mode: "paint", + size: 56, + feather: 0, + flow: 1, + points: [[128, 96, 1]], + }, + ], + offset: [0, 60], + }, + ], }, ], }; - for (const proxy of [false, true]) { - await renderer.update(renderScene, "spot", proxy, undefined, 0.25); + for (const density of [1, 0.25]) { + await renderer.update(scene, undefined, false, undefined, density); const output = renderer.fullImage(); const pixels = await output.readFloats(); - const input = renderer.inputImage("spot"); - if (!input) throw Error("Patch input missing."); - const before = - mode === "clone" ? await input.readFloats() : new Float32Array(); - const scale = 256 / output.size[0]; - const samples = [ - [128, 96], - [120, 96], - [136, 96], - [100, 96], - [158, 96], - [64, 64], - ].map(([x, y], index) => { - const px = Math.floor(x / scale); - const py = Math.floor(y / scale); - const p = [(px + 0.5) * scale, (py + 0.5) * scale]; - const i = (py * output.size[0] + px) * 4; - const background = [ - 0.2 + p[0] * 0.002 + (p[0] >= 128 ? 0.5 : 0), - 0.3 + p[1] * 0.001, - 1.4, - ]; - const inputPixel = (x: number, y: number) => - (Math.floor((y * input.size[1]) / 192) * input.size[0] + - Math.floor((x * input.size[0]) / 256)) * - 4; - const original = before.slice( - inputPixel(p[0], p[1]), - inputPixel(p[0], p[1]) + 3, - ); - const donor = before.slice( - inputPixel(p[0], p[1] + 60), - inputPixel(p[0], p[1] + 60) + 3, - ); - const cloned = - index < 3 - ? [...donor].map( - (value, channel) => - original[channel] + (value - original[channel]) * opacity, - ) - : background; - const expected = mode === "clone" ? cloned : background; - return { - actual: [...pixels.slice(i, i + 4)], - expected: [...expected, 0.75], - }; - }); - results.push({ feather, proxy, samples }); + const scale = output.size[0] / image.size[0]; + const mean = ([x, y, width, height]: readonly number[]) => { + const sum = [0, 0, 0, 0]; + let count = 0; + for (let py = y * scale; py < (y + height) * scale; py++) { + for (let px = x * scale; px < (x + width) * scale; px++) { + const i = (py * output.size[0] + px) * 4; + for (let channel = 0; channel < 4; channel++) + sum[channel] += pixels[i + channel]; + count++; + } + } + return sum.map((value) => value / count); + }; + const means = { + left: mean(regions.left), + right: mean(regions.right), + edge: mean(regions.edge), + outside: mean(regions.outside), + }; + results.push({ mode, opacity, feather, density, means }); } } return { results, errors }; diff --git a/tests/heal-settle.e2e.ts b/tests/heal-settle.e2e.ts index 6563a26f..bd63988d 100644 --- a/tests/heal-settle.e2e.ts +++ b/tests/heal-settle.e2e.ts @@ -96,7 +96,7 @@ for (const gesture of ["stroke", "destination"] as const) { ) .toBe(true); if (gesture === "stroke") await expect(preview).toBeVisible(); - else expect((await box(target)).x).toBeCloseTo(position.x); + else expect(Math.abs((await box(target)).x - position.x)).toBeLessThan(2); await page.mouse.move(start.x + 60, start.y + 30); await page.mouse.up(); await expect(preview).toHaveCount(0); diff --git a/tests/heal.e2e.ts b/tests/heal.e2e.ts index 42c6c37c..9af21b9c 100644 --- a/tests/heal.e2e.ts +++ b/tests/heal.e2e.ts @@ -1,38 +1,56 @@ -import { expect, test } from "./fixtures"; +import { expect, expectNear, test } from "./fixtures"; import { readImage } from "./images"; import { box, drag } from "./pointer"; -test("healing preserves an edge, alpha, and HDR texture at full and proxy resolution", async ({ +/** The reference's HDR gradient, with its luminance edge at x = 128, and its alpha. */ +function background(x: number, y: number) { + return [0.2 + x * 0.002 + (x >= 128 ? 0.5 : 0), 0.3 + y * 0.001, 1.4, 0.75]; +} + +test("Heal corrects donor color and Clone copies it, across an edge, with HDR, alpha, feather, opacity, and a proxy", async ({ page, }) => { await page.goto("/tests/gpu.html"); - const result = await page.evaluate(async () => { + const { results, errors } = await page.evaluate(async () => { const path = "/tests/heal-gpu.ts"; const { renderHealReference } = (await import( path )) as typeof import("./heal-gpu"); - return renderHealReference(); + return renderHealReference([ + { mode: "heal", opacity: 1, feather: 0 }, + { mode: "clone", opacity: 1, feather: 0 }, + { mode: "clone", opacity: 0.4, feather: 0 }, + { mode: "clone", opacity: 1, feather: 0.75 }, + ]); }); - expect(result.errors).toEqual([]); - for (const { feather, samples } of result.results) { - for (const [index, { actual, expected }] of samples.entries()) { - if (feather > 0 && index < 4) continue; - for (let channel = 0; channel < 4; channel++) { - expect(Math.abs(actual[channel] - expected[channel])).toBeLessThan( - 0.015, - ); - } - } + expect(errors).toEqual([]); + // The spot is a dark 0.02 under the patch; its donor lies 60 pixels below. + const repaired = (x: number, y: number, mode: string, opacity: number) => { + const target = mode === "heal" ? background(x, y) : background(x, y + 60); + return target.map((value, channel) => + channel === 3 ? value : 0.02 + (value - 0.02) * opacity, + ); + }; + for (const { mode, opacity, feather, density, means } of results) { + await test.step(`${mode} at opacity ${opacity}, feather ${feather}, density ${density}`, () => { + expectNear(means.outside, background(64, 64), 0.01); + if (feather > 0) return; + expectNear(means.left, repaired(124, 96, mode, opacity), 0.02); + expectNear(means.right, repaired(132, 96, mode, opacity), 0.02); + }); } - const interactive = result.results.filter(({ proxy }) => proxy); - expect(interactive).toHaveLength(2); - for (let channel = 0; channel < 4; channel++) { - expect( - Math.abs( - interactive[0].samples[0].actual[channel] - - interactive[1].samples[0].actual[channel], - ), - ).toBeLessThan(0.002); + // Feather softens the patch's border: its edge keeps more of the original than a hard patch's. + for (const density of [1, 0.25]) { + const edge = (feather: number) => + results.find( + (result) => + result.mode === "clone" && + result.opacity === 1 && + result.feather === feather && + result.density === density, + )?.means.edge[1] ?? Number.NaN; + expect(edge(0.75)).toBeGreaterThan(background(100, 96)[1]); + expect(edge(0.75)).toBeLessThan(edge(0) - 0.005); } }); @@ -129,26 +147,12 @@ test("Healing paints patches, edits them, and undoes", async ({ page }) => { await expect( patchList.getByRole("button", { name: "Select patch 1" }), ).toHaveAttribute("aria-pressed", "true"); - await expect(canvas.locator('[data-heal-outline="destination"]')).toHaveCount( - 1, - ); - await expect(canvas.locator('[data-heal-outline="source"]')).toHaveCount(1); - await expect(canvas.locator('[data-heal-source-handle="true"]')).toHaveCount( - 1, - ); - await expect( - canvas.locator('[data-heal-destination-handle="true"]'), - ).toHaveCount(1); - await expect(canvas.locator("[data-heal-connector]")).toHaveCount(0); await expect( - canvas.getByRole("button", { name: "Select patch 1" }), - ).toHaveCount(0); + canvas.locator('[data-heal-source-handle="true"]'), + ).toBeVisible(); await expect( page.getByRole("textbox", { name: "Opacity", exact: true }), ).toHaveValue("100"); - await expect( - page.getByRole("textbox", { name: "Opacity", exact: true }), - ).toHaveCount(1); await size.fill("120"); await size.press("Enter"); expect( @@ -172,8 +176,8 @@ test("Healing paints patches, edits them, and undoes", async ({ page }) => { for (const channel of after.samples?.[0].slice(0, 3) ?? []) { expect(Math.abs(channel - 128)).toBeLessThanOrEqual(5); } - expect(after.samples?.[1]).toEqual(before.samples?.[1]); - expect(after.samples?.[2]).toEqual([128, 128, 128, 255]); + expectNear(after.samples?.[1], before.samples?.[1] ?? [], 1); + expectNear(after.samples?.[2], [128, 128, 128, 255], 4); await page.evaluate( ({ id, patch }) => window.openlight.setHealSource(id, patch, [20, 360]), { id: layer.id, patch: layer.patches[0].id }, @@ -183,8 +187,10 @@ test("Healing paints patches, edits them, and undoes", async ({ page }) => { ).toBeGreaterThan(120); await page.keyboard.press("ControlOrMeta+z"); await page.keyboard.press("ControlOrMeta+z"); - expect((await readImage(page, undefined, [[350, 200]])).samples?.[0]).toEqual( - before.samples?.[0], + expectNear( + (await readImage(page, undefined, [[350, 200]])).samples?.[0], + before.samples?.[0] ?? [], + 1, ); // Alt-click explicitly chooses the next donor using the same document mapping. await page.keyboard.down("Alt"); @@ -220,8 +226,10 @@ test("Healing paints patches, edits them, and undoes", async ({ page }) => { if (!patch || patch.mode === "remove") throw Error("Healing patch missing"); return { id: patch.id }; }); - expect((await readImage(page, undefined, [[350, 200]])).samples?.[0]).toEqual( + expectNear( + (await readImage(page, undefined, [[350, 200]])).samples?.[0], [128, 128, 128, 255], + 4, ); // Automatic source drops the manual donor, so the next patch searches for its own. await automatic.click(); @@ -297,11 +305,6 @@ test("Healing paints patches, edits them, and undoes", async ({ page }) => { const grabX = handleBounds.x + handleBounds.width / 2 + 11; const grabY = handleBounds.y + handleBounds.height / 2; await page.mouse.move(grabX, grabY); - await expect(handle).toHaveCSS("cursor", "grab"); - await expect(canvas.locator("svg:has(radialGradient)")).toHaveCount(0); - await page.mouse.move(bounds.x + 10, bounds.y + 10); - await expect(canvas.locator("svg:has(radialGradient)")).toHaveCount(1); - await page.mouse.move(grabX, grabY); await page.mouse.down(); await page.mouse.move(grabX + 30, grabY); // The repair follows the donor while it is dragged, not only once it drops. @@ -486,39 +489,6 @@ test("Healing takes the first stroke after selecting Heal and finds donors insid expect(patches[0].offset).not.toEqual([0, 0]); }); -test("Clone copies donor color without correction, with feather, opacity, HDR, and proxy rendering", async ({ - page, -}) => { - await page.goto("/tests/gpu.html"); - for (const opacity of [1, 0.4]) { - const result = await page.evaluate(async (opacity) => { - const path = "/tests/heal-gpu.ts"; - const { renderHealReference } = (await import( - path - )) as typeof import("./heal-gpu"); - return renderHealReference({ mode: "clone", opacity }); - }, opacity); - expect(result.errors).toEqual([]); - for (const { samples } of result.results) { - for (const [index, { actual, expected }] of samples.entries()) { - if (index === 3) continue; - for (let channel = 0; channel < 4; channel++) { - expect(Math.abs(actual[channel] - expected[channel])).toBeLessThan( - 0.015, - ); - } - } - } - for (const proxy of [false, true]) { - const edges = result.results - .filter((result) => result.proxy === proxy) - .map(({ samples }) => samples[3].actual); - expect(edges[1][1]).toBeLessThan(edges[0][1] - 0.001); - expect(edges[1][3]).toBeCloseTo(0.75, 3); - } - } -}); - test("retouch modes cycle, preserve each family's brush, and keep patches independent", async ({ page, }) => { @@ -609,7 +579,7 @@ test("retouch modes cycle, preserve each family's brush, and keep patches indepe [350, 200], [600, 400], ]); - expect(copied.samples?.[0]).toEqual(copied.samples?.[1]); + expectNear(copied.samples?.[0], copied.samples?.[1] ?? [], 2); // Clone shares modifier-stroke editing with Heal, but must keep its mode and donor. const originalClone = (await patches())[0]; await page.keyboard.down("Shift"); @@ -694,19 +664,11 @@ test("retouch modes cycle, preserve each family's brush, and keep patches indepe .click(); await expect(clone).toHaveAttribute("aria-pressed", "true"); await expect(feather).toHaveValue("45"); - const cursorFeather = canvas - .locator('[data-brush-cursor="true"] stop') - .nth(1); - await page.mouse.move(target[0] + 80, target[1]); - await expect(cursorFeather).toHaveAttribute("offset", "0.55"); await feather.fill("80"); await feather.press("Enter"); - await page.mouse.move(target[0] + 80, target[1]); - await expect(feather).toHaveValue("80"); - await expect(cursorFeather).toHaveAttribute("offset", `${1 - 0.8}`); + expect((await patches())[0].feather).toBe(0.8); await page.keyboard.press("ControlOrMeta+z"); await expect(feather).toHaveValue("45"); - await expect(cursorFeather).toHaveAttribute("offset", "0.55"); const next = point(950, 600); await page.mouse.click(next[0], next[1]); expect((await patches()).at(-1)?.feather).toBe(0.45); diff --git a/tests/inpaint-gpu.ts b/tests/inpaint-gpu.ts index bea82642..791050a0 100644 --- a/tests/inpaint-gpu.ts +++ b/tests/inpaint-gpu.ts @@ -1,5 +1,4 @@ import { effect, frame, init, target } from "vgpu"; -import { encodeImage } from "@/app/editor/export/export-image"; import { createImageLayer, createLayer } from "@/app/editor/layers"; import { createEditorRenderer } from "@/app/editor/renderer"; import { @@ -13,12 +12,7 @@ import { imageFrame } from "@/core/image/frame"; import { addHealStroke, addRemovePatch } from "@/features/heal/edits"; import { addLayer } from "@/features/layers/edits"; -export type InpaintFixture = - | "flat" - | "gradient" - | "texture" - | "edge" - | "structure"; +export type InpaintFixture = "flat" | "gradient" | "texture" | "edge"; /** Known backgrounds make removal measurable, including cases that cannot be inferred locally. */ export async function renderInpaintReference( @@ -40,8 +34,6 @@ export async function renderInpaintReference( texture: "vec3f(0.3, 0.5, 0.7) + 0.12 * sin(p.x * 0.785398) * cos(p.y * 0.785398)", edge: "select(vec3f(0.2, 0.35, 0.6), vec3f(0.65, 0.5, 0.3), p.y >= 72.0)", - structure: - "select(vec3f(0.55, 0.22, 0.12), vec3f(0.75), (u32(p.y) % 24u < 2u) || (u32(p.x + f32(u32(p.y) / 24u % 2u) * 24.0) % 48u < 2u))", }; const shader = (defect: boolean) => effect( @@ -85,12 +77,8 @@ export async function renderInpaintReference( }); await gpu.gpu.queue.onSubmittedWorkDone(); const density = proxy ? 0.5 : 1; - const start = performance.now(); // A patch waiting for its field synthesizes in full whatever the display; a proxy then shows the field it keeps. await renderer.update(scene, undefined, false, undefined, density); - await gpu.gpu.queue.onSubmittedWorkDone(); - const completedMs = performance.now() - start; - const solved = renderer.inspect(); await renderer.update(scene, undefined, proxy, undefined, density); const result = renderer.fullImage(); const actual = await result.readFloats(); @@ -128,44 +116,6 @@ export async function renderInpaintReference( alphaError = Math.max(alphaError, Math.abs(actual[index + 3] - 0.75)); } } - const images = await Promise.all( - [image, result, clean].map(async (target) => [ - ...new Uint8Array(await (await encodeImage(gpu, target)).arrayBuffer()), - ]), - ); - await renderer.update( - { ...scene, layers: [scene.layers[0], { ...healing, visible: false }] }, - undefined, - proxy, - undefined, - density, - ); - const fields = () => - renderer.inspect().rasters.filter(({ id }) => id.endsWith("/field")) - .length; - const hiddenFields = fields(); - await renderer.update(scene, undefined, proxy, undefined, density); - const shown = renderer.inspect(); - const visible = await renderer.fullImage().readFloats(); - const visibilityError = visible.reduce( - (maximum, value, index) => - Math.max(maximum, Math.abs(value - actual[index])), - 0, - ); - await renderer.update( - { - ...scene, - layers: [ - scene.layers[0], - { ...healing, patches: [{ ...patch, opacity: 0.5 }] }, - ], - }, - undefined, - proxy, - undefined, - density, - ); - const cached = renderer.inspect(); await renderer.update( { ...scene, @@ -182,7 +132,6 @@ export async function renderInpaintReference( undefined, density, ); - const exposed = renderer.inspect(); // The patch keeps its donors, whose colors follow the exposure below it. const brighter = await renderer.fullImage().readFloats(); let exposedError = 0; @@ -222,37 +171,15 @@ export async function renderInpaintReference( Math.max(maximum, Math.abs(value - actual[index])), 0, ); - await renderer.update({ ...scene, layers: [scene.layers[0]] }); - const releasedFields = fields(); - await gpu.settled(); return { - fixture, - diameter, - proxy, - completedMs, beforeError: errorBefore / count, afterError: errorAfter / count, outsideError, alphaError, restoredError, - visibilityError, exposedError: exposedError / count, - hiddenFields, - shownSolverPasses: shown.passes.filter((name) => - name.includes("/inpaint/"), - ).length, finite: actual.every(Number.isFinite), errors, - images, - solverPasses: solved.passes.filter((name) => name.includes("/inpaint/")) - .length, - cachedSolverPasses: cached.passes.filter((name) => - name.includes("/inpaint/"), - ).length, - exposedSolverPasses: exposed.passes.filter((name) => - name.includes("/inpaint/"), - ).length, - releasedFields, }; } finally { renderer.dispose(); @@ -264,8 +191,8 @@ export async function renderInpaintReference( /** * A Remove patch over a dark bar on noise whose first stroke stops short of the bar's tip: a stroke - * added over the tip, and one erasing part of the patch, each synthesize the whole new shape, as - * painting it at once does, so nothing of the bar stays. + * added over the tip, and one erasing part of the patch, each synthesize the whole new shape, so + * nothing of the bar stays. */ export async function renderInpaintReshape() { const gpu = await init(); @@ -284,12 +211,11 @@ export async function renderInpaintReshape() { }, resources, ); - // The editor's renderer keeps each version's field in the document; another paints shapes at once. + // The editor's renderer keeps each version's field in the document. const renderer = createEditorRenderer(gpu, source, { field: (id) => document.resources.field(id), saveField: (id, field) => document.resources.fillField(id, field), }); - const fresh = createEditorRenderer(gpu, source); const stroke = ( mode: "paint" | "erase", from: readonly [number, number], @@ -338,32 +264,6 @@ export async function renderInpaintReshape() { await renderer.update(document.scene.getState()); reshaped.push(await renderer.fullImage().readFloats()); } - // The same shapes painted at once: the scene of each version, rendered with no field held. - const atOnce = []; - for (const count of [2, 3]) { - const scene = document.scene.getState(); - const [, healing] = scene.layers; - if (healing.kind !== "heal") throw Error("The Healing layer is gone."); - const [painted] = healing.patches; - if (painted?.mode !== "remove") throw Error("The Remove patch is gone."); - await fresh.update({ - ...scene, - layers: [ - scene.layers[0], - { - ...healing, - patches: [ - { - ...painted, - field: `at-once/${count}`, - strokes: painted.strokes.slice(0, count), - }, - ], - }, - ], - }); - atOnce.push(await fresh.fullImage().readFloats()); - } /** The red of each pixel `within` selects. */ function reds( pixels: Float32Array, @@ -377,9 +277,8 @@ export async function renderInpaintReshape() { } return selected; } - function change(a: Float32Array, b: Float32Array) { - return Math.max(...a.map((value, index) => Math.abs(value - b[index]))); - } + const change = (a: number[], b: number[]) => + Math.max(...a.map((value, index) => Math.abs(value - b[index]))); const bar = (x: number, y: number) => x >= 40 && x <= 150 && Math.abs(y - 72) < 6; const tip = (x: number, y: number) => bar(x, y) && x > 136; @@ -390,16 +289,11 @@ export async function renderInpaintReshape() { errors, missedTip: Math.min(...reds(first, tip)), barLeft: Math.min(...reds(added, bar)), - added: change(added, atOnce[0]), - erased: change(subtracted, atOnce[1]), - erasedRestored: change( - new Float32Array(reds(original, near)), - new Float32Array(reds(subtracted, near)), - ), + barLeftErased: Math.min(...reds(subtracted, bar)), + erasedRestored: change(reds(original, near), reds(subtracted, near)), }; } finally { renderer.dispose(); - fresh.dispose(); document.dispose(); gpu.dispose(); } diff --git a/tests/inpaint-photo.ts b/tests/inpaint-photo.ts deleted file mode 100644 index a0a03447..00000000 --- a/tests/inpaint-photo.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { effect, frame, init, target } from "vgpu"; -import { encodeImage } from "@/app/editor/export/export-image"; -import { createImageLayer, createLayer } from "@/app/editor/layers"; -import { createEditorRenderer } from "@/app/editor/renderer"; -import type { BrushStroke, Scene } from "@/core/document"; -import { createImageSource } from "@/core/image"; -import { decode } from "@/core/image/decode"; -import { imageFrame } from "@/core/image/frame"; - -/** Exercise decoding, removal and export without mounting React. A spot adds a known defect. */ -export async function removeFromPhoto(stroke: BrushStroke, spot = false) { - const gpu = await init(); - const errors: string[] = []; - gpu.onError((error) => errors.push(error.message)); - const blob = await (await fetch("/images/demo.jpg")).blob(); - const original = await decode( - gpu, - new File([blob], "demo.jpg", { type: blob.type }), - ); - const damaged = target(gpu, { - size: original.image.size, - format: "rgba16float", - }); - const source = createImageSource(damaged); - const renderer = createEditorRenderer(gpu, source); - const [x, y] = stroke.points[0]; - try { - const copy = effect( - gpu, - ` - struct Params { center: vec2f, radius: f32, damaged: u32 } - @group(0) @binding(0) var source: texture_2d; - @group(0) @binding(1) var params: Params; - @fragment fn fs_main(@builtin(position) p: vec4f) -> @location(0) vec4f { - let color = textureLoad(source, vec2i(p.xy), 0); - let defect = params.damaged != 0u && distance(p.xy, params.center) < params.radius; - return vec4f(select(color.rgb, vec3f(0.01), defect), color.a); - } - `, - { - set: { - source: original.image.color, - params: { - center: [x, y], - radius: stroke.size / 2 - 5, - damaged: Number(spot), - }, - }, - }, - ); - frame(gpu, (f) => f.pass(damaged, copy)); - const scene: Scene = { - frame: imageFrame(damaged.size), - layers: [ - createImageLayer("photo", "Demo"), - { - ...createLayer("heal"), - patches: [ - { - id: "object", - mode: "remove", - field: "object", - feather: stroke.feather, - opacity: 1, - strokes: [{ ...stroke, feather: 0 }], - }, - ], - }, - ], - }; - await gpu.gpu.queue.onSubmittedWorkDone(); - const start = performance.now(); - await renderer.update(scene); - await gpu.gpu.queue.onSubmittedWorkDone(); - const completedMs = performance.now() - start; - const images = await Promise.all( - [damaged, renderer.fullImage(), original.image].map(async (image) => [ - ...new Uint8Array(await (await encodeImage(gpu, image)).arrayBuffer()), - ]), - ); - await gpu.settled(); - return { completedMs, images, errors, storage: renderer.inspect() }; - } finally { - renderer.dispose(); - source.dispose(); - original.dispose(); - gpu.dispose(); - } -} diff --git a/tests/inpaint.e2e.ts b/tests/inpaint.e2e.ts index 40256bfd..99f2a543 100644 --- a/tests/inpaint.e2e.ts +++ b/tests/inpaint.e2e.ts @@ -1,19 +1,13 @@ -import { expect, test } from "./fixtures"; +import { expect, expectNear, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; import { box } from "./pointer"; -test("Remove previews each frame, solves on release, exports, undoes, and cancels", async ({ +test("Remove previews each frame, solves on release, exports, undoes, cancels, and moves on drop", async ({ page, }) => { test.setTimeout(90_000); await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); - await expect( - page.getByRole("textbox", { name: "Exposure", exact: true }), - ).toHaveValue("0.00"); + await openPhoto(page); const before = await readImage(page, undefined, [[350, 200]]); await page.keyboard.press("h"); const remove = page @@ -54,43 +48,27 @@ test("Remove previews each frame, solves on release, exports, undoes, and cancel await page.mouse.down(); const preview = canvas.locator('[data-heal-stroke-preview="true"]'); await expect(preview).toBeVisible(); + // The outline follows the stroke each frame, before anything is edited. const path = preview.locator("path").first(); - const initialWidth = (await box(path)).width; + let width = (await box(path)).width; for (let step = 1; step <= 6; step++) { await page.mouse.move(x + step * 20 * scale, y); await page.clock.runFor(17); - expect((await box(path)).width).toBeCloseTo( - initialWidth + step * 20 * scale, - 0, - ); + const grown = (await box(path)).width; + expect(grown).toBeGreaterThan(width); + width = grown; expect(await patchPoints()).toBeUndefined(); } - // The visible border grows with the stroke, beyond the initial circle. - const { samples: edge } = await readImage( - page, - await page.screenshot({ - clip: { - x: Math.round(x + 60 * scale), - y: Math.round(y - 160) - 1, - width: 1, - height: 3, - }, - }), - [ - [0, 0], - [0, 1], - [0, 2], - ], - ); - expect(edge?.some(([r, g, b]) => Math.min(r, g, b) > 220)).toBe(true); // Pausing while held never creates an edit or runs synthesis. await page.clock.runFor(500); expect(await patchPoints()).toBeUndefined(); expect( await page.evaluate(() => window.openlight.getState().history), ).toEqual(history); - expect((await readImage(page, undefined, [[350, 200]])).samples).toEqual( - before.samples, + expectNear( + (await readImage(page, undefined, [[350, 200]])).samples?.[0], + before.samples?.[0] ?? [], + 1, ); await page.mouse.move(x + 150 * scale, y); // Release also includes points queued since the last animation frame. @@ -101,8 +79,9 @@ test("Remove previews each frame, solves on release, exports, undoes, and cancel page.evaluate(() => window.openlight.getState().history.undoCount), ) .toBe(history.undoCount + 1); - expect((await patchPoints())?.at(-1)?.[0]).toBeCloseTo(500, 0); - expect((await patchPoints())?.length).toBeGreaterThanOrEqual(8); + const points = (await patchPoints()) ?? []; + expect(Math.abs((points.at(-1)?.[0] ?? Number.NaN) - 500)).toBeLessThan(1); + expect(points.length).toBeGreaterThanOrEqual(8); await expect(canvas.locator('[data-heal-source-handle="true"]')).toHaveCount( 0, ); @@ -116,34 +95,20 @@ test("Remove previews each frame, solves on release, exports, undoes, and cancel { mode: "remove", strokes: [{ size: expect.closeTo(320 / scale, 6) }] }, ]); expect(patches[0]).not.toHaveProperty("offset"); + // The dark spot fills with the gray around it in the export. const after = await readImage(page, undefined, [[350, 200]]); - for (const channel of after.samples?.[0].slice(0, 3) ?? []) - expect(Math.abs(channel - 128)).toBeLessThanOrEqual(2); + expectNear(after.samples?.[0].slice(0, 3), [128, 128, 128], 6); await page.keyboard.press("ControlOrMeta+z"); const undone = await readImage(page, undefined, [[350, 200]]); - expect(undone.samples).toEqual(before.samples); - // A brush crossing the viewport edge has no artificial contour along the clipping boundary. + expectNear(undone.samples?.[0], before.samples?.[0] ?? [], 1); + // A brush crossing the viewport edge keeps its outline's raster edge outside the viewport. await page.mouse.move(bounds.x + 30, bounds.y + bounds.height / 2); await page.mouse.down(); await page.clock.runFor(17); - const { samples: clippedEdge } = await readImage( - page, - await page.screenshot({ - clip: { - x: bounds.x, - y: bounds.y + bounds.height / 2, - width: 2, - height: 1, - }, - }), - [ - [0, 0], - [1, 0], - ], - ); - expect( - clippedEdge?.every((pixel) => Math.min(...pixel.slice(0, 3)) < 220), - ).toBe(true); + const region = await preview + .locator('[data-heal-outline="destination"] filter') + .getAttribute("x"); + expect(Number(region)).toBeLessThan(0); await page.keyboard.press("Escape"); await page.mouse.up(); for (const cancel of ["Escape", "pointercancel", "exit", "ControlOrMeta+z"]) { @@ -161,40 +126,21 @@ test("Remove previews each frame, solves on release, exports, undoes, and cancel await expect(preview).toHaveCount(0); } await page.clock.resume(); -}); -test("Remove moves on drop and cancels a pending move on undo, pointer cancellation, and tool exit", async ({ - page, -}) => { - await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); - await expect( - page.getByRole("textbox", { name: "Exposure", exact: true }), - ).toHaveValue("0.00"); + // A patch moves on drop, and undo, pointer cancellation, or tool exit cancel a pending move. + // Undo has emptied the history, closing the tool with its Healing layer. await page.keyboard.press("h"); - const size = page.getByRole("textbox", { name: "Size", exact: true }); + await expect(remove).toHaveAttribute("aria-pressed", "true"); await size.fill("20"); await size.press("Enter"); - const canvas = page.getByLabel("Healing canvas", { exact: true }); - const bounds = await box(canvas); await page.mouse.click( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); const handle = canvas.locator('[data-heal-destination-handle="true"]'); await expect(handle).toBeVisible(); - const points = () => - page.evaluate(() => { - const layer = window.openlight - .getState() - .scene?.layers.find((layer) => layer.kind === "heal"); - return layer?.kind === "heal" ? layer.patches[0]?.strokes[0].points : []; - }); - const initial = await points(); - const history = await page.evaluate( + const initial = await patchPoints(); + const moved = await page.evaluate( () => window.openlight.getState().history.undoCount, ); async function startMove() { @@ -210,15 +156,15 @@ test("Remove moves on drop and cancels a pending move on undo, pointer cancellat return { x, y }; } await startMove(); - expect(await points()).toEqual(initial); + expect(await patchPoints()).toEqual(initial); expect( await page.evaluate(() => window.openlight.getState().history), ).toMatchObject({ editing: false }); await page.mouse.up(); - await expect.poll(points).not.toEqual(initial); + await expect.poll(patchPoints).not.toEqual(initial); expect( await page.evaluate(() => window.openlight.getState().history.undoCount), - ).toBe(history + 1); + ).toBe(moved + 1); for (const cancel of ["undo", "pointercancel", "exit"]) { const { x, y } = await startMove(); if (cancel === "undo") await page.keyboard.press("ControlOrMeta+z"); @@ -226,38 +172,53 @@ test("Remove moves on drop and cancels a pending move on undo, pointer cancellat if (cancel === "exit") await page.keyboard.press("Escape"); await page.mouse.move(x + 60, y + 30); await page.mouse.up(); - expect(await points()).toEqual(initial); + expect(await patchPoints()).toEqual(initial); expect( await page.evaluate(() => window.openlight.getState().history), - ).toMatchObject({ undoCount: history, editing: false }); + ).toMatchObject({ undoCount: moved, editing: false }); } }); -test("Remove synthesizes a spot once, without donor selection, and follows the layers below", async ({ +test("Remove reconstructs flat, gradient, texture, and edge context, at full size and as a proxy, and follows the layers below", async ({ page, }) => { await page.goto("/tests/gpu.html"); - const result = await page.evaluate(async () => { + const results = await page.evaluate(async () => { const path = "/tests/inpaint-gpu.ts"; const { renderInpaintReference } = (await import( path )) as typeof import("./inpaint-gpu"); - return renderInpaintReference("flat"); + return { + flat: await renderInpaintReference("flat"), + gradient: await renderInpaintReference("gradient"), + texture: await renderInpaintReference("texture"), + edge: await renderInpaintReference("edge"), + proxy: await renderInpaintReference("texture", 32, true), + // A spot larger than the image leaves no clean donor. + noDonor: await renderInpaintReference("flat", 400), + }; }); - expect(result.errors).toEqual([]); - expect(result.finite).toBe(true); - expect(result.afterError).toBeLessThan(0.002); - expect(result.outsideError).toBe(0); - expect(result.alphaError).toBe(0); - expect(result.restoredError).toBe(0); - expect(result.visibilityError).toBe(0); - expect(result.exposedError).toBeLessThan(0.003); - expect(result.hiddenFields).toBe(1); - expect(result.shownSolverPasses).toBe(0); - expect(result.solverPasses).toBeGreaterThan(0); - expect(result.cachedSolverPasses).toBe(0); - expect(result.exposedSolverPasses).toBe(0); - expect(result.releasedFields).toBe(0); + for (const [name, result] of Object.entries(results)) { + await test.step(name, () => { + expect(result.errors).toEqual([]); + expect(result.finite).toBe(true); + expect(result.alphaError).toBeLessThan(0.001); + expect(result.outsideError).toBeLessThan(0.001); + }); + } + expect(results.flat.afterError).toBeLessThan(0.01); + for (const fixture of ["gradient", "texture", "edge"] as const) { + const { afterError, beforeError } = results[fixture]; + expect(afterError, fixture).toBeLessThan(0.015); + expect(afterError, fixture).toBeLessThan(beforeError * 0.05); + } + expect(results.proxy.afterError).toBeLessThan(0.04); + expect( + Math.abs(results.noDonor.afterError - results.noDonor.beforeError), + ).toBeLessThan(0.001); + // The fill keeps its donors, whose colors follow exposure below, and returns with it. + expect(results.flat.exposedError).toBeLessThan(0.01); + expect(results.flat.restoredError).toBeLessThan(0.002); }); test("Remove synthesizes the whole patch again when a stroke adds to or erases part of it", async ({ @@ -274,54 +235,9 @@ test("Remove synthesizes the whole patch again when a stroke adds to or erases p expect(result.errors).toEqual([]); // The first stroke stops short of the bar's tip, which stays dark. expect(result.missedTip).toBeLessThan(0.05); - // A stroke over the tip fills as painting both at once does, and nothing of the bar stays. - expect(result.added).toBeLessThan(0.002); + // A stroke over the tip leaves nothing of the bar, nor does erasing part of the patch, which + // restores what it erases. expect(result.barLeft).toBeGreaterThan(0.25); - // Erasing part of the patch synthesizes the rest again too, and restores what it erases. - expect(result.erased).toBeLessThan(0.002); - expect(result.erasedRestored).toBe(0); -}); - -for (const fixture of ["gradient", "texture", "edge"] as const) { - test(`Remove reconstructs ${fixture} context and preserves pixels outside the stroke`, async ({ - page, - }) => { - await page.goto("/tests/gpu.html"); - const result = await page.evaluate(async (fixture) => { - const path = "/tests/inpaint-gpu.ts"; - const { renderInpaintReference } = (await import( - path - )) as typeof import("./inpaint-gpu"); - return renderInpaintReference(fixture); - }, fixture); - expect(result.errors).toEqual([]); - expect(result.finite).toBe(true); - expect(result.afterError).toBeLessThan(0.015); - expect(result.afterError).toBeLessThan(result.beforeError * 0.05); - expect(result.outsideError).toBe(0); - expect(result.alphaError).toBe(0); - }); -} - -test("Remove supports a proxy and leaves an image alone when no clean donor exists", async ({ - page, -}) => { - await page.goto("/tests/gpu.html"); - const results = await page.evaluate(async () => { - const path = "/tests/inpaint-gpu.ts"; - const { renderInpaintReference } = (await import( - path - )) as typeof import("./inpaint-gpu"); - return [ - await renderInpaintReference("texture", 32, true), - await renderInpaintReference("flat", 400), - ]; - }); - for (const result of results) { - expect(result.errors).toEqual([]); - expect(result.finite).toBe(true); - expect(result.alphaError).toBe(0); - } - expect(results[0].afterError).toBeLessThan(0.04); - expect(results[1].afterError).toBe(results[1].beforeError); + expect(result.barLeftErased).toBeGreaterThan(0.25); + expect(result.erasedRestored).toBeLessThan(0.001); }); From d72b8eb2472416e5238a4c917b600a1ceb37fd9b Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Wed, 14 Oct 2026 21:29:48 +0000 Subject: [PATCH 4/7] Check crop, tone, effect, and range tests as a person would, on any GPU Compare colors within a GPU's rounding instead of exact bytes, wait for the canvas to show the dot before clicking it, judge the perspective proxy by its mean stray, merge tests that share a page load, and drop the perspective scene-file and draft step that scene-file.test.ts and draft tests cover. Co-Authored-By: Claude Opus 5.5 --- tests/auto-white-balance.e2e.ts | 43 +-- tests/clarity.e2e.ts | 3 +- tests/color-mixer.e2e.ts | 21 +- tests/crop.e2e.ts | 128 +++---- tests/grain.e2e.ts | 21 +- tests/lut.e2e.ts | 43 ++- tests/mask-range.e2e.ts | 112 +++--- tests/perspective-gpu.ts | 15 +- tests/range-picker.e2e.ts | 582 ++++++++++++++------------------ tests/rendering.e2e.ts | 93 +++-- tests/vignette.e2e.ts | 11 +- 11 files changed, 516 insertions(+), 556 deletions(-) diff --git a/tests/auto-white-balance.e2e.ts b/tests/auto-white-balance.e2e.ts index 5cd9bcc6..408b1fb2 100644 --- a/tests/auto-white-balance.e2e.ts +++ b/tests/auto-white-balance.e2e.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import type { Page } from "@playwright/test"; -import { expect, test } from "./fixtures"; +import { expect, expectNear, test } from "./fixtures"; import { readImage } from "./images"; /** Gray stripes under warm light, as linear sRGB encoded in an SVG; the middle stripe is middle gray. */ @@ -33,7 +33,7 @@ function spread([r, g, b]: number[]) { const undoCount = (page: Page) => page.evaluate(() => window.openlight.getState().history.undoCount); -test("Auto takes most of a warm photo's cast away as one undoable edit", async ({ +test("Auto takes most of a warm photo's cast away as one undoable edit, and drops a stale result", async ({ page, }) => { await page.goto("/"); @@ -59,7 +59,21 @@ test("Auto takes most of a warm photo's cast away as one undoable edit", async ( expect(incrementalTemperature).toBeLessThan(0); await page.getByRole("button", { name: "Undo", exact: true }).click(); - expect(await readImage(page)).toEqual(cast); + expectNear((await readImage(page)).center, cast.center, 1); + + await test.step("Auto drops its result when another photo opens while it measures", async () => { + await page.evaluate(async (svg) => { + const open = () => + window.openlight.loadImage( + new File([svg], "cast.svg", { type: "image/svg+xml" }), + ); + await open(); + const auto = window.openlight.autoWhiteBalance(); + await open(); + await auto; + }, castWedge()); + expect(await undoCount(page)).toBe(0); + }); }); test("Auto moves a RAW photo's white balance from As Shot toward neutral", async ({ @@ -92,31 +106,10 @@ test("Auto moves a RAW photo's white balance from As Shot toward neutral", async expect(balance?.temperature).toBeLessThan(asShot?.temperature ?? 0); await page.getByRole("button", { name: "Undo", exact: true }).click(); - expect(await readImage(page)).toEqual(cast); + expectNear((await readImage(page)).center, cast.center, 1); // The cast is measured on the As Shot development, so Auto gives the same balance again. await page.evaluate(() => window.openlight.autoWhiteBalance()); expect( await page.evaluate(() => window.openlight.getState().whiteBalance), ).toEqual(balance); }); - -test("Auto drops its result when another photo opens while it measures", async ({ - page, -}) => { - await page.goto("/"); - await page.waitForFunction(() => window.openlight); - const photo = [...Buffer.from(castWedge())]; - await page.evaluate(async (bytes) => { - const open = () => - window.openlight.loadImage( - new File([new Uint8Array(bytes)], "cast.svg", { - type: "image/svg+xml", - }), - ); - await open(); - const auto = window.openlight.autoWhiteBalance(); - await open(); - await auto; - }, photo); - expect(await undoCount(page)).toBe(0); -}); diff --git a/tests/clarity.e2e.ts b/tests/clarity.e2e.ts index f43c31a5..52fe7d93 100644 --- a/tests/clarity.e2e.ts +++ b/tests/clarity.e2e.ts @@ -95,12 +95,11 @@ test("detail filters follow Gaussian unsharp masking and preserve flat fields an for (const [i, pixel] of pixels.entries()) { for (const channel of pixel.slice(0, 3)) { const expected = expectedStep(xs[i], amount, sigma); - expect(Math.abs(channel - expected)).toBeLessThanOrEqual(2); + expect(Math.abs(channel - expected)).toBeLessThanOrEqual(4); } expect(pixel[3]).toBe(255); } } - expect(result.neutral[0]).toEqual([115, 115, 115, 255]); for (const pixels of result.filtered) { for (const [i, pixel] of pixels.entries()) { expect(pixel[3]).toBe(result.neutral[i][3]); diff --git a/tests/color-mixer.e2e.ts b/tests/color-mixer.e2e.ts index 6b36e628..1b9e61d0 100644 --- a/tests/color-mixer.e2e.ts +++ b/tests/color-mixer.e2e.ts @@ -6,7 +6,8 @@ function luminance(rgb: number[]) { return rgb[0] * 0.2627 + rgb[1] * 0.678 + rgb[2] * 0.0593; } -function expectClose(actual: number[], expected: number[], tolerance = 0.003) { +// A few half-float steps: GPUs round the shader's color conversions differently. +function expectClose(actual: number[], expected: number[], tolerance = 0.01) { for (const [i, value] of expected.entries()) { expect(Math.abs(actual[i] - value)).toBeLessThan(tolerance); } @@ -27,23 +28,23 @@ test("color mixing preserves luminance, neutrals, alpha and HDR, isolates ranges output.slice(index * 4, index * 4 + 3); const alpha = (output: number[]) => output.filter((_, i) => i % 4 === 3); const [neutral, hue, gray, lighter, blue, red] = outputs; - expect(neutral).toEqual(original); + expectClose(neutral, original); for (const output of outputs) { expect(output.every(Number.isFinite)).toBe(true); expect(alpha(output)).toEqual(alpha(original)); // Transparent black, mid gray, and HDR white have no color to mix. - expect(output.slice(32, 44)).toEqual(original.slice(32, 44)); + expectClose(output.slice(32, 44), original.slice(32, 44)); } for (let index = 0; index < sampleCount; index++) { if (index >= 8 && index <= 10) continue; const input = rgb(original, index); const light = luminance(input); - expect(Math.abs(luminance(rgb(hue, index)) - light)).toBeLessThan(0.003); + expect(Math.abs(luminance(rgb(hue, index)) - light)).toBeLessThan(0.01); expectClose(rgb(gray, index), [light, light, light]); expectClose( rgb(lighter, index), input.map((value) => value * 2), - 0.006, + 0.02, ); } for (const index of [0, 1, 2, 3, 4, 7]) { @@ -93,12 +94,12 @@ test("color mixer drags a vertical slider as one step, exports the selected colo }); await numeric.fill("-100"); await numeric.press("Enter"); + // The blue patch turns gray. const { samples } = await readImage(page, undefined, [[350, 200]]); - const pixel = samples?.[0] ?? []; - expect(pixel).toHaveLength(4); - for (const channel of pixel.slice(0, 3)) { - expect(Math.abs(channel - 79)).toBeLessThanOrEqual(2); - } + const [red, green, blue] = samples?.[0] ?? []; + expect(Math.max(red, green, blue) - Math.min(red, green, blue)).toBeLessThan( + 6, + ); await saturation.locator("..").dblclick(); await expect(saturation).toHaveValue("0"); await page.keyboard.press("ControlOrMeta+z"); diff --git a/tests/crop.e2e.ts b/tests/crop.e2e.ts index c2547b1e..aeec0e7a 100644 --- a/tests/crop.e2e.ts +++ b/tests/crop.e2e.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { expect, openPhoto, test } from "./fixtures"; +import { expect, expectNear, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; import { box, choose, drag, zoom } from "./pointer"; @@ -90,14 +90,17 @@ function expectParallel([first, second]: number[][]) { } } -/** The blue dot's center on the page, as the canvas shows it. */ -async function dotOnPage(page: Page) { - const canvas = page +function distance(a: readonly number[], b: readonly number[]) { + return Math.hypot(a[0] - b[0], a[1] - b[1]); +} + +/** The blue dot's center in the canvas's screenshot, or nothing while the canvas doesn't show it. */ +async function findDot(page: Page) { + const bytes = await page .getByRole("region", { name: "Image canvas" }) - .locator("canvas"); - const origin = await box(canvas); - const bytes = await canvas.screenshot(); - const [x, y] = await page.evaluate( + .locator("canvas") + .screenshot(); + return page.evaluate( async (bytes) => { const image = await createImageBitmap(new Blob([new Uint8Array(bytes)])); const canvas = new OffscreenCanvas(image.width, image.height); @@ -107,17 +110,35 @@ async function dotOnPage(page: Page) { const { data } = context.getImageData(0, 0, image.width, image.height); let [sx, sy, count] = [0, 0, 0]; for (let i = 0; i < data.length; i += 4) { - if (data[i + 2] > 200 && data[i] < 80 && data[i + 1] < 80) { + if (data[i + 2] > data[i] + 100 && data[i + 2] > data[i + 1] + 100) { sx += ((i / 4) % image.width) + 0.5; sy += Math.floor(i / 4 / image.width) + 0.5; count++; } } - return [sx / count, sy / count]; + return count < 20 ? [] : [sx / count, sy / count]; }, [...bytes], ); - return [origin.x + x, origin.y + y] as const; +} + +/** The blue dot's center on the page, once the canvas shows it in the same place twice in a row. */ +async function dotOnPage(page: Page) { + let previous: number[] = []; + let at: number[] = []; + await expect + .poll(async () => { + previous = at; + at = await findDot(page); + return at.length === 2 && previous.length === 2 + ? distance(at, previous) + : Number.POSITIVE_INFINITY; + }) + .toBeLessThan(0.5); + const origin = await box( + page.getByRole("region", { name: "Image canvas" }).locator("canvas"), + ); + return [origin.x + at[0], origin.y + at[1]] as const; } /** In the export, the blue dot's center and that of the dark paint within the dot's bounds. */ @@ -165,10 +186,6 @@ async function paintOnDot(page: Page) { }); } -function distance(a: readonly number[], b: readonly number[]) { - return Math.hypot(a[0] - b[0], a[1] - b[1]); -} - test("crop, rotate, flip and straighten the photo, then undo", async ({ page, }) => { @@ -212,7 +229,7 @@ test("crop, rotate, flip and straighten the photo, then undo", async ({ const cropped = await readImage(page); expect(cropped.size[0]).toBe(cropped.size[1]); expect(cropped.size[0]).toBeLessThan(800); - expect(cropped.corner).toEqual([0, 0, 0, 255]); + expectNear(cropped.corner, [0, 0, 0, 255]); expect((await state()).history.undoCount).toBe(1); await page.getByRole("button", { name: "Undo", exact: true }).click(); expect((await readImage(page)).size).toEqual([1200, 800]); @@ -225,11 +242,10 @@ test("crop, rotate, flip and straighten the photo, then undo", async ({ .click(); await panel.getByRole("button", { name: "Flip vertical" }).click(); await apply.click(); - expect(await readImage(page)).toEqual({ - size: [800, 1200], - center: [128, 128, 128, 255], - corner: [0, 0, 0, 255], - }); + const turned = await readImage(page); + expect(turned.size).toEqual([800, 1200]); + expectNear(turned.center, [128, 128, 128, 255]); + expectNear(turned.corner, [0, 0, 0, 255]); }); await test.step("a drag outside the crop straightens it, and reset restores the photo", async () => { @@ -244,11 +260,11 @@ test("crop, rotate, flip and straighten the photo, then undo", async ({ await drag(page, [x, y], [x, y + rise]); await apply.click(); expect((await state()).frame?.angle).toBeCloseTo(30, 0); - expect(await readImage(page)).toEqual({ - size: [1200, 800], - center: [128, 128, 128, 255], - corner: [32, 32, 32, 255], - }); + // The turned crop's corner falls on the dark band beside the black square. + const straightened = await readImage(page); + expect(straightened.size).toEqual([1200, 800]); + expectNear(straightened.center, [128, 128, 128, 255]); + expectNear(straightened.corner, [32, 32, 32, 255]); await open.click(); await reset.click(); await apply.click(); @@ -256,35 +272,15 @@ test("crop, rotate, flip and straighten the photo, then undo", async ({ }); }); -/** The stored draft's frame, read straight from IndexedDB. */ -function savedFrame(page: Page) { - return page.evaluate( - () => - new Promise((resolve, reject) => { - const opening = indexedDB.open("openlight", 1); - opening.onerror = () => reject(opening.error); - opening.onsuccess = () => { - const database = opening.result; - const request = database - .transaction("draft") - .objectStore("draft") - .get("latest"); - request.onsuccess = () => { - database.close(); - resolve(request.result?.scene.scene.frame); - }; - }; - }), - ); -} - -test("perspective makes converging lines parallel and stays with the photo through undo, turns, flips, scene files, and drafts", async ({ +test("perspective makes converging lines parallel, stays with the photo through undo, turns, and flips, and picks and paint land where it shows them", async ({ page, }) => { test.setTimeout(120_000); const state = () => page.evaluate(() => window.openlight.getState()); + const layers = async () => (await state()).scene?.layers ?? []; await openKeystone(page); const initial = await state(); + let upright = initial.frame; const crop = page.getByRole("group", { name: "Crop" }); const vertical = crop.getByRole("textbox", { name: "Vertical", exact: true }); const horizontal = crop.getByRole("textbox", { @@ -323,6 +319,7 @@ test("perspective makes converging lines parallel and stays with the photo throu const { frame, history } = await state(); expect(frame?.perspective).toEqual([0, -50]); expect(history.undoCount).toBe(1); + upright = frame; expect((await readImage(page)).size).toEqual([1200, 800]); expectParallel(await lines(page, [0.25, 0.75])); }); @@ -331,7 +328,7 @@ test("perspective makes converging lines parallel and stays with the photo throu await page.keyboard.press("ControlOrMeta+z"); expect((await state()).frame).toEqual(initial.frame); await page.keyboard.press("ControlOrMeta+Shift+z"); - expect((await state()).frame?.perspective).toEqual([0, -50]); + expect((await state()).frame).toEqual(upright); }); await test.step("a quarter turn and a flip carry the correction, shown along the displayed axes", async () => { @@ -349,34 +346,11 @@ test("perspective makes converging lines parallel and stays with the photo throu expectParallel(await lines(page, [0.25, 0.75], 1)); }); - await test.step("a scene file and the draft reopen with the correction", async () => { - const corrected = await state(); - const shown = await lines(page, [0.25, 0.75], 1); - await page.evaluate(async () => { - const api = window.openlight; - await api.loadScene(await api.exportScene()); - }); - const reopened = await state(); - expect(reopened.documentId).not.toBe(corrected.documentId); - expect(reopened.frame).toEqual(corrected.frame); - expect(await lines(page, [0.25, 0.75], 1)).toEqual(shown); - await expect.poll(() => savedFrame(page)).toEqual(corrected.frame); - await page.reload(); - await page.getByRole("button", { name: "Recover", exact: true }).click(); - await expect - .poll(async () => (await state()).frame) - .toEqual(corrected.frame); + await test.step("undo returns the correction upright", async () => { + await page.keyboard.press("ControlOrMeta+z"); + expect((await state()).frame).toEqual(upright); + expect((await readImage(page)).size).toEqual([1200, 800]); }); -}); - -test("brush, retouch, and color picks land where the corrected photo shows them, and paint stays there", async ({ - page, -}) => { - test.setTimeout(120_000); - const state = () => page.evaluate(() => window.openlight.getState()); - const layers = async () => (await state()).scene?.layers ?? []; - await openKeystone(page); - await correct(page, "Vertical", "-50"); await test.step("a color range picks the dot's blue", async () => { const at = await dotOnPage(page); diff --git a/tests/grain.e2e.ts b/tests/grain.e2e.ts index 64aaee7b..81710d9a 100644 --- a/tests/grain.e2e.ts +++ b/tests/grain.e2e.ts @@ -31,6 +31,14 @@ function correlation(a: readonly number[], b: readonly number[]) { return covariance / (deviation(a) * deviation(b)); } +/** The same grain: the same particles at the same strength, within a GPU's rounding. */ +function expectSame(actual: readonly number[], expected: readonly number[]) { + expect(actual).toHaveLength(expected.length); + expect(correlation(actual, expected)).toBeGreaterThan(0.98); + expect(Math.abs(deviation(actual) - deviation(expected))).toBeLessThan(0.5); + expect(Math.abs(mean(actual) - mean(expected))).toBeLessThan(0.5); +} + test("grain textures a gray image without shifting its tone, holds through a crop, and undoes", async ({ page, }) => { @@ -55,14 +63,14 @@ test("grain textures a gray image without shifting its tone, holds through a cro const light = await exportRed(page); expect(deviation(light)).toBeGreaterThan(2); - expect(Math.abs(mean(light) - 128)).toBeLessThan(1); + expect(Math.abs(mean(light) - 128)).toBeLessThan(2); await amount.fill("100"); await amount.press("Enter"); const heavy = await exportRed(page); expect(deviation(heavy)).toBeGreaterThan(3 * deviation(light)); - expect(Math.abs(mean(heavy) - 128)).toBeLessThan(1); - expect(await exportRed(page)).toEqual(heavy); + expect(Math.abs(mean(heavy) - 128)).toBeLessThan(2); + expectSame(await exportRed(page), heavy); // A little more size fades coarser grain in over the same particles rather than stretching them. const size = page.getByRole("textbox", { name: "Size", exact: true }); @@ -70,20 +78,21 @@ test("grain textures a gray image without shifting its tone, holds through a cro await size.press("Enter"); expect(correlation(await exportRed(page), heavy)).toBeGreaterThan(0.9); await page.getByRole("button", { name: "Undo", exact: true }).click(); - expect(await exportRed(page)).toEqual(heavy); + expectSame(await exportRed(page), heavy); // A centered square crop of the 256 × 128 image keeps source columns 64 to 191. await page.evaluate(() => window.openlight.run({ type: "set-crop", aspectRatio: 1 }), ); - expect(await exportRed(page)).toEqual( + expectSame( + await exportRed(page), heavy.filter((_, index) => index % 256 >= 64 && index % 256 < 192), ); const undo = page.getByRole("button", { name: "Undo", exact: true }); await undo.click(); await undo.click(); - expect(await exportRed(page)).toEqual(light); + expectSame(await exportRed(page), light); await amount.fill("0"); await amount.press("Enter"); diff --git a/tests/lut.e2e.ts b/tests/lut.e2e.ts index b46ae08c..9a84b213 100644 --- a/tests/lut.e2e.ts +++ b/tests/lut.e2e.ts @@ -1,4 +1,4 @@ -import { expect, test } from "./fixtures"; +import { expect, expectNear, test } from "./fixtures"; import { readImage } from "./images"; test("a LUT layer grades the photo from a .cube file at the layer's opacity, undoes, and saves with the scene", async ({ @@ -17,14 +17,21 @@ test("a LUT layer grades the photo from a .cube file at the layer's opacity, und [350, 200], [600, 400], ] as const; - const sample = async () => (await readImage(page, undefined, points)).samples; - const near = (actual: number[] | undefined, expected: number[]) => { - for (const [channel, value] of expected.entries()) { - expect(Math.abs((actual?.[channel] ?? 0) - value)).toBeLessThanOrEqual(1); + const sample = async () => + (await readImage(page, undefined, points)).samples ?? []; + /** Expects each sample near the one at the same index. */ + const expectSamples = ( + actual: number[][], + expected: readonly (readonly number[])[], + tolerance = 1, + ) => { + expect(actual).toHaveLength(expected.length); + for (const [i, pixel] of expected.entries()) { + expectNear(actual[i], pixel, tolerance); } }; const original = await sample(); - expect(original).toEqual([ + expectSamples(original, [ [48, 80, 128, 255], [128, 128, 128, 255], ]); @@ -36,9 +43,15 @@ test("a LUT layer grades the photo from a .cube file at the layer's opacity, und await page.getByRole("menuitem", { name: "LUT", exact: true }).click(); await (await chooser).setFiles("tests/fixtures/swap.cube"); await expect(layers.getByText("Swap red and blue")).toBeVisible(); - const [patch, gray] = (await sample()) ?? []; - near(patch, [128, 80, 48, 255]); - near(gray, [128, 128, 128, 255]); + // Trilinear lookups in half floats round within a few levels. + expectSamples( + await sample(), + [ + [128, 80, 48, 255], + [128, 128, 128, 255], + ], + 3, + ); }); const swapped = await sample(); @@ -60,7 +73,7 @@ test("a LUT layer grades the photo from a .cube file at the layer's opacity, und }), ) .toBe(0.5); - const [patch] = (await sample()) ?? []; + const [patch] = await sample(); expect(patch?.[0]).toBeGreaterThan(48); expect(patch?.[0]).toBeLessThan(128); expect(patch?.[2]).toBeGreaterThan(48); @@ -70,12 +83,12 @@ test("a LUT layer grades the photo from a .cube file at the layer's opacity, und await test.step("undo steps back through the opacity and the layer", async () => { await page.keyboard.press("ControlOrMeta+z"); - expect(await sample()).toEqual(swapped); + expectSamples(await sample(), swapped); await page.keyboard.press("ControlOrMeta+z"); - expect(await sample()).toEqual(original); + expectSamples(await sample(), original); await page.keyboard.press("ControlOrMeta+Shift+z"); await page.keyboard.press("ControlOrMeta+Shift+z"); - expect(await sample()).toEqual(half); + expectSamples(await sample(), half); }); await test.step("a scene file carries the LUT", async () => { @@ -87,7 +100,7 @@ test("a LUT layer grades the photo from a .cube file at the layer's opacity, und }); expect(reopened).toBe(true); await expect(layers.getByText("Swap red and blue")).toBeVisible(); - expect(await sample()).toEqual(half); + expectSamples(await sample(), half); }); await test.step("a file that isn't a 3D LUT says why and changes nothing", async () => { @@ -98,7 +111,7 @@ test("a LUT layer grades the photo from a .cube file at the layer's opacity, und ), ); await expect( - page.getByText("Couldn't open curve.cube: Error: This is a 1D LUT"), + page.getByText(/Couldn't open curve\.cube: .*1D LUT/), ).toBeVisible(); const after = await page.evaluate(() => window.openlight.getState()); expect(after.failure?.file).toBe("curve.cube"); diff --git a/tests/mask-range.e2e.ts b/tests/mask-range.e2e.ts index 227d29cd..a6aed390 100644 --- a/tests/mask-range.e2e.ts +++ b/tests/mask-range.e2e.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { expect, test } from "./fixtures"; +import { expect, expectNear, test } from "./fixtures"; import { readImage } from "./images"; import { box } from "./pointer"; @@ -37,6 +37,15 @@ async function quadrantColors(page: Page) { return { dark, light, blue, orange }; } +type Quadrants = Awaited>; + +/** Expects every quadrant near the expected one, within a GPU's rounding. */ +function expectQuadrants(actual: Quadrants, expected: Quadrants) { + for (const [name, color] of Object.entries(expected)) { + expectNear(actual[name as keyof Quadrants], color, 2); + } +} + async function setField(page: Page, name: string, value: string) { const field = page.getByRole("textbox", { name, exact: true }); await field.fill(value); @@ -63,7 +72,7 @@ test("luminance and color range masks select tones and a picked color", async ({ await setField(page, "Exposure", "-2"); const darkened = await quadrantColors(page); expect(darkened.light[0]).toBeLessThan(original.light[0] - 60); - expect({ ...darkened, light: original.light }).toEqual(original); + expectQuadrants({ ...darkened, light: original.light }, original); await addEffect(page, "Color Range"); const picker = page.getByRole("application", { name: "Color range canvas" }); @@ -86,7 +95,7 @@ test("luminance and color range masks select tones and a picked color", async ({ await setField(page, "Saturation", "-100"); const gray = await quadrantColors(page); expect(Math.abs(gray.orange[0] - gray.orange[2])).toBeLessThan(12); - expect({ ...gray, orange: darkened.orange }).toEqual(darkened); + expectQuadrants({ ...gray, orange: darkened.orange }, darkened); // Picking again reads the photo below the mask, so its own edit doesn't change the color. await page @@ -102,18 +111,18 @@ test("luminance and color range masks select tones and a picked color", async ({ await expect(color).toHaveValue("#2060d0"); const recolored = await quadrantColors(page); expect(Math.abs(recolored.blue[0] - recolored.blue[2])).toBeLessThan(12); - expect(recolored.orange).toEqual(darkened.orange); + expectNear(recolored.orange, darkened.orange, 2); await page.getByRole("button", { name: "Undo", exact: true }).click(); await expect(color).toHaveValue("#e07020"); - expect(await quadrantColors(page)).toEqual(gray); + expectQuadrants(await quadrantColors(page), gray); }); -test("a range intersects, or is shaped by, gradient and brush masks", async ({ +test("a range intersects, or is shaped by, gradient and brush masks, and an unpicked one covers nothing", async ({ page, }) => { await open(page); const original = await quadrantColors(page); - const darkened = (colors: Awaited>) => + const darkened = (colors: Quadrants) => Object.entries(colors) .filter(([name, color]) => color[0] < original[name as "light"][0] - 40) .map(([name]) => name); @@ -180,54 +189,51 @@ test("a range intersects, or is shaped by, gradient and brush masks", async ({ api.setMaskOperation(range.id, "subtract"); }, id); expect(darkened(await quadrantColors(page))).toEqual(["light"]); -}); - -test("an unpicked color range has no coverage, including as an intersection", async ({ - page, -}) => { - await open(page); - const original = await quadrantColors(page); - await addEffect(page, "Color Range"); - const state = await page.evaluate(() => window.openlight.getState()); - expect(state.scene?.layers.at(-1)).toMatchObject({ - kind: "mask", - mask: { kind: "color-range", color: null }, - }); - expect(state.selectedLayerId).toBe(state.scene?.layers.at(-1)?.id); - await expect(page.getByLabel("Range color")).toHaveCount(0); - await setField(page, "Exposure", "-2"); - expect(await quadrantColors(page)).toEqual(original); - await page.keyboard.press("Escape"); - const child = await page.evaluate(() => { - const api = window.openlight; - const parent = api.addLayer("mask"); - api.setLayerMask(parent, { - kind: "linear", - start: [101, 100], - end: [99, 100], + await test.step("an unpicked color range has no coverage, including as an intersection", async () => { + await page.evaluate((id) => window.openlight.deleteLayer(id), id); + await addEffect(page, "Color Range"); + const state = await page.evaluate(() => window.openlight.getState()); + expect(state.scene?.layers.at(-1)).toMatchObject({ + kind: "mask", + mask: { kind: "color-range", color: null }, }); - api.setAdjustments({ exposure: -2 }, parent); - const child = api.addLayer("mask", { inside: parent }); - api.setLayerMask(child, { - kind: "color-range", - color: null, - tolerance: 30, + expect(state.selectedLayerId).toBe(state.scene?.layers.at(-1)?.id); + await expect(page.getByLabel("Range color")).toHaveCount(0); + await setField(page, "Exposure", "-2"); + expectQuadrants(await quadrantColors(page), original); + await page.keyboard.press("Escape"); + + const child = await page.evaluate(() => { + const api = window.openlight; + const parent = api.addLayer("mask"); + api.setLayerMask(parent, { + kind: "linear", + start: [101, 100], + end: [99, 100], + }); + api.setAdjustments({ exposure: -2 }, parent); + const child = api.addLayer("mask", { inside: parent }); + api.setLayerMask(child, { + kind: "color-range", + color: null, + tolerance: 30, + }); + api.setMaskOperation(child, "intersect"); + return child; }); - api.setMaskOperation(child, "intersect"); - return child; + expectQuadrants(await quadrantColors(page), original); + await page.evaluate( + (id) => window.openlight.setMaskOperation(id, "subtract"), + child, + ); + const subtracted = await quadrantColors(page); + expect(subtracted.light[0]).toBeLessThan(original.light[0] - 60); + expect(subtracted.orange[0]).toBeLessThan(original.orange[0] - 60); + await page.evaluate( + (id) => window.openlight.setMaskOperation(id, "add"), + child, + ); + expectQuadrants(await quadrantColors(page), subtracted); }); - expect(await quadrantColors(page)).toEqual(original); - await page.evaluate( - (id) => window.openlight.setMaskOperation(id, "subtract"), - child, - ); - const subtracted = await quadrantColors(page); - expect(subtracted.light[0]).toBeLessThan(original.light[0] - 60); - expect(subtracted.orange[0]).toBeLessThan(original.orange[0] - 60); - await page.evaluate( - (id) => window.openlight.setMaskOperation(id, "add"), - child, - ); - expect(await quadrantColors(page)).toEqual(subtracted); }); diff --git a/tests/perspective-gpu.ts b/tests/perspective-gpu.ts index d31b2d23..f1ed4bb4 100644 --- a/tests/perspective-gpu.ts +++ b/tests/perspective-gpu.ts @@ -8,7 +8,8 @@ import { correct } from "@/features/crop/geometry"; /** * Renders a smooth chart through strong perspective twice, at full size and as the half-size proxy an - * open gesture shows, and returns how far each proxy texel strays from the full pixels it stands for. + * open gesture shows, and returns how far proxy texels stray from the full pixels they stand for: the + * largest stray, and the mean one, which a misaligned proxy raises everywhere while rounding hardly moves it. */ export async function perspectiveProxy() { const gpu = await init(); @@ -41,6 +42,8 @@ export async function perspectiveProxy() { const reduced = renderer.outputImage(); const proxy = await reduced.readFloats(); let stray = 0; + let total = 0; + let count = 0; for (let y = 0; y < reduced.size[1]; y++) { for (let x = 0; x < reduced.size[0]; x++) { for (let channel = 0; channel < 2; channel++) { @@ -58,13 +61,21 @@ export async function perspectiveProxy() { // Sampling clamps within a texel of the chart's edge, where the crop may touch it. if (block > 0.01 && block < 0.99) { stray = Math.max(stray, Math.abs(texel - block)); + total += Math.abs(texel - block); + count++; } } } } // Without correction the output's corners would show the chart's own corners. const corner = [...full.slice(0, 2)]; - return { proxySize: reduced.size, stray, corner, errors }; + return { + proxySize: reduced.size, + stray, + meanStray: total / count, + corner, + errors, + }; } finally { renderer.dispose(); source.dispose(); diff --git a/tests/range-picker.e2e.ts b/tests/range-picker.e2e.ts index 7cf83a5b..73ccb563 100644 --- a/tests/range-picker.e2e.ts +++ b/tests/range-picker.e2e.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { expect, test } from "./fixtures"; +import { expect, expectNear, test } from "./fixtures"; import { readImage } from "./images"; import { box } from "./pointer"; @@ -76,369 +76,299 @@ async function open(page: Page, buffer = photo) { ); } -async function pick(page: Page) { +/** Clicks the picker canvas `dx` pixels right of its center. */ +async function pick(page: Page, dx = 0) { const picker = page.getByRole("application", { name: "Color range canvas" }); const canvas = await box(picker); await page.mouse.click( - canvas.x + canvas.width / 2, + canvas.x + canvas.width / 2 + dx, canvas.y + canvas.height / 2, ); } +/** Holds the next pick's read until `releasePick`. */ +async function pausePick(page: Page) { + await page.evaluate(() => window.rangeSampleGate.pauseNext()); +} + +async function releasePick(page: Page) { + const settled = await page.evaluate(() => window.rangeSampleGate.settled); + await page.evaluate(() => window.rangeSampleGate.release()); + await page.waitForFunction( + (settled) => window.rangeSampleGate.settled > settled, + settled, + ); +} + +function editing(page: Page) { + return page.evaluate(() => { + const { history } = window.openlight.getState(); + return "editing" in history && history.editing; + }); +} + +function rangeColors(page: Page, ids: string[]) { + return page.evaluate( + (ids) => + ids.map((id) => { + const layer = window.openlight + .getState() + .scene?.layers.find((item) => item.id === id); + return layer?.kind === "mask" && layer.mask.kind === "color-range" + ? layer.mask.color + : undefined; + }), + ids, + ); +} + +function addColorRange(page: Page, color: string) { + return page.evaluate((color) => { + const api = window.openlight; + const id = api.addLayer("mask"); + api.setLayerMask(id, { kind: "color-range", color, tolerance: 20 }); + return id; + }, color); +} + test.describe("mixed pointers", () => { test.use({ hasTouch: true }); - test("a touch cannot replace an active mouse pick", async ({ page }) => { + test("a touch cannot replace an active mouse pick, and adding or selecting a color range retargets the picker", async ({ + page, + }) => { await open( page, Buffer.from( '', ), ); - await page.getByRole("button", { name: "Add effect" }).click(); - await page.getByRole("menuitem", { name: "Color Range" }).click(); - const canvas = await box( - page.getByRole("application", { name: "Color range canvas" }), - ); - const x = canvas.x + canvas.width / 2 - 20; - const y = canvas.y + canvas.height / 2; - await page.mouse.move(x, y); - await page.mouse.down(); - await expect(page.getByLabel("Range color")).toHaveValue("#e07020"); - await page.touchscreen.tap(x, y); - await page.mouse.move(x + 40, y); - await expect(page.getByLabel("Range color")).toHaveValue("#2060d0"); - await page.mouse.up(); - await expect - .poll(() => - page.evaluate(() => { - const { history } = window.openlight.getState(); - return "editing" in history && history.editing; - }), - ) - .toBe(false); - await page.getByRole("button", { name: "Undo", exact: true }).click(); - await expect(page.getByLabel("Range color")).toHaveCount(0); - }); -}); - -test("a range inside a hidden effect can still pick its source color", async ({ - page, -}) => { - await open(page); - const original = (await readImage(page)).center; - const parent = await page.evaluate(() => { - const api = window.openlight; - const parent = api.addLayer("exposure"); - api.setExposure(parent, -1); - const child = api.addLayer("mask", { inside: parent }); - api.setLayerMask(child, { - kind: "color-range", - color: null, - tolerance: 30, + const picker = page.getByRole("application", { + name: "Color range canvas", }); - return parent; - }); - const exposed = (await readImage(page)).center; - await page.evaluate( - (id) => window.openlight.setLayer(id, { visible: false }), - parent, - ); - const before = await page.evaluate( - () => window.openlight.getState().history.undoCount, - ); - await page - .getByRole("button", { name: "Pick a color from the photo" }) - .click(); - await pick(page); - const color = `#${exposed - .slice(0, 3) - .map((channel) => channel.toString(16).padStart(2, "0")) - .join("")}`; - await expect(page.getByLabel("Range color")).toHaveValue(color); - expect((await readImage(page)).center).toEqual(original); - expect( - await page.evaluate(() => window.openlight.getState().history.undoCount), - ).toBe(before + 1); -}); + const add = async () => { + await page.getByRole("button", { name: "Add effect" }).click(); + await page.getByRole("menuitem", { name: "Color Range" }).click(); + return page.evaluate( + () => window.openlight.getState().selectedLayerId ?? "", + ); + }; -test("a delayed pick stays with its original selection", async ({ page }) => { - await open(page); - const { first, second } = await page.evaluate(() => { - const api = window.openlight; - const first = api.addLayer("mask"); - api.setLayerMask(first, { - kind: "color-range", - color: "#000000", - tolerance: 20, + const first = await add(); + + await test.step("a touch cannot replace an active mouse pick", async () => { + const canvas = await box(picker); + const x = canvas.x + canvas.width / 2 - 20; + const y = canvas.y + canvas.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await expect(page.getByLabel("Range color")).toHaveValue("#e07020"); + await page.touchscreen.tap(x, y); + await page.mouse.move(x + 40, y); + await expect(page.getByLabel("Range color")).toHaveValue("#2060d0"); + await page.mouse.up(); + await expect.poll(() => editing(page)).toBe(false); + // Undo takes back the pick and keeps the range. + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(page.getByLabel("Range color")).toHaveCount(0); }); - const second = api.addLayer("mask"); - api.setLayerMask(second, { - kind: "color-range", - color: "#ffffff", - tolerance: 20, + + await test.step("adding another color range and selecting an existing one retargets the picker", async () => { + const second = await add(); + expect(second).not.toBe(first); + await expect(picker).toBeVisible(); + await pick(page, -20); + await expect.poll(() => rangeColors(page, [second])).toEqual(["#e07020"]); + await page + .getByRole("button", { name: "Select Color Range", exact: true }) + .last() + .click(); + await expect(picker).toBeVisible(); + await pick(page, -20); + await expect + .poll(() => rangeColors(page, [first, second])) + .toEqual(["#e07020", "#e07020"]); }); - api.selectLayer(first); - return { first, second }; }); - await page - .getByRole("button", { name: "Pick a color from the photo" }) - .click(); - await page.evaluate(() => window.rangeSampleGate.pauseNext()); - await pick(page); - await page.waitForFunction(() => window.rangeSampleGate.waiting); - await page.evaluate((id) => window.openlight.selectLayer(id), second); - await expect( - page.getByRole("application", { name: "Color range canvas" }), - ).toBeVisible(); - await page.evaluate(() => window.rangeSampleGate.release()); - await page.waitForFunction(() => window.rangeSampleGate.settled > 0); - await expect - .poll(() => - page.evaluate(() => { - const { history } = window.openlight.getState(); - return "editing" in history && history.editing; - }), - ) - .toBe(false); - const colors = await page.evaluate( - ([a, b]) => - [a, b].map((id) => { - const layer = window.openlight - .getState() - .scene?.layers.find((item) => item.id === id); - return layer?.kind === "mask" && layer.mask.kind === "color-range" - ? layer.mask.color - : undefined; - }), - [first, second], - ); - expect(colors).toEqual(["#000000", "#ffffff"]); }); -test("leaving the picker cancels a delayed pick but keeps its new mask", async ({ +test("delayed and failed picks stay with their range, and leaving the picker drops them", async ({ page, }) => { await open(page); - await page.getByRole("button", { name: "Add effect" }).click(); - await page.getByRole("menuitem", { name: "Color Range" }).click(); - const initial = await page.evaluate(() => window.openlight.getState()); - expect(initial.scene?.layers.at(-1)).toMatchObject({ - kind: "mask", - mask: { kind: "color-range", color: null }, - }); - expect(initial.selectedLayerId).toBe(initial.scene?.layers.at(-1)?.id); - await page.evaluate(() => window.rangeSampleGate.pauseNext()); - await pick(page); - await page.waitForFunction(() => window.rangeSampleGate.waiting); - await page.keyboard.press("Escape"); - await expect( - page.getByRole("application", { name: "Color range canvas" }), - ).toHaveCount(0); - await page.evaluate(() => window.rangeSampleGate.release()); - await page.waitForFunction(() => window.rangeSampleGate.settled > 0); - await expect - .poll(() => - page.evaluate(() => { - const { history } = window.openlight.getState(); - return "editing" in history && history.editing; - }), - ) - .toBe(false); - const mask = await page.evaluate(() => - window.openlight.getState().scene?.layers.at(-1), - ); - expect(mask).toMatchObject({ - kind: "mask", - mask: { kind: "color-range", color: null }, + const picker = page.getByRole("application", { name: "Color range canvas" }); + const pickButton = page.getByRole("button", { + name: "Pick a color from the photo", }); -}); -test("adding another color range and selecting an existing one retargets the picker", async ({ - page, -}) => { - await open(page); - const add = async () => { + await test.step("selection away or deletion leaves the picker, and K needs a color range", async () => { + await page.keyboard.press("k"); + await expect(picker).toHaveCount(0); await page.getByRole("button", { name: "Add effect" }).click(); await page.getByRole("menuitem", { name: "Color Range" }).click(); - return page.evaluate(() => window.openlight.getState().selectedLayerId); - }; - const first = await add(); - const second = await add(); - expect(second).not.toBe(first); - const picker = page.getByRole("application", { name: "Color range canvas" }); - await expect(picker).toBeVisible(); - await pick(page); - await expect - .poll(() => - page.evaluate(() => window.openlight.getState().scene?.layers.at(-1)), - ) - .toMatchObject({ mask: { color: "#e07020" } }); - await page - .getByRole("button", { name: "Select Color Range", exact: true }) - .last() - .click(); - await expect(picker).toBeVisible(); - await pick(page); - await expect - .poll(() => - page.evaluate((id) => { - const layer = window.openlight - .getState() - .scene?.layers.find((item) => item.id === id); - return layer?.kind === "mask" && layer.mask.kind === "color-range" - ? layer.mask.color - : undefined; - }, first), - ) - .toBe("#e07020"); - const colors = await page.evaluate( - ([a, b]) => - [a, b].map((id) => { - const layer = window.openlight - .getState() - .scene?.layers.find((item) => item.id === id); - return layer?.kind === "mask" && layer.mask.kind === "color-range" - ? layer.mask.color - : undefined; - }), - [first, second], - ); - expect(colors).toEqual(["#e07020", "#e07020"]); -}); - -test("selection away or deletion leaves the picker, and K needs a color range", async ({ - page, -}) => { - await open(page); - const picker = page.getByRole("application", { name: "Color range canvas" }); - await page.keyboard.press("k"); - await expect(picker).toHaveCount(0); - await page.getByRole("button", { name: "Add effect" }).click(); - await page.getByRole("menuitem", { name: "Color Range" }).click(); - const id = await page.evaluate( - () => window.openlight.getState().selectedLayerId ?? "", - ); - expect(id).not.toBe(""); - await expect(picker).toBeVisible(); - await page.evaluate(() => { - const api = window.openlight; - api.selectLayer(api.getState().scene?.layers[0].id ?? ""); + const id = await page.evaluate( + () => window.openlight.getState().selectedLayerId ?? "", + ); + expect(id).not.toBe(""); + await expect(picker).toBeVisible(); + await page.evaluate(() => { + const api = window.openlight; + api.selectLayer(api.getState().scene?.layers[0].id ?? ""); + }); + await expect(picker).toHaveCount(0); + await page + .getByRole("button", { name: "Select Color Range", exact: true }) + .last() + .click(); + await expect(picker).toBeVisible(); + await pausePick(page); + await pick(page); + await page.waitForFunction(() => window.rangeSampleGate.waiting); + await page.evaluate((layerId) => window.openlight.deleteLayer(layerId), id); + await expect(picker).toHaveCount(0); + await releasePick(page); + expect( + await page.evaluate( + (layerId) => + window.openlight + .getState() + .scene?.layers.some((layer) => layer.id === layerId), + id, + ), + ).toBe(false); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(picker).toHaveCount(0); + await page + .getByRole("button", { name: "Select Color Range", exact: true }) + .click(); + await expect(picker).toBeVisible(); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(picker).toHaveCount(0); }); - await expect(picker).toHaveCount(0); - await page - .getByRole("button", { name: "Select Color Range", exact: true }) - .last() - .click(); - await expect(picker).toBeVisible(); - await page.evaluate(() => window.rangeSampleGate.pauseNext()); - await pick(page); - await page.waitForFunction(() => window.rangeSampleGate.waiting); - await page.evaluate((layerId) => window.openlight.deleteLayer(layerId), id); - await expect(picker).toHaveCount(0); - await page.evaluate(() => window.rangeSampleGate.release()); - await page.waitForFunction(() => window.rangeSampleGate.settled > 0); - expect( - await page.evaluate( - (layerId) => - window.openlight - .getState() - .scene?.layers.some((layer) => layer.id === layerId), - id, - ), - ).toBe(false); - await page.getByRole("button", { name: "Undo", exact: true }).click(); - await expect(picker).toHaveCount(0); - await page - .getByRole("button", { name: "Select Color Range", exact: true }) - .click(); - await expect(picker).toBeVisible(); - await page.getByRole("button", { name: "Undo", exact: true }).click(); - await expect(picker).toHaveCount(0); -}); -test("an old pending pick cannot cancel a new gesture's history", async ({ - page, -}) => { - await open(page); - await page.evaluate(() => { - const api = window.openlight; - const id = api.addLayer("mask"); - api.setLayerMask(id, { - kind: "color-range", - color: "#000000", - tolerance: 20, + await test.step("leaving the picker cancels a delayed pick but keeps its new mask", async () => { + await page.getByRole("button", { name: "Add effect" }).click(); + await page.getByRole("menuitem", { name: "Color Range" }).click(); + const initial = await page.evaluate(() => window.openlight.getState()); + expect(initial.scene?.layers.at(-1)).toMatchObject({ + kind: "mask", + mask: { kind: "color-range", color: null }, + }); + expect(initial.selectedLayerId).toBe(initial.scene?.layers.at(-1)?.id); + await pausePick(page); + await pick(page); + await page.waitForFunction(() => window.rangeSampleGate.waiting); + await page.keyboard.press("Escape"); + await expect(picker).toHaveCount(0); + await releasePick(page); + await expect.poll(() => editing(page)).toBe(false); + expect( + await page.evaluate(() => + window.openlight.getState().scene?.layers.at(-1), + ), + ).toMatchObject({ + kind: "mask", + mask: { kind: "color-range", color: null }, }); }); - const button = page.getByRole("button", { - name: "Pick a color from the photo", + + await test.step("a delayed pick stays with its original selection", async () => { + const first = await addColorRange(page, "#000000"); + const second = await addColorRange(page, "#ffffff"); + await page.evaluate((id) => window.openlight.selectLayer(id), first); + await pickButton.click(); + await pausePick(page); + await pick(page); + await page.waitForFunction(() => window.rangeSampleGate.waiting); + await page.evaluate((id) => window.openlight.selectLayer(id), second); + await expect(picker).toBeVisible(); + await releasePick(page); + await expect.poll(() => editing(page)).toBe(false); + expect(await rangeColors(page, [first, second])).toEqual([ + "#000000", + "#ffffff", + ]); + await page.keyboard.press("Escape"); + await expect(picker).toHaveCount(0); }); - await button.click(); - const picker = page.getByRole("application", { name: "Color range canvas" }); - const canvas = await box(picker); - const x = canvas.x + canvas.width / 2; - const y = canvas.y + canvas.height / 2; - await page.mouse.move(x, y); - await page.mouse.down(); - await expect(page.getByLabel("Range color")).toHaveValue("#e07020"); - await page.evaluate(() => window.rangeSampleGate.pauseNext()); - await page.mouse.move(x + 5, y); - await page.waitForFunction(() => window.rangeSampleGate.waiting); - await page.mouse.up(); - await page.keyboard.press("Escape"); - await expect(picker).toHaveCount(0); - await button.click(); - await page.mouse.move(x, y); - await page.mouse.down(); - await page.waitForFunction(() => { - const { history } = window.openlight.getState(); - return "editing" in history && history.editing; + + await test.step("an old pending pick cannot cancel a new gesture's history", async () => { + await addColorRange(page, "#000000"); + await pickButton.click(); + const canvas = await box(picker); + const x = canvas.x + canvas.width / 2; + const y = canvas.y + canvas.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await expect(page.getByLabel("Range color")).toHaveValue("#e07020"); + await pausePick(page); + await page.mouse.move(x + 5, y); + await page.waitForFunction(() => window.rangeSampleGate.waiting); + await page.mouse.up(); + await page.keyboard.press("Escape"); + await expect(picker).toHaveCount(0); + await pickButton.click(); + await page.mouse.move(x, y); + await page.mouse.down(); + await expect.poll(() => editing(page)).toBe(true); + await releasePick(page); + expect(await editing(page)).toBe(true); + await page.mouse.up(); + await page.keyboard.press("Escape"); + await expect(picker).toHaveCount(0); }); - await page.evaluate(() => window.rangeSampleGate.release()); - await page.waitForFunction(() => window.rangeSampleGate.settled > 0); - const editing = await page.evaluate(() => { - const { history } = window.openlight.getState(); - return "editing" in history && history.editing; + + await test.step("a failed read reports the error and the next pick succeeds", async () => { + const id = await addColorRange(page, "#000000"); + await pickButton.click(); + await pausePick(page); + await pick(page); + await page.waitForFunction(() => window.rangeSampleGate.waiting); + await page.evaluate(() => window.rangeSampleGate.reject()); + await expect( + page.getByText("Couldn't pick a color. Try again."), + ).toBeVisible(); + await pick(page); + await expect.poll(() => rangeColors(page, [id])).toEqual(["#e07020"]); + await page.keyboard.press("Escape"); + await expect(picker).toHaveCount(0); }); - expect(editing).toBe(true); - await page.mouse.up(); -}); -test("a failed read reports the error and the next pick succeeds", async ({ - page, -}) => { - await open(page); - const id = await page.evaluate(() => { - const api = window.openlight; - const id = api.addLayer("mask"); - api.setLayerMask(id, { - kind: "color-range", - color: "#000000", - tolerance: 20, + await test.step("a range inside a hidden effect can still pick its source color", async () => { + const original = (await readImage(page)).center; + const parent = await page.evaluate(() => { + const api = window.openlight; + const parent = api.addLayer("exposure"); + api.setExposure(parent, -1); + const child = api.addLayer("mask", { inside: parent }); + api.setLayerMask(child, { + kind: "color-range", + color: null, + tolerance: 30, + }); + return parent; }); - return id; + const exposed = (await readImage(page)).center; + await page.evaluate( + (id) => window.openlight.setLayer(id, { visible: false }), + parent, + ); + const before = await page.evaluate( + () => window.openlight.getState().history.undoCount, + ); + await pickButton.click(); + await pick(page); + const color = page.getByLabel("Range color"); + await expect(color).toHaveValue(/^#[0-9a-f]{6}$/); + const hex = await color.inputValue(); + expectNear( + [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)), + exposed.slice(0, 3), + 1, + ); + expectNear((await readImage(page)).center, original, 1); + expect( + await page.evaluate(() => window.openlight.getState().history.undoCount), + ).toBe(before + 1); }); - await page - .getByRole("button", { name: "Pick a color from the photo" }) - .click(); - await page.evaluate(() => window.rangeSampleGate.pauseNext()); - await pick(page); - await page.waitForFunction(() => window.rangeSampleGate.waiting); - await page.evaluate(() => window.rangeSampleGate.reject()); - await expect( - page.getByText("Couldn't pick a color. Try again."), - ).toBeVisible(); - await pick(page); - await expect - .poll(() => - page.evaluate((id) => { - const layer = window.openlight - .getState() - .scene?.layers.find((item) => item.id === id); - return layer?.kind === "mask" && layer.mask.kind === "color-range" - ? layer.mask.color - : undefined; - }, id), - ) - .toBe("#e07020"); }); diff --git a/tests/rendering.e2e.ts b/tests/rendering.e2e.ts index b9ada318..d1d69814 100644 --- a/tests/rendering.e2e.ts +++ b/tests/rendering.e2e.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { expect, test } from "./fixtures"; +import { expect, expectNear, test } from "./fixtures"; function linear(value: number) { const encoded = value / 255; @@ -53,7 +53,7 @@ test("tone adjustments preserve ramps, colors and alpha, compose, and reset", as pixels.slice((125 * 256 + x) * 4, (125 * 256 + x) * 4 + 3), ), alphaMatches: pixels.every( - (value, i) => i % 4 !== 3 || value === neutral[i], + (value, i) => i % 4 !== 3 || Math.abs(value - neutral[i]) <= 1, ), grayMatches: pixels .slice(0, 256 * 4) @@ -67,57 +67,71 @@ test("tone adjustments preserve ramps, colors and alpha, compose, and reset", as const reset = await read(); return { outputs, - resetMatches: neutral.every((value, i) => value === reset[i]), + resetMatches: neutral.every( + (value, i) => Math.abs(value - reset[i]) <= 1, + ), }; }, [...bytes], ); const [original, whites, blacks, clipped, negative, half, positive, shadows] = result.outputs; + const ramp = Array.from({ length: 256 }, (_, x) => x); + expectNear(original.ramp, ramp, 1); for (const output of result.outputs) { expect(output.alphaMatches).toBe(true); expect(output.grayMatches).toBe(true); + // A gradient stays smooth: it never steps back or jumps. for (let x = 1; x < 256; x++) { expect(output.ramp[x] - output.ramp[x - 1]).toBeGreaterThanOrEqual(-1); - expect(output.ramp[x] - output.ramp[x - 1]).toBeLessThanOrEqual(6); + expect(output.ramp[x] - output.ramp[x - 1]).toBeLessThanOrEqual(16); } } - expect(original.ramp).toEqual(Array.from({ length: 256 }, (_, x) => x)); - // Whites and blacks each leave the other half of the ramp untouched. - expect(whites.ramp.slice(0, 129)).toEqual(original.ramp.slice(0, 129)); - expect(blacks.ramp.slice(128)).toEqual(original.ramp.slice(128)); - expect(clipped.ramp[128]).toBe(128); - expect(whites.ramp[230]).toBe(255); - expect(whites.colorEnds[1]).toEqual([255, 255, 255]); - expect(clipped.ramp[25]).toBe(0); - expect(clipped.colorEnds[0]).toEqual([0, 0, 0]); - expect(blacks.ramp[0]).toBeGreaterThanOrEqual(25); - expect(blacks.ramp[0]).toBeLessThanOrEqual(26); - expect(clipped.ramp[255]).toBeGreaterThanOrEqual(229); - expect(clipped.ramp[255]).toBeLessThanOrEqual(230); + // Whites and blacks each brighten their half of the ramp and leave the other. + expectNear(whites.ramp.slice(0, 129), ramp.slice(0, 129), 1); + expect(whites.ramp[192]).toBeGreaterThan(200); + expect(whites.ramp[230]).toBeGreaterThanOrEqual(254); + expectNear(whites.colorEnds[1], [255, 255, 255], 1); + expectNear(blacks.ramp.slice(128), ramp.slice(128), 1); + expect(blacks.ramp[64]).toBeGreaterThan(70); + expectNear([blacks.ramp[0]], [25], 3); + // Negative whites and blacks pull the ends in and clip the darkest tones. + expectNear([clipped.ramp[128]], [128], 1); + expect(clipped.ramp[25]).toBeLessThanOrEqual(1); + expectNear(clipped.colorEnds[0], [0, 0, 0], 1); + expectNear([clipped.ramp[255]], [230], 3); for (let x = 0; x < 256; x++) { - expect(negative.ramp[x]).toBeLessThanOrEqual(half.ramp[x]); - expect(half.ramp[x]).toBeLessThanOrEqual(x); - expect(positive.ramp[x]).toBeGreaterThanOrEqual(x); - expect(shadows.ramp[x]).toBeGreaterThanOrEqual(x); + expect(negative.ramp[x]).toBeLessThanOrEqual(half.ramp[x] + 1); + expect(half.ramp[x]).toBeLessThanOrEqual(x + 1); + expect(positive.ramp[x]).toBeGreaterThanOrEqual(x - 1); + expect(shadows.ramp[x]).toBeGreaterThanOrEqual(x - 1); // Half the slider moves halfway in linear light. expect( Math.abs( linear(half.ramp[x]) - (linear(x) + linear(negative.ramp[x])) / 2, ), - ).toBeLessThan(0.01); + ).toBeLessThan(0.02); } + // Highlights move the light end and Shadows the dark end, each leaving the other; black and white stay. + expect(negative.ramp[224]).toBeLessThan(214); + expect(positive.ramp[192]).toBeGreaterThan(202); + expect(shadows.ramp[32]).toBeGreaterThan(40); + for (const output of [negative, positive]) { + expectNear([output.ramp[16]], [16], 3); + } + expectNear([shadows.ramp[224]], [224], 3); for (const output of [negative, half, positive, shadows]) { - expect(output.ramp[0]).toBe(0); + expect(output.ramp[0]).toBeLessThanOrEqual(1); } - expect(shadows.ramp[255]).toBe(255); - expect(original.patches).toEqual([240, 240]); - expect(negative.patches[0]).toBe(negative.patches[1]); - expect(negative.patches[0]).toBeLessThan(240); - expect(positive.patches[0]).toBe(positive.patches[1]); - expect(positive.patches[0]).toBeGreaterThan(240); + expect(shadows.ramp[255]).toBeGreaterThanOrEqual(254); + // Highlights keep each patch's hue: both patches share a tone and move together. + expectNear(original.patches, [240, 240], 1); + expectNear([negative.patches[0]], [negative.patches[1]], 1); + expect(negative.patches[0]).toBeLessThan(236); + expectNear([positive.patches[0]], [positive.patches[1]], 1); + expect(positive.patches[0]).toBeGreaterThan(244); expect(Math.abs(negative.pastel[0] - negative.pastel[2])).toBeLessThanOrEqual( - 1, + 2, ); for (let channel = 0; channel < 3; channel++) { expect(negative.pastel[channel]).toBeLessThan(original.pastel[channel]); @@ -140,8 +154,9 @@ test("a perspective-corrected proxy shows the full render it stands for", async expect(result.proxySize).toEqual([240, 160]); // The correction moved the output's corner into the chart, and the proxy follows it texel by texel. expect(Math.max(...result.corner)).toBeGreaterThan(0.02); - // Less than the 2e-3 a half-texel shift of the proxy would make, beyond half-float rounding. - expect(result.stray).toBeLessThan(1.5e-3); + // A proxy half a texel off strays 7e-4 on average; filtering and half-float rounding stay far below. + expect(result.meanStray).toBeLessThan(5e-4); + expect(result.stray).toBeLessThan(0.01); }); test("a gradient recovers +3 EV photo exposure without clipping between layers", async ({ @@ -156,11 +171,17 @@ test("a gradient recovers +3 EV photo exposure without clipping between layers", return recoverExposure(); }); expect(result.errors).toEqual([]); + // Half floats hold these values exactly; the tolerance covers GPUs whose exp2 rounds. + const expectFloats = (actual: number[], expected: number[]) => { + for (const [channel, value] of expected.entries()) { + expect(actual[channel]).toBeCloseTo(value, 2); + } + }; for (const exposed of result.exposed) { - expect(exposed).toEqual([2, 4, 6, 0.75]); + expectFloats(exposed, [2, 4, 6, 0.75]); } - expect(result.recovered[0]).toEqual([0.25, 0.5, 0.75, 0.75]); - expect(result.recovered[2]).toEqual([2, 4, 6, 0.75]); + expectFloats(result.recovered[0], [0.25, 0.5, 0.75, 0.75]); + expectFloats(result.recovered[2], [2, 4, 6, 0.75]); expect(result.recovered[1][2]).toBeGreaterThan(1); - expect(result.recovered[1][3]).toBe(0.75); + expect(result.recovered[1][3]).toBeCloseTo(0.75, 2); }); diff --git a/tests/vignette.e2e.ts b/tests/vignette.e2e.ts index 658a6226..2d7d9903 100644 --- a/tests/vignette.e2e.ts +++ b/tests/vignette.e2e.ts @@ -1,4 +1,4 @@ -import { expect, test } from "./fixtures"; +import { expect, expectNear, test } from "./fixtures"; import { readImage } from "./images"; test("vignette darkens a white image's edges, softens, undoes and disables", async ({ @@ -21,7 +21,8 @@ test("vignette darkens a white image's edges, softens, undoes and disables", asy await intensity.fill("80"); await intensity.press("Enter"); const vignetted = await readImage(page); - expect(vignetted.center).toEqual([255, 255, 255, 255]); + // The center stays white. + expect(Math.min(...vignetted.center)).toBeGreaterThanOrEqual(253); expect(vignetted.corner[0]).toBeLessThan(255); const softness = page.getByRole("textbox", { name: "Softness", exact: true }); @@ -29,9 +30,11 @@ test("vignette darkens a white image's edges, softens, undoes and disables", asy await softness.press("Enter"); expect((await readImage(page)).corner[0]).toBeLessThan(vignetted.corner[0]); await page.getByRole("button", { name: "Undo", exact: true }).click(); - expect(await readImage(page)).toEqual(vignetted); + const undone = await readImage(page); + expectNear(undone.center, vignetted.center, 1); + expectNear(undone.corner, vignetted.corner, 1); await intensity.fill("0"); await intensity.press("Enter"); - expect((await readImage(page)).corner).toEqual([255, 255, 255, 255]); + expectNear((await readImage(page)).corner, [255, 255, 255, 255], 1); }); From 7458a767f66de56d6ddab00b790b17929cc2d731 Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Wed, 14 Oct 2026 21:28:05 +0000 Subject: [PATCH 5/7] Check formats, editing, and app tests as a person would, on any GPU Merge the loader, RAW, TIFF, and start tests into one formats test on two page loads, and check the sample JPEG's colors. Compare histograms by their heights, the zoom by its level and what the view shows, and pixels within a tolerance, instead of exact bytes, polylines, and screenshots. Count GPU allocations while editing instead of sleeping for the stats panel. Co-Authored-By: Claude Opus 5.5 --- tests/assistant.e2e.ts | 5 +- tests/draft.e2e.ts | 7 +- tests/editing.e2e.ts | 152 ++++++++++++++++++++++--------- tests/formats.e2e.ts | 196 ++++++++++++++++++++++++++++++++++++++++ tests/gpu-stats.e2e.ts | 36 +++++++- tests/heic.e2e.ts | 17 ++-- tests/loaders.e2e.ts | 73 --------------- tests/raw.e2e.ts | 38 -------- tests/scene-file.e2e.ts | 8 +- tests/start.e2e.ts | 22 ----- tests/tiff.e2e.ts | 63 ------------- 11 files changed, 358 insertions(+), 259 deletions(-) create mode 100644 tests/formats.e2e.ts delete mode 100644 tests/loaders.e2e.ts delete mode 100644 tests/raw.e2e.ts delete mode 100644 tests/start.e2e.ts delete mode 100644 tests/tiff.e2e.ts diff --git a/tests/assistant.e2e.ts b/tests/assistant.e2e.ts index 9e2c5a12..5cd43276 100644 --- a/tests/assistant.e2e.ts +++ b/tests/assistant.e2e.ts @@ -1,4 +1,4 @@ -import { expect, openPhoto, test } from "./fixtures"; +import { expect, test } from "./fixtures"; test("the assistant at /?assistant edits the photo it was asked about and selects the layer it edits", async ({ page, @@ -32,9 +32,6 @@ test("the assistant at /?assistant edits the photo it was asked about and select }); const button = page.getByRole("button", { name: "Assistant", exact: true }); const state = () => page.evaluate(() => window.openlight.getState()); - await openPhoto(page); - await expect(button).toBeHidden(); - await page.goto("/?assistant"); await page .locator('input[type="file"]') diff --git a/tests/draft.e2e.ts b/tests/draft.e2e.ts index e7c7b83d..3cf61d4a 100644 --- a/tests/draft.e2e.ts +++ b/tests/draft.e2e.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { expect, openPhoto, test } from "./fixtures"; +import { expect, expectNear, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; /** The stored draft's base exposure, read straight from IndexedDB. */ @@ -40,7 +40,10 @@ test("edits survive a reload as a draft that recovers, keeps saving, and can be await page.reload(); await recover.click(); await expect(exposure).toHaveValue("-1.00"); - expect(await readImage(page)).toEqual(edited); + const recovered = await readImage(page); + expect(recovered.size).toEqual(edited.size); + expectNear(recovered.center, edited.center, 1); + expectNear(recovered.corner, edited.corner, 1); expect( (await page.evaluate(() => window.openlight.getState())).history.undoCount, ).toBe(0); diff --git a/tests/editing.e2e.ts b/tests/editing.e2e.ts index 8aa0279c..062fed38 100644 --- a/tests/editing.e2e.ts +++ b/tests/editing.e2e.ts @@ -1,15 +1,53 @@ import { readFile } from "node:fs/promises"; +import type { Locator } from "@playwright/test"; import { interpolatePchip } from "@/lib/math"; -import { expect, openPhoto, test } from "./fixtures"; +import { expect, expectNear, openPhoto, test } from "./fixtures"; import { readImage, readPixel, readPreview } from "./images"; import { box, choose, drag, zoom } from "./pointer"; +/** A histogram's bin heights, 0 to 100, from the polyline its SVG draws downward. */ +async function heights(plot: Locator) { + const points = (await plot.getAttribute("points")) ?? ""; + return points + .split(" ") + .filter(Boolean) + .map((point) => 100 - Number(point.split(",")[1])); +} + +/** The height-weighted mean bin, which rises as the image brightens. */ +function meanBin(heights: number[]) { + const total = heights.reduce((sum, height) => sum + height, 0); + return heights.reduce((sum, height, bin) => sum + height * bin, 0) / total; +} + +/** The mean height difference between two histograms of the same bins. */ +function difference(a: number[], b: number[]) { + if (a.length !== b.length) return Number.POSITIVE_INFINITY; + return ( + a.reduce((sum, height, bin) => sum + Math.abs(height - b[bin]), 0) / + a.length + ); +} + +/** The largest channel difference between two colors. */ +function distance(a: number[], b: number[]) { + return Math.max(...b.map((value, channel) => Math.abs(a[channel] - value))); +} + +const isGray = (pixel: number[]) => + distance(pixel.slice(0, 3), [128, 128, 128]) <= 10; +const isBlue = (pixel: number[]) => pixel[2] - pixel[0] > 40; + test("edit a photo, inspect the preview and histograms, undo changes, and export", async ({ page, }) => { test.setTimeout(90_000); const state = () => page.evaluate(() => window.openlight.getState()); await openPhoto(page); + // The experimental assistant mounts only for `/?assistant`. + await expect( + page.getByRole("button", { name: "Assistant", exact: true }), + ).toBeHidden(); const canvas = page .getByRole("region", { name: "Image canvas" }) .locator("canvas"); @@ -17,9 +55,17 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export .getByLabel("output histogram", { exact: true }) .locator("polyline") .first(); - await expect(output).toHaveAttribute("points", /,\d{1,2}\./); - const original = await canvas.screenshot(); - const histogram = (await output.getAttribute("points")) ?? ""; + const drawn = (plot: Locator) => + expect + .poll(async () => Math.max(...(await heights(plot)))) + .toBeGreaterThan(1); + await drawn(output); + const histogram = await heights(output); + /** The preview shows the unedited photo again, as its output histogram does. */ + const restored = () => + expect + .poll(async () => difference(await heights(output), histogram)) + .toBeLessThan(0.5); const initial = await state(); async function type(name: string, value: string) { const field = page.getByRole("textbox", { name, exact: true }); @@ -33,20 +79,27 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export viewport.x + viewport.width / 2, viewport.y + viewport.height / 2, ]; + const top = { x, y: viewport.y + 4 }; + const level = page.getByRole("button", { name: /^\d+%$/ }); + const fit = (await level.textContent()) ?? ""; await canvas.hover(); await zoom(page, 2); + await expect(level).not.toHaveText(fit); // Only a zoomed photo reaches past the fitted margin to the top edge. await expect - .poll(() => readPixel(page, { x, y: viewport.y + 4 })) - .toEqual([128, 128, 128, 255]); + .poll(async () => isGray(await readPixel(page, top))) + .toBe(true); await canvas.dblclick(); - await expect.poll(() => canvas.screenshot()).toEqual(original); + await expect(level).toHaveText(fit); + await expect + .poll(async () => isGray(await readPixel(page, top))) + .toBe(false); const scale = Math.min( (viewport.width - 48) / 1200, (viewport.height - 48) / 800, ); const blue = { x: x + (430 - 600) * scale, y: y + (150 - 400) * scale }; - expect(await readPixel(page, blue)).toEqual([48, 80, 128, 255]); + expect(isBlue(await readPixel(page, blue))).toBe(true); // Releasing Space mid-drag keeps the pan going. await page.keyboard.down("Space"); await page.mouse.down(); @@ -55,10 +108,13 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export await page.mouse.move(x + 101, y); await page.mouse.up(); await expect - .poll(() => readPixel(page, { ...blue, x: blue.x + 101 })) - .toEqual([48, 80, 128, 255]); + .poll(async () => + isBlue(await readPixel(page, { ...blue, x: blue.x + 101 })), + ) + .toBe(true); expect(await state()).toEqual(initial); await canvas.dblclick(); + await expect(level).toHaveText(fit); }); await test.step("an exposure drag is one edit of the image and histogram", async () => { @@ -76,11 +132,13 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export [track.x + track.width / 2, y], [track.x + track.width * 0.6, y], ); - await expect(output).not.toHaveAttribute("points", histogram); + await expect + .poll(async () => meanBin(await heights(output))) + .toBeGreaterThan(meanBin(histogram) + 10); expect((await readImage(page)).center[0]).toBeGreaterThan(160); await page.keyboard.press("ControlOrMeta+z"); await expect(exposure).toHaveValue("0"); - await expect.poll(() => canvas.screenshot()).toEqual(original); + await restored(); }); await test.step("compare and clipping change only the preview", async () => { @@ -94,16 +152,19 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export }); const edited = await readImage(page); const { history } = await state(); - const center = async () => (await readPreview(page)).center; + const center = (expected: number[]) => + expect + .poll(async () => distance((await readPreview(page)).center, expected)) + .toBeLessThanOrEqual(4); const compare = page.getByRole("button", { name: "Compare before and after", exact: true, }); await compare.focus(); await page.keyboard.down("Backslash"); - await expect.poll(center).toEqual([128, 128, 128, 255]); + await center([128, 128, 128, 255]); await page.keyboard.up("Backslash"); - await expect.poll(center).toEqual([255, 255, 255, 255]); + await center([255, 255, 255, 255]); await compare.click(); const divider = page.getByRole("slider", { name: "Before and after divider", @@ -114,7 +175,7 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export await page.mouse.move(bounds.x + bounds.width * 0.8, bounds.y + 10); await page.mouse.up(); await expect(divider).toHaveAttribute("aria-valuenow", "80"); - await expect.poll(center).toEqual([128, 128, 128, 255]); + await center([128, 128, 128, 255]); await compare.click(); const shadows = page.getByRole("button", { name: "Show clipped shadows" }); const highlights = page.getByRole("button", { @@ -122,7 +183,7 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export }); await shadows.click(); await highlights.click(); - await expect.poll(center).toEqual([255, 0, 0, 255]); + await center([255, 0, 0, 255]); expect((await readPreview(page)).blue).toBeGreaterThan(100); expect(await readImage(page)).toEqual(edited); expect((await state()).history).toEqual(history); @@ -132,7 +193,7 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export window.openlight.setAdjustments({ exposure: 0 }); window.openlight.setToneCurve(); }); - await expect.poll(() => canvas.screenshot()).toEqual(original); + await restored(); }); await test.step("clarity and sharpening edit a Details effect", async () => { @@ -143,14 +204,16 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export const clarity = page.getByRole("slider", { name: "Clarity", exact: true }); await clarity.locator("..").dblclick(); await expect(clarity).toHaveValue("0"); - const edge = () => readImage(page, undefined, [[102, 50]]); + // Beside the black corner, on the dark strip. + const edge = async () => + (await readImage(page, undefined, [[102, 50]])).samples?.[0]; await type("Sharpening", "100"); await type("Radius", "3"); - expect((await edge()).samples?.[0][0]).toBeGreaterThan(40); + expect((await edge())?.[0]).toBeGreaterThan(40); await page.getByRole("button", { name: "Undo", exact: true }).click(); await page.getByRole("button", { name: "Undo", exact: true }).click(); - expect((await edge()).samples?.[0][0]).toBe(32); - await expect(output).toHaveAttribute("points", histogram); + expectNear((await edge())?.slice(0, 3), [32, 32, 32]); + await restored(); }); await test.step("a curve drag after adjustments is one edit", async () => { @@ -164,8 +227,8 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export .getByLabel("curve input histogram", { exact: true }) .locator("polyline"); await graph.scrollIntoViewIfNeeded(); - await expect(input).toHaveAttribute("points", /,\d{1,2}\./); - const points = (await input.getAttribute("points")) ?? ""; + await drawn(input); + const before = await heights(input); const bounds = await box(graph); const x = bounds.x + bounds.width / 2; await drag( @@ -174,11 +237,12 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export [x, bounds.y + bounds.height / 4], ); await expect(graph.locator("circle")).toHaveCount(3); - await expect(input).toHaveAttribute("points", points); + // The curve's input comes before the curve. + expect(difference(await heights(input), before)).toBeLessThan(0.5); const curve = interpolatePchip((await state()).toneCurve); const expected = curve(adjusted.center[0] / 255) * 255; expect(Math.abs((await readImage(page)).center[0] - expected)).toBeLessThan( - 2, + 4, ); await page.keyboard.press("ControlOrMeta+z"); expect(await readImage(page)).toEqual(adjusted); @@ -189,10 +253,10 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export [bounds.x, bounds.y + bounds.height * 0.75], 5, ); - expect(Math.abs((await readImage(page)).corner[0] - 64)).toBeLessThan(2); + expect(Math.abs((await readImage(page)).corner[0] - 64)).toBeLessThan(4); await page.getByRole("button", { name: "Reset curve" }).click(); await page.evaluate(() => window.openlight.setAdjustments({ exposure: 0 })); - await expect.poll(() => canvas.screenshot()).toEqual(original); + await restored(); }); await test.step("the mode bar switches panels by click, arrow keys, and letters", async () => { @@ -226,7 +290,7 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export window.openlight.commitEdit(); }); const expected = await readImage(page); - expect(expected.center).not.toEqual([128, 128, 128, 255]); + expect(expected.center[0]).toBeGreaterThan(160); await canvas.hover(); await zoom(page, Math.E); await page.getByRole("button", { name: "Export", exact: true }).click(); @@ -262,9 +326,7 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export .toBeLessThan(Math.max(1000, large.length * 0.01)); const jpeg = await readImage(page, large); expect(jpeg.size).toEqual([1200, 800]); - expect(Math.abs(jpeg.center[0] - expected.center[0])).toBeLessThanOrEqual( - 3, - ); + expectNear(jpeg.center, expected.center, 4); await choose(page, format, "WebP"); await type("Width", "600"); await expect(panel.getByRole("textbox", { name: "Height" })).toHaveValue( @@ -272,9 +334,7 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export ); const webp = await readImage(page, await save("photo.webp")); expect(webp.size).toEqual([600, 400]); - expect(Math.abs(webp.center[0] - expected.center[0])).toBeLessThanOrEqual( - 3, - ); + expectNear(webp.center, expected.center, 4); await page.keyboard.press("a"); }); @@ -282,26 +342,32 @@ test("edit a photo, inspect the preview and histograms, undo changes, and export const plot = page .getByLabel("curve input histogram", { exact: true }) .locator("polyline"); - await expect(plot).toHaveAttribute("points", /,\d{1,2}\./); - const whole = (await plot.getAttribute("points")) ?? ""; + await drawn(plot); + const whole = await heights(plot); const mask = await page.evaluate(() => { const mask = window.openlight.addLayer("mask"); window.openlight.setLayer(mask, { name: "Curve input mask" }); return mask; }); // A neutral mask receives the same pixels, counted only where it covers them. - await expect(plot).toHaveAttribute("points", /,\d{1,2}\./); - await expect(plot).not.toHaveAttribute("points", whole); - const neutral = (await plot.getAttribute("points")) ?? ""; + await expect + .poll(async () => { + const now = await heights(plot); + return Math.max(...now) > 1 && difference(now, whole) > 1; + }) + .toBe(true); + const neutral = await heights(plot); await page.evaluate( (mask) => window.openlight.setAdjustments({ exposure: 1 }, mask), mask, ); - await expect(plot).not.toHaveAttribute("points", neutral); - const points = (await plot.getAttribute("points")) ?? ""; + await expect + .poll(async () => meanBin(await heights(plot))) + .toBeGreaterThan(meanBin(neutral) + 2); + const brighter = await heights(plot); await page .getByRole("button", { name: "Show Curve input mask", exact: true }) .click(); - await expect(plot).toHaveAttribute("points", points); + expect(difference(await heights(plot), brighter)).toBeLessThan(0.5); }); }); diff --git a/tests/formats.e2e.ts b/tests/formats.e2e.ts new file mode 100644 index 00000000..9163b3cf --- /dev/null +++ b/tests/formats.e2e.ts @@ -0,0 +1,196 @@ +import { expect, expectNear, test } from "./fixtures"; +import { readImage, readPreview } from "./images"; + +test("open a blank canvas, the sample JPEG, TIFF, RAW, and SVG files, take XMP settings, survive a failed open, and export while another opens", async ({ + page, +}) => { + const state = () => page.evaluate(() => window.openlight.getState()); + const opened = () => + page.evaluate(() => { + const { documentId, file, size } = window.openlight.getState(); + return documentId && { file, size }; + }); + const open = (url: string) => + page.evaluate((url) => window.openlight.loadUrl(url), url); + const exposure = (exposure: number) => + page.evaluate( + (exposure) => window.openlight.setAdjustments({ exposure }), + exposure, + ); + + await test.step("a blank canvas", async () => { + await page.goto("/"); + await page.getByRole("button", { name: /Blank canvas/ }).click(); + await page.getByRole("button", { name: /Square/ }).click(); + await expect.poll(opened).toEqual({ file: "Untitled", size: [3000, 3000] }); + // A small export reads the white canvas without encoding 9 megapixels. + const small = await page.evaluate(async () => [ + ...new Uint8Array( + await ( + await window.openlight.exportImage({ longEdge: 30 }) + ).arrayBuffer(), + ), + ]); + expectNear( + (await readImage(page, new Uint8Array(small))).center, + [255, 255, 255, 255], + ); + }); + + await test.step("the sample photo, a JPEG", async () => { + await page.goto("/"); + await page.getByRole("button", { name: /Sample photo/ }).click(); + await expect.poll(opened).toEqual({ file: "demo.jpg", size: [1400, 764] }); + // The hiker's jacket, the forest, and the sky. + const { samples = [] } = await readImage(page, undefined, [ + [440, 430], + [100, 280], + [700, 60], + ]); + expectNear(samples[0], [106, 26, 15, 255], 6); + expectNear(samples[1], [30, 32, 21, 255], 6); + expectNear(samples[2], [245, 244, 240, 255], 6); + }); + + await test.step("TIFF files keep their color, orientation, headroom, and alpha", async () => { + // A gray profile file exported back to sRGB returns its encoded value: 16384 of 65535 is 64 of 255. + await open("/tests/fixtures/gray16-para.tif"); + const gray = await readImage(page); + expect(gray.size).toEqual([7, 5]); + expectNear(gray.center, [64, 64, 64, 255]); + await open("/tests/fixtures/orientation-6.tif"); + expect((await readImage(page)).size).toEqual([17, 19]); + + // Row 1 of the float file holds linear 1, 2, and 4 at x = 4..6: all clip at first, then separate at -2 stops. + await open("/tests/fixtures/float32-be.tif"); + const reds = async () => { + const { samples = [] } = await readImage(page, undefined, [ + [4, 1], + [5, 1], + [6, 1], + ]); + return samples.map((pixel) => pixel[0]); + }; + expectNear(await reds(), [255, 255, 255]); + await exposure(-2); + const [one, two, four] = await reds(); + expect(one).toBeLessThan(150); + expect(two).toBeGreaterThan(one); + expect(four).toBeGreaterThan(240); + + // The blue pixel brightens without losing its hue, then approaches white. + await open("/tests/fixtures/blue-float.tif"); + await exposure(2); + const [red, green, blue] = (await readImage(page)).center; + expect(blue - Math.max(red, green)).toBeGreaterThan(20); + await exposure(5); + const white = (await readImage(page)).center.slice(0, 3); + expect(Math.min(...white)).toBeGreaterThan(240); + expect(Math.max(...white) - Math.min(...white)).toBeLessThan(10); + + await open("/tests/fixtures/alpha16.tif"); + const alpha = await readImage(page); + expect(alpha.size).toEqual([7, 5]); + // Export composites 50% alpha over the editor's 0.09 display background. + expectNear(alpha.center, [43, 75, 107, 255]); + }); + + for (const name of ["bayer.dng", "linear-jxl.dng"]) { + await test.step(`${name} opens in color and takes white balance`, async () => { + await open(`/tests/fixtures/raw/${name}`); + const image = await readImage(page); + expect(image.size).toEqual([96, 128]); + // The fixture's camera profile is sRGB with known linear samples .5, .25, .125. + expectNear(image.center, [188, 137, 99, 255], 6); + await page.evaluate(() => + window.openlight.setWhiteBalance({ temperature: 4000, tint: 20 }), + ); + const cool = await readImage(page); + // Full SDK camera calibration, including its white-point normalization. + expectNear(cool.center, [192, 161, 166, 255], 6); + expect(cool.center[2] - image.center[2]).toBeGreaterThan(40); + await expect + .poll(async () => { + const { center } = await readPreview(page); + return Math.max( + ...[0, 1, 2].map((i) => Math.abs(center[i] - cool.center[i])), + ); + }) + .toBeLessThanOrEqual(4); + await page.getByRole("button", { name: "As Shot", exact: true }).click(); + expectNear((await readImage(page)).center, image.center, 1); + }); + } + + await test.step("an SVG takes XMP settings and survives a failed open", async () => { + await open("/tests/fixtures/photo.svg"); + expectNear((await readImage(page)).center, [128, 128, 128, 255]); + const chooser = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "Open an image or scene" }).click(); + await (await chooser).setFiles({ + name: "photo.xmp", + mimeType: "", + buffer: Buffer.from( + '', + ), + }); + await expect( + page.getByRole("textbox", { name: "Exposure", exact: true }), + ).toHaveValue("-1.00"); + const edited = await readImage(page); + expect(edited.center[0]).toBeLessThan(110); + + const { documentId } = await state(); + await page.evaluate(() => + window.openlight.loadImage(new File(["invalid"], "broken.png")), + ); + const failure = page.getByText("Couldn't open broken.png:", { + exact: false, + }); + await expect(failure).toBeVisible(); + expect(await state()).toMatchObject({ + documentId, + failure: { file: "broken.png" }, + adjustments: { exposure: -1 }, + }); + await page.getByRole("button", { name: "Dismiss", exact: true }).click(); + await expect(failure).toBeHidden(); + + // Export renders the scene it captured, even when another image opens before it encodes. + const exported = await page.evaluate(async () => { + const api = window.openlight; + const convert = OffscreenCanvas.prototype.convertToBlob; + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + OffscreenCanvas.prototype.convertToBlob = async function (options) { + await gate; + return convert.call(this, options); + }; + try { + const pending = api.exportImage(); + await api.loadImage( + new File( + [ + '', + ], + "replacement.svg", + { type: "image/svg+xml" }, + ), + ); + release(); + return [...new Uint8Array(await (await pending).arrayBuffer())]; + } finally { + release(); + OffscreenCanvas.prototype.convertToBlob = convert; + } + }); + expect(await readImage(page, new Uint8Array(exported))).toEqual(edited); + expect(await state()).toMatchObject({ + size: [32, 32], + adjustments: { exposure: 0 }, + history: { undoCount: 0 }, + }); + }); +}); diff --git a/tests/gpu-stats.e2e.ts b/tests/gpu-stats.e2e.ts index e80dd7ae..199717a4 100644 --- a/tests/gpu-stats.e2e.ts +++ b/tests/gpu-stats.e2e.ts @@ -27,12 +27,40 @@ test("?stats counts the GPU's textures and buffers, and Reset starts a workflow ); await expect(value("Textures")).toHaveText(/ · [1-9]\d* made/); // Changing values only rewrites uniforms: every render, histograms included, reuses what it has. - await stats.getByRole("button", { name: "Reset" }).click(); + // Counted here, since the panel refreshes once a second with no sign of which renders it saw. + const counts = await page.evaluateHandle(() => { + const counts = { made: 0, destroyed: 0 }; + const { createTexture, createBuffer } = GPUDevice.prototype; + const destroyTexture = GPUTexture.prototype.destroy; + const destroyBuffer = GPUBuffer.prototype.destroy; + GPUDevice.prototype.createTexture = function (descriptor) { + counts.made++; + return createTexture.call(this, descriptor); + }; + GPUDevice.prototype.createBuffer = function (descriptor) { + counts.made++; + return createBuffer.call(this, descriptor); + }; + GPUTexture.prototype.destroy = function () { + counts.destroyed++; + destroyTexture.call(this); + }; + GPUBuffer.prototype.destroy = function () { + counts.destroyed++; + destroyBuffer.call(this); + }; + return counts; + }); + const histogram = page + .getByLabel("output histogram", { exact: true }) + .locator("polyline") + .first(); for (const next of ["2", "0.5", "1.5"]) { + const shown = (await histogram.getAttribute("points")) ?? ""; await exposure.fill(next); await exposure.press("Enter"); + // The histogram reads back the finished render. + await expect(histogram).not.toHaveAttribute("points", shown); } - await page.waitForTimeout(1500); - await expect(value("Buffers")).toHaveText(/ · 0 made · 0 freed/); - await expect(value("Textures")).toHaveText(/ · 0 made · 0 freed/); + expect(await counts.jsonValue()).toEqual({ made: 0, destroyed: 0 }); }); diff --git a/tests/heic.e2e.ts b/tests/heic.e2e.ts index b9afb270..29871c28 100644 --- a/tests/heic.e2e.ts +++ b/tests/heic.e2e.ts @@ -1,4 +1,4 @@ -import { expect, test } from "./fixtures"; +import { expect, expectNear, test } from "./fixtures"; import { readImage } from "./images"; test("a HEIC image opens with its size and pixels", async ({ page }) => { @@ -22,13 +22,14 @@ test("a HEIC image opens with its size and pixels", async ({ page }) => { [48, 48], ]); expect(size).toEqual([64, 64]); - // Reference pixels decoded independently with libheif. + // Reference pixels decoded independently with libheif; hardware decoders round differently. const expected = [ - 176, 96, 95, 255, 97, 176, 97, 255, 94, 95, 175, 255, 128, 128, 128, 255, + [176, 96, 95, 255], + [97, 176, 97, 255], + [94, 95, 175, 255], + [128, 128, 128, 255], ]; - samples?.flat().forEach((value, i) => { - expect(Math.abs(value - expected[i]), `Channel ${i}`).toBeLessThanOrEqual( - 3, - ); - }); + for (const [i, pixel] of expected.entries()) { + expectNear(samples?.[i], pixel, 6); + } }); diff --git a/tests/loaders.e2e.ts b/tests/loaders.e2e.ts deleted file mode 100644 index eba492af..00000000 --- a/tests/loaders.e2e.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { expect, openPhoto, test } from "./fixtures"; -import { readImage } from "./images"; - -test("a photo takes XMP settings, survives a failed open, and exports while another replaces it", async ({ - page, -}) => { - const state = () => page.evaluate(() => window.openlight.getState()); - await openPhoto(page); - expect((await readImage(page)).center).toEqual([128, 128, 128, 255]); - const chooser = page.waitForEvent("filechooser"); - await page.getByRole("button", { name: "Open an image or scene" }).click(); - await (await chooser).setFiles({ - name: "photo.xmp", - mimeType: "", - buffer: Buffer.from( - '', - ), - }); - await expect( - page.getByRole("textbox", { name: "Exposure", exact: true }), - ).toHaveValue("-1.00"); - const edited = await readImage(page); - expect(edited.center[0]).toBeLessThan(128); - - const { documentId } = await state(); - await page.evaluate(() => - window.openlight.loadImage(new File(["invalid"], "broken.png")), - ); - const failure = page.getByText("Couldn't open broken.png:", { exact: false }); - await expect(failure).toBeVisible(); - expect(await state()).toMatchObject({ - documentId, - failure: { file: "broken.png" }, - adjustments: { exposure: -1 }, - }); - await page.getByRole("button", { name: "Dismiss", exact: true }).click(); - await expect(failure).toBeHidden(); - - // Export renders the scene it captured, even when another image opens before it encodes. - const exported = await page.evaluate(async () => { - const api = window.openlight; - const convert = OffscreenCanvas.prototype.convertToBlob; - let release = () => {}; - const gate = new Promise((resolve) => { - release = resolve; - }); - OffscreenCanvas.prototype.convertToBlob = async function (options) { - await gate; - return convert.call(this, options); - }; - try { - const pending = api.exportImage(); - await api.loadImage( - new File( - [''], - "replacement.svg", - { type: "image/svg+xml" }, - ), - ); - release(); - return [...new Uint8Array(await (await pending).arrayBuffer())]; - } finally { - release(); - OffscreenCanvas.prototype.convertToBlob = convert; - } - }); - expect(await readImage(page, new Uint8Array(exported))).toEqual(edited); - expect(await state()).toMatchObject({ - size: [32, 32], - adjustments: { exposure: 0 }, - history: { undoCount: 0 }, - }); -}); diff --git a/tests/raw.e2e.ts b/tests/raw.e2e.ts deleted file mode 100644 index 05200392..00000000 --- a/tests/raw.e2e.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expect, test } from "./fixtures"; -import { readImage, readPreview } from "./images"; - -function expectNear(actual: number[], expected: number[]) { - for (const [i, value] of expected.entries()) { - expect(Math.abs(actual[i] - value), `Channel ${i}`).toBeLessThanOrEqual(1); - } -} - -test("Bayer and JPEG XL DNG files open in color and take white balance", async ({ - page, -}) => { - await page.goto("/"); - await page.waitForFunction(() => window.openlight); - for (const name of ["bayer.dng", "linear-jxl.dng"]) { - await test.step(name, async () => { - await page.evaluate( - (url) => window.openlight.loadUrl(url), - `/tests/fixtures/raw/${name}`, - ); - const image = await readImage(page); - expect(image.size).toEqual([96, 128]); - // The fixture's camera profile is sRGB with known linear samples .5, .25, .125. - expectNear(image.center, [188, 137, 99, 255]); - await page.evaluate(() => - window.openlight.setWhiteBalance({ temperature: 4000, tint: 20 }), - ); - const cool = await readImage(page); - // Full SDK camera calibration, including its white-point normalization. - expectNear(cool.center, [192, 161, 166, 255]); - await expect - .poll(async () => (await readPreview(page)).center.slice(0, 3)) - .toEqual(cool.center.slice(0, 3)); - await page.getByRole("button", { name: "As Shot", exact: true }).click(); - expect((await readImage(page)).center).toEqual(image.center); - }); - } -}); diff --git a/tests/scene-file.e2e.ts b/tests/scene-file.e2e.ts index afde2682..a36aee79 100644 --- a/tests/scene-file.e2e.ts +++ b/tests/scene-file.e2e.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import { readZip } from "@/lib/zip"; -import { expect, openPhoto, test } from "./fixtures"; +import { expect, expectNear, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; test("a saved scene reopens the photo with its edits and keeps editing", async ({ @@ -72,7 +72,11 @@ test("a saved scene reopens the photo with its edits and keeps editing", async ( const exposure = page.getByRole("textbox", { name: "Exposure", exact: true }); await expect(exposure).toHaveValue("-1.00"); expect(await field()).toEqual(saved); - expect(await readImage(page, undefined, samples)).toEqual(edited); + const reopened = await readImage(page, undefined, samples); + expect(reopened.size).toEqual(edited.size); + for (const [i, pixel] of (edited.samples ?? []).entries()) { + expectNear(reopened.samples?.[i], pixel, 1); + } await page.evaluate(() => window.openlight.setAdjustments({ exposure: 0 })); await expect(exposure).toHaveValue("0.00"); diff --git a/tests/start.e2e.ts b/tests/start.e2e.ts deleted file mode 100644 index 4a149bb8..00000000 --- a/tests/start.e2e.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect, test } from "./fixtures"; -import { readImage } from "./images"; - -test("start from a blank canvas or the sample photo", async ({ page }) => { - const opened = () => - page.evaluate(() => { - const { documentId, file, size } = window.openlight.getState(); - return documentId && { file, size }; - }); - await page.goto("/"); - await page.getByRole("button", { name: /Blank canvas/ }).click(); - await page.getByRole("button", { name: /Square/ }).click(); - await expect.poll(opened).toEqual({ file: "Untitled", size: [3000, 3000] }); - expect(await readImage(page)).toMatchObject({ - size: [3000, 3000], - center: [255, 255, 255, 255], - }); - - await page.goto("/"); - await page.getByRole("button", { name: /Sample photo/ }).click(); - await expect.poll(opened).toEqual({ file: "demo.jpg", size: [1400, 764] }); -}); diff --git a/tests/tiff.e2e.ts b/tests/tiff.e2e.ts deleted file mode 100644 index 9d2c86ce..00000000 --- a/tests/tiff.e2e.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect, test } from "./fixtures"; -import { readImage } from "./images"; - -test("TIFF files open with their color, orientation, headroom, and alpha", async ({ - page, -}) => { - await page.goto("/"); - await page.waitForFunction(() => window.openlight); - const open = (name: string) => - page.evaluate( - (url) => window.openlight.loadUrl(url), - `/tests/fixtures/${name}`, - ); - const exposure = (exposure: number) => - page.evaluate( - (exposure) => window.openlight.setAdjustments({ exposure }), - exposure, - ); - // A gray profile file exported back to sRGB returns its encoded value: 16384 of 65535 is 64 of 255. - await open("gray16-para.tif"); - const gray = await readImage(page); - expect(gray.size).toEqual([7, 5]); - for (const value of gray.center.slice(0, 3)) { - expect(Math.abs(value - 64)).toBeLessThanOrEqual(1); - } - await open("orientation-6.tif"); - expect((await readImage(page)).size).toEqual([17, 19]); - - // Row 1 of the float file holds linear 1, 2, and 4 at x = 4..6: all clip at first, then separate at -2 stops. - await open("float32-be.tif"); - const reds = async () => { - const { samples = [] } = await readImage(page, undefined, [ - [4, 1], - [5, 1], - [6, 1], - ]); - return samples.map((pixel) => pixel[0]); - }; - expect(await reds()).toEqual([255, 255, 255]); - await exposure(-2); - const [one, two, four] = await reds(); - expect(one).toBeLessThan(150); - expect(two).toBeGreaterThan(one); - expect(four).toBeGreaterThan(240); - - // The blue pixel brightens without losing its hue, then approaches white. - await open("blue-float.tif"); - await exposure(2); - const [red, green, blue] = (await readImage(page)).center; - expect(blue - Math.max(red, green)).toBeGreaterThan(20); - await exposure(5); - const white = (await readImage(page)).center.slice(0, 3); - expect(Math.min(...white)).toBeGreaterThan(240); - expect(Math.max(...white) - Math.min(...white)).toBeLessThan(10); - - await open("alpha16.tif"); - const alpha = await readImage(page); - expect(alpha.size).toEqual([7, 5]); - // Export composites 50% alpha over the editor's 0.09 display background. - for (const [i, expected] of [43, 75, 107, 255].entries()) { - expect(Math.abs(alpha.center[i] - expected)).toBeLessThanOrEqual(1); - } -}); From 055892e36d10305ad7ba0c37b40379d5a46d87d6 Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Wed, 14 Oct 2026 21:34:59 +0000 Subject: [PATCH 6/7] Share the pixel list and overlay helpers across browser tests Co-Authored-By: Claude Opus 5.5 --- tests/brush.e2e.ts | 44 ++------------------------------------------ tests/fixtures.ts | 12 ++++++++++++ tests/images.ts | 31 +++++++++++++++++++++++++++++++ tests/layers.e2e.ts | 44 ++------------------------------------------ tests/paint.e2e.ts | 13 +------------ 5 files changed, 48 insertions(+), 96 deletions(-) diff --git a/tests/brush.e2e.ts b/tests/brush.e2e.ts index 0f4973ec..02e163e3 100644 --- a/tests/brush.e2e.ts +++ b/tests/brush.e2e.ts @@ -1,47 +1,7 @@ -import type { Page } from "@playwright/test"; -import { expect, expectNear, openPhoto, test } from "./fixtures"; -import { readImage } from "./images"; +import { expect, expectNear, expectPixels, openPhoto, test } from "./fixtures"; +import { readImage, redness } from "./images"; import { box, drag } from "./pointer"; -function expectPixels( - actual: readonly number[][], - expected: readonly (readonly number[])[], - tolerance?: number, -) { - expect(actual).toHaveLength(expected.length); - for (const [i, pixel] of expected.entries()) { - expectNear(actual[i], pixel, tolerance); - } -} - -/** Mean red over green across a small square at the displayed canvas's center, as the mask overlay tints it. */ -async function redness(page: Page) { - const bytes = await page - .getByRole("region", { name: "Image canvas" }) - .locator("canvas") - .screenshot(); - return page.evaluate( - async (bytes) => { - const image = await createImageBitmap(new Blob([new Uint8Array(bytes)])); - const canvas = new OffscreenCanvas(image.width, image.height); - const context = canvas.getContext("2d"); - if (!context) throw new Error("Cannot read preview pixels."); - context.drawImage(image, 0, 0); - const { data } = context.getImageData( - image.width / 2 - 10, - image.height / 2 - 10, - 21, - 21, - ); - image.close(); - let sum = 0; - for (let i = 0; i < data.length; i += 4) sum += data[i] - data[i + 1]; - return sum / (data.length / 4); - }, - [...bytes], - ); -} - test("paint a brush mask, adjust it in the sidebar, erase, and undo", async ({ page, }) => { diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 69f6b893..4d0a6534 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -31,6 +31,18 @@ export async function openPhoto(page: Page) { ).toHaveValue("0.00"); } +/** Expects each pixel near its expected one, as `expectNear` does. */ +export function expectPixels( + actual: readonly (readonly number[])[] | undefined, + expected: readonly (readonly number[])[], + tolerance?: number, +) { + expect(actual).toHaveLength(expected.length); + for (const [i, pixel] of expected.entries()) { + expectNear(actual?.[i], pixel, tolerance); + } +} + export const test = base.extend<{ browserErrors: undefined }>({ browserErrors: [ async ({ page, baseURL }, use) => { diff --git a/tests/images.ts b/tests/images.ts index 28fc8925..3280a68a 100644 --- a/tests/images.ts +++ b/tests/images.ts @@ -75,6 +75,37 @@ export async function readPreview(page: Page, offset = [0, 0]) { ); } +/** Mean red over green across a small square of the displayed canvas, `offset` from its center, as the mask overlay tints it. */ +export async function redness( + page: Page, + offset: readonly [number, number] = [0, 0], +) { + const bytes = await page + .getByRole("region", { name: "Image canvas" }) + .locator("canvas") + .screenshot(); + return page.evaluate( + async ([bytes, [x, y]]) => { + const image = await createImageBitmap(new Blob([new Uint8Array(bytes)])); + const canvas = new OffscreenCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Cannot read preview pixels."); + context.drawImage(image, 0, 0); + const { data } = context.getImageData( + image.width / 2 + x - 10, + image.height / 2 + y - 10, + 21, + 21, + ); + image.close(); + let sum = 0; + for (let i = 0; i < data.length; i += 4) sum += data[i] - data[i + 1]; + return sum / (data.length / 4); + }, + [[...bytes], offset] as const, + ); +} + export async function readPixel(page: Page, point: { x: number; y: number }) { const bytes = await page.screenshot({ clip: { ...point, width: 1, height: 1 }, diff --git a/tests/layers.e2e.ts b/tests/layers.e2e.ts index 4c4cbe5e..53b3a2f5 100644 --- a/tests/layers.e2e.ts +++ b/tests/layers.e2e.ts @@ -1,47 +1,7 @@ -import type { Page } from "@playwright/test"; -import { expect, expectNear, openPhoto, test } from "./fixtures"; -import { readImage } from "./images"; +import { expect, expectNear, expectPixels, openPhoto, test } from "./fixtures"; +import { readImage, redness } from "./images"; import { box, choose, drag } from "./pointer"; -function expectPixels( - actual: readonly number[][], - expected: readonly (readonly number[])[], - tolerance?: number, -) { - expect(actual).toHaveLength(expected.length); - for (const [i, pixel] of expected.entries()) { - expectNear(actual[i], pixel, tolerance); - } -} - -/** Mean red over green across a small square of the displayed canvas, `offset` from its center, as the mask overlay tints it. */ -async function redness(page: Page, offset: readonly [number, number]) { - const bytes = await page - .getByRole("region", { name: "Image canvas" }) - .locator("canvas") - .screenshot(); - return page.evaluate( - async ([bytes, [x, y]]) => { - const image = await createImageBitmap(new Blob([new Uint8Array(bytes)])); - const canvas = new OffscreenCanvas(image.width, image.height); - const context = canvas.getContext("2d"); - if (!context) throw new Error("Cannot read preview pixels."); - context.drawImage(image, 0, 0); - const { data } = context.getImageData( - image.width / 2 + x - 10, - image.height / 2 + y - 10, - 21, - 21, - ); - image.close(); - let sum = 0; - for (let i = 0; i < data.length; i += 4) sum += data[i] - data[i + 1]; - return sum / (data.length / 4); - }, - [[...bytes], offset] as const, - ); -} - test("draw masks, edit their child effects, reorder layers and undo", async ({ page, }) => { diff --git a/tests/paint.e2e.ts b/tests/paint.e2e.ts index 2e456a54..7e6928f3 100644 --- a/tests/paint.e2e.ts +++ b/tests/paint.e2e.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { expect, expectNear, openPhoto, test } from "./fixtures"; +import { expect, expectNear, expectPixels, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; import { box, choose, drag } from "./pointer"; @@ -31,17 +31,6 @@ async function photoToPage(page: Page) { ]; } -function expectPixels( - actual: readonly number[][], - expected: readonly (readonly number[])[], - tolerance?: number, -) { - expect(actual).toHaveLength(expected.length); - for (const [i, pixel] of expected.entries()) { - expectNear(actual[i], pixel, tolerance); - } -} - /** Export pixels inside the strokes and far above them. */ async function samples(page: Page) { const { samples } = await readImage(page, undefined, [ From 9210756741380c4d391c6564cb8f204ad7d92006 Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Wed, 14 Oct 2026 22:12:44 +0000 Subject: [PATCH 7/7] Ask browser tests to pass on any GPU Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ade39ef3..5fb999d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,8 @@ Frame rate and low memory are product requirements, on phones as much as desktop Tests follow what a person verifies: open an image, use a tool, undo, export, check the output. Add one when a workflow or its processing changes or a regression is fixed; core algorithms may have unit tests. Layout, styling, and copy are checked by looking at the app. Tests run offline: the browser fixture fails any request beyond the dev server. +Browser tests pass on any GPU: GPUs round filtering, `pow`, and half floats differently. Check pixels as a person would, with `expectNear`, directions, and outcomes rather than exact bytes or golden constants; read image content from the export and the preview only for what only it shows. + ## Review and PRs - Verify what the change can reach with the cheapest check that catches a regression: Bun tests for document, history, and loaders; the feature's `*.e2e.ts` for processing and rendering; the whole suite only when a change cuts across the editor; the affected screens for UI. A failure that also happens on the base is a gap to report.