import { expect, test } from '@playwright/test'; import type { Locator, Page } from '@playwright/test'; import { readFile } from 'node:fs/promises'; import { decodeState, encodeState } from '../src/lib/url-state'; import { PROJECT_STORAGE_KEY } from '../src/lib/use-project'; 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'; import type { UrlState } from '../src/lib/types'; const errors = new WeakMap(); 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).state; }; const savedQuery = (page: Page) => page.evaluate((key) => localStorage.getItem(key), PROJECT_STORAGE_KEY); 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), // Its end pieces complement the first room's offcuts after saw kerf. rectangle('room-2', 'Study', 1220, 1814), ]; 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', optimisation: 'cuts', 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 as SVGElement).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 { 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.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('/?planner'); await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible(); await expect(page).toHaveURL(/\?plan=/); }); 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).getByRole('button', { name: /^Select flooring cut / })).toHaveCount( cutFloor.length, ); await page .getByRole('group', { name: 'Plan view' }) .getByRole('button', { name: 'Skirting', exact: true }) .click(); await expect(preview(page).locator('.skirt-piece')).toHaveCount(skirt.pieces.length); await expect(preview(page).getByRole('button', { name: /^Select skirting cut / })).toHaveCount( cutSkirt.length, ); 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 page.getByLabel('Wall 4 length', { exact: true }).blur(); 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 page.getByLabel('Wall 4 length', { exact: true }).blur(); 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 page.getByLabel('Wall 4 length', { exact: true }).blur(); 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'); await page.getByLabel('Room name', { exact: true }).blur(); 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 page.getByLabel('Wall 4 length', { exact: true }).blur(); 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.getByLabel('Wall 2 length', { exact: true }).blur(); await page.getByRole('button', { name: 'Add a wall', exact: true }).click(); await page.getByLabel('Wall 3 length', { exact: true }).fill('4000'); await page.getByLabel('Wall 3 length', { exact: true }).blur(); 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 page.getByLabel('Wall 4 length', { exact: true }).blur(); 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 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); await page.keyboard.press('Escape'); const floorCount = await floorEntries(page).count(); await page.getByText('Advanced skirting settings', { exact: true }).click(); await page.getByLabel('Minimum piece', { exact: true }).fill('2365'); await page.getByLabel('Minimum piece', { exact: true }).blur(); 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(/^From W\d+ (corner|start)$/).blur(); await movedDoor.getByLabel('Door width', { exact: true }).fill('900'); await movedDoor.getByLabel('Door width', { exact: true }).blur(); 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 board length', { exact: true }).blur(); await page.getByLabel('Skirting thickness', { exact: true }).fill('22'); await page.getByLabel('Skirting thickness', { exact: true }).blur(); 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 movedDoor.getByLabel('Door width', { exact: true }).blur(); 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 page.getByLabel('Skirting board length', { exact: true }).blur(); 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'); await page.getByLabel('Skirting board length', { exact: true }).blur(); 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.getByLabel('Room length', { exact: true }).blur(); 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'); await page.getByLabel('Wall spacer', { exact: true }).blur(); expect(state(page).project.flooring).toMatchObject({ pattern: 'random', gap: 12 }); expect(state(page).project.rooms[0].direction).toBe(90); 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 savedQuery(page)).toBe(new URL(page.url()).search); }); test('localStorage restores all project and view parameters when reopening without a link', async ({ page, context, }) => { const fixture = sharedRoomFixture(); fixture.project.rooms[0].name = 'Café 北 flooring'; fixture.project.tolerance = 7; fixture.project.flooring = { ...fixture.project.flooring, length: 1300, width: 180, gap: 12, pattern: 'random', minOffset: 250, maxOffset: 600, minLength: 180, minWidth: 50, kerf: 2.5, }; fixture.project.skirting = { stockLength: 3600, thickness: 20, minLength: 250, kerf: 2 }; fixture.project.rooms[1].walls[0].doors = [{ id: 'study-door', offset: 300, width: 850 }]; fixture.project.completedBoards = ['floor:B1', 'skirting:S1']; fixture.view = { roomId: 'room-2', selectedPiece: 'flooring:F1', focus: null, search: 'Study', collapsedSections: ['cuts', 'materials'], material: 'flooring', }; const query = encodeState(fixture); await page.goto(`/${query}`); await expect(page.getByRole('dialog', { name: 'Flooring cut F1', exact: true })).toBeVisible(); expect(await savedQuery(page)).toBe(query); const reopened = await context.newPage(); await reopened.goto('/?planner'); await expect(reopened).toHaveURL(page.url()); expect(state(reopened)).toEqual(fixture); await expect( reopened.getByRole('dialog', { name: 'Flooring cut F1', exact: true }), ).toBeVisible(); await reopened.keyboard.press('Escape'); await expect(reopened.getByLabel('Room name', { exact: true })).toHaveValue('Study'); await expect(reopened.locator('#materials-content')).toBeHidden(); await expect(reopened.locator('#cuts-content')).toBeHidden(); await reopened.getByLabel('Room name', { exact: true }).fill('Saved study'); await reopened.getByLabel('Room name', { exact: true }).blur(); fixture.project.rooms[1].name = 'Saved study'; fixture.view.selectedPiece = null; expect(await savedQuery(reopened)).toBe(encodeState(fixture)); await reopened.goto('/?planner'); await expect(reopened.getByLabel('Room name', { exact: true })).toHaveValue('Saved study'); expect(state(reopened)).toEqual(fixture); await reopened.close(); }); test('explicit query parameters override the saved plan and default omitted fields', async ({ page, }) => { await page.getByLabel('Room name', { exact: true }).fill('Saved room'); await page.getByLabel('Room name', { exact: true }).blur(); await page.getByLabel('Wall spacer', { exact: true }).fill('12'); await page.getByLabel('Wall spacer', { exact: true }).blur(); await page.goto('/?flooring.length=1400'); await expect(page.getByLabel('Board length', { exact: true })).toHaveValue('1400'); const expected = defaultState(); expected.project.flooring.length = 1400; expect(state(page)).toEqual(expected); expect(await savedQuery(page)).toBe(encodeState(expected)); }); for (const saved of ['damaged data', '?flooring.length=broken']) { test(`invalid localStorage falls back to defaults: ${saved}`, async ({ page }) => { await page.evaluate( ([key, value]) => localStorage.setItem(key, value), [PROJECT_STORAGE_KEY, saved], ); await page.goto('/?planner'); await expect(page).toHaveURL(/\?plan=/); await expect(page.locator('.url-error')).toHaveCount(0); expect(state(page)).toEqual(defaultState()); expect(await savedQuery(page)).toBe(encodeState(defaultState())); }); } for (const failure of ['blocked', 'full']) { test(`URL persistence still works when localStorage is ${failure}`, async ({ page }) => { await page.addInitScript((failure) => { if (failure === 'blocked') { Object.defineProperty(window, 'localStorage', { get() { throw new DOMException('Storage blocked', 'SecurityError'); }, }); } else { Storage.prototype.setItem = () => { throw new DOMException('Storage full', 'QuotaExceededError'); }; } }, failure); await page.goto('/?planner'); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room'); await page.getByLabel('Room name', { exact: true }).fill('URL still works'); await page.getByLabel('Room name', { exact: true }).blur(); expect(state(page).project.rooms[0].name).toBe('URL still works'); await page.reload(); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('URL still works'); await expect(page.locator('.url-error')).toHaveCount(0); }); } 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'); await page.getByLabel('Room length', { exact: true }).blur(); 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 name', { exact: true }).blur(); await page.getByLabel('Room width', { exact: true }).fill('3200'); await page.getByLabel('Room width', { exact: true }).blur(); 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é 北'); await page.getByLabel('Room name', { exact: true }).blur(); 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(`flooring:${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 }) => { await page.getByText('Advanced flooring settings', { exact: true }).click(); await page.getByText('Advanced skirting settings', { exact: true }).click(); for (const name of ['What is saw kerf?', 'What is skirting saw kerf?']) { const button = page.getByRole('button', { name, exact: true }); await button.focus(); 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 }) => { const saved = await savedQuery(page); await page.goto('/?flooring.length=broken'); await expect(page.getByText(/Could not load this plan/)).toBeVisible(); expect(await savedQuery(page)).toBe(saved); await page.getByLabel('Room name', { exact: true }).fill('Recovered plan'); await page.getByLabel('Room name', { exact: true }).blur(); 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(''); expect(await savedQuery(page)).toBe(new URL(page.url()).search); 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 }) => { await page .getByRole('group', { name: 'Plan view' }) .getByRole('button', { name: 'Skirting', exact: true }) .click(); for (const target of ['rect', 'text']) { const code = preview(page).locator('.skirting-code').first(); const id = await code.evaluate((element) => 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(`skirting:${id}`); await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0); } }); test('the versioned payload and browser history preserve the plan', async ({ page }) => { const initial = defaultState(); initial.project.rooms[0].name = 'Current lounge'; initial.project.flooring.pattern = 'random'; await page.goto(`/${encodeState(initial)}`); await expect(page).toHaveURL(/\?plan=/); expect(state(page)).toEqual(initial); await expect.poll(() => savedQuery(page)).toBe(encodeState(initial)); const original = page.url(); await page.getByRole('button', { name: 'Add room', exact: true }).click(); await page.getByLabel('Room name', { exact: true }).fill('Study'); await page.getByLabel('Room name', { exact: true }).blur(); await page.getByLabel('Room width', { exact: true }).fill('3333'); await page.getByLabel('Room width', { exact: true }).blur(); const twoRooms = page.url(); expect(state(page).project.rooms[1]).toMatchObject({ name: 'Study', walls: [{ length: 3333 }, {}, {}, {}], }); expect(state(page).project.flooring.pattern).toBe('random'); await page.goBack(); await expect(page).toHaveURL(original); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Current lounge'); expect(state(page).project.rooms).toHaveLength(1); expect(await savedQuery(page)).toBe(new URL(original).search); await page.goForward(); 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'); expect(await savedQuery(page)).toBe(new URL(twoRooms).search); const edited = state(page); edited.project.flooring.width = 205; await page.goto(`/${encodeState(edited)}`); 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' }); await expect(page.locator('.print-plan')).toBeVisible(); 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.slice().sort()).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('flooring: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(-90); 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('-45'); expect(page.url()).toBe(committed); await turn.press('Escape'); await expect(turn).toHaveValue('-90'); await turn.fill('-80'); await turn.press('Enter'); expect(state(page).project.rooms[0].walls[3].turn).toBe(-80); await turn.fill('-90'); await turn.blur(); 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 page.locator('.wall-table').evaluate((table) => table.scrollIntoView({ block: 'start' })); 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 = JSON.parse(angledRoom) as UrlState; await page.goto(`/${encodeState(fixture)}`); await expect(preview(page).locator('.wall-drawing')).toHaveCount(12); for (const width of isMobile ? [320, 390] : [1280]) { await page.setViewportSize({ width, height: 844 }); 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 = JSON.parse(json) as UrlState; fixture.view.selectedPiece = null; fixture.view.search = ''; await page.goto(`/${encodeState(fixture)}`); const { floor, skirt } = plans(page); const corner = { x: 7607.513, y: 2160.34 }; const atCorner = floor.pieces.filter((piece) => 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.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}"]`); await expect(group).toBeVisible(); 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]); await group.getByRole('button', { name: `Show cuts ${groupCodes} on plan`, exact: true }).click(); expect(state(page).view.focus).toEqual({ kind: 'board', material: 'flooring', id: shared.id }); expect(state(page).view.search).toBe(''); const search = page.getByLabel('Find a cut or room', { exact: true }); await expect(search).toHaveValue(''); await expect(page.locator('.plan-search-status')).toContainText(groupCodes); await noVisibleStockIds(page); await search.fill(''); await search.blur(); await search.fill(groupCodes); await search.blur(); 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.focus).toEqual({ kind: 'piece', material: 'flooring', id: 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(''); await expect(page.locator('.plan-search-status')).toContainText(piece.id); await expect(done).toBeChecked(); expect(await highlightedIds(page)).toEqual([piece.id]); await page.getByLabel('Room name', { exact: true }).fill('Office'); await page.getByLabel('Room name', { exact: true }).blur(); expect(state(page).project.completedBoards).toEqual([key]); await page.locator('.room-tab').first().click(); expect(state(page).project.completedBoards).toEqual([key]); await page.getByRole('button', { name: 'Show all pieces', exact: true }).click(); 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]); await page .getByRole('group', { name: 'Plan view' }) .getByRole('button', { name: 'Skirting', exact: true }) .click(); expect(await preview(page).locator('.skirt-piece[data-done="true"]').count()).toBeGreaterThan(0); await page.getByLabel('Board width', { exact: true }).fill('201'); await page.getByLabel('Board width', { exact: true }).blur(); expect(state(page).project.completedBoards).toEqual([]); await expect(preview(page).locator('[data-done="true"]')).toHaveCount(0); }); 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'); await page.getByLabel('Find a cut or room', { exact: true }).blur(); 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'); await expect(group).toBeVisible(); 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('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('Living room'); 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(''); await expect(page.locator('.plan-search-status')).toContainText('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 search.blur(); 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.getByText('Advanced flooring settings', { exact: true }).click(); await page.getByRole('button', { name: 'What is saw kerf?', exact: true }).click(); await noHorizontalOverflow(page); await expect(page.getByRole('tooltip')).toBeVisible(); 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); }); } test('material-qualified selections disambiguate flooring and sixth-room skirting F1', async ({ page, }) => { const fixture = defaultState(); fixture.project.rooms = Array.from({ length: 6 }, (_, i) => rectangle(`room-${i + 1}`, `Room ${i + 1}`, 2400, 2400), ); fixture.view.roomId = 'room-6'; fixture.view.material = 'skirting'; await page.goto(`/${encodeState(fixture)}`); await preview(page).getByRole('button', { name: 'Select skirting cut F1', exact: true }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toHaveAccessibleName('Skirting cut F1'); await expect(dialog).toContainText('Room 6'); await expect(dialog.locator('.flooring-preview')).toHaveCount(0); expect(state(page).view.selectedPiece).toBe('skirting:F1'); await page.reload(); await expect(dialog).toHaveAccessibleName('Skirting cut F1'); await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Preview flooring cut F1', exact: true }).click(); await expect(dialog).toHaveAccessibleName('Flooring cut F1'); await expect(dialog).toContainText('Room 1'); await expect(dialog.locator('.skirting-full-preview')).toHaveCount(0); expect(state(page).view.selectedPiece).toBe('flooring:F1'); }); test('native print events build current sheets synchronously and release them afterward', async ({ page, }) => { await expect(page.locator('.print-plan')).toHaveCount(0); await page.getByLabel('Room name', { exact: true }).fill('Print this room'); const printed = await page.evaluate(() => { window.dispatchEvent(new Event('beforeprint')); return document.querySelector('.print-plan')?.textContent; }); expect(printed).toContain('Print this room'); expect(printed).toContain('Flooring cuts'); expect(printed).toContain('Skirting cuts'); await page.evaluate(() => window.dispatchEvent(new Event('afterprint'))); await expect(page.locator('.print-plan')).toHaveCount(0); });