This commit is contained in:
Andras Schmelczer 2026-09-11 16:58:58 +01:00
commit 62a385e4c9
33 changed files with 2649 additions and 809 deletions

View file

@ -7,9 +7,11 @@ 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';
import { cutMm } from '../src/lib/format';
const errors = new WeakMap<Page, string[]>();
const panel = (page: Page, section: string) =>
page.locator(`.planner-panel[data-section="${section}"]`);
const preview = (page: Page) => page.getByRole('region', { name: 'Floor plan preview' });
const floorEntries = (page: Page) => page.locator('.flooring-cuts .board-cut');
const skirtEntries = (page: Page) => page.locator('.skirting-cuts .board-cut');
@ -30,6 +32,78 @@ const plans = (page: Page) => {
};
};
function sharedRoomFixture() {
const fixture = defaultState();
fixture.project.flooring.width = 200;
fixture.project.rooms = [
rectangle('room-1', 'Living room', 1820, 1220),
rectangle('room-2', 'Study', 1220, 1820),
];
fixture.project.rooms.forEach((room) =>
room.walls.forEach((wall) => {
wall.doors = [];
}),
);
fixture.project.rooms[1].direction = 90;
fixture.view.roomId = 'room-2';
return fixture;
}
async function highlightedIds(page: Page, material?: 'flooring' | 'skirting') {
const selector = `${material ? `[data-material="${material}"]` : '[data-piece-id]'}[data-highlighted="true"]`;
return (
await preview(page)
.locator(selector)
.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-piece-id')))
).sort();
}
async function wallDimensionsInsideSvg(page: Page, wallNumber: number) {
const wall = preview(page)
.locator('.wall-drawing')
.filter({ hasText: new RegExp(`W${wallNumber} ·`) });
await expect(wall.locator('.wall-label text')).toBeVisible();
const result = await wall.evaluate((element) => {
const svg = element.ownerSVGElement!;
const box = svg.viewBox.baseVal;
const inverse = svg.getScreenCTM()!.inverse();
const rootPoint = (element: SVGGraphicsElement, x: number, y: number) =>
new DOMPoint(x, y).matrixTransform(inverse.multiply(element.getScreenCTM()!));
const lines = [...element.querySelectorAll(':scope > line')].map(
(line) => line as SVGLineElement,
);
const endpoints = lines.flatMap((line) => [
rootPoint(line, line.x1.baseVal.value, line.y1.baseVal.value),
rootPoint(line, line.x2.baseVal.value, line.y2.baseVal.value),
]);
const label = element.querySelector('.wall-label') as SVGGElement;
const bounds = label.getBBox();
const corners = [
rootPoint(label, bounds.x, bounds.y),
rootPoint(label, bounds.x + bounds.width, bounds.y),
rootPoint(label, bounds.x + bounds.width, bounds.y + bounds.height),
rootPoint(label, bounds.x, bounds.y + bounds.height),
];
return {
lineCount: lines.length,
outside: [...endpoints, ...corners]
.filter(
(point) =>
point.x < box.x - 0.01 ||
point.x > box.x + box.width + 0.01 ||
point.y < box.y - 0.01 ||
point.y > box.y + box.height + 0.01,
)
.map((point) => ({ x: point.x, y: point.y })),
};
});
expect(result.lineCount).toBe(4);
expect(
result.outside,
`W${wallNumber} lines, ticks and label must fit inside the SVG viewBox`,
).toEqual([]);
}
async function stockGroups(groups: Locator) {
return groups.evaluateAll((elements) =>
elements.map((element) => ({
@ -73,6 +147,31 @@ async function measuredEdges(preview: Locator) {
});
}
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
@ -161,6 +260,8 @@ test('measurements, materials, drawing and both cut lists share one page', async
await expect(page.getByRole('heading', { name: /^Skirting cuts/ })).toBeVisible();
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
await expect(page.locator('.layer-switch, .step-nav')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Export CSV' })).toHaveCount(0);
await noVisibleStockIds(page);
await expect(page.locator('.app-header').getByText('mm', { exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Zoom (in|out)$/ })).toHaveCount(0);
await noDrawingScroll(page);
@ -207,7 +308,7 @@ test('source-board groups contain every numbered cut once and leave full boards
}),
})
.locator('.cut-size'),
).toHaveText(`${mm(piece.length)} × ${mm(piece.width)} mm`);
).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);
@ -255,11 +356,10 @@ test('source-board groups contain every numbered cut once and leave full boards
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);
expectHalfMillimetreCuts(dimensions);
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 ({
@ -270,11 +370,9 @@ test('inconsistent final measurements block cuts and small residuals show the ex
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);
});
@ -313,7 +411,6 @@ test('custom walls close using a calculated wall that can be measured again', as
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 ({
@ -343,8 +440,8 @@ test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialo
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)),
Number(cutMm(mitred.length).replaceAll(',', '')),
Number(cutMm(mitred.backLength).replaceAll(',', '')),
]),
);
const end = Math.abs(mitred.leftMitre) === 45 ? 'left' : 'right';
@ -383,7 +480,7 @@ test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialo
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);
expectHalfMillimetreCuts(dimensions);
await expect(
page.getByRole('dialog').locator('.template-coordinates, .vertex-label, .cut-measures'),
).toHaveCount(0);
@ -411,7 +508,6 @@ test('door positions and material lengths update skirting immediately', async ({
'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');
@ -420,11 +516,9 @@ test('door positions and material lengths update skirting immediately', async ({
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 }) => {
@ -433,10 +527,8 @@ test('invalid skirting settings keep flooring results usable', async ({ page })
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 ({
@ -448,7 +540,12 @@ test('pattern, direction and spacers persist without steps, layers or stored res
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).view).sort()).toEqual([
'collapsedSections',
'roomId',
'search',
'selectedPiece',
]);
expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']);
const table = await page.locator('.flooring-cuts').innerText();
await page.reload();
@ -499,12 +596,12 @@ test('multiple rooms retain independent measurements with combined cut lists', a
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 ({
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, skirt } = plans(page);
const { floor } = plans(page);
const pieceCode = flooringCuts(floor)[0].id;
await page
.getByRole('button', { name: `Preview flooring cut ${pieceCode}`, exact: true })
@ -530,22 +627,6 @@ test('shared URLs reopen the selected preview and CSV contains every cut once',
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();
@ -646,19 +727,7 @@ test('readable query fields, legacy links and browser history preserve the plan'
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';
const fixture = sharedRoomFixture();
await page.goto(`/${encodeState(fixture)}`);
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
const { floor, skirt, project } = plans(page);
@ -708,12 +777,392 @@ test('printing includes every room and every cut once, grouped by its source boa
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/);
expect(await printed.innerText()).not.toMatch(
/\b(?:FU|SU)\d+\b|(?:Floorboard B|Skirting(?: board)? S)\d+/i,
);
await page.emulateMedia({ media: 'screen' });
await expect(printed).toBeHidden();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
});
test('panels collapse independently and the room stays pinned while reading cuts', async ({
page,
}) => {
for (const section of ['measurements', 'materials', 'cuts']) {
await expect(panel(page, section).locator('.panel-toggle')).toHaveAttribute(
'aria-expanded',
'true',
);
await panel(page, section).locator('.panel-toggle').click();
await expect(page.locator(`#${section}-content`)).toBeHidden();
}
expect(state(page).view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
expect(new URL(page.url()).searchParams.getAll('collapsed')).toEqual([
'cuts',
'materials',
'measurements',
]);
await page.reload();
for (const section of ['measurements', 'materials', 'cuts']) {
await expect(panel(page, section).locator('.panel-toggle')).toHaveAttribute(
'aria-expanded',
'false',
);
}
await panel(page, 'cuts').locator('.panel-toggle').click();
await expect(page.getByLabel('Find a cut or room', { exact: true })).toBeVisible();
await skirtEntries(page).last().scrollIntoViewIfNeeded();
const scroll = await page.evaluate(() => window.scrollY);
expect(scroll).toBeGreaterThan(500);
const drawing = await page.locator('.drawing-column').boundingBox();
const viewport = page.viewportSize()!;
expect(drawing!.y).toBeGreaterThanOrEqual(0);
expect(drawing!.y).toBeLessThanOrEqual(20);
expect(drawing!.y + drawing!.height).toBeLessThanOrEqual(viewport.height + 1);
if (viewport.width >= 900) {
const panels = await page.locator('.planning-panels').boundingBox();
expect(drawing!.x).toBeGreaterThan(panels!.x + panels!.width);
}
await noDrawingScroll(page);
await noHorizontalOverflow(page);
});
test('drawing cuts still open a usable preview when the cut plan is collapsed', async ({
page,
}) => {
await panel(page, 'cuts').locator('.panel-toggle').click();
await expect(page.locator('#cuts-content')).toBeHidden();
await preview(page).getByRole('button', { name: 'Select flooring cut F1', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Flooring cut F1', exact: true });
await expect(dialog).toBeVisible();
await expect(dialog.locator('.cut-dimensions')).toBeVisible();
expect(state(page).view.selectedPiece).toBe('F1');
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await expect(page.locator('#cuts-content')).toBeHidden();
});
test('negative angles can be backspaced and retyped without saving unfinished drafts', async ({
page,
}) => {
await page.getByRole('button', { name: 'With fireplace' }).click();
const turn = page.getByLabel('Wall 4 turn', { exact: true });
await expect(turn).toHaveValue('-90');
await expect(turn).toHaveAttribute('type', 'text');
await turn.focus();
await turn.press('End');
await turn.press('Backspace');
await expect(turn).toHaveValue('-9');
expect(state(page).project.rooms[0].walls[3].turn).toBe(-9);
const committed = page.url();
await turn.press('Backspace');
await expect(turn).toHaveValue('-');
expect(page.url()).toBe(committed);
await turn.press('Backspace');
await expect(turn).toHaveValue('');
expect(page.url()).toBe(committed);
await turn.pressSequentially('-90');
await expect(turn).toHaveValue('-90');
expect(state(page).project.rooms[0].walls[3].turn).toBe(-90);
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
const restored = page.url();
await turn.fill('-');
expect(page.url()).toBe(restored);
await turn.press('Enter');
await expect(turn).toHaveValue('-90');
expect(page.url()).toBe(restored);
const length = page.getByLabel('Wall 4 length', { exact: true });
await length.fill('');
expect(page.url()).toBe(restored);
await length.press('Tab');
await expect(length).toHaveValue('450');
expect(decodeState(new URL(page.url()).search).error).toBeNull();
});
test('inserting and reordering walls keeps stable wall IDs and attached doors', async ({
page,
isMobile,
}) => {
await openWalls(page);
const originals = state(page).project.rooms[0].walls;
const originalIds = originals.map((wall) => wall.id);
const doorWall = originals.find((wall) => wall.doors.length)!;
await page.getByRole('button', { name: 'Insert wall after W2', exact: true }).click();
let walls = state(page).project.rooms[0].walls;
expect(walls).toHaveLength(5);
const inserted = walls[2];
expect(originalIds).not.toContain(inserted.id);
expect(inserted).toMatchObject({ length: 1000, turn: 90, doors: [] });
expect(walls.map((wall) => wall.id)).toEqual([
originalIds[0],
originalIds[1],
inserted.id,
originalIds[2],
originalIds[3],
]);
await page.getByRole('button', { name: 'Move wall 5 up', exact: true }).click();
expect(state(page).project.rooms[0].walls[3]).toEqual(doorWall);
await page.getByRole('button', { name: 'Move wall 4 down', exact: true }).click();
expect(state(page).project.rooms[0].walls[4]).toEqual(doorWall);
if (!isMobile) {
const source = page.locator(`.wall-row[data-wall-id="${doorWall.id}"] .wall-drag-handle`);
const target = page.locator(`.wall-row[data-wall-id="${originalIds[0]}"]`);
await source.dragTo(target);
expect(state(page).project.rooms[0].walls[0]).toEqual(doorWall);
}
walls = state(page).project.rooms[0].walls;
for (const original of originals)
expect(walls.find((wall) => wall.id === original.id)).toEqual(original);
expect(new Set(walls.map((wall) => wall.id)).size).toBe(5);
const order = walls.map((wall) => wall.id);
await page.reload();
await expect(page.locator('.wall-row')).toHaveCount(5);
expect(
await page
.locator('.wall-row')
.evaluateAll((rows) => rows.map((row) => row.getAttribute('data-wall-id'))),
).toEqual(order);
expect(state(page).project.rooms[0].walls.find((wall) => wall.id === doorWall.id)!.doors).toEqual(
doorWall.doors,
);
});
test('the supplied angled room keeps W12 lines and labels inside the drawing', async ({
page,
isMobile,
}) => {
const angledRoom = await readFile(
new URL('../tests/fixtures/angled-room.json', import.meta.url),
'utf8',
);
const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(angledRoom)}`);
expect(fixture.error).toBeNull();
await page.goto(`/${encodeState(fixture.state)}`);
await expect(preview(page).locator('.wall-drawing')).toHaveCount(12);
for (const width of isMobile ? [320, 390] : [1280]) {
await page.setViewportSize({ width, height: 844 });
await wallDimensionsInsideSvg(page, 12);
await noDrawingScroll(page);
await noHorizontalOverflow(page);
}
});
test('the supplied F8 room aligns a row seam with its fireplace corner and keeps exact templates', async ({
page,
}) => {
const json = await readFile(
new URL('../tests/fixtures/angled-room-f8.json', import.meta.url),
'utf8',
);
const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(json)}`);
expect(fixture.error).toBeNull();
fixture.state.view.selectedPiece = null;
fixture.state.view.search = '';
await page.goto(`/${encodeState(fixture.state)}`);
const { floor, skirt } = plans(page);
const corner = { x: 7607.513, y: 2160.34 };
const atCorner = floor.pieces.filter((piece) =>
piece.polygon.some((point) => Math.hypot(point.x - corner.x, point.y - corner.y) < 0.01),
);
expect(atCorner.length).toBeGreaterThanOrEqual(2);
expect(new Set(atCorner.map((piece) => piece.row)).size).toBe(2);
for (const piece of atCorner) {
// Flooring runs vertically here, so a row seam has a constant world x coordinate.
const xs = piece.polygon.map((point) => point.x);
expect(
Math.min(Math.abs(corner.x - Math.min(...xs)), Math.abs(corner.x - Math.max(...xs))),
).toBeLessThan(0.01);
const polygon = preview(page).locator(`.floor-piece[data-piece-id="${piece.id}"] > polygon`);
const drawn = await polygon.evaluate((element) =>
[...(element as SVGPolygonElement).points].map((point) => ({ x: point.x, y: point.y })),
);
expect(drawn).toHaveLength(piece.polygon.length);
drawn.forEach((point, i) => {
expect(point.x).toBeCloseTo(piece.polygon[i].x, 2);
expect(point.y).toBeCloseTo(piece.polygon[i].y, 2);
});
}
const cut = atCorner.find((piece) => piece.cut)!;
await page.getByRole('button', { name: `Preview flooring cut ${cut.id}`, exact: true }).click();
const dialog = page.getByRole('dialog', { name: `Flooring cut ${cut.id}`, exact: true });
const shape = dialog.getByRole('img', { name: `Flooring shape ${cut.id}`, exact: true });
await expect(shape).toBeVisible();
const dimensions = await measuredEdges(shape);
expect(
dimensions.some(({ actual }) => Math.abs(actual * 2 - Math.round(actual * 2)) > 0.001),
).toBe(true);
expectHalfMillimetreCuts(dimensions);
expect(state(page).project.rooms[0].walls).toEqual(fixture.state.project.rooms[0].walls);
await page.keyboard.press('Escape');
const skirtCut = skirtingCuts(skirt).find(
(piece) =>
Math.abs(piece.length * 2 - Math.round(piece.length * 2)) > 0.001 ||
Math.abs(piece.backLength * 2 - Math.round(piece.backLength * 2)) > 0.001,
)!;
expect(skirtCut).toBeDefined();
await page
.getByRole('button', { name: `Preview skirting cut ${skirtCut.id}`, exact: true })
.click();
const skirtDialog = page.getByRole('dialog', {
name: `Skirting cut ${skirtCut.id}`,
exact: true,
});
const lengths = await skirtDialog
.locator('.skirting-full-preview .dimension-label')
.allTextContents();
expect(lengths.map((text) => Number(text.replaceAll(',', '')))).toEqual(
expect.arrayContaining([
Number(cutMm(skirtCut.length).replaceAll(',', '')),
Number(cutMm(skirtCut.backLength).replaceAll(',', '')),
]),
);
await page.keyboard.press('Escape');
await noVisibleStockIds(page);
await noHorizontalOverflow(page);
});
test('completed source boards and locate actions follow shared offcuts across rooms', async ({
page,
}) => {
const fixture = sharedRoomFixture();
await page.goto(`/${encodeState(fixture)}`);
const { floor, skirt } = plans(page);
const shared = floor.stocks.find(
(stock) =>
new Set(stock.pieceIds.map((id) => floor.pieces.find((piece) => piece.id === id)!.roomId))
.size > 1,
)!;
expect(shared).toBeDefined();
const sourcePieces = floor.pieces.filter((piece) => piece.stockId === shared.id);
const key = `floor:${shared.id}`;
const group = page.locator(`.stock-cut-group[data-board-key="${key}"]`);
const groupCodes = await group
.locator('.board-cut')
.evaluateAll((cuts) => cuts.map((cut) => cut.getAttribute('data-cut-id')).join(' + '));
const done = group.getByRole('checkbox', {
name: `Mark cuts ${groupCodes} done`,
exact: true,
});
await done.check();
await expect(group).toHaveClass(/is-done/);
expect(state(page).project.completedBoards).toEqual([key]);
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([key]);
await group.getByRole('button', { name: `Show cuts ${groupCodes} on plan`, exact: true }).click();
expect(state(page).view.search).toBe(key);
const search = page.getByLabel('Find a cut or room', { exact: true });
await expect(search).toHaveValue(groupCodes);
await noVisibleStockIds(page);
await search.fill('');
await search.fill(groupCodes);
expect(state(page).view.search).toBe(groupCodes);
await noVisibleStockIds(page);
expect(await highlightedIds(page, 'flooring')).toEqual(
sourcePieces
.filter((piece) => piece.roomId === 'room-2')
.map((piece) => piece.id)
.sort(),
);
await page
.locator('.matching-rooms')
.getByRole('button', { name: 'Living room', exact: true })
.click();
expect(state(page).view.roomId).toBe('room-1');
expect(await highlightedIds(page, 'flooring')).toEqual(
sourcePieces
.filter((piece) => piece.roomId === 'room-1')
.map((piece) => piece.id)
.sort(),
);
expect(
await preview(page)
.locator('.floor-piece[data-highlighted="true"]')
.evaluateAll((pieces) => pieces.every((piece) => piece.getAttribute('data-done') === 'true')),
).toBe(true);
const piece = sourcePieces.find((piece) => piece.roomId === 'room-2')!;
await page.getByRole('button', { name: `Show cut ${piece.id} on plan`, exact: true }).click();
expect(state(page).view.roomId).toBe('room-2');
expect(state(page).view.search).toBe(piece.id);
expect(await highlightedIds(page)).toEqual([piece.id]);
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(group.locator('.board-cut')).toHaveCount(sourcePieces.length);
await page.reload();
await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue(piece.id);
await expect(done).toBeChecked();
expect(await highlightedIds(page)).toEqual([piece.id]);
await page.getByLabel('Room name', { exact: true }).fill('Office');
expect(state(page).project.completedBoards).toEqual([key]);
await page.locator('.room-tab').first().click();
expect(state(page).project.completedBoards).toEqual([key]);
await page.getByLabel('Find a cut or room', { exact: true }).fill('');
const skirtPiece = skirtingCuts(skirt).find((piece) => piece.roomId === 'room-1')!;
const skirtKey = `skirting:${skirtPiece.stockId}`;
await page
.locator(`.stock-cut-group[data-board-key="${skirtKey}"]`)
.getByRole('checkbox')
.check();
expect(state(page).project.completedBoards).toEqual([key, skirtKey]);
expect(await preview(page).locator('.skirt-piece[data-done="true"]').count()).toBeGreaterThan(0);
await page.getByLabel('Board width', { exact: true }).fill('201');
expect(state(page).project.completedBoards).toEqual([]);
await expect(preview(page).locator('[data-done="true"]')).toHaveCount(0);
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]);
});
test('cuts from one board share the available width and wrap on a narrow phone', async ({
page,
isMobile,
}) => {
await page.setViewportSize({ width: isMobile ? 320 : 1600, height: 900 });
await page.goto(`/${encodeState(sharedRoomFixture())}`);
const group = page
.locator('.flooring-cuts .stock-cut-group')
.filter({ has: page.locator('.board-cut:nth-child(2)') })
.first();
const pieces = group.locator('.board-cut');
expect(await pieces.count()).toBeGreaterThan(1);
const first = (await pieces.nth(0).boundingBox())!;
const second = (await pieces.nth(1).boundingBox())!;
if (isMobile) {
expect(second.y).toBeGreaterThanOrEqual(first.y + first.height);
expect(second.x).toBeCloseTo(first.x, 1);
} else {
expect(second.y).toBeCloseTo(first.y, 1);
expect(second.x).toBeGreaterThanOrEqual(first.x + first.width);
}
await noHorizontalOverflow(page);
await noVisibleStockIds(page);
});
test('search can locate and complete a whole board without inventing a cut code', async ({
page,
}) => {
const { floor } = plans(page);
const whole = floor.pieces.find((piece) => !piece.cut && !piece.reused)!;
expect(whole).toBeDefined();
const search = page.getByLabel('Find a cut or room', { exact: true });
await search.fill(`floor:${whole.stockId}`);
const group = page.locator(`.stock-cut-group[data-board-key="floor:${whole.stockId}"]`);
await expect(group).toContainText('Installed uncut');
await group.getByRole('button', { name: 'Show uncut floorboard on plan', exact: true }).click();
await expect(search).toHaveValue('Uncut floorboard');
await noVisibleStockIds(page);
await expect(group.locator('.board-cut')).toHaveCount(0);
await expect(group.getByRole('button', { name: /^Preview/ })).toHaveCount(0);
expect(await highlightedIds(page)).toEqual([whole.id]);
await expect(preview(page).locator(`.floor-piece[data-piece-id="${whole.id}"] text`)).toHaveCount(
0,
);
await group.getByRole('checkbox', { name: 'Mark uncut floorboard done', exact: true }).check();
await expect(preview(page).locator(`.floor-piece[data-piece-id="${whole.id}"]`)).toHaveAttribute(
'data-done',
'true',
);
await search.fill('nothing matches this room');
await expect(page.locator('.search-results')).toHaveText('No matching boards or cuts.');
expect(await highlightedIds(page)).toEqual([]);
expect(state(page).project.completedBoards).toEqual([`floor:${whole.stockId}`]);
});
for (const width of [320, 390]) {
test(`phone layout works at ${width} px with readable inputs and touch targets`, async ({
page,
@ -727,7 +1176,11 @@ for (const width of [320, 390]) {
.locator('button, input, select, summary')
.evaluateAll((elements) =>
elements.flatMap((element) => {
const rect = element.getBoundingClientRect();
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)
? [
{
@ -743,7 +1196,7 @@ for (const width of [320, 390]) {
[],
);
const smallInputs = await page
.locator('input, select')
.locator('input:not([type="checkbox"]), select')
.evaluateAll((elements) =>
elements
.filter(