@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.
@@ -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, which must never commit it (L1). `undefined`
133
- * = the L1 interaction case is reported as skipped
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, apiVersionsCompatible, attachedHost, conformanceCases, registerConformance };
214
+ export { type CaseOutcome, type ConformanceCase, type ConformanceHarness, type InteractionState, type RegisterOptions, type UserMutation, attachedHost, conformanceCases, registerConformance };
@@ -1,6 +1,7 @@
1
1
  import {
2
- MODELER_API_VERSION
3
- } from "./chunk-32BMH76T.js";
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) {