@1agh/maude 0.29.0 → 0.31.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 +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
existsSync,
|
|
12
12
|
mkdirSync,
|
|
13
13
|
mkdtempSync,
|
|
14
|
+
readdirSync,
|
|
14
15
|
readFileSync,
|
|
15
16
|
rmSync,
|
|
16
17
|
writeFileSync,
|
|
@@ -50,7 +51,6 @@ beforeEach(() => {
|
|
|
50
51
|
afterEach(() => {
|
|
51
52
|
// Node's process.env stringifies on assignment; assigning undefined yields
|
|
52
53
|
// the literal string "undefined". delete is the correct restoration.
|
|
53
|
-
// biome-ignore lint/performance/noDelete: process.env semantics.
|
|
54
54
|
if (cfgPathEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
|
|
55
55
|
else process.env.HUBS_CONFIG_PATH = cfgPathEnv;
|
|
56
56
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -424,14 +424,15 @@ describe('createSyncRuntime', () => {
|
|
|
424
424
|
await runtime?.stop();
|
|
425
425
|
});
|
|
426
426
|
|
|
427
|
-
test('
|
|
427
|
+
test('DDR-102: reconcile over divergent local content records a diverged conflict + snapshots both sides', async () => {
|
|
428
428
|
const url = 'https://hub.example.com';
|
|
429
429
|
writeHubsConfig(url, 'mau_test');
|
|
430
430
|
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
431
|
-
// Local disk has divergent, non-empty content.
|
|
431
|
+
// Local disk has divergent, non-empty content (and no journal — divergence).
|
|
432
432
|
writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>LOCAL EDIT</button>');
|
|
433
433
|
|
|
434
|
-
// Provider whose doc already carries different hub state.
|
|
434
|
+
// Provider whose doc already carries different hub state. No syncMeta
|
|
435
|
+
// stamp (older peer) → newest-wins falls back to hub.
|
|
435
436
|
const factory = () => {
|
|
436
437
|
const document = new Y.Doc();
|
|
437
438
|
document.getText('html').insert(0, '<button>HUB STATE</button>');
|
|
@@ -451,7 +452,25 @@ describe('createSyncRuntime', () => {
|
|
|
451
452
|
|
|
452
453
|
const status = runtime?.status();
|
|
453
454
|
expect(status?.conflicts.length).toBeGreaterThanOrEqual(1);
|
|
454
|
-
|
|
455
|
+
const conflict = status?.conflicts[0];
|
|
456
|
+
expect(conflict?.kind).toBe('cold-start-diverged');
|
|
457
|
+
expect(conflict?.winner).toBe('hub');
|
|
458
|
+
// Both pre-resolution versions were snapshotted to _history/<slug>/.
|
|
459
|
+
expect(conflict?.snapshots?.local).toBeDefined();
|
|
460
|
+
expect(conflict?.snapshots?.hub).toBeDefined();
|
|
461
|
+
const historyDir = join(ctx.paths.historyDir, 'ui-screen');
|
|
462
|
+
const blobs = readdirSync(historyDir).filter((f) => f.endsWith('.html'));
|
|
463
|
+
expect(blobs.length).toBe(2);
|
|
464
|
+
// The loser (local) is recoverable byte-identical from its snapshot.
|
|
465
|
+
const metas = readdirSync(historyDir).filter((f) => f.endsWith('.json'));
|
|
466
|
+
const snapshotContents = blobs.map((f) => readFileSync(join(historyDir, f), 'utf8'));
|
|
467
|
+
expect(snapshotContents).toContain('<button>LOCAL EDIT</button>');
|
|
468
|
+
expect(snapshotContents).toContain('<button>HUB STATE</button>');
|
|
469
|
+
expect(metas.length).toBe(2);
|
|
470
|
+
// Hub won → disk now carries hub state.
|
|
471
|
+
expect(readFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), 'utf8')).toBe(
|
|
472
|
+
'<button>HUB STATE</button>'
|
|
473
|
+
);
|
|
455
474
|
await runtime?.stop();
|
|
456
475
|
});
|
|
457
476
|
});
|
|
@@ -885,3 +904,339 @@ describe('toWsUrl', () => {
|
|
|
885
904
|
expect(toWsUrl('ws://localhost:1234')).toBe('ws://localhost:1234');
|
|
886
905
|
});
|
|
887
906
|
});
|
|
907
|
+
|
|
908
|
+
// DDR-102 — WS multiplexing: the default factory shares ONE
|
|
909
|
+
// HocuspocusProviderWebsocket per hub URL across all providers.
|
|
910
|
+
describe('createDefaultProviderFactory — shared socket multiplexing', () => {
|
|
911
|
+
class FakeEmitter {
|
|
912
|
+
handlers = new Map<string, Set<(arg?: unknown) => void>>();
|
|
913
|
+
on(evt: string, cb: (arg?: unknown) => void) {
|
|
914
|
+
if (!this.handlers.has(evt)) this.handlers.set(evt, new Set());
|
|
915
|
+
this.handlers.get(evt)?.add(cb);
|
|
916
|
+
}
|
|
917
|
+
off(evt: string, cb: (arg?: unknown) => void) {
|
|
918
|
+
this.handlers.get(evt)?.delete(cb);
|
|
919
|
+
}
|
|
920
|
+
emit(evt: string, arg?: unknown) {
|
|
921
|
+
for (const cb of this.handlers.get(evt) ?? []) cb(arg);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function makeFakeModule() {
|
|
926
|
+
const sockets: Array<{ url: string; destroyed: boolean }> = [];
|
|
927
|
+
const providers: Array<{
|
|
928
|
+
name: string;
|
|
929
|
+
attached: boolean;
|
|
930
|
+
destroyed: boolean;
|
|
931
|
+
socket: unknown;
|
|
932
|
+
}> = [];
|
|
933
|
+
|
|
934
|
+
class HocuspocusProviderWebsocket extends FakeEmitter {
|
|
935
|
+
url: string;
|
|
936
|
+
status = 'connecting';
|
|
937
|
+
destroyed = false;
|
|
938
|
+
constructor(cfg: { url: string }) {
|
|
939
|
+
super();
|
|
940
|
+
this.url = cfg.url;
|
|
941
|
+
sockets.push(this);
|
|
942
|
+
}
|
|
943
|
+
destroy() {
|
|
944
|
+
this.destroyed = true;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
class HocuspocusProvider extends FakeEmitter {
|
|
949
|
+
configuration: { name: string; websocketProvider: unknown; document: unknown };
|
|
950
|
+
awareness = undefined;
|
|
951
|
+
synced = false;
|
|
952
|
+
attached = false;
|
|
953
|
+
destroyed = false;
|
|
954
|
+
name: string;
|
|
955
|
+
socket: unknown;
|
|
956
|
+
constructor(cfg: { name: string; websocketProvider: unknown; document: unknown }) {
|
|
957
|
+
super();
|
|
958
|
+
this.configuration = cfg;
|
|
959
|
+
this.name = cfg.name;
|
|
960
|
+
this.socket = cfg.websocketProvider;
|
|
961
|
+
providers.push(this);
|
|
962
|
+
}
|
|
963
|
+
attach() {
|
|
964
|
+
this.attached = true;
|
|
965
|
+
}
|
|
966
|
+
destroy() {
|
|
967
|
+
this.destroyed = true;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return { mod: { HocuspocusProviderWebsocket, HocuspocusProvider }, sockets, providers };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
test('N providers for one hub URL share ONE socket, each explicitly attached', async () => {
|
|
975
|
+
const { mod, sockets, providers } = makeFakeModule();
|
|
976
|
+
const { createDefaultProviderFactory } = await import('../sync/index.ts');
|
|
977
|
+
const factory = createDefaultProviderFactory(async () => mod);
|
|
978
|
+
|
|
979
|
+
await factory({ url: 'https://hub.example.com', token: 't', documentName: 'ui-a' });
|
|
980
|
+
await factory({ url: 'https://hub.example.com', token: 't', documentName: 'ui-b' });
|
|
981
|
+
await factory({ url: 'https://hub.example.com', token: 't', documentName: 'ui-c' });
|
|
982
|
+
|
|
983
|
+
expect(sockets).toHaveLength(1);
|
|
984
|
+
expect(sockets[0].url).toBe('wss://hub.example.com');
|
|
985
|
+
expect(providers).toHaveLength(3);
|
|
986
|
+
for (const p of providers) {
|
|
987
|
+
expect(p.attached).toBe(true);
|
|
988
|
+
expect(p.socket).toBe(sockets[0]);
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
test('provider destroy() detaches only; dispose() destroys the shared socket', async () => {
|
|
993
|
+
const { mod, sockets, providers } = makeFakeModule();
|
|
994
|
+
const { createDefaultProviderFactory } = await import('../sync/index.ts');
|
|
995
|
+
const factory = createDefaultProviderFactory(async () => mod);
|
|
996
|
+
|
|
997
|
+
const p1 = await factory({ url: 'https://h.example.com', token: 't', documentName: 'ui-a' });
|
|
998
|
+
p1.destroy();
|
|
999
|
+
expect(providers[0].destroyed).toBe(true);
|
|
1000
|
+
expect(sockets[0].destroyed).toBe(false); // socket survives provider death
|
|
1001
|
+
|
|
1002
|
+
factory.dispose();
|
|
1003
|
+
expect(sockets[0].destroyed).toBe(true);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
test('onStatus seeds from the SOCKET status and forwards provider status events', async () => {
|
|
1007
|
+
const { mod, sockets, providers } = makeFakeModule();
|
|
1008
|
+
const { createDefaultProviderFactory } = await import('../sync/index.ts');
|
|
1009
|
+
const factory = createDefaultProviderFactory(async () => mod);
|
|
1010
|
+
|
|
1011
|
+
const wrapped = await factory({
|
|
1012
|
+
url: 'https://h.example.com',
|
|
1013
|
+
token: 't',
|
|
1014
|
+
documentName: 'ui-a',
|
|
1015
|
+
});
|
|
1016
|
+
const seen: string[] = [];
|
|
1017
|
+
const unsub = wrapped.onStatus?.((s) => seen.push(s));
|
|
1018
|
+
// Seeded from socket.status ('connecting') immediately.
|
|
1019
|
+
expect(seen).toEqual(['connecting']);
|
|
1020
|
+
// Provider-level forwarded events keep flowing.
|
|
1021
|
+
(providers[0] as unknown as FakeEmitter).emit('status', { status: 'connected' });
|
|
1022
|
+
expect(seen).toEqual(['connecting', 'connected']);
|
|
1023
|
+
unsub?.();
|
|
1024
|
+
(providers[0] as unknown as FakeEmitter).emit('status', { status: 'disconnected' });
|
|
1025
|
+
expect(seen).toEqual(['connecting', 'connected']); // unsubscribed
|
|
1026
|
+
expect(sockets).toHaveLength(1);
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// DDR-102 — auth-failure intelligence (classification, aggregation,
|
|
1031
|
+
// destroy-on-permanent + re-probe) and the honest settled boot summary.
|
|
1032
|
+
describe('DDR-102 — auth-failure intelligence + boot summary', () => {
|
|
1033
|
+
function fakeTimerQueue() {
|
|
1034
|
+
let nextId = 1;
|
|
1035
|
+
const timers = new Map<number, { cb: () => void; ms: number }>();
|
|
1036
|
+
return {
|
|
1037
|
+
setTimer: (cb: () => void, ms: number) => {
|
|
1038
|
+
const id = nextId++;
|
|
1039
|
+
timers.set(id, { cb, ms });
|
|
1040
|
+
return id as unknown as ReturnType<typeof setTimeout>;
|
|
1041
|
+
},
|
|
1042
|
+
clearTimer: (h: ReturnType<typeof setTimeout>) => {
|
|
1043
|
+
timers.delete(h as unknown as number);
|
|
1044
|
+
},
|
|
1045
|
+
/** Fire every pending timer with ms <= maxMs (one sweep). */
|
|
1046
|
+
fire(maxMs: number) {
|
|
1047
|
+
for (const [id, t] of [...timers.entries()]) {
|
|
1048
|
+
if (t.ms <= maxMs) {
|
|
1049
|
+
timers.delete(id);
|
|
1050
|
+
t.cb();
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
},
|
|
1054
|
+
pending: () => timers.size,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Stub factory whose providers expose onAuthFailed + track destroys. */
|
|
1059
|
+
function authStubFactory() {
|
|
1060
|
+
const made: Array<{
|
|
1061
|
+
documentName: string;
|
|
1062
|
+
destroyed: boolean;
|
|
1063
|
+
emitAuthFailure: (reason: string) => void;
|
|
1064
|
+
document: Y.Doc;
|
|
1065
|
+
}> = [];
|
|
1066
|
+
const factory = (args: { documentName: string; document?: Y.Doc }) => {
|
|
1067
|
+
const document = args.document ?? new Y.Doc();
|
|
1068
|
+
const cbs = new Set<(info: { reason: string }) => void>();
|
|
1069
|
+
const entry = {
|
|
1070
|
+
documentName: args.documentName,
|
|
1071
|
+
destroyed: false,
|
|
1072
|
+
emitAuthFailure: (reason: string) => {
|
|
1073
|
+
for (const cb of cbs) cb({ reason });
|
|
1074
|
+
},
|
|
1075
|
+
document,
|
|
1076
|
+
};
|
|
1077
|
+
made.push(entry);
|
|
1078
|
+
return {
|
|
1079
|
+
document,
|
|
1080
|
+
onAuthFailed(cb: (info: { reason: string }) => void) {
|
|
1081
|
+
cbs.add(cb);
|
|
1082
|
+
return () => cbs.delete(cb);
|
|
1083
|
+
},
|
|
1084
|
+
onceSynced() {
|
|
1085
|
+
return new Promise<void>(() => {}); // never settles (rejected docs)
|
|
1086
|
+
},
|
|
1087
|
+
destroy() {
|
|
1088
|
+
entry.destroyed = true;
|
|
1089
|
+
},
|
|
1090
|
+
};
|
|
1091
|
+
};
|
|
1092
|
+
return { factory, made };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
test('classification + ONE aggregated warn + destroy-on-permanent + re-probe', async () => {
|
|
1096
|
+
const url = 'https://hub.example.com';
|
|
1097
|
+
writeHubsConfig(url, 'mau_test');
|
|
1098
|
+
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
1099
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
|
|
1100
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'b.html'), '<b/>');
|
|
1101
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'c.html'), '<c/>');
|
|
1102
|
+
|
|
1103
|
+
const timers = fakeTimerQueue();
|
|
1104
|
+
const { factory, made } = authStubFactory();
|
|
1105
|
+
const warns: string[] = [];
|
|
1106
|
+
const origWarn = console.warn;
|
|
1107
|
+
console.warn = (...args: unknown[]) => {
|
|
1108
|
+
warns.push(args.map(String).join(' '));
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
try {
|
|
1112
|
+
const runtime = createSyncRuntime(ctx, {
|
|
1113
|
+
providerFactory: factory,
|
|
1114
|
+
auth: {
|
|
1115
|
+
warnDebounceMs: 2_000,
|
|
1116
|
+
reprobeMs: 300_000,
|
|
1117
|
+
settleTimeoutMs: 15_000,
|
|
1118
|
+
setTimer: timers.setTimer,
|
|
1119
|
+
clearTimer: timers.clearTimer,
|
|
1120
|
+
},
|
|
1121
|
+
});
|
|
1122
|
+
await runtime?.start();
|
|
1123
|
+
expect(made).toHaveLength(3);
|
|
1124
|
+
// readdir order is not deterministic — address providers by slug.
|
|
1125
|
+
const bySlug = (slug: string) => {
|
|
1126
|
+
const m = made.find((p) => p.documentName === slug);
|
|
1127
|
+
if (!m) throw new Error(`no provider for ${slug}`);
|
|
1128
|
+
return m;
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
// Hub rejects: a + b permanently (scope), c transiently (rate limit).
|
|
1132
|
+
bySlug('ui-a').emitAuthFailure('token not authorized for this documentName');
|
|
1133
|
+
bySlug('ui-b').emitAuthFailure('token not authorized for this documentName');
|
|
1134
|
+
bySlug('ui-c').emitAuthFailure('rate limit exceeded for this token');
|
|
1135
|
+
|
|
1136
|
+
// Per-slug statuses are honest immediately.
|
|
1137
|
+
const status = runtime?.status();
|
|
1138
|
+
expect(status?.docs?.rejected).toBe(3);
|
|
1139
|
+
expect(status?.rejectedSlugs?.sort()).toEqual(['ui-a', 'ui-b', 'ui-c']);
|
|
1140
|
+
|
|
1141
|
+
// Permanent classes destroyed (retry storm stopped); transient keeps its
|
|
1142
|
+
// provider (built-in backoff).
|
|
1143
|
+
expect(bySlug('ui-a').destroyed).toBe(true);
|
|
1144
|
+
expect(bySlug('ui-b').destroyed).toBe(true);
|
|
1145
|
+
expect(bySlug('ui-c').destroyed).toBe(false);
|
|
1146
|
+
|
|
1147
|
+
// No warn yet (debounced); ONE aggregated warn after the window.
|
|
1148
|
+
const authWarnsBefore = warns.filter((w) => w.includes('auth rejections')).length;
|
|
1149
|
+
expect(authWarnsBefore).toBe(0);
|
|
1150
|
+
timers.fire(2_000);
|
|
1151
|
+
const authWarns = warns.filter((w) => w.includes('auth rejections'));
|
|
1152
|
+
expect(authWarns).toHaveLength(1);
|
|
1153
|
+
// Reason-specific hints, both classes in the one warn.
|
|
1154
|
+
expect(authWarns[0]).toContain('not-authorized');
|
|
1155
|
+
expect(authWarns[0]).toContain('rate-limit');
|
|
1156
|
+
expect(authWarns[0]).toContain('mint a hub-wide token');
|
|
1157
|
+
expect(authWarns[0]).toContain('HUB_CONN_RATE_LIMIT');
|
|
1158
|
+
|
|
1159
|
+
// Re-probe (5 min): the two permanently-rejected docs reconnect with the
|
|
1160
|
+
// SAME doc instance (agent wiring survives the provider swap).
|
|
1161
|
+
const docA = bySlug('ui-a').document;
|
|
1162
|
+
timers.fire(300_000);
|
|
1163
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
1164
|
+
const reprobed = made.slice(3);
|
|
1165
|
+
expect(reprobed.map((m) => m.documentName).sort()).toEqual(['ui-a', 'ui-b']);
|
|
1166
|
+
expect(reprobed.find((m) => m.documentName === 'ui-a')?.document).toBe(docA);
|
|
1167
|
+
|
|
1168
|
+
await runtime?.stop();
|
|
1169
|
+
} finally {
|
|
1170
|
+
console.warn = origWarn;
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
test('boot summary prints AFTER settle with honest counts (not the premature N/N)', async () => {
|
|
1175
|
+
const url = 'https://hub.example.com';
|
|
1176
|
+
writeHubsConfig(url, 'mau_test');
|
|
1177
|
+
const ctx = makeCtx({ url, linkedAt: 1 });
|
|
1178
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'good.html'), '<g/>');
|
|
1179
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'bad.html'), '<b/>');
|
|
1180
|
+
|
|
1181
|
+
const timers = fakeTimerQueue();
|
|
1182
|
+
const logs: string[] = [];
|
|
1183
|
+
const origLog = console.log;
|
|
1184
|
+
const origWarn = console.warn;
|
|
1185
|
+
console.log = (...args: unknown[]) => {
|
|
1186
|
+
logs.push(args.map(String).join(' '));
|
|
1187
|
+
};
|
|
1188
|
+
console.warn = () => {};
|
|
1189
|
+
|
|
1190
|
+
// 'good' syncs immediately; 'bad' is auth-rejected and never settles.
|
|
1191
|
+
const { factory, made } = authStubFactory();
|
|
1192
|
+
const mixedFactory = (args: { documentName: string; document?: Y.Doc }) => {
|
|
1193
|
+
if (args.documentName === 'ui-good') {
|
|
1194
|
+
const document = args.document ?? new Y.Doc();
|
|
1195
|
+
return {
|
|
1196
|
+
document,
|
|
1197
|
+
async onceSynced() {},
|
|
1198
|
+
destroy() {},
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
return factory(args);
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
try {
|
|
1205
|
+
const runtime = createSyncRuntime(ctx, {
|
|
1206
|
+
providerFactory: mixedFactory,
|
|
1207
|
+
auth: {
|
|
1208
|
+
settleTimeoutMs: 15_000,
|
|
1209
|
+
warnDebounceMs: 2_000,
|
|
1210
|
+
setTimer: timers.setTimer,
|
|
1211
|
+
clearTimer: timers.clearTimer,
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
await runtime?.start();
|
|
1215
|
+
made.find((m) => m.documentName === 'ui-bad')?.emitAuthFailure('invalid token');
|
|
1216
|
+
// Boot prints the linking line immediately, NOT a premature N/N.
|
|
1217
|
+
expect(logs.some((l) => l.includes('linking to'))).toBe(true);
|
|
1218
|
+
expect(logs.some((l) => l.includes('synced'))).toBe(false);
|
|
1219
|
+
|
|
1220
|
+
// Let the good handshake's microtask chain run, then hit the ceiling.
|
|
1221
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
1222
|
+
timers.fire(15_000);
|
|
1223
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
1224
|
+
|
|
1225
|
+
const summary = logs.find((l) => l.includes('synced'));
|
|
1226
|
+
expect(summary).toBeDefined();
|
|
1227
|
+
expect(summary).toContain('1/2 synced');
|
|
1228
|
+
expect(summary).toContain('1 auth-rejected');
|
|
1229
|
+
expect(summary).toContain('ui-bad');
|
|
1230
|
+
expect(summary).toContain('invalid-token');
|
|
1231
|
+
|
|
1232
|
+
// lastSyncAt reflects the real reconcile activity of the good canvas.
|
|
1233
|
+
expect(runtime?.status()?.lastSyncAt).not.toBeNull();
|
|
1234
|
+
expect(runtime?.status()?.docs?.synced).toBe(1);
|
|
1235
|
+
|
|
1236
|
+
await runtime?.stop();
|
|
1237
|
+
} finally {
|
|
1238
|
+
console.log = origLog;
|
|
1239
|
+
console.warn = origWarn;
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
@@ -78,3 +78,69 @@ describe('sync status store', () => {
|
|
|
78
78
|
expect(() => store.update(snap({ state: 'offline' }))).not.toThrow();
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
// DDR-102 — the payload carries the per-doc rollup + rich conflicts additively.
|
|
83
|
+
describe('sync status store — DDR-102 additive fields', () => {
|
|
84
|
+
test('docs + rejectedSlugs flow through update() into the payload', () => {
|
|
85
|
+
const writes: SyncStatusPayload[] = [];
|
|
86
|
+
const store = createSyncStatusStore({
|
|
87
|
+
url: 'https://h.example.com',
|
|
88
|
+
canvases: 3,
|
|
89
|
+
write: (p) => writes.push(p),
|
|
90
|
+
});
|
|
91
|
+
store.update({
|
|
92
|
+
state: 'online',
|
|
93
|
+
queuedOps: 0,
|
|
94
|
+
lastSyncAt: 123,
|
|
95
|
+
offlineSince: null,
|
|
96
|
+
flash: null,
|
|
97
|
+
updatedAt: 456,
|
|
98
|
+
docs: { synced: 1, pending: 0, rejected: 2 },
|
|
99
|
+
rejectedSlugs: ['ui-x', 'ui-y'],
|
|
100
|
+
});
|
|
101
|
+
const p = store.get();
|
|
102
|
+
expect(p.docs).toEqual({ synced: 1, pending: 0, rejected: 2 });
|
|
103
|
+
expect(p.rejectedSlugs).toEqual(['ui-x', 'ui-y']);
|
|
104
|
+
expect(p.lastSyncAt).toBe(123);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('addConflict carries winner + snapshots (cold-start-diverged)', () => {
|
|
108
|
+
const store = createSyncStatusStore({
|
|
109
|
+
url: 'https://h.example.com',
|
|
110
|
+
canvases: 1,
|
|
111
|
+
write: () => {},
|
|
112
|
+
now: () => 999,
|
|
113
|
+
});
|
|
114
|
+
store.addConflict({
|
|
115
|
+
slug: 'ui-maskot',
|
|
116
|
+
kind: 'cold-start-diverged',
|
|
117
|
+
winner: 'local',
|
|
118
|
+
snapshots: { local: '2026-06-11T10:00:00.000Z', hub: '2026-06-11T10:00:00.001Z' },
|
|
119
|
+
});
|
|
120
|
+
const c = store.get().conflicts[0];
|
|
121
|
+
expect(c.kind).toBe('cold-start-diverged');
|
|
122
|
+
expect(c.winner).toBe('local');
|
|
123
|
+
expect(c.snapshots?.local).toBe('2026-06-11T10:00:00.000Z');
|
|
124
|
+
expect(c.at).toBe(999);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('old-shape payload readers: a snapshot WITHOUT docs/rejectedSlugs still works', () => {
|
|
128
|
+
const store = createSyncStatusStore({
|
|
129
|
+
url: 'https://h.example.com',
|
|
130
|
+
canvases: 1,
|
|
131
|
+
write: () => {},
|
|
132
|
+
});
|
|
133
|
+
store.update({
|
|
134
|
+
state: 'online',
|
|
135
|
+
queuedOps: 0,
|
|
136
|
+
lastSyncAt: null,
|
|
137
|
+
offlineSince: null,
|
|
138
|
+
flash: null,
|
|
139
|
+
updatedAt: 1,
|
|
140
|
+
});
|
|
141
|
+
const p = store.get();
|
|
142
|
+
expect(p.docs).toBeUndefined();
|
|
143
|
+
expect(p.rejectedSlugs).toBeUndefined();
|
|
144
|
+
expect(p.state).toBe('online');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -43,6 +43,24 @@ describe('TourOverlay', () => {
|
|
|
43
43
|
expect(html).not.toContain('mdcc-tour__back'); // i === 0
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
test('a step with a render graphic widens the card and draws the graphic (Phase 29 / E4)', () => {
|
|
47
|
+
function Graphic() {
|
|
48
|
+
return <div className="cm-info">TWO-LAYER-DIAGRAM</div>;
|
|
49
|
+
}
|
|
50
|
+
const html = renderToStaticMarkup(
|
|
51
|
+
<TourOverlay
|
|
52
|
+
open
|
|
53
|
+
steps={[{ render: Graphic, title: 'Working together', body: 'Two layers.' }]}
|
|
54
|
+
onClose={() => {}}
|
|
55
|
+
onComplete={() => {}}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
expect(html).toContain('mdcc-tour__card--graphic');
|
|
59
|
+
expect(html).toContain('mdcc-tour__graphic');
|
|
60
|
+
expect(html).toContain('TWO-LAYER-DIAGRAM');
|
|
61
|
+
expect(html).toContain('Working together');
|
|
62
|
+
});
|
|
63
|
+
|
|
46
64
|
test('closed or empty renders nothing', () => {
|
|
47
65
|
expect(
|
|
48
66
|
renderToStaticMarkup(
|
|
@@ -241,3 +241,162 @@ describe('resizeStroke / pen — point scaling', () => {
|
|
|
241
241
|
expect(out.points[2]).toEqual([150, 150]);
|
|
242
242
|
});
|
|
243
243
|
});
|
|
244
|
+
|
|
245
|
+
describe('resizeStroke / rotation — Wave G corner rotate zones', () => {
|
|
246
|
+
// rect center = (50, 25). Angles below are measured around it.
|
|
247
|
+
test('relative rotation: grab angle is the zero reference (no jump)', () => {
|
|
248
|
+
// Grab at angle 0° (due east of center), drag to 45° → rotation 45.
|
|
249
|
+
const rotRef = { angle0: 0, rot0: 0 };
|
|
250
|
+
const out = resizeStroke(
|
|
251
|
+
rect,
|
|
252
|
+
'rot-se',
|
|
253
|
+
50 + 70,
|
|
254
|
+
25 + 70,
|
|
255
|
+
{ shift: false, alt: false },
|
|
256
|
+
rotRef
|
|
257
|
+
);
|
|
258
|
+
expect(out).toEqual({ rotation: 45 });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('starting rotation accumulates with the drag delta', () => {
|
|
262
|
+
const rotated: RectStroke = { ...rect, rotation: 30 };
|
|
263
|
+
const rotRef = { angle0: 0, rot0: 30 };
|
|
264
|
+
const out = resizeStroke(
|
|
265
|
+
rotated,
|
|
266
|
+
'rot-ne',
|
|
267
|
+
50 + 70,
|
|
268
|
+
25 + 70,
|
|
269
|
+
{ shift: false, alt: false },
|
|
270
|
+
rotRef
|
|
271
|
+
);
|
|
272
|
+
expect(out).toEqual({ rotation: 75 });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('magnetic cardinals: within 2° of 0 lock on and serialize as undefined', () => {
|
|
276
|
+
const rotated: RectStroke = { ...rect, rotation: 44 };
|
|
277
|
+
// Drag back by 42.5° → raw −1.5° + rot0 44 = ... use angle0 50, current 7.5 → deg = 44 − 42.5 = 1.5 → locks to 0.
|
|
278
|
+
const cur = (Math.PI / 180) * 7.5;
|
|
279
|
+
const out = resizeStroke(
|
|
280
|
+
rotated,
|
|
281
|
+
'rot-nw',
|
|
282
|
+
50 + 70 * Math.cos(cur),
|
|
283
|
+
25 + 70 * Math.sin(cur),
|
|
284
|
+
{ shift: false, alt: false },
|
|
285
|
+
{ angle0: 50, rot0: 44 }
|
|
286
|
+
);
|
|
287
|
+
expect(out).toEqual({ rotation: undefined });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('Shift snaps to 15° steps', () => {
|
|
291
|
+
// delta = 40° → snaps to 45.
|
|
292
|
+
const cur = (Math.PI / 180) * 40;
|
|
293
|
+
const out = resizeStroke(
|
|
294
|
+
rect,
|
|
295
|
+
'rot-sw',
|
|
296
|
+
50 + 70 * Math.cos(cur),
|
|
297
|
+
25 + 70 * Math.sin(cur),
|
|
298
|
+
{ shift: true, alt: false },
|
|
299
|
+
{ angle0: 0, rot0: 0 }
|
|
300
|
+
);
|
|
301
|
+
expect(out).toEqual({ rotation: 45 });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('non-rotatable strokes refuse the rotate corners', () => {
|
|
305
|
+
const arrow: ArrowStroke = {
|
|
306
|
+
id: 'a1',
|
|
307
|
+
tool: 'arrow',
|
|
308
|
+
color: '#000',
|
|
309
|
+
width: 2,
|
|
310
|
+
x1: 0,
|
|
311
|
+
y1: 0,
|
|
312
|
+
x2: 100,
|
|
313
|
+
y2: 0,
|
|
314
|
+
};
|
|
315
|
+
expect(
|
|
316
|
+
resizeStroke(arrow, 'rot-se', 50, 50, { shift: false, alt: false }, { angle0: 0, rot0: 0 })
|
|
317
|
+
).toBeNull();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('polygon rotates (diamond/triangle are rotatable hosts)', () => {
|
|
321
|
+
const poly: PolygonStroke = {
|
|
322
|
+
id: 'p1',
|
|
323
|
+
tool: 'polygon',
|
|
324
|
+
shape: 'diamond',
|
|
325
|
+
color: '#000',
|
|
326
|
+
width: 2,
|
|
327
|
+
x: 0,
|
|
328
|
+
y: 0,
|
|
329
|
+
w: 100,
|
|
330
|
+
h: 50,
|
|
331
|
+
};
|
|
332
|
+
const out = resizeStroke(
|
|
333
|
+
poly,
|
|
334
|
+
'rot-se',
|
|
335
|
+
50 + 70,
|
|
336
|
+
25 + 70,
|
|
337
|
+
{ shift: false, alt: false },
|
|
338
|
+
{ angle0: 0, rot0: 0 }
|
|
339
|
+
);
|
|
340
|
+
expect(out).toEqual({ rotation: 45 });
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('resizeStroke / rotated strokes — Wave H anchor compensation', () => {
|
|
345
|
+
// rect 100×50 at origin, rotated 90° around its center (50, 25).
|
|
346
|
+
const rotated: RectStroke = { ...rect, rotation: 90 };
|
|
347
|
+
const rotP = (px: number, py: number, cx: number, cy: number, deg: number): [number, number] => {
|
|
348
|
+
const r = (deg * Math.PI) / 180;
|
|
349
|
+
const dx = px - cx;
|
|
350
|
+
const dy = py - cy;
|
|
351
|
+
return [cx + dx * Math.cos(r) - dy * Math.sin(r), cy + dx * Math.sin(r) + dy * Math.cos(r)];
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
test('se-corner drag keeps the nw corner fixed in WORLD space', () => {
|
|
355
|
+
// Cursor in the LOCAL frame (applyResize inverse-rotates before calling).
|
|
356
|
+
const out = resizeStroke(rotated, 'se', 140, 90) as RectStroke;
|
|
357
|
+
expect(out.w).toBeCloseTo(140, 6);
|
|
358
|
+
expect(out.h).toBeCloseTo(90, 6);
|
|
359
|
+
const before = rotP(0, 0, 50, 25, 90);
|
|
360
|
+
const after = rotP(out.x, out.y, out.x + out.w / 2, out.y + out.h / 2, 90);
|
|
361
|
+
expect(after[0]).toBeCloseTo(before[0], 6);
|
|
362
|
+
expect(after[1]).toBeCloseTo(before[1], 6);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('e-edge drag keeps the west edge midpoint fixed in WORLD space', () => {
|
|
366
|
+
const out = resizeStroke(rotated, 'e', 130, 25) as RectStroke;
|
|
367
|
+
expect(out.w).toBeCloseTo(130, 6);
|
|
368
|
+
const before = rotP(0, 25, 50, 25, 90);
|
|
369
|
+
const after = rotP(out.x, out.y + out.h / 2, out.x + out.w / 2, out.y + out.h / 2, 90);
|
|
370
|
+
expect(after[0]).toBeCloseTo(before[0], 6);
|
|
371
|
+
expect(after[1]).toBeCloseTo(before[1], 6);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('Alt resize keeps the center (no compensation path)', () => {
|
|
375
|
+
const out = resizeStroke(rotated, 'se', 130, 80, { shift: false, alt: true }) as RectStroke;
|
|
376
|
+
expect(out.x + out.w / 2).toBeCloseTo(50, 6);
|
|
377
|
+
expect(out.y + out.h / 2).toBeCloseTo(25, 6);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('unrotated strokes are byte-stable (no shift applied)', () => {
|
|
381
|
+
expect(resizeStroke(rect, 'se', 120, 80)).toEqual({ x: 0, y: 0, w: 120, h: 80 });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('rotated ellipse: se drag keeps the nw bbox corner fixed in WORLD space', () => {
|
|
385
|
+
const ell: EllipseStroke = {
|
|
386
|
+
id: 'e1',
|
|
387
|
+
tool: 'ellipse',
|
|
388
|
+
color: '#000',
|
|
389
|
+
width: 2,
|
|
390
|
+
cx: 50,
|
|
391
|
+
cy: 25,
|
|
392
|
+
rx: 50,
|
|
393
|
+
ry: 25,
|
|
394
|
+
rotation: 45,
|
|
395
|
+
};
|
|
396
|
+
const out = resizeStroke(ell, 'se', 140, 90) as EllipseStroke;
|
|
397
|
+
const before = rotP(0, 0, 50, 25, 45);
|
|
398
|
+
const after = rotP(out.cx - out.rx, out.cy - out.ry, out.cx, out.cy, 45);
|
|
399
|
+
expect(after[0]).toBeCloseTo(before[0], 6);
|
|
400
|
+
expect(after[1]).toBeCloseTo(before[1], 6);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
Binary file
|