This commit is contained in:
parent
171fbf1927
commit
8bf3e3b705
40 changed files with 1443 additions and 1497 deletions
|
|
@ -1,9 +1,7 @@
|
|||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultProject, rectangle } from '../src/lib/defaults';
|
||||
import { boardSearchLabel, findBoardMatches } from '../src/lib/board-tracking';
|
||||
import { CutList } from '../src/components/CutList';
|
||||
import { focusLabel, findBoardMatches } from '../src/lib/board-tracking';
|
||||
import { cutData } from '../src/lib/cut-list';
|
||||
import type { FlooringPiece, FlooringPlan, SkirtingPiece, SkirtingPlan } from '../src/lib/types';
|
||||
|
||||
const project = defaultProject();
|
||||
|
|
@ -20,7 +18,6 @@ function floorPiece(id: string, stockId: string, roomId = 'room-1', cut = true):
|
|||
blankLength: length,
|
||||
blankWidth: 190,
|
||||
row: 1,
|
||||
column: 1,
|
||||
polygon: [],
|
||||
template: [
|
||||
{ x: 0, y: 0 },
|
||||
|
|
@ -69,6 +66,7 @@ function skirtPiece(id: string, stockId: string, roomId = 'room-1', length = 900
|
|||
roomId,
|
||||
length,
|
||||
blankLength: length,
|
||||
cut: length !== 2400,
|
||||
backLength: length,
|
||||
wallId: 'w1',
|
||||
wallIndex: 0,
|
||||
|
|
@ -81,10 +79,6 @@ function skirtPiece(id: string, stockId: string, roomId = 'room-1', length = 900
|
|||
template,
|
||||
installedTemplate: template,
|
||||
visibleLength: length,
|
||||
leftOverlap: 0,
|
||||
rightOverlap: 0,
|
||||
leftBevel: 0,
|
||||
rightBevel: 0,
|
||||
};
|
||||
}
|
||||
const skirting: SkirtingPlan = {
|
||||
|
|
@ -104,122 +98,57 @@ const skirting: SkirtingPlan = {
|
|||
waste: 1503,
|
||||
issues: [],
|
||||
};
|
||||
|
||||
const find = (query: string) => findBoardMatches(project, flooring, skirting, query);
|
||||
const noop = () => {};
|
||||
function list(search = '', completedBoards: string[] = []) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(CutList, {
|
||||
project,
|
||||
flooring,
|
||||
skirting,
|
||||
select: noop,
|
||||
search,
|
||||
setSearch: noop,
|
||||
completedBoards,
|
||||
toggleBoard: noop,
|
||||
locateBoard: noop,
|
||||
locatePiece: noop,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const cuts = cutData(flooring, skirting, project.flooring);
|
||||
|
||||
describe('board tracking search', () => {
|
||||
it('matches cut and stock codes exactly instead of confusing F1 with F10/F12 or B1 with B10', () => {
|
||||
expect(find(' F1 ')).toEqual({
|
||||
pieceIds: ['F1'],
|
||||
roomIds: ['room-1'],
|
||||
boardKeys: ['floor:B1'],
|
||||
});
|
||||
expect(find('b1')).toEqual({
|
||||
pieceIds: ['F1', 'F12', 'B1'],
|
||||
roomIds: ['room-1', 'room-2'],
|
||||
boardKeys: ['floor:B1', 'skirting:S6'],
|
||||
});
|
||||
expect(find('S6').pieceIds).toEqual(['A2', 'B1']);
|
||||
expect(find('B10').pieceIds).toEqual(['F10', 'B10']);
|
||||
describe('board and cut location', () => {
|
||||
it('matches complete cut codes and multiple codes without interpreting stock commands', () => {
|
||||
expect([...find('f1').pieceKeys]).toEqual(['flooring:F1']);
|
||||
expect([...find('F1 + F12').pieceKeys]).toEqual(['flooring:F1', 'flooring:F12']);
|
||||
expect([...find('B1').pieceKeys]).toEqual(['skirting:B1']);
|
||||
expect(find('floor:B1').pieceKeys.size).toBe(0);
|
||||
});
|
||||
|
||||
it('uses material-qualified source keys for unambiguous board location and includes all its rooms', () => {
|
||||
expect(find(' FLOOR:B1 ')).toEqual({
|
||||
pieceIds: ['F1', 'F12'],
|
||||
roomIds: ['room-1', 'room-2'],
|
||||
boardKeys: ['floor:B1'],
|
||||
});
|
||||
expect(find('skirting:S6')).toEqual({
|
||||
pieceIds: ['A2', 'B1'],
|
||||
roomIds: ['room-1', 'room-2'],
|
||||
boardKeys: ['skirting:S6'],
|
||||
});
|
||||
it('keeps identical display labels in different materials distinct', () => {
|
||||
const skirt = { ...skirting, pieces: [...skirting.pieces, skirtPiece('F1', 'S8', 'room-2')] };
|
||||
const result = findBoardMatches(project, flooring, skirt, 'F1');
|
||||
expect([...result.pieceKeys]).toEqual(['flooring:F1', 'skirting:F1']);
|
||||
expect(result.materials).toEqual(new Set(['flooring', 'skirting']));
|
||||
const data = cutData(flooring, skirt, project.flooring);
|
||||
expect(data.selections.get('flooring:F1')?.piece.roomId).toBe('room-1');
|
||||
expect(data.selections.get('skirting:F1')?.piece.roomId).toBe('room-2');
|
||||
});
|
||||
|
||||
it('finds full uncut boards as well as cuts, and searches room names without case sensitivity', () => {
|
||||
expect(find('b3')).toEqual({ pieceIds: ['FU1'], roomIds: ['room-2'], boardKeys: ['floor:B3'] });
|
||||
expect(find('s7')).toEqual({
|
||||
pieceIds: ['SU1'],
|
||||
roomIds: ['room-2'],
|
||||
boardKeys: ['skirting:S7'],
|
||||
});
|
||||
expect(find(' OfFiCe ')).toEqual({
|
||||
pieceIds: ['F12', 'FU1', 'B1', 'SU1'],
|
||||
roomIds: ['room-2'],
|
||||
boardKeys: ['floor:B1', 'floor:B3', 'skirting:S6', 'skirting:S7'],
|
||||
});
|
||||
expect(find('living').roomIds).toEqual(['room-1']);
|
||||
it('locates all pieces of a source board across rooms without setting search text', () => {
|
||||
const focus = { kind: 'board', material: 'flooring', id: 'B1' } as const;
|
||||
const result = findBoardMatches(project, flooring, skirting, '', focus);
|
||||
expect([...result.pieceKeys]).toEqual(['flooring:F1', 'flooring:F12']);
|
||||
expect(result.roomIds).toEqual(['room-1', 'room-2']);
|
||||
expect(result.boardKeys).toEqual(new Set(['floor:B1']));
|
||||
expect(focusLabel(focus, cuts)).toBe('F1 + F12');
|
||||
});
|
||||
|
||||
it('returns empty matches for empty or unknown queries and never mutates the plans', () => {
|
||||
it('locates uncut boards and searches room names including code-like names', () => {
|
||||
const focus = { kind: 'board', material: 'flooring', id: 'B3' } as const;
|
||||
expect([...findBoardMatches(project, flooring, skirting, '', focus).pieceKeys]).toEqual([
|
||||
'flooring:FU1',
|
||||
]);
|
||||
expect(focusLabel(focus, cuts)).toBe('Uncut floorboard');
|
||||
expect(find(' OfFiCe ').roomIds).toEqual(['room-2']);
|
||||
const renamed = { ...project, rooms: project.rooms.map((room) => ({ ...room, name: 'A101' })) };
|
||||
expect(findBoardMatches(renamed, flooring, skirting, 'A101').pieceKeys.size).toBe(8);
|
||||
});
|
||||
it('keeps a focused piece distinct from a focused source board', () => {
|
||||
expect([
|
||||
...findBoardMatches(project, flooring, skirting, '', {
|
||||
kind: 'piece',
|
||||
material: 'flooring',
|
||||
id: 'F12',
|
||||
}).pieceKeys,
|
||||
]).toEqual(['flooring:F12']);
|
||||
});
|
||||
it('returns no matches for empty or unknown input and does not mutate plans', () => {
|
||||
const before = structuredClone({ project, flooring, skirting });
|
||||
expect(find(' ')).toEqual({ pieceIds: [], roomIds: [], boardKeys: [] });
|
||||
expect(find('missing')).toEqual({ pieceIds: [], roomIds: [], boardKeys: [] });
|
||||
expect(find('B1')).toEqual(find('B1'));
|
||||
expect(find(' ').pieceKeys.size).toBe(0);
|
||||
expect(find('missing').pieceKeys.size).toBe(0);
|
||||
expect({ project, flooring, skirting }).toEqual(before);
|
||||
});
|
||||
|
||||
it('keeps all cuts from a matching source together and applies completion to the whole group', () => {
|
||||
const markup = list('F1', ['floor:B1']);
|
||||
expect(markup).toContain('data-board-key="floor:B1"');
|
||||
expect(markup).not.toContain('data-board-key="floor:B10"');
|
||||
expect(markup).not.toContain('data-board-key="skirting:S6"');
|
||||
expect(markup).toContain('Preview flooring cut F1');
|
||||
expect(markup).toContain('Preview flooring cut F12');
|
||||
expect(markup.match(/class="board-cut is-done/g)).toHaveLength(2);
|
||||
expect(markup).toContain('aria-label="Mark cuts F1 + F12 done" checked=""');
|
||||
expect(markup).toContain('Show cuts F1 + F12 on plan');
|
||||
expect(markup).toContain('Show cut F12 on plan');
|
||||
expect(markup).toContain('<span>Done</span>');
|
||||
});
|
||||
|
||||
it('names board groups and actions using their cuts while keeping bookkeeping IDs internal', () => {
|
||||
const markup = list();
|
||||
expect(markup).toContain('aria-label="Flooring cuts F1 + F12"');
|
||||
expect(markup).toContain('aria-label="Skirting cuts A2 + B1"');
|
||||
expect(markup).toContain('Mark cuts A2 + B1 done');
|
||||
expect(markup).toContain('data-board-key="skirting:S6"');
|
||||
expect(markup).toContain('Find a cut or room');
|
||||
expect(markup).not.toMatch(/(?:Floorboard B\d|Skirting board S\d|Export CSV)/i);
|
||||
expect(markup).not.toMatch(/aria-label="[^"]*(?:floor:B\d|skirting:S\d)/i);
|
||||
});
|
||||
|
||||
it('shows cut names when a located board is stored as a qualified source key', () => {
|
||||
expect(boardSearchLabel(project, flooring, skirting, 'floor:B1')).toBe('F1 + F12');
|
||||
expect(boardSearchLabel(project, flooring, skirting, 'skirting:S6')).toBe('A2 + B1');
|
||||
expect(boardSearchLabel(project, flooring, skirting, 'Office')).toBe('Office');
|
||||
expect(list('floor:B1')).toContain('value="F1 + F12"');
|
||||
expect(list('skirting:S6')).toContain('value="A2 + B1"');
|
||||
expect(find('F1 + F12')).toEqual(find('floor:B1'));
|
||||
// B1 here is a skirting cut, not the hidden flooring stock with the same internal code.
|
||||
expect(find('A2 + B1')).toEqual(find('skirting:S6'));
|
||||
});
|
||||
|
||||
it('exposes an interactive full-board result only when searched, without inventing a cut preview', () => {
|
||||
expect(list()).not.toContain('data-board-key="floor:B3"');
|
||||
const markup = list('floor:B3');
|
||||
expect(markup).toContain('data-board-key="floor:B3"');
|
||||
expect(markup).toContain('Mark uncut floorboard done');
|
||||
expect(markup).toContain('Show uncut floorboard on plan');
|
||||
expect(markup).toContain('Installed uncut');
|
||||
expect(markup).toContain('value="Uncut floorboard"');
|
||||
expect(markup).not.toContain('Preview flooring cut FU1');
|
||||
expect(list('skirting:S7')).toContain('value="Uncut skirting board"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ function piece(id: string, stockId: string, changes: Partial<FlooringPiece> = {}
|
|||
id,
|
||||
roomId: 'room-1',
|
||||
row: 1,
|
||||
column: 1,
|
||||
|
||||
polygon: [],
|
||||
length,
|
||||
width,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { createElement } from 'react';
|
|||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview';
|
||||
import { CutDialog } from '../src/components/CutDialog';
|
||||
import { planSkirting } from '../src/lib/skirting';
|
||||
import { skirtingEndLabel } from '../src/lib/cut-list';
|
||||
import { defaultProject } from '../src/lib/defaults';
|
||||
|
|
@ -14,7 +13,6 @@ const floor: FlooringPiece = {
|
|||
id: 'F1',
|
||||
roomId: 'room-1',
|
||||
row: 1,
|
||||
column: 1,
|
||||
polygon: [],
|
||||
length: 600.001,
|
||||
width: 190,
|
||||
|
|
@ -41,6 +39,7 @@ const template: SkirtingPiece['template'] = [
|
|||
{ x: 0, y: 18 },
|
||||
];
|
||||
const skirt: SkirtingPiece = {
|
||||
cut: true,
|
||||
id: 'A1',
|
||||
roomId: 'room-1',
|
||||
wallId: 'w1',
|
||||
|
|
@ -58,10 +57,6 @@ const skirt: SkirtingPiece = {
|
|||
template,
|
||||
installedTemplate: template,
|
||||
visibleLength: 1200,
|
||||
leftOverlap: 0,
|
||||
rightOverlap: 0,
|
||||
leftBevel: -45,
|
||||
rightBevel: 45,
|
||||
};
|
||||
|
||||
describe('cut shape previews', () => {
|
||||
|
|
@ -223,39 +218,6 @@ describe('cut shape previews', () => {
|
|||
expect(markup).toContain('angles from horizontal');
|
||||
});
|
||||
|
||||
it('keeps dialog instructions useful without source codes or numbered coordinate lists', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(CutDialog, {
|
||||
floor: { ...floor, factoryLeft: true, ripEdge: 'top' },
|
||||
project: defaultProject(),
|
||||
close: () => {},
|
||||
}),
|
||||
);
|
||||
expect(markup).toContain('Living room · Row 1');
|
||||
expect(markup).toContain('Keep the left factory end; trim from the right.');
|
||||
expect(markup).toContain('Rip the top long edge; keep the bottom factory edge.');
|
||||
expect(markup).not.toContain('Source');
|
||||
expect(markup).not.toContain('coordinates');
|
||||
expect(markup).not.toContain('cut-edge-lengths');
|
||||
});
|
||||
|
||||
it('shows skirting dimensions and mitre directions without a second measurement list', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(CutDialog, {
|
||||
skirt,
|
||||
project: defaultProject(),
|
||||
close: () => {},
|
||||
}),
|
||||
);
|
||||
expect(markup).toContain('Living room · Wall 1');
|
||||
expect(markup).toContain('>1,164</text>');
|
||||
expect(markup).toContain('>1,200</text>');
|
||||
expect(markup).toContain('Mitre 45°');
|
||||
expect(markup).not.toContain('Source');
|
||||
expect(markup).not.toContain('coordinates');
|
||||
expect(markup).not.toContain('<dl');
|
||||
});
|
||||
|
||||
it('keeps the complete end cut visible even at shallow mitre angles', () => {
|
||||
const shallow = [
|
||||
{ x: 0, y: 0 },
|
||||
|
|
@ -278,34 +240,11 @@ describe('cut shape previews', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('makes thumbnails decorative while retaining true-scale SVGs and visible end breaks', () => {
|
||||
const floorMarkup = renderToStaticMarkup(
|
||||
createElement(FlooringPreview, { piece: floor, compact: true }),
|
||||
);
|
||||
const skirtMarkup = renderToStaticMarkup(
|
||||
createElement(SkirtingPreview, { piece: skirt, thickness: 18, compact: true }),
|
||||
);
|
||||
expect(floorMarkup).toContain('aria-hidden="true"');
|
||||
expect(skirtMarkup.match(/aria-hidden="true"/g)).toHaveLength(3);
|
||||
expect(skirtMarkup).toContain('>//</span>');
|
||||
expect(skirtMarkup.match(/preserveAspectRatio="xMidYMid meet"/g)).toHaveLength(2);
|
||||
expect(floorMarkup).not.toContain('<figure');
|
||||
expect(skirtMarkup).not.toContain('<figure');
|
||||
});
|
||||
it('shows cope preparation and its full-thickness body without inventing a decorative profile', () => {
|
||||
const project = defaultProject();
|
||||
const plan = planSkirting(project.rooms, project.skirting, project.tolerance);
|
||||
const piece = plan.pieces.find((piece) => piece.leftType === 'cope')!;
|
||||
expect(piece).toBeDefined();
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(CutDialog, { skirt: piece, project, close: () => {} }),
|
||||
);
|
||||
expect(markup).toContain(`Left cope preparation close-up ${piece.id}`);
|
||||
expect(markup).toContain('Cope · prepare 45°');
|
||||
expect(markup).toContain('preparation before coping');
|
||||
expect(markup).toContain('class="skirting-cope-body"');
|
||||
expect(markup).toContain('mark your moulding’s exposed profile');
|
||||
expect(markup).toContain('Exposed front length:');
|
||||
expect(skirtingEndLabel({ ...piece, rightType: 'internal', rightMitre: 0 }, 'right')).toBe(
|
||||
'Receive cope · square',
|
||||
);
|
||||
|
|
@ -316,14 +255,6 @@ describe('cut shape previews', () => {
|
|||
const installed = piece.installedTemplate;
|
||||
expect(prepared[0].x).toBeLessThan(installed[0].x);
|
||||
expect(prepared[3].x).toBeCloseTo(installed[3].x);
|
||||
const compact = renderToStaticMarkup(
|
||||
createElement(SkirtingPreview, {
|
||||
piece,
|
||||
thickness: project.skirting.thickness,
|
||||
compact: true,
|
||||
}),
|
||||
);
|
||||
expect(compact).toContain('class="skirting-cope-body"');
|
||||
});
|
||||
|
||||
it('keeps an oblique coped body inside the uniformly scaled end close-up', () => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { plannerCases } from '../benchmarks/cases';
|
||||
import {
|
||||
compareFlooring,
|
||||
compareCurrentFlooring,
|
||||
compareSkirting,
|
||||
flooringMetrics,
|
||||
skirtingMetrics,
|
||||
} from '../benchmarks/metrics';
|
||||
import { compareCurrentFlooring, flooringMetrics, skirtingMetrics } from '../benchmarks/metrics';
|
||||
import baseline from '../benchmarks/baseline.json';
|
||||
import hBaseline from '../benchmarks/h-pattern-baseline.json';
|
||||
import { planFlooringOptions } from '../src/lib/flooring';
|
||||
import { planSkirting } from '../src/lib/skirting';
|
||||
import { defaultProject, fireplace, rectangle } from '../src/lib/defaults';
|
||||
|
|
@ -118,41 +111,31 @@ describe('measured optimiser quality', () => {
|
|||
const { options, skirt } = resultFor({ name, project });
|
||||
const cuts = flooringMetrics(options.cuts, project.flooring);
|
||||
const random = flooringMetrics(options.random, project.flooring);
|
||||
const before = baseline.find((row) => row.name === name)!;
|
||||
expect(compareFlooring(cuts, before.flooring)).toBeLessThanOrEqual(0);
|
||||
expect(compareCurrentFlooring(cuts, random)).toBeLessThanOrEqual(0);
|
||||
expect(compareCurrentFlooring(random, cuts, true)).toBeLessThanOrEqual(0);
|
||||
const previousAppearance = hBaseline.find((row) => row.name === name)!;
|
||||
expect(compareCurrentFlooring(random, previousAppearance.random, true)).toBeLessThanOrEqual(
|
||||
0,
|
||||
);
|
||||
expect(random.nearH).toBeLessThanOrEqual(previousAppearance.random.nearH);
|
||||
// Only this fixture's 600 mm minimum pieces force alternating starts.
|
||||
if (name !== 'forced-alternation') expect(random.nearH).toBe(0);
|
||||
validateFlooring(options.cuts, project);
|
||||
if (options.random !== options.cuts) validateFlooring(options.random, project);
|
||||
expect(compareSkirting(skirt, before.skirting)).toBeLessThanOrEqual(0);
|
||||
expect(skirt.errors).toBe(0);
|
||||
expect(JSON.stringify(project)).toBe(input);
|
||||
},
|
||||
);
|
||||
|
||||
it('retains the measured improvements across the complete fixed corpus', () => {
|
||||
const totals = { cuts: 0, randomCuts: 0, pattern: 0, nearH: 0, skirtingCuts: 0 };
|
||||
const totals = { cuts: 0, randomCuts: 0, skirtingCuts: 0 };
|
||||
for (const c of cases) {
|
||||
const { options, skirt } = resultFor(c);
|
||||
totals.cuts += options.cuts.cuts;
|
||||
totals.randomCuts += options.random.cuts;
|
||||
totals.pattern += flooringMetrics(options.random, c.project.flooring).patternPenalty;
|
||||
totals.nearH += flooringMetrics(options.random, c.project.flooring).nearH;
|
||||
totals.skirtingCuts += skirt.cuts;
|
||||
}
|
||||
expect(totals.cuts).toBeLessThanOrEqual(1741); // Original: 2177.
|
||||
// Broader H avoidance costs more cuts, while still beating the original 2177 passes.
|
||||
expect(totals.randomCuts).toBeLessThanOrEqual(2113);
|
||||
expect(totals.nearH).toBeLessThanOrEqual(6); // Before the visual correction: 557.
|
||||
expect(totals.pattern).toBeLessThanOrEqual(346); // Retain the separate legacy diagnostic.
|
||||
expect(totals.skirtingCuts).toBeLessThanOrEqual(1436); // Original: 1447.
|
||||
// Keep a substantial cuts-first gain and let appearance-first spend cuts on joint spacing.
|
||||
const originalCuts = baseline.reduce((sum, row) => sum + row.flooring.cuts, 0);
|
||||
const originalSkirting = baseline.reduce((sum, row) => sum + row.skirting.cuts, 0);
|
||||
expect(totals.cuts).toBeLessThan(originalCuts * 0.81);
|
||||
expect(totals.randomCuts).toBeLessThan(originalCuts);
|
||||
expect(totals.skirtingCuts).toBeLessThan(originalSkirting);
|
||||
});
|
||||
|
||||
it('packs reserved offcuts before applying the 2000-board cap on a 500-row floor', () => {
|
||||
|
|
|
|||
99
tests/project-state.test.ts
Normal file
99
tests/project-state.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultState, rectangle } from '../src/lib/defaults';
|
||||
import { reduceProject } from '../src/lib/project-state';
|
||||
import { createPlanCalculator } from '../src/lib/planning';
|
||||
|
||||
describe('project edits', () => {
|
||||
it('preserves progress when renaming but clears all allocation locations when measurements change', () => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = ['floor:B1'];
|
||||
state.view.selectedPiece = 'flooring:F1';
|
||||
state.view.focus = { kind: 'board', material: 'flooring', id: 'B1' };
|
||||
state.view.search = 'F1';
|
||||
const renamed = reduceProject(state, {
|
||||
type: 'room',
|
||||
id: 'room-1',
|
||||
change: (room) => ({ ...room, name: 'New name' }),
|
||||
});
|
||||
expect(renamed.project.completedBoards).toBe(state.project.completedBoards);
|
||||
expect(renamed.view).toBe(state.view);
|
||||
const changed = reduceProject(renamed, { type: 'flooring', patch: { gap: 12 } });
|
||||
expect(changed.project.completedBoards).toEqual([]);
|
||||
expect(changed.view).toMatchObject({ selectedPiece: null, search: '', focus: null });
|
||||
expect(reduceProject(state, { type: 'flooring', patch: { optimisation: 'cuts' } })).toBe(state);
|
||||
});
|
||||
it('keeps piece selection material-qualified and clears location when typing', () => {
|
||||
const state = reduceProject(defaultState(), {
|
||||
type: 'select',
|
||||
material: 'skirting',
|
||||
id: 'F1',
|
||||
roomId: 'room-6',
|
||||
});
|
||||
expect(state.view.selectedPiece).toBe('skirting:F1');
|
||||
const focused = reduceProject(state, {
|
||||
type: 'focus',
|
||||
focus: { kind: 'board', material: 'skirting', id: 'S1' },
|
||||
roomId: 'room-6',
|
||||
});
|
||||
expect(focused.view).toMatchObject({ selectedPiece: null, search: '' });
|
||||
const searched = reduceProject(focused, {
|
||||
type: 'search',
|
||||
text: 'Kitchen',
|
||||
material: 'flooring',
|
||||
roomId: 'room-1',
|
||||
});
|
||||
expect(searched.view.focus).toBeNull();
|
||||
});
|
||||
it('keeps at least one room and navigates away from a removed room', () => {
|
||||
const state = defaultState();
|
||||
expect(reduceProject(state, { type: 'removeRoom', id: 'room-1' })).toBe(state);
|
||||
const added = reduceProject(state, { type: 'addRoom' });
|
||||
expect(added.project.rooms).toHaveLength(2);
|
||||
const removed = reduceProject(added, { type: 'removeRoom', id: added.view.roomId });
|
||||
expect(removed.view.roomId).toBe('room-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculation invalidation', () => {
|
||||
it('reuses geometry and plans for names, progress, and selecting an already calculated option', () => {
|
||||
const calculate = createPlanCalculator();
|
||||
let state = defaultState();
|
||||
const initial = calculate(state.project);
|
||||
for (const action of [
|
||||
{
|
||||
type: 'room',
|
||||
id: 'room-1',
|
||||
change: (room: ReturnType<typeof rectangle>) => ({ ...room, name: 'Renamed' }),
|
||||
},
|
||||
{ type: 'toggleBoard', key: 'floor:B1' },
|
||||
{ type: 'flooring', patch: { optimisation: 'random' } },
|
||||
] as const) {
|
||||
state = reduceProject(state, action);
|
||||
const next = calculate(state.project);
|
||||
expect(next.flooringOptions).toBe(initial.flooringOptions);
|
||||
expect(next.skirting).toBe(initial.skirting);
|
||||
expect(next.geometries).toBe(initial.geometries);
|
||||
}
|
||||
state = reduceProject(state, { type: 'flooring', patch: { gap: 12 } });
|
||||
const edited = calculate(state.project);
|
||||
expect(edited.flooringOptions).not.toBe(initial.flooringOptions);
|
||||
expect(edited.geometries).toBe(initial.geometries);
|
||||
expect(edited.skirting).toBe(initial.skirting);
|
||||
});
|
||||
it('rebuilds changed outlines and leaves another room geometry intact', () => {
|
||||
const calculate = createPlanCalculator();
|
||||
const state = defaultState();
|
||||
state.project.rooms.push(rectangle('room-2'));
|
||||
const first = calculate(state.project);
|
||||
const changed = reduceProject(state, {
|
||||
type: 'room',
|
||||
id: 'room-2',
|
||||
change: () => rectangle('room-2', 'Second', 4200, 3600),
|
||||
});
|
||||
const second = calculate(changed.project);
|
||||
expect(second.geometries[0]).toBe(first.geometries[0]);
|
||||
expect(second.geometries[1]).not.toBe(first.geometries[1]);
|
||||
expect(second.flooringOptions).not.toBe(first.flooringOptions);
|
||||
expect(second.skirting).not.toBe(first.skirting);
|
||||
});
|
||||
});
|
||||
|
|
@ -101,15 +101,12 @@ describe('skirting cutting plan', () => {
|
|||
expect(front.leftMitre).toBeCloseTo(45);
|
||||
expect(front.rightMitre).toBeCloseTo(45);
|
||||
expect(front.leftType).toBe('external');
|
||||
expect(front.leftBevel).toBeCloseTo(-45);
|
||||
expect(front.rightBevel).toBeCloseTo(45);
|
||||
|
||||
expect(front.template.map((point) => Math.round(point.x))).toEqual([18, 1018, 1036, 0]);
|
||||
const reversed = { ...room, walls: room.walls.map((wall) => ({ ...wall, turn: -wall.turn })) };
|
||||
const reversePlan = planSkirting([reversed], { ...settings, stockLength: 6000 }, 5);
|
||||
const reverseFront = reversePlan.pieces.find((piece) => piece.wallIndex === 4)!;
|
||||
expect(reverseFront.length).toBeCloseTo(1036);
|
||||
expect(reverseFront.leftBevel).toBeCloseTo(-45);
|
||||
expect(reverseFront.rightBevel).toBeCloseTo(45);
|
||||
});
|
||||
|
||||
it('packs across rooms with deterministic unique IDs and accounts for kerf', () => {
|
||||
|
|
@ -404,8 +401,6 @@ describe('skirting cutting plan', () => {
|
|||
expect(plan.pieces[0].id).toMatch(/^SU/);
|
||||
for (const piece of plan.pieces) {
|
||||
expect(piece.visibleLength).toBeCloseTo(2364);
|
||||
expect(piece.leftOverlap).toBeCloseTo(18);
|
||||
expect(piece.rightOverlap).toBeCloseTo(18);
|
||||
}
|
||||
const two = planSkirting(
|
||||
[rectangle(2400, 2400, 'one'), rectangle(2400, 2400, 'two')],
|
||||
|
|
@ -486,3 +481,18 @@ describe('skirting cutting plan', () => {
|
|||
expect(plan.pieces.every((piece) => cutLength(piece.length) === 1200)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the allocator cut decision at fractional stock boundaries', () => {
|
||||
const room = rectangle(999.995, 999.995, 'precision');
|
||||
room.walls.forEach((wall) => {
|
||||
wall.doors = [];
|
||||
});
|
||||
const plan = planSkirting(
|
||||
[room],
|
||||
{ stockLength: 1000, thickness: 0, minLength: 100, kerf: 3 },
|
||||
1,
|
||||
);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.cuts).toBe(4);
|
||||
expect(skirtingCuts(plan)).toHaveLength(4);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,76 +3,47 @@ import { defaultState, fireplace, rectangle } from '../src/lib/defaults';
|
|||
import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from '../src/lib/limits';
|
||||
import { decodeState, encodeState } from '../src/lib/url-state';
|
||||
|
||||
const payload = (state: unknown) =>
|
||||
`?plan=${encodeURIComponent(JSON.stringify({ version: 3, ...(state as object) }))}`;
|
||||
function expectInvalid(link: string, message?: RegExp) {
|
||||
const result = decodeState(link);
|
||||
expect(result.state).toEqual(defaultState());
|
||||
expect(result.error).toMatch(message ?? /Could not load this plan/);
|
||||
}
|
||||
|
||||
describe('URL state', () => {
|
||||
it('starts with independent defaults when no link is provided', () => {
|
||||
for (const link of ['', '#', '?']) {
|
||||
describe('saved plans', () => {
|
||||
it('starts with independent defaults', () => {
|
||||
for (const link of ['', '#', '?', '?planner']) {
|
||||
const first = decodeState(link);
|
||||
expect(first).toEqual({ state: defaultState(), error: null });
|
||||
first.state.project.rooms[0].name = 'Edited';
|
||||
expect(decodeState(link).state.project.rooms[0].name).toBe('Living room');
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips current inputs exactly and produces a stable readable link', () => {
|
||||
it('round-trips one versioned payload, including Unicode and material-qualified selection', () => {
|
||||
const state = defaultState();
|
||||
const query = encodeState(state);
|
||||
expect(query).toMatch(/^\?version=2&name=/);
|
||||
const params = new URLSearchParams(query);
|
||||
expect(params.get('flooring.length')).toBe('1200');
|
||||
expect(params.get('flooring.optimisation')).toBe('cuts');
|
||||
expect(params.get('skirting.length')).toBe('2400');
|
||||
expect(params.get('room.1.wall.1.length')).toBe('4800');
|
||||
expect(params.get('room.1.wall.4.door.1.width')).toBe('826');
|
||||
expect(query).not.toContain('#plan=');
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
expect(encodeState(decodeState(query).state)).toBe(query);
|
||||
expect(encodeState(structuredClone(state))).toBe(query);
|
||||
});
|
||||
|
||||
it('round-trips Unicode, rooms, doors, materials, progress and view selections', () => {
|
||||
const state = defaultState();
|
||||
state.project.name = 'Zoë’s 家 🏡 – mõõtmised';
|
||||
state.project.rooms[0].name = 'Zoë’s 家 🏡 – mõõtmised';
|
||||
state.project.rooms.push(fireplace('room-2', 'Slaapkamer 北'));
|
||||
state.project.rooms[1].direction = 90;
|
||||
state.project.rooms[0].walls[0].doors = [{ id: 'wide-door', offset: 123.75, width: 1500 }];
|
||||
state.project.flooring.pattern = 'random';
|
||||
state.project.flooring.optimisation = 'random';
|
||||
state.project.skirting.stockLength = 3600;
|
||||
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
|
||||
state.view = {
|
||||
roomId: 'room-2',
|
||||
selectedPiece: 'A12',
|
||||
search: 'A12',
|
||||
selectedPiece: 'skirting:B12',
|
||||
search: '',
|
||||
focus: { kind: 'board', material: 'skirting', id: 'S6' },
|
||||
collapsedSections: ['materials'],
|
||||
material: 'skirting',
|
||||
};
|
||||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
const query = encodeState(state);
|
||||
expect([...new URLSearchParams(query).keys()]).toEqual(['plan']);
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
expect(encodeState(decodeState(query).state)).toBe(query);
|
||||
});
|
||||
|
||||
it('canonicalizes property order and discards unknown derived fields', () => {
|
||||
it('keeps finite invalid measurements and unfinished outlines for editor feedback', () => {
|
||||
const state = defaultState();
|
||||
const reordered = {
|
||||
view: { ...state.view, derived: 6 },
|
||||
project: { ...state.project, boardCount: 500 },
|
||||
cache: [],
|
||||
};
|
||||
expect(encodeState(reordered)).toBe(encodeState(state));
|
||||
const params = new URLSearchParams(encodeState(state));
|
||||
params.set('boardCount', '500');
|
||||
params.set('room.1.polygon', 'derived');
|
||||
expect(decodeState(`?${params}`)).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('preserves finite domain-invalid measurements for planner feedback', () => {
|
||||
const state = defaultState();
|
||||
state.project.rooms[0].walls[0].length = -1;
|
||||
state.project.rooms[0].walls[0].turn = 999;
|
||||
state.project.rooms[0].walls = [];
|
||||
state.project.flooring.width = 0;
|
||||
state.project.flooring.minOffset = 1000;
|
||||
state.project.flooring.maxOffset = 100;
|
||||
|
|
@ -80,186 +51,129 @@ describe('URL state', () => {
|
|||
state.project.tolerance = -1;
|
||||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('allows unfinished current projects without inventing inputs', () => {
|
||||
it('validates untrusted payloads and discards unknown derived fields', () => {
|
||||
const state = defaultState();
|
||||
state.project.rooms[0].walls = [];
|
||||
state.project.skirting.stockLength = 0;
|
||||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('falls back to the first room for a stale selected room', () => {
|
||||
const params = new URLSearchParams(encodeState(defaultState()));
|
||||
params.set('selectedRoom', 'deleted-room');
|
||||
const result = decodeState(`?${params}`);
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.state.view.roomId).toBe(result.state.project.rooms[0].id);
|
||||
});
|
||||
|
||||
it.each(['#plan=old', '#other=value', 'version=2'])('rejects non-query links %s', (link) => {
|
||||
expectInvalid(link, /starting with \?/);
|
||||
});
|
||||
|
||||
it.each(['?version=1', '?version=3'])('rejects unsupported schema link %s', (link) => {
|
||||
expectInvalid(link, /version is unsupported/);
|
||||
});
|
||||
|
||||
it('requires every field in the current in-memory contract', () => {
|
||||
const missingOptimisation = defaultState();
|
||||
delete (
|
||||
missingOptimisation.project.flooring as Partial<typeof missingOptimisation.project.flooring>
|
||||
).optimisation;
|
||||
expect(() => encodeState(missingOptimisation)).toThrow(/optimisation.*unsupported/i);
|
||||
|
||||
const missingStockLength = defaultState();
|
||||
delete (
|
||||
missingStockLength.project.skirting as Partial<typeof missingStockLength.project.skirting>
|
||||
).stockLength;
|
||||
expect(() => encodeState(missingStockLength)).toThrow(/finite number/);
|
||||
});
|
||||
|
||||
it('rejects incorrectly typed and nonfinite measurements when reading or writing', () => {
|
||||
for (const query of [
|
||||
'?flooring.length=NaN',
|
||||
'?flooring.length=Infinity',
|
||||
'?flooring.length=1e999',
|
||||
'?flooring.length=',
|
||||
'?flooring.length=true',
|
||||
'?flooring.length=0x10',
|
||||
])
|
||||
expectInvalid(query, /finite number/);
|
||||
|
||||
const state = defaultState();
|
||||
state.project.flooring.length = '1200' as never;
|
||||
expect(() => encodeState(state)).toThrow(/finite number/);
|
||||
for (const invalid of [NaN, Infinity, -Infinity]) {
|
||||
state.project.flooring.length = invalid;
|
||||
expect(() => encodeState(state)).toThrow(/finite number/);
|
||||
expect(
|
||||
decodeState(payload({ ...state, project: { ...state.project, boardCount: 500 } })).state,
|
||||
).toEqual(state);
|
||||
for (const invalid of ['1200', null, true, {}, []]) {
|
||||
expectInvalid(
|
||||
payload({
|
||||
...state,
|
||||
project: { ...state.project, flooring: { ...state.project.flooring, length: invalid } },
|
||||
}),
|
||||
/finite number/,
|
||||
);
|
||||
}
|
||||
expectInvalid(payload({ ...state, version: 99 }), /version is unsupported/);
|
||||
expectInvalid(
|
||||
payload({ ...state, view: { ...state.view, selectedPiece: 'F1' } }),
|
||||
/material and an identifier/,
|
||||
);
|
||||
expectInvalid(
|
||||
payload({
|
||||
...state,
|
||||
view: { ...state.view, focus: { kind: 'mystery', material: 'flooring', id: 'F1' } },
|
||||
}),
|
||||
);
|
||||
expectInvalid('?plan=%7Bbroken');
|
||||
expectInvalid('?plan={}&plan={}');
|
||||
});
|
||||
|
||||
it.each(['?flooring.pattern=diagonal', '?flooring.optimisation=quick', '?room.1.direction=45'])(
|
||||
'rejects an invalid current enum in %s',
|
||||
(query) => expectInvalid(query),
|
||||
);
|
||||
|
||||
it('uses individually editable fields and canonical parameter order', () => {
|
||||
it('rejects duplicate identities, excessive collections and long text at import', () => {
|
||||
const state = defaultState();
|
||||
const params = new URLSearchParams(encodeState(state));
|
||||
params.set('flooring.length', '1400');
|
||||
params.set('flooring.maxOffset', '700');
|
||||
params.set('room.1.wall.1.length', '5123.5');
|
||||
params.set('room.1.wall.4.door.1.width', '900');
|
||||
params.set('skirting.length', '3600');
|
||||
params.set('selectedCut', 'F12');
|
||||
const result = decodeState(`?${params}`);
|
||||
for (const rooms of [
|
||||
[],
|
||||
[rectangle(), rectangle()],
|
||||
Array.from({ length: PROJECT_LIMITS.rooms + 1 }, (_, i) => rectangle(`room-${i}`)),
|
||||
])
|
||||
expectInvalid(payload({ ...state, project: { ...state.project, rooms } }));
|
||||
const room = rectangle();
|
||||
room.walls = Array.from({ length: PROJECT_LIMITS.wallsPerRoom + 1 }, (_, i) => ({
|
||||
id: `w${i}`,
|
||||
length: 1,
|
||||
turn: 90,
|
||||
doors: [],
|
||||
}));
|
||||
expectInvalid(payload({ ...state, project: { ...state.project, rooms: [room] } }));
|
||||
const longName = rectangle();
|
||||
longName.name = 'a'.repeat(PROJECT_LIMITS.nameLength + 1);
|
||||
expectInvalid(payload({ ...state, project: { ...state.project, rooms: [longName] } }));
|
||||
const doors = rectangle();
|
||||
doors.walls[0].doors = Array.from({ length: PROJECT_LIMITS.doorsPerWall + 1 }, (_, i) => ({
|
||||
id: `d${i}`,
|
||||
offset: 0,
|
||||
width: 1,
|
||||
}));
|
||||
expectInvalid(payload({ ...state, project: { ...state.project, rooms: [doors] } }));
|
||||
});
|
||||
it('bounds progress, search and link size', () => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = Array.from(
|
||||
{ length: PERSISTENCE_LIMITS.completedBoards + 1 },
|
||||
(_, i) => `floor:B${i + 1}`,
|
||||
);
|
||||
expectInvalid(payload(state));
|
||||
expectInvalid(
|
||||
payload({ ...defaultState(), view: { ...defaultState().view, search: 'a'.repeat(201) } }),
|
||||
);
|
||||
expectInvalid(`?plan=${'x'.repeat(PERSISTENCE_LIMITS.queryLength)}`, /link is too large/);
|
||||
expect(() =>
|
||||
encodeState({
|
||||
...defaultState(),
|
||||
view: { ...defaultState().view, search: 'x'.repeat(PERSISTENCE_LIMITS.queryLength) },
|
||||
}),
|
||||
).toThrow(/too large/);
|
||||
});
|
||||
it('falls back to the first room for stale navigation', () => {
|
||||
const state = defaultState();
|
||||
state.view.roomId = 'deleted';
|
||||
expect(decodeState(payload(state)).state.view.roomId).toBe('room-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('version 2 link compatibility', () => {
|
||||
it('imports measurements, defaults, progress, and explicit locations', () => {
|
||||
const result = decodeState(
|
||||
'?version=2&flooring.length=1400&flooring.pattern=random&selectedCut=A2&view=skirting&search=skirting:S6&done=skirting:S6',
|
||||
);
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.state.project.flooring.length).toBe(1400);
|
||||
expect(result.state.project.rooms[0].walls[0].length).toBe(5123.5);
|
||||
expect(result.state.project.rooms[0].walls[3].doors[0].width).toBe(900);
|
||||
expect(result.state.project.skirting.stockLength).toBe(3600);
|
||||
expect(result.state.view.selectedPiece).toBe('F12');
|
||||
const reversed = new URLSearchParams([...params].reverse());
|
||||
expect(encodeState(decodeState(`?${reversed}`).state)).toBe(encodeState(result.state));
|
||||
expect(result.state.project.flooring.width).toBe(190);
|
||||
expect(result.state.project.completedBoards).toEqual(['skirting:S6']);
|
||||
expect(result.state.view.selectedPiece).toBe('skirting:A2');
|
||||
expect(result.state.view.focus).toEqual({ kind: 'board', material: 'skirting', id: 'S6' });
|
||||
expect(result.state.view.search).toBe('');
|
||||
expect(decodeState(encodeState(result.state))).toEqual(result);
|
||||
});
|
||||
|
||||
it('accepts a hand-written partial query with current defaults', () => {
|
||||
const result = decodeState('?flooring.length=1400&flooring.pattern=random&selectedCut=F2');
|
||||
const state = defaultState();
|
||||
state.project.flooring.length = 1400;
|
||||
state.project.flooring.pattern = 'random';
|
||||
state.view.selectedPiece = 'F2';
|
||||
expect(result).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('can describe an unfinished room without array counts or generated plans', () => {
|
||||
it('imports an unfinished custom room', () => {
|
||||
const result = decodeState(
|
||||
'?room.1.name=Office&room.1.wall.1.length=4000&room.1.wall.1.turn=-90',
|
||||
);
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.state.project.rooms).toEqual([
|
||||
{
|
||||
id: 'room-1',
|
||||
name: 'Office',
|
||||
direction: 0,
|
||||
walls: [{ id: 'room-1-w1', length: 4000, turn: -90, doors: [] }],
|
||||
},
|
||||
]);
|
||||
const encoded = encodeState(result.state);
|
||||
expect(encoded).not.toMatch(/count|polygon|stocks|pieces|JSON|plan=/);
|
||||
expect(new URLSearchParams(encoded).has('room.1.wall.2.length')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects duplicate fields and the retired stock-list field', () => {
|
||||
expectInvalid('?flooring.length=1200&flooring.length=1400', /only once/);
|
||||
expectInvalid('?selectedRoom=one&selectedRoom=two', /only once/);
|
||||
expectInvalid('?room.1.name=Office&room.1.name=Kitchen', /only once/);
|
||||
expectInvalid('?skirting.length=2400&skirting.length=3600', /only once/);
|
||||
expectInvalid('?skirting.stock=2400', /retired skirting stock-list/);
|
||||
});
|
||||
|
||||
it('rejects sparse, zero, malformed and excessive indices', () => {
|
||||
expectInvalid('?room.2.id=second', /consecutive/);
|
||||
expectInvalid('?room.0.id=zero', /positive whole numbers/);
|
||||
expectInvalid('?room.one.id=one', /positive whole numbers/);
|
||||
expectInvalid(`?room.${PROJECT_LIMITS.rooms + 1}.id=excess`, /at most 20/);
|
||||
expectInvalid(`?room.1.wall.${PROJECT_LIMITS.wallsPerRoom + 1}.length=4000`, /at most 100/);
|
||||
expectInvalid(
|
||||
`?room.1.wall.1.length=4000&room.1.wall.1.door.${PROJECT_LIMITS.doorsPerWall + 1}.width=500`,
|
||||
/at most 30/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects impossible collections and oversized strings before encoding', () => {
|
||||
const empty = defaultState();
|
||||
empty.project.rooms = [];
|
||||
expect(() => encodeState(empty)).toThrow(/at least one room/);
|
||||
|
||||
const duplicate = defaultState();
|
||||
duplicate.project.rooms.push(rectangle());
|
||||
expect(() => encodeState(duplicate)).toThrow(/duplicate identifiers/);
|
||||
|
||||
const rooms = defaultState();
|
||||
rooms.project.rooms = Array.from({ length: PROJECT_LIMITS.rooms + 1 }, (_, index) =>
|
||||
rectangle(`room-${index}`),
|
||||
);
|
||||
expect(() => encodeState(rooms)).toThrow(/at most 20/);
|
||||
|
||||
const walls = defaultState();
|
||||
walls.project.rooms[0].walls = Array.from(
|
||||
{ length: PROJECT_LIMITS.wallsPerRoom + 1 },
|
||||
(_, index) => ({ id: `wall-${index}`, length: 1, turn: 90, doors: [] }),
|
||||
);
|
||||
expect(() => encodeState(walls)).toThrow(/at most 100/);
|
||||
|
||||
const doors = defaultState();
|
||||
doors.project.rooms[0].walls[0].doors = Array.from(
|
||||
{ length: PROJECT_LIMITS.doorsPerWall + 1 },
|
||||
(_, index) => ({ id: `door-${index}`, offset: index * 10, width: 5 }),
|
||||
);
|
||||
expect(() => encodeState(doors)).toThrow(/at most 30/);
|
||||
|
||||
const name = defaultState();
|
||||
name.project.name = 'a'.repeat(PROJECT_LIMITS.nameLength + 1);
|
||||
expect(() => encodeState(name)).toThrow(/at most 200 characters/);
|
||||
});
|
||||
|
||||
it('bounds progress, search and total query length', () => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = Array.from(
|
||||
{ length: PERSISTENCE_LIMITS.completedBoards + 1 },
|
||||
(_, index) => `floor:B${index + 1}`,
|
||||
);
|
||||
expect(() => encodeState(state)).toThrow(/at most 12000/);
|
||||
expectInvalid(
|
||||
`?search=${'a'.repeat(PERSISTENCE_LIMITS.searchLength + 1)}`,
|
||||
/at most 200 characters/,
|
||||
);
|
||||
expectInvalid(`?name=${'x'.repeat(PERSISTENCE_LIMITS.queryLength)}`, /link is too large/);
|
||||
});
|
||||
|
||||
it('rejects damaged percent encoding', () => {
|
||||
expectInvalid('?name=%', /invalid URL encoding/);
|
||||
expectInvalid('?name=%C0%AF', /invalid URL encoding/);
|
||||
expect(result.state.project.rooms[0]).toEqual({
|
||||
id: 'room-1',
|
||||
name: 'Office',
|
||||
direction: 0,
|
||||
walls: [{ id: 'room-1-w1', length: 4000, turn: -90, doors: [] }],
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
'?flooring.length=NaN',
|
||||
'?flooring.length=Infinity',
|
||||
'?flooring.length=',
|
||||
'?flooring.length=0x10',
|
||||
'?flooring.pattern=diagonal',
|
||||
'?flooring.optimisation=quick',
|
||||
'?version=1',
|
||||
'?version=3',
|
||||
'?room.2.id=second',
|
||||
'?room.0.id=zero',
|
||||
'?room.one.id=one',
|
||||
'?room.21.id=excess',
|
||||
'?flooring.length=1200&flooring.length=1400',
|
||||
'?skirting.stock=2400',
|
||||
'?name=%',
|
||||
'?name=%C0%AF',
|
||||
'#plan=old',
|
||||
])('rejects damaged legacy input %s', (link) => expectInvalid(link));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue