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 { mm } from '../src/lib/format'; const errors = new WeakMap(); 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), }; }; 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), }; }); }); } 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.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(`${mm(piece.length)} × ${mm(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); for (const dimension of dimensions) expect(dimension.shown).toBeCloseTo(dimension.actual, 3); await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(thumbnail).toBeFocused(); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled(); }); 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 expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); 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(); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled(); 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(); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); }); 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(mitred.length.toFixed(3)), Number(mitred.backLength.toFixed(3)), ]), ); 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); for (const dimension of dimensions) expect(dimension.shown).toBeCloseTo(dimension.actual, 3); 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', ); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled(); 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 expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled(); 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); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); }); 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 expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); await page.getByLabel('Stock length 1', { exact: true }).fill('2400'); expect(await skirtEntries(page).count()).toBeGreaterThan(0); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled(); }); 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(['roomId', '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 CSV contains every cut once', async ({ page, context, }) => { await page.getByLabel('Room name', { exact: true }).fill('Café 北'); const { floor, skirt } = 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(); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Export CSV' }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe('flooring-cut-plan.csv'); const csv = await readFile((await download.path())!, 'utf8'); expect(csv).toContain('"Material","Room","Piece","Stock"'); expect(csv).toContain(`"Flooring","Café 北","${pieceCode}"`); expect(csv.split('\r\n').filter((line) => line.startsWith('"Flooring",'))).toHaveLength( flooringCuts(floor).length, ); expect(csv.split('\r\n').filter((line) => line.startsWith('"Skirting",'))).toHaveLength( skirtingCuts(skirt).length, ); for (const piece of floor.pieces.filter((piece) => !piece.cut && !piece.reused)) expect(csv).not.toContain(`"${piece.id}"`); expect(csv).not.toContain('bounded heuristic'); 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 = 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'; 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/); await page.emulateMedia({ media: 'screen' }); await expect(printed).toBeHidden(); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study'); }); 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 rect = element.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, 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); }); }