landing
All checks were successful
Deploy to Pages / build (push) Successful in 26s

This commit is contained in:
Andras Schmelczer 2026-09-11 20:34:36 +01:00
commit b9485e4097
32 changed files with 4692 additions and 65 deletions

60
e2e/landing.spec.ts Normal file
View file

@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test';
test('the home page explains the app and its guides fit the screen', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'A great floorstarts witha little planning.',
);
await expect(page.getByRole('heading', { name: 'Scotia or skirting board?' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Plan my floor' })).toHaveCount(2);
await expect(page.getByRole('link', { name: 'Plan my floor' }).first()).toBeInViewport();
await page.getByRole('link', { name: 'First time fitting a floor?' }).click();
await expect(page).toHaveURL(/#guide$/);
await expect(page.getByRole('heading', { name: /Your first floor/ })).toBeInViewport();
await page.reload();
await expect(page.getByRole('heading', { name: /Your first floor/ })).toBeInViewport();
await page.getByRole('link', { name: 'Back to top' }).click();
await expect(page.getByRole('heading', { level: 1 })).toBeInViewport();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
page.viewportSize()!.width,
);
expect(errors).toEqual([]);
});
for (const [name, selector] of [
['header', '.landing-header'],
['hero', '.landing-hero'],
['closing', '.landing-final-cta'],
]) {
test(`the ${name} CTA opens a working planner and browser history returns home`, async ({
page,
}) => {
await page.goto('/');
await page.locator(selector).locator('a[href="?planner"]').click();
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
await expect(page).toHaveURL(/\?version=1&/);
await page.getByLabel('Room name', { exact: true }).fill('My first floor');
const projectUrl = page.url();
await page.reload();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('My first floor');
await page.goBack();
await expect(page.getByRole('heading', { level: 1 })).toContainText('A great floor');
await page.goForward();
await expect(page).toHaveURL(projectUrl);
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('My first floor');
});
}
test('returning through the home page resumes the saved project', async ({ page }) => {
await page.goto('/?planner');
await page.getByLabel('Room name', { exact: true }).fill('Weekend project');
await page.getByLabel('Room width', { exact: true }).fill('4200');
await page.getByRole('link', { name: 'Floor planner home' }).click();
await expect(page.getByRole('heading', { level: 1 })).toContainText('A great floor');
await page.getByRole('link', { name: 'Plan my floor' }).first().click();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Weekend project');
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4200');
});

68
e2e/optimisation.spec.ts Normal file
View file

@ -0,0 +1,68 @@
import { expect, test } from '@playwright/test';
import { defaultState } from '../src/lib/defaults';
import { planFlooringOptions } from '../src/lib/flooring';
import { mm } from '../src/lib/format';
import { decodeState, encodeState } from '../src/lib/url-state';
test('random optimisation choices show actual totals, select the plan and survive reload', async ({
page,
isMobile,
}) => {
if (isMobile) await page.setViewportSize({ width: 320, height: 844 });
const state = defaultState();
state.project.flooring.pattern = 'random';
const p = state.project;
const options = planFlooringOptions(p.rooms, p.flooring, p.tolerance);
await page.goto(`/${encodeState(state)}`);
const choices = page.getByRole('group', { name: 'Flooring optimisation', exact: true });
const cuts = choices.getByRole('button', { name: /^Minimise cuts/ });
const random = choices.getByRole('button', { name: /^Maximise randomness/ });
await expect(cuts).toHaveAttribute('aria-pressed', 'true');
await expect(cuts).toContainText(
`${options.cuts.cuts} cuts · ${mm(options.cuts.wastePercent)}% waste`,
);
await expect(random).toContainText(
`${options.random.cuts} cuts · ${mm(options.random.wastePercent)}% waste`,
);
expect(options.cuts.cuts).toBeLessThan(options.random.cuts);
const checkbox = page.locator('.stock-cut-group input[type="checkbox"]').first();
await checkbox.check();
expect(new URL(page.url()).searchParams.getAll('done')).not.toEqual([]);
await random.click();
await expect(random).toHaveAttribute('aria-pressed', 'true');
await expect(cuts).toHaveAttribute('aria-pressed', 'false');
expect(new URL(page.url()).searchParams.get('flooring.optimisation')).toBe('random');
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]);
const drawn = page.getByRole('region', { name: 'Floor plan preview' }).locator('.floor-piece');
await expect(drawn).toHaveCount(options.random.pieces.length);
const ids = await drawn.evaluateAll((pieces) =>
pieces.map((piece) => piece.getAttribute('data-piece-id')),
);
expect(ids.sort()).toEqual(options.random.pieces.map((piece) => piece.id).sort());
await page.reload();
await expect(random).toHaveAttribute('aria-pressed', 'true');
expect(decodeState(new URL(page.url()).search).state.project.flooring.optimisation).toBe(
'random',
);
await cuts.click();
await expect(cuts).toHaveAttribute('aria-pressed', 'true');
await expect(drawn).toHaveCount(options.cuts.pieces.length);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
page.viewportSize()!.width,
);
await page.getByRole('button', { name: 'Brick', exact: true }).click();
await expect(choices).toHaveCount(0);
});
test('infeasible measurements do not advertise zero-cut or zero-waste options', async ({
page,
}) => {
const state = defaultState();
state.project.flooring.pattern = 'random';
state.project.flooring.minOffset = 700;
await page.goto(`/${encodeState(state)}`);
const choices = page.getByRole('group', { name: 'Flooring optimisation', exact: true });
await expect(choices.getByText('Check measurements', { exact: true })).toHaveCount(2);
await expect(choices).not.toContainText('0 cuts');
await expect(choices).not.toContainText('0% waste');
});

View file

@ -3,6 +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 { PROJECT_STORAGE_KEY } from '../src/lib/use-project';
import { defaultState, fireplace, rectangle } from '../src/lib/defaults';
import { planFlooring } from '../src/lib/flooring';
import { planSkirting } from '../src/lib/skirting';
@ -23,6 +24,8 @@ const state = (page: Page) => {
const url = new URL(page.url());
return decodeState(url.search || url.hash).state;
};
const savedQuery = (page: Page) =>
page.evaluate((key) => localStorage.getItem(key), PROJECT_STORAGE_KEY);
const plans = (page: Page) => {
const { project } = state(page);
return {
@ -37,7 +40,8 @@ function sharedRoomFixture() {
fixture.project.flooring.width = 200;
fixture.project.rooms = [
rectangle('room-1', 'Living room', 1820, 1220),
rectangle('room-2', 'Study', 1220, 1820),
// Its end pieces complement the first room's offcuts after saw kerf.
rectangle('room-2', 'Study', 1220, 1814),
];
fixture.project.rooms.forEach((room) =>
room.walls.forEach((wall) => {
@ -259,7 +263,7 @@ async function noWallLabelOverlap(page: Page) {
test.beforeEach(async ({ page }) => {
errors.set(page, []);
page.on('pageerror', (error) => errors.get(page)!.push(error.message));
await page.goto('/');
await page.goto('/?planner');
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
await expect(page).toHaveURL(/\?version=1&/);
});
@ -679,9 +683,115 @@ test('pattern, direction and spacers persist without steps, layers or stored res
).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.flooring-cuts')).toHaveText(table, { useInnerText: true });
await noHorizontalOverflow(page);
expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
expect(await savedQuery(page)).toBe(new URL(page.url()).search);
});
test('localStorage restores all project and view parameters when reopening without a link', async ({
page,
context,
}) => {
const fixture = sharedRoomFixture();
fixture.project.name = 'Café 北 flooring';
fixture.project.tolerance = 7;
fixture.project.flooring = {
...fixture.project.flooring,
length: 1300,
width: 180,
gap: 12,
pattern: 'random',
minOffset: 250,
maxOffset: 600,
minLength: 180,
minWidth: 50,
kerf: 2.5,
};
fixture.project.skirting = { stockLength: 3600, thickness: 20, minLength: 250, kerf: 2 };
fixture.project.rooms[1].walls[0].doors = [{ id: 'study-door', offset: 300, width: 850 }];
fixture.project.completedBoards = ['floor:B1', 'skirting:S1'];
fixture.view = {
roomId: 'room-2',
selectedPiece: 'F1',
search: 'Study',
collapsedSections: ['cuts', 'materials'],
};
const query = encodeState(fixture);
await page.goto(`/${query}`);
await expect(page.getByRole('dialog', { name: 'Flooring cut F1', exact: true })).toBeVisible();
expect(await savedQuery(page)).toBe(query);
const reopened = await context.newPage();
await reopened.goto('/?planner');
await expect(reopened).toHaveURL(page.url());
expect(state(reopened)).toEqual(fixture);
await expect(
reopened.getByRole('dialog', { name: 'Flooring cut F1', exact: true }),
).toBeVisible();
await reopened.keyboard.press('Escape');
await expect(reopened.getByLabel('Room name', { exact: true })).toHaveValue('Study');
await expect(reopened.locator('#materials-content')).toBeHidden();
await expect(reopened.locator('#cuts-content')).toBeHidden();
await reopened.getByLabel('Room name', { exact: true }).fill('Saved study');
fixture.project.rooms[1].name = 'Saved study';
fixture.view.selectedPiece = null;
expect(await savedQuery(reopened)).toBe(encodeState(fixture));
await reopened.goto('/?planner');
await expect(reopened.getByLabel('Room name', { exact: true })).toHaveValue('Saved study');
expect(state(reopened)).toEqual(fixture);
await reopened.close();
});
test('explicit query parameters override the saved plan and default omitted fields', async ({
page,
}) => {
await page.getByLabel('Room name', { exact: true }).fill('Saved room');
await page.getByLabel('Wall spacer', { exact: true }).fill('12');
await page.goto('/?flooring.length=1400');
await expect(page.getByLabel('Board length', { exact: true })).toHaveValue('1400');
const expected = defaultState();
expected.project.flooring.length = 1400;
expect(state(page)).toEqual(expected);
expect(await savedQuery(page)).toBe(encodeState(expected));
});
for (const saved of ['damaged data', '?flooring.length=broken']) {
test(`invalid localStorage falls back to defaults: ${saved}`, async ({ page }) => {
await page.evaluate(
([key, value]) => localStorage.setItem(key, value),
[PROJECT_STORAGE_KEY, saved],
);
await page.goto('/?planner');
await expect(page).toHaveURL(/\?version=1&/);
await expect(page.locator('.url-error')).toHaveCount(0);
expect(state(page)).toEqual(defaultState());
expect(await savedQuery(page)).toBe(encodeState(defaultState()));
});
}
for (const failure of ['blocked', 'full']) {
test(`URL persistence still works when localStorage is ${failure}`, async ({ page }) => {
await page.addInitScript((failure) => {
if (failure === 'blocked') {
Object.defineProperty(window, 'localStorage', {
get() {
throw new DOMException('Storage blocked', 'SecurityError');
},
});
} else {
Storage.prototype.setItem = () => {
throw new DOMException('Storage full', 'QuotaExceededError');
};
}
}, failure);
await page.goto('/?planner');
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room');
await page.getByLabel('Room name', { exact: true }).fill('URL still works');
expect(state(page).project.rooms[0].name).toBe('URL still works');
await page.reload();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('URL still works');
await expect(page.locator('.url-error')).toHaveCount(0);
});
}
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');
@ -780,12 +890,15 @@ test('saw kerf help works with keyboard and touch or mouse', async ({ page, isMo
});
test('invalid URLs recover when measurements are edited', async ({ page }) => {
const saved = await savedQuery(page);
await page.goto('/?flooring.length=broken');
await expect(page.getByText(/Could not load this plan/)).toBeVisible();
expect(await savedQuery(page)).toBe(saved);
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()).search).error).toBeNull();
expect(new URL(page.url()).hash).toBe('');
expect(await savedQuery(page)).toBe(new URL(page.url()).search);
await page.reload();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Recovered plan');
});
@ -818,6 +931,7 @@ test('readable query fields, legacy links and browser history preserve the plan'
await expect(page).toHaveURL(/\?version=1&/);
expect(new URL(page.url()).hash).toBe('');
expect(state(page)).toEqual(legacy);
expect(await savedQuery(page)).toBe(encodeState(legacy));
const original = page.url();
await page.getByRole('button', { name: 'Add room', exact: true }).click();
await page.getByLabel('Room name', { exact: true }).fill('Study');
@ -833,10 +947,12 @@ test('readable query fields, legacy links and browser history preserve the plan'
await expect(page).toHaveURL(original);
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Legacy lounge');
expect(state(page).project.rooms).toHaveLength(1);
expect(await savedQuery(page)).toBe(new URL(original).search);
await page.goForward();
await expect(page).toHaveURL(twoRooms);
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3333');
expect(await savedQuery(page)).toBe(new URL(twoRooms).search);
params.set('flooring.width', '205');
await page.goto(`/?${params}`);
await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205');