@miragon/modeler-api 1.1.0 → 1.2.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 +122 -22
- package/dist/chunk-E6KJB7QH.js +14 -0
- package/dist/chunk-E6KJB7QH.js.map +1 -0
- package/dist/conformance.cjs +192 -7
- package/dist/conformance.cjs.map +1 -1
- package/dist/conformance.d.cts +29 -9
- package/dist/conformance.d.ts +29 -9
- package/dist/conformance.js +188 -8
- package/dist/conformance.js.map +1 -1
- package/dist/index.cjs +11 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +117 -2
- package/dist/index.d.ts +117 -2
- package/dist/index.js +5 -3
- package/package.json +1 -1
- package/dist/chunk-32BMH76T.js +0 -7
- package/dist/chunk-32BMH76T.js.map +0 -1
package/dist/conformance.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CollaborativeModeler } from './index.js';
|
|
2
|
+
export { apiVersionsCompatible } from './index.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The conformance test kit — every law of the CollaborativeModeler contract
|
|
@@ -29,7 +30,8 @@ interface ConformanceHarness {
|
|
|
29
30
|
* document with a non-zero size (`attachedHost()` below) — diagram-js
|
|
30
31
|
* engines compute viewboxes from the host's client size, so a detached
|
|
31
32
|
* or 0×0 host yields NaN view state; they also need a real browser
|
|
32
|
-
* (vitest browser mode / Playwright), jsdom has no getBBox
|
|
33
|
+
* (vitest browser mode / Playwright), jsdom has no getBBox. Keep
|
|
34
|
+
* `fitOnImport` at its default: the L5 cases check the modeler's own fit
|
|
33
35
|
*/
|
|
34
36
|
create(): Promise<CollaborativeModeler>;
|
|
35
37
|
/** a fresh READ-ONLY instance (`meta.editable === false`) — the viewer
|
|
@@ -125,12 +127,26 @@ interface ConformanceHarness {
|
|
|
125
127
|
/** trigger the modeler's redo; `undefined` with `undo` defined = the redo
|
|
126
128
|
* half of the undo/redo case is reported as skipped */
|
|
127
129
|
redo?(modeler: CollaborativeModeler): Promise<void> | void;
|
|
130
|
+
/**
|
|
131
|
+
* move ONE element read from `canonicalTexts[0]` (imported, then one
|
|
132
|
+
* `mutate` added an element) through the modeler's own commands, the way
|
|
133
|
+
* a user drags it, far enough that its box changes position after any
|
|
134
|
+
* snapping, rounding or clamping — and return that element's id
|
|
135
|
+
* (ElementInfo.id). The bounds case requires `elements.bounds` to report
|
|
136
|
+
* the moved box. `undefined` = that half of the bounds case is reported as
|
|
137
|
+
* skipped (only when the modeler has `bounds`)
|
|
138
|
+
*/
|
|
139
|
+
moveElement?(modeler: CollaborativeModeler): Promise<string> | string;
|
|
128
140
|
/**
|
|
129
141
|
* open an INTERACTION on an element of `canonicalTexts[0]` (already
|
|
130
142
|
* imported) the way a user does — its label editor, or a popup with an
|
|
131
143
|
* input — and type input WITHOUT committing it (no Enter, no blur). The
|
|
132
|
-
* kit then imports over it
|
|
133
|
-
*
|
|
144
|
+
* kit then imports over it (L1) and changes the view over it (L5), and
|
|
145
|
+
* neither may commit it. Type the same input on every call, one the
|
|
146
|
+
* serializer writes verbatim: the L5 case looks for it in the export and
|
|
147
|
+
* compares what a commit writes with and without a view change in
|
|
148
|
+
* between. `undefined` = the L1 and L5 interaction cases are reported as
|
|
149
|
+
* skipped
|
|
134
150
|
*/
|
|
135
151
|
openInteraction?(modeler: CollaborativeModeler): Promise<void> | void;
|
|
136
152
|
/**
|
|
@@ -141,6 +157,15 @@ interface ConformanceHarness {
|
|
|
141
157
|
* of the L1 interaction case is reported as skipped
|
|
142
158
|
*/
|
|
143
159
|
interactionState?(modeler: CollaborativeModeler): InteractionState | undefined;
|
|
160
|
+
/**
|
|
161
|
+
* commit the interaction `openInteraction` opened, the way a user does
|
|
162
|
+
* (Enter, or a click on the canvas). The L5 interaction case commits once
|
|
163
|
+
* without a view change, proving the typed input changes the document,
|
|
164
|
+
* and once after a host's view change, checking the RECOMMENDED keep-open
|
|
165
|
+
* (an advisory, never a failure). `undefined` = both halves are reported
|
|
166
|
+
* as skipped
|
|
167
|
+
*/
|
|
168
|
+
commitInteraction?(modeler: CollaborativeModeler): Promise<void> | void;
|
|
144
169
|
}
|
|
145
170
|
/** an interaction in progress, as `ConformanceHarness.interactionState` reports it */
|
|
146
171
|
interface InteractionState {
|
|
@@ -173,11 +198,6 @@ interface ConformanceCase {
|
|
|
173
198
|
* are given to 100% × 100%, which then resolves against the wrapper
|
|
174
199
|
*/
|
|
175
200
|
declare function attachedHost(width?: number, height?: number): HTMLElement;
|
|
176
|
-
/**
|
|
177
|
-
* apiVersion compatibility: same major — and, while the contract is 0.x,
|
|
178
|
-
* the same minor too (pre-1.0 minors may break; bump-minor-pre-major)
|
|
179
|
-
*/
|
|
180
|
-
declare function apiVersionsCompatible(a: string, b: string): boolean;
|
|
181
201
|
declare function conformanceCases(): ConformanceCase[];
|
|
182
202
|
interface RegisterOptions {
|
|
183
203
|
/** how a SKIPPED case is reported — a law the kit could not (fully) verify
|
|
@@ -191,4 +211,4 @@ interface RegisterOptions {
|
|
|
191
211
|
/** bind every case to a (name, fn) test runner — node:test, vitest, jest */
|
|
192
212
|
declare function registerConformance(harness: ConformanceHarness, test: (name: string, fn: () => Promise<void>) => unknown, options?: RegisterOptions): void;
|
|
193
213
|
|
|
194
|
-
export { type CaseOutcome, type ConformanceCase, type ConformanceHarness, type InteractionState, type RegisterOptions, type UserMutation,
|
|
214
|
+
export { type CaseOutcome, type ConformanceCase, type ConformanceHarness, type InteractionState, type RegisterOptions, type UserMutation, attachedHost, conformanceCases, registerConformance };
|
package/dist/conformance.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
MODELER_API_VERSION
|
|
3
|
-
|
|
2
|
+
MODELER_API_VERSION,
|
|
3
|
+
apiVersionsCompatible
|
|
4
|
+
} from "./chunk-E6KJB7QH.js";
|
|
4
5
|
|
|
5
6
|
// src/conformance.ts
|
|
6
7
|
function attachedHost(width = 800, height = 600) {
|
|
@@ -12,12 +13,6 @@ function attachedHost(width = 800, height = 600) {
|
|
|
12
13
|
document.body.append(wrapper);
|
|
13
14
|
return host;
|
|
14
15
|
}
|
|
15
|
-
function apiVersionsCompatible(a, b) {
|
|
16
|
-
const [aMajor = "", aMinor = ""] = a.split(".");
|
|
17
|
-
const [bMajor = "", bMinor = ""] = b.split(".");
|
|
18
|
-
if (aMajor !== bMajor) return false;
|
|
19
|
-
return aMajor !== "0" || aMinor === bMinor;
|
|
20
|
-
}
|
|
21
16
|
function fail(message) {
|
|
22
17
|
throw new Error(`modeler-api conformance: ${message}`);
|
|
23
18
|
}
|
|
@@ -30,6 +25,22 @@ function exportOrFail(modeler, when) {
|
|
|
30
25
|
}
|
|
31
26
|
}
|
|
32
27
|
var same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
28
|
+
var hasBounds = (elements) => typeof elements.bounds === "function";
|
|
29
|
+
var isFiniteBox = (box) => box !== void 0 && [box.x, box.y, box.width, box.height].every((n) => typeof n === "number" && Number.isFinite(n)) && box.width >= 0 && box.height >= 0;
|
|
30
|
+
var describeBox = (box) => box === void 0 ? "undefined" : `{ x: ${box.x}, y: ${box.y}, width: ${box.width}, height: ${box.height} }`;
|
|
31
|
+
function boxesOf(elements, when) {
|
|
32
|
+
const boxes = /* @__PURE__ */ new Map();
|
|
33
|
+
for (const { id } of elements.list()) {
|
|
34
|
+
const box = elements.bounds(id);
|
|
35
|
+
if (!isFiniteBox(box)) {
|
|
36
|
+
fail(
|
|
37
|
+
`elements.bounds('${id}') ${when} is ${describeBox(box)} \u2014 every listed id must give a finite box with a non-negative size`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
boxes.set(id, { x: box.x, y: box.y, width: box.width, height: box.height });
|
|
41
|
+
}
|
|
42
|
+
return boxes;
|
|
43
|
+
}
|
|
33
44
|
function assertFinite(state, when) {
|
|
34
45
|
const json = JSON.stringify(
|
|
35
46
|
state,
|
|
@@ -544,6 +555,97 @@ ${out}`);
|
|
|
544
555
|
}
|
|
545
556
|
}
|
|
546
557
|
},
|
|
558
|
+
{
|
|
559
|
+
name: "L5: a host's view change never commits an open interaction",
|
|
560
|
+
async run(h) {
|
|
561
|
+
if (!h.openInteraction) {
|
|
562
|
+
return skip(
|
|
563
|
+
"openInteraction is undefined \u2014 a host's view change over an open interaction (a label editor with typed input) was not verified"
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
if (h.viewState === void 0) {
|
|
567
|
+
return skip(
|
|
568
|
+
"viewState is undefined \u2014 a host's view change over an open interaction was not verified (supply a panned/zoomed state)"
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
const openInteraction = h.openInteraction;
|
|
572
|
+
const commitInteraction = h.commitInteraction;
|
|
573
|
+
const viewState = h.viewState;
|
|
574
|
+
const settle = h.settle ?? defaultSettle;
|
|
575
|
+
const first = h.canonicalTexts[0] ?? "";
|
|
576
|
+
const m = await h.create();
|
|
577
|
+
try {
|
|
578
|
+
await m.importText(first);
|
|
579
|
+
const doc = exportOrFail(m, "after importing canonicalTexts[0]");
|
|
580
|
+
const open = async () => {
|
|
581
|
+
await openInteraction(m);
|
|
582
|
+
await settle();
|
|
583
|
+
const opened2 = h.interactionState?.(m);
|
|
584
|
+
if (h.interactionState && opened2 === void 0) {
|
|
585
|
+
fail("interactionState reports no interaction after openInteraction \u2014 the case proves nothing");
|
|
586
|
+
}
|
|
587
|
+
return opened2;
|
|
588
|
+
};
|
|
589
|
+
let committed;
|
|
590
|
+
if (commitInteraction) {
|
|
591
|
+
const typed = (await open())?.text;
|
|
592
|
+
await commitInteraction(m);
|
|
593
|
+
await settle();
|
|
594
|
+
committed = exportOrFail(m, "after committing the interaction");
|
|
595
|
+
if (committed === doc || typed && !committed.includes(typed)) {
|
|
596
|
+
fail(
|
|
597
|
+
"committing the interaction openInteraction opened did not write its typed input into the export \u2014 the case proves nothing (commitInteraction must commit the way a user does, openInteraction type input the serializer writes verbatim)"
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
await importOrFail(m, first, "re-importing canonicalTexts[0] after the commit rejected");
|
|
601
|
+
}
|
|
602
|
+
const opened = await open();
|
|
603
|
+
const view = m.getViewState();
|
|
604
|
+
const { calls } = await changeEvents(h, m, () => m.setViewState(viewState));
|
|
605
|
+
if (calls > 0) {
|
|
606
|
+
fail(
|
|
607
|
+
`setViewState over an open interaction fired onContentChanged ${calls}x \u2014 the host's view change committed it (L5; if every setViewState emits, see the L5 round-trip case first)`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
if (exportOrFail(m, "after setViewState over an open interaction") !== doc) {
|
|
611
|
+
fail(
|
|
612
|
+
"setViewState over an open interaction changed the export \u2014 the host's view change committed it (L5)"
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (same(m.getViewState(), view)) {
|
|
616
|
+
fail(
|
|
617
|
+
"setViewState(viewState) had no effect over an open interaction \u2014 the case proves nothing (supply a state that differs from the fitted view)"
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
const after = h.interactionState?.(m);
|
|
621
|
+
let advice;
|
|
622
|
+
if (opened !== void 0 && (after?.elementId !== opened.elementId || after?.text !== opened.text)) {
|
|
623
|
+
const input = opened.text === void 0 ? "" : ` with the input ${JSON.stringify(opened.text)}`;
|
|
624
|
+
const what = after === void 0 ? "closed it" : `left ${JSON.stringify(after)} open`;
|
|
625
|
+
advice = `an interaction on '${opened.elementId}'${input} did not survive a host's view change (setViewState ${what}) \u2014 RECOMMENDED: keep it open with its typed input, so the user's commit afterwards writes what it would have written without the view change`;
|
|
626
|
+
} else if (commitInteraction && committed !== void 0) {
|
|
627
|
+
await commitInteraction(m);
|
|
628
|
+
await settle();
|
|
629
|
+
const out = exportOrFail(m, "after committing the interaction after a view change");
|
|
630
|
+
if (out !== committed) {
|
|
631
|
+
advice = `committing an interaction after a host's view change wrote another document than committing it without one \u2014 RECOMMENDED: keep it open with its typed input across a view change (if openInteraction types different input per call, this advice is the harness's):
|
|
632
|
+
--- committed without a view change
|
|
633
|
+
${committed}
|
|
634
|
+
--- committed after setViewState
|
|
635
|
+
${out}`;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
...commitInteraction ? {} : {
|
|
640
|
+
skipped: "commitInteraction is undefined \u2014 that the typed input changes the document (without it the case may prove nothing) and that it still commits after the view change were not checked (pass commitInteraction to check both)"
|
|
641
|
+
},
|
|
642
|
+
...advice === void 0 ? {} : { advisories: [advice] }
|
|
643
|
+
};
|
|
644
|
+
} finally {
|
|
645
|
+
m.destroy();
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
},
|
|
547
649
|
{
|
|
548
650
|
name: "elements (when present): stable ids across re-imports and unrelated edits, reveal and selection semantics",
|
|
549
651
|
async run(h) {
|
|
@@ -723,6 +825,84 @@ ${out}`);
|
|
|
723
825
|
}
|
|
724
826
|
}
|
|
725
827
|
},
|
|
828
|
+
{
|
|
829
|
+
name: "elements.bounds (when present): a finite box in diagram coordinates for every listed id, undefined for an unknown one, live after a new element and a move",
|
|
830
|
+
async run(h) {
|
|
831
|
+
const text = h.canonicalTexts[0] ?? "";
|
|
832
|
+
const settle = h.settle ?? defaultSettle;
|
|
833
|
+
const m = await h.create();
|
|
834
|
+
let viewer;
|
|
835
|
+
try {
|
|
836
|
+
await m.importText(text);
|
|
837
|
+
const elements = m.elements;
|
|
838
|
+
if (!elements)
|
|
839
|
+
return skip("the modeler exposes no elements surface (optional) \u2014 nothing to verify");
|
|
840
|
+
if (!hasBounds(elements))
|
|
841
|
+
return skip("the modeler exposes no elements.bounds (optional) \u2014 nothing to verify");
|
|
842
|
+
boxesOf(elements, "after an import");
|
|
843
|
+
if (elements.bounds("__modeler_api_no_such_id__") !== void 0)
|
|
844
|
+
fail("elements.bounds(unknown id) must return undefined");
|
|
845
|
+
const imported = new Set(elements.list().map((e) => e.id));
|
|
846
|
+
await h.mutate(m);
|
|
847
|
+
await settle();
|
|
848
|
+
for (const { id } of elements.list().filter((e) => !imported.has(e.id))) {
|
|
849
|
+
const box = elements.bounds(id);
|
|
850
|
+
if (!isFiniteBox(box)) {
|
|
851
|
+
fail(
|
|
852
|
+
`elements.bounds('${id}') of an element created in the session is ${describeBox(box)} \u2014 bounds must read the live canvas, not the last import`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const unverified = [];
|
|
857
|
+
if (h.moveElement) {
|
|
858
|
+
const before = boxesOf(elements, "before a move");
|
|
859
|
+
const movedId = await h.moveElement(m);
|
|
860
|
+
await settle();
|
|
861
|
+
const was = before.get(movedId);
|
|
862
|
+
if (was === void 0)
|
|
863
|
+
fail(`moveElement returned '${movedId}', an id elements.list() did not report before the move`);
|
|
864
|
+
const box = boxesOf(elements, "after a move").get(movedId);
|
|
865
|
+
if (box === void 0)
|
|
866
|
+
fail(`'${movedId}' is gone from elements.list() after moveElement moved it`);
|
|
867
|
+
if (box.x === was.x && box.y === was.y) {
|
|
868
|
+
fail(
|
|
869
|
+
`elements.bounds('${movedId}') did not follow a move made through the modeler's own commands (still ${describeBox(was)}) \u2014 bounds must read the live canvas, not the last import (or moveElement did not move the element)`
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
} else {
|
|
873
|
+
unverified.push("moveElement is undefined \u2014 that a box follows a move was not verified");
|
|
874
|
+
}
|
|
875
|
+
if (h.viewState !== void 0) {
|
|
876
|
+
const atRest = boxesOf(elements, "before a view change");
|
|
877
|
+
m.setViewState(h.viewState);
|
|
878
|
+
for (const [id, box] of boxesOf(elements, "after setViewState")) {
|
|
879
|
+
if (!same(box, atRest.get(id))) {
|
|
880
|
+
fail(
|
|
881
|
+
`elements.bounds('${id}') changed with the view (${describeBox(atRest.get(id))} \u2192 ${describeBox(box)}) \u2014 boxes are in diagram coordinates, which zoom and scroll leave alone`
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
} else {
|
|
886
|
+
unverified.push(
|
|
887
|
+
"viewState is undefined \u2014 that boxes are independent of the view was not verified"
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
if (h.createReadonly) {
|
|
891
|
+
viewer = await h.createReadonly();
|
|
892
|
+
await importOrFail(viewer, text, "the viewer rejected canonicalTexts[0]");
|
|
893
|
+
if (viewer.elements && hasBounds(viewer.elements)) {
|
|
894
|
+
boxesOf(viewer.elements, "on the viewer after an import");
|
|
895
|
+
if (viewer.elements.bounds("__modeler_api_no_such_id__") !== void 0)
|
|
896
|
+
fail("the viewer's elements.bounds(unknown id) must return undefined");
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (unverified.length > 0) return skip(unverified.join("; "));
|
|
900
|
+
} finally {
|
|
901
|
+
m.destroy();
|
|
902
|
+
viewer?.destroy();
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
},
|
|
726
906
|
{
|
|
727
907
|
name: "L6: export is total after imports, user mutations and undo",
|
|
728
908
|
async run(h) {
|