This commit is contained in:
Andras Schmelczer 2026-09-11 22:14:24 +01:00
commit 0c0f08b350
45 changed files with 1565 additions and 1579 deletions

View file

@ -5,14 +5,19 @@ test('the home page explains the app and its guides fit the screen', async ({ pa
page.on('pageerror', (error) => errors.push(error.message));
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'A great floorstarts witha little planning.',
'A great floor starts with a little planning.',
);
await expect(page.getByRole('heading', { name: 'Scotia or skirting board?' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Plan my floor' })).toHaveCount(2);
await expect(page.getByRole('link', { name: 'Plan my floor' })).toHaveCount(1);
await expect(page.getByRole('link', { name: 'Plan my floor' }).first()).toBeInViewport();
await page.getByRole('link', { name: 'First time fitting a floor?' }).click();
await expect(page).toHaveURL(/#guide$/);
await expect(page.getByRole('heading', { name: /Your first floor/ })).toBeInViewport();
await expect(page.getByRole('link', { name: 'Open planner' })).toBeInViewport();
const header = await page.getByRole('banner').boundingBox();
const guide = await page.getByRole('heading', { name: /Your first floor/ }).boundingBox();
expect(header!.y).toBe(0);
expect(guide!.y).toBeGreaterThanOrEqual(header!.height);
await page.reload();
await expect(page.getByRole('heading', { name: /Your first floor/ })).toBeInViewport();
await page.getByRole('link', { name: 'Back to top' }).click();
@ -26,16 +31,19 @@ test('the home page explains the app and its guides fit the screen', async ({ pa
for (const [name, selector] of [
['header', '.landing-header'],
['hero', '.landing-hero'],
['closing', '.landing-final-cta'],
]) {
test(`the ${name} CTA opens a working planner and browser history returns home`, async ({
page,
}) => {
await page.goto('/');
if (name === 'header') {
await page.getByRole('contentinfo').scrollIntoViewIfNeeded();
await expect(page.getByRole('link', { name: 'Open planner' })).toBeInViewport();
}
await page.locator(selector).locator('a[href="?planner"]').click();
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
await expect(page).toHaveURL(/\?version=1&/);
await expect(page).toHaveURL(/\?version=\d+&/);
await page.getByLabel('Room name', { exact: true }).fill('My first floor');
const projectUrl = page.url();
await page.reload();

View file

@ -1,6 +1,5 @@
import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import LZString from 'lz-string';
import { readFile } from 'node:fs/promises';
import { decodeState, encodeState } from '../src/lib/url-state';
import { PROJECT_STORAGE_KEY } from '../src/lib/use-project';
@ -9,6 +8,7 @@ import { planFlooring } from '../src/lib/flooring';
import { planSkirting } from '../src/lib/skirting';
import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list';
import { cutMm } from '../src/lib/format';
import type { UrlState } from '../src/lib/types';
const errors = new WeakMap<Page, string[]>();
const panel = (page: Page, section: string) =>
@ -22,7 +22,7 @@ const skirtCodes = (page: Page) =>
skirtEntries(page).getByRole('button', { name: /^Preview skirting cut / });
const state = (page: Page) => {
const url = new URL(page.url());
return decodeState(url.search || url.hash).state;
return decodeState(url.search).state;
};
const savedQuery = (page: Page) =>
page.evaluate((key) => localStorage.getItem(key), PROJECT_STORAGE_KEY);
@ -60,6 +60,7 @@ function shortFireplaceFixture() {
length: 1200,
width: 190,
pattern: 'random',
optimisation: 'cuts',
minOffset: 100,
maxOffset: 1000,
minLength: 10,
@ -265,7 +266,7 @@ test.beforeEach(async ({ page }) => {
page.on('pageerror', (error) => errors.get(page)!.push(error.message));
await page.goto('/?planner');
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
await expect(page).toHaveURL(/\?version=1&/);
await expect(page).toHaveURL(/\?version=2&/);
});
test.afterEach(async ({ page }) => {
@ -345,10 +346,14 @@ test('source-board groups contain every numbered cut once and leave full boards
}
await expect(page.locator('.material-cuts table')).toHaveCount(0);
await expect(preview(page).locator('.floor-piece')).toHaveCount(floor.pieces.length);
await expect(preview(page).locator('.skirt-piece')).toHaveCount(skirt.pieces.length);
await expect(preview(page).getByRole('button', { name: /^Select flooring cut / })).toHaveCount(
cutFloor.length,
);
await page
.getByRole('group', { name: 'Plan view' })
.getByRole('button', { name: 'Skirting', exact: true })
.click();
await expect(preview(page).locator('.skirt-piece')).toHaveCount(skirt.pieces.length);
await expect(preview(page).getByRole('button', { name: /^Select skirting cut / })).toHaveCount(
cutSkirt.length,
);
@ -539,7 +544,7 @@ test('internal skirting corners retain coping blanks and expose the installed mi
expect(
skirt.pieces.every(
(piece) =>
Math.abs(piece.length - 2400) < 0.001 && Math.abs(piece.visibleLength! - 2364) < 0.001,
Math.abs(piece.length - 2400) < 0.001 && Math.abs(piece.visibleLength - 2364) < 0.001,
),
).toBe(true);
const receiver = skirt.pieces.find((piece) => piece.wallIndex === 0)!;
@ -580,6 +585,7 @@ test('internal skirting corners retain coping blanks and expose the installed mi
expect(piece.leftOverlap).toBeCloseTo(18, 3);
await page.keyboard.press('Escape');
const floorCount = await floorEntries(page).count();
await page.getByText('Advanced skirting settings', { exact: true }).click();
await page.getByLabel('Minimum piece', { exact: true }).fill('2365');
await expect(skirtEntries(page)).toHaveCount(0);
await expect(floorEntries(page)).toHaveCount(floorCount);
@ -666,6 +672,7 @@ test('pattern, direction and spacers persist without steps, layers or stored res
expect(state(page).project.rooms[0].direction).toBe(90);
expect(Object.keys(state(page).view).sort()).toEqual([
'collapsedSections',
'material',
'roomId',
'search',
'selectedPiece',
@ -713,6 +720,7 @@ test('localStorage restores all project and view parameters when reopening witho
selectedPiece: 'F1',
search: 'Study',
collapsedSections: ['cuts', 'materials'],
material: 'flooring',
};
const query = encodeState(fixture);
await page.goto(`/${query}`);
@ -760,7 +768,7 @@ for (const saved of ['damaged data', '?flooring.length=broken']) {
[PROJECT_STORAGE_KEY, saved],
);
await page.goto('/?planner');
await expect(page).toHaveURL(/\?version=1&/);
await expect(page).toHaveURL(/\?version=2&/);
await expect(page.locator('.url-error')).toHaveCount(0);
expect(state(page)).toEqual(defaultState());
expect(await savedQuery(page)).toBe(encodeState(defaultState()));
@ -869,6 +877,8 @@ test('shared URLs reopen the selected preview and copying the link preserves the
});
test('saw kerf help works with keyboard and touch or mouse', async ({ page, isMobile }) => {
await page.getByText('Advanced flooring settings', { exact: true }).click();
await page.getByText('Advanced skirting settings', { exact: true }).click();
for (const name of ['What is saw kerf?', 'What is skirting saw kerf?']) {
const button = page.getByRole('button', { name, exact: true });
await button.focus();
@ -904,6 +914,10 @@ test('invalid URLs recover when measurements are edited', async ({ page }) => {
});
test('clicking the skirting code itself opens its cutting dialog', async ({ page }) => {
await page
.getByRole('group', { name: 'Plan view' })
.getByRole('button', { name: 'Skirting', exact: true })
.click();
for (const target of ['rect', 'text']) {
const code = preview(page).locator('.skirting-code').first();
const id = await code.evaluate((element) =>
@ -920,18 +934,14 @@ test('clicking the skirting code itself opens its cutting dialog', async ({ page
}
});
test('readable query fields, legacy links and browser history preserve the plan', async ({
page,
}) => {
const legacy = defaultState();
legacy.project.rooms[0].name = 'Legacy lounge';
legacy.project.flooring.pattern = 'random';
const hash = `#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(legacy))}`;
await page.goto(`/${hash}`);
await expect(page).toHaveURL(/\?version=1&/);
expect(new URL(page.url()).hash).toBe('');
expect(state(page)).toEqual(legacy);
expect(await savedQuery(page)).toBe(encodeState(legacy));
test('readable query fields and browser history preserve the plan', async ({ page }) => {
const initial = defaultState();
initial.project.rooms[0].name = 'Current lounge';
initial.project.flooring.pattern = 'random';
await page.goto(`/${encodeState(initial)}`);
await expect(page).toHaveURL(/\?version=2&/);
expect(state(page)).toEqual(initial);
await expect.poll(() => savedQuery(page)).toBe(encodeState(initial));
const original = page.url();
await page.getByRole('button', { name: 'Add room', exact: true }).click();
await page.getByLabel('Room name', { exact: true }).fill('Study');
@ -945,7 +955,7 @@ test('readable query fields, legacy links and browser history preserve the plan'
expect([...params.keys()].some((key) => /boardCount|polygon|pieceIds/.test(key))).toBe(false);
await page.goBack();
await expect(page).toHaveURL(original);
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Legacy lounge');
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Current lounge');
expect(state(page).project.rooms).toHaveLength(1);
expect(await savedQuery(page)).toBe(new URL(original).search);
await page.goForward();
@ -961,35 +971,6 @@ test('readable query fields, legacy links and browser history preserve the plan'
expect(state(page).project.rooms).toHaveLength(2);
});
test('legacy skirting sizes migrate to one editable length and reset changed allocations', async ({
page,
}) => {
const initial = defaultState();
const legacy = {
...initial,
project: {
...initial.project,
completedBoards: ['skirting:S1'],
skirting: { ...initial.project.skirting, stockLength: undefined, stockLengths: [3600, 2400] },
},
};
await page.goto(`/#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(legacy))}`);
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3600');
expect(state(page).project.skirting.stockLength).toBe(3600);
expect(state(page).project.completedBoards).toEqual([]);
const params = new URL(page.url()).searchParams;
expect(params.get('skirting.length')).toBe('3600');
expect(params.has('skirting.stock')).toBe(false);
await page.getByLabel('Skirting board length', { exact: true }).fill('3000');
await page.reload();
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3000');
await page.goto('/?skirting.stock=2400&skirting.stock=2400&done=skirting:S1');
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('2400');
expect(state(page).project.completedBoards).toEqual([]);
expect(new URL(page.url()).searchParams.get('skirting.length')).toBe('2400');
expect(new URL(page.url()).searchParams.has('skirting.stock')).toBe(false);
});
test('printing includes every room and every cut once, grouped by its source board', async ({
page,
}) => {
@ -1173,6 +1154,7 @@ test('inserting and reordering walls keeps stable wall IDs and attached doors',
if (!isMobile) {
const source = page.locator(`.wall-row[data-wall-id="${doorWall.id}"] .wall-drag-handle`);
const target = page.locator(`.wall-row[data-wall-id="${originalIds[0]}"]`);
await page.locator('.wall-table').evaluate((table) => table.scrollIntoView({ block: 'start' }));
await source.dragTo(target);
expect(state(page).project.rooms[0].walls[0]).toEqual(doorWall);
}
@ -1201,9 +1183,8 @@ test('the supplied angled room keeps W12 lines and labels inside the drawing', a
new URL('../tests/fixtures/angled-room.json', import.meta.url),
'utf8',
);
const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(angledRoom)}`);
expect(fixture.error).toBeNull();
await page.goto(`/${encodeState(fixture.state)}`);
const fixture = JSON.parse(angledRoom) as UrlState;
await page.goto(`/${encodeState(fixture)}`);
await expect(preview(page).locator('.wall-drawing')).toHaveCount(12);
for (const width of isMobile ? [320, 390] : [1280]) {
await page.setViewportSize({ width, height: 844 });
@ -1317,11 +1298,10 @@ test('the supplied F8 room aligns a row seam with its fireplace corner and keeps
new URL('../tests/fixtures/angled-room-f8.json', import.meta.url),
'utf8',
);
const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(json)}`);
expect(fixture.error).toBeNull();
fixture.state.view.selectedPiece = null;
fixture.state.view.search = '';
await page.goto(`/${encodeState(fixture.state)}`);
const fixture = JSON.parse(json) as UrlState;
fixture.view.selectedPiece = null;
fixture.view.search = '';
await page.goto(`/${encodeState(fixture)}`);
const { floor, skirt } = plans(page);
const corner = { x: 7607.513, y: 2160.34 };
const atCorner = floor.pieces.filter((piece) =>
@ -1355,7 +1335,7 @@ test('the supplied F8 room aligns a row seam with its fireplace corner and keeps
dimensions.some(({ actual }) => Math.abs(actual * 2 - Math.round(actual * 2)) > 0.001),
).toBe(true);
expectHalfMillimetreCuts(dimensions);
expect(state(page).project.rooms[0].walls).toEqual(fixture.state.project.rooms[0].walls);
expect(state(page).project.rooms[0].walls).toEqual(fixture.project.rooms[0].walls);
await page.keyboard.press('Escape');
const skirtCut = skirtingCuts(skirt).find(
(piece) =>
@ -1399,6 +1379,7 @@ test('completed source boards and locate actions follow shared offcuts across ro
const sourcePieces = floor.pieces.filter((piece) => piece.stockId === shared.id);
const key = `floor:${shared.id}`;
const group = page.locator(`.stock-cut-group[data-board-key="${key}"]`);
await expect(group).toBeVisible();
const groupCodes = await group
.locator('.board-cut')
.evaluateAll((cuts) => cuts.map((cut) => cut.getAttribute('data-cut-id')).join(' + '));
@ -1464,6 +1445,10 @@ test('completed source boards and locate actions follow shared offcuts across ro
.getByRole('checkbox')
.check();
expect(state(page).project.completedBoards).toEqual([key, skirtKey]);
await page
.getByRole('group', { name: 'Plan view' })
.getByRole('button', { name: 'Skirting', exact: true })
.click();
expect(await preview(page).locator('.skirt-piece[data-done="true"]').count()).toBeGreaterThan(0);
await page.getByLabel('Board width', { exact: true }).fill('201');
expect(state(page).project.completedBoards).toEqual([]);
@ -1508,6 +1493,7 @@ test('cuts from one board share the available width and wrap on a narrow phone',
.filter({ has: page.locator('.board-cut:nth-child(2)') })
.first();
const pieces = group.locator('.board-cut');
await expect(group).toBeVisible();
expect(await pieces.count()).toBeGreaterThan(1);
const first = (await pieces.nth(0).boundingBox())!;
const second = (await pieces.nth(1).boundingBox())!;
@ -1558,6 +1544,7 @@ for (const width of [320, 390]) {
}) => {
await page.setViewportSize({ width, height: 844 });
await openWalls(page);
await page.getByText('Advanced flooring settings', { exact: true }).click();
await page.getByRole('button', { name: 'What is saw kerf?', exact: true }).click();
await noHorizontalOverflow(page);
await expect(page.getByRole('tooltip')).toBeVisible();

160
e2e/usability.spec.ts Normal file
View file

@ -0,0 +1,160 @@
import { expect, test } from '@playwright/test';
import { defaultState } from '../src/lib/defaults';
import { decodeState, encodeState } from '../src/lib/url-state';
test.beforeEach(async ({ page }) => {
await page.goto('/?planner');
await expect(page.getByRole('heading', { name: 'Room measurements', exact: true })).toBeVisible();
});
test('new plans start random and guide custom measurements through to the cut plan', async ({
page,
}) => {
await expect(page.getByRole('button', { name: 'Random', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
await expect(page.locator('#custom-shape-help')).toContainText('Click Custom');
await page.getByRole('button', { name: 'Custom', exact: true }).click();
await expect(page.getByRole('button', { name: 'Custom', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
await expect(page.getByLabel('Wall 1 length', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
await page.getByLabel('Wall 2 length', { exact: true }).fill('3000');
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
await page.getByLabel('Wall 3 length', { exact: true }).fill('4000');
await page.getByRole('button', { name: 'Add closing wall', exact: true }).click();
await expect(page.getByText('Outline closed', { exact: true })).toBeVisible();
await expect(page.getByLabel('Wall 4 length', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Custom', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
await page.getByRole('button', { name: 'Materials', exact: true }).click();
await expect(page.getByLabel('Board length', { exact: true })).toBeHidden();
await page.getByRole('button', { name: 'Materials', exact: true }).click();
await expect(page.getByRole('button', { name: 'Materials', exact: true })).toBeFocused();
await expect(page.getByLabel('Board length', { exact: true })).toBeVisible();
await expect(page.getByLabel('Saw kerf', { exact: true })).toBeHidden();
await page.getByText('Advanced flooring settings', { exact: true }).click();
await page.getByLabel('Saw kerf', { exact: true }).fill('4');
expect(decodeState(new URL(page.url()).search).state.project.flooring.kerf).toBe(4);
await page.getByRole('button', { name: 'Cut plan', exact: true }).click();
await page.getByRole('button', { name: 'Cut plan', exact: true }).click();
await expect(page.getByRole('button', { name: 'Cut plan', exact: true })).toBeFocused();
await expect(page.getByLabel('Find a cut or room', { exact: true })).toBeVisible();
});
test('the top switch shows one material and preserves inputs and progress across reloads', async ({
page,
}) => {
const preview = page.getByRole('region', { name: 'Floor plan preview' });
const toggle = page.getByRole('group', { name: 'Plan view', exact: true });
await expect(toggle.getByRole('button', { name: 'Floorboards', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(await preview.locator('.floor-piece').count()).toBeGreaterThan(0);
await expect(preview.locator('.skirt-piece')).toHaveCount(0);
await page.locator('.stock-cut-group input[type="checkbox"]').first().check();
const project = decodeState(new URL(page.url()).search).state.project;
await toggle.getByRole('button', { name: 'Skirting', exact: true }).click();
await expect(preview.locator('.floor-piece')).toHaveCount(0);
expect(await preview.locator('.skirt-piece').count()).toBeGreaterThan(0);
await expect(preview.getByText('Flooring cuts', { exact: true })).toHaveCount(0);
expect(decodeState(new URL(page.url()).search).state.project).toEqual(project);
await page.reload();
await expect(toggle.getByRole('button', { name: 'Skirting', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
await expect(preview.locator('.floor-piece')).toHaveCount(0);
await page.goto('/?planner');
await expect(toggle.getByRole('button', { name: 'Skirting', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(decodeState(new URL(page.url()).search).state.project).toEqual(project);
await toggle.getByRole('button', { name: 'Floorboards', exact: true }).click();
expect(await preview.locator('.floor-piece').count()).toBeGreaterThan(0);
await expect(preview.locator('.skirt-piece')).toHaveCount(0);
});
test('saved brick plans keep their pattern and explicit links restore the selected material', async ({
page,
}) => {
const state = defaultState();
state.project.flooring.pattern = 'brick';
state.view.material = 'skirting';
await page.goto(`/${encodeState(state)}`);
await expect(page.getByRole('button', { name: 'Brick', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
await expect(
page
.getByRole('group', { name: 'Plan view' })
.getByRole('button', { name: 'Skirting', exact: true }),
).toHaveAttribute('aria-pressed', 'true');
await page.goto('/?planner');
await expect(page.getByRole('button', { name: 'Brick', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
});
test('locating cuts changes material automatically and printing includes both materials', async ({
page,
}) => {
const preview = page.getByRole('region', { name: 'Floor plan preview' });
const toggle = page.getByRole('group', { name: 'Plan view', exact: true });
await page.locator('.skirting-cuts .board-group-actions .board-locate').first().click();
await expect(toggle.getByRole('button', { name: 'Skirting', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(await preview.locator('.skirt-piece[data-highlighted="true"]').count()).toBeGreaterThan(0);
await page.getByRole('button', { name: 'Show all pieces', exact: true }).click();
await page.getByLabel('Find a cut or room', { exact: true }).fill('F1');
await expect(toggle.getByRole('button', { name: 'Floorboards', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
await expect(preview.locator('.floor-piece[data-piece-id="F1"]')).toHaveAttribute(
'data-highlighted',
'true',
);
await toggle.getByRole('button', { name: 'Skirting', exact: true }).click();
await expect(page.getByRole('button', { name: 'Show all pieces', exact: true })).toHaveCount(0);
await preview
.getByRole('button', { name: /^Select skirting cut / })
.first()
.click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await page.emulateMedia({ media: 'print' });
expect(await page.locator('.print-plan .floor-piece').count()).toBeGreaterThan(0);
expect(await page.locator('.print-plan .skirt-piece').count()).toBeGreaterThan(0);
});
test('setup and the pinned view switch fit a narrow phone', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 844 });
await page.getByRole('button', { name: 'Materials', exact: true }).scrollIntoViewIfNeeded();
const heading = await page.getByRole('button', { name: 'Materials', exact: true }).boundingBox();
const drawing = await page.getByRole('complementary', { name: 'Room drawing' }).boundingBox();
expect(heading!.y).toBeGreaterThanOrEqual(drawing!.y + drawing!.height);
await page
.getByRole('group', { name: 'Plan view' })
.getByRole('button', { name: 'Skirting', exact: true })
.click();
await page.getByText('Advanced flooring settings', { exact: true }).click();
await page.getByText('Advanced skirting settings', { exact: true }).click();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320);
const toggle = await page.getByRole('group', { name: 'Plan view' }).boundingBox();
expect(toggle!.y).toBeGreaterThanOrEqual(0);
expect(toggle!.y + toggle!.height).toBeLessThan(844);
});