@1agh/maude 0.45.1 → 0.46.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/apps/studio/acp/bridge.ts +628 -74
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +344 -14
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-edit.ts +302 -3
- package/apps/studio/canvas-lib.tsx +203 -11
- package/apps/studio/canvas-shell.tsx +167 -28
- package/apps/studio/client/app.jsx +678 -176
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +846 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +82 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +689 -11
- package/apps/studio/dist/client.bundle.js +1738 -1736
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +246 -9
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/canvas-meta-api.test.ts +167 -0
- package/apps/studio/test/element-structural-edit.test.ts +250 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +21 -2
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/whats-new.json +62 -0
- package/cli/commands/design.mjs +116 -3
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +35 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// DDR-184 — the curated tool allow-list the bridge auto-approves so the design
|
|
2
|
+
// workflow never stalls on a per-edit / per-`maude` permission prompt (the
|
|
3
|
+
// "Manual mode blocks every edit" complaint). Two things under test:
|
|
4
|
+
//
|
|
5
|
+
// (1) WIRE contract — the list lands on `_meta.claudeCode.options.allowedTools`
|
|
6
|
+
// (SDK `allowedTools`, sdk.d.ts:1331), coexisting with the DDR-144
|
|
7
|
+
// `settingSources` narrowing and the DDR-143 `plugins` carrier. Same
|
|
8
|
+
// adapter/SDK-INTERNAL `_meta` path as acp-session-plugins.test.ts — a
|
|
9
|
+
// bump that stops forwarding it must fail HERE, not silently re-prompt.
|
|
10
|
+
// (2) SOURCE-OF-TRUTH guard — the single `Bash(maude:*)` rule only covers the
|
|
11
|
+
// whole design-helper surface because DDR-062 routes every helper through
|
|
12
|
+
// `maude design <verb>`. If that stops being true, or someone widens the
|
|
13
|
+
// Bash scope, this test fails loudly.
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { MAUDE_DEFAULT_ALLOWED_TOOLS, newSessionParams } from '../acp/bridge.ts';
|
|
20
|
+
import type { SdkPluginConfig } from '../acp/plugin-bootstrap.ts';
|
|
21
|
+
|
|
22
|
+
type Meta = {
|
|
23
|
+
systemPrompt?: { append?: string };
|
|
24
|
+
claudeCode?: {
|
|
25
|
+
options?: {
|
|
26
|
+
allowedTools?: string[];
|
|
27
|
+
plugins?: SdkPluginConfig[];
|
|
28
|
+
settingSources?: string[];
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const PLUGINS: SdkPluginConfig[] = [
|
|
34
|
+
{ type: 'local', path: '/bundle/plugins/design', skipMcpDiscovery: true },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
describe('newSessionParams — allowedTools carrier shape (DDR-184)', () => {
|
|
38
|
+
test('the allow-list rides _meta.claudeCode.options.allowedTools', () => {
|
|
39
|
+
const opts = (newSessionParams('/repo', undefined)._meta as Meta).claudeCode?.options;
|
|
40
|
+
expect(opts?.allowedTools).toEqual([...MAUDE_DEFAULT_ALLOWED_TOOLS]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('allowedTools is a COPY, not the shared module constant (no accidental mutation)', () => {
|
|
44
|
+
const opts = (newSessionParams('/repo')._meta as Meta).claudeCode?.options;
|
|
45
|
+
expect(opts?.allowedTools).not.toBe(MAUDE_DEFAULT_ALLOWED_TOOLS);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('coexists with settingSources (DDR-144) and plugins (DDR-143) under one options', () => {
|
|
49
|
+
const opts = (newSessionParams('/repo', 'BRIEF', PLUGINS)._meta as Meta).claudeCode?.options;
|
|
50
|
+
expect(opts?.settingSources).toEqual(['user']);
|
|
51
|
+
expect(opts?.plugins).toEqual(PLUGINS);
|
|
52
|
+
expect(opts?.allowedTools).toEqual([...MAUDE_DEFAULT_ALLOWED_TOOLS]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('present even with no plugins (npm / web / power-user path)', () => {
|
|
56
|
+
const opts = (newSessionParams('/repo', 'BRIEF', [])._meta as Meta).claudeCode?.options;
|
|
57
|
+
expect(opts?.plugins).toBeUndefined();
|
|
58
|
+
expect(opts?.allowedTools).toEqual([...MAUDE_DEFAULT_ALLOWED_TOOLS]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('MAUDE_DEFAULT_ALLOWED_TOOLS — source-of-truth guard (DDR-184 / DDR-062)', () => {
|
|
63
|
+
const bashRules = MAUDE_DEFAULT_ALLOWED_TOOLS.filter((t) => t.startsWith('Bash'));
|
|
64
|
+
|
|
65
|
+
test('the canvas-editing file tools are all present', () => {
|
|
66
|
+
for (const t of ['Read', 'Edit', 'Write', 'Glob', 'Grep', 'NotebookEdit']) {
|
|
67
|
+
expect(MAUDE_DEFAULT_ALLOWED_TOOLS).toContain(t);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the ONLY Bash rule is prefix-scoped to `maude` — never a bare/un-scoped Bash', () => {
|
|
72
|
+
// A bare `Bash` (or `Bash(*)`) would auto-approve arbitrary command execution
|
|
73
|
+
// — exactly the blanket surface DDR-179 kept behind the prompt. Guard against
|
|
74
|
+
// anyone widening it: the list may auto-approve `maude` and nothing else.
|
|
75
|
+
expect(bashRules).toEqual(['Bash(maude:*)']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the single Bash rule is load-bearing on DDR-062: every design helper is a `maude design <verb>`', () => {
|
|
79
|
+
// Resolve cli/commands/design.mjs relative to this test file's real path
|
|
80
|
+
// (mirrors acp-session-plugins.test.ts's realpath dance for compiled roots).
|
|
81
|
+
const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
|
|
82
|
+
const designCli = join(here, '..', '..', '..', 'cli', 'commands', 'design.mjs');
|
|
83
|
+
expect(existsSync(designCli)).toBe(true);
|
|
84
|
+
const src = readFileSync(designCli, 'utf8');
|
|
85
|
+
// The dispatch table the `Bash(maude:*)` scope rests on — helpers reached via
|
|
86
|
+
// `maude design <verb>`, not raw `bash <path>.sh` (DDR-062). If this marker
|
|
87
|
+
// disappears, the one-rule assumption needs re-checking.
|
|
88
|
+
expect(src).toContain('BIN_VERBS');
|
|
89
|
+
expect(src).toMatch(/maude design <verb>/);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Pure unit tests for client/panels/ToolGroup.jsx's grouping/summary helpers
|
|
2
|
+
// (Task C1 — collapsed consecutive-tool-call groups).
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { groupToolCalls, summarizeGroup } from '../client/panels/ToolGroup.jsx';
|
|
7
|
+
|
|
8
|
+
describe('groupToolCalls', () => {
|
|
9
|
+
test('a lone tool-call stays a single item, not a group', () => {
|
|
10
|
+
const parts = [{ type: 'tool-call', toolCallId: '1', toolName: 'Read file' }];
|
|
11
|
+
expect(groupToolCalls(parts)).toEqual([{ type: 'single', part: parts[0] }]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('2+ CONSECUTIVE tool-calls fold into one group', () => {
|
|
15
|
+
const parts = [
|
|
16
|
+
{ type: 'tool-call', toolCallId: '1', toolName: 'Read file' },
|
|
17
|
+
{ type: 'tool-call', toolCallId: '2', toolName: 'Run command' },
|
|
18
|
+
{ type: 'tool-call', toolCallId: '3', toolName: 'Write file' },
|
|
19
|
+
];
|
|
20
|
+
const grouped = groupToolCalls(parts);
|
|
21
|
+
expect(grouped).toEqual([{ type: 'tool-group', parts }]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('text/reasoning parts break a run into separate groups', () => {
|
|
25
|
+
const parts = [
|
|
26
|
+
{ type: 'tool-call', toolCallId: '1', toolName: 'Read file' },
|
|
27
|
+
{ type: 'tool-call', toolCallId: '2', toolName: 'Write file' },
|
|
28
|
+
{ type: 'text', text: 'done reading and writing' },
|
|
29
|
+
{ type: 'tool-call', toolCallId: '3', toolName: 'Run command' },
|
|
30
|
+
];
|
|
31
|
+
const grouped = groupToolCalls(parts);
|
|
32
|
+
expect(grouped).toEqual([
|
|
33
|
+
{ type: 'tool-group', parts: [parts[0], parts[1]] },
|
|
34
|
+
{ type: 'single', part: parts[2] },
|
|
35
|
+
{ type: 'single', part: parts[3] }, // lone tool-call after the break — not a group
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a mix of text and reasoning parts each stay single, in order', () => {
|
|
40
|
+
const parts = [
|
|
41
|
+
{ type: 'text', text: 'thinking about it' },
|
|
42
|
+
{ type: 'reasoning', text: '...' },
|
|
43
|
+
{ type: 'text', text: 'here is my answer' },
|
|
44
|
+
];
|
|
45
|
+
expect(groupToolCalls(parts)).toEqual(parts.map((part) => ({ type: 'single', part })));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('tolerates an empty/missing parts array', () => {
|
|
49
|
+
expect(groupToolCalls([])).toEqual([]);
|
|
50
|
+
expect(groupToolCalls(undefined)).toEqual([]);
|
|
51
|
+
expect(groupToolCalls(null)).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('summarizeGroup', () => {
|
|
56
|
+
test('all entries sharing one title collapse to "Ran N × <title>"', () => {
|
|
57
|
+
const parts = [{ toolName: 'Read file' }, { toolName: 'Read file' }, { toolName: 'Read file' }];
|
|
58
|
+
expect(summarizeGroup(parts)).toBe('Ran 3 × Read file');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('mixed titles list the distinct ones, capped at 3 with an ellipsis', () => {
|
|
62
|
+
const parts = [
|
|
63
|
+
{ toolName: 'Read file' },
|
|
64
|
+
{ toolName: 'Run command' },
|
|
65
|
+
{ toolName: 'Web search' },
|
|
66
|
+
{ toolName: 'Write file' },
|
|
67
|
+
{ toolName: 'Read file' }, // duplicate — must not inflate the unique count
|
|
68
|
+
];
|
|
69
|
+
const summary = summarizeGroup(parts);
|
|
70
|
+
expect(summary).toBe('Ran 5 tools — Read file, Run command, Web search…');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a missing toolName falls back to the generic label, not undefined', () => {
|
|
74
|
+
expect(summarizeGroup([{}, {}])).toBe('Ran 2 × tool');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Pure unit tests for client/panels/transcript-view.js (Task C4).
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_TRANSCRIPT_VIEW,
|
|
7
|
+
filterTranscriptParts,
|
|
8
|
+
TRANSCRIPT_VIEWS,
|
|
9
|
+
transcriptForcesExpand,
|
|
10
|
+
} from '../client/panels/transcript-view.js';
|
|
11
|
+
|
|
12
|
+
const PARTS = [
|
|
13
|
+
{ type: 'reasoning', text: 'let me think' },
|
|
14
|
+
{ type: 'tool-call', toolCallId: '1', toolName: 'Read file' },
|
|
15
|
+
{ type: 'text', text: 'first answer' },
|
|
16
|
+
{ type: 'reasoning', text: 'more thinking' },
|
|
17
|
+
{ type: 'text', text: 'final answer' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe('filterTranscriptParts', () => {
|
|
21
|
+
test('normal hides reasoning, keeps text + tool-calls in order', () => {
|
|
22
|
+
const result = filterTranscriptParts(PARTS, 'normal');
|
|
23
|
+
expect(result.map((p) => p.type)).toEqual(['tool-call', 'text', 'text']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('thinking keeps everything, including reasoning', () => {
|
|
27
|
+
expect(filterTranscriptParts(PARTS, 'thinking')).toEqual(PARTS);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('verbose also keeps everything (the extra verbosity is a render-detail toggle)', () => {
|
|
31
|
+
expect(filterTranscriptParts(PARTS, 'verbose')).toEqual(PARTS);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('summary keeps ONLY the last text part', () => {
|
|
35
|
+
const result = filterTranscriptParts(PARTS, 'summary');
|
|
36
|
+
expect(result).toEqual([{ type: 'text', text: 'final answer' }]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('summary with no text parts at all returns empty, not a crash', () => {
|
|
40
|
+
expect(filterTranscriptParts([{ type: 'tool-call', toolCallId: '1' }], 'summary')).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('an unrecognized mode fails toward normal (less noise), not everything', () => {
|
|
44
|
+
expect(filterTranscriptParts(PARTS, 'nonsense')).toEqual(
|
|
45
|
+
filterTranscriptParts(PARTS, 'normal')
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('tolerates missing/empty input', () => {
|
|
50
|
+
expect(filterTranscriptParts(undefined, 'normal')).toEqual([]);
|
|
51
|
+
expect(filterTranscriptParts(null, 'thinking')).toEqual([]);
|
|
52
|
+
expect(filterTranscriptParts([], 'summary')).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('transcriptForcesExpand', () => {
|
|
57
|
+
test('only verbose forces expansion', () => {
|
|
58
|
+
expect(transcriptForcesExpand('verbose')).toBe(true);
|
|
59
|
+
expect(transcriptForcesExpand('normal')).toBe(false);
|
|
60
|
+
expect(transcriptForcesExpand('thinking')).toBe(false);
|
|
61
|
+
expect(transcriptForcesExpand('summary')).toBe(false);
|
|
62
|
+
expect(transcriptForcesExpand(undefined)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('constants', () => {
|
|
67
|
+
test('the view list + default are stable and consistent', () => {
|
|
68
|
+
expect(TRANSCRIPT_VIEWS).toEqual(['normal', 'thinking', 'verbose', 'summary']);
|
|
69
|
+
expect(TRANSCRIPT_VIEWS).toContain(DEFAULT_TRANSCRIPT_VIEW);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// acp/transcript — repo-level chat list + raw-transcript → clean-messages.
|
|
2
2
|
|
|
3
3
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
4
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
deleteChat,
|
|
10
|
+
listChats,
|
|
11
|
+
readChatMessages,
|
|
12
|
+
readChatMeta,
|
|
13
|
+
writeChatMeta,
|
|
14
|
+
} from '../acp/transcript.ts';
|
|
9
15
|
|
|
10
16
|
let root: string;
|
|
11
17
|
afterEach(() => {
|
|
@@ -109,6 +115,73 @@ describe('deleteChat', () => {
|
|
|
109
115
|
expect(listChats(designRoot).length).toBe(0);
|
|
110
116
|
expect(deleteChat(designRoot, 'gone')).toBe(false);
|
|
111
117
|
});
|
|
118
|
+
|
|
119
|
+
test('also removes the .meta.json and .session.json sidecars (Task C5)', () => {
|
|
120
|
+
const designRoot = seed('withmeta', [{ ts: 1, role: 'user', text: 'hi' }]);
|
|
121
|
+
writeChatMeta(designRoot, 'withmeta', { title: 'Renamed' });
|
|
122
|
+
writeFileSync(
|
|
123
|
+
join(designRoot, '_chat', 'withmeta.session.json'),
|
|
124
|
+
JSON.stringify({ sessionId: 'x' })
|
|
125
|
+
);
|
|
126
|
+
expect(deleteChat(designRoot, 'withmeta')).toBe(true);
|
|
127
|
+
expect(readChatMeta(designRoot, 'withmeta')).toEqual({});
|
|
128
|
+
expect(existsSync(join(designRoot, '_chat', 'withmeta.session.json'))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('readChatMeta / writeChatMeta (Task C5 — rename + archive)', () => {
|
|
133
|
+
test('missing sidecar → {} (no override), never throws', () => {
|
|
134
|
+
const designRoot = seed('nometa', [{ ts: 1, role: 'user', text: 'hi' }]);
|
|
135
|
+
expect(readChatMeta(designRoot, 'nometa')).toEqual({});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a corrupt sidecar degrades to {} rather than throwing', () => {
|
|
139
|
+
const designRoot = seed('badmeta', [{ ts: 1, role: 'user', text: 'hi' }]);
|
|
140
|
+
writeFileSync(join(designRoot, '_chat', 'badmeta.meta.json'), '{not valid json');
|
|
141
|
+
expect(readChatMeta(designRoot, 'badmeta')).toEqual({});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('writeChatMeta sets a title and it wins over the auto-derived one in listChats', () => {
|
|
145
|
+
const designRoot = seed('rn1', [
|
|
146
|
+
{ ts: 1, role: 'user', text: 'the auto-derived first-line title' },
|
|
147
|
+
]);
|
|
148
|
+
expect(listChats(designRoot)[0]?.title).toBe('the auto-derived first-line title');
|
|
149
|
+
expect(listChats(designRoot)[0]?.renamed).toBeFalsy();
|
|
150
|
+
writeChatMeta(designRoot, 'rn1', { title: 'My Renamed Chat' });
|
|
151
|
+
expect(listChats(designRoot)[0]?.title).toBe('My Renamed Chat');
|
|
152
|
+
expect(listChats(designRoot)[0]?.renamed).toBe(true);
|
|
153
|
+
expect(readChatMeta(designRoot, 'rn1')).toEqual({ title: 'My Renamed Chat' });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('writeChatMeta merges — setting archived does not clobber an existing title', () => {
|
|
157
|
+
const designRoot = seed('rn2', [{ ts: 1, role: 'user', text: 'hi' }]);
|
|
158
|
+
writeChatMeta(designRoot, 'rn2', { title: 'Kept Title' });
|
|
159
|
+
writeChatMeta(designRoot, 'rn2', { archived: true });
|
|
160
|
+
expect(readChatMeta(designRoot, 'rn2')).toEqual({ title: 'Kept Title', archived: true });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('archived chats are excluded from listChats but the transcript survives', () => {
|
|
164
|
+
const designRoot = seed('arch1', [{ ts: 1, role: 'user', text: 'archive me' }]);
|
|
165
|
+
writeChatMeta(designRoot, 'arch1', { archived: true });
|
|
166
|
+
expect(listChats(designRoot)).toEqual([]);
|
|
167
|
+
expect(readChatMessages(designRoot, 'arch1').length).toBe(1); // still readable directly
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('unarchiving (archived: false) brings the chat back into listChats', () => {
|
|
171
|
+
const designRoot = seed('arch2', [{ ts: 1, role: 'user', text: 'x' }]);
|
|
172
|
+
writeChatMeta(designRoot, 'arch2', { archived: true });
|
|
173
|
+
expect(listChats(designRoot)).toEqual([]);
|
|
174
|
+
writeChatMeta(designRoot, 'arch2', { archived: false });
|
|
175
|
+
expect(listChats(designRoot).length).toBe(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('clearing a title (title: null) falls back to the auto-derived one again', () => {
|
|
179
|
+
const designRoot = seed('rn3', [{ ts: 1, role: 'user', text: 'original first line' }]);
|
|
180
|
+
writeChatMeta(designRoot, 'rn3', { title: 'Overridden' });
|
|
181
|
+
expect(listChats(designRoot)[0]?.title).toBe('Overridden');
|
|
182
|
+
writeChatMeta(designRoot, 'rn3', { title: null });
|
|
183
|
+
expect(listChats(designRoot)[0]?.title).toBe('original first line');
|
|
184
|
+
});
|
|
112
185
|
});
|
|
113
186
|
|
|
114
187
|
describe('context-hardening projection (feature-acp-context-hardening)', () => {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Proves the usage-channel harvest (Milestone D, Task D1) end-to-end against
|
|
2
|
+
// a mock ACP agent (fixtures/mock-acp-agent-usage.mjs) — no real `claude`
|
|
3
|
+
// needed. Covers: onUsage fires with {used,size,cost}, the rate-limit _meta
|
|
4
|
+
// payload is carried through opaque, usage_update never leaks into onUpdate
|
|
5
|
+
// (chrome, not turn content), and the Acp manager caches + replays it to a
|
|
6
|
+
// freshly-opened socket.
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import type { ServerWebSocket } from 'bun';
|
|
12
|
+
|
|
13
|
+
import { AcpBridge, type BridgeUsage } from '../acp/bridge.ts';
|
|
14
|
+
import { createAcp } from '../acp/index.ts';
|
|
15
|
+
import type { Context } from '../context.ts';
|
|
16
|
+
import type { WsData } from '../ws.ts';
|
|
17
|
+
|
|
18
|
+
const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent-usage.mjs');
|
|
19
|
+
const TEST_ENV_KEYS = ['MAUDE_ACP_ADAPTER_ENTRY', 'MAUDE_ACP_RUNTIME', 'MAUDE_CLAUDE_BIN'];
|
|
20
|
+
|
|
21
|
+
function useMockAgent() {
|
|
22
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
23
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
24
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
for (const key of TEST_ENV_KEYS) delete process.env[key];
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function until<T>(fn: () => T | undefined, timeoutMs = 12000): Promise<T> {
|
|
32
|
+
const start = performance.now();
|
|
33
|
+
for (;;) {
|
|
34
|
+
const v = fn();
|
|
35
|
+
if (v !== undefined) return v;
|
|
36
|
+
if (performance.now() - start > timeoutMs) throw new Error('timeout waiting for condition');
|
|
37
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fakeWs(id: string) {
|
|
42
|
+
const frames: Array<Record<string, unknown>> = [];
|
|
43
|
+
const ws = {
|
|
44
|
+
data: { id } as WsData,
|
|
45
|
+
send: (raw: string) => {
|
|
46
|
+
frames.push(JSON.parse(raw));
|
|
47
|
+
},
|
|
48
|
+
} as unknown as ServerWebSocket<WsData>;
|
|
49
|
+
return { ws, frames };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('AcpBridge — usage harvest (Task D1)', () => {
|
|
53
|
+
test('onUsage fires with used/size/cost; usage_update never reaches onUpdate', async () => {
|
|
54
|
+
useMockAgent();
|
|
55
|
+
const usages: BridgeUsage[] = [];
|
|
56
|
+
const updates: unknown[] = [];
|
|
57
|
+
const bridge = new AcpBridge({
|
|
58
|
+
repoRoot: process.cwd(),
|
|
59
|
+
onUpdate: (u) => updates.push(u),
|
|
60
|
+
onUsage: (u) => usages.push(u),
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
await bridge.prompt('hi', 'c1');
|
|
64
|
+
const usage = await until(() => (usages.length ? usages[usages.length - 1] : undefined));
|
|
65
|
+
expect(usage.used).toBe(4200);
|
|
66
|
+
expect(usage.size).toBe(200000);
|
|
67
|
+
expect(usage.cost).toEqual({ amount: 0.0123, currency: 'USD' });
|
|
68
|
+
expect(bridge.usage).toEqual(usage);
|
|
69
|
+
// chrome, not turn content — no usage_update leaked into onUpdate
|
|
70
|
+
expect(
|
|
71
|
+
updates.some((u) => (u as { sessionUpdate?: string }).sessionUpdate === 'usage_update')
|
|
72
|
+
).toBe(false);
|
|
73
|
+
} finally {
|
|
74
|
+
await bridge.stop();
|
|
75
|
+
}
|
|
76
|
+
}, 15000);
|
|
77
|
+
|
|
78
|
+
test('the rate-limit _meta payload is carried through opaque', async () => {
|
|
79
|
+
useMockAgent();
|
|
80
|
+
const usages: BridgeUsage[] = [];
|
|
81
|
+
const bridge = new AcpBridge({
|
|
82
|
+
repoRoot: process.cwd(),
|
|
83
|
+
onUpdate: () => {},
|
|
84
|
+
onUsage: (u) => usages.push(u),
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
await bridge.prompt('trigger-rate-limit', 'c1');
|
|
88
|
+
const withRateLimit = await until(() => usages.find((u) => u.rateLimit != null));
|
|
89
|
+
expect(withRateLimit.rateLimit).toMatchObject({
|
|
90
|
+
status: 'allowed_warning',
|
|
91
|
+
rateLimitType: 'five_hour',
|
|
92
|
+
utilization: 82,
|
|
93
|
+
});
|
|
94
|
+
} finally {
|
|
95
|
+
await bridge.stop();
|
|
96
|
+
}
|
|
97
|
+
}, 15000);
|
|
98
|
+
|
|
99
|
+
test('no usage yet → bridge.usage is null', async () => {
|
|
100
|
+
useMockAgent();
|
|
101
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
102
|
+
try {
|
|
103
|
+
expect(bridge.usage).toBeNull();
|
|
104
|
+
} finally {
|
|
105
|
+
await bridge.stop();
|
|
106
|
+
}
|
|
107
|
+
}, 15000);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('Acp manager — usage frame is cached + replayed on a fresh socket (Task D1)', () => {
|
|
111
|
+
test('a usage frame is broadcast during a turn, then replayed to a newly-opened socket', async () => {
|
|
112
|
+
useMockAgent();
|
|
113
|
+
const ctx = {
|
|
114
|
+
paths: { repoRoot: process.cwd(), designRoot: '/tmp/does-not-matter-usage' },
|
|
115
|
+
} as unknown as Context;
|
|
116
|
+
const acp = createAcp(ctx);
|
|
117
|
+
|
|
118
|
+
const a = fakeWs('usage-ws-a');
|
|
119
|
+
try {
|
|
120
|
+
acp.onOpen(a.ws);
|
|
121
|
+
acp.onMessage(a.ws, JSON.stringify({ t: 'prompt', text: 'hi', chat: 'c1' }));
|
|
122
|
+
const usageFrame = await until(() => a.frames.find((f) => f.t === 'usage'));
|
|
123
|
+
expect((usageFrame.usage as BridgeUsage).used).toBe(4200);
|
|
124
|
+
|
|
125
|
+
const b = fakeWs('usage-ws-b');
|
|
126
|
+
acp.onOpen(b.ws);
|
|
127
|
+
const replay = b.frames.find((f) => f.t === 'usage');
|
|
128
|
+
if (!replay) throw new Error('expected a replayed usage frame');
|
|
129
|
+
expect((replay.usage as BridgeUsage).used).toBe(4200);
|
|
130
|
+
acp.onClose(b.ws);
|
|
131
|
+
} finally {
|
|
132
|
+
acp.onClose(a.ws);
|
|
133
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
134
|
+
}
|
|
135
|
+
}, 20000);
|
|
136
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Pure unit tests for client/panels/acp-usage.js — parseUsage (Tasks D2/D4).
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import { parseUsage } from '../client/panels/acp-usage.js';
|
|
6
|
+
|
|
7
|
+
const NOW = 1_700_000_000_000;
|
|
8
|
+
|
|
9
|
+
describe('parseUsage — context + cost', () => {
|
|
10
|
+
test('computes a rounded percentage from used/size', () => {
|
|
11
|
+
const result = parseUsage({ t: 'usage', usage: { used: 4200, size: 200000 } }, NOW);
|
|
12
|
+
expect(result.context).toEqual({ used: 4200, size: 200000, pct: 2 });
|
|
13
|
+
expect(result.asOf).toBe(NOW);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('clamps the percentage to [0,100] against a bogus used > size', () => {
|
|
17
|
+
const result = parseUsage({ usage: { used: 999999, size: 1000 } }, NOW);
|
|
18
|
+
expect(result.context?.pct).toBe(100);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('size:0 (or missing) yields no context gauge rather than dividing by zero', () => {
|
|
22
|
+
expect(parseUsage({ usage: { used: 10, size: 0 } }, NOW).context).toBeNull();
|
|
23
|
+
expect(parseUsage({ usage: { used: 10 } }, NOW).context).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('carries cost through, defaulting currency to USD when absent', () => {
|
|
27
|
+
const result = parseUsage(
|
|
28
|
+
{ usage: { used: 1, size: 2, cost: { amount: 0.5, currency: 'EUR' } } },
|
|
29
|
+
NOW
|
|
30
|
+
);
|
|
31
|
+
expect(result.cost).toEqual({ amount: 0.5, currency: 'EUR' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('no cost on the frame → null, not undefined/0', () => {
|
|
35
|
+
expect(parseUsage({ usage: { used: 1, size: 2 } }, NOW).cost).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('parseUsage — rate limit (SDKRateLimitInfo fixtures)', () => {
|
|
40
|
+
const TYPES = [
|
|
41
|
+
['five_hour', '5-hour limit'],
|
|
42
|
+
['seven_day', 'Weekly limit'],
|
|
43
|
+
['seven_day_opus', 'Weekly · Opus'],
|
|
44
|
+
['seven_day_sonnet', 'Weekly · Sonnet'],
|
|
45
|
+
['seven_day_overage_included', 'Weekly (overage included)'],
|
|
46
|
+
['overage', 'Overage'],
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
for (const [type, label] of TYPES) {
|
|
50
|
+
test(`maps rateLimitType "${type}" → "${label}"`, () => {
|
|
51
|
+
const result = parseUsage(
|
|
52
|
+
{
|
|
53
|
+
usage: {
|
|
54
|
+
used: 1,
|
|
55
|
+
size: 2,
|
|
56
|
+
rateLimit: {
|
|
57
|
+
status: 'allowed_warning',
|
|
58
|
+
rateLimitType: type,
|
|
59
|
+
utilization: 61,
|
|
60
|
+
resetsAt: 123,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
NOW
|
|
65
|
+
);
|
|
66
|
+
expect(result.rateLimit).toEqual({
|
|
67
|
+
type,
|
|
68
|
+
label,
|
|
69
|
+
pct: 61,
|
|
70
|
+
resetsAt: 123,
|
|
71
|
+
status: 'allowed_warning',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test('an unrecognized rateLimitType falls back to a generic label, not undefined', () => {
|
|
77
|
+
const result = parseUsage(
|
|
78
|
+
{
|
|
79
|
+
usage: {
|
|
80
|
+
used: 1,
|
|
81
|
+
size: 2,
|
|
82
|
+
rateLimit: { status: 'rejected', rateLimitType: 'something_new' },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
NOW
|
|
86
|
+
);
|
|
87
|
+
expect(result.rateLimit?.label).toBe('Usage limit');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('null/missing rate-limit on the frame → gauge-only (rateLimit: null), no crash', () => {
|
|
91
|
+
expect(parseUsage({ usage: { used: 1, size: 2, rateLimit: null } }, NOW).rateLimit).toBeNull();
|
|
92
|
+
expect(parseUsage({ usage: { used: 1, size: 2 } }, NOW).rateLimit).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('utilization/resetsAt clamp + tolerate missing fields', () => {
|
|
96
|
+
const result = parseUsage(
|
|
97
|
+
{ usage: { used: 1, size: 2, rateLimit: { status: 'allowed', utilization: 250 } } },
|
|
98
|
+
NOW
|
|
99
|
+
);
|
|
100
|
+
expect(result.rateLimit).toEqual({
|
|
101
|
+
type: null,
|
|
102
|
+
label: 'Usage limit',
|
|
103
|
+
pct: 100,
|
|
104
|
+
resetsAt: null,
|
|
105
|
+
status: 'allowed',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('parseUsage — malformed input tolerance', () => {
|
|
111
|
+
test('missing usage entirely → all-null shape, never throws', () => {
|
|
112
|
+
expect(parseUsage({}, NOW)).toEqual({ context: null, cost: null, rateLimit: null, asOf: NOW });
|
|
113
|
+
expect(parseUsage(undefined, NOW)).toEqual({
|
|
114
|
+
context: null,
|
|
115
|
+
cost: null,
|
|
116
|
+
rateLimit: null,
|
|
117
|
+
asOf: NOW,
|
|
118
|
+
});
|
|
119
|
+
expect(parseUsage(null, NOW)).toEqual({
|
|
120
|
+
context: null,
|
|
121
|
+
cost: null,
|
|
122
|
+
rateLimit: null,
|
|
123
|
+
asOf: NOW,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a malformed _meta-derived rateLimit (not an object) is tolerated', () => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
parseUsage({ usage: { used: 1, size: 2, rateLimit: 'not-an-object' } }, NOW)
|
|
130
|
+
).not.toThrow();
|
|
131
|
+
expect(
|
|
132
|
+
parseUsage({ usage: { used: 1, size: 2, rateLimit: 'not-an-object' } }, NOW).rateLimit
|
|
133
|
+
).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('defaults `now` to Date.now() when not injected', () => {
|
|
137
|
+
const before = Date.now();
|
|
138
|
+
const result = parseUsage({ usage: { used: 1, size: 2 } });
|
|
139
|
+
const after = Date.now();
|
|
140
|
+
expect(result.asOf).toBeGreaterThanOrEqual(before);
|
|
141
|
+
expect(result.asOf).toBeLessThanOrEqual(after);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -5,6 +5,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
5
5
|
import {
|
|
6
6
|
computeSnap,
|
|
7
7
|
GRID_PITCH_PX,
|
|
8
|
+
type GuideLineCandidate,
|
|
8
9
|
SNAP_THRESHOLD_PX,
|
|
9
10
|
type SnapBox,
|
|
10
11
|
} from '../annotations-snap.ts';
|
|
@@ -77,3 +78,58 @@ describe('computeSnap', () => {
|
|
|
77
78
|
expect(res.dy).toBe(0);
|
|
78
79
|
});
|
|
79
80
|
});
|
|
81
|
+
|
|
82
|
+
// feature-1-artboard-kinds-foundation, T7 — generic-layout-guide lines feed
|
|
83
|
+
// the same candidate pool as stroke/artboard bboxes.
|
|
84
|
+
describe('computeSnap / guide lines (T7)', () => {
|
|
85
|
+
test('leading edge snaps to a guide line within threshold', () => {
|
|
86
|
+
const guideLines: GuideLineCandidate[] = [{ axis: 'x', at: 100, from: 0, to: 1000 }];
|
|
87
|
+
const res = computeSnap(box(104, 300), [], SNAP_THRESHOLD_PX, { guideLines });
|
|
88
|
+
expect(res.dx).toBe(-4);
|
|
89
|
+
expect(res.guides).toHaveLength(1);
|
|
90
|
+
expect(res.guides[0]).toMatchObject({ axis: 'x', at: 100, from: 0, to: 1000 });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('center also tests against a guide line', () => {
|
|
94
|
+
// moving center x = 153, guide at 150 → dx -3.
|
|
95
|
+
const guideLines: GuideLineCandidate[] = [{ axis: 'y', at: 350, from: 0, to: 1000 }];
|
|
96
|
+
const res = computeSnap(box(103, 300), [], SNAP_THRESHOLD_PX, { guideLines });
|
|
97
|
+
// center y = 350, guide at 350 → dy 0 (exact), still emits a guide.
|
|
98
|
+
expect(res.dy).toBe(0);
|
|
99
|
+
expect(res.guides.find((g) => g.axis === 'y')).toMatchObject({ at: 350 });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('nearest-wins applies across BOTH sources — a closer guide beats a farther candidate', () => {
|
|
103
|
+
const guideLines: GuideLineCandidate[] = [{ axis: 'x', at: 105, from: 0, to: 1000 }];
|
|
104
|
+
const res = computeSnap(box(104, 300), [box(100, 0)], SNAP_THRESHOLD_PX, { guideLines });
|
|
105
|
+
// candidate edge (100) is 4 away; guide (105) is 1 away — guide wins.
|
|
106
|
+
expect(res.dx).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a closer stroke/artboard candidate beats a farther guide line', () => {
|
|
110
|
+
// candidate edge (100) is 4 away from x=104; guide (110) is 6 away — the
|
|
111
|
+
// candidate is strictly closer, so it wins even though the guide is
|
|
112
|
+
// still within the 6px threshold.
|
|
113
|
+
const guideLines: GuideLineCandidate[] = [{ axis: 'x', at: 110, from: 0, to: 1000 }];
|
|
114
|
+
const res = computeSnap(box(104, 300), [box(100, 0)], SNAP_THRESHOLD_PX, { guideLines });
|
|
115
|
+
expect(res.dx).toBe(-4);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('outside threshold, a guide line is ignored', () => {
|
|
119
|
+
const guideLines: GuideLineCandidate[] = [{ axis: 'x', at: 500, from: 0, to: 1000 }];
|
|
120
|
+
const res = computeSnap(box(104, 300), [], SNAP_THRESHOLD_PX, { guideLines });
|
|
121
|
+
expect(res.dx).toBe(0);
|
|
122
|
+
expect(res.guides).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('guide-line snap composes with the grid fallback on the OTHER axis', () => {
|
|
126
|
+
// x snaps to the guide line; y has no candidate → grid fallback fires.
|
|
127
|
+
const guideLines: GuideLineCandidate[] = [{ axis: 'x', at: 100, from: 0, to: 1000 }];
|
|
128
|
+
const res = computeSnap(box(104, 50), [], SNAP_THRESHOLD_PX, {
|
|
129
|
+
guideLines,
|
|
130
|
+
grid: GRID_PITCH_PX,
|
|
131
|
+
});
|
|
132
|
+
expect(res.dx).toBe(-4);
|
|
133
|
+
expect(res.dy).toBe(-2);
|
|
134
|
+
});
|
|
135
|
+
});
|