1611 lines
69 KiB
TypeScript
1611 lines
69 KiB
TypeScript
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<Page, string[]>();
|
||
const panel = (page: Page, section: string) =>
|
||
page.locator(`.planner-panel[data-section="${section}"]`);
|
||
const preview = (page: Page) => page.getByRole('region', { name: 'Floor plan preview' });
|
||
const floorEntries = (page: Page) => page.locator('.flooring-cuts .board-cut');
|
||
const skirtEntries = (page: Page) => page.locator('.skirting-cuts .board-cut');
|
||
const floorCodes = (page: Page) =>
|
||
floorEntries(page).getByRole('button', { name: /^Preview flooring cut / });
|
||
const skirtCodes = (page: Page) =>
|
||
skirtEntries(page).getByRole('button', { name: /^Preview skirting cut / });
|
||
const state = (page: Page) => {
|
||
const url = new URL(page.url());
|
||
return decodeState(url.search).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.ownerSVGElement!;
|
||
const box = svg.viewBox.baseVal;
|
||
const inverse = svg.getScreenCTM()!.inverse();
|
||
const rootPoint = (element: SVGGraphicsElement, x: number, y: number) =>
|
||
new DOMPoint(x, y).matrixTransform(inverse.multiply(element.getScreenCTM()!));
|
||
const lines = [...element.querySelectorAll(':scope > line')].map(
|
||
(line) => line as SVGLineElement,
|
||
);
|
||
const endpoints = lines.flatMap((line) => [
|
||
rootPoint(line, line.x1.baseVal.value, line.y1.baseVal.value),
|
||
rootPoint(line, line.x2.baseVal.value, line.y2.baseVal.value),
|
||
]);
|
||
const label = element.querySelector('.wall-label') as SVGGElement;
|
||
const bounds = label.getBBox();
|
||
const corners = [
|
||
rootPoint(label, bounds.x, bounds.y),
|
||
rootPoint(label, bounds.x + bounds.width, bounds.y),
|
||
rootPoint(label, bounds.x + bounds.width, bounds.y + bounds.height),
|
||
rootPoint(label, bounds.x, bounds.y + bounds.height),
|
||
];
|
||
return {
|
||
lineCount: lines.length,
|
||
outside: [...endpoints, ...corners]
|
||
.filter(
|
||
(point) =>
|
||
point.x < box.x - 0.01 ||
|
||
point.x > box.x + box.width + 0.01 ||
|
||
point.y < box.y - 0.01 ||
|
||
point.y > box.y + box.height + 0.01,
|
||
)
|
||
.map((point) => ({ x: point.x, y: point.y })),
|
||
};
|
||
});
|
||
expect(result.lineCount).toBe(4);
|
||
expect(
|
||
result.outside,
|
||
`W${wallNumber} lines, ticks and label must fit inside the SVG viewBox`,
|
||
).toEqual([]);
|
||
}
|
||
|
||
async function stockGroups(groups: Locator) {
|
||
return groups.evaluateAll((elements) =>
|
||
elements.map((element) => ({
|
||
stockId: element.getAttribute('data-stock-id'),
|
||
codes: [...element.querySelectorAll('[data-cut-id]')]
|
||
.map((cut) => cut.getAttribute('data-cut-id'))
|
||
.sort(),
|
||
})),
|
||
);
|
||
}
|
||
|
||
function expectedGroups(stocks: { id: string; pieceIds: string[] }[], cuts: { id: string }[]) {
|
||
const visible = new Set(cuts.map((piece) => piece.id));
|
||
return stocks
|
||
.map((stock) => ({
|
||
stockId: stock.id,
|
||
codes: stock.pieceIds.filter((id) => visible.has(id)).sort(),
|
||
}))
|
||
.filter((group) => group.codes.length);
|
||
}
|
||
|
||
async function measuredEdges(preview: Locator) {
|
||
return preview.evaluate((element) => {
|
||
const polygon = element.querySelector('polygon')!;
|
||
const points = [...(polygon as SVGPolygonElement).points].map((point) => ({
|
||
x: point.x,
|
||
y: point.y,
|
||
}));
|
||
return [...element.querySelectorAll('.dimension-caption')].map((caption) => {
|
||
const edge = Number(caption.getAttribute('data-edge'));
|
||
const from = points[edge],
|
||
to = points[(edge + 1) % points.length];
|
||
return {
|
||
shown: Number(
|
||
(caption.querySelector('.dimension-label tspan') ??
|
||
caption.querySelector('.dimension-label'))!.textContent!.replaceAll(',', ''),
|
||
),
|
||
actual: Math.hypot(to.x - from.x, to.y - from.y),
|
||
};
|
||
});
|
||
});
|
||
}
|
||
|
||
function expectHalfMillimetreCuts(dimensions: { shown: number; actual: number }[]) {
|
||
for (const { shown, actual } of dimensions) {
|
||
expect(shown, 'A displayed cut must never round down').toBeGreaterThanOrEqual(actual - 1e-7);
|
||
expect(shown, 'Round up by less than half a millimetre').toBeLessThan(actual + 0.5 + 1e-7);
|
||
expect(shown * 2).toBeCloseTo(Math.round(shown * 2), 7);
|
||
}
|
||
}
|
||
|
||
async function noVisibleStockIds(page: Page) {
|
||
expect(await page.locator('body').innerText()).not.toMatch(
|
||
/(?:Floorboard B|Skirting(?: board)? S)\d+|(?:floor:B|skirting:S)\d+/i,
|
||
);
|
||
const labels = await page
|
||
.locator('[aria-label]')
|
||
.evaluateAll((elements) =>
|
||
elements.map((element) => element.getAttribute('aria-label')).join(' '),
|
||
);
|
||
expect(labels).not.toMatch(
|
||
/(?:Floorboard B|Skirting(?: board)? S)\d+|(?:floor:B|skirting:S)\d+/i,
|
||
);
|
||
expect(await page.getByLabel('Find a cut or room', { exact: true }).inputValue()).not.toMatch(
|
||
/(?:floor:B|skirting:S)\d+/,
|
||
);
|
||
}
|
||
|
||
async function openWalls(page: Page) {
|
||
if (
|
||
!(await page
|
||
.locator('.wall-details')
|
||
.evaluate((element) => (element as HTMLDetailsElement).open))
|
||
) {
|
||
await page.locator('.wall-details summary').click();
|
||
}
|
||
}
|
||
|
||
async function noHorizontalOverflow(page: Page) {
|
||
expect(
|
||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1),
|
||
).toBe(true);
|
||
}
|
||
|
||
async function noDrawingScroll(page: Page) {
|
||
const scrollContainers = await preview(page).evaluate((root) =>
|
||
[root, ...root.querySelectorAll('*')]
|
||
.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
||
.filter((element) => {
|
||
const style = getComputedStyle(element);
|
||
return (
|
||
(/auto|scroll/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 1) ||
|
||
(/auto|scroll/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 1)
|
||
);
|
||
})
|
||
.map((element) => element.className),
|
||
);
|
||
expect(
|
||
scrollContainers,
|
||
'The room drawing must use the page scroll, without its own scrollbars',
|
||
).toEqual([]);
|
||
}
|
||
|
||
async function noWallLabelOverlap(page: Page) {
|
||
const labels = await preview(page)
|
||
.locator('.wall-label text')
|
||
.evaluateAll((elements) =>
|
||
elements.map((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
return {
|
||
text: element.textContent,
|
||
x: rect.x,
|
||
y: rect.y,
|
||
right: rect.right,
|
||
bottom: rect.bottom,
|
||
};
|
||
}),
|
||
);
|
||
expect(labels).toHaveLength(8);
|
||
const overlaps: string[] = [];
|
||
labels.forEach((label, i) =>
|
||
labels.slice(i + 1).forEach((other) => {
|
||
if (
|
||
Math.min(label.right, other.right) - Math.max(label.x, other.x) > 0.5 &&
|
||
Math.min(label.bottom, other.bottom) - Math.max(label.y, other.y) > 0.5
|
||
) {
|
||
overlaps.push(`${label.text} overlaps ${other.text}`);
|
||
}
|
||
}),
|
||
);
|
||
expect(overlaps, 'Fireplace wall dimensions should remain independently readable').toEqual([]);
|
||
}
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
errors.set(page, []);
|
||
page.on('pageerror', (error) => errors.get(page)!.push(error.message));
|
||
await page.goto('/?planner');
|
||
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
|
||
await expect(page).toHaveURL(/\?version=2&/);
|
||
});
|
||
|
||
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 expect(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible();
|
||
await expect(floorEntries(page)).toHaveCount(0);
|
||
await expect(skirtEntries(page)).toHaveCount(0);
|
||
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
|
||
await expect(page.getByText(/non-straight wall/)).toBeVisible();
|
||
await expect(page.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible();
|
||
expect(await floorEntries(page).count()).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('room warnings stay with their room even when two room names match', async ({ page }) => {
|
||
await openWalls(page);
|
||
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
|
||
await expect(page.getByText(/non-straight wall/).first()).toBeVisible();
|
||
await page.getByRole('button', { name: 'Add room', exact: true }).click();
|
||
await page.getByLabel('Room name', { exact: true }).fill('Living room');
|
||
expect(state(page).project.rooms.map((room) => room.name)).toEqual([
|
||
'Living room',
|
||
'Living room',
|
||
]);
|
||
await expect(page.getByText(/non-straight wall/)).toHaveCount(0);
|
||
await page.locator('.room-tab').first().click();
|
||
await expect(page.getByText(/non-straight wall/).first()).toBeVisible();
|
||
await openWalls(page);
|
||
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700');
|
||
await expect(page.getByText(/outline is open by 100\.0 mm/).first()).toBeVisible();
|
||
await page.locator('.room-tab').nth(1).click();
|
||
await expect(page.getByText(/outline is open by 100\.0 mm/)).toHaveCount(0);
|
||
await page.locator('.room-tab').first().click();
|
||
await expect(page.getByText(/outline is open by 100\.0 mm/).first()).toBeVisible();
|
||
});
|
||
|
||
test('custom walls close using a calculated wall that can be measured again', async ({ page }) => {
|
||
await page.getByRole('button', { name: 'Custom', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
|
||
await page.getByLabel('Wall 2 length', { exact: true }).fill('3000');
|
||
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
|
||
await page.getByLabel('Wall 3 length', { exact: true }).fill('4000');
|
||
await expect(page.getByText('3,000 mm to close')).toBeVisible();
|
||
await page.getByRole('button', { name: 'Add closing wall' }).click();
|
||
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
|
||
await openWalls(page);
|
||
await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000');
|
||
await page.getByLabel('Wall 4 length', { exact: true }).fill('3050');
|
||
await expect(page.getByText(/3,000 mm expected; length differs by 50 mm/)).toBeVisible();
|
||
});
|
||
|
||
test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialogs', async ({
|
||
page,
|
||
}) => {
|
||
await page.getByRole('button', { name: 'With fireplace' }).click();
|
||
await expect(page.getByLabel('Wall 8 length', { exact: true })).toHaveValue('3600');
|
||
await expect(preview(page).getByRole('img', { name: /floor plan with/ })).toHaveAttribute(
|
||
'aria-label',
|
||
/8 walls/,
|
||
);
|
||
await noWallLabelOverlap(page);
|
||
const { floor, skirt } = plans(page);
|
||
const mitred = skirtingCuts(skirt).find(
|
||
(piece) => Math.abs(piece.leftMitre) === 45 || Math.abs(piece.rightMitre) === 45,
|
||
)!;
|
||
expect(mitred).toBeDefined();
|
||
const thumbnail = page.getByRole('button', {
|
||
name: `Preview skirting cut ${mitred.id}`,
|
||
exact: true,
|
||
});
|
||
await expect(thumbnail.locator('svg').first()).toBeVisible();
|
||
await thumbnail.click();
|
||
const dialog = page.getByRole('dialog', { name: `Skirting cut ${mitred.id}`, exact: true });
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog).toContainText(/longest points/i);
|
||
const lengths = await dialog.locator('.skirting-full-preview .dimension-label').allTextContents();
|
||
expect(lengths.map((text) => Number(text.replaceAll(',', '')))).toEqual(
|
||
expect.arrayContaining([
|
||
Number(cutMm(mitred.length).replaceAll(',', '')),
|
||
Number(cutMm(mitred.backLength).replaceAll(',', '')),
|
||
]),
|
||
);
|
||
const end = Math.abs(mitred.leftMitre) === 45 ? 'left' : 'right';
|
||
const projected = await dialog
|
||
.locator(`.skirting-cut-edge[data-end="${end}"]`)
|
||
.evaluate((element) => {
|
||
const line = element as SVGLineElement;
|
||
const matrix = line.getScreenCTM()!;
|
||
const start = new DOMPoint(line.x1.baseVal.value, line.y1.baseVal.value).matrixTransform(
|
||
matrix,
|
||
);
|
||
const finish = new DOMPoint(line.x2.baseVal.value, line.y2.baseVal.value).matrixTransform(
|
||
matrix,
|
||
);
|
||
return { dx: Math.abs(finish.x - start.x), dy: Math.abs(finish.y - start.y) };
|
||
});
|
||
expect(projected.dx).toBeGreaterThan(5);
|
||
expect(projected.dy).toBeGreaterThan(5);
|
||
expect(
|
||
projected.dx,
|
||
'A 45° mitre must have equal horizontal and vertical screen distances',
|
||
).toBeCloseTo(projected.dy, 1);
|
||
await page.getByRole('button', { name: 'Close cut preview', exact: true }).click();
|
||
await expect(thumbnail).toBeFocused();
|
||
const shaped = flooringCuts(floor).find((piece) => piece.shaped)!;
|
||
expect(shaped).toBeDefined();
|
||
await page
|
||
.getByRole('button', { name: `Preview flooring cut ${shaped.id}`, exact: true })
|
||
.click();
|
||
await expect(
|
||
page.getByRole('dialog', { name: `Flooring cut ${shaped.id}`, exact: true }),
|
||
).toBeVisible();
|
||
const shapedPreview = page
|
||
.getByRole('dialog')
|
||
.getByRole('img', { name: `Flooring shape ${shaped.id}` });
|
||
await expect(shapedPreview).toBeVisible();
|
||
const dimensions = await measuredEdges(shapedPreview);
|
||
expect(dimensions).toHaveLength(shaped.template.length);
|
||
expectHalfMillimetreCuts(dimensions);
|
||
await expect(
|
||
page.getByRole('dialog').locator('.template-coordinates, .vertex-label, .cut-measures'),
|
||
).toHaveCount(0);
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
await expect(page.locator('.selected-cut')).toHaveCount(0);
|
||
});
|
||
|
||
test('internal skirting corners retain coping blanks and expose the installed minimum', async ({
|
||
page,
|
||
}) => {
|
||
const fixture = defaultState();
|
||
fixture.project.rooms = [rectangle('room-1', 'Coped room', 2400, 2400)];
|
||
fixture.project.rooms[0].walls.forEach((wall) => {
|
||
wall.doors = [];
|
||
});
|
||
fixture.project.skirting = { stockLength: 2400, thickness: 18, minLength: 200, kerf: 3 };
|
||
await page.goto(`/${encodeState(fixture)}`);
|
||
const { skirt } = plans(page);
|
||
expect(skirt.stockCount).toBe(4);
|
||
expect(skirt.cuts).toBe(8);
|
||
expect(skirt.pieces).toHaveLength(4);
|
||
expect(
|
||
skirt.pieces.every(
|
||
(piece) =>
|
||
Math.abs(piece.length - 2400) < 0.001 && Math.abs(piece.visibleLength - 2364) < 0.001,
|
||
),
|
||
).toBe(true);
|
||
const receiver = skirt.pieces.find((piece) => piece.wallIndex === 0)!;
|
||
expect([receiver.leftType, receiver.rightType]).toEqual(['internal', 'internal']);
|
||
expect(skirtingCuts(skirt).map((piece) => piece.id)).toEqual(['A1', 'A2', 'A3']);
|
||
await expect(skirtCodes(page)).toHaveCount(3);
|
||
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
|
||
'Buy 4 lengths · 1 installed uncut',
|
||
);
|
||
const piece = skirt.pieces.find((piece) => piece.id === 'A1')!;
|
||
const entry = skirtEntries(page).filter({
|
||
has: page.getByRole('button', { name: 'Preview skirting cut A1', exact: true }),
|
||
});
|
||
await expect(entry.locator('.cut-size')).toContainText('2,400 mm');
|
||
await expect(entry).toContainText('Left: Cope · prepare 45°');
|
||
await expect(entry).toContainText('Right: Receive cope · square');
|
||
await entry.getByRole('button', { name: 'Preview skirting cut A1', exact: true }).click();
|
||
const dialog = page.getByRole('dialog', { name: 'Skirting cut A1', exact: true });
|
||
await expect(dialog).toContainText('preparation before coping');
|
||
await expect(dialog).toContainText('mark your moulding’s exposed profile');
|
||
await expect(dialog).toContainText('Exposed front length: 2,364 mm.');
|
||
const end = dialog.getByRole('img', { name: 'Left cope preparation close-up A1', exact: true });
|
||
await expect(end).toBeVisible();
|
||
const body = end.locator('.skirting-cope-body');
|
||
await expect(body).toHaveAttribute('stroke-dasharray', '3 2');
|
||
const geometry = await end.evaluate((element) => {
|
||
const prep = element.querySelector('.skirting-cut-edge') as SVGLineElement;
|
||
const body = element.querySelector('.skirting-cope-body') as SVGLineElement;
|
||
return {
|
||
preparationX: prep.x2.baseVal.value - prep.x1.baseVal.value,
|
||
preparationY: prep.y2.baseVal.value - prep.y1.baseVal.value,
|
||
installedX: body.x2.baseVal.value - body.x1.baseVal.value,
|
||
};
|
||
});
|
||
expect(Math.abs(geometry.preparationX)).toBeCloseTo(18, 3);
|
||
expect(Math.abs(geometry.preparationY)).toBeCloseTo(18, 3);
|
||
expect(geometry.installedX).toBeCloseTo(0, 3);
|
||
expect(piece.leftOverlap).toBeCloseTo(18, 3);
|
||
await page.keyboard.press('Escape');
|
||
const floorCount = await floorEntries(page).count();
|
||
await page.getByText('Advanced skirting settings', { exact: true }).click();
|
||
await page.getByLabel('Minimum piece', { exact: true }).fill('2365');
|
||
await expect(skirtEntries(page)).toHaveCount(0);
|
||
await expect(floorEntries(page)).toHaveCount(floorCount);
|
||
await expect(page.locator('.issue.error').first()).toBeVisible();
|
||
});
|
||
|
||
test('door positions and material lengths update skirting immediately', async ({ page }) => {
|
||
const before = await page.locator('.skirting-cuts').innerText();
|
||
await page.getByRole('button', { name: 'Add a doorway' }).click();
|
||
await expect(page.locator('.door-card')).toHaveCount(2);
|
||
await page.locator('.door-card').last().getByRole('combobox').selectOption({ label: 'W1' });
|
||
const movedDoor = page.locator('.door-card').first();
|
||
await expect(movedDoor.locator('.door-position')).toContainText(
|
||
'W1: measure from W4 corner toward W2 corner.',
|
||
);
|
||
await movedDoor.getByLabel(/^From W\d+ (corner|start)$/).fill('1200');
|
||
await movedDoor.getByLabel('Door width', { exact: true }).fill('900');
|
||
expect(state(page).project.rooms[0].walls[0].doors[0]).toMatchObject({
|
||
offset: 1200,
|
||
width: 900,
|
||
});
|
||
await expect(movedDoor.getByRole('img', { name: /^Door on W1:/ })).toHaveAttribute(
|
||
'aria-label',
|
||
'Door on W1: 1,200 mm from W4 corner, then 900 mm opening toward W2 corner',
|
||
);
|
||
expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before);
|
||
await page.getByLabel('Skirting board length', { exact: true }).fill('3600');
|
||
await page.getByLabel('Skirting thickness', { exact: true }).fill('22');
|
||
const { skirt } = plans(page);
|
||
await expect(skirtCodes(page)).toHaveCount(skirtingCuts(skirt).length);
|
||
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
|
||
`Buy ${skirt.stockCount} lengths`,
|
||
);
|
||
await movedDoor.getByLabel('Door width', { exact: true }).fill('6000');
|
||
await expect(page.getByText(/must fit within the wall/).first()).toBeVisible();
|
||
await expect(skirtEntries(page)).toHaveCount(0);
|
||
});
|
||
|
||
test('invalid skirting settings keep flooring results usable', async ({ page }) => {
|
||
const floorCount = await floorCodes(page).count();
|
||
await page.getByLabel('Skirting board length', { exact: true }).fill('100');
|
||
await expect(skirtEntries(page)).toHaveCount(0);
|
||
await expect(floorCodes(page)).toHaveCount(floorCount);
|
||
await expect(page.locator('.issue.error').first()).toBeVisible();
|
||
await page.getByLabel('Skirting board length', { exact: true }).fill('2400');
|
||
expect(await skirtEntries(page).count()).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('fractional stock keeps the planner usable and displays the blanks that actually fit', async ({
|
||
page,
|
||
}) => {
|
||
const fixture = defaultState();
|
||
fixture.project.rooms = [rectangle('room-1', 'Fractional room', 2400.1, 2400.1)];
|
||
fixture.project.rooms[0].walls.forEach((wall) => {
|
||
wall.doors = [];
|
||
});
|
||
fixture.project.skirting = { stockLength: 1200.2, thickness: 18, minLength: 300, kerf: 3 };
|
||
await page.goto(`/${encodeState(fixture)}`);
|
||
await expect(preview(page)).toBeVisible();
|
||
await expect(skirtCodes(page)).toHaveCount(7);
|
||
const code = skirtingCuts(plans(page).skirt).find(
|
||
(piece) => Math.abs(piece.length - 1200.2) < 1e-6,
|
||
)!.id;
|
||
const entry = skirtEntries(page).filter({
|
||
has: page.getByRole('button', { name: `Preview skirting cut ${code}`, exact: true }),
|
||
});
|
||
await expect(entry.locator('.cut-size')).toContainText('1,200.2 mm');
|
||
await entry.getByRole('button', { name: `Preview skirting cut ${code}`, exact: true }).click();
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog.locator('.skirting-full-preview figcaption')).toContainText(
|
||
'1,200.2 × 18 mm',
|
||
);
|
||
expect(await dialog.locator('.dimension-label').allTextContents()).not.toContain('1,200.5');
|
||
});
|
||
|
||
test('pattern, direction and spacers persist without steps, layers or stored results', async ({
|
||
page,
|
||
}) => {
|
||
await page.getByLabel('Room length', { exact: true }).fill('3650');
|
||
await page.getByRole('button', { name: 'Random', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Across first wall', exact: true }).click();
|
||
await page.getByLabel('Wall spacer', { exact: true }).fill('12');
|
||
expect(state(page).project.flooring).toMatchObject({ pattern: 'random', gap: 12 });
|
||
expect(state(page).project.rooms[0].direction).toBe(90);
|
||
expect(Object.keys(state(page).view).sort()).toEqual([
|
||
'collapsedSections',
|
||
'material',
|
||
'roomId',
|
||
'search',
|
||
'selectedPiece',
|
||
]);
|
||
expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']);
|
||
const table = await page.locator('.flooring-cuts').innerText();
|
||
await page.reload();
|
||
await expect(page.getByLabel('Wall spacer', { exact: true })).toHaveValue('12');
|
||
await expect(page.getByRole('button', { name: 'Random', exact: true })).toHaveAttribute(
|
||
'aria-pressed',
|
||
'true',
|
||
);
|
||
await expect(
|
||
page.getByRole('button', { name: 'Across first wall', exact: true }),
|
||
).toHaveAttribute('aria-pressed', 'true');
|
||
await expect(page.locator('.flooring-cuts')).toHaveText(table, { useInnerText: true });
|
||
await noHorizontalOverflow(page);
|
||
expect(await 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.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: 'F1',
|
||
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');
|
||
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('Wall spacer', { exact: true }).fill('12');
|
||
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(/\?version=2&/);
|
||
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');
|
||
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');
|
||
const { floor } = plans(page);
|
||
for (const row of [1, 20]) {
|
||
const piece = flooringCuts(floor).find((piece) => piece.row === row);
|
||
expect(piece).toBeDefined();
|
||
const group = floorEntries(page).filter({
|
||
has: page.getByRole('button', { name: `Preview flooring cut ${piece!.id}`, exact: true }),
|
||
});
|
||
await expect(group.locator('.cut-size')).toContainText('× 105');
|
||
}
|
||
});
|
||
|
||
test('multiple rooms retain independent measurements with combined cut lists', async ({ page }) => {
|
||
await page.getByRole('button', { name: 'Add room', exact: true }).click();
|
||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Room 2');
|
||
await page.getByLabel('Room name', { exact: true }).fill('Study');
|
||
await page.getByLabel('Room width', { exact: true }).fill('3200');
|
||
expect(state(page).project.rooms).toHaveLength(2);
|
||
expect(state(page).project.rooms[0].walls[0].length).toBe(4800);
|
||
expect(state(page).project.rooms[1].walls[0].length).toBe(3200);
|
||
expect(await floorEntries(page).filter({ hasText: 'Living room' }).count()).toBeGreaterThan(0);
|
||
expect(await skirtEntries(page).filter({ hasText: 'Study' }).count()).toBeGreaterThan(0);
|
||
await page.getByRole('button', { name: /^Living room.*m²/ }).click();
|
||
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
|
||
await page.getByRole('button', { name: /^Study.*m²/ }).click();
|
||
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3200');
|
||
await page.getByRole('button', { name: 'Remove this room' }).click();
|
||
expect(state(page).project.rooms).toHaveLength(1);
|
||
await page.reload();
|
||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room');
|
||
});
|
||
|
||
test('shared URLs reopen the selected preview and copying the link preserves the plan', async ({
|
||
page,
|
||
context,
|
||
}) => {
|
||
await page.getByLabel('Room name', { exact: true }).fill('Café 北');
|
||
const { floor } = plans(page);
|
||
const pieceCode = flooringCuts(floor)[0].id;
|
||
await page
|
||
.getByRole('button', { name: `Preview flooring cut ${pieceCode}`, exact: true })
|
||
.click();
|
||
await expect(
|
||
page.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
|
||
).toBeVisible();
|
||
expect(state(page).view.selectedPiece).toBe(pieceCode);
|
||
const shareUrl = page.url();
|
||
const originalTable = await page.locator('.flooring-cuts').innerText();
|
||
const shared = await context.newPage();
|
||
await shared.goto(shareUrl);
|
||
await expect(
|
||
shared.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
|
||
).toBeVisible();
|
||
await expect(shared.locator('.flooring-cuts')).toHaveText(originalTable, {
|
||
useInnerText: true,
|
||
});
|
||
await shared.reload();
|
||
await expect(
|
||
shared.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
|
||
).toBeVisible();
|
||
await shared.close();
|
||
await page.keyboard.press('Escape');
|
||
expect(state(page).view.selectedPiece).toBeNull();
|
||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||
const closedUrl = page.url();
|
||
await page.getByRole('button', { name: 'Copy link', exact: true }).click();
|
||
await expect(page.getByText('Link copied', { exact: true })).toBeVisible();
|
||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(closedUrl);
|
||
await page.reload();
|
||
await expect(page.locator('.flooring-cuts')).toHaveText(originalTable, {
|
||
useInnerText: true,
|
||
});
|
||
});
|
||
|
||
test('saw kerf help works with keyboard and touch or mouse', async ({ page, isMobile }) => {
|
||
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 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(id);
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
}
|
||
});
|
||
|
||
test('readable query fields 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(/\?version=2&/);
|
||
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 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('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);
|
||
params.set('flooring.width', '205');
|
||
await page.goto(`/?${params}`);
|
||
await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205');
|
||
await page.reload();
|
||
await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205');
|
||
expect(state(page).project.rooms).toHaveLength(2);
|
||
});
|
||
|
||
test('printing includes every room and every cut once, grouped by its source board', async ({
|
||
page,
|
||
}) => {
|
||
const fixture = sharedRoomFixture();
|
||
await page.goto(`/${encodeState(fixture)}`);
|
||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
|
||
const { floor, skirt, project } = plans(page);
|
||
const cuts = flooringCuts(floor),
|
||
skirting = skirtingCuts(skirt);
|
||
const screenFloor = await stockGroups(page.locator('.flooring-cuts .stock-cut-group'));
|
||
const screenSkirting = await stockGroups(page.locator('.skirting-cuts .stock-cut-group'));
|
||
expect(screenFloor).toEqual(expectedGroups(floor.stocks, cuts));
|
||
expect(screenSkirting).toEqual(expectedGroups(skirt.stocks, skirting));
|
||
expect(
|
||
screenFloor.some(
|
||
(group) =>
|
||
new Set(group.codes.map((id) => cuts.find((piece) => piece.id === id)!.roomId)).size > 1,
|
||
),
|
||
).toBe(true);
|
||
await expect(page.locator('.print-plan')).toBeHidden();
|
||
await page.emulateMedia({ media: 'print' });
|
||
const printed = page.locator('.print-plan');
|
||
await expect(printed).toBeVisible();
|
||
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeHidden();
|
||
await expect(printed.locator('.print-room')).toHaveCount(project.rooms.length);
|
||
for (const room of project.rooms) {
|
||
const drawing = printed.locator(`.print-room[data-room-id="${room.id}"]`);
|
||
await expect(drawing.locator('h2').first()).toContainText(room.name);
|
||
await expect(drawing.getByRole('img', { name: /floor plan with/ })).toBeVisible();
|
||
await expect(drawing.locator('.floor-piece')).toHaveCount(
|
||
floor.pieces.filter((piece) => piece.roomId === room.id).length,
|
||
);
|
||
await expect(drawing.locator('.skirt-piece')).toHaveCount(
|
||
skirt.pieces.filter((piece) => piece.roomId === room.id).length,
|
||
);
|
||
await expect(drawing.getByRole('button')).toHaveCount(0);
|
||
}
|
||
const printedFloor = await stockGroups(printed.locator('.print-flooring .print-stock'));
|
||
const printedSkirting = await stockGroups(printed.locator('.print-skirting .print-stock'));
|
||
expect(printedFloor).toEqual(screenFloor);
|
||
expect(printedSkirting).toEqual(screenSkirting);
|
||
expect(printedFloor.flatMap((group) => group.codes).sort()).toEqual(
|
||
cuts.map((piece) => piece.id).sort(),
|
||
);
|
||
expect(printedSkirting.flatMap((group) => group.codes).sort()).toEqual(
|
||
skirting.map((piece) => piece.id).sort(),
|
||
);
|
||
for (const piece of [...cuts, ...skirting]) {
|
||
const cut = printed.locator(`.print-cut[data-cut-id="${piece.id}"]`);
|
||
await expect(cut).toBeVisible();
|
||
expect(await cut.locator('.dimension-label').count()).toBeGreaterThanOrEqual(2);
|
||
await expect(cut.locator('svg').first()).toBeVisible();
|
||
}
|
||
expect(await printed.innerText()).not.toMatch(
|
||
/\b(?:FU|SU)\d+\b|(?:Floorboard B|Skirting(?: board)? S)\d+/i,
|
||
);
|
||
await page.emulateMedia({ media: 'screen' });
|
||
await expect(printed).toBeHidden();
|
||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
|
||
});
|
||
|
||
test('panels collapse independently and the room stays pinned while reading cuts', async ({
|
||
page,
|
||
}) => {
|
||
for (const section of ['measurements', 'materials', 'cuts']) {
|
||
await expect(panel(page, section).locator('.panel-toggle')).toHaveAttribute(
|
||
'aria-expanded',
|
||
'true',
|
||
);
|
||
await panel(page, section).locator('.panel-toggle').click();
|
||
await expect(page.locator(`#${section}-content`)).toBeHidden();
|
||
}
|
||
expect(state(page).view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
|
||
expect(new URL(page.url()).searchParams.getAll('collapsed')).toEqual([
|
||
'cuts',
|
||
'materials',
|
||
'measurements',
|
||
]);
|
||
await page.reload();
|
||
for (const section of ['measurements', 'materials', 'cuts']) {
|
||
await expect(panel(page, section).locator('.panel-toggle')).toHaveAttribute(
|
||
'aria-expanded',
|
||
'false',
|
||
);
|
||
}
|
||
await panel(page, 'cuts').locator('.panel-toggle').click();
|
||
await expect(page.getByLabel('Find a cut or room', { exact: true })).toBeVisible();
|
||
await skirtEntries(page).last().scrollIntoViewIfNeeded();
|
||
const scroll = await page.evaluate(() => window.scrollY);
|
||
expect(scroll).toBeGreaterThan(500);
|
||
const drawing = await page.locator('.drawing-column').boundingBox();
|
||
const viewport = page.viewportSize()!;
|
||
expect(drawing!.y).toBeGreaterThanOrEqual(0);
|
||
expect(drawing!.y).toBeLessThanOrEqual(20);
|
||
expect(drawing!.y + drawing!.height).toBeLessThanOrEqual(viewport.height + 1);
|
||
if (viewport.width >= 900) {
|
||
const panels = await page.locator('.planning-panels').boundingBox();
|
||
expect(drawing!.x).toBeGreaterThan(panels!.x + panels!.width);
|
||
}
|
||
await noDrawingScroll(page);
|
||
await noHorizontalOverflow(page);
|
||
});
|
||
|
||
test('drawing cuts still open a usable preview when the cut plan is collapsed', async ({
|
||
page,
|
||
}) => {
|
||
await panel(page, 'cuts').locator('.panel-toggle').click();
|
||
await expect(page.locator('#cuts-content')).toBeHidden();
|
||
await preview(page).locator('.floor-code[data-piece-id="F1"] text').click();
|
||
const dialog = page.getByRole('dialog', { name: 'Flooring cut F1', exact: true });
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog.locator('.cut-dimensions')).toBeVisible();
|
||
expect(state(page).view.selectedPiece).toBe('F1');
|
||
await page.keyboard.press('Escape');
|
||
await expect(dialog).toHaveCount(0);
|
||
await expect(page.locator('#cuts-content')).toBeHidden();
|
||
});
|
||
|
||
test('negative angles can be backspaced and retyped without saving unfinished drafts', async ({
|
||
page,
|
||
}) => {
|
||
await page.getByRole('button', { name: 'With fireplace' }).click();
|
||
const turn = page.getByLabel('Wall 4 turn', { exact: true });
|
||
await expect(turn).toHaveValue('-90');
|
||
await expect(turn).toHaveAttribute('type', 'text');
|
||
await turn.focus();
|
||
await turn.press('End');
|
||
await turn.press('Backspace');
|
||
await expect(turn).toHaveValue('-9');
|
||
expect(state(page).project.rooms[0].walls[3].turn).toBe(-9);
|
||
const committed = page.url();
|
||
await turn.press('Backspace');
|
||
await expect(turn).toHaveValue('-');
|
||
expect(page.url()).toBe(committed);
|
||
await turn.press('Backspace');
|
||
await expect(turn).toHaveValue('');
|
||
expect(page.url()).toBe(committed);
|
||
await turn.pressSequentially('-90');
|
||
await expect(turn).toHaveValue('-90');
|
||
expect(state(page).project.rooms[0].walls[3].turn).toBe(-90);
|
||
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
|
||
const restored = page.url();
|
||
await turn.fill('-');
|
||
expect(page.url()).toBe(restored);
|
||
await turn.press('Enter');
|
||
await expect(turn).toHaveValue('-90');
|
||
expect(page.url()).toBe(restored);
|
||
const length = page.getByLabel('Wall 4 length', { exact: true });
|
||
await length.fill('');
|
||
expect(page.url()).toBe(restored);
|
||
await length.press('Tab');
|
||
await expect(length).toHaveValue('450');
|
||
expect(decodeState(new URL(page.url()).search).error).toBeNull();
|
||
});
|
||
|
||
test('inserting and reordering walls keeps stable wall IDs and attached doors', async ({
|
||
page,
|
||
isMobile,
|
||
}) => {
|
||
await openWalls(page);
|
||
const originals = state(page).project.rooms[0].walls;
|
||
const originalIds = originals.map((wall) => wall.id);
|
||
const doorWall = originals.find((wall) => wall.doors.length)!;
|
||
await page.getByRole('button', { name: 'Insert wall after W2', exact: true }).click();
|
||
let walls = state(page).project.rooms[0].walls;
|
||
expect(walls).toHaveLength(5);
|
||
const inserted = walls[2];
|
||
expect(originalIds).not.toContain(inserted.id);
|
||
expect(inserted).toMatchObject({ length: 1000, turn: 90, doors: [] });
|
||
expect(walls.map((wall) => wall.id)).toEqual([
|
||
originalIds[0],
|
||
originalIds[1],
|
||
inserted.id,
|
||
originalIds[2],
|
||
originalIds[3],
|
||
]);
|
||
await page.getByRole('button', { name: 'Move wall 5 up', exact: true }).click();
|
||
expect(state(page).project.rooms[0].walls[3]).toEqual(doorWall);
|
||
await page.getByRole('button', { name: 'Move wall 4 down', exact: true }).click();
|
||
expect(state(page).project.rooms[0].walls[4]).toEqual(doorWall);
|
||
if (!isMobile) {
|
||
const source = page.locator(`.wall-row[data-wall-id="${doorWall.id}"] .wall-drag-handle`);
|
||
const target = page.locator(`.wall-row[data-wall-id="${originalIds[0]}"]`);
|
||
await 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]);
|
||
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([key]);
|
||
await group.getByRole('button', { name: `Show cuts ${groupCodes} on plan`, exact: true }).click();
|
||
expect(state(page).view.search).toBe(key);
|
||
const search = page.getByLabel('Find a cut or room', { exact: true });
|
||
await expect(search).toHaveValue(groupCodes);
|
||
await noVisibleStockIds(page);
|
||
await search.fill('');
|
||
await search.fill(groupCodes);
|
||
expect(state(page).view.search).toBe(groupCodes);
|
||
await noVisibleStockIds(page);
|
||
expect(await highlightedIds(page, 'flooring')).toEqual(
|
||
sourcePieces
|
||
.filter((piece) => piece.roomId === 'room-2')
|
||
.map((piece) => piece.id)
|
||
.sort(),
|
||
);
|
||
await page
|
||
.locator('.matching-rooms')
|
||
.getByRole('button', { name: 'Living room', exact: true })
|
||
.click();
|
||
expect(state(page).view.roomId).toBe('room-1');
|
||
expect(await highlightedIds(page, 'flooring')).toEqual(
|
||
sourcePieces
|
||
.filter((piece) => piece.roomId === 'room-1')
|
||
.map((piece) => piece.id)
|
||
.sort(),
|
||
);
|
||
expect(
|
||
await preview(page)
|
||
.locator('.floor-piece[data-highlighted="true"]')
|
||
.evaluateAll((pieces) => pieces.every((piece) => piece.getAttribute('data-done') === 'true')),
|
||
).toBe(true);
|
||
const piece = sourcePieces.find((piece) => piece.roomId === 'room-2')!;
|
||
await page.getByRole('button', { name: `Show cut ${piece.id} on plan`, exact: true }).click();
|
||
expect(state(page).view.roomId).toBe('room-2');
|
||
expect(state(page).view.search).toBe(piece.id);
|
||
expect(await highlightedIds(page)).toEqual([piece.id]);
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
await expect(group.locator('.board-cut')).toHaveCount(sourcePieces.length);
|
||
await page.reload();
|
||
await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue(piece.id);
|
||
await expect(done).toBeChecked();
|
||
expect(await highlightedIds(page)).toEqual([piece.id]);
|
||
await page.getByLabel('Room name', { exact: true }).fill('Office');
|
||
expect(state(page).project.completedBoards).toEqual([key]);
|
||
await page.locator('.room-tab').first().click();
|
||
expect(state(page).project.completedBoards).toEqual([key]);
|
||
await page.getByLabel('Find a cut or room', { exact: true }).fill('');
|
||
const skirtPiece = skirtingCuts(skirt).find((piece) => piece.roomId === 'room-1')!;
|
||
const skirtKey = `skirting:${skirtPiece.stockId}`;
|
||
await page
|
||
.locator(`.stock-cut-group[data-board-key="${skirtKey}"]`)
|
||
.getByRole('checkbox')
|
||
.check();
|
||
expect(state(page).project.completedBoards).toEqual([key, skirtKey]);
|
||
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');
|
||
expect(state(page).project.completedBoards).toEqual([]);
|
||
await expect(preview(page).locator('[data-done="true"]')).toHaveCount(0);
|
||
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]);
|
||
});
|
||
|
||
test('the pinned plan makes active search obvious and can restore every piece', async ({
|
||
page,
|
||
}) => {
|
||
const allCuts = await floorEntries(page).count();
|
||
await page.getByLabel('Find a cut or room', { exact: true }).fill('F1');
|
||
const banner = page.locator('.drawing-column .plan-search-status');
|
||
await expect(banner).toContainText('Search active');
|
||
await expect(banner).toContainText('F1');
|
||
await expect(banner.getByRole('button', { name: 'Show all pieces', exact: true })).toBeVisible();
|
||
expect(await highlightedIds(page, 'flooring')).toEqual(['F1']);
|
||
await panel(page, 'cuts').locator('.panel-toggle').click();
|
||
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
|
||
const bounds = (await banner.boundingBox())!;
|
||
expect(bounds.y).toBeGreaterThanOrEqual(0);
|
||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(page.viewportSize()!.height);
|
||
await banner.getByRole('button', { name: 'Show all pieces', exact: true }).click();
|
||
await expect(banner).toHaveCount(0);
|
||
expect(state(page).view.search).toBe('');
|
||
expect(await highlightedIds(page)).toEqual([]);
|
||
await expect(preview(page).locator('.search-muted')).toHaveCount(0);
|
||
await panel(page, 'cuts').locator('.panel-toggle').click();
|
||
await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue('');
|
||
await expect(floorEntries(page)).toHaveCount(allCuts);
|
||
await noHorizontalOverflow(page);
|
||
});
|
||
|
||
test('cuts from one board share the available width and wrap on a narrow phone', async ({
|
||
page,
|
||
isMobile,
|
||
}) => {
|
||
await page.setViewportSize({ width: isMobile ? 320 : 1600, height: 900 });
|
||
await page.goto(`/${encodeState(sharedRoomFixture())}`);
|
||
const group = page
|
||
.locator('.flooring-cuts .stock-cut-group')
|
||
.filter({ has: page.locator('.board-cut:nth-child(2)') })
|
||
.first();
|
||
const pieces = group.locator('.board-cut');
|
||
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('search can locate and complete a whole board without inventing a cut code', async ({
|
||
page,
|
||
}) => {
|
||
const { floor } = plans(page);
|
||
const whole = floor.pieces.find((piece) => !piece.cut && !piece.reused)!;
|
||
expect(whole).toBeDefined();
|
||
const search = page.getByLabel('Find a cut or room', { exact: true });
|
||
await search.fill(`floor:${whole.stockId}`);
|
||
const group = page.locator(`.stock-cut-group[data-board-key="floor:${whole.stockId}"]`);
|
||
await expect(group).toContainText('Installed uncut');
|
||
await group.getByRole('button', { name: 'Show uncut floorboard on plan', exact: true }).click();
|
||
await expect(search).toHaveValue('Uncut floorboard');
|
||
await noVisibleStockIds(page);
|
||
await expect(group.locator('.board-cut')).toHaveCount(0);
|
||
await expect(group.getByRole('button', { name: /^Preview/ })).toHaveCount(0);
|
||
expect(await highlightedIds(page)).toEqual([whole.id]);
|
||
await expect(preview(page).locator(`.floor-piece[data-piece-id="${whole.id}"] text`)).toHaveCount(
|
||
0,
|
||
);
|
||
await group.getByRole('checkbox', { name: 'Mark uncut floorboard done', exact: true }).check();
|
||
await expect(preview(page).locator(`.floor-piece[data-piece-id="${whole.id}"]`)).toHaveAttribute(
|
||
'data-done',
|
||
'true',
|
||
);
|
||
await search.fill('nothing matches this room');
|
||
await expect(page.locator('.search-results')).toHaveText('No matching boards or cuts.');
|
||
expect(await highlightedIds(page)).toEqual([]);
|
||
expect(state(page).project.completedBoards).toEqual([`floor:${whole.stockId}`]);
|
||
});
|
||
|
||
for (const width of [320, 390]) {
|
||
test(`phone layout works at ${width} px with readable inputs and touch targets`, async ({
|
||
page,
|
||
}) => {
|
||
await page.setViewportSize({ width, height: 844 });
|
||
await openWalls(page);
|
||
await page.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);
|
||
});
|
||
}
|