reviewed
This commit is contained in:
parent
62a385e4c9
commit
7959fe85cc
29 changed files with 1679 additions and 498 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue