wip
This commit is contained in:
commit
d8f2407ddf
44 changed files with 10377 additions and 0 deletions
580
e2e/planner.spec.ts
Normal file
580
e2e/planner.spec.ts
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { decodeState } from '../src/lib/url-state';
|
||||
import { planFlooring } from '../src/lib/flooring';
|
||||
import { planSkirting } from '../src/lib/skirting';
|
||||
import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list';
|
||||
import { mm } from '../src/lib/format';
|
||||
|
||||
const errors = new WeakMap<Page, string[]>();
|
||||
const preview = (page: Page) => page.getByRole('region', { name: 'Floor plan preview' });
|
||||
const floorRows = (page: Page) =>
|
||||
page.getByRole('table', { name: 'Flooring cuts and offcut placements' }).locator('tbody tr');
|
||||
const skirtRows = (page: Page) =>
|
||||
page.getByRole('table', { name: 'Skirting pieces requiring cuts' }).locator('tbody tr');
|
||||
const floorCodes = (page: Page) =>
|
||||
floorRows(page).getByRole('button', { name: /^Preview flooring cut / });
|
||||
const skirtCodes = (page: Page) =>
|
||||
skirtRows(page).getByRole('button', { name: /^Preview skirting cut / });
|
||||
const state = (page: Page) => decodeState(new URL(page.url()).hash).state;
|
||||
const plans = (page: Page) => {
|
||||
const { project } = state(page);
|
||||
return {
|
||||
project,
|
||||
floor: planFlooring(project.rooms, project.flooring, project.tolerance),
|
||||
skirt: planSkirting(project.rooms, project.skirting, project.tolerance),
|
||||
};
|
||||
};
|
||||
|
||||
async function openWalls(page: Page) {
|
||||
if (
|
||||
!(await page
|
||||
.locator('.wall-details')
|
||||
.evaluate((element) => (element as HTMLDetailsElement).open))
|
||||
) {
|
||||
await page.locator('.wall-details summary').click();
|
||||
}
|
||||
}
|
||||
|
||||
async function noHorizontalOverflow(page: Page) {
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
async function noDrawingScroll(page: Page) {
|
||||
const scrollContainers = await preview(page).evaluate((root) =>
|
||||
[root, ...root.querySelectorAll('*')]
|
||||
.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
||||
.filter((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return (
|
||||
(/auto|scroll/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 1) ||
|
||||
(/auto|scroll/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 1)
|
||||
);
|
||||
})
|
||||
.map((element) => element.className),
|
||||
);
|
||||
expect(
|
||||
scrollContainers,
|
||||
'The room drawing must use the page scroll, without its own scrollbars',
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
async function noWallLabelOverlap(page: Page) {
|
||||
const labels = await preview(page)
|
||||
.locator('.wall-label text')
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
text: element.textContent,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
};
|
||||
}),
|
||||
);
|
||||
expect(labels).toHaveLength(8);
|
||||
const overlaps: string[] = [];
|
||||
labels.forEach((label, i) =>
|
||||
labels.slice(i + 1).forEach((other) => {
|
||||
if (
|
||||
Math.min(label.right, other.right) - Math.max(label.x, other.x) > 0.5 &&
|
||||
Math.min(label.bottom, other.bottom) - Math.max(label.y, other.y) > 0.5
|
||||
) {
|
||||
overlaps.push(`${label.text} overlaps ${other.text}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
expect(overlaps, 'Fireplace wall dimensions should remain independently readable').toEqual([]);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors.set(page, []);
|
||||
page.on('pageerror', (error) => errors.get(page)!.push(error.message));
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
expect(errors.get(page), 'The application should not throw browser errors').toEqual([]);
|
||||
});
|
||||
|
||||
test('measurements, materials, drawing and both cut lists share one page', async ({ page }) => {
|
||||
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
|
||||
await expect(page.getByLabel('Room length', { exact: true })).toHaveValue('3600');
|
||||
for (const label of ['Board length', 'Board width', 'Stock length 1', 'Skirting thickness']) {
|
||||
await expect(page.getByLabel(label, { exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByRole('heading', { name: 'Materials', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /^Flooring cuts/ })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /^Skirting cuts/ })).toBeVisible();
|
||||
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('.layer-switch, .step-nav')).toHaveCount(0);
|
||||
await expect(page.locator('.app-header').getByText('mm', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Zoom (in|out)$/ })).toHaveCount(0);
|
||||
await noDrawingScroll(page);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Choose materials|Get my cut plan|Measure room/ }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText(
|
||||
/LESS WASTE\. MORE CONFIDENCE|A better floor starts|Thoughtful plans|Saved in your URL|bounded heuristic|inspect narrow tips/i,
|
||||
),
|
||||
).toHaveCount(0);
|
||||
expect(
|
||||
await page.locator('#cut-plan').evaluate((element) => element.getBoundingClientRect().top),
|
||||
).toBeGreaterThan(
|
||||
await page.locator('#materials').evaluate((element) => element.getBoundingClientRect().bottom),
|
||||
);
|
||||
});
|
||||
|
||||
test('drawing includes full boards, while numbered cut thumbnails stay complete and sequential', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { project, floor, skirt } = plans(page);
|
||||
const cutFloor = flooringCuts(floor);
|
||||
const 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 shownCodes = await floorCodes(page).evaluateAll((buttons) =>
|
||||
buttons.map((button) =>
|
||||
button.getAttribute('aria-label')!.replace('Preview flooring cut ', ''),
|
||||
),
|
||||
);
|
||||
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}`));
|
||||
expect(await floorRows(page).count(), 'Repeated dimensions share one cutting batch').toBeLessThan(
|
||||
cutFloor.length,
|
||||
);
|
||||
const batches = await floorRows(page).evaluateAll((rows) =>
|
||||
rows.map((row) => ({
|
||||
codes: [...row.querySelectorAll('button[aria-label^="Preview flooring cut "]')].map(
|
||||
(button) => button.getAttribute('aria-label')!.replace('Preview flooring cut ', ''),
|
||||
),
|
||||
size: row.querySelector('.batch-size')?.textContent?.trim(),
|
||||
quantity: row.querySelector('.batch-qty')?.textContent?.trim(),
|
||||
})),
|
||||
);
|
||||
for (const batch of batches) {
|
||||
expect(batch.quantity).toBe(String(batch.codes.length));
|
||||
for (const code of batch.codes) {
|
||||
const piece = cutFloor.find((piece) => piece.id === code)!;
|
||||
expect(batch.size).toBe(`${mm(piece.length)} × ${mm(piece.width)}`);
|
||||
}
|
||||
}
|
||||
await expect(preview(page).locator('.floor-piece')).toHaveCount(floor.pieces.length);
|
||||
await expect(preview(page).locator('.skirt-piece')).toHaveCount(skirt.pieces.length);
|
||||
await expect(preview(page).getByRole('button', { name: /^Select flooring cut / })).toHaveCount(
|
||||
cutFloor.length,
|
||||
);
|
||||
await expect(preview(page).getByRole('button', { name: /^Select skirting cut / })).toHaveCount(
|
||||
cutSkirt.length,
|
||||
);
|
||||
await expect(page.locator('.flooring-cuts .cut-summary')).toContainText(
|
||||
`${wholeBoards} full boards 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 repeatedCode = batches.find((batch) => batch.codes.length > 1)!.codes.at(-1)!;
|
||||
const repeatedPiece = cutFloor.find((piece) => piece.id === repeatedCode)!;
|
||||
const thumbnail = page.getByRole('button', {
|
||||
name: `Preview flooring cut ${repeatedCode}`,
|
||||
exact: true,
|
||||
});
|
||||
await expect(thumbnail.locator('svg')).toBeVisible();
|
||||
await thumbnail.click();
|
||||
const dialog = page.getByRole('dialog', { name: `Flooring cut ${repeatedCode}`, exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator('.cut-origin')).toContainText(`Row ${repeatedPiece.row}`);
|
||||
await expect(dialog.locator('.cut-origin')).toContainText(repeatedPiece.stockId);
|
||||
await expect(dialog.getByRole('img', { name: `Flooring shape ${repeatedCode}` })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await expect(thumbnail).toBeFocused();
|
||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('inconsistent final measurements block cuts and small residuals show the expected length', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openWalls(page);
|
||||
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700');
|
||||
await expect(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible();
|
||||
await expect(floorRows(page)).toHaveCount(0);
|
||||
await expect(skirtRows(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 floorRows(page).count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('room warnings stay with their room even when two room names match', async ({ page }) => {
|
||||
await openWalls(page);
|
||||
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
|
||||
await expect(page.getByText(/non-straight wall/).first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Add room', exact: true }).click();
|
||||
await page.getByLabel('Room name', { exact: true }).fill('Living room');
|
||||
expect(state(page).project.rooms.map((room) => room.name)).toEqual([
|
||||
'Living room',
|
||||
'Living room',
|
||||
]);
|
||||
await expect(page.getByText(/non-straight wall/)).toHaveCount(0);
|
||||
await page.locator('.room-tab').first().click();
|
||||
await expect(page.getByText(/non-straight wall/).first()).toBeVisible();
|
||||
await openWalls(page);
|
||||
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700');
|
||||
await expect(page.getByText(/outline is open by 100\.0 mm/).first()).toBeVisible();
|
||||
await page.locator('.room-tab').nth(1).click();
|
||||
await expect(page.getByText(/outline is open by 100\.0 mm/)).toHaveCount(0);
|
||||
await page.locator('.room-tab').first().click();
|
||||
await expect(page.getByText(/outline is open by 100\.0 mm/).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('custom walls close using a calculated wall that can be measured again', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Custom', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
|
||||
await page.getByLabel('Wall 2 length', { exact: true }).fill('3000');
|
||||
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
|
||||
await page.getByLabel('Wall 3 length', { exact: true }).fill('4000');
|
||||
await expect(page.getByText('3,000 mm to close')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Add closing wall' }).click();
|
||||
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
|
||||
await openWalls(page);
|
||||
await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000');
|
||||
await page.getByLabel('Wall 4 length', { exact: true }).fill('3050');
|
||||
await expect(page.getByText(/3,000 mm expected; length differs by 50 mm/)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialogs', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'With fireplace' }).click();
|
||||
await expect(page.getByLabel('Wall 8 length', { exact: true })).toHaveValue('3600');
|
||||
await expect(preview(page).getByRole('img', { name: /floor plan with/ })).toHaveAttribute(
|
||||
'aria-label',
|
||||
/8 walls/,
|
||||
);
|
||||
await noWallLabelOverlap(page);
|
||||
const { floor, skirt } = plans(page);
|
||||
const mitred = skirtingCuts(skirt).find(
|
||||
(piece) => Math.abs(piece.leftMitre) === 45 || Math.abs(piece.rightMitre) === 45,
|
||||
)!;
|
||||
expect(mitred).toBeDefined();
|
||||
const thumbnail = page.getByRole('button', {
|
||||
name: `Preview skirting cut ${mitred.id}`,
|
||||
exact: true,
|
||||
});
|
||||
await expect(thumbnail.locator('svg').first()).toBeVisible();
|
||||
await thumbnail.click();
|
||||
const dialog = page.getByRole('dialog', { name: `Skirting cut ${mitred.id}`, exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText(/long point/i);
|
||||
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();
|
||||
await expect(page.getByRole('img', { name: `Flooring shape ${shaped.id}` })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await expect(page.locator('.selected-cut')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('door positions and material lengths update skirting immediately', async ({ page }) => {
|
||||
const before = await page.locator('.skirting-cuts').innerText();
|
||||
await page.getByRole('button', { name: 'Add a doorway' }).click();
|
||||
await expect(page.locator('.door-card')).toHaveCount(2);
|
||||
await page.locator('.door-card').last().getByRole('combobox').selectOption({ label: 'W1' });
|
||||
const movedDoor = page.locator('.door-card').first();
|
||||
await movedDoor.getByLabel('Door offset', { exact: true }).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(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
||||
expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before);
|
||||
await page.getByLabel('Stock length 1', { exact: true }).fill('3600');
|
||||
await page.getByLabel('Skirting thickness', { exact: true }).fill('22');
|
||||
const { skirt } = plans(page);
|
||||
await expect(skirtCodes(page)).toHaveCount(skirtingCuts(skirt).length);
|
||||
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
|
||||
`Buy ${skirt.stockCount} lengths`,
|
||||
);
|
||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
||||
await movedDoor.getByLabel('Door width', { exact: true }).fill('6000');
|
||||
await expect(page.getByText(/must fit within the wall/).first()).toBeVisible();
|
||||
await expect(skirtRows(page)).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('invalid skirting settings keep flooring results usable', async ({ page }) => {
|
||||
const floorCount = await floorCodes(page).count();
|
||||
await page.getByLabel('Stock length 1', { exact: true }).fill('100');
|
||||
await expect(skirtRows(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 skirtRows(page).count()).toBeGreaterThan(0);
|
||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('pattern, direction and spacers persist without steps, layers or stored results', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByLabel('Room length', { exact: true }).fill('3650');
|
||||
await page.getByRole('button', { name: 'Random', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Across first wall', exact: true }).click();
|
||||
await page.getByLabel('Wall spacer', { exact: true }).fill('12');
|
||||
expect(state(page).project.flooring).toMatchObject({ pattern: 'random', gap: 12 });
|
||||
expect(state(page).project.rooms[0].direction).toBe(90);
|
||||
expect(Object.keys(state(page).view).sort()).toEqual(['roomId', 'selectedPiece']);
|
||||
expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']);
|
||||
const table = await page.locator('.flooring-cuts tbody').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 tbody')).toHaveText(table, { useInnerText: true });
|
||||
await noHorizontalOverflow(page);
|
||||
expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
|
||||
});
|
||||
|
||||
test('a narrow remainder is shared between the first and last flooring rows', async ({ page }) => {
|
||||
// 3650 minus two 10 mm spacers leaves 19 full rows and a 20 mm sliver.
|
||||
await page.getByLabel('Room length', { exact: true }).fill('3650');
|
||||
const { floor } = plans(page);
|
||||
for (const row of [1, 20]) {
|
||||
const piece = flooringCuts(floor).find((piece) => piece.row === row);
|
||||
expect(piece).toBeDefined();
|
||||
const group = floorRows(page).filter({
|
||||
has: page.getByRole('button', { name: `Preview flooring cut ${piece!.id}`, exact: true }),
|
||||
});
|
||||
await expect(group.locator('[data-label="Cut size · mm"]')).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 floorRows(page).filter({ hasText: 'Living room' }).count()).toBeGreaterThan(0);
|
||||
expect(await skirtRows(page).filter({ hasText: 'Study' }).count()).toBeGreaterThan(0);
|
||||
await page.getByRole('button', { name: /^Living room.*m²/ }).click();
|
||||
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
|
||||
await page.getByRole('button', { name: /^Study.*m²/ }).click();
|
||||
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3200');
|
||||
await page.getByRole('button', { name: 'Remove this room' }).click();
|
||||
expect(state(page).project.rooms).toHaveLength(1);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room');
|
||||
});
|
||||
|
||||
test('shared URLs reopen the selected preview and CSV contains every cut once', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.getByLabel('Room name', { exact: true }).fill('Café 北');
|
||||
const { floor, skirt } = plans(page);
|
||||
const pieceCode = flooringCuts(floor)[0].id;
|
||||
await page
|
||||
.getByRole('button', { name: `Preview flooring cut ${pieceCode}`, exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
|
||||
).toBeVisible();
|
||||
expect(state(page).view.selectedPiece).toBe(pieceCode);
|
||||
const shareUrl = page.url();
|
||||
const originalTable = await page.locator('.flooring-cuts tbody').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 tbody')).toHaveText(originalTable, {
|
||||
useInnerText: true,
|
||||
});
|
||||
await shared.reload();
|
||||
await expect(
|
||||
shared.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
|
||||
).toBeVisible();
|
||||
await shared.close();
|
||||
await page.keyboard.press('Escape');
|
||||
expect(state(page).view.selectedPiece).toBeNull();
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Export CSV' }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('flooring-cut-plan.csv');
|
||||
const csv = await readFile((await download.path())!, 'utf8');
|
||||
expect(csv).toContain('"Material","Room","Piece","Stock"');
|
||||
expect(csv).toContain(`"Flooring","Café 北","${pieceCode}"`);
|
||||
expect(csv.split('\r\n').filter((line) => line.startsWith('"Flooring",'))).toHaveLength(
|
||||
flooringCuts(floor).length,
|
||||
);
|
||||
expect(csv.split('\r\n').filter((line) => line.startsWith('"Skirting",'))).toHaveLength(
|
||||
skirtingCuts(skirt).length,
|
||||
);
|
||||
for (const piece of floor.pieces.filter((piece) => !piece.cut && !piece.reused))
|
||||
expect(csv).not.toContain(`"${piece.id}"`);
|
||||
expect(csv).not.toContain('bounded heuristic');
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
const closedUrl = page.url();
|
||||
await page.getByRole('button', { name: 'Copy link', exact: true }).click();
|
||||
await expect(page.getByText('Link copied', { exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(closedUrl);
|
||||
await page.reload();
|
||||
await expect(page.locator('.flooring-cuts tbody')).toHaveText(originalTable, {
|
||||
useInnerText: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('saw kerf help works with keyboard and touch or mouse', async ({ page, isMobile }) => {
|
||||
for (const name of ['What is saw kerf?', 'What is skirting saw kerf?']) {
|
||||
const button = page.getByRole('button', { name, exact: true });
|
||||
await button.focus();
|
||||
await button.press('Space');
|
||||
await expect(button).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(page.getByRole('tooltip')).toContainText(
|
||||
'Width of material removed by the saw blade.',
|
||||
);
|
||||
await expect(page.getByRole('tooltip')).toContainText('600 mm + 397 mm');
|
||||
await button.press('Escape');
|
||||
await expect(page.getByRole('tooltip')).toHaveCount(0);
|
||||
if (isMobile) await button.tap();
|
||||
else await button.click();
|
||||
await expect(page.getByRole('tooltip')).toBeVisible();
|
||||
await noHorizontalOverflow(page);
|
||||
await page.getByLabel('Board length', { exact: true }).focus();
|
||||
await expect(page.getByRole('tooltip')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid URLs recover when measurements are edited', async ({ page }) => {
|
||||
await page.goto('/#plan=broken');
|
||||
await expect(page.getByText(/Could not load this plan/)).toBeVisible();
|
||||
await page.getByLabel('Room name', { exact: true }).fill('Recovered plan');
|
||||
await expect(page.getByText(/Could not load this plan/)).toHaveCount(0);
|
||||
expect(decodeState(new URL(page.url()).hash).error).toBeNull();
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Recovered plan');
|
||||
});
|
||||
|
||||
for (const width of [320, 390]) {
|
||||
test(`phone layout works at ${width} px with readable inputs and touch targets`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await openWalls(page);
|
||||
await page.getByRole('button', { name: 'What is saw kerf?', exact: true }).click();
|
||||
await noHorizontalOverflow(page);
|
||||
await expect(page.getByRole('tooltip')).toBeVisible();
|
||||
const smallControls = await page
|
||||
.locator('button, input, select, summary')
|
||||
.evaluateAll((elements) =>
|
||||
elements.flatMap((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0 && (rect.width < 39.5 || rect.height < 39.5)
|
||||
? [
|
||||
{
|
||||
label: element.getAttribute('aria-label') || element.textContent?.trim(),
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
expect(smallControls, 'Every visible form control should have a usable touch target').toEqual(
|
||||
[],
|
||||
);
|
||||
const smallInputs = await page
|
||||
.locator('input, select')
|
||||
.evaluateAll((elements) =>
|
||||
elements
|
||||
.filter(
|
||||
(element) =>
|
||||
element.getBoundingClientRect().height > 0 &&
|
||||
parseFloat(getComputedStyle(element).fontSize) < 16,
|
||||
)
|
||||
.map((element) => element.getAttribute('aria-label') || element.id),
|
||||
);
|
||||
expect(smallInputs, '16 px input text avoids phone browser zoom').toEqual([]);
|
||||
expect(
|
||||
await floorRows(page)
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).display),
|
||||
).toMatch(/block|grid/);
|
||||
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 page.getByText('Source lengths for skirting cuts', { exact: true }).click();
|
||||
await noHorizontalOverflow(page);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue