feat: add play apps and layout tools
This commit is contained in:
56
tests/layoutDraw/layoutDrawHistory.test.ts
Normal file
56
tests/layoutDraw/layoutDrawHistory.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
commitLayoutDrawHistory,
|
||||
createLayoutDrawHistoryState,
|
||||
redoLayoutDrawHistory,
|
||||
undoLayoutDrawHistory,
|
||||
} from '../../src/features/layout/draw/layoutDrawHistory.ts';
|
||||
import type { LayoutDrawDocument } from '../../src/features/layout/draw/layoutDrawTypes.ts';
|
||||
|
||||
function createDocument(partial?: Partial<LayoutDrawDocument>): LayoutDrawDocument {
|
||||
return {
|
||||
backgroundMode: partial?.backgroundMode ?? 'grid',
|
||||
shapes:
|
||||
partial?.shapes ?? [
|
||||
{
|
||||
id: 'line-1',
|
||||
type: 'line',
|
||||
x1: 10,
|
||||
y1: 20,
|
||||
x2: 10,
|
||||
y2: 180,
|
||||
orientation: 'vertical',
|
||||
label: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('commits a new snapshot into undo history and clears redo history', () => {
|
||||
const initial = createLayoutDrawHistoryState(createDocument({ shapes: [] }));
|
||||
const committed = commitLayoutDrawHistory(initial, createDocument());
|
||||
|
||||
assert.equal(committed.past.length, 1);
|
||||
assert.equal(committed.future.length, 0);
|
||||
assert.equal(committed.present.shapes.length, 1);
|
||||
});
|
||||
|
||||
test('undo restores the previous snapshot and redo reapplies it', () => {
|
||||
const initial = createLayoutDrawHistoryState(createDocument({ shapes: [] }));
|
||||
const committed = commitLayoutDrawHistory(initial, createDocument());
|
||||
const undone = undoLayoutDrawHistory(committed);
|
||||
const redone = redoLayoutDrawHistory(undone);
|
||||
|
||||
assert.equal(undone.present.shapes.length, 0);
|
||||
assert.equal(redone.present.shapes.length, 1);
|
||||
});
|
||||
|
||||
test('does not create duplicate history entries for unchanged documents', () => {
|
||||
const initialDocument = createDocument();
|
||||
const initial = createLayoutDrawHistoryState(initialDocument);
|
||||
const committed = commitLayoutDrawHistory(initial, initialDocument);
|
||||
|
||||
assert.equal(committed, initial);
|
||||
assert.equal(committed.past.length, 0);
|
||||
});
|
||||
65
tests/layoutDraw/layoutDrawRegions.test.ts
Normal file
65
tests/layoutDraw/layoutDrawRegions.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { findRegionAtPoint, resolveDrawRegions } from '../../src/features/layout/draw/layoutDrawRegions.ts';
|
||||
import type { DrawShape } from '../../src/features/layout/draw/layoutDrawTypes.ts';
|
||||
|
||||
function createSplitShapes(): DrawShape[] {
|
||||
return [
|
||||
{
|
||||
id: 'line-vertical',
|
||||
type: 'line',
|
||||
x1: 100,
|
||||
y1: 0,
|
||||
x2: 100,
|
||||
y2: 200,
|
||||
orientation: 'vertical',
|
||||
label: '',
|
||||
},
|
||||
{
|
||||
id: 'line-horizontal',
|
||||
type: 'line',
|
||||
x1: 0,
|
||||
y1: 120,
|
||||
x2: 100,
|
||||
y2: 120,
|
||||
orientation: 'horizontal',
|
||||
label: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
test('resolves line-divided empty areas into separate selectable regions', () => {
|
||||
const regions = resolveDrawRegions(createSplitShapes(), 200, 200);
|
||||
|
||||
assert.equal(regions.length, 3);
|
||||
assert.equal(findRegionAtPoint(regions, 50, 60)?.key, regions[0]?.key);
|
||||
assert.equal(findRegionAtPoint(regions, 50, 160)?.key, regions[1]?.key);
|
||||
assert.equal(findRegionAtPoint(regions, 150, 80)?.key, regions[2]?.key);
|
||||
});
|
||||
|
||||
test('maps stored region labels and fill colors back onto the computed region', () => {
|
||||
const baseShapes = createSplitShapes();
|
||||
const baseRegions = resolveDrawRegions(baseShapes, 200, 200);
|
||||
const leftTopRegion = findRegionAtPoint(baseRegions, 50, 60);
|
||||
|
||||
assert.ok(leftTopRegion);
|
||||
|
||||
const regions = resolveDrawRegions(
|
||||
[
|
||||
...baseShapes,
|
||||
{
|
||||
id: 'region-1',
|
||||
type: 'region',
|
||||
regionKey: leftTopRegion.key,
|
||||
label: '거실',
|
||||
fillColor: '#bfdbfe',
|
||||
},
|
||||
],
|
||||
200,
|
||||
200,
|
||||
);
|
||||
const resolved = findRegionAtPoint(regions, 50, 60);
|
||||
|
||||
assert.equal(resolved?.label, '거실');
|
||||
assert.equal(resolved?.fillColor, '#bfdbfe');
|
||||
});
|
||||
45
tests/layoutDraw/layoutDrawSelectionUtils.test.ts
Normal file
45
tests/layoutDraw/layoutDrawSelectionUtils.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
clampSelectionRect,
|
||||
findShapesInSelection,
|
||||
rebaseShapesToComponentBlueprint,
|
||||
resolveGroupedShapeIds,
|
||||
} from '../../src/features/layout/draw/layoutDrawSelectionUtils.ts';
|
||||
import type { DrawableShape } from '../../src/features/layout/draw/layoutDrawTypes.ts';
|
||||
|
||||
test('expands grouped selection from any seed id', () => {
|
||||
const shapes: DrawableShape[] = [
|
||||
{ id: 'a', type: 'rect', x: 0, y: 0, width: 10, height: 10, label: '', groupId: 'g1' },
|
||||
{ id: 'b', type: 'rect', x: 20, y: 0, width: 10, height: 10, label: '', groupId: 'g1' },
|
||||
{ id: 'c', type: 'rect', x: 40, y: 0, width: 10, height: 10, label: '' },
|
||||
];
|
||||
|
||||
assert.deepEqual([...resolveGroupedShapeIds(shapes, ['a'])], ['a', 'b']);
|
||||
});
|
||||
|
||||
test('finds shapes intersecting drag selection bounds', () => {
|
||||
const shapes: DrawableShape[] = [
|
||||
{ id: 'a', type: 'rect', x: 10, y: 10, width: 50, height: 50, label: '' },
|
||||
{ id: 'b', type: 'line', x1: 80, y1: 20, x2: 140, y2: 20, orientation: 'horizontal', label: '' },
|
||||
{ id: 'c', type: 'rect', x: 200, y: 200, width: 20, height: 20, label: '' },
|
||||
];
|
||||
|
||||
const selection = clampSelectionRect(0, 0, 120, 80);
|
||||
assert.deepEqual(
|
||||
findShapesInSelection(shapes, selection).map((shape) => shape.id),
|
||||
['a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('rebases saved component shapes to local coordinates', () => {
|
||||
const shapes: DrawableShape[] = [
|
||||
{ id: 'a', type: 'rect', x: 100, y: 40, width: 30, height: 20, label: 'A' },
|
||||
{ id: 'b', type: 'line', x1: 120, y1: 60, x2: 180, y2: 60, orientation: 'horizontal', label: 'B' },
|
||||
];
|
||||
|
||||
assert.deepEqual(rebaseShapesToComponentBlueprint(shapes), [
|
||||
{ id: 'a', type: 'rect', x: 0, y: 0, width: 30, height: 20, label: 'A' },
|
||||
{ id: 'b', type: 'line', x1: 20, y1: 20, x2: 80, y2: 20, orientation: 'horizontal', label: 'B' },
|
||||
]);
|
||||
});
|
||||
86
tests/layoutDraw/layoutDrawShapeUtils.test.ts
Normal file
86
tests/layoutDraw/layoutDrawShapeUtils.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { duplicateShapeWithLabel } from '../../src/features/layout/draw/layoutDrawShapeUtils.ts';
|
||||
|
||||
test('duplicates a line with original label when no override is provided', () => {
|
||||
const duplicated = duplicateShapeWithLabel(
|
||||
{
|
||||
id: 'line-1',
|
||||
type: 'line',
|
||||
x1: 10,
|
||||
y1: 20,
|
||||
x2: 10,
|
||||
y2: 160,
|
||||
orientation: 'vertical',
|
||||
label: '기존',
|
||||
},
|
||||
'line-2',
|
||||
);
|
||||
|
||||
assert.deepEqual(duplicated, {
|
||||
id: 'line-2',
|
||||
type: 'line',
|
||||
x1: 34,
|
||||
y1: 44,
|
||||
x2: 34,
|
||||
y2: 184,
|
||||
orientation: 'vertical',
|
||||
label: '기존',
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicates a line with label override and offset', () => {
|
||||
const duplicated = duplicateShapeWithLabel(
|
||||
{
|
||||
id: 'line-1',
|
||||
type: 'line',
|
||||
x1: 10,
|
||||
y1: 20,
|
||||
x2: 10,
|
||||
y2: 160,
|
||||
orientation: 'vertical',
|
||||
label: '기존',
|
||||
},
|
||||
'line-2',
|
||||
'복사본',
|
||||
);
|
||||
|
||||
assert.deepEqual(duplicated, {
|
||||
id: 'line-2',
|
||||
type: 'line',
|
||||
x1: 34,
|
||||
y1: 44,
|
||||
x2: 34,
|
||||
y2: 184,
|
||||
orientation: 'vertical',
|
||||
label: '복사본',
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicates a rect with label override and offset', () => {
|
||||
const duplicated = duplicateShapeWithLabel(
|
||||
{
|
||||
id: 'rect-1',
|
||||
type: 'rect',
|
||||
x: 40,
|
||||
y: 50,
|
||||
width: 120,
|
||||
height: 80,
|
||||
label: '사각형',
|
||||
fillColor: '#bfdbfe',
|
||||
},
|
||||
'rect-2',
|
||||
'새 라벨',
|
||||
);
|
||||
|
||||
assert.deepEqual(duplicated, {
|
||||
id: 'rect-2',
|
||||
type: 'rect',
|
||||
x: 64,
|
||||
y: 74,
|
||||
width: 120,
|
||||
height: 80,
|
||||
label: '새 라벨',
|
||||
fillColor: '#bfdbfe',
|
||||
});
|
||||
});
|
||||
46
tests/layoutDraw/layoutDrawStorage.test.ts
Normal file
46
tests/layoutDraw/layoutDrawStorage.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
normalizeSavedLayoutDrawShapes,
|
||||
serializeSavedLayoutDrawShapes,
|
||||
} from '../../src/features/layout/draw/layoutDrawStorageShapes.ts';
|
||||
import type { DrawShape } from '../../src/features/layout/draw/layoutDrawTypes.ts';
|
||||
|
||||
function createShapes(): DrawShape[] {
|
||||
return [
|
||||
{
|
||||
id: 'line-1',
|
||||
type: 'line',
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 0,
|
||||
y2: 120,
|
||||
orientation: 'vertical',
|
||||
label: '',
|
||||
},
|
||||
{
|
||||
id: 'region-1',
|
||||
type: 'region',
|
||||
regionKey: '0:0',
|
||||
label: '거실',
|
||||
fillColor: '#dbeafe',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
test('serializes saved draw shapes into a JSON string for jsonb insert payloads', () => {
|
||||
const serialized = serializeSavedLayoutDrawShapes(createShapes());
|
||||
|
||||
assert.equal(typeof serialized, 'string');
|
||||
assert.deepEqual(JSON.parse(serialized), createShapes());
|
||||
});
|
||||
|
||||
test('normalizes stringified jsonb array values back into shape arrays', () => {
|
||||
const normalized = normalizeSavedLayoutDrawShapes(JSON.stringify(createShapes()));
|
||||
|
||||
assert.deepEqual(normalized, createShapes());
|
||||
});
|
||||
|
||||
test('falls back to an empty array for malformed saved draw shapes', () => {
|
||||
assert.deepEqual(normalizeSavedLayoutDrawShapes('{bad json'), []);
|
||||
});
|
||||
171
tests/layoutDraw/lineDraft.test.ts
Normal file
171
tests/layoutDraw/lineDraft.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveLineDraft, type DrawShapeLike } from '../../src/features/layout/draw/lineDraft.ts';
|
||||
|
||||
const canvasWidth = 500;
|
||||
const canvasHeight = 300;
|
||||
|
||||
function line(shape: Omit<Extract<DrawShapeLike, { type: 'line' }>, 'type'>): DrawShapeLike {
|
||||
return { type: 'line', ...shape };
|
||||
}
|
||||
|
||||
function rect(): DrawShapeLike {
|
||||
return { type: 'rect', x: 40, y: 40, width: 80, height: 60 };
|
||||
}
|
||||
|
||||
test('creates a vertical divider from a horizontal gesture when no horizontal crossing line exists ahead', () => {
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 300, 90, [], canvasWidth, canvasHeight), {
|
||||
startX: 120,
|
||||
startY: 0,
|
||||
endX: 120,
|
||||
endY: canvasHeight,
|
||||
orientation: 'vertical',
|
||||
});
|
||||
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 10, 70, [], canvasWidth, canvasHeight), {
|
||||
startX: 120,
|
||||
startY: 0,
|
||||
endX: 120,
|
||||
endY: canvasHeight,
|
||||
orientation: 'vertical',
|
||||
});
|
||||
});
|
||||
|
||||
test('creates a horizontal divider from a vertical gesture when no vertical crossing line exists ahead', () => {
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 130, 220, [], canvasWidth, canvasHeight), {
|
||||
startX: 0,
|
||||
startY: 80,
|
||||
endX: canvasWidth,
|
||||
endY: 80,
|
||||
orientation: 'horizontal',
|
||||
});
|
||||
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 110, 10, [], canvasWidth, canvasHeight), {
|
||||
startX: 0,
|
||||
startY: 80,
|
||||
endX: canvasWidth,
|
||||
endY: 80,
|
||||
orientation: 'horizontal',
|
||||
});
|
||||
});
|
||||
|
||||
test('stops at the nearest horizontal crossing line for a horizontal gesture divider', () => {
|
||||
const shapes: DrawShapeLike[] = [
|
||||
line({ x1: 20, y1: 40, x2: 220, y2: 40, orientation: 'horizontal' }),
|
||||
line({ x1: 40, y1: 180, x2: 260, y2: 180, orientation: 'horizontal' }),
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 480, 90, shapes, canvasWidth, canvasHeight), {
|
||||
startX: 120,
|
||||
startY: 40,
|
||||
endX: 120,
|
||||
endY: 180,
|
||||
orientation: 'vertical',
|
||||
});
|
||||
});
|
||||
|
||||
test('stops at the nearest vertical crossing line for a vertical gesture divider', () => {
|
||||
const shapes: DrawShapeLike[] = [
|
||||
line({ x1: 60, y1: 20, x2: 60, y2: 180, orientation: 'vertical' }),
|
||||
line({ x1: 260, y1: 20, x2: 260, y2: 160, orientation: 'vertical' }),
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 130, 260, shapes, canvasWidth, canvasHeight), {
|
||||
startX: 60,
|
||||
startY: 80,
|
||||
endX: 260,
|
||||
endY: 80,
|
||||
orientation: 'horizontal',
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores crossing lines that are behind the start point or outside the crossing range', () => {
|
||||
const shapes: DrawShapeLike[] = [
|
||||
line({ x1: 60, y1: 20, x2: 60, y2: 180, orientation: 'vertical' }),
|
||||
line({ x1: 320, y1: 120, x2: 320, y2: 220, orientation: 'vertical' }),
|
||||
line({ x1: 20, y1: 20, x2: 260, y2: 20, orientation: 'horizontal' }),
|
||||
rect(),
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 480, 90, shapes, canvasWidth, canvasHeight), {
|
||||
startX: 120,
|
||||
startY: 20,
|
||||
endX: 120,
|
||||
endY: canvasHeight,
|
||||
orientation: 'vertical',
|
||||
});
|
||||
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 130, 260, shapes, canvasWidth, canvasHeight), {
|
||||
startX: 60,
|
||||
startY: 80,
|
||||
endX: canvasWidth,
|
||||
endY: 80,
|
||||
orientation: 'horizontal',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not snap to a crossing line that starts exactly at the same point', () => {
|
||||
const shapes: DrawShapeLike[] = [
|
||||
line({ x1: 120, y1: 40, x2: 120, y2: 160, orientation: 'vertical' }),
|
||||
line({ x1: 120, y1: 180, x2: 240, y2: 180, orientation: 'horizontal' }),
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 300, 90, shapes, canvasWidth, canvasHeight), {
|
||||
startX: 120,
|
||||
startY: 0,
|
||||
endX: 120,
|
||||
endY: 180,
|
||||
orientation: 'vertical',
|
||||
});
|
||||
|
||||
assert.deepEqual(resolveLineDraft(120, 180, 130, 260, shapes, canvasWidth, canvasHeight), {
|
||||
startX: 0,
|
||||
startY: 180,
|
||||
endX: canvasWidth,
|
||||
endY: 180,
|
||||
orientation: 'horizontal',
|
||||
});
|
||||
});
|
||||
|
||||
test('extends to the nearest crossing line on both forward and reverse directions', () => {
|
||||
const shapes: DrawShapeLike[] = [
|
||||
line({ x1: 60, y1: 20, x2: 60, y2: 180, orientation: 'vertical' }),
|
||||
line({ x1: 260, y1: 20, x2: 260, y2: 180, orientation: 'vertical' }),
|
||||
line({ x1: 20, y1: 40, x2: 220, y2: 40, orientation: 'horizontal' }),
|
||||
line({ x1: 20, y1: 180, x2: 220, y2: 180, orientation: 'horizontal' }),
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 320, 90, shapes, canvasWidth, canvasHeight), {
|
||||
startX: 120,
|
||||
startY: 40,
|
||||
endX: 120,
|
||||
endY: 180,
|
||||
orientation: 'vertical',
|
||||
});
|
||||
|
||||
assert.deepEqual(resolveLineDraft(120, 100, 130, 260, shapes, canvasWidth, canvasHeight), {
|
||||
startX: 60,
|
||||
startY: 100,
|
||||
endX: 260,
|
||||
endY: 100,
|
||||
orientation: 'horizontal',
|
||||
});
|
||||
});
|
||||
|
||||
test('respects preferred orientation when the pointer drift would otherwise flip the axis', () => {
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 140, 160, [], canvasWidth, canvasHeight, 'horizontal'), {
|
||||
startX: 0,
|
||||
startY: 80,
|
||||
endX: canvasWidth,
|
||||
endY: 80,
|
||||
orientation: 'horizontal',
|
||||
});
|
||||
|
||||
assert.deepEqual(resolveLineDraft(120, 80, 260, 100, [], canvasWidth, canvasHeight, 'vertical'), {
|
||||
startX: 120,
|
||||
startY: 0,
|
||||
endX: 120,
|
||||
endY: canvasHeight,
|
||||
orientation: 'vertical',
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user