floor-planner/e2e/planner.spec.ts
2026-09-11 17:57:03 +01:00

1508 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { defaultState, fireplace, rectangle } from '../src/lib/defaults';
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';
const errors = new WeakMap<Page, string[]>();
const panel = (page: Page, section: string) =>
page.locator(`.planner-panel[data-section="${section}"]`);
const preview = (page: Page) => page.getByRole('region', { name: 'Floor plan preview' });
const floorEntries = (page: Page) => page.locator('.flooring-cuts .board-cut');
const skirtEntries = (page: Page) => page.locator('.skirting-cuts .board-cut');
const floorCodes = (page: Page) =>
floorEntries(page).getByRole('button', { name: /^Preview flooring cut / });
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;
};
const plans = (page: Page) => {
const { project } = state(page);
return {
project,
floor: planFlooring(project.rooms, project.flooring, project.tolerance),
skirt: planSkirting(project.rooms, project.skirting, project.tolerance),
};
};
function sharedRoomFixture() {
const fixture = defaultState();
fixture.project.flooring.width = 200;
fixture.project.rooms = [
rectangle('room-1', 'Living room', 1820, 1220),
rectangle('room-2', 'Study', 1220, 1820),
];
fixture.project.rooms.forEach((room) =>
room.walls.forEach((wall) => {
wall.doors = [];
}),
);
fixture.project.rooms[1].direction = 90;
fixture.view.roomId = 'room-2';
return fixture;
}
function shortFireplaceFixture() {
const fixture = defaultState();
fixture.project.rooms = [fireplace('room-2', 'Room 2')];
fixture.project.flooring = {
length: 1200,
width: 190,
pattern: 'random',
minOffset: 100,
maxOffset: 1000,
minLength: 10,
minWidth: 0.1,
gap: 10,
kerf: 3,
};
fixture.project.skirting = { stockLength: 2400, thickness: 27, minLength: 200, kerf: 3 };
fixture.view.roomId = 'room-2';
return fixture;
}
async function highlightedIds(page: Page, material?: 'flooring' | 'skirting') {
const selector = `${material ? `[data-material="${material}"]` : '[data-piece-id]'}[data-highlighted="true"]`;
return (
await preview(page)
.locator(selector)
.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-piece-id')))
).sort();
}
async function wallDimensionsInsideSvg(page: Page, wallNumber: number) {
const wall = preview(page)
.locator('.wall-drawing')
.filter({ hasText: new RegExp(`W${wallNumber} ·`) });
await expect(wall.locator('.wall-label text')).toBeVisible();
const result = await wall.evaluate((element) => {
const svg = element.ownerSVGElement!;
const box = svg.viewBox.baseVal;
const inverse = svg.getScreenCTM()!.inverse();
const rootPoint = (element: SVGGraphicsElement, x: number, y: number) =>
new DOMPoint(x, y).matrixTransform(inverse.multiply(element.getScreenCTM()!));
const lines = [...element.querySelectorAll(':scope > line')].map(
(line) => line as SVGLineElement,
);
const endpoints = lines.flatMap((line) => [
rootPoint(line, line.x1.baseVal.value, line.y1.baseVal.value),
rootPoint(line, line.x2.baseVal.value, line.y2.baseVal.value),
]);
const label = element.querySelector('.wall-label') as SVGGElement;
const bounds = label.getBBox();
const corners = [
rootPoint(label, bounds.x, bounds.y),
rootPoint(label, bounds.x + bounds.width, bounds.y),
rootPoint(label, bounds.x + bounds.width, bounds.y + bounds.height),
rootPoint(label, bounds.x, bounds.y + bounds.height),
];
return {
lineCount: lines.length,
outside: [...endpoints, ...corners]
.filter(
(point) =>
point.x < box.x - 0.01 ||
point.x > box.x + box.width + 0.01 ||
point.y < box.y - 0.01 ||
point.y > box.y + box.height + 0.01,
)
.map((point) => ({ x: point.x, y: point.y })),
};
});
expect(result.lineCount).toBe(4);
expect(
result.outside,
`W${wallNumber} lines, ticks and label must fit inside the SVG viewBox`,
).toEqual([]);
}
async function stockGroups(groups: Locator) {
return groups.evaluateAll((elements) =>
elements.map((element) => ({
stockId: element.getAttribute('data-stock-id'),
codes: [...element.querySelectorAll('[data-cut-id]')]
.map((cut) => cut.getAttribute('data-cut-id'))
.sort(),
})),
);
}
function expectedGroups(stocks: { id: string; pieceIds: string[] }[], cuts: { id: string }[]) {
const visible = new Set(cuts.map((piece) => piece.id));
return stocks
.map((stock) => ({
stockId: stock.id,
codes: stock.pieceIds.filter((id) => visible.has(id)).sort(),
}))
.filter((group) => group.codes.length);
}
async function measuredEdges(preview: Locator) {
return preview.evaluate((element) => {
const polygon = element.querySelector('polygon')!;
const points = [...(polygon as SVGPolygonElement).points].map((point) => ({
x: point.x,
y: point.y,
}));
return [...element.querySelectorAll('.dimension-caption')].map((caption) => {
const edge = Number(caption.getAttribute('data-edge'));
const from = points[edge],
to = points[(edge + 1) % points.length];
return {
shown: Number(
(caption.querySelector('.dimension-label tspan') ??
caption.querySelector('.dimension-label'))!.textContent!.replaceAll(',', ''),
),
actual: Math.hypot(to.x - from.x, to.y - from.y),
};
});
});
}
function expectHalfMillimetreCuts(dimensions: { shown: number; actual: number }[]) {
for (const { shown, actual } of dimensions) {
expect(shown, 'A displayed cut must never round down').toBeGreaterThanOrEqual(actual - 1e-7);
expect(shown, 'Round up by less than half a millimetre').toBeLessThan(actual + 0.5 + 1e-7);
expect(shown * 2).toBeCloseTo(Math.round(shown * 2), 7);
}
}
async function noVisibleStockIds(page: Page) {
expect(await page.locator('body').innerText()).not.toMatch(
/(?:Floorboard B|Skirting(?: board)? S)\d+|(?:floor:B|skirting:S)\d+/i,
);
const labels = await page
.locator('[aria-label]')
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute('aria-label')).join(' '),
);
expect(labels).not.toMatch(
/(?:Floorboard B|Skirting(?: board)? S)\d+|(?:floor:B|skirting:S)\d+/i,
);
expect(await page.getByLabel('Find a cut or room', { exact: true }).inputValue()).not.toMatch(
/(?:floor:B|skirting:S)\d+/,
);
}
async function openWalls(page: Page) {
if (
!(await page
.locator('.wall-details')
.evaluate((element) => (element as HTMLDetailsElement).open))
) {
await page.locator('.wall-details summary').click();
}
}
async function noHorizontalOverflow(page: Page) {
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1),
).toBe(true);
}
async function noDrawingScroll(page: Page) {
const scrollContainers = await preview(page).evaluate((root) =>
[root, ...root.querySelectorAll('*')]
.filter((element): element is HTMLElement => element instanceof HTMLElement)
.filter((element) => {
const style = getComputedStyle(element);
return (
(/auto|scroll/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 1) ||
(/auto|scroll/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 1)
);
})
.map((element) => element.className),
);
expect(
scrollContainers,
'The room drawing must use the page scroll, without its own scrollbars',
).toEqual([]);
}
async function noWallLabelOverlap(page: Page) {
const labels = await preview(page)
.locator('.wall-label text')
.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return {
text: element.textContent,
x: rect.x,
y: rect.y,
right: rect.right,
bottom: rect.bottom,
};
}),
);
expect(labels).toHaveLength(8);
const overlaps: string[] = [];
labels.forEach((label, i) =>
labels.slice(i + 1).forEach((other) => {
if (
Math.min(label.right, other.right) - Math.max(label.x, other.x) > 0.5 &&
Math.min(label.bottom, other.bottom) - Math.max(label.y, other.y) > 0.5
) {
overlaps.push(`${label.text} overlaps ${other.text}`);
}
}),
);
expect(overlaps, 'Fireplace wall dimensions should remain independently readable').toEqual([]);
}
test.beforeEach(async ({ page }) => {
errors.set(page, []);
page.on('pageerror', (error) => errors.get(page)!.push(error.message));
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
await expect(page).toHaveURL(/\?version=1&/);
});
test.afterEach(async ({ page }) => {
expect(errors.get(page), 'The application should not throw browser errors').toEqual([]);
});
test('measurements, materials, drawing and both cut lists share one page', async ({ page }) => {
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
await expect(page.getByLabel('Room length', { exact: true })).toHaveValue('3600');
for (const label of [
'Board length',
'Board width',
'Skirting board length',
'Skirting thickness',
]) {
await expect(page.getByLabel(label, { exact: true })).toBeVisible();
}
await expect(page.getByRole('heading', { name: 'Materials', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: /^Flooring cuts/ })).toBeVisible();
await expect(page.getByRole('heading', { name: /^Skirting cuts/ })).toBeVisible();
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
await expect(page.locator('.layer-switch, .step-nav')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Export CSV' })).toHaveCount(0);
await expect(preview(page).getByText('Outline closed', { exact: true })).toHaveCount(0);
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveCount(1);
await expect(
page.getByRole('button', { name: /Add stock length|Remove stock length/ }),
).toHaveCount(0);
await noVisibleStockIds(page);
await expect(page.locator('.app-header').getByText('mm', { exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Zoom (in|out)$/ })).toHaveCount(0);
await noDrawingScroll(page);
await expect(
page.getByRole('button', { name: /Choose materials|Get my cut plan|Measure room/ }),
).toHaveCount(0);
await expect(
page.getByText(
/LESS WASTE\. MORE CONFIDENCE|A better floor starts|Thoughtful plans|Saved in your URL|bounded heuristic|inspect narrow tips/i,
),
).toHaveCount(0);
expect(
await page.locator('#cut-plan').evaluate((element) => element.getBoundingClientRect().top),
).toBeGreaterThan(
await page.locator('#materials').evaluate((element) => element.getBoundingClientRect().bottom),
);
});
test('source-board groups contain every numbered cut once and leave full boards unlabelled', async ({
page,
}) => {
const { project, floor, skirt } = plans(page);
const cutFloor = flooringCuts(floor),
cutSkirt = skirtingCuts(skirt);
const wholeBoards = uncutFlooring(floor, project.flooring).wholeBoards;
expect(wholeBoards).toBeGreaterThan(0);
await expect(floorCodes(page)).toHaveCount(cutFloor.length);
await expect(skirtCodes(page)).toHaveCount(cutSkirt.length);
const floorGroups = await stockGroups(page.locator('.flooring-cuts .stock-cut-group'));
const skirtGroups = await stockGroups(page.locator('.skirting-cuts .stock-cut-group'));
expect(floorGroups).toEqual(expectedGroups(floor.stocks, cutFloor));
expect(skirtGroups).toEqual(expectedGroups(skirt.stocks, cutSkirt));
const shownCodes = floorGroups.flatMap((group) => group.codes);
expect(shownCodes.sort()).toEqual(cutFloor.map((piece) => piece.id).sort());
expect(cutFloor.map((piece) => piece.id)).toEqual(cutFloor.map((_, i) => `F${i + 1}`));
expect(cutSkirt.map((piece) => piece.id)).toEqual(cutSkirt.map((_, i) => `A${i + 1}`));
for (const piece of cutFloor) {
await expect(
floorEntries(page)
.filter({
has: page.getByRole('button', {
name: `Preview flooring cut ${piece.id}`,
exact: true,
}),
})
.locator('.cut-size'),
).toHaveText(`${cutMm(piece.length)} × ${cutMm(piece.width)} mm`);
}
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 expect(preview(page).getByRole('button', { name: /^Select skirting cut / })).toHaveCount(
cutSkirt.length,
);
await expect(page.locator('.flooring-cuts .cut-summary')).toContainText(
`${wholeBoards} laid uncut`,
);
const drawnCodes = await preview(page).locator('svg text').allTextContents();
for (const piece of floor.pieces.filter((piece) => !piece.cut && !piece.reused)) {
expect(shownCodes).not.toContain(piece.id);
expect(drawnCodes).not.toContain(piece.id);
}
const skirtCutIds = new Set(cutSkirt.map((piece) => piece.id));
for (const piece of skirt.pieces.filter((piece) => !skirtCutIds.has(piece.id))) {
expect(drawnCodes).not.toContain(piece.id);
await expect(
preview(page).locator(`.skirt-piece[data-piece-id="${piece.id}"]`),
).toHaveAttribute('aria-hidden', 'true');
}
const uncutColor = await preview(page)
.locator('.skirt-piece.uncut > polygon')
.first()
.getAttribute('fill');
const cutColor = await preview(page).locator('.skirt-cut > polygon').first().getAttribute('fill');
expect(uncutColor).not.toBe(cutColor);
const piece = cutFloor[0];
const thumbnail = page.getByRole('button', {
name: `Preview flooring cut ${piece.id}`,
exact: true,
});
await expect(thumbnail.locator('svg')).toBeVisible();
await thumbnail.click();
const dialog = page.getByRole('dialog', { name: `Flooring cut ${piece.id}`, exact: true });
await expect(dialog).toBeVisible();
await expect(dialog.locator('.cut-origin')).toHaveText(
`${project.rooms[0].name} · Row ${piece.row}`,
);
const shape = dialog.getByRole('img', { name: `Flooring shape ${piece.id}` });
await expect(shape).toBeVisible();
const dimensions = await measuredEdges(shape);
expect(dimensions).toHaveLength(2);
expectHalfMillimetreCuts(dimensions);
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(thumbnail).toBeFocused();
});
test('inconsistent final measurements block cuts and small residuals show the expected length', async ({
page,
}) => {
await openWalls(page);
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700');
await expect(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible();
await expect(floorEntries(page)).toHaveCount(0);
await expect(skirtEntries(page)).toHaveCount(0);
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
await expect(page.getByText(/non-straight wall/)).toBeVisible();
await expect(page.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible();
expect(await floorEntries(page).count()).toBeGreaterThan(0);
});
test('room warnings stay with their room even when two room names match', async ({ page }) => {
await openWalls(page);
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
await expect(page.getByText(/non-straight wall/).first()).toBeVisible();
await page.getByRole('button', { name: 'Add room', exact: true }).click();
await page.getByLabel('Room name', { exact: true }).fill('Living room');
expect(state(page).project.rooms.map((room) => room.name)).toEqual([
'Living room',
'Living room',
]);
await expect(page.getByText(/non-straight wall/)).toHaveCount(0);
await page.locator('.room-tab').first().click();
await expect(page.getByText(/non-straight wall/).first()).toBeVisible();
await openWalls(page);
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700');
await expect(page.getByText(/outline is open by 100\.0 mm/).first()).toBeVisible();
await page.locator('.room-tab').nth(1).click();
await expect(page.getByText(/outline is open by 100\.0 mm/)).toHaveCount(0);
await page.locator('.room-tab').first().click();
await expect(page.getByText(/outline is open by 100\.0 mm/).first()).toBeVisible();
});
test('custom walls close using a calculated wall that can be measured again', async ({ page }) => {
await page.getByRole('button', { name: 'Custom', exact: true }).click();
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 expect(page.getByText('3,000 mm to close')).toBeVisible();
await page.getByRole('button', { name: 'Add closing wall' }).click();
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
await openWalls(page);
await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000');
await page.getByLabel('Wall 4 length', { exact: true }).fill('3050');
await expect(page.getByText(/3,000 mm expected; length differs by 50 mm/)).toBeVisible();
});
test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialogs', async ({
page,
}) => {
await page.getByRole('button', { name: 'With fireplace' }).click();
await expect(page.getByLabel('Wall 8 length', { exact: true })).toHaveValue('3600');
await expect(preview(page).getByRole('img', { name: /floor plan with/ })).toHaveAttribute(
'aria-label',
/8 walls/,
);
await noWallLabelOverlap(page);
const { floor, skirt } = plans(page);
const mitred = skirtingCuts(skirt).find(
(piece) => Math.abs(piece.leftMitre) === 45 || Math.abs(piece.rightMitre) === 45,
)!;
expect(mitred).toBeDefined();
const thumbnail = page.getByRole('button', {
name: `Preview skirting cut ${mitred.id}`,
exact: true,
});
await expect(thumbnail.locator('svg').first()).toBeVisible();
await thumbnail.click();
const dialog = page.getByRole('dialog', { name: `Skirting cut ${mitred.id}`, exact: true });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(/longest points/i);
const lengths = await dialog.locator('.skirting-full-preview .dimension-label').allTextContents();
expect(lengths.map((text) => Number(text.replaceAll(',', '')))).toEqual(
expect.arrayContaining([
Number(cutMm(mitred.length).replaceAll(',', '')),
Number(cutMm(mitred.backLength).replaceAll(',', '')),
]),
);
const end = Math.abs(mitred.leftMitre) === 45 ? 'left' : 'right';
const projected = await dialog
.locator(`.skirting-cut-edge[data-end="${end}"]`)
.evaluate((element) => {
const line = element as SVGLineElement;
const matrix = line.getScreenCTM()!;
const start = new DOMPoint(line.x1.baseVal.value, line.y1.baseVal.value).matrixTransform(
matrix,
);
const finish = new DOMPoint(line.x2.baseVal.value, line.y2.baseVal.value).matrixTransform(
matrix,
);
return { dx: Math.abs(finish.x - start.x), dy: Math.abs(finish.y - start.y) };
});
expect(projected.dx).toBeGreaterThan(5);
expect(projected.dy).toBeGreaterThan(5);
expect(
projected.dx,
'A 45° mitre must have equal horizontal and vertical screen distances',
).toBeCloseTo(projected.dy, 1);
await page.getByRole('button', { name: 'Close cut preview', exact: true }).click();
await expect(thumbnail).toBeFocused();
const shaped = flooringCuts(floor).find((piece) => piece.shaped)!;
expect(shaped).toBeDefined();
await page
.getByRole('button', { name: `Preview flooring cut ${shaped.id}`, exact: true })
.click();
await expect(
page.getByRole('dialog', { name: `Flooring cut ${shaped.id}`, exact: true }),
).toBeVisible();
const shapedPreview = page
.getByRole('dialog')
.getByRole('img', { name: `Flooring shape ${shaped.id}` });
await expect(shapedPreview).toBeVisible();
const dimensions = await measuredEdges(shapedPreview);
expect(dimensions).toHaveLength(shaped.template.length);
expectHalfMillimetreCuts(dimensions);
await expect(
page.getByRole('dialog').locator('.template-coordinates, .vertex-label, .cut-measures'),
).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.locator('.selected-cut')).toHaveCount(0);
});
test('internal skirting corners retain coping blanks and expose the installed minimum', async ({
page,
}) => {
const fixture = defaultState();
fixture.project.rooms = [rectangle('room-1', 'Coped room', 2400, 2400)];
fixture.project.rooms[0].walls.forEach((wall) => {
wall.doors = [];
});
fixture.project.skirting = { stockLength: 2400, thickness: 18, minLength: 200, kerf: 3 };
await page.goto(`/${encodeState(fixture)}`);
const { skirt } = plans(page);
expect(skirt.stockCount).toBe(4);
expect(skirt.cuts).toBe(8);
expect(skirt.pieces).toHaveLength(4);
expect(
skirt.pieces.every(
(piece) =>
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)!;
expect([receiver.leftType, receiver.rightType]).toEqual(['internal', 'internal']);
expect(skirtingCuts(skirt).map((piece) => piece.id)).toEqual(['A1', 'A2', 'A3']);
await expect(skirtCodes(page)).toHaveCount(3);
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
'Buy 4 lengths · 1 installed uncut',
);
const piece = skirt.pieces.find((piece) => piece.id === 'A1')!;
const entry = skirtEntries(page).filter({
has: page.getByRole('button', { name: 'Preview skirting cut A1', exact: true }),
});
await expect(entry.locator('.cut-size')).toContainText('2,400 mm');
await expect(entry).toContainText('Left: Cope · prepare 45°');
await expect(entry).toContainText('Right: Receive cope · square');
await entry.getByRole('button', { name: 'Preview skirting cut A1', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Skirting cut A1', exact: true });
await expect(dialog).toContainText('preparation before coping');
await expect(dialog).toContainText('mark your moulding’s exposed profile');
await expect(dialog).toContainText('Exposed front length: 2,364 mm.');
const end = dialog.getByRole('img', { name: 'Left cope preparation close-up A1', exact: true });
await expect(end).toBeVisible();
const body = end.locator('.skirting-cope-body');
await expect(body).toHaveAttribute('stroke-dasharray', '3 2');
const geometry = await end.evaluate((element) => {
const prep = element.querySelector('.skirting-cut-edge') as SVGLineElement;
const body = element.querySelector('.skirting-cope-body') as SVGLineElement;
return {
preparationX: prep.x2.baseVal.value - prep.x1.baseVal.value,
preparationY: prep.y2.baseVal.value - prep.y1.baseVal.value,
installedX: body.x2.baseVal.value - body.x1.baseVal.value,
};
});
expect(Math.abs(geometry.preparationX)).toBeCloseTo(18, 3);
expect(Math.abs(geometry.preparationY)).toBeCloseTo(18, 3);
expect(geometry.installedX).toBeCloseTo(0, 3);
expect(piece.leftOverlap).toBeCloseTo(18, 3);
await page.keyboard.press('Escape');
const floorCount = await floorEntries(page).count();
await page.getByLabel('Minimum piece', { exact: true }).fill('2365');
await expect(skirtEntries(page)).toHaveCount(0);
await expect(floorEntries(page)).toHaveCount(floorCount);
await expect(page.locator('.issue.error').first()).toBeVisible();
});
test('door positions and material lengths update skirting immediately', async ({ page }) => {
const before = await page.locator('.skirting-cuts').innerText();
await page.getByRole('button', { name: 'Add a doorway' }).click();
await expect(page.locator('.door-card')).toHaveCount(2);
await page.locator('.door-card').last().getByRole('combobox').selectOption({ label: 'W1' });
const movedDoor = page.locator('.door-card').first();
await expect(movedDoor.locator('.door-position')).toContainText(
'W1: measure from W4 corner toward W2 corner.',
);
await movedDoor.getByLabel(/^From W\d+ (corner|start)$/).fill('1200');
await movedDoor.getByLabel('Door width', { exact: true }).fill('900');
expect(state(page).project.rooms[0].walls[0].doors[0]).toMatchObject({
offset: 1200,
width: 900,
});
await expect(movedDoor.getByRole('img', { name: /^Door on W1:/ })).toHaveAttribute(
'aria-label',
'Door on W1: 1,200 mm from W4 corner, then 900 mm opening toward W2 corner',
);
expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before);
await page.getByLabel('Skirting board length', { exact: true }).fill('3600');
await page.getByLabel('Skirting thickness', { exact: true }).fill('22');
const { skirt } = plans(page);
await expect(skirtCodes(page)).toHaveCount(skirtingCuts(skirt).length);
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
`Buy ${skirt.stockCount} lengths`,
);
await movedDoor.getByLabel('Door width', { exact: true }).fill('6000');
await expect(page.getByText(/must fit within the wall/).first()).toBeVisible();
await expect(skirtEntries(page)).toHaveCount(0);
});
test('invalid skirting settings keep flooring results usable', async ({ page }) => {
const floorCount = await floorCodes(page).count();
await page.getByLabel('Skirting board length', { exact: true }).fill('100');
await expect(skirtEntries(page)).toHaveCount(0);
await expect(floorCodes(page)).toHaveCount(floorCount);
await expect(page.locator('.issue.error').first()).toBeVisible();
await page.getByLabel('Skirting board length', { exact: true }).fill('2400');
expect(await skirtEntries(page).count()).toBeGreaterThan(0);
});
test('fractional stock keeps the planner usable and displays the blanks that actually fit', async ({
page,
}) => {
const fixture = defaultState();
fixture.project.rooms = [rectangle('room-1', 'Fractional room', 2400.1, 2400.1)];
fixture.project.rooms[0].walls.forEach((wall) => {
wall.doors = [];
});
fixture.project.skirting = { stockLength: 1200.2, thickness: 18, minLength: 300, kerf: 3 };
await page.goto(`/${encodeState(fixture)}`);
await expect(preview(page)).toBeVisible();
await expect(skirtCodes(page)).toHaveCount(7);
const code = skirtingCuts(plans(page).skirt).find(
(piece) => Math.abs(piece.length - 1200.2) < 1e-6,
)!.id;
const entry = skirtEntries(page).filter({
has: page.getByRole('button', { name: `Preview skirting cut ${code}`, exact: true }),
});
await expect(entry.locator('.cut-size')).toContainText('1,200.2 mm');
await entry.getByRole('button', { name: `Preview skirting cut ${code}`, exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.locator('.skirting-full-preview figcaption')).toContainText(
'1,200.2 × 18 mm',
);
expect(await dialog.locator('.dimension-label').allTextContents()).not.toContain('1,200.5');
});
test('pattern, direction and spacers persist without steps, layers or stored results', async ({
page,
}) => {
await page.getByLabel('Room length', { exact: true }).fill('3650');
await page.getByRole('button', { name: 'Random', exact: true }).click();
await page.getByRole('button', { name: 'Across first wall', exact: true }).click();
await page.getByLabel('Wall spacer', { exact: true }).fill('12');
expect(state(page).project.flooring).toMatchObject({ pattern: 'random', gap: 12 });
expect(state(page).project.rooms[0].direction).toBe(90);
expect(Object.keys(state(page).view).sort()).toEqual([
'collapsedSections',
'roomId',
'search',
'selectedPiece',
]);
expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']);
const table = await page.locator('.flooring-cuts').innerText();
await page.reload();
await expect(page.getByLabel('Wall spacer', { exact: true })).toHaveValue('12');
await expect(page.getByRole('button', { name: 'Random', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
);
await expect(
page.getByRole('button', { name: 'Across first wall', exact: true }),
).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.flooring-cuts')).toHaveText(table, { useInnerText: true });
await noHorizontalOverflow(page);
expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
});
test('a narrow remainder is shared between the first and last flooring rows', async ({ page }) => {
// 3650 minus two 10 mm spacers leaves 19 full rows and a 20 mm sliver.
await page.getByLabel('Room length', { exact: true }).fill('3650');
const { floor } = plans(page);
for (const row of [1, 20]) {
const piece = flooringCuts(floor).find((piece) => piece.row === row);
expect(piece).toBeDefined();
const group = floorEntries(page).filter({
has: page.getByRole('button', { name: `Preview flooring cut ${piece!.id}`, exact: true }),
});
await expect(group.locator('.cut-size')).toContainText('× 105');
}
});
test('multiple rooms retain independent measurements with combined cut lists', async ({ page }) => {
await page.getByRole('button', { name: 'Add room', exact: true }).click();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Room 2');
await page.getByLabel('Room name', { exact: true }).fill('Study');
await page.getByLabel('Room width', { exact: true }).fill('3200');
expect(state(page).project.rooms).toHaveLength(2);
expect(state(page).project.rooms[0].walls[0].length).toBe(4800);
expect(state(page).project.rooms[1].walls[0].length).toBe(3200);
expect(await floorEntries(page).filter({ hasText: 'Living room' }).count()).toBeGreaterThan(0);
expect(await skirtEntries(page).filter({ hasText: 'Study' }).count()).toBeGreaterThan(0);
await page.getByRole('button', { name: /^Living room.*m²/ }).click();
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
await page.getByRole('button', { name: /^Study.*m²/ }).click();
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3200');
await page.getByRole('button', { name: 'Remove this room' }).click();
expect(state(page).project.rooms).toHaveLength(1);
await page.reload();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room');
});
test('shared URLs reopen the selected preview and copying the link preserves the plan', async ({
page,
context,
}) => {
await page.getByLabel('Room name', { exact: true }).fill('Café 北');
const { floor } = plans(page);
const pieceCode = flooringCuts(floor)[0].id;
await page
.getByRole('button', { name: `Preview flooring cut ${pieceCode}`, exact: true })
.click();
await expect(
page.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
).toBeVisible();
expect(state(page).view.selectedPiece).toBe(pieceCode);
const shareUrl = page.url();
const originalTable = await page.locator('.flooring-cuts').innerText();
const shared = await context.newPage();
await shared.goto(shareUrl);
await expect(
shared.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
).toBeVisible();
await expect(shared.locator('.flooring-cuts')).toHaveText(originalTable, {
useInnerText: true,
});
await shared.reload();
await expect(
shared.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
).toBeVisible();
await shared.close();
await page.keyboard.press('Escape');
expect(state(page).view.selectedPiece).toBeNull();
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const closedUrl = page.url();
await page.getByRole('button', { name: 'Copy link', exact: true }).click();
await expect(page.getByText('Link copied', { exact: true })).toBeVisible();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(closedUrl);
await page.reload();
await expect(page.locator('.flooring-cuts')).toHaveText(originalTable, {
useInnerText: true,
});
});
test('saw kerf help works with keyboard and touch or mouse', async ({ page, isMobile }) => {
for (const name of ['What is saw kerf?', 'What is skirting saw kerf?']) {
const button = page.getByRole('button', { name, exact: true });
await button.focus();
await button.press('Space');
await expect(button).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByRole('tooltip')).toContainText(
'Width of material removed by the saw blade.',
);
await expect(page.getByRole('tooltip')).toContainText('600 mm + 397 mm');
await button.press('Escape');
await expect(page.getByRole('tooltip')).toHaveCount(0);
if (isMobile) await button.tap();
else await button.click();
await expect(page.getByRole('tooltip')).toBeVisible();
await noHorizontalOverflow(page);
await page.getByLabel('Board length', { exact: true }).focus();
await expect(page.getByRole('tooltip')).toHaveCount(0);
}
});
test('invalid URLs recover when measurements are edited', async ({ page }) => {
await page.goto('/?flooring.length=broken');
await expect(page.getByText(/Could not load this plan/)).toBeVisible();
await page.getByLabel('Room name', { exact: true }).fill('Recovered plan');
await expect(page.getByText(/Could not load this plan/)).toHaveCount(0);
expect(decodeState(new URL(page.url()).search).error).toBeNull();
expect(new URL(page.url()).hash).toBe('');
await page.reload();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Recovered plan');
});
test('clicking the skirting code itself opens its cutting dialog', async ({ page }) => {
for (const target of ['rect', 'text']) {
const code = preview(page).locator('.skirting-code').first();
const id = await code.evaluate((element) =>
element.closest('[data-piece-id]')!.getAttribute('data-piece-id'),
);
// The text receives centre clicks; use the exposed corner to test the backing rectangle.
await code.locator(target).click(target === 'rect' ? { position: { x: 1, y: 1 } } : {});
await expect(
page.getByRole('dialog', { name: `Skirting cut ${id}`, exact: true }),
).toBeVisible();
expect(state(page).view.selectedPiece).toBe(id);
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
}
});
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);
const original = page.url();
await page.getByRole('button', { name: 'Add room', exact: true }).click();
await page.getByLabel('Room name', { exact: true }).fill('Study');
await page.getByLabel('Room width', { exact: true }).fill('3333');
const twoRooms = page.url();
const params = new URL(twoRooms).searchParams;
expect(params.get('room.2.name')).toBe('Study');
expect(params.get('room.2.wall.1.length')).toBe('3333');
expect(params.get('flooring.pattern')).toBe('random');
expect(params.has('plan')).toBe(false);
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');
expect(state(page).project.rooms).toHaveLength(1);
await page.goForward();
await expect(page).toHaveURL(twoRooms);
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3333');
params.set('flooring.width', '205');
await page.goto(`/?${params}`);
await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205');
await page.reload();
await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205');
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,
}) => {
const fixture = sharedRoomFixture();
await page.goto(`/${encodeState(fixture)}`);
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
const { floor, skirt, project } = plans(page);
const cuts = flooringCuts(floor),
skirting = skirtingCuts(skirt);
const screenFloor = await stockGroups(page.locator('.flooring-cuts .stock-cut-group'));
const screenSkirting = await stockGroups(page.locator('.skirting-cuts .stock-cut-group'));
expect(screenFloor).toEqual(expectedGroups(floor.stocks, cuts));
expect(screenSkirting).toEqual(expectedGroups(skirt.stocks, skirting));
expect(
screenFloor.some(
(group) =>
new Set(group.codes.map((id) => cuts.find((piece) => piece.id === id)!.roomId)).size > 1,
),
).toBe(true);
await expect(page.locator('.print-plan')).toBeHidden();
await page.emulateMedia({ media: 'print' });
const printed = page.locator('.print-plan');
await expect(printed).toBeVisible();
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeHidden();
await expect(printed.locator('.print-room')).toHaveCount(project.rooms.length);
for (const room of project.rooms) {
const drawing = printed.locator(`.print-room[data-room-id="${room.id}"]`);
await expect(drawing.locator('h2').first()).toContainText(room.name);
await expect(drawing.getByRole('img', { name: /floor plan with/ })).toBeVisible();
await expect(drawing.locator('.floor-piece')).toHaveCount(
floor.pieces.filter((piece) => piece.roomId === room.id).length,
);
await expect(drawing.locator('.skirt-piece')).toHaveCount(
skirt.pieces.filter((piece) => piece.roomId === room.id).length,
);
await expect(drawing.getByRole('button')).toHaveCount(0);
}
const printedFloor = await stockGroups(printed.locator('.print-flooring .print-stock'));
const printedSkirting = await stockGroups(printed.locator('.print-skirting .print-stock'));
expect(printedFloor).toEqual(screenFloor);
expect(printedSkirting).toEqual(screenSkirting);
expect(printedFloor.flatMap((group) => group.codes).sort()).toEqual(
cuts.map((piece) => piece.id).sort(),
);
expect(printedSkirting.flatMap((group) => group.codes).sort()).toEqual(
skirting.map((piece) => piece.id).sort(),
);
for (const piece of [...cuts, ...skirting]) {
const cut = printed.locator(`.print-cut[data-cut-id="${piece.id}"]`);
await expect(cut).toBeVisible();
expect(await cut.locator('.dimension-label').count()).toBeGreaterThanOrEqual(2);
await expect(cut.locator('svg').first()).toBeVisible();
}
expect(await printed.innerText()).not.toMatch(
/\b(?:FU|SU)\d+\b|(?:Floorboard B|Skirting(?: board)? S)\d+/i,
);
await page.emulateMedia({ media: 'screen' });
await expect(printed).toBeHidden();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
});
test('panels collapse independently and the room stays pinned while reading cuts', async ({
page,
}) => {
for (const section of ['measurements', 'materials', 'cuts']) {
await expect(panel(page, section).locator('.panel-toggle')).toHaveAttribute(
'aria-expanded',
'true',
);
await panel(page, section).locator('.panel-toggle').click();
await expect(page.locator(`#${section}-content`)).toBeHidden();
}
expect(state(page).view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
expect(new URL(page.url()).searchParams.getAll('collapsed')).toEqual([
'cuts',
'materials',
'measurements',
]);
await page.reload();
for (const section of ['measurements', 'materials', 'cuts']) {
await expect(panel(page, section).locator('.panel-toggle')).toHaveAttribute(
'aria-expanded',
'false',
);
}
await panel(page, 'cuts').locator('.panel-toggle').click();
await expect(page.getByLabel('Find a cut or room', { exact: true })).toBeVisible();
await skirtEntries(page).last().scrollIntoViewIfNeeded();
const scroll = await page.evaluate(() => window.scrollY);
expect(scroll).toBeGreaterThan(500);
const drawing = await page.locator('.drawing-column').boundingBox();
const viewport = page.viewportSize()!;
expect(drawing!.y).toBeGreaterThanOrEqual(0);
expect(drawing!.y).toBeLessThanOrEqual(20);
expect(drawing!.y + drawing!.height).toBeLessThanOrEqual(viewport.height + 1);
if (viewport.width >= 900) {
const panels = await page.locator('.planning-panels').boundingBox();
expect(drawing!.x).toBeGreaterThan(panels!.x + panels!.width);
}
await noDrawingScroll(page);
await noHorizontalOverflow(page);
});
test('drawing cuts still open a usable preview when the cut plan is collapsed', async ({
page,
}) => {
await panel(page, 'cuts').locator('.panel-toggle').click();
await expect(page.locator('#cuts-content')).toBeHidden();
await preview(page).locator('.floor-code[data-piece-id="F1"] text').click();
const dialog = page.getByRole('dialog', { name: 'Flooring cut F1', exact: true });
await expect(dialog).toBeVisible();
await expect(dialog.locator('.cut-dimensions')).toBeVisible();
expect(state(page).view.selectedPiece).toBe('F1');
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await expect(page.locator('#cuts-content')).toBeHidden();
});
test('negative angles can be backspaced and retyped without saving unfinished drafts', async ({
page,
}) => {
await page.getByRole('button', { name: 'With fireplace' }).click();
const turn = page.getByLabel('Wall 4 turn', { exact: true });
await expect(turn).toHaveValue('-90');
await expect(turn).toHaveAttribute('type', 'text');
await turn.focus();
await turn.press('End');
await turn.press('Backspace');
await expect(turn).toHaveValue('-9');
expect(state(page).project.rooms[0].walls[3].turn).toBe(-9);
const committed = page.url();
await turn.press('Backspace');
await expect(turn).toHaveValue('-');
expect(page.url()).toBe(committed);
await turn.press('Backspace');
await expect(turn).toHaveValue('');
expect(page.url()).toBe(committed);
await turn.pressSequentially('-90');
await expect(turn).toHaveValue('-90');
expect(state(page).project.rooms[0].walls[3].turn).toBe(-90);
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
const restored = page.url();
await turn.fill('-');
expect(page.url()).toBe(restored);
await turn.press('Enter');
await expect(turn).toHaveValue('-90');
expect(page.url()).toBe(restored);
const length = page.getByLabel('Wall 4 length', { exact: true });
await length.fill('');
expect(page.url()).toBe(restored);
await length.press('Tab');
await expect(length).toHaveValue('450');
expect(decodeState(new URL(page.url()).search).error).toBeNull();
});
test('inserting and reordering walls keeps stable wall IDs and attached doors', async ({
page,
isMobile,
}) => {
await openWalls(page);
const originals = state(page).project.rooms[0].walls;
const originalIds = originals.map((wall) => wall.id);
const doorWall = originals.find((wall) => wall.doors.length)!;
await page.getByRole('button', { name: 'Insert wall after W2', exact: true }).click();
let walls = state(page).project.rooms[0].walls;
expect(walls).toHaveLength(5);
const inserted = walls[2];
expect(originalIds).not.toContain(inserted.id);
expect(inserted).toMatchObject({ length: 1000, turn: 90, doors: [] });
expect(walls.map((wall) => wall.id)).toEqual([
originalIds[0],
originalIds[1],
inserted.id,
originalIds[2],
originalIds[3],
]);
await page.getByRole('button', { name: 'Move wall 5 up', exact: true }).click();
expect(state(page).project.rooms[0].walls[3]).toEqual(doorWall);
await page.getByRole('button', { name: 'Move wall 4 down', exact: true }).click();
expect(state(page).project.rooms[0].walls[4]).toEqual(doorWall);
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 source.dragTo(target);
expect(state(page).project.rooms[0].walls[0]).toEqual(doorWall);
}
walls = state(page).project.rooms[0].walls;
for (const original of originals)
expect(walls.find((wall) => wall.id === original.id)).toEqual(original);
expect(new Set(walls.map((wall) => wall.id)).size).toBe(5);
const order = walls.map((wall) => wall.id);
await page.reload();
await expect(page.locator('.wall-row')).toHaveCount(5);
expect(
await page
.locator('.wall-row')
.evaluateAll((rows) => rows.map((row) => row.getAttribute('data-wall-id'))),
).toEqual(order);
expect(state(page).project.rooms[0].walls.find((wall) => wall.id === doorWall.id)!.doors).toEqual(
doorWall.doors,
);
});
test('the supplied angled room keeps W12 lines and labels inside the drawing', async ({
page,
isMobile,
}) => {
const angledRoom = await readFile(
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)}`);
await expect(preview(page).locator('.wall-drawing')).toHaveCount(12);
for (const width of isMobile ? [320, 390] : [1280]) {
await page.setViewportSize({ width, height: 844 });
await wallDimensionsInsideSvg(page, 12);
await noDrawingScroll(page);
await noHorizontalOverflow(page);
}
});
test('short fireplace cuts retain positive shapes and readable labels above the walls', async ({
page,
isMobile,
}) => {
await page.setViewportSize({ width: isMobile ? 320 : 1280, height: 844 });
await page.goto(`/${encodeState(shortFireplaceFixture())}`);
const { floor } = plans(page);
const cuts = flooringCuts(floor);
expect(cuts.some((piece) => piece.length < 100)).toBe(true);
for (const piece of floor.pieces) {
expect(piece.length).toBeGreaterThan(0);
expect(piece.width).toBeGreaterThan(0);
const twiceArea = piece.polygon.reduce((sum, point, i) => {
const next = piece.polygon[(i + 1) % piece.polygon.length];
return sum + point.x * next.y - next.x * point.y;
}, 0);
expect(Math.abs(twiceArea)).toBeGreaterThan(0.001);
}
const labels = preview(page).locator('.floor-code');
await expect(labels).toHaveCount(cuts.length);
const failures = await preview(page)
.locator('svg.room-svg')
.evaluate((element) => {
const svg = element as SVGSVGElement;
const box = svg.viewBox.baseVal;
const inverse = svg.getScreenCTM()!.inverse();
const geometry = [...svg.querySelectorAll('.wall-drawing, .skirt-piece')];
const failures: string[] = [];
const rectangles: { id: string | null; rect: DOMRect }[] = [];
for (const label of svg.querySelectorAll('.floor-code')) {
const id = label.getAttribute('data-piece-id');
const rect = label.querySelector('rect') as SVGRectElement;
const text = label.querySelector('text') as SVGTextElement;
const rectBox = rect.getBoundingClientRect(),
textBox = text.getBoundingClientRect();
if (
textBox.left < rectBox.left - 0.5 ||
textBox.right > rectBox.right + 0.5 ||
textBox.top < rectBox.top - 0.5 ||
textBox.bottom > rectBox.bottom + 0.5
)
failures.push(`${id}: text exceeds its backing rectangle`);
rectangles.push({ id, rect: rectBox });
const bounds = rect.getBBox();
const transform = inverse.multiply(rect.getScreenCTM()!);
const corners = [
[bounds.x, bounds.y],
[bounds.x + bounds.width, bounds.y],
[bounds.x + bounds.width, bounds.y + bounds.height],
[bounds.x, bounds.y + bounds.height],
].map(([x, y]) => new DOMPoint(x, y).matrixTransform(transform));
if (
corners.some(
(point) =>
point.x < box.x - 0.01 ||
point.y < box.y - 0.01 ||
point.x > box.x + box.width + 0.01 ||
point.y > box.y + box.height + 0.01,
)
)
failures.push(`${id}: label extends outside the SVG viewBox`);
if (
!geometry.every((shape) =>
Boolean(shape.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING),
)
)
failures.push(`${id}: wall or skirting paints above label`);
}
rectangles.forEach((first, i) =>
rectangles.slice(i + 1).forEach((second) => {
if (
Math.min(first.rect.right, second.rect.right) -
Math.max(first.rect.left, second.rect.left) >
0.5 &&
Math.min(first.rect.bottom, second.rect.bottom) -
Math.max(first.rect.top, second.rect.top) >
0.5
)
failures.push(`${first.id} and ${second.id}: label rectangles overlap`);
}),
);
return failures;
});
expect(failures).toEqual([]);
expect(await labels.locator('.piece-label-leader').count()).toBeGreaterThan(0);
const short = cuts.find((piece) => piece.length < 100)!;
await preview(page).locator(`.floor-code[data-piece-id="${short.id}"] text`).click();
await expect(
page.getByRole('dialog', { name: `Flooring cut ${short.id}`, exact: true }),
).toBeVisible();
await page.keyboard.press('Escape');
const header = (await preview(page).locator('.drawing-header').boundingBox())!;
const legend = (await preview(page).locator('.drawing-legend').boundingBox())!;
expect(legend.y + legend.height - header.y).toBeLessThanOrEqual(isMobile ? 70 : 60);
await noHorizontalOverflow(page);
});
test('the supplied F8 room aligns a row seam with its fireplace corner and keeps exact templates', async ({
page,
}) => {
const json = await readFile(
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 { floor, skirt } = plans(page);
const corner = { x: 7607.513, y: 2160.34 };
const atCorner = floor.pieces.filter((piece) =>
piece.polygon.some((point) => Math.hypot(point.x - corner.x, point.y - corner.y) < 0.01),
);
expect(atCorner.length).toBeGreaterThanOrEqual(2);
expect(new Set(atCorner.map((piece) => piece.row)).size).toBe(2);
for (const piece of atCorner) {
// Flooring runs vertically here, so a row seam has a constant world x coordinate.
const xs = piece.polygon.map((point) => point.x);
expect(
Math.min(Math.abs(corner.x - Math.min(...xs)), Math.abs(corner.x - Math.max(...xs))),
).toBeLessThan(0.01);
const polygon = preview(page).locator(`.floor-piece[data-piece-id="${piece.id}"] > polygon`);
const drawn = await polygon.evaluate((element) =>
[...(element as SVGPolygonElement).points].map((point) => ({ x: point.x, y: point.y })),
);
expect(drawn).toHaveLength(piece.polygon.length);
drawn.forEach((point, i) => {
expect(point.x).toBeCloseTo(piece.polygon[i].x, 2);
expect(point.y).toBeCloseTo(piece.polygon[i].y, 2);
});
}
const cut = atCorner.find((piece) => piece.cut)!;
await page.getByRole('button', { name: `Preview flooring cut ${cut.id}`, exact: true }).click();
const dialog = page.getByRole('dialog', { name: `Flooring cut ${cut.id}`, exact: true });
const shape = dialog.getByRole('img', { name: `Flooring shape ${cut.id}`, exact: true });
await expect(shape).toBeVisible();
const dimensions = await measuredEdges(shape);
expect(
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);
await page.keyboard.press('Escape');
const skirtCut = skirtingCuts(skirt).find(
(piece) =>
Math.abs(piece.length * 2 - Math.round(piece.length * 2)) > 0.001 ||
Math.abs(piece.backLength * 2 - Math.round(piece.backLength * 2)) > 0.001,
)!;
expect(skirtCut).toBeDefined();
await page
.getByRole('button', { name: `Preview skirting cut ${skirtCut.id}`, exact: true })
.click();
const skirtDialog = page.getByRole('dialog', {
name: `Skirting cut ${skirtCut.id}`,
exact: true,
});
const lengths = await skirtDialog
.locator('.skirting-full-preview .dimension-label')
.allTextContents();
expect(lengths.map((text) => Number(text.replaceAll(',', '')))).toEqual(
expect.arrayContaining([
Number(cutMm(skirtCut.length).replaceAll(',', '')),
Number(cutMm(skirtCut.backLength).replaceAll(',', '')),
]),
);
await page.keyboard.press('Escape');
await noVisibleStockIds(page);
await noHorizontalOverflow(page);
});
test('completed source boards and locate actions follow shared offcuts across rooms', async ({
page,
}) => {
const fixture = sharedRoomFixture();
await page.goto(`/${encodeState(fixture)}`);
const { floor, skirt } = plans(page);
const shared = floor.stocks.find(
(stock) =>
new Set(stock.pieceIds.map((id) => floor.pieces.find((piece) => piece.id === id)!.roomId))
.size > 1,
)!;
expect(shared).toBeDefined();
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}"]`);
const groupCodes = await group
.locator('.board-cut')
.evaluateAll((cuts) => cuts.map((cut) => cut.getAttribute('data-cut-id')).join(' + '));
const done = group.getByRole('checkbox', {
name: `Mark cuts ${groupCodes} done`,
exact: true,
});
await done.check();
await expect(group).toHaveClass(/is-done/);
expect(state(page).project.completedBoards).toEqual([key]);
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([key]);
await group.getByRole('button', { name: `Show cuts ${groupCodes} on plan`, exact: true }).click();
expect(state(page).view.search).toBe(key);
const search = page.getByLabel('Find a cut or room', { exact: true });
await expect(search).toHaveValue(groupCodes);
await noVisibleStockIds(page);
await search.fill('');
await search.fill(groupCodes);
expect(state(page).view.search).toBe(groupCodes);
await noVisibleStockIds(page);
expect(await highlightedIds(page, 'flooring')).toEqual(
sourcePieces
.filter((piece) => piece.roomId === 'room-2')
.map((piece) => piece.id)
.sort(),
);
await page
.locator('.matching-rooms')
.getByRole('button', { name: 'Living room', exact: true })
.click();
expect(state(page).view.roomId).toBe('room-1');
expect(await highlightedIds(page, 'flooring')).toEqual(
sourcePieces
.filter((piece) => piece.roomId === 'room-1')
.map((piece) => piece.id)
.sort(),
);
expect(
await preview(page)
.locator('.floor-piece[data-highlighted="true"]')
.evaluateAll((pieces) => pieces.every((piece) => piece.getAttribute('data-done') === 'true')),
).toBe(true);
const piece = sourcePieces.find((piece) => piece.roomId === 'room-2')!;
await page.getByRole('button', { name: `Show cut ${piece.id} on plan`, exact: true }).click();
expect(state(page).view.roomId).toBe('room-2');
expect(state(page).view.search).toBe(piece.id);
expect(await highlightedIds(page)).toEqual([piece.id]);
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(group.locator('.board-cut')).toHaveCount(sourcePieces.length);
await page.reload();
await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue(piece.id);
await expect(done).toBeChecked();
expect(await highlightedIds(page)).toEqual([piece.id]);
await page.getByLabel('Room name', { exact: true }).fill('Office');
expect(state(page).project.completedBoards).toEqual([key]);
await page.locator('.room-tab').first().click();
expect(state(page).project.completedBoards).toEqual([key]);
await page.getByLabel('Find a cut or room', { exact: true }).fill('');
const skirtPiece = skirtingCuts(skirt).find((piece) => piece.roomId === 'room-1')!;
const skirtKey = `skirting:${skirtPiece.stockId}`;
await page
.locator(`.stock-cut-group[data-board-key="${skirtKey}"]`)
.getByRole('checkbox')
.check();
expect(state(page).project.completedBoards).toEqual([key, skirtKey]);
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([]);
await expect(preview(page).locator('[data-done="true"]')).toHaveCount(0);
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]);
});
test('the pinned plan makes active search obvious and can restore every piece', async ({
page,
}) => {
const allCuts = await floorEntries(page).count();
await page.getByLabel('Find a cut or room', { exact: true }).fill('F1');
const banner = page.locator('.drawing-column .plan-search-status');
await expect(banner).toContainText('Search active');
await expect(banner).toContainText('F1');
await expect(banner.getByRole('button', { name: 'Show all pieces', exact: true })).toBeVisible();
expect(await highlightedIds(page, 'flooring')).toEqual(['F1']);
await panel(page, 'cuts').locator('.panel-toggle').click();
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
const bounds = (await banner.boundingBox())!;
expect(bounds.y).toBeGreaterThanOrEqual(0);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(page.viewportSize()!.height);
await banner.getByRole('button', { name: 'Show all pieces', exact: true }).click();
await expect(banner).toHaveCount(0);
expect(state(page).view.search).toBe('');
expect(await highlightedIds(page)).toEqual([]);
await expect(preview(page).locator('.search-muted')).toHaveCount(0);
await panel(page, 'cuts').locator('.panel-toggle').click();
await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue('');
await expect(floorEntries(page)).toHaveCount(allCuts);
await noHorizontalOverflow(page);
});
test('cuts from one board share the available width and wrap on a narrow phone', async ({
page,
isMobile,
}) => {
await page.setViewportSize({ width: isMobile ? 320 : 1600, height: 900 });
await page.goto(`/${encodeState(sharedRoomFixture())}`);
const group = page
.locator('.flooring-cuts .stock-cut-group')
.filter({ has: page.locator('.board-cut:nth-child(2)') })
.first();
const pieces = group.locator('.board-cut');
expect(await pieces.count()).toBeGreaterThan(1);
const first = (await pieces.nth(0).boundingBox())!;
const second = (await pieces.nth(1).boundingBox())!;
if (isMobile) {
expect(second.y).toBeGreaterThanOrEqual(first.y + first.height);
expect(second.x).toBeCloseTo(first.x, 1);
} else {
expect(second.y).toBeCloseTo(first.y, 1);
expect(second.x).toBeGreaterThanOrEqual(first.x + first.width);
}
await noHorizontalOverflow(page);
await noVisibleStockIds(page);
});
test('search can locate and complete a whole board without inventing a cut code', async ({
page,
}) => {
const { floor } = plans(page);
const whole = floor.pieces.find((piece) => !piece.cut && !piece.reused)!;
expect(whole).toBeDefined();
const search = page.getByLabel('Find a cut or room', { exact: true });
await search.fill(`floor:${whole.stockId}`);
const group = page.locator(`.stock-cut-group[data-board-key="floor:${whole.stockId}"]`);
await expect(group).toContainText('Installed uncut');
await group.getByRole('button', { name: 'Show uncut floorboard on plan', exact: true }).click();
await expect(search).toHaveValue('Uncut floorboard');
await noVisibleStockIds(page);
await expect(group.locator('.board-cut')).toHaveCount(0);
await expect(group.getByRole('button', { name: /^Preview/ })).toHaveCount(0);
expect(await highlightedIds(page)).toEqual([whole.id]);
await expect(preview(page).locator(`.floor-piece[data-piece-id="${whole.id}"] text`)).toHaveCount(
0,
);
await group.getByRole('checkbox', { name: 'Mark uncut floorboard done', exact: true }).check();
await expect(preview(page).locator(`.floor-piece[data-piece-id="${whole.id}"]`)).toHaveAttribute(
'data-done',
'true',
);
await search.fill('nothing matches this room');
await expect(page.locator('.search-results')).toHaveText('No matching boards or cuts.');
expect(await highlightedIds(page)).toEqual([]);
expect(state(page).project.completedBoards).toEqual([`floor:${whole.stockId}`]);
});
for (const width of [320, 390]) {
test(`phone layout works at ${width} px with readable inputs and touch targets`, async ({
page,
}) => {
await page.setViewportSize({ width, height: 844 });
await openWalls(page);
await page.getByRole('button', { name: 'What is saw kerf?', exact: true }).click();
await noHorizontalOverflow(page);
await expect(page.getByRole('tooltip')).toBeVisible();
const smallControls = await page
.locator('button, input, select, summary')
.evaluateAll((elements) =>
elements.flatMap((element) => {
const target =
element instanceof HTMLInputElement && element.type === 'checkbox'
? (element.closest('label') ?? element)
: element;
const rect = target.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && (rect.width < 39.5 || rect.height < 39.5)
? [
{
label: element.getAttribute('aria-label') || element.textContent?.trim(),
width: rect.width,
height: rect.height,
},
]
: [];
}),
);
expect(smallControls, 'Every visible form control should have a usable touch target').toEqual(
[],
);
const smallInputs = await page
.locator('input:not([type="checkbox"]), select')
.evaluateAll((elements) =>
elements
.filter(
(element) =>
element.getBoundingClientRect().height > 0 &&
parseFloat(getComputedStyle(element).fontSize) < 16,
)
.map((element) => element.getAttribute('aria-label') || element.id),
);
expect(smallInputs, '16 px input text avoids phone browser zoom').toEqual([]);
expect(
await floorEntries(page)
.first()
.evaluate((element) => getComputedStyle(element).display),
).toMatch(/block|grid|flex/);
await page.getByRole('button', { name: 'With fireplace' }).click();
await noWallLabelOverlap(page);
await noDrawingScroll(page);
await skirtCodes(page).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
const box = await dialog.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.y).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
expect(box!.y + box!.height).toBeLessThanOrEqual(844);
await noHorizontalOverflow(page);
await page.mouse.click(1, 1);
await expect(page.getByRole('dialog')).toHaveCount(0);
expect(state(page).view.selectedPiece).toBeNull();
await expect(page.getByText('Source lengths for skirting cuts', { exact: true })).toHaveCount(
0,
);
await noHorizontalOverflow(page);
});
}