@1agh/maude 0.19.1 → 0.21.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/README.md +7 -0
- package/cli/bin/maude.mjs +5 -1
- package/cli/commands/design-link.test.mjs +207 -0
- package/cli/commands/design.mjs +42 -12
- package/cli/commands/doctor.mjs +350 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +231 -0
- package/cli/commands/hub.test.mjs +87 -0
- package/cli/lib/config-lint.mjs +141 -0
- package/cli/lib/config-lint.test.mjs +117 -0
- package/cli/lib/design-link.mjs +216 -0
- package/cli/lib/hubs-config.mjs +123 -0
- package/cli/lib/hubs-config.test.mjs +100 -0
- package/cli/lib/preflight.mjs +232 -0
- package/cli/lib/stack-detect.mjs +344 -0
- package/cli/lib/stack-detect.test.mjs +121 -0
- package/package.json +17 -9
- package/plugins/design/dependencies.json +147 -0
- package/plugins/design/dependencies.schema.json +107 -0
- package/plugins/design/dev-server/ai-banner.tsx +188 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
- package/plugins/design/dev-server/annotations-layer.tsx +256 -107
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/preflight.sh +32 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +534 -32
- package/plugins/design/dev-server/canvas-shell.tsx +479 -29
- package/plugins/design/dev-server/client/app.jsx +72 -0
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
- package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
- package/plugins/design/dev-server/collab/index.ts +47 -0
- package/plugins/design/dev-server/collab/persistence.ts +123 -0
- package/plugins/design/dev-server/collab/protocol.ts +108 -0
- package/plugins/design/dev-server/collab/registry.ts +110 -0
- package/plugins/design/dev-server/collab/room.ts +215 -0
- package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
- package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
- package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
- package/plugins/design/dev-server/comments-overlay.tsx +41 -4
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
- package/plugins/design/dev-server/dist/client.bundle.js +75 -3
- package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
- package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
- package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +91 -1
- package/plugins/design/dev-server/input-router.tsx +52 -2
- package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
- package/plugins/design/dev-server/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +15 -0
- package/plugins/design/dev-server/server.ts +78 -11
- package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
- package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
- package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
- package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
- package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
- package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
- package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
- package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
- package/plugins/design/dev-server/test/input-router.test.ts +87 -1
- package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
- package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
- package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
- package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
- package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
- package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
- package/plugins/design/dev-server/tool-palette.tsx +7 -7
- package/plugins/design/dev-server/undo-hud.tsx +95 -0
- package/plugins/design/dev-server/undo-stack.ts +240 -0
- package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
- package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
- package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
- package/plugins/design/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +51 -6
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
- package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
- package/plugins/flow/.claude-plugin/config.schema.json +12 -0
- package/plugins/flow/dependencies.json +143 -0
- package/plugins/flow/dependencies.schema.json +107 -0
- /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Unit: Task 7 — `.git/HEAD` watcher. We don't exercise fs.watch directly
|
|
2
|
+
// (flaky across platforms in CI); instead we drive the module's public
|
|
3
|
+
// surface: it gracefully no-ops when no .git/ exists, and the public stop()
|
|
4
|
+
// is idempotent.
|
|
5
|
+
//
|
|
6
|
+
// The flush-before-broadcast invariant is verified separately by the
|
|
7
|
+
// collab-bridge tests and the persistence module's contract — registry
|
|
8
|
+
// already exposes flushAll(). The integration with .git/HEAD writes is
|
|
9
|
+
// exercised manually in the Task 7 acceptance test (Phase 8 plan validate row).
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
16
|
+
|
|
17
|
+
import { createGitLifecycle } from '../collab/git-lifecycle.ts';
|
|
18
|
+
import { createRegistry } from '../collab/registry.ts';
|
|
19
|
+
import type { RoomCallbacks } from '../collab/room.ts';
|
|
20
|
+
import { type Context, createBus } from '../context.ts';
|
|
21
|
+
|
|
22
|
+
function ctxFor(repoRoot: string): Context {
|
|
23
|
+
return {
|
|
24
|
+
cfg: {} as Context['cfg'],
|
|
25
|
+
projectLabel: 'test',
|
|
26
|
+
paths: {
|
|
27
|
+
repoRoot,
|
|
28
|
+
designRel: '.design',
|
|
29
|
+
designRoot: path.join(repoRoot, '.design'),
|
|
30
|
+
serverInfoFile: '',
|
|
31
|
+
activeFile: '',
|
|
32
|
+
commentsDir: '',
|
|
33
|
+
canvasStateDir: '',
|
|
34
|
+
historyDir: '',
|
|
35
|
+
tokensUrlRel: '',
|
|
36
|
+
systemDirRel: '',
|
|
37
|
+
},
|
|
38
|
+
bus: createBus(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function noopCallbacks(): RoomCallbacks {
|
|
43
|
+
return { async seed() {}, async persistJson() {}, async persistBinary() {} };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('git-lifecycle', () => {
|
|
47
|
+
let tmp: string;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
tmp = mkdtempSync(path.join(tmpdir(), 'maude-git-lifecycle-'));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
try {
|
|
55
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore */
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('no .git/ directory → silent no-op (still returns a stop function)', () => {
|
|
62
|
+
const ctx = ctxFor(tmp);
|
|
63
|
+
const registry = createRegistry(noopCallbacks());
|
|
64
|
+
expect(existsSync(path.join(tmp, '.git'))).toBe(false);
|
|
65
|
+
const gl = createGitLifecycle(ctx, registry);
|
|
66
|
+
expect(typeof gl.stop).toBe('function');
|
|
67
|
+
gl.stop(); // idempotent
|
|
68
|
+
gl.stop();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('with .git/HEAD present → watcher attaches without throwing', () => {
|
|
72
|
+
mkdirSync(path.join(tmp, '.git'), { recursive: true });
|
|
73
|
+
writeFileSync(path.join(tmp, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
74
|
+
const ctx = ctxFor(tmp);
|
|
75
|
+
const registry = createRegistry(noopCallbacks());
|
|
76
|
+
const gl = createGitLifecycle(ctx, registry);
|
|
77
|
+
expect(typeof gl.stop).toBe('function');
|
|
78
|
+
gl.stop();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('emits git-lifecycle bus event on HEAD change (manual rewrite)', async () => {
|
|
82
|
+
mkdirSync(path.join(tmp, '.git'), { recursive: true });
|
|
83
|
+
writeFileSync(path.join(tmp, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
84
|
+
const ctx = ctxFor(tmp);
|
|
85
|
+
const registry = createRegistry(noopCallbacks());
|
|
86
|
+
const events: unknown[] = [];
|
|
87
|
+
ctx.bus.on('git-lifecycle', (e) => events.push(e));
|
|
88
|
+
const gl = createGitLifecycle(ctx, registry);
|
|
89
|
+
|
|
90
|
+
// Rewrite HEAD; the watcher fires + 250 ms debounce + flush. Wait > 500 ms
|
|
91
|
+
// to be safe with fs.watch + flushAll latency on macOS + Linux.
|
|
92
|
+
writeFileSync(path.join(tmp, '.git', 'HEAD'), 'ref: refs/heads/other\n');
|
|
93
|
+
await Bun.sleep(700);
|
|
94
|
+
|
|
95
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
96
|
+
const evt = events[0] as { reason?: string; head?: string };
|
|
97
|
+
expect(evt.reason).toBe('head-changed');
|
|
98
|
+
expect(evt.head).toContain('refs/heads/other');
|
|
99
|
+
gl.stop();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from 'bun:test';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type ClassifyInput,
|
|
7
|
+
DRAG_THRESHOLD_PX,
|
|
8
|
+
type Tool,
|
|
9
|
+
classify,
|
|
10
|
+
crossedDragThreshold,
|
|
11
|
+
isEditableTarget,
|
|
12
|
+
} from '../input-router.tsx';
|
|
6
13
|
|
|
7
14
|
const base = (over: Partial<ClassifyInput>): ClassifyInput => ({
|
|
8
15
|
type: 'pointermove',
|
|
@@ -314,3 +321,82 @@ describe('input-router / isEditableTarget', () => {
|
|
|
314
321
|
expect(isEditableTarget(el)).toBe(true);
|
|
315
322
|
});
|
|
316
323
|
});
|
|
324
|
+
|
|
325
|
+
// T25 — Drag-vs-click threshold. Centralized here so every drag-class gesture
|
|
326
|
+
// (artboard drag, artboard marquee, element marquee, annotation pen/rect/etc.)
|
|
327
|
+
// reads the same value. Microsoft Win32 SM_CXDRAG default + d3-drag convention.
|
|
328
|
+
describe('input-router / crossedDragThreshold', () => {
|
|
329
|
+
test('constant is 4', () => {
|
|
330
|
+
expect(DRAG_THRESHOLD_PX).toBe(4);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('no movement → false', () => {
|
|
334
|
+
expect(crossedDragThreshold(100, 200, 100, 200)).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('3 px move (sub-threshold) → false', () => {
|
|
338
|
+
expect(crossedDragThreshold(0, 0, 3, 0)).toBe(false);
|
|
339
|
+
expect(crossedDragThreshold(0, 0, 2, 2)).toBe(false); // hypot ≈ 2.83
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('exactly 4 px → true (boundary inclusive)', () => {
|
|
343
|
+
expect(crossedDragThreshold(0, 0, 4, 0)).toBe(true);
|
|
344
|
+
expect(crossedDragThreshold(0, 0, 0, 4)).toBe(true);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('5 px diagonal → true', () => {
|
|
348
|
+
// 3-4-5 right triangle: hypot = 5
|
|
349
|
+
expect(crossedDragThreshold(10, 20, 13, 24)).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('negative deltas — hypot is direction-agnostic', () => {
|
|
353
|
+
expect(crossedDragThreshold(50, 50, 47, 46)).toBe(true); // hypot = 5
|
|
354
|
+
expect(crossedDragThreshold(50, 50, 48, 49)).toBe(false); // hypot ≈ 2.24
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('input-router / keydown — undo + redo (Phase 20)', () => {
|
|
359
|
+
test('Cmd+Z → undo', () => {
|
|
360
|
+
expect(classify(base({ type: 'keydown', key: 'z', metaKey: true })).kind).toBe('undo');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('Cmd+Shift+Z → redo', () => {
|
|
364
|
+
expect(classify(base({ type: 'keydown', key: 'z', metaKey: true, shiftKey: true })).kind).toBe(
|
|
365
|
+
'redo'
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('Ctrl+Z → undo (Windows / Linux mac-less)', () => {
|
|
370
|
+
expect(classify(base({ type: 'keydown', key: 'z', ctrlKey: true })).kind).toBe('undo');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('Ctrl+Y → redo (Windows convention)', () => {
|
|
374
|
+
expect(classify(base({ type: 'keydown', key: 'y', ctrlKey: true })).kind).toBe('redo');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('Cmd+Y → redo (mac users with Windows muscle-memory)', () => {
|
|
378
|
+
expect(classify(base({ type: 'keydown', key: 'y', metaKey: true })).kind).toBe('redo');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('Cmd+Z inside editable → no-op (browser native text undo wins)', () => {
|
|
382
|
+
expect(
|
|
383
|
+
classify(base({ type: 'keydown', key: 'z', metaKey: true, isEditable: true })).kind
|
|
384
|
+
).toBe('no-op');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('Cmd+Opt+Z → no-op (Alt is reserved for browser text gestures)', () => {
|
|
388
|
+
expect(classify(base({ type: 'keydown', key: 'z', metaKey: true, altKey: true })).kind).toBe(
|
|
389
|
+
'no-op'
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('bare Z → no-op (Z is not a tool letter; needs Cmd)', () => {
|
|
394
|
+
expect(classify(base({ type: 'keydown', key: 'z' })).kind).toBe('no-op');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('Cmd+Shift+Y → no-op (only Cmd+Y; we do not over-claim)', () => {
|
|
398
|
+
expect(classify(base({ type: 'keydown', key: 'y', metaKey: true, shiftKey: true })).kind).toBe(
|
|
399
|
+
'no-op'
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// marquee-overlay — T26 (Wave 3). Aseprite-vocabulary modifier semantics
|
|
2
|
+
// table tests + applyMarqueeMode set-algebra tests. Pure functions, no DOM.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { applyMarqueeMode, modeOf } from '../marquee-overlay.tsx';
|
|
7
|
+
import type { Selection } from '../use-selection-set.tsx';
|
|
8
|
+
|
|
9
|
+
describe('marquee-overlay / modeOf', () => {
|
|
10
|
+
test('no modifiers → replace', () => {
|
|
11
|
+
expect(modeOf({ shiftKey: false, altKey: false })).toBe('replace');
|
|
12
|
+
});
|
|
13
|
+
test('shift → add', () => {
|
|
14
|
+
expect(modeOf({ shiftKey: true, altKey: false })).toBe('add');
|
|
15
|
+
});
|
|
16
|
+
test('alt → subtract', () => {
|
|
17
|
+
expect(modeOf({ shiftKey: false, altKey: true })).toBe('subtract');
|
|
18
|
+
});
|
|
19
|
+
test('shift+alt → intersect', () => {
|
|
20
|
+
expect(modeOf({ shiftKey: true, altKey: true })).toBe('intersect');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('marquee-overlay / applyMarqueeMode', () => {
|
|
25
|
+
// Stub `selSet` that records the last call so the test can assert outcome.
|
|
26
|
+
type Recorded = { method: 'replace' | 'add'; payload: Selection[] };
|
|
27
|
+
const makeStub = () => {
|
|
28
|
+
const log: Recorded[] = [];
|
|
29
|
+
const stub = {
|
|
30
|
+
replace: (s: Selection | Selection[]) => {
|
|
31
|
+
log.push({ method: 'replace', payload: Array.isArray(s) ? s : [s] });
|
|
32
|
+
},
|
|
33
|
+
add: (s: Selection | Selection[]) => {
|
|
34
|
+
log.push({ method: 'add', payload: Array.isArray(s) ? s : [s] });
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
return { stub, log };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const sel = (id: string): Selection => ({ id, selector: `[data-cd-id="${id}"]` });
|
|
41
|
+
|
|
42
|
+
test('replace with empty hits → no-op (selection preserved)', () => {
|
|
43
|
+
const { stub, log } = makeStub();
|
|
44
|
+
applyMarqueeMode(stub, 'replace', [sel('a')], []);
|
|
45
|
+
expect(log).toHaveLength(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('replace with hits → swaps to hits', () => {
|
|
49
|
+
const { stub, log } = makeStub();
|
|
50
|
+
applyMarqueeMode(stub, 'replace', [sel('a')], [sel('b'), sel('c')]);
|
|
51
|
+
expect(log).toEqual([{ method: 'replace', payload: [sel('b'), sel('c')] }]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('add with empty hits → no-op', () => {
|
|
55
|
+
const { stub, log } = makeStub();
|
|
56
|
+
applyMarqueeMode(stub, 'add', [sel('a')], []);
|
|
57
|
+
expect(log).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('add with hits → adds to existing', () => {
|
|
61
|
+
const { stub, log } = makeStub();
|
|
62
|
+
applyMarqueeMode(stub, 'add', [sel('a')], [sel('b')]);
|
|
63
|
+
expect(log).toEqual([{ method: 'add', payload: [sel('b')] }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('subtract removes the hit ids from startSet', () => {
|
|
67
|
+
const { stub, log } = makeStub();
|
|
68
|
+
applyMarqueeMode(stub, 'subtract', [sel('a'), sel('b'), sel('c')], [sel('b')]);
|
|
69
|
+
expect(log).toEqual([{ method: 'replace', payload: [sel('a'), sel('c')] }]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('subtract with no overlap → identity (replace with startSet)', () => {
|
|
73
|
+
const { stub, log } = makeStub();
|
|
74
|
+
applyMarqueeMode(stub, 'subtract', [sel('a'), sel('b')], [sel('z')]);
|
|
75
|
+
expect(log).toEqual([{ method: 'replace', payload: [sel('a'), sel('b')] }]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('intersect keeps only ids present in both sets', () => {
|
|
79
|
+
const { stub, log } = makeStub();
|
|
80
|
+
applyMarqueeMode(
|
|
81
|
+
stub,
|
|
82
|
+
'intersect',
|
|
83
|
+
[sel('a'), sel('b'), sel('c')],
|
|
84
|
+
[sel('b'), sel('c'), sel('d')]
|
|
85
|
+
);
|
|
86
|
+
expect(log).toEqual([{ method: 'replace', payload: [sel('b'), sel('c')] }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('intersect with empty hits → clears (empty replace)', () => {
|
|
90
|
+
const { stub, log } = makeStub();
|
|
91
|
+
applyMarqueeMode(stub, 'intersect', [sel('a'), sel('b')], []);
|
|
92
|
+
expect(log).toEqual([{ method: 'replace', payload: [] }]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ArtboardLayoutEntry,
|
|
5
|
+
createMoveArtboardsCommand,
|
|
6
|
+
diffLayoutPositions,
|
|
7
|
+
} from '../commands/move-artboards-command.ts';
|
|
8
|
+
|
|
9
|
+
const A: ArtboardLayoutEntry = { id: 'a', x: 0, y: 0, w: 100, h: 80 };
|
|
10
|
+
const B: ArtboardLayoutEntry = { id: 'b', x: 200, y: 0, w: 100, h: 80 };
|
|
11
|
+
const A2: ArtboardLayoutEntry = { id: 'a', x: 50, y: 25, w: 100, h: 80 };
|
|
12
|
+
const B2: ArtboardLayoutEntry = { id: 'b', x: 260, y: 0, w: 100, h: 80 };
|
|
13
|
+
|
|
14
|
+
describe('createMoveArtboardsCommand', () => {
|
|
15
|
+
test('do() patches with the AFTER snapshot', async () => {
|
|
16
|
+
const patchFn = mock(() => {});
|
|
17
|
+
const cmd = createMoveArtboardsCommand({ before: [A, B], after: [A2, B], patchFn });
|
|
18
|
+
await cmd.do();
|
|
19
|
+
expect(patchFn).toHaveBeenCalledTimes(1);
|
|
20
|
+
const arg = patchFn.mock.calls[0]?.[0];
|
|
21
|
+
expect(arg).toEqual([A2, B]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('undo() patches with the BEFORE snapshot', async () => {
|
|
25
|
+
const patchFn = mock(() => {});
|
|
26
|
+
const cmd = createMoveArtboardsCommand({ before: [A, B], after: [A2, B], patchFn });
|
|
27
|
+
await cmd.undo();
|
|
28
|
+
expect(patchFn).toHaveBeenCalledTimes(1);
|
|
29
|
+
const arg = patchFn.mock.calls[0]?.[0];
|
|
30
|
+
expect(arg).toEqual([A, B]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('redo idempotence — do() twice yields the same patch payload', async () => {
|
|
34
|
+
const patchFn = mock(() => {});
|
|
35
|
+
const cmd = createMoveArtboardsCommand({ before: [A, B], after: [A2, B2], patchFn });
|
|
36
|
+
await cmd.do();
|
|
37
|
+
await cmd.do();
|
|
38
|
+
expect(patchFn).toHaveBeenCalledTimes(2);
|
|
39
|
+
expect(patchFn.mock.calls[0]?.[0]).toEqual([A2, B2]);
|
|
40
|
+
expect(patchFn.mock.calls[1]?.[0]).toEqual([A2, B2]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('snapshots are deep-cloned — mutating the source after construction has no effect', async () => {
|
|
44
|
+
const before: ArtboardLayoutEntry[] = [{ ...A }, { ...B }];
|
|
45
|
+
const after: ArtboardLayoutEntry[] = [{ ...A2 }, { ...B }];
|
|
46
|
+
const patchFn = mock(() => {});
|
|
47
|
+
const cmd = createMoveArtboardsCommand({ before, after, patchFn });
|
|
48
|
+
// Poison the source arrays in place.
|
|
49
|
+
// biome-ignore lint/style/noNonNullAssertion: test invariant — arrays literally constructed two lines above
|
|
50
|
+
after[0]!.x = 9999;
|
|
51
|
+
// biome-ignore lint/style/noNonNullAssertion: test invariant — arrays literally constructed two lines above
|
|
52
|
+
before[1]!.y = -777;
|
|
53
|
+
await cmd.do();
|
|
54
|
+
expect(patchFn.mock.calls[0]?.[0]).toEqual([A2, B]);
|
|
55
|
+
await cmd.undo();
|
|
56
|
+
expect(patchFn.mock.calls[1]?.[0]).toEqual([A, B]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('label defaults to "move N artboards" with correct count', () => {
|
|
60
|
+
const cmd = createMoveArtboardsCommand({
|
|
61
|
+
before: [A, B],
|
|
62
|
+
after: [A2, B],
|
|
63
|
+
patchFn: () => {},
|
|
64
|
+
});
|
|
65
|
+
expect(cmd.label).toBe('move 1 artboard');
|
|
66
|
+
|
|
67
|
+
const cmd2 = createMoveArtboardsCommand({
|
|
68
|
+
before: [A, B],
|
|
69
|
+
after: [A2, B2],
|
|
70
|
+
patchFn: () => {},
|
|
71
|
+
});
|
|
72
|
+
expect(cmd2.label).toBe('move 2 artboards');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('label override (equal-spacing wraps with its own copy)', () => {
|
|
76
|
+
const cmd = createMoveArtboardsCommand({
|
|
77
|
+
before: [A, B],
|
|
78
|
+
after: [A2, B2],
|
|
79
|
+
patchFn: () => {},
|
|
80
|
+
label: 'equal-space 2 artboards',
|
|
81
|
+
});
|
|
82
|
+
expect(cmd.label).toBe('equal-space 2 artboards');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('diffLayoutPositions', () => {
|
|
87
|
+
test('identical → null (skip push)', () => {
|
|
88
|
+
expect(diffLayoutPositions([A, B], [A, B])).toBeNull();
|
|
89
|
+
expect(diffLayoutPositions([A, B], [{ ...A }, { ...B }])).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('1 moved → { changed: 1 }', () => {
|
|
93
|
+
expect(diffLayoutPositions([A, B], [A2, B])).toEqual({ changed: 1 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('both moved → { changed: 2 }', () => {
|
|
97
|
+
expect(diffLayoutPositions([A, B], [A2, B2])).toEqual({ changed: 2 });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('different array lengths → reports max (defensive — real flows align ids)', () => {
|
|
101
|
+
expect(diffLayoutPositions([A], [A, B])).toEqual({ changed: 2 });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('size-only diff (no position change) → null', () => {
|
|
105
|
+
const Aresized: ArtboardLayoutEntry = { id: 'a', x: 0, y: 0, w: 200, h: 160 };
|
|
106
|
+
expect(diffLayoutPositions([A], [Aresized])).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Unit: pure helpers from participants-chrome.tsx.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import { initialsFor } from '../participants-chrome.tsx';
|
|
6
|
+
|
|
7
|
+
describe('initialsFor', () => {
|
|
8
|
+
test('two-word name → uppercase first letters', () => {
|
|
9
|
+
expect(initialsFor('Alice Smith')).toBe('AS');
|
|
10
|
+
expect(initialsFor('michał dovrtěl')).toBe('MD');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('single-word name → first two letters uppercased', () => {
|
|
14
|
+
expect(initialsFor('alice')).toBe('AL');
|
|
15
|
+
expect(initialsFor('A')).toBe('A');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('many-word name → first letter of first two words', () => {
|
|
19
|
+
expect(initialsFor('John Ronald Reuel Tolkien')).toBe('JR');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('empty / whitespace → ?', () => {
|
|
23
|
+
expect(initialsFor('')).toBe('?');
|
|
24
|
+
expect(initialsFor(' ')).toBe('?');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('handles extra whitespace', () => {
|
|
28
|
+
expect(initialsFor(' Alice Smith ')).toBe('AS');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CommandRecord,
|
|
5
|
+
MAX_DEPTH,
|
|
6
|
+
_clearBuilderRegistry,
|
|
7
|
+
_clearStackStore,
|
|
8
|
+
canRedo,
|
|
9
|
+
canUndo,
|
|
10
|
+
createUndoStackState,
|
|
11
|
+
loadStackState,
|
|
12
|
+
peekRedo,
|
|
13
|
+
peekUndo,
|
|
14
|
+
rebuildCommand,
|
|
15
|
+
registerCommand,
|
|
16
|
+
saveStackState,
|
|
17
|
+
undoReducer,
|
|
18
|
+
} from '../undo-stack.ts';
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
_clearStackStore();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function rec(label = 'fake', payload: unknown = null): CommandRecord {
|
|
25
|
+
return { kind: 'test', label, payload };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('undoReducer', () => {
|
|
29
|
+
test('push appends record to past and clears future', () => {
|
|
30
|
+
const a = rec('a');
|
|
31
|
+
const b = rec('b');
|
|
32
|
+
let s = createUndoStackState();
|
|
33
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
34
|
+
expect(s.past).toEqual([a]);
|
|
35
|
+
expect(s.future).toEqual([]);
|
|
36
|
+
s = undoReducer(s, { type: 'push', record: b });
|
|
37
|
+
expect(s.past).toEqual([a, b]);
|
|
38
|
+
expect(s.future).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('double push preserves order', () => {
|
|
42
|
+
const a = rec('a');
|
|
43
|
+
const b = rec('b');
|
|
44
|
+
const c = rec('c');
|
|
45
|
+
let s = createUndoStackState();
|
|
46
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
47
|
+
s = undoReducer(s, { type: 'push', record: b });
|
|
48
|
+
s = undoReducer(s, { type: 'push', record: c });
|
|
49
|
+
expect(s.past).toEqual([a, b, c]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('undo pops past top into future', () => {
|
|
53
|
+
const a = rec('a');
|
|
54
|
+
const b = rec('b');
|
|
55
|
+
let s = createUndoStackState();
|
|
56
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
57
|
+
s = undoReducer(s, { type: 'push', record: b });
|
|
58
|
+
s = undoReducer(s, { type: 'undo' });
|
|
59
|
+
expect(s.past).toEqual([a]);
|
|
60
|
+
expect(s.future).toEqual([b]);
|
|
61
|
+
expect(peekUndo(s)).toBe(a);
|
|
62
|
+
expect(peekRedo(s)).toBe(b);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('undo on empty is a no-op (identity)', () => {
|
|
66
|
+
const s = createUndoStackState();
|
|
67
|
+
const next = undoReducer(s, { type: 'undo' });
|
|
68
|
+
expect(next).toBe(s);
|
|
69
|
+
expect(canUndo(next)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('redo pops future top back into past', () => {
|
|
73
|
+
const a = rec('a');
|
|
74
|
+
let s = createUndoStackState();
|
|
75
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
76
|
+
s = undoReducer(s, { type: 'undo' });
|
|
77
|
+
s = undoReducer(s, { type: 'redo' });
|
|
78
|
+
expect(s.past).toEqual([a]);
|
|
79
|
+
expect(s.future).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('redo on empty future is a no-op', () => {
|
|
83
|
+
const a = rec('a');
|
|
84
|
+
let s = createUndoStackState();
|
|
85
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
86
|
+
const next = undoReducer(s, { type: 'redo' });
|
|
87
|
+
expect(next).toBe(s);
|
|
88
|
+
expect(canRedo(next)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('push discards future branch (canonical undo-stack semantics)', () => {
|
|
92
|
+
const a = rec('a');
|
|
93
|
+
const b = rec('b');
|
|
94
|
+
const c = rec('c');
|
|
95
|
+
let s = createUndoStackState();
|
|
96
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
97
|
+
s = undoReducer(s, { type: 'push', record: b });
|
|
98
|
+
s = undoReducer(s, { type: 'undo' });
|
|
99
|
+
expect(s.future.length).toBe(1);
|
|
100
|
+
s = undoReducer(s, { type: 'push', record: c });
|
|
101
|
+
expect(s.past).toEqual([a, c]);
|
|
102
|
+
expect(s.future).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('depth cap drops oldest from past (ring buffer)', () => {
|
|
106
|
+
let s = createUndoStackState();
|
|
107
|
+
const records: CommandRecord[] = [];
|
|
108
|
+
for (let i = 0; i < MAX_DEPTH + 5; i++) {
|
|
109
|
+
const r = rec(`r-${i}`);
|
|
110
|
+
records.push(r);
|
|
111
|
+
s = undoReducer(s, { type: 'push', record: r });
|
|
112
|
+
}
|
|
113
|
+
expect(s.past.length).toBe(MAX_DEPTH);
|
|
114
|
+
// biome-ignore lint/style/noNonNullAssertion: records pushed sequentially above; index guaranteed in range
|
|
115
|
+
expect(s.past[0]).toBe(records[5]!);
|
|
116
|
+
// biome-ignore lint/style/noNonNullAssertion: records pushed sequentially above; index guaranteed in range
|
|
117
|
+
expect(s.past[s.past.length - 1]).toBe(records[records.length - 1]!);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('clear resets both stacks', () => {
|
|
121
|
+
const a = rec('a');
|
|
122
|
+
let s = createUndoStackState();
|
|
123
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
124
|
+
s = undoReducer(s, { type: 'undo' });
|
|
125
|
+
s = undoReducer(s, { type: 'clear' });
|
|
126
|
+
expect(s.past).toEqual([]);
|
|
127
|
+
expect(s.future).toEqual([]);
|
|
128
|
+
expect(canUndo(s)).toBe(false);
|
|
129
|
+
expect(canRedo(s)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('hydrate replaces both stacks atomically', () => {
|
|
133
|
+
const a = rec('a');
|
|
134
|
+
const b = rec('b');
|
|
135
|
+
let s = createUndoStackState();
|
|
136
|
+
s = undoReducer(s, { type: 'hydrate', state: { past: [a, b], future: [a] } });
|
|
137
|
+
expect(s.past).toEqual([a, b]);
|
|
138
|
+
expect(s.future).toEqual([a]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('command builder registry', () => {
|
|
143
|
+
test('registerCommand + rebuildCommand round-trip', () => {
|
|
144
|
+
_clearBuilderRegistry();
|
|
145
|
+
registerCommand('test', (record, sinks) => {
|
|
146
|
+
const fn = sinks.layoutPatchFn as ((layout: unknown) => void) | undefined;
|
|
147
|
+
if (!fn) return null;
|
|
148
|
+
return {
|
|
149
|
+
kind: record.kind,
|
|
150
|
+
label: record.label,
|
|
151
|
+
do() {
|
|
152
|
+
fn(record.payload);
|
|
153
|
+
},
|
|
154
|
+
undo() {
|
|
155
|
+
fn({ undone: true });
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
const record = rec('test-label', { foo: 'bar' });
|
|
160
|
+
const built = rebuildCommand(record, { layoutPatchFn: () => {} } as never);
|
|
161
|
+
expect(built).not.toBeNull();
|
|
162
|
+
expect(built?.label).toBe('test-label');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('rebuildCommand returns null for unknown kind', () => {
|
|
166
|
+
_clearBuilderRegistry();
|
|
167
|
+
const out = rebuildCommand({ kind: 'unknown', label: '?', payload: null }, {});
|
|
168
|
+
expect(out).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('rebuildCommand returns null when required sink is missing', () => {
|
|
172
|
+
_clearBuilderRegistry();
|
|
173
|
+
registerCommand('needs-sink', (_record, sinks) => {
|
|
174
|
+
if (!sinks.layoutPatchFn) return null;
|
|
175
|
+
return { kind: 'needs-sink', label: '', do() {}, undo() {} };
|
|
176
|
+
});
|
|
177
|
+
const out = rebuildCommand({ kind: 'needs-sink', label: '', payload: null }, {});
|
|
178
|
+
expect(out).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('cross-iframe persistence (loadStackState / saveStackState)', () => {
|
|
183
|
+
test('round-trips state under a canvas file key', () => {
|
|
184
|
+
const a = rec('a');
|
|
185
|
+
const initial = loadStackState('ui/Foo.tsx');
|
|
186
|
+
expect(initial).toEqual({ past: [], future: [] });
|
|
187
|
+
saveStackState('ui/Foo.tsx', { past: [a], future: [] });
|
|
188
|
+
expect(loadStackState('ui/Foo.tsx').past).toEqual([a]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('isolation across canvas files', () => {
|
|
192
|
+
saveStackState('ui/Foo.tsx', { past: [rec('foo')], future: [] });
|
|
193
|
+
saveStackState('ui/Bar.tsx', { past: [rec('bar-1'), rec('bar-2')], future: [] });
|
|
194
|
+
expect(loadStackState('ui/Foo.tsx').past.length).toBe(1);
|
|
195
|
+
expect(loadStackState('ui/Bar.tsx').past.length).toBe(2);
|
|
196
|
+
expect(loadStackState('ui/Baz.tsx').past).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('survives a "remount" simulation — load → save → reload yields same state', () => {
|
|
200
|
+
// Simulate: iframe A pushes 2 records, iframe A unmounts, iframe B
|
|
201
|
+
// mounts for the same canvas → sees both records.
|
|
202
|
+
saveStackState('ui/Persist.tsx', {
|
|
203
|
+
past: [rec('e1'), rec('e2')],
|
|
204
|
+
future: [rec('redo-me')],
|
|
205
|
+
});
|
|
206
|
+
// Pretend a remount happens — same window store, fresh load.
|
|
207
|
+
const reloaded = loadStackState('ui/Persist.tsx');
|
|
208
|
+
expect(reloaded.past.length).toBe(2);
|
|
209
|
+
expect(reloaded.future.length).toBe(1);
|
|
210
|
+
});
|
|
211
|
+
});
|