..
This commit is contained in:
parent
9689bfcc18
commit
62a385e4c9
33 changed files with 2649 additions and 809 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue