@1agh/maude 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/cli/bin/maude.exe +15 -0
- package/cli/bin/maude.mjs +45 -0
- package/cli/bin/mdcc.exe +10 -0
- package/cli/bin/mdcc.mjs +7 -0
- package/cli/cli-wrapper.cjs +67 -0
- package/cli/commands/config.mjs +94 -0
- package/cli/commands/design.mjs +386 -0
- package/cli/commands/help.mjs +57 -0
- package/cli/commands/init.mjs +178 -0
- package/cli/commands/version.mjs +7 -0
- package/cli/install.cjs +113 -0
- package/cli/lib/argv.mjs +37 -0
- package/cli/lib/argv.test.mjs +46 -0
- package/cli/lib/copy-tree.mjs +78 -0
- package/package.json +94 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +397 -0
- package/plugins/design/dev-server/annotations-layer.tsx +1717 -0
- package/plugins/design/dev-server/api.ts +674 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +50 -0
- package/plugins/design/dev-server/bin/bootstrap-check.sh +83 -0
- package/plugins/design/dev-server/bin/canvas-edit.sh +48 -0
- package/plugins/design/dev-server/bin/handoff.sh +27 -0
- package/plugins/design/dev-server/bin/screenshot.sh +232 -0
- package/plugins/design/dev-server/bin/server-up.sh +135 -0
- package/plugins/design/dev-server/bin/slug.sh +22 -0
- package/plugins/design/dev-server/bin/smoke.sh +272 -0
- package/plugins/design/dev-server/build.ts +267 -0
- package/plugins/design/dev-server/canvas-build.ts +219 -0
- package/plugins/design/dev-server/canvas-edit.ts +388 -0
- package/plugins/design/dev-server/canvas-header.ts +165 -0
- package/plugins/design/dev-server/canvas-icons.tsx +131 -0
- package/plugins/design/dev-server/canvas-lib-inline.ts +260 -0
- package/plugins/design/dev-server/canvas-lib-resolver.ts +85 -0
- package/plugins/design/dev-server/canvas-lib.tsx +1995 -0
- package/plugins/design/dev-server/canvas-meta.schema.json +181 -0
- package/plugins/design/dev-server/canvas-pipeline.ts +270 -0
- package/plugins/design/dev-server/canvas-shell.tsx +813 -0
- package/plugins/design/dev-server/client/app.jsx +2027 -0
- package/plugins/design/dev-server/client/hmr.mjs +85 -0
- package/plugins/design/dev-server/client/iframe-lazy.mjs +121 -0
- package/plugins/design/dev-server/client/index.html +15 -0
- package/plugins/design/dev-server/client/styles/0-reset.css +18 -0
- package/plugins/design/dev-server/client/styles/1-tokens.css +297 -0
- package/plugins/design/dev-server/client/styles/2-layout.css +35 -0
- package/plugins/design/dev-server/client/styles/3-shell.css +906 -0
- package/plugins/design/dev-server/client/styles/4-components.css +1268 -0
- package/plugins/design/dev-server/client/styles/5-utilities.css +4 -0
- package/plugins/design/dev-server/client/styles/_index.css +24 -0
- package/plugins/design/dev-server/client/styles.css +1419 -0
- package/plugins/design/dev-server/config.schema.json +147 -0
- package/plugins/design/dev-server/context-menu.tsx +343 -0
- package/plugins/design/dev-server/context.ts +173 -0
- package/plugins/design/dev-server/dist/client.bundle.js +20323 -0
- package/plugins/design/dev-server/dist/styles.css +2875 -0
- package/plugins/design/dev-server/examples/README.md +9 -0
- package/plugins/design/dev-server/examples/perf-100-artboards.tsx +113 -0
- package/plugins/design/dev-server/fs-watch.ts +63 -0
- package/plugins/design/dev-server/handoff.ts +721 -0
- package/plugins/design/dev-server/history.ts +125 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +114 -0
- package/plugins/design/dev-server/http.ts +413 -0
- package/plugins/design/dev-server/input-router.tsx +485 -0
- package/plugins/design/dev-server/inspect.ts +365 -0
- package/plugins/design/dev-server/locator.ts +159 -0
- package/plugins/design/dev-server/mem.ts +97 -0
- package/plugins/design/dev-server/runtime-bundle.ts +235 -0
- package/plugins/design/dev-server/server.mjs +1246 -0
- package/plugins/design/dev-server/server.ts +131 -0
- package/plugins/design/dev-server/test/_helpers.ts +81 -0
- package/plugins/design/dev-server/test/active-state.test.ts +145 -0
- package/plugins/design/dev-server/test/annotations-api.test.ts +146 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +419 -0
- package/plugins/design/dev-server/test/binary-smoke.test.ts +47 -0
- package/plugins/design/dev-server/test/bundle-smoke.test.ts +29 -0
- package/plugins/design/dev-server/test/canvas-build.test.ts +78 -0
- package/plugins/design/dev-server/test/canvas-edit.test.ts +139 -0
- package/plugins/design/dev-server/test/canvas-header.test.ts +127 -0
- package/plugins/design/dev-server/test/canvas-lib-inline.test.ts +146 -0
- package/plugins/design/dev-server/test/canvas-lib-resolver.test.ts +112 -0
- package/plugins/design/dev-server/test/canvas-meta-api.test.ts +236 -0
- package/plugins/design/dev-server/test/canvas-pipeline.test.ts +180 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +176 -0
- package/plugins/design/dev-server/test/fs-watch.test.ts +41 -0
- package/plugins/design/dev-server/test/handoff-static-frames.test.ts +215 -0
- package/plugins/design/dev-server/test/handoff.test.ts +281 -0
- package/plugins/design/dev-server/test/history-rollback.test.ts +62 -0
- package/plugins/design/dev-server/test/hmr-broadcast.test.ts +108 -0
- package/plugins/design/dev-server/test/input-router.test.ts +316 -0
- package/plugins/design/dev-server/test/locator.test.ts +214 -0
- package/plugins/design/dev-server/test/perf-harness.ts +193 -0
- package/plugins/design/dev-server/test/phase-3.6-smoke.test.ts +77 -0
- package/plugins/design/dev-server/test/runtime-bundle.test.ts +69 -0
- package/plugins/design/dev-server/test/server-lifecycle.test.ts +28 -0
- package/plugins/design/dev-server/test/tool-palette.test.tsx +55 -0
- package/plugins/design/dev-server/test/use-annotation-selection.test.tsx +77 -0
- package/plugins/design/dev-server/test/use-artboard-drag.test.ts +325 -0
- package/plugins/design/dev-server/test/use-selection-set.test.tsx +166 -0
- package/plugins/design/dev-server/test/use-snap-guides.test.ts +190 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +93 -0
- package/plugins/design/dev-server/test/ws-handshake.test.ts +33 -0
- package/plugins/design/dev-server/tool-palette.tsx +278 -0
- package/plugins/design/dev-server/tsconfig.json +26 -0
- package/plugins/design/dev-server/use-annotation-selection.tsx +92 -0
- package/plugins/design/dev-server/use-annotations-visibility.tsx +43 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +445 -0
- package/plugins/design/dev-server/use-selection-set.tsx +224 -0
- package/plugins/design/dev-server/use-snap-guides.tsx +215 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +114 -0
- package/plugins/design/dev-server/ws.ts +90 -0
- package/plugins/design/templates/_shell.html +177 -0
- package/plugins/design/templates/canvas.tsx.template +54 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +277 -0
- package/plugins/design/templates/design-system-inspiration/_README.md +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-banner.html +68 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-empty-state-generous.html +39 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-feature-grid.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-marketing-card.html +63 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-testimonial.html +80 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-code-block.html +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-diff-view.html +65 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-log-stream.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-monospace-table.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-terminal-pane.html +67 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/type-mono.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/colors-presence.html +74 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-command-palette.html +90 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-keyboard.html +51 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-list.html +89 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-shortcuts-overlay.html +85 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-toast-menu.html +80 -0
- package/plugins/design/templates/design-system-inspiration/core/INDEX.md.tpl +25 -0
- package/plugins/design/templates/design-system-inspiration/core/README.orchestration.md.tpl +71 -0
- package/plugins/design/templates/design-system-inspiration/core/README.philosophy.md.tpl +77 -0
- package/plugins/design/templates/design-system-inspiration/core/SKILL.md.tpl +50 -0
- package/plugins/design/templates/design-system-inspiration/core/colors_and_type.css.tpl +111 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +28 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_layout.css +113 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-accent.html +48 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-surfaces.html +49 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-text.html +52 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-buttons.html +83 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-cards.html +79 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-inputs.html +82 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.html +66 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/spacing-scale.html +53 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/type-scale.html +62 -0
- package/plugins/design/templates/design-system-inspiration/foundations/borders.html +39 -0
- package/plugins/design/templates/design-system-inspiration/foundations/elevation.html +46 -0
- package/plugins/design/templates/design-system-inspiration/foundations/focus.html +48 -0
- package/plugins/design/templates/design-system-inspiration/foundations/grid.html +51 -0
- package/plugins/design/templates/design-system-inspiration/foundations/iconography.html +73 -0
- package/plugins/design/templates/design-system-inspiration/foundations/opacity.html +45 -0
- package/plugins/design/templates/design-system-inspiration/foundations/radii.html +40 -0
- package/plugins/design/templates/design-system-inspiration/foundations/selection.html +45 -0
- package/plugins/design/templates/design-system-inspiration/meta/accessibility.html +62 -0
- package/plugins/design/templates/design-system-inspiration/meta/i18n.html +73 -0
- package/plugins/design/templates/design-system-inspiration/meta/presence-multiplayer.html +71 -0
- package/plugins/design/templates/design-system-inspiration/meta/tokens-index.html +80 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-auth.html +78 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-data-density.html +61 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-error-pages.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-form-layouts.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-onboarding.html +71 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-pricing.html +83 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/components-resize-panels.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-index.html +55 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-showcase.html +302 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-bottom-sheet.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-pull-to-refresh.html +74 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-segmented-control.html +51 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-tab-bar.html +57 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-index.html +58 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-showcase.html +237 -0
- package/plugins/design/templates/design-system-inspiration/status/colors-status.html +49 -0
- package/plugins/design/templates/design-system-inspiration/status/components-status.html +63 -0
- package/plugins/design/templates/design-system-inspiration/status/skeletons.html +74 -0
- package/plugins/design/templates/design-system-inspiration/theme-both/colors-themes-side-by-side.html +59 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-callout.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-dialogs.html +81 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tables.html +101 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-toggles.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tooltips.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/empty-state.html.tpl +34 -0
- package/plugins/design/templates/design-system-inspiration/universal/logo.html +42 -0
- package/plugins/design/templates/ds-specimen.tsx.template +59 -0
- package/plugins/flow/.claude-plugin/config.schema.json +398 -0
- package/plugins/flow/README.md +45 -0
- package/plugins/flow/templates/ai-skeleton/INDEX.md +50 -0
- package/plugins/flow/templates/ai-skeleton/README.md +46 -0
- package/plugins/flow/templates/ai-skeleton/browser/har/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/browser/snapshots/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/business/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/context/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/decisions/README.md +20 -0
- package/plugins/flow/templates/ai-skeleton/design-import/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/dev-logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/device/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/docs/README.md +5 -0
- package/plugins/flow/templates/ai-skeleton/logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/logs/README.md +13 -0
- package/plugins/flow/templates/ai-skeleton/plans/README.md +16 -0
- package/plugins/flow/templates/ai-skeleton/plans/archive/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/release-guide.md +35 -0
- package/plugins/flow/templates/ai-skeleton/reviews/README.md +15 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +26 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/_lib/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/state/STATE.md +25 -0
- package/plugins/flow/templates/ai-skeleton/templates/HANDOFF.md +32 -0
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +74 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// use-artboard-drag — Phase 4.2 Task 2. Pure reducer + helpers.
|
|
2
|
+
//
|
|
3
|
+
// The hook itself attaches DOM listeners; we verify the state-machine reducer
|
|
4
|
+
// (`dragReducer`) and the follower/others computations are correct without
|
|
5
|
+
// spinning up a DOM. The integration smoke (T3 ghost render, T5 commit
|
|
6
|
+
// round-trip) is covered by the scenario in `.ai/scenarios/canvas-artboard-drag.md`.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_GRID_SIZE,
|
|
12
|
+
DEFAULT_SNAP_TOLERANCE,
|
|
13
|
+
DRAG_THRESHOLD_PX,
|
|
14
|
+
type DragEvent,
|
|
15
|
+
type DragState,
|
|
16
|
+
commitFromState,
|
|
17
|
+
computeFollowers,
|
|
18
|
+
computeOthers,
|
|
19
|
+
dragReducer,
|
|
20
|
+
selectionsToArtboardIds,
|
|
21
|
+
} from '../use-artboard-drag.tsx';
|
|
22
|
+
import type { Selection } from '../use-selection-set.tsx';
|
|
23
|
+
import type { Rect } from '../use-snap-guides.tsx';
|
|
24
|
+
|
|
25
|
+
const rect = (id: string, x: number, y: number, w = 100, h = 80): Rect => ({
|
|
26
|
+
id,
|
|
27
|
+
x,
|
|
28
|
+
y,
|
|
29
|
+
w,
|
|
30
|
+
h,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const move = (over: Partial<Extract<DragEvent, { type: 'move' }>>): DragEvent => ({
|
|
34
|
+
type: 'move',
|
|
35
|
+
clientX: 0,
|
|
36
|
+
clientY: 0,
|
|
37
|
+
alt: false,
|
|
38
|
+
zoom: 1,
|
|
39
|
+
gridSize: DEFAULT_GRID_SIZE,
|
|
40
|
+
tolerance: DEFAULT_SNAP_TOLERANCE,
|
|
41
|
+
...over,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
// computeFollowers + computeOthers
|
|
46
|
+
|
|
47
|
+
describe('computeFollowers', () => {
|
|
48
|
+
test('returns [] when leader is not in selection', () => {
|
|
49
|
+
const all = [rect('a', 0, 0), rect('b', 200, 0), rect('c', 400, 0)];
|
|
50
|
+
const followers = computeFollowers('a', all[0] as Rect, new Set(['b', 'c']), all);
|
|
51
|
+
expect(followers).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('returns siblings selected alongside leader, with relative offsets', () => {
|
|
55
|
+
const all = [rect('a', 100, 50), rect('b', 300, 200), rect('c', 600, 400)];
|
|
56
|
+
const followers = computeFollowers('a', all[0] as Rect, new Set(['a', 'b', 'c']), all);
|
|
57
|
+
expect(followers).toHaveLength(2);
|
|
58
|
+
expect(followers[0]).toMatchObject({ id: 'b', offsetX: 200, offsetY: 150 });
|
|
59
|
+
expect(followers[1]).toMatchObject({ id: 'c', offsetX: 500, offsetY: 350 });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('excludes the leader from its own followers', () => {
|
|
63
|
+
const all = [rect('a', 0, 0), rect('b', 200, 0)];
|
|
64
|
+
const followers = computeFollowers('a', all[0] as Rect, new Set(['a', 'b']), all);
|
|
65
|
+
expect(followers.map((f) => f.id)).toEqual(['b']);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('computeOthers', () => {
|
|
70
|
+
test('excludes leader + followers from the snap candidate set', () => {
|
|
71
|
+
const all = [rect('a', 0, 0), rect('b', 200, 0), rect('c', 400, 0), rect('d', 600, 0)];
|
|
72
|
+
const others = computeOthers('a', new Set(['b']), all);
|
|
73
|
+
expect(others.map((r) => r.id)).toEqual(['c', 'd']);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
// dragReducer — state machine
|
|
79
|
+
|
|
80
|
+
function downAt(clientX: number, clientY: number, leaderStart: Rect): DragEvent {
|
|
81
|
+
return {
|
|
82
|
+
type: 'down',
|
|
83
|
+
clientX,
|
|
84
|
+
clientY,
|
|
85
|
+
leaderId: leaderStart.id ?? '',
|
|
86
|
+
leaderStart,
|
|
87
|
+
followers: [],
|
|
88
|
+
others: [],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('dragReducer / idle → pending', () => {
|
|
93
|
+
test('down event transitions to pending', () => {
|
|
94
|
+
const leader = rect('a', 0, 0);
|
|
95
|
+
const next = dragReducer({ kind: 'idle' }, downAt(50, 60, leader));
|
|
96
|
+
expect(next.kind).toBe('pending');
|
|
97
|
+
if (next.kind !== 'pending') throw new Error();
|
|
98
|
+
expect(next.startClientX).toBe(50);
|
|
99
|
+
expect(next.startClientY).toBe(60);
|
|
100
|
+
expect(next.leaderId).toBe('a');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('dragReducer / pending → idle when below threshold', () => {
|
|
105
|
+
test('move below 4 px stays in pending', () => {
|
|
106
|
+
const leader = rect('a', 0, 0);
|
|
107
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
108
|
+
const next = dragReducer(pending, move({ clientX: 102, clientY: 101 }));
|
|
109
|
+
expect(next.kind).toBe('pending');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test(`move past ${DRAG_THRESHOLD_PX} px transitions to dragging`, () => {
|
|
113
|
+
const leader = rect('a', 0, 0);
|
|
114
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
115
|
+
const next = dragReducer(pending, move({ clientX: 100 + DRAG_THRESHOLD_PX, clientY: 100 }));
|
|
116
|
+
expect(next.kind).toBe('dragging');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('dragReducer / dragging math', () => {
|
|
121
|
+
test('world delta scales by zoom (zoom 1.0)', () => {
|
|
122
|
+
const leader = rect('a', 0, 0);
|
|
123
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
124
|
+
const dragging = dragReducer(pending, move({ clientX: 200, clientY: 100, zoom: 1 }));
|
|
125
|
+
if (dragging.kind !== 'dragging') throw new Error();
|
|
126
|
+
expect(dragging.leaderRect.x).toBe(100);
|
|
127
|
+
expect(dragging.leaderRect.y).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('world delta scales by zoom (zoom 0.5 — every screen px = 2 world px)', () => {
|
|
131
|
+
const leader = rect('a', 0, 0);
|
|
132
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
133
|
+
const dragging = dragReducer(pending, move({ clientX: 200, clientY: 100, zoom: 0.5 }));
|
|
134
|
+
if (dragging.kind !== 'dragging') throw new Error();
|
|
135
|
+
expect(dragging.leaderRect.x).toBe(200);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('world delta scales by zoom (zoom 2.0 — every screen px = 0.5 world px)', () => {
|
|
139
|
+
const leader = rect('a', 0, 0);
|
|
140
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
141
|
+
const dragging = dragReducer(pending, move({ clientX: 200, clientY: 100, zoom: 2 }));
|
|
142
|
+
if (dragging.kind !== 'dragging') throw new Error();
|
|
143
|
+
expect(dragging.leaderRect.x).toBe(50);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('snap applies during drag (proposed near grid line)', () => {
|
|
147
|
+
// Move leader from (0,0) by (+37 client px) at zoom 1 → proposed.x = 37.
|
|
148
|
+
// Grid line at 40 within tolerance 8 → snaps to 40.
|
|
149
|
+
const leader = rect('a', 0, 0);
|
|
150
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
151
|
+
const dragging = dragReducer(pending, move({ clientX: 137, clientY: 100 }));
|
|
152
|
+
if (dragging.kind !== 'dragging') throw new Error();
|
|
153
|
+
expect(dragging.leaderRect.x).toBe(40);
|
|
154
|
+
expect(dragging.snap.guides.find((g) => g.axis === 'x')?.pos).toBe(40);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('alt=true disables snap mid-drag', () => {
|
|
158
|
+
const leader = rect('a', 0, 0);
|
|
159
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
160
|
+
const dragging = dragReducer(pending, move({ clientX: 137, clientY: 100, alt: true }));
|
|
161
|
+
if (dragging.kind !== 'dragging') throw new Error();
|
|
162
|
+
expect(dragging.leaderRect.x).toBe(37);
|
|
163
|
+
expect(dragging.snap.guides).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('dragReducer / cancel + up', () => {
|
|
168
|
+
test('cancel returns to idle from pending', () => {
|
|
169
|
+
const leader = rect('a', 0, 0);
|
|
170
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
171
|
+
const next = dragReducer(pending, { type: 'cancel' });
|
|
172
|
+
expect(next.kind).toBe('idle');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('cancel returns to idle from dragging (no commit emitted by reducer)', () => {
|
|
176
|
+
const leader = rect('a', 0, 0);
|
|
177
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
178
|
+
const dragging = dragReducer(pending, move({ clientX: 200, clientY: 100 }));
|
|
179
|
+
const next = dragReducer(dragging, { type: 'cancel' });
|
|
180
|
+
expect(next.kind).toBe('idle');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('up returns to idle', () => {
|
|
184
|
+
const leader = rect('a', 0, 0);
|
|
185
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
186
|
+
const dragging = dragReducer(pending, move({ clientX: 200, clientY: 100 }));
|
|
187
|
+
const next = dragReducer(dragging, { type: 'up' });
|
|
188
|
+
expect(next.kind).toBe('idle');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
// commitFromState
|
|
194
|
+
|
|
195
|
+
describe('commitFromState', () => {
|
|
196
|
+
test('returns null for idle/pending', () => {
|
|
197
|
+
expect(commitFromState({ kind: 'idle' })).toBeNull();
|
|
198
|
+
const leader = rect('a', 0, 0);
|
|
199
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
200
|
+
expect(commitFromState(pending)).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("returns leader's snapped position when no followers", () => {
|
|
204
|
+
const leader = rect('a', 0, 0);
|
|
205
|
+
const pending = dragReducer({ kind: 'idle' }, downAt(100, 100, leader));
|
|
206
|
+
const dragging = dragReducer(pending, move({ clientX: 200, clientY: 100 }));
|
|
207
|
+
const commit = commitFromState(dragging);
|
|
208
|
+
expect(commit).toEqual([{ id: 'a', x: 100, y: 0 }]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('multi-drag preserves relative offsets', () => {
|
|
212
|
+
// Leader 'a' starts at (0, 0). Follower 'b' starts at (200, 150) — offset
|
|
213
|
+
// (200, 150) from leader. Drag leader by (50, 25) world — alt=true to
|
|
214
|
+
// disable snap so we can predict the result exactly.
|
|
215
|
+
const leader = rect('a', 0, 0);
|
|
216
|
+
const follower = rect('b', 200, 150);
|
|
217
|
+
const all = [leader, follower];
|
|
218
|
+
const followers = computeFollowers('a', leader, new Set(['a', 'b']), all);
|
|
219
|
+
const others = computeOthers('a', new Set(['b']), all);
|
|
220
|
+
|
|
221
|
+
const down: DragEvent = {
|
|
222
|
+
type: 'down',
|
|
223
|
+
clientX: 100,
|
|
224
|
+
clientY: 100,
|
|
225
|
+
leaderId: 'a',
|
|
226
|
+
leaderStart: leader,
|
|
227
|
+
followers,
|
|
228
|
+
others,
|
|
229
|
+
};
|
|
230
|
+
const pending = dragReducer({ kind: 'idle' }, down);
|
|
231
|
+
const dragging = dragReducer(pending, move({ clientX: 150, clientY: 125, alt: true }));
|
|
232
|
+
const commit = commitFromState(dragging);
|
|
233
|
+
expect(commit).toEqual([
|
|
234
|
+
{ id: 'a', x: 50, y: 25 },
|
|
235
|
+
{ id: 'b', x: 250, y: 175 },
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
// Integration — snap also applies in dragging with siblings present
|
|
242
|
+
|
|
243
|
+
describe('dragReducer / snap with siblings', () => {
|
|
244
|
+
test("leader snaps to sibling's left edge during drag", () => {
|
|
245
|
+
const leader = rect('a', 0, 0, 100, 80);
|
|
246
|
+
const sibling = rect('b', 200, 0, 100, 80);
|
|
247
|
+
const others = computeOthers('a', new Set(), [leader, sibling]);
|
|
248
|
+
|
|
249
|
+
const down: DragEvent = {
|
|
250
|
+
type: 'down',
|
|
251
|
+
clientX: 100,
|
|
252
|
+
clientY: 100,
|
|
253
|
+
leaderId: 'a',
|
|
254
|
+
leaderStart: leader,
|
|
255
|
+
followers: [],
|
|
256
|
+
others,
|
|
257
|
+
};
|
|
258
|
+
const pending = dragReducer({ kind: 'idle' }, down);
|
|
259
|
+
// Drag leader by (+197 client px) at zoom 1 → proposed.x = 197.
|
|
260
|
+
// Sibling.left = 200, delta = 3 → snap to 200.
|
|
261
|
+
const dragging = dragReducer(pending, move({ clientX: 297, clientY: 100 }));
|
|
262
|
+
if (dragging.kind !== 'dragging') throw new Error();
|
|
263
|
+
expect(dragging.leaderRect.x).toBe(200);
|
|
264
|
+
expect(dragging.snap.guides.find((g) => g.axis === 'x')?.pos).toBe(200);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
269
|
+
// Move event from idle is a no-op (defensive)
|
|
270
|
+
|
|
271
|
+
describe('dragReducer / move from idle is a no-op', () => {
|
|
272
|
+
test('move dispatched without a prior down stays idle', () => {
|
|
273
|
+
const next = dragReducer({ kind: 'idle' }, move({ clientX: 100 }));
|
|
274
|
+
expect(next.kind).toBe('idle');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
279
|
+
// selectionsToArtboardIds — Phase 4.2 multi-drag identity gate (C1 regression).
|
|
280
|
+
//
|
|
281
|
+
// Selection.id is a child element's data-cd-id; Selection.artboardId is the
|
|
282
|
+
// host artboard. Multi-drag identity MUST key on artboardId only — falling
|
|
283
|
+
// back to id would pollute the set with child cd-ids and silently disable
|
|
284
|
+
// multi-drag.
|
|
285
|
+
|
|
286
|
+
describe('selectionsToArtboardIds', () => {
|
|
287
|
+
const sel = (over: Partial<Selection>): Selection => ({
|
|
288
|
+
selector: '[data-cd-id="x"]',
|
|
289
|
+
...over,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('ignores Selection.id (child cd-id) and keys on artboardId only', () => {
|
|
293
|
+
const list: Selection[] = [
|
|
294
|
+
sel({ id: 'button_cd_xyz', artboardId: 'A' }),
|
|
295
|
+
sel({ id: 'span_cd_abc', artboardId: 'B' }),
|
|
296
|
+
];
|
|
297
|
+
const ids = selectionsToArtboardIds(list);
|
|
298
|
+
expect([...ids].sort()).toEqual(['A', 'B']);
|
|
299
|
+
expect(ids.has('button_cd_xyz')).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('skips selections that have no artboardId (orphan / world-level)', () => {
|
|
303
|
+
const list: Selection[] = [
|
|
304
|
+
sel({ id: 'orphan_cd', artboardId: null }),
|
|
305
|
+
sel({ artboardId: 'A' }),
|
|
306
|
+
];
|
|
307
|
+
const ids = selectionsToArtboardIds(list);
|
|
308
|
+
expect([...ids]).toEqual(['A']);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('dedupes when two selections share the same artboardId', () => {
|
|
312
|
+
const list: Selection[] = [
|
|
313
|
+
sel({ id: 'a1', artboardId: 'A' }),
|
|
314
|
+
sel({ id: 'a2', artboardId: 'A' }),
|
|
315
|
+
];
|
|
316
|
+
const ids = selectionsToArtboardIds(list);
|
|
317
|
+
expect(ids.size).toBe(1);
|
|
318
|
+
expect(ids.has('A')).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('empty list → empty set', () => {
|
|
322
|
+
const ids = selectionsToArtboardIds([]);
|
|
323
|
+
expect(ids.size).toBe(0);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// use-selection-set — Phase 4.1 Task 3. Set semantics + wire-shape helpers.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Selection,
|
|
7
|
+
denormalizeSelectedWrite,
|
|
8
|
+
normalizeSelectedRead,
|
|
9
|
+
} from '../use-selection-set.tsx';
|
|
10
|
+
|
|
11
|
+
const mkSel = (over: Partial<Selection> = {}): Selection => ({
|
|
12
|
+
selector: 'section:nth-child(1) > div',
|
|
13
|
+
...over,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('use-selection-set / normalizeSelectedRead', () => {
|
|
17
|
+
test('null → []', () => {
|
|
18
|
+
expect(normalizeSelectedRead(null)).toEqual([]);
|
|
19
|
+
expect(normalizeSelectedRead(undefined)).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('single object → length-1 array', () => {
|
|
23
|
+
const s = mkSel({ id: 'cd-a' });
|
|
24
|
+
expect(normalizeSelectedRead(s)).toEqual([s]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('array passes through', () => {
|
|
28
|
+
const a = mkSel({ id: 'cd-a' });
|
|
29
|
+
const b = mkSel({ id: 'cd-b' });
|
|
30
|
+
expect(normalizeSelectedRead([a, b])).toEqual([a, b]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('array dedupe by data-cd-id', () => {
|
|
34
|
+
const a = mkSel({ id: 'cd-a' });
|
|
35
|
+
const aDup = mkSel({ id: 'cd-a', text: 'should be dropped' });
|
|
36
|
+
expect(normalizeSelectedRead([a, aDup])).toEqual([a]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('array dedupe by selector when no id', () => {
|
|
40
|
+
const a = mkSel({ selector: '.card:nth-child(1)' });
|
|
41
|
+
const b = mkSel({ selector: '.card:nth-child(1)' });
|
|
42
|
+
expect(normalizeSelectedRead([a, b])).toEqual([a]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("mixed id / selector keys don't collide", () => {
|
|
46
|
+
const byId = mkSel({ id: 'cd-a', selector: '.x' });
|
|
47
|
+
const bySel = mkSel({ selector: 'cd-a' }); // no id, selector happens to be 'cd-a'
|
|
48
|
+
expect(normalizeSelectedRead([byId, bySel])).toEqual([byId, bySel]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('use-selection-set / denormalizeSelectedWrite', () => {
|
|
53
|
+
test('empty → null', () => {
|
|
54
|
+
expect(denormalizeSelectedWrite([])).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('length-1 → bare object (back-compat with legacy single-element shape)', () => {
|
|
58
|
+
const s = mkSel({ id: 'cd-a' });
|
|
59
|
+
expect(denormalizeSelectedWrite([s])).toBe(s);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('length>1 → array', () => {
|
|
63
|
+
const a = mkSel({ id: 'cd-a' });
|
|
64
|
+
const b = mkSel({ id: 'cd-b' });
|
|
65
|
+
expect(denormalizeSelectedWrite([a, b])).toEqual([a, b]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('use-selection-set / round trip read-write', () => {
|
|
70
|
+
test('legacy single-element shape → array of 1 → back to single-element', () => {
|
|
71
|
+
const legacy = mkSel({ id: 'cd-a' });
|
|
72
|
+
const arr = normalizeSelectedRead(legacy);
|
|
73
|
+
expect(arr).toEqual([legacy]);
|
|
74
|
+
expect(denormalizeSelectedWrite(arr)).toBe(legacy);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('null round-trips as null', () => {
|
|
78
|
+
expect(denormalizeSelectedWrite(normalizeSelectedRead(null))).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('use-selection-set / inspect.ts setSelected widening (integration)', () => {
|
|
83
|
+
test('inspect.setSelected accepts array and collapses single-entry to object', async () => {
|
|
84
|
+
// Spin up createInspect with a real on-disk active file in a tmp dir.
|
|
85
|
+
const { createInspect } = await import('../inspect.ts');
|
|
86
|
+
const { createBus } = await import('../context.ts');
|
|
87
|
+
const tmp = `/tmp/use-selection-set-${Date.now()}`;
|
|
88
|
+
await Bun.write(`${tmp}/.design/.keep`, '');
|
|
89
|
+
const ctx: Parameters<typeof createInspect>[0] = {
|
|
90
|
+
cfg: {} as never,
|
|
91
|
+
projectLabel: '',
|
|
92
|
+
paths: {
|
|
93
|
+
designRoot: `${tmp}/.design`,
|
|
94
|
+
designRel: '.design',
|
|
95
|
+
activeFile: `${tmp}/.design/_active.json`,
|
|
96
|
+
serverFile: `${tmp}/.design/_server.json`,
|
|
97
|
+
historyRoot: `${tmp}/.design/_history`,
|
|
98
|
+
commentsRoot: `${tmp}/.design/_comments`,
|
|
99
|
+
cwd: tmp,
|
|
100
|
+
} as never,
|
|
101
|
+
bus: createBus(),
|
|
102
|
+
};
|
|
103
|
+
const insp = createInspect(ctx, async () => []);
|
|
104
|
+
|
|
105
|
+
insp.setSelected({
|
|
106
|
+
file: '.design/ui/Foo.tsx',
|
|
107
|
+
selector: '.x',
|
|
108
|
+
tag: 'div',
|
|
109
|
+
classes: '',
|
|
110
|
+
text: '',
|
|
111
|
+
dom_path: [],
|
|
112
|
+
bounds: null,
|
|
113
|
+
html: '',
|
|
114
|
+
id: 'cd-a',
|
|
115
|
+
});
|
|
116
|
+
expect(Array.isArray(insp.state.selected)).toBe(false);
|
|
117
|
+
expect((insp.state.selected as { id?: string } | null)?.id).toBe('cd-a');
|
|
118
|
+
|
|
119
|
+
insp.setSelected([
|
|
120
|
+
{
|
|
121
|
+
file: '.design/ui/Foo.tsx',
|
|
122
|
+
selector: '.a',
|
|
123
|
+
tag: 'div',
|
|
124
|
+
classes: '',
|
|
125
|
+
text: '',
|
|
126
|
+
dom_path: [],
|
|
127
|
+
bounds: null,
|
|
128
|
+
html: '',
|
|
129
|
+
id: 'cd-a',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
file: '.design/ui/Foo.tsx',
|
|
133
|
+
selector: '.b',
|
|
134
|
+
tag: 'div',
|
|
135
|
+
classes: '',
|
|
136
|
+
text: '',
|
|
137
|
+
dom_path: [],
|
|
138
|
+
bounds: null,
|
|
139
|
+
html: '',
|
|
140
|
+
id: 'cd-b',
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
expect(Array.isArray(insp.state.selected)).toBe(true);
|
|
144
|
+
expect((insp.state.selected as unknown[]).length).toBe(2);
|
|
145
|
+
|
|
146
|
+
// Single-entry array → object collapse
|
|
147
|
+
insp.setSelected([
|
|
148
|
+
{
|
|
149
|
+
file: '.design/ui/Foo.tsx',
|
|
150
|
+
selector: '.only',
|
|
151
|
+
tag: 'div',
|
|
152
|
+
classes: '',
|
|
153
|
+
text: '',
|
|
154
|
+
dom_path: [],
|
|
155
|
+
bounds: null,
|
|
156
|
+
html: '',
|
|
157
|
+
id: 'cd-only',
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
expect(Array.isArray(insp.state.selected)).toBe(false);
|
|
161
|
+
expect((insp.state.selected as { id?: string } | null)?.id).toBe('cd-only');
|
|
162
|
+
|
|
163
|
+
insp.setSelected(null);
|
|
164
|
+
expect(insp.state.selected).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// use-snap-guides — Phase 4.2 Task 1. Pure snap-math table tests.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import { type Rect, type SnapOptions, computeSnap } from '../use-snap-guides.tsx';
|
|
6
|
+
|
|
7
|
+
const DEFAULTS: SnapOptions = { gridSize: 40, tolerance: 8, disabled: false };
|
|
8
|
+
|
|
9
|
+
const rect = (x: number, y: number, w = 100, h = 80): Rect => ({ x, y, w, h });
|
|
10
|
+
|
|
11
|
+
describe('computeSnap / disabled (Alt-held)', () => {
|
|
12
|
+
test('returns proposed unchanged + zero guides', () => {
|
|
13
|
+
const r = computeSnap(rect(37, 78), [rect(100, 100)], { ...DEFAULTS, disabled: true });
|
|
14
|
+
expect(r.x).toBe(37);
|
|
15
|
+
expect(r.y).toBe(78);
|
|
16
|
+
expect(r.guides).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('computeSnap / grid', () => {
|
|
21
|
+
test('snaps left edge to nearest grid line within tolerance', () => {
|
|
22
|
+
const r = computeSnap(rect(37, 200), [], DEFAULTS);
|
|
23
|
+
expect(r.x).toBe(40);
|
|
24
|
+
expect(r.guides.find((g) => g.axis === 'x')).toMatchObject({ pos: 40 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('does NOT snap when outside tolerance', () => {
|
|
28
|
+
const r = computeSnap(rect(27, 200), [], DEFAULTS);
|
|
29
|
+
expect(r.x).toBe(27);
|
|
30
|
+
expect(r.guides.find((g) => g.axis === 'x')).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('snaps top edge independently of left edge', () => {
|
|
34
|
+
const r = computeSnap(rect(200, 37), [], DEFAULTS);
|
|
35
|
+
expect(r.y).toBe(40);
|
|
36
|
+
expect(r.guides.find((g) => g.axis === 'y')).toMatchObject({ pos: 40 });
|
|
37
|
+
expect(r.x).toBe(200);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('exact grid boundary (delta = 0) snaps to itself + emits guide', () => {
|
|
41
|
+
const r = computeSnap(rect(40, 80), [], DEFAULTS);
|
|
42
|
+
expect(r.x).toBe(40);
|
|
43
|
+
expect(r.y).toBe(80);
|
|
44
|
+
expect(r.guides.find((g) => g.axis === 'x')).toMatchObject({ pos: 40 });
|
|
45
|
+
expect(r.guides.find((g) => g.axis === 'y')).toMatchObject({ pos: 80 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("guide line spans proposed's perpendicular extent (no sibling)", () => {
|
|
49
|
+
const r = computeSnap(rect(37, 200, 100, 80), [], DEFAULTS);
|
|
50
|
+
const xg = r.guides.find((g) => g.axis === 'x');
|
|
51
|
+
expect(xg).toMatchObject({ from: 200, to: 280 });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('computeSnap / sibling edges (X-axis)', () => {
|
|
56
|
+
test('left↔left snap', () => {
|
|
57
|
+
const proposed = rect(503, 200);
|
|
58
|
+
const sibling = rect(500, 50);
|
|
59
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
60
|
+
expect(r.x).toBe(500);
|
|
61
|
+
expect(r.guides.find((g) => g.axis === 'x')).toMatchObject({ pos: 500 });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('right↔right snap (proposed.right snaps to sibling.right)', () => {
|
|
65
|
+
// proposed right = 503 + 100 = 603; sibling right = 500 + 100 = 600.
|
|
66
|
+
// delta = 600 - 603 = -3 → snap x by -3.
|
|
67
|
+
const proposed = rect(503, 300, 100, 80);
|
|
68
|
+
const sibling = rect(500, 100, 100, 80);
|
|
69
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
70
|
+
expect(r.x).toBe(500);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('left↔right (proposed.left snaps to sibling.right — abutment)', () => {
|
|
74
|
+
// proposed.left = 597; sibling.right = 600. delta = +3.
|
|
75
|
+
const proposed = rect(597, 300, 100, 80);
|
|
76
|
+
const sibling = rect(500, 100, 100, 80);
|
|
77
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
78
|
+
expect(r.x).toBe(600);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('centerX↔centerX snap', () => {
|
|
82
|
+
// proposed center = 553 + 50 = 603; sibling center = 550 + 50 = 600.
|
|
83
|
+
const proposed = rect(553, 300, 100, 80);
|
|
84
|
+
const sibling = rect(550, 100, 100, 80);
|
|
85
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
86
|
+
expect(r.x).toBe(550);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('does NOT snap when delta exceeds tolerance', () => {
|
|
90
|
+
// proposed.x = 530 sits between grid lines 520 (Δ-10) and 560 (Δ+30) — both
|
|
91
|
+
// outside tolerance=8. Sibling at 500 is also -30 away. Nothing snaps.
|
|
92
|
+
const proposed = rect(530, 305);
|
|
93
|
+
const sibling = rect(500, 105);
|
|
94
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
95
|
+
expect(r.x).toBe(530);
|
|
96
|
+
expect(r.guides.find((g) => g.axis === 'x')).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("guide line spans union of both rects' Y range", () => {
|
|
100
|
+
const proposed = rect(503, 50, 100, 80); // y range [50, 130]
|
|
101
|
+
const sibling = rect(500, 300, 100, 80); // y range [300, 380]
|
|
102
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
103
|
+
const xg = r.guides.find((g) => g.axis === 'x');
|
|
104
|
+
expect(xg).toMatchObject({ from: 50, to: 380 });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('computeSnap / sibling edges (Y-axis)', () => {
|
|
109
|
+
test('top↔top snap', () => {
|
|
110
|
+
const proposed = rect(300, 103, 100, 80);
|
|
111
|
+
const sibling = rect(50, 100, 100, 80);
|
|
112
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
113
|
+
expect(r.y).toBe(100);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('bottom↔top snap (proposed below sibling, abutting)', () => {
|
|
117
|
+
// proposed.bottom = 177; sibling.top = 180. delta = +3 → shift y by +3.
|
|
118
|
+
const proposed = rect(300, 97, 100, 80);
|
|
119
|
+
const sibling = rect(50, 180, 100, 80);
|
|
120
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
121
|
+
expect(r.y).toBe(100);
|
|
122
|
+
expect(proposed.y + 100 - 97).toBe(100);
|
|
123
|
+
const yg = r.guides.find((g) => g.axis === 'y');
|
|
124
|
+
expect(yg).toMatchObject({ pos: 180 });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('centerY↔centerY snap', () => {
|
|
128
|
+
const proposed = rect(300, 103, 100, 80); // centerY = 143
|
|
129
|
+
const sibling = rect(50, 100, 100, 80); // centerY = 140
|
|
130
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
131
|
+
expect(r.y).toBe(100);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('computeSnap / closest wins', () => {
|
|
136
|
+
test('when sibling and grid both within tolerance, closer wins', () => {
|
|
137
|
+
// proposed.x = 37; grid candidate at 40 (delta 3); sibling.left at 38 (delta 1).
|
|
138
|
+
const proposed = rect(37, 300);
|
|
139
|
+
const sibling = rect(38, 100);
|
|
140
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
141
|
+
expect(r.x).toBe(38);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('when grid is closer, grid wins', () => {
|
|
145
|
+
// proposed.x = 39; grid candidate at 40 (delta 1); sibling at 35 (delta -4).
|
|
146
|
+
const proposed = rect(39, 300);
|
|
147
|
+
const sibling = rect(35, 100);
|
|
148
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
149
|
+
expect(r.x).toBe(40);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('computeSnap / X and Y axes are independent', () => {
|
|
154
|
+
test('snaps X to sibling AND Y to grid simultaneously', () => {
|
|
155
|
+
// proposed at (503, 37): X snaps to sibling.left=500; Y snaps to grid 40.
|
|
156
|
+
const proposed = rect(503, 37);
|
|
157
|
+
const sibling = rect(500, 300);
|
|
158
|
+
const r = computeSnap(proposed, [sibling], DEFAULTS);
|
|
159
|
+
expect(r.x).toBe(500);
|
|
160
|
+
expect(r.y).toBe(40);
|
|
161
|
+
expect(r.guides.length).toBe(2);
|
|
162
|
+
expect(r.guides.find((g) => g.axis === 'x')).toMatchObject({ pos: 500 });
|
|
163
|
+
expect(r.guides.find((g) => g.axis === 'y')).toMatchObject({ pos: 40 });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('computeSnap / multiple siblings at same pos merge into one guide', () => {
|
|
168
|
+
test('two siblings with the same left edge produce a single merged guide', () => {
|
|
169
|
+
const proposed = rect(503, 300, 100, 80);
|
|
170
|
+
const sibA = rect(500, 100, 100, 80); // y range [100, 180]
|
|
171
|
+
const sibB = rect(500, 500, 100, 80); // y range [500, 580]
|
|
172
|
+
const r = computeSnap(proposed, [sibA, sibB], DEFAULTS);
|
|
173
|
+
const xGuides = r.guides.filter((g) => g.axis === 'x');
|
|
174
|
+
expect(xGuides.length).toBe(1);
|
|
175
|
+
expect(xGuides[0]).toMatchObject({
|
|
176
|
+
pos: 500,
|
|
177
|
+
from: 100,
|
|
178
|
+
to: 580,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('computeSnap / no candidates', () => {
|
|
184
|
+
test('empty others + outside grid tolerance → identity, no guides', () => {
|
|
185
|
+
const r = computeSnap(rect(27, 27), [], DEFAULTS);
|
|
186
|
+
expect(r.x).toBe(27);
|
|
187
|
+
expect(r.y).toBe(27);
|
|
188
|
+
expect(r.guides).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
});
|