782 lines
35 KiB
TypeScript
782 lines
35 KiB
TypeScript
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<Page, string[]>();
|
||
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);
|
||
});
|
||
}
|