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

@ -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();