.
This commit is contained in:
parent
d8f2407ddf
commit
9689bfcc18
23 changed files with 1815 additions and 955 deletions
|
|
@ -1,10 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultProject } from '../src/lib/defaults';
|
||||
import { flooringCuts, groupFlooringCuts } from '../src/lib/cut-list';
|
||||
import { defaultProject, rectangle } from '../src/lib/defaults';
|
||||
import {
|
||||
flooringCuts,
|
||||
groupFlooringCuts,
|
||||
groupSkirtingCuts,
|
||||
skirtingCuts,
|
||||
} from '../src/lib/cut-list';
|
||||
import { planFlooring } from '../src/lib/flooring';
|
||||
import { planSkirting } from '../src/lib/skirting';
|
||||
import type { FlooringPiece, FlooringPlan } from '../src/lib/types';
|
||||
|
||||
function piece(id: string, changes: Partial<FlooringPiece> = {}): FlooringPiece {
|
||||
function piece(id: string, stockId: string, changes: Partial<FlooringPiece> = {}): FlooringPiece {
|
||||
return {
|
||||
id,
|
||||
roomId: 'room-1',
|
||||
|
|
@ -13,14 +19,11 @@ function piece(id: string, changes: Partial<FlooringPiece> = {}): FlooringPiece
|
|||
polygon: [],
|
||||
length: 600,
|
||||
width: 190,
|
||||
stockId: 'B1',
|
||||
stockId,
|
||||
reused: false,
|
||||
cut: true,
|
||||
shaped: false,
|
||||
template: [],
|
||||
factoryLeft: true,
|
||||
factoryRight: false,
|
||||
ripEdge: null,
|
||||
...changes,
|
||||
};
|
||||
}
|
||||
|
|
@ -28,8 +31,12 @@ function plan(pieces: FlooringPiece[]): FlooringPlan {
|
|||
return {
|
||||
pieces,
|
||||
rows: [],
|
||||
stocks: [],
|
||||
boardCount: 0,
|
||||
stocks: ['B1', 'B2', 'B3'].map((id) => ({
|
||||
id,
|
||||
pieceIds: pieces.filter((p) => p.stockId === id).map((p) => p.id),
|
||||
remaining: 0,
|
||||
})),
|
||||
boardCount: 3,
|
||||
cuts: 0,
|
||||
wasteArea: 0,
|
||||
wastePercent: 0,
|
||||
|
|
@ -39,81 +46,54 @@ function plan(pieces: FlooringPiece[]): FlooringPlan {
|
|||
};
|
||||
}
|
||||
|
||||
describe('flooring cut batches', () => {
|
||||
it('combines matching cuts across rows and stocks, preserving every placement exactly once', () => {
|
||||
const pieces = [
|
||||
piece('F1'),
|
||||
piece('F2', { row: 4, stockId: 'B4', reused: true }),
|
||||
piece('F3', { cut: false, length: 1200 }),
|
||||
piece('F4', { cut: false, reused: true }),
|
||||
piece('F5', { roomId: 'room-2' }),
|
||||
];
|
||||
const source = plan(pieces),
|
||||
before = structuredClone(source);
|
||||
describe('cuts grouped by purchased board', () => {
|
||||
it('keeps different cuts and cross-room offcuts together, separating identical cuts from different boards', () => {
|
||||
const source = plan([
|
||||
piece('F1', 'B2'),
|
||||
piece('F2', 'B1'),
|
||||
piece('F3', 'B2', { roomId: 'room-2', row: 4, length: 397, reused: true }),
|
||||
piece('FU1', 'B3', { length: 1200, cut: false }),
|
||||
]);
|
||||
const before = structuredClone(source);
|
||||
const groups = groupFlooringCuts(source);
|
||||
expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F1', 'F2'], ['F4'], ['F5']]);
|
||||
expect(
|
||||
groups
|
||||
.flat()
|
||||
.map((p) => p.id)
|
||||
.sort(),
|
||||
).toEqual(
|
||||
flooringCuts(source)
|
||||
.map((p) => p.id)
|
||||
.sort(),
|
||||
);
|
||||
expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F2'], ['F1', 'F3']]);
|
||||
expect(source).toEqual(before);
|
||||
expect(groupFlooringCuts(source)).toEqual(groups);
|
||||
});
|
||||
|
||||
it('keeps different factory ends, rip edges and thousandth-millimetre dimensions separate', () => {
|
||||
it('includes ready-to-use offcuts in their original board group', () => {
|
||||
const groups = groupFlooringCuts(
|
||||
plan([
|
||||
piece('F1'),
|
||||
piece('F2', { factoryLeft: false, factoryRight: true }),
|
||||
piece('F3', { ripEdge: 'top' }),
|
||||
piece('F4', { ripEdge: 'bottom' }),
|
||||
piece('F5', { length: 600.001 }),
|
||||
piece('F6', { width: 190.001 }),
|
||||
]),
|
||||
plan([piece('F1', 'B1'), piece('F2', 'B1', { cut: false, reused: true })]),
|
||||
);
|
||||
expect(groups).toHaveLength(6);
|
||||
expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F1', 'F2']]);
|
||||
});
|
||||
|
||||
it('groups shapes only when their complete measured templates match', () => {
|
||||
const template = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 600, y: 0 },
|
||||
{ x: 600, y: 190 },
|
||||
{ x: 0, y: 100 },
|
||||
];
|
||||
const groups = groupFlooringCuts(
|
||||
plan([
|
||||
piece('F1', { shaped: true, template }),
|
||||
piece('F2', { shaped: true, template: structuredClone(template) }),
|
||||
piece('F3', { shaped: true, template: [...template.slice(0, 3), { x: 0, y: 100.001 }] }),
|
||||
piece('F4'),
|
||||
]),
|
||||
);
|
||||
expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F1', 'F2'], ['F3'], ['F4']]);
|
||||
});
|
||||
|
||||
it('reduces the default room to a few batches while keeping all cut and offcut codes', () => {
|
||||
it('includes every flooring and skirting cut once in stock order across multiple rooms', () => {
|
||||
const project = defaultProject();
|
||||
project.rooms.push(rectangle('room-2', 'Study', 3200, 3100));
|
||||
const floor = planFlooring(project.rooms, project.flooring, project.tolerance);
|
||||
const groups = groupFlooringCuts(floor);
|
||||
expect(groups.length).toBeLessThanOrEqual(7);
|
||||
expect(groups.length).toBeLessThan(flooringCuts(floor).length / 2);
|
||||
const skirt = planSkirting(project.rooms, project.skirting, project.tolerance);
|
||||
for (const [groups, pieces, stocks] of [
|
||||
[groupFlooringCuts(floor), flooringCuts(floor), floor.stocks],
|
||||
[groupSkirtingCuts(skirt), skirtingCuts(skirt), skirt.stocks],
|
||||
] as const) {
|
||||
expect(
|
||||
groups
|
||||
.flat()
|
||||
.map((p) => p.id)
|
||||
.sort(),
|
||||
).toEqual(pieces.map((p) => p.id).sort());
|
||||
expect(new Set(groups.flat().map((p) => p.id)).size).toBe(pieces.length);
|
||||
const used = new Set(pieces.map((p) => p.stockId));
|
||||
expect(groups.map((group) => group[0].stockId)).toEqual(
|
||||
stocks.filter((s) => used.has(s.id)).map((s) => s.id),
|
||||
);
|
||||
for (const group of groups) expect(new Set(group.map((p) => p.stockId)).size).toBe(1);
|
||||
}
|
||||
expect(
|
||||
groups
|
||||
.flat()
|
||||
.map((p) => p.id)
|
||||
.sort(),
|
||||
).toEqual(
|
||||
flooringCuts(floor)
|
||||
.map((p) => p.id)
|
||||
.sort(),
|
||||
);
|
||||
expect(new Set(groups.flat().map((p) => p.id)).size).toBe(flooringCuts(floor).length);
|
||||
[...groupFlooringCuts(floor), ...groupSkirtingCuts(skirt)].some(
|
||||
(group) => new Set(group.map((p) => p.roomId)).size > 1,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import { createElement } from 'react';
|
|||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview';
|
||||
import { exactMm, flooringTemplate, skirtingEndBoxes } from '../src/lib/cut-preview';
|
||||
import { CutDialog } from '../src/components/CutDialog';
|
||||
import { defaultProject } from '../src/lib/defaults';
|
||||
import {
|
||||
dimensionedOutline,
|
||||
exactMm,
|
||||
flooringTemplate,
|
||||
skirtingEndBoxes,
|
||||
} from '../src/lib/cut-preview';
|
||||
import type { FlooringPiece, Point, SkirtingPiece } from '../src/lib/types';
|
||||
|
||||
const floor: FlooringPiece = {
|
||||
|
|
@ -54,7 +61,10 @@ describe('cut shape previews', () => {
|
|||
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece: floor }));
|
||||
expect(markup).toContain('aria-label="Flooring shape F1"');
|
||||
expect(markup).toContain('preserveAspectRatio="xMidYMid meet"');
|
||||
expect(markup).toContain('Length 600.001 mm × width 190 mm');
|
||||
expect(markup).toContain('600.001 × 190 mm');
|
||||
expect(markup).toContain('>600.001</text>');
|
||||
expect(markup).toContain('>190</text>');
|
||||
expect(markup.match(/class="dimension-label"/g)).toHaveLength(2);
|
||||
expect(exactMm(600.001)).toBe('600.001');
|
||||
});
|
||||
|
||||
|
|
@ -81,10 +91,133 @@ describe('cut shape previews', () => {
|
|||
);
|
||||
expect(markup).toContain('points="18,0 1182,0 1200,18 0,18"');
|
||||
expect(markup.match(/preserveAspectRatio="xMidYMid meet"/g)).toHaveLength(3);
|
||||
expect(markup).toContain('Full length 1,200 mm × thickness 18 mm');
|
||||
expect(markup).toContain('1,200 × 18 mm');
|
||||
expect(markup).toContain('>1,164</text>');
|
||||
expect(markup).toContain('>1,200</text>');
|
||||
expect(markup).not.toContain('exaggerat');
|
||||
});
|
||||
|
||||
it('labels every edge of a 320 by 100 mm notch without collisions or tiny mobile text', () => {
|
||||
const notch = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 880, y: 0 },
|
||||
{ x: 880, y: 100 },
|
||||
{ x: 1200, y: 100 },
|
||||
{ x: 1200, y: 190 },
|
||||
{ x: 0, y: 190 },
|
||||
];
|
||||
const original = structuredClone(notch);
|
||||
const preview = dimensionedOutline(notch);
|
||||
expect(preview.dimensions.map((dimension) => dimension.length)).toEqual([
|
||||
880, 100, 320, 90, 1200, 190,
|
||||
]);
|
||||
expect((preview.font * 280) / preview.box.width).toBeGreaterThanOrEqual(12);
|
||||
preview.dimensions.forEach((dimension, i) => {
|
||||
const box = dimension.labelBox;
|
||||
expect(box.x).toBeGreaterThan(preview.box.x);
|
||||
expect(box.y).toBeGreaterThan(preview.box.y);
|
||||
expect(box.x + box.width).toBeLessThan(preview.box.x + preview.box.width);
|
||||
expect(box.y + box.height).toBeLessThan(preview.box.y + preview.box.height);
|
||||
const projection = (point: Point) =>
|
||||
point.x * dimension.normal.x + point.y * dimension.normal.y;
|
||||
const nearestLabel = Math.min(
|
||||
...[
|
||||
{ x: box.x, y: box.y },
|
||||
{ x: box.x + box.width, y: box.y },
|
||||
{ x: box.x, y: box.y + box.height },
|
||||
{ x: box.x + box.width, y: box.y + box.height },
|
||||
].map(projection),
|
||||
);
|
||||
expect(nearestLabel, 'Dimension text stays outside the shape').toBeGreaterThan(
|
||||
Math.max(...notch.map(projection)),
|
||||
);
|
||||
for (const other of preview.dimensions.slice(i + 1)) {
|
||||
const b = other.labelBox;
|
||||
expect(
|
||||
box.x + box.width <= b.x ||
|
||||
b.x + b.width <= box.x ||
|
||||
box.y + box.height <= b.y ||
|
||||
b.y + b.height <= box.y,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
expect(notch).toEqual(original);
|
||||
expect(dimensionedOutline(notch)).toEqual(preview);
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(FlooringPreview, {
|
||||
piece: { ...floor, length: 1200, shaped: true, template: notch },
|
||||
}),
|
||||
);
|
||||
expect(markup).toContain('>320</text>');
|
||||
expect(markup).toContain('>100</text>');
|
||||
expect(markup).not.toMatch(/>[1-6]<\/text>/);
|
||||
});
|
||||
|
||||
it('measures angled edges along the cut and retains the original outline', () => {
|
||||
const points = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 800, y: 0 },
|
||||
{ x: 920, y: 160 },
|
||||
{ x: 0, y: 160 },
|
||||
];
|
||||
const preview = dimensionedOutline(points);
|
||||
expect((preview.font * 280) / preview.box.width).toBeGreaterThanOrEqual(12);
|
||||
expect(preview.dimensions[1].length).toBe(200);
|
||||
expect(preview.dimensions[1].text).toBe('200');
|
||||
const angle = preview.dimensions[1].angle!;
|
||||
expect(angle).toBeCloseTo(53.130102, 6);
|
||||
const markedAngle = (Number(exactMm(angle)) * Math.PI) / 180;
|
||||
expect(200 * Math.cos(markedAngle)).toBeCloseTo(120, 2);
|
||||
expect(200 * Math.sin(markedAngle)).toBeCloseTo(160, 2);
|
||||
expect(preview.dimensions[1].start).toEqual({ x: 800, y: 0 });
|
||||
expect(preview.dimensions[1].end).toEqual({ x: 920, y: 160 });
|
||||
const reversed = dimensionedOutline([...points].reverse());
|
||||
expect(reversed.dimensions.map((dimension) => dimension.length).sort()).toEqual(
|
||||
preview.dimensions.map((dimension) => dimension.length).sort(),
|
||||
);
|
||||
expect(preview.dimensions.filter((dimension) => dimension.angle !== undefined)).toHaveLength(1);
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(FlooringPreview, {
|
||||
piece: { ...floor, length: 920, width: 160, shaped: true, template: points },
|
||||
}),
|
||||
);
|
||||
expect(markup).toContain('>53.13°</tspan>');
|
||||
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('45° external');
|
||||
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 },
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ const skirt: SkirtingPlan = {
|
|||
};
|
||||
|
||||
describe('cut list CSV', () => {
|
||||
it('exports measured cuts, shape coordinates, mitres, allocations and purchase counts', () => {
|
||||
it('exports measured cuts, shape coordinates, mitres, board references and purchase counts', () => {
|
||||
const csv = cutListCsv(defaultProject(), floor, skirt);
|
||||
expect(csv.startsWith('\uFEFF"Material"')).toBe(true);
|
||||
expect(csv).toContain('"Flooring","Living room","F1","B1","450","190"');
|
||||
|
|
@ -70,11 +70,9 @@ describe('cut list CSV', () => {
|
|||
expect(csv).toContain('0.0:0.0 | 450.0:0.0 | 450.0:190.0 | 0.0:100.0');
|
||||
expect(csv).toContain('"Skirting","Living room","A1","S1","1518","18","0","45"');
|
||||
expect(csv).toContain('Back length 1500.0 mm; left square; right external');
|
||||
expect(csv).toContain('Remaining length 747.0 mm');
|
||||
expect(csv).toContain('Waste including kerf 879.0 mm');
|
||||
expect(csv).toContain('Boards to buy; 1 installed pieces; 2 saw cuts');
|
||||
expect(csv).not.toContain('Check the shaped edge.');
|
||||
expect(csv.split('\r\n')).toHaveLength(8);
|
||||
expect(csv.split('\r\n')).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('counts uncut full boards and lengths without exporting their codes or source allocations', () => {
|
||||
|
|
@ -134,12 +132,12 @@ describe('cut list CSV', () => {
|
|||
expect(csv).toContain('"Skirting total","","","","","18","","","2"');
|
||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring",'))).toHaveLength(2);
|
||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting",'))).toHaveLength(1);
|
||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring stock",'))).toHaveLength(1);
|
||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting stock",'))).toHaveLength(1);
|
||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring stock",'))).toHaveLength(0);
|
||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting stock",'))).toHaveLength(0);
|
||||
expect(flooringCuts(floorPlan)).toEqual([...floor.pieces, readyOffcut]);
|
||||
expect(csv).toContain('"Flooring","Living room","READY-F","B1","747","190"');
|
||||
expect(csv).toContain('Reused offcut; Use offcut; no new cut');
|
||||
expect(csv).toContain('"Flooring stock","","F1 / READY-F","B1"');
|
||||
expect(csv).not.toContain('"Flooring stock"');
|
||||
expect(uncutFlooring(floorPlan, project.flooring)).toEqual({ wholeBoards: 1, readyOffcuts: 1 });
|
||||
expect(uncutSkirting(skirtPlan)).toEqual([{ length: 2400, count: 1 }]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -215,6 +215,78 @@ describe('laminate planning', () => {
|
|||
expectCoverage(a, room(), s.gap);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['rectangle', room(4800, 3600, 'room-1'), 76, 42],
|
||||
['fireplace', fireplace('room-2', 'Fireplace room'), 74, 48],
|
||||
] as const)(
|
||||
'breaks random staircases and near-brick repeats in a common %s',
|
||||
(_, r, boardBudget, cutBudget) => {
|
||||
const s = { ...settings, width: 190, pattern: 'random' as const };
|
||||
const plan = planFlooring([r], s, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan).toEqual(planFlooring([r], s, 1));
|
||||
const phases = plan.rows.map((row) => row.offset);
|
||||
const signedDistance = (a: number, b: number) => {
|
||||
let difference = b - a;
|
||||
if (difference >= s.length / 2) difference -= s.length;
|
||||
if (difference < -s.length / 2) difference += s.length;
|
||||
return difference;
|
||||
};
|
||||
const steps = phases.slice(1).map((phase, i) => signedDistance(phases[i], phase));
|
||||
// This case actually crosses the board boundary, so checking raw differences would miss steps.
|
||||
expect(
|
||||
phases.some((phase, i) => i > 0 && Math.abs(phase - phases[i - 1]) > s.length / 2),
|
||||
).toBe(true);
|
||||
for (let i = 1; i < steps.length; i++) {
|
||||
// Similar signed steps across three rows produce a visible staircase, even with small kerf drift.
|
||||
expect(Math.abs(steps[i] - steps[i - 1])).toBeGreaterThan(36);
|
||||
// Returning to almost the same joint line two rows later produces an H/brick repeat.
|
||||
expect(Math.abs(signedDistance(phases[i - 1], phases[i + 1]))).toBeGreaterThan(36);
|
||||
}
|
||||
for (let i = 2; i < steps.length; i++) {
|
||||
expect(
|
||||
steps.slice(i - 2, i + 1).every((step) => step > 0) ||
|
||||
steps.slice(i - 2, i + 1).every((step) => step < 0),
|
||||
).toBe(false);
|
||||
}
|
||||
// Breaking the old regular pattern must not increase the stock requirement for these rooms.
|
||||
expect(plan.boardCount).toBeLessThanOrEqual(boardBudget);
|
||||
expect(plan.cuts).toBeLessThanOrEqual(cutBudget);
|
||||
expect(plan.reusedCount).toBeGreaterThan(0);
|
||||
expect(
|
||||
plan.pieces.every((piece) => piece.length >= s.minLength && piece.width >= s.minWidth),
|
||||
).toBe(true);
|
||||
expectOffsets(plan, s);
|
||||
expectStockFits(plan, s);
|
||||
expectCoverage(plan, r, s.gap);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a constrained random layout valid when only alternating starts can fit', () => {
|
||||
const r = room(2420, 1620);
|
||||
const s = { ...settings, pattern: 'random' as const, minLength: 600 };
|
||||
const plan = planFlooring([r], s, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan.rows).toHaveLength(8);
|
||||
expect(new Set(plan.rows.map((row) => row.offset))).toEqual(new Set([0, 600]));
|
||||
expect(plan.rows[0].offset).toBe(plan.rows[2].offset);
|
||||
expectOffsets(plan, s);
|
||||
expectStockFits(plan, s);
|
||||
expectCoverage(plan, r, s.gap);
|
||||
});
|
||||
|
||||
it.each([400, 600])(
|
||||
'respects an exact %i mm random offset even when repetition is unavoidable',
|
||||
(offset) => {
|
||||
const s = { ...settings, pattern: 'random' as const, minOffset: offset, maxOffset: offset };
|
||||
const plan = planFlooring([room()], s, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan).toEqual(planFlooring([room()], s, 1));
|
||||
expectOffsets(plan, s);
|
||||
expectStockFits(plan, s);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects impossible brick stagger constraints', () => {
|
||||
const plan = planFlooring([room()], { ...settings, maxOffset: 400 }, 1);
|
||||
expect(plan.boardCount).toBe(0);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ function expectInvalid(hash: string, message?: RegExp) {
|
|||
|
||||
describe('URL state', () => {
|
||||
it('starts with independent defaults when no link is provided', () => {
|
||||
for (const hash of ['', '#']) {
|
||||
for (const hash of ['', '#', '?']) {
|
||||
const first = decodeState(hash);
|
||||
expect(first).toEqual({ state: defaultState(), error: null });
|
||||
first.state.project.rooms[0].name = 'Edited';
|
||||
|
|
@ -26,7 +26,12 @@ describe('URL state', () => {
|
|||
it('round-trips all default inputs exactly and produces the same link every time', () => {
|
||||
const state = defaultState();
|
||||
const hash = encodeState(state);
|
||||
expect(hash).toMatch(/^#plan=[A-Za-z0-9+$-]+$/);
|
||||
expect(hash).toMatch(/^\?version=1&name=/);
|
||||
const params = new URLSearchParams(hash);
|
||||
expect(params.get('flooring.length')).toBe('1200');
|
||||
expect(params.get('room.1.wall.1.length')).toBe('4800');
|
||||
expect(params.get('room.1.wall.4.door.1.width')).toBe('826');
|
||||
expect(hash).not.toContain('#plan=');
|
||||
expect(decodeState(hash)).toEqual({ state, error: null });
|
||||
expect(encodeState(decodeState(hash).state)).toBe(hash);
|
||||
expect(encodeState(structuredClone(state))).toBe(hash);
|
||||
|
|
@ -101,8 +106,8 @@ describe('URL state', () => {
|
|||
expect(result.state.view.roomId).toBe(state.project.rooms[0].id);
|
||||
});
|
||||
|
||||
it('accepts URL-escaped compression characters', () => {
|
||||
const hash = encodeState(defaultState());
|
||||
it('accepts URL-escaped legacy compression characters', () => {
|
||||
const hash = rawLink(defaultState());
|
||||
expect(decodeState(`#plan=${encodeURIComponent(hash.slice(6))}`).state).toEqual(defaultState());
|
||||
});
|
||||
|
||||
|
|
@ -111,8 +116,8 @@ describe('URL state', () => {
|
|||
(hash) => expectInvalid(hash),
|
||||
);
|
||||
|
||||
it('rejects truncated and appended payloads even when the decoder ignores trailing data', () => {
|
||||
const hash = encodeState(defaultState());
|
||||
it('rejects truncated and appended legacy payloads even when the decoder ignores trailing data', () => {
|
||||
const hash = rawLink(defaultState());
|
||||
expectInvalid(hash.slice(0, -4));
|
||||
expectInvalid(`${hash}AAAA`);
|
||||
});
|
||||
|
|
@ -226,6 +231,129 @@ describe('URL state', () => {
|
|||
expectInvalid(rawLink(largeName), /at most 200 characters/);
|
||||
});
|
||||
|
||||
it('uses individually editable fields and canonicalizes query parameter order', () => {
|
||||
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.append('skirting.stock', '3600');
|
||||
params.set('selectedCut', 'F12');
|
||||
const result = decodeState(`?${params}`);
|
||||
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.stockLengths).toEqual([2400, 3600]);
|
||||
expect(result.state.view.selectedPiece).toBe('F12');
|
||||
const reversed = new URLSearchParams([...params].reverse());
|
||||
// Repeated stock values retain their order; every other key can move freely.
|
||||
reversed.delete('skirting.stock');
|
||||
params.getAll('skirting.stock').forEach((length) => reversed.append('skirting.stock', length));
|
||||
expect(encodeState(decodeState(`?${reversed}`).state)).toBe(encodeState(result.state));
|
||||
});
|
||||
|
||||
it('accepts a small hand-written query and uses defaults for omitted material fields', () => {
|
||||
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 storing array counts or generated plans', () => {
|
||||
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('preserves explicitly empty stocks and rooms while omitting an unselected cut', () => {
|
||||
const state = defaultState();
|
||||
state.project.skirting.stockLengths = [];
|
||||
state.project.rooms[0].walls = [];
|
||||
const query = encodeState(state);
|
||||
expect(query).toContain('skirting.stock=');
|
||||
expect(query).not.toContain('selectedCut=');
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'?flooring.length=NaN',
|
||||
'?flooring.length=Infinity',
|
||||
'?flooring.length=1e999',
|
||||
'?flooring.length=',
|
||||
'?flooring.length=true',
|
||||
'?flooring.length=0x10',
|
||||
'?flooring.pattern=diagonal',
|
||||
'?room.1.direction=45',
|
||||
'?version=2',
|
||||
'?room.1.wall.1.turn=90',
|
||||
'?room.1.wall.1.length=4000&room.1.wall.1.door.1.offset=300',
|
||||
'?skirting.stock=&skirting.stock=2400',
|
||||
'?name=%',
|
||||
'?name=%C0%AF',
|
||||
])('rejects an invalid editable query %s', (query) => expectInvalid(query));
|
||||
|
||||
it('rejects ambiguous duplicate single-value fields but permits multiple stock lengths', () => {
|
||||
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/);
|
||||
const result = decodeState('?skirting.stock=2400&skirting.stock=3600');
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.state.project.skirting.stockLengths).toEqual([2400, 3600]);
|
||||
});
|
||||
|
||||
it('rejects sparse, zero, malformed and excessive room, wall and door 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.21.id=excess', /at most 20/);
|
||||
expectInvalid('?room.1.wall.101.length=4000', /at most 100/);
|
||||
expectInvalid('?room.1.wall.1.length=4000&room.1.wall.1.door.31.width=500', /at most 30/);
|
||||
expectInvalid(`?${Array(31).fill('skirting.stock=2400').join('&')}`, /at most 30/);
|
||||
expectInvalid('?room.1.id=same&room.2.id=same', /duplicate identifiers/);
|
||||
});
|
||||
|
||||
it('discards unrecognized query fields instead of storing derived state', () => {
|
||||
const decoded = decodeState('?flooring.length=1400&boardCount=999&cache=old&room.8.cache=old');
|
||||
expect(decoded.error).toBeNull();
|
||||
const canonical = encodeState(decoded.state);
|
||||
expect(canonical).toContain('flooring.length=1400');
|
||||
expect(canonical).not.toMatch(/boardCount|cache/);
|
||||
expect(decoded.state.project.rooms).toEqual(defaultState().project.rooms);
|
||||
});
|
||||
|
||||
it('converts a legacy shared plan to readable fields without losing inputs or selections', () => {
|
||||
const state = defaultState();
|
||||
state.project.rooms.push(fireplace('second', 'Fireplace room'));
|
||||
state.project.skirting.stockLengths = [2400, 3600];
|
||||
state.view = { roomId: 'second', selectedPiece: 'B2' };
|
||||
const legacy = decodeState(rawLink(state));
|
||||
expect(legacy.error).toBeNull();
|
||||
const query = encodeState(legacy.state);
|
||||
expect(query).toContain('room.2.wall.4.turn=-90');
|
||||
expect(query).toContain('selectedRoom=second&selectedCut=B2');
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('rejects oversized query strings before parsing fields', () => {
|
||||
expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/);
|
||||
});
|
||||
|
||||
it('rejects oversized compressed and expanded payloads', () => {
|
||||
expectInvalid(`#plan=${'a'.repeat(URL_LIMITS.hashLength)}`, /link is too large/);
|
||||
expectInvalid(
|
||||
|
|
|
|||
Loading…
Reference in a new issue