This commit is contained in:
Andras Schmelczer 2026-09-11 17:57:03 +01:00
commit 7959fe85cc
29 changed files with 1679 additions and 498 deletions

View file

@ -3,7 +3,7 @@ import type { Locator, Page } from '@playwright/test';
import LZString from 'lz-string';
import { readFile } from 'node:fs/promises';
import { decodeState, encodeState } from '../src/lib/url-state';
import { defaultState, rectangle } from '../src/lib/defaults';
import { 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';
@ -49,6 +49,25 @@ function sharedRoomFixture() {
return fixture;
}
function shortFireplaceFixture() {
const fixture = defaultState();
fixture.project.rooms = [fireplace('room-2', 'Room 2')];
fixture.project.flooring = {
length: 1200,
width: 190,
pattern: 'random',
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 (
@ -252,15 +271,25 @@ test.afterEach(async ({ page }) => {
test('measurements, materials, drawing and both cut lists share one page', async ({ page }) => {
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
await expect(page.getByLabel('Room length', { exact: true })).toHaveValue('3600');
for (const label of ['Board length', 'Board width', 'Stock length 1', 'Skirting thickness']) {
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();
await expect(preview(page).getByText('Outline closed', { exact: true })).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);
@ -406,7 +435,7 @@ test('custom walls close using a calculated wall that can be measured again', as
await page.getByLabel('Wall 3 length', { exact: true }).fill('4000');
await expect(page.getByText('3,000 mm to close')).toBeVisible();
await page.getByRole('button', { name: 'Add closing wall' }).click();
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
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');
@ -489,6 +518,70 @@ test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialo
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.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();
@ -509,7 +602,7 @@ test('door positions and material lengths update skirting immediately', async ({
'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('Stock length 1', { exact: true }).fill('3600');
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);
@ -523,14 +616,41 @@ test('door positions and material lengths update skirting immediately', async ({
test('invalid skirting settings keep flooring results usable', async ({ page }) => {
const floorCount = await floorCodes(page).count();
await page.getByLabel('Stock length 1', { exact: true }).fill('100');
await 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('Stock length 1', { exact: true }).fill('2400');
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,
}) => {
@ -676,7 +796,8 @@ test('clicking the skirting code itself opens its cutting dialog', async ({ page
const id = await code.evaluate((element) =>
element.closest('[data-piece-id]')!.getAttribute('data-piece-id'),
);
await code.locator(target).click();
// 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();
@ -724,6 +845,35 @@ test('readable query fields, legacy links and browser history preserve the plan'
expect(state(page).project.rooms).toHaveLength(2);
});
test('legacy skirting sizes migrate to one editable length and reset changed allocations', async ({
page,
}) => {
const initial = defaultState();
const legacy = {
...initial,
project: {
...initial.project,
completedBoards: ['skirting:S1'],
skirting: { ...initial.project.skirting, stockLength: undefined, stockLengths: [3600, 2400] },
},
};
await page.goto(`/#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(legacy))}`);
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3600');
expect(state(page).project.skirting.stockLength).toBe(3600);
expect(state(page).project.completedBoards).toEqual([]);
const params = new URL(page.url()).searchParams;
expect(params.get('skirting.length')).toBe('3600');
expect(params.has('skirting.stock')).toBe(false);
await page.getByLabel('Skirting board length', { exact: true }).fill('3000');
await page.reload();
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3000');
await page.goto('/?skirting.stock=2400&skirting.stock=2400&done=skirting:S1');
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('2400');
expect(state(page).project.completedBoards).toEqual([]);
expect(new URL(page.url()).searchParams.get('skirting.length')).toBe('2400');
expect(new URL(page.url()).searchParams.has('skirting.stock')).toBe(false);
});
test('printing includes every room and every cut once, grouped by its source board', async ({
page,
}) => {
@ -832,7 +982,7 @@ test('drawing cuts still open a usable preview when the cut plan is collapsed',
}) => {
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();
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();
@ -864,7 +1014,7 @@ test('negative angles can be backspaced and retyped without saving unfinished dr
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();
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
const restored = page.url();
await turn.fill('-');
expect(page.url()).toBe(restored);
@ -947,6 +1097,103 @@ test('the supplied angled room keeps W12 lines and labels inside the drawing', a
}
});
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,
}) => {
@ -1108,6 +1355,32 @@ test('completed source boards and locate actions follow shared offcuts across ro
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,