@miragon/modeler-api 1.0.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.
@@ -28,7 +28,13 @@ __export(conformance_exports, {
28
28
  module.exports = __toCommonJS(conformance_exports);
29
29
 
30
30
  // src/index.ts
31
- var MODELER_API_VERSION = "1.0.0";
31
+ var MODELER_API_VERSION = "1.2.0";
32
+ function apiVersionsCompatible(a, b) {
33
+ const [aMajor = "", aMinor = ""] = a.split(".");
34
+ const [bMajor = "", bMinor = ""] = b.split(".");
35
+ if (aMajor !== bMajor) return false;
36
+ return aMajor !== "0" || aMinor === bMinor;
37
+ }
32
38
 
33
39
  // src/conformance.ts
34
40
  function attachedHost(width = 800, height = 600) {
@@ -40,12 +46,6 @@ function attachedHost(width = 800, height = 600) {
40
46
  document.body.append(wrapper);
41
47
  return host;
42
48
  }
43
- function apiVersionsCompatible(a, b) {
44
- const [aMajor = "", aMinor = ""] = a.split(".");
45
- const [bMajor = "", bMinor = ""] = b.split(".");
46
- if (aMajor !== bMajor) return false;
47
- return aMajor !== "0" || aMinor === bMinor;
48
- }
49
49
  function fail(message) {
50
50
  throw new Error(`modeler-api conformance: ${message}`);
51
51
  }
@@ -58,6 +58,22 @@ function exportOrFail(modeler, when) {
58
58
  }
59
59
  }
60
60
  var same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
61
+ var hasBounds = (elements) => typeof elements.bounds === "function";
62
+ 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;
63
+ var describeBox = (box) => box === void 0 ? "undefined" : `{ x: ${box.x}, y: ${box.y}, width: ${box.width}, height: ${box.height} }`;
64
+ function boxesOf(elements, when) {
65
+ const boxes = /* @__PURE__ */ new Map();
66
+ for (const { id } of elements.list()) {
67
+ const box = elements.bounds(id);
68
+ if (!isFiniteBox(box)) {
69
+ fail(
70
+ `elements.bounds('${id}') ${when} is ${describeBox(box)} \u2014 every listed id must give a finite box with a non-negative size`
71
+ );
72
+ }
73
+ boxes.set(id, { x: box.x, y: box.y, width: box.width, height: box.height });
74
+ }
75
+ return boxes;
76
+ }
61
77
  function assertFinite(state, when) {
62
78
  const json = JSON.stringify(
63
79
  state,
@@ -70,6 +86,19 @@ function assertFinite(state, when) {
70
86
  }
71
87
  }
72
88
  var defaultSettle = () => new Promise((resolve) => setTimeout(resolve, 0));
89
+ var PRE_IMPORT_CALL_BOUND_MS = 2e3;
90
+ async function toleratedCall(action, ms) {
91
+ let timer;
92
+ const call = Promise.resolve().then(action).catch(() => void 0);
93
+ const bound = new Promise((resolve) => {
94
+ timer = setTimeout(resolve, ms);
95
+ });
96
+ try {
97
+ await Promise.race([call, bound]);
98
+ } finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
73
102
  async function changeEvents(harness, modeler, action) {
74
103
  const settle = harness.settle ?? defaultSettle;
75
104
  let calls = 0;
@@ -86,12 +115,42 @@ async function changeEvents(harness, modeler, action) {
86
115
  }
87
116
  return { calls, exportAtEmit };
88
117
  }
118
+ var lettersAndDigits = (value) => value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
89
119
  var secondText = (h) => h.canonicalTexts[1] ?? h.canonicalTexts[0] ?? "";
90
120
  var mutationsOf = (h) => [
91
121
  { name: "mutate (element command)", run: (m) => h.mutate(m) },
92
122
  ...h.configMutations ?? []
93
123
  ];
94
124
  var sortedIds = (m) => m.elements ? [...m.elements.list().map((e) => e.id)].sort() : void 0;
125
+ var namesById = (m) => m.elements ? new Map(m.elements.list().map((e) => [e.id, e.name])) : void 0;
126
+ async function importOrFail(m, text, failure) {
127
+ await m.importText(text).catch((e) => fail(`${failure}: ${String(e)}`));
128
+ }
129
+ var describeProbe = (probe) => {
130
+ const excerpt = probe.text.length > 40 ? `${probe.text.slice(0, 40)}\u2026` : probe.text;
131
+ return `${probe.label} (${JSON.stringify(excerpt)})`;
132
+ };
133
+ function probesOf(h) {
134
+ const probes = [];
135
+ h.canonicalTexts.forEach((text, index) => {
136
+ const source = `canonicalTexts[${index}]`;
137
+ const codePoints = Array.from(text);
138
+ const cut = (length) => codePoints.slice(0, length).join("");
139
+ probes.push(
140
+ { label: `the first half of ${source}`, text: cut(Math.floor(codePoints.length / 2)) },
141
+ { label: `the first third of ${source}`, text: cut(Math.floor(codePoints.length / 3)) },
142
+ { label: `${source} without its last character`, text: cut(codePoints.length - 1) },
143
+ { label: `${source} followed by a NUL character`, text: `${text}\0` }
144
+ );
145
+ });
146
+ (h.probeTexts ?? []).forEach((text, index) => probes.push({ label: `probeTexts[${index}]`, text }));
147
+ const seen = /* @__PURE__ */ new Set();
148
+ return probes.filter((probe) => {
149
+ if (seen.has(probe.text)) return false;
150
+ seen.add(probe.text);
151
+ return true;
152
+ });
153
+ }
95
154
  async function importContentText(h, m) {
96
155
  for (const text of h.canonicalTexts) {
97
156
  await m.importText(text);
@@ -117,6 +176,56 @@ function conformanceCases() {
117
176
  }
118
177
  }
119
178
  },
179
+ {
180
+ name: "calls before the first import never wedge the instance",
181
+ async run(h) {
182
+ const m = await h.create();
183
+ let control;
184
+ try {
185
+ try {
186
+ m.exportText();
187
+ } catch {
188
+ }
189
+ await toleratedCall(() => h.mutate(m), PRE_IMPORT_CALL_BOUND_MS);
190
+ await (h.settle ?? defaultSettle)();
191
+ control = await h.create();
192
+ const texts = h.canonicalTexts.length > 0 ? h.canonicalTexts.slice(0, 2) : [""];
193
+ let last = "";
194
+ for (const [index, text] of texts.entries()) {
195
+ const source = `canonicalTexts[${index}]`;
196
+ await control.importText(text);
197
+ const expected = exportOrFail(control, `after importing ${source} into a fresh instance`);
198
+ await importOrFail(
199
+ m,
200
+ text,
201
+ `importText(${source}) rejected on an instance that ran exportText() and a user command before its first import (wedged)`
202
+ );
203
+ last = exportOrFail(m, `after importing ${source} into an instance used before its first import`);
204
+ if (last !== expected) {
205
+ fail(
206
+ `importText(${source}) on an instance that ran exportText() and a user command before its first import did not render that document:
207
+ --- expected (a fresh instance's export)
208
+ ${expected}
209
+ --- got
210
+ ${last}`
211
+ );
212
+ }
213
+ }
214
+ const { calls } = await changeEvents(h, m, () => h.mutate(m));
215
+ if (calls === 0) {
216
+ fail(
217
+ "a user mutation fired no onContentChanged on an instance used before its first import (wedged)"
218
+ );
219
+ }
220
+ if (exportOrFail(m, "after a user mutation on an instance used before its first import") === last) {
221
+ fail("a user mutation did not apply on an instance used before its first import (wedged)");
222
+ }
223
+ } finally {
224
+ m.destroy();
225
+ control?.destroy();
226
+ }
227
+ }
228
+ },
120
229
  {
121
230
  name: "L1: importText is silent (no change events, for canonical, loose and foreign inputs)",
122
231
  async run(h) {
@@ -151,6 +260,87 @@ function conformanceCases() {
151
260
  }
152
261
  }
153
262
  },
263
+ {
264
+ name: "L1: an interaction open during importText never commits",
265
+ async run(h) {
266
+ if (!h.openInteraction) {
267
+ return skip(
268
+ "openInteraction is undefined \u2014 an interaction open during an import (a label editor with typed input) was not verified"
269
+ );
270
+ }
271
+ const openInteraction = h.openInteraction;
272
+ const first = h.canonicalTexts[0] ?? "";
273
+ const second = h.canonicalTexts[1];
274
+ const m = await h.create();
275
+ try {
276
+ const legs = [];
277
+ if (second !== void 0) {
278
+ await m.importText(second);
279
+ const expected = exportOrFail(m, "after importing canonicalTexts[1]");
280
+ legs.push({ text: second, expected, what: "a different document (canonicalTexts[1])" });
281
+ }
282
+ await m.importText(first);
283
+ const firstExport = exportOrFail(m, "after importing canonicalTexts[0]");
284
+ legs.push({
285
+ text: first,
286
+ expected: firstExport,
287
+ what: "the same document again (canonicalTexts[0])"
288
+ });
289
+ let advice;
290
+ for (const [index, leg] of legs.entries()) {
291
+ await m.importText(first);
292
+ await openInteraction(m);
293
+ await (h.settle ?? defaultSettle)();
294
+ const opened = h.interactionState?.(m);
295
+ if (h.interactionState && opened === void 0) {
296
+ fail("interactionState reports no interaction after openInteraction \u2014 the case proves nothing");
297
+ }
298
+ const { calls } = await changeEvents(h, m, () => m.importText(leg.text).then(() => void 0));
299
+ if (calls > 0) {
300
+ fail(
301
+ `importing ${leg.what} over an open interaction fired onContentChanged ${calls}x \u2014 the interaction committed during or because of the import (if every import emits, see the L1 cases first)`
302
+ );
303
+ }
304
+ const reopened = h.interactionState?.(m);
305
+ if (exportOrFail(m, `after importing ${leg.what} over an open interaction`) !== leg.expected) {
306
+ fail(
307
+ `importing ${leg.what} over an open interaction did not leave that document \u2014 the interaction's input was applied to it`
308
+ );
309
+ }
310
+ if (h.undo) {
311
+ await h.undo(m);
312
+ await (h.settle ?? defaultSettle)();
313
+ if (exportOrFail(m, `after an undo that followed importing ${leg.what}`) !== leg.expected) {
314
+ fail(
315
+ `undo after importing ${leg.what} over an open interaction changed the document \u2014 the interaction was committed onto the fresh undo history (L4)`
316
+ );
317
+ }
318
+ }
319
+ const sameDocument = index === legs.length - 1;
320
+ const kept = reopened?.elementId === opened?.elementId && reopened?.text === opened?.text;
321
+ if (sameDocument && opened !== void 0 && !kept) {
322
+ const input = opened.text === void 0 ? "" : ` with the input ${JSON.stringify(opened.text)}`;
323
+ const after = reopened === void 0 ? "closed it" : `left ${JSON.stringify(reopened)} open`;
324
+ advice = `an interaction on '${opened.elementId}'${input} did not survive a re-import of the same document (the import ${after}) \u2014 RECOMMENDED: keep the typed input and reopen the interaction on the element with the same id, so a remote re-import does not cost the user what they were typing`;
325
+ }
326
+ }
327
+ const unverified = [
328
+ ...second === void 0 ? [
329
+ "canonicalTexts holds one text \u2014 importing a DIFFERENT document over an open interaction was not verified (add a second canonical text)"
330
+ ] : [],
331
+ ...h.interactionState ? [] : [
332
+ "interactionState is undefined \u2014 the RECOMMENDED keep-and-reopen of an interaction across an import was not checked (pass interactionState to check it)"
333
+ ]
334
+ ];
335
+ return {
336
+ ...unverified.length > 0 ? { skipped: unverified.join("; ") } : {},
337
+ ...advice === void 0 ? {} : { advisories: [advice] }
338
+ };
339
+ } finally {
340
+ m.destroy();
341
+ }
342
+ }
343
+ },
154
344
  {
155
345
  name: "L2: canonical texts are byte fixpoints of import \u2192 export",
156
346
  async run(h) {
@@ -398,6 +588,97 @@ ${out}`);
398
588
  }
399
589
  }
400
590
  },
591
+ {
592
+ name: "L5: a host's view change never commits an open interaction",
593
+ async run(h) {
594
+ if (!h.openInteraction) {
595
+ return skip(
596
+ "openInteraction is undefined \u2014 a host's view change over an open interaction (a label editor with typed input) was not verified"
597
+ );
598
+ }
599
+ if (h.viewState === void 0) {
600
+ return skip(
601
+ "viewState is undefined \u2014 a host's view change over an open interaction was not verified (supply a panned/zoomed state)"
602
+ );
603
+ }
604
+ const openInteraction = h.openInteraction;
605
+ const commitInteraction = h.commitInteraction;
606
+ const viewState = h.viewState;
607
+ const settle = h.settle ?? defaultSettle;
608
+ const first = h.canonicalTexts[0] ?? "";
609
+ const m = await h.create();
610
+ try {
611
+ await m.importText(first);
612
+ const doc = exportOrFail(m, "after importing canonicalTexts[0]");
613
+ const open = async () => {
614
+ await openInteraction(m);
615
+ await settle();
616
+ const opened2 = h.interactionState?.(m);
617
+ if (h.interactionState && opened2 === void 0) {
618
+ fail("interactionState reports no interaction after openInteraction \u2014 the case proves nothing");
619
+ }
620
+ return opened2;
621
+ };
622
+ let committed;
623
+ if (commitInteraction) {
624
+ const typed = (await open())?.text;
625
+ await commitInteraction(m);
626
+ await settle();
627
+ committed = exportOrFail(m, "after committing the interaction");
628
+ if (committed === doc || typed && !committed.includes(typed)) {
629
+ fail(
630
+ "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)"
631
+ );
632
+ }
633
+ await importOrFail(m, first, "re-importing canonicalTexts[0] after the commit rejected");
634
+ }
635
+ const opened = await open();
636
+ const view = m.getViewState();
637
+ const { calls } = await changeEvents(h, m, () => m.setViewState(viewState));
638
+ if (calls > 0) {
639
+ fail(
640
+ `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)`
641
+ );
642
+ }
643
+ if (exportOrFail(m, "after setViewState over an open interaction") !== doc) {
644
+ fail(
645
+ "setViewState over an open interaction changed the export \u2014 the host's view change committed it (L5)"
646
+ );
647
+ }
648
+ if (same(m.getViewState(), view)) {
649
+ fail(
650
+ "setViewState(viewState) had no effect over an open interaction \u2014 the case proves nothing (supply a state that differs from the fitted view)"
651
+ );
652
+ }
653
+ const after = h.interactionState?.(m);
654
+ let advice;
655
+ if (opened !== void 0 && (after?.elementId !== opened.elementId || after?.text !== opened.text)) {
656
+ const input = opened.text === void 0 ? "" : ` with the input ${JSON.stringify(opened.text)}`;
657
+ const what = after === void 0 ? "closed it" : `left ${JSON.stringify(after)} open`;
658
+ 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`;
659
+ } else if (commitInteraction && committed !== void 0) {
660
+ await commitInteraction(m);
661
+ await settle();
662
+ const out = exportOrFail(m, "after committing the interaction after a view change");
663
+ if (out !== committed) {
664
+ 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):
665
+ --- committed without a view change
666
+ ${committed}
667
+ --- committed after setViewState
668
+ ${out}`;
669
+ }
670
+ }
671
+ return {
672
+ ...commitInteraction ? {} : {
673
+ 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)"
674
+ },
675
+ ...advice === void 0 ? {} : { advisories: [advice] }
676
+ };
677
+ } finally {
678
+ m.destroy();
679
+ }
680
+ }
681
+ },
401
682
  {
402
683
  name: "elements (when present): stable ids across re-imports and unrelated edits, reveal and selection semantics",
403
684
  async run(h) {
@@ -473,6 +754,188 @@ ${out}`);
473
754
  }
474
755
  }
475
756
  },
757
+ {
758
+ name: "elements (when present): ids and names survive export \u2192 import, in this and in a fresh instance",
759
+ async run(h) {
760
+ const m = await h.create();
761
+ const others = [];
762
+ try {
763
+ await m.importText(h.canonicalTexts[0] ?? "");
764
+ const elements = m.elements;
765
+ if (!elements)
766
+ return skip("the modeler exposes no elements surface (optional) \u2014 nothing to verify");
767
+ await h.mutate(m);
768
+ await (h.settle ?? defaultSettle)();
769
+ const session = new Map(elements.list().map((e) => [e.id, e.name]));
770
+ const exported = exportOrFail(m, "after a user mutation");
771
+ const expectSessionIds = (target, where) => {
772
+ const after = namesById(target);
773
+ if (!after) fail(`${where} exposes no elements surface \u2014 the editor does`);
774
+ for (const [id, name] of session) {
775
+ if (!after.has(id)) {
776
+ fail(
777
+ `element id '${id}' is gone after export \u2192 import ${where} \u2014 the id a session reports must be the element's id in the document (write it back)`
778
+ );
779
+ }
780
+ if (name !== void 0 && (after.get(id) ?? "") !== name) {
781
+ fail(
782
+ `element id '${id}' names a different element after export \u2192 import ${where} ('${name}' \u2192 '${String(after.get(id))}')`
783
+ );
784
+ }
785
+ }
786
+ };
787
+ await importOrFail(m, exported, "importText rejected the modeler's own export");
788
+ expectSessionIds(m, "into the same instance");
789
+ const fresh = await h.create();
790
+ others.push(fresh);
791
+ await importOrFail(fresh, exported, "a fresh instance rejected the modeler's own export");
792
+ expectSessionIds(fresh, "into a fresh instance");
793
+ if (h.createReadonly) {
794
+ const viewer = await h.createReadonly();
795
+ others.push(viewer);
796
+ await importOrFail(viewer, exported, "the viewer rejected the editor's export");
797
+ if (viewer.elements) expectSessionIds(viewer, "into the viewer");
798
+ }
799
+ } finally {
800
+ m.destroy();
801
+ for (const other of others) other.destroy();
802
+ }
803
+ }
804
+ },
805
+ {
806
+ name: "elements (when present, recommended): ids created on two peers at the same time do not collide",
807
+ async run(h) {
808
+ const text = h.canonicalTexts[0] ?? "";
809
+ const peers = [];
810
+ try {
811
+ const created = [];
812
+ for (let count = 0; count < 2; count += 1) {
813
+ const peer = await h.create();
814
+ peers.push(peer);
815
+ await peer.importText(text);
816
+ if (!peer.elements)
817
+ return skip("the modeler exposes no elements surface (optional) \u2014 nothing to verify");
818
+ const before = new Set(peer.elements.list().map((e) => e.id));
819
+ await h.mutate(peer);
820
+ await (h.settle ?? defaultSettle)();
821
+ const added = new Map(
822
+ peer.elements.list().filter((e) => !before.has(e.id)).map((e) => [e.id, e.name])
823
+ );
824
+ if (added.size === 0) {
825
+ fail(
826
+ "the harness mutate must ADD an element (the elements cases compare the ids two instances allocate)"
827
+ );
828
+ }
829
+ created.push(added);
830
+ }
831
+ const [
832
+ onFirst = /* @__PURE__ */ new Map(),
833
+ onSecond = /* @__PURE__ */ new Map()
834
+ ] = created;
835
+ const shared = [...onFirst.keys()].filter((id) => onSecond.has(id));
836
+ const derivedFromContent = (id) => {
837
+ const name = onFirst.get(id) ?? "";
838
+ if (name === "" || name !== (onSecond.get(id) ?? "")) return false;
839
+ const spelled = lettersAndDigits(name);
840
+ return spelled !== "" && lettersAndDigits(id).includes(spelled);
841
+ };
842
+ const allocated = shared.filter((id) => !derivedFromContent(id));
843
+ const quoted = (ids) => ids.map((id) => `'${id}'`).join(", ");
844
+ if (allocated.length > 0) {
845
+ return {
846
+ advisories: [
847
+ `two instances that imported the same document and each created one element, not of the same name, allocated the same id (${quoted(allocated)}) \u2014 RECOMMENDED: allocate ids for new elements that do not collide across peers (random or peer-scoped, not a per-instance counter); once both edits merge, two elements share one id`
848
+ ]
849
+ };
850
+ }
851
+ if (shared.length > 0) {
852
+ return skip(
853
+ `both instances created an element of the same name and allocated the same id, one derived from that name (${quoted(shared)}) \u2014 such ids collide for identical content by design, so the check is inconclusive: let mutate's elements differ in content (a counter the harness shares across instances)`
854
+ );
855
+ }
856
+ } finally {
857
+ for (const peer of peers) peer.destroy();
858
+ }
859
+ }
860
+ },
861
+ {
862
+ 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",
863
+ async run(h) {
864
+ const text = h.canonicalTexts[0] ?? "";
865
+ const settle = h.settle ?? defaultSettle;
866
+ const m = await h.create();
867
+ let viewer;
868
+ try {
869
+ await m.importText(text);
870
+ const elements = m.elements;
871
+ if (!elements)
872
+ return skip("the modeler exposes no elements surface (optional) \u2014 nothing to verify");
873
+ if (!hasBounds(elements))
874
+ return skip("the modeler exposes no elements.bounds (optional) \u2014 nothing to verify");
875
+ boxesOf(elements, "after an import");
876
+ if (elements.bounds("__modeler_api_no_such_id__") !== void 0)
877
+ fail("elements.bounds(unknown id) must return undefined");
878
+ const imported = new Set(elements.list().map((e) => e.id));
879
+ await h.mutate(m);
880
+ await settle();
881
+ for (const { id } of elements.list().filter((e) => !imported.has(e.id))) {
882
+ const box = elements.bounds(id);
883
+ if (!isFiniteBox(box)) {
884
+ fail(
885
+ `elements.bounds('${id}') of an element created in the session is ${describeBox(box)} \u2014 bounds must read the live canvas, not the last import`
886
+ );
887
+ }
888
+ }
889
+ const unverified = [];
890
+ if (h.moveElement) {
891
+ const before = boxesOf(elements, "before a move");
892
+ const movedId = await h.moveElement(m);
893
+ await settle();
894
+ const was = before.get(movedId);
895
+ if (was === void 0)
896
+ fail(`moveElement returned '${movedId}', an id elements.list() did not report before the move`);
897
+ const box = boxesOf(elements, "after a move").get(movedId);
898
+ if (box === void 0)
899
+ fail(`'${movedId}' is gone from elements.list() after moveElement moved it`);
900
+ if (box.x === was.x && box.y === was.y) {
901
+ fail(
902
+ `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)`
903
+ );
904
+ }
905
+ } else {
906
+ unverified.push("moveElement is undefined \u2014 that a box follows a move was not verified");
907
+ }
908
+ if (h.viewState !== void 0) {
909
+ const atRest = boxesOf(elements, "before a view change");
910
+ m.setViewState(h.viewState);
911
+ for (const [id, box] of boxesOf(elements, "after setViewState")) {
912
+ if (!same(box, atRest.get(id))) {
913
+ fail(
914
+ `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`
915
+ );
916
+ }
917
+ }
918
+ } else {
919
+ unverified.push(
920
+ "viewState is undefined \u2014 that boxes are independent of the view was not verified"
921
+ );
922
+ }
923
+ if (h.createReadonly) {
924
+ viewer = await h.createReadonly();
925
+ await importOrFail(viewer, text, "the viewer rejected canonicalTexts[0]");
926
+ if (viewer.elements && hasBounds(viewer.elements)) {
927
+ boxesOf(viewer.elements, "on the viewer after an import");
928
+ if (viewer.elements.bounds("__modeler_api_no_such_id__") !== void 0)
929
+ fail("the viewer's elements.bounds(unknown id) must return undefined");
930
+ }
931
+ }
932
+ if (unverified.length > 0) return skip(unverified.join("; "));
933
+ } finally {
934
+ m.destroy();
935
+ viewer?.destroy();
936
+ }
937
+ }
938
+ },
476
939
  {
477
940
  name: "L6: export is total after imports, user mutations and undo",
478
941
  async run(h) {
@@ -594,6 +1057,84 @@ ${out}`);
594
1057
  }
595
1058
  }
596
1059
  },
1060
+ {
1061
+ name: "L7: every rejection of a derived probe leaves no trace",
1062
+ async run(h) {
1063
+ const base = h.canonicalTexts[0] ?? "";
1064
+ const m = await h.create();
1065
+ try {
1066
+ await m.importText(base);
1067
+ const baseline = exportOrFail(m, "after importing canonicalTexts[0]");
1068
+ const baseIds = sortedIds(m);
1069
+ let previous;
1070
+ const reimportBase = async () => {
1071
+ if (previous === void 0) return;
1072
+ const after = `after importing ${describeProbe(previous)}`;
1073
+ await importOrFail(m, base, `the next valid import ${after} did not render`);
1074
+ const doc = exportOrFail(m, `on the next valid import ${after}`);
1075
+ if (doc !== baseline || baseIds && !same(baseIds, sortedIds(m))) {
1076
+ fail(`the next valid import ${after} did not replace the document (wedged)`);
1077
+ }
1078
+ };
1079
+ const mutateAndExpectApplied = async () => {
1080
+ await h.mutate(m);
1081
+ await (h.settle ?? defaultSettle)();
1082
+ const doc = exportOrFail(m, "after a user mutation");
1083
+ if (doc === baseline) {
1084
+ fail(
1085
+ previous === void 0 ? "the harness mutation did not change the export \u2014 the case proves nothing" : `a user mutation after importing ${describeProbe(previous)} did not apply (wedged)`
1086
+ );
1087
+ }
1088
+ return doc;
1089
+ };
1090
+ for (const probe of probesOf(h)) {
1091
+ await reimportBase();
1092
+ const doc = await mutateAndExpectApplied();
1093
+ const ids = sortedIds(m);
1094
+ const what = `importText(${describeProbe(probe)})`;
1095
+ let rejected = false;
1096
+ const { calls } = await changeEvents(
1097
+ h,
1098
+ m,
1099
+ () => m.importText(probe.text).then(
1100
+ () => void 0,
1101
+ () => {
1102
+ rejected = true;
1103
+ }
1104
+ )
1105
+ );
1106
+ if (rejected) {
1107
+ if (calls > 0) fail(`a rejected ${what} fired onContentChanged ${calls}x (L7)`);
1108
+ if (exportOrFail(m, `after a rejected ${what}`) !== doc) {
1109
+ fail(`a rejected ${what} changed the document \u2014 the previous state must stay intact (L7)`);
1110
+ }
1111
+ if (ids && !same(ids, sortedIds(m))) {
1112
+ fail(
1113
+ `a rejected ${what} changed the rendered elements \u2014 the canvas must keep the previous document (L7)`
1114
+ );
1115
+ }
1116
+ if (h.undo) {
1117
+ await h.undo(m);
1118
+ if (exportOrFail(m, `after an undo that followed a rejected ${what}`) !== baseline) {
1119
+ fail(
1120
+ `a rejected ${what} erased the undo history \u2014 the previous document's history must stay intact (L7)`
1121
+ );
1122
+ }
1123
+ }
1124
+ } else {
1125
+ if (calls > 0)
1126
+ fail(`${what} resolved and fired onContentChanged ${calls}x \u2014 an import is silent (L1)`);
1127
+ exportOrFail(m, `after ${what} resolved (L6)`);
1128
+ }
1129
+ previous = probe;
1130
+ }
1131
+ await reimportBase();
1132
+ await mutateAndExpectApplied();
1133
+ } finally {
1134
+ m.destroy();
1135
+ }
1136
+ }
1137
+ },
597
1138
  {
598
1139
  name: "viewer (when shipped): read-only, silent, canonical, fits, reveals, keeps foreign content, unshaken by a rejected import",
599
1140
  async run(h) {
@@ -701,10 +1242,14 @@ function registerConformance(harness, test, options = {}) {
701
1242
  const onSkip = options.onSkip ?? ((name, reason) => {
702
1243
  console.warn(`modeler-api conformance: SKIPPED "${name}" \u2014 ${reason}`);
703
1244
  });
1245
+ const onAdvisory = options.onAdvisory ?? ((name, advice) => {
1246
+ console.warn(`modeler-api conformance: ADVISORY "${name}" \u2014 ${advice}`);
1247
+ });
704
1248
  for (const conformanceCase of conformanceCases()) {
705
1249
  test(`conformance: ${conformanceCase.name}`, async () => {
706
1250
  const outcome = await conformanceCase.run(harness);
707
1251
  if (outcome?.skipped) onSkip(conformanceCase.name, outcome.skipped);
1252
+ for (const advice of outcome?.advisories ?? []) onAdvisory(conformanceCase.name, advice);
708
1253
  });
709
1254
  }
710
1255
  }