floor-planner/e2e/planner.spec.ts
2026-09-11 16:58:58 +01:00

1235 lines
53 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, 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;
}
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', 'Stock length 1', '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();
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
await expect(page.locator('.layer-switch, .step-nav')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Export CSV' })).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();
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
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('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('Stock length 1', { 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('Stock length 1', { 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('Stock length 1', { exact: true }).fill('2400');
expect(await skirtEntries(page).count()).toBeGreaterThan(0);
});
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'),
);
await code.locator(target).click();
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('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).getByRole('button', { name: 'Select flooring cut F1', exact: true }).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);
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
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('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('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);
});
}