@miragon/modeler-api 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +231 -2
- package/dist/chunk-32BMH76T.js +7 -0
- package/dist/chunk-32BMH76T.js.map +1 -0
- package/dist/conformance.cjs +1078 -0
- package/dist/conformance.cjs.map +1 -0
- package/dist/conformance.d.cts +194 -0
- package/dist/conformance.d.ts +194 -0
- package/dist/conformance.js +1049 -0
- package/dist/conformance.js.map +1 -0
- package/dist/index.cjs +31 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +176 -0
- package/dist/index.d.ts +176 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -4
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MODELER_API_VERSION
|
|
3
|
+
} from "./chunk-32BMH76T.js";
|
|
4
|
+
|
|
5
|
+
// src/conformance.ts
|
|
6
|
+
function attachedHost(width = 800, height = 600) {
|
|
7
|
+
const wrapper = document.createElement("div");
|
|
8
|
+
wrapper.style.cssText = `width:${width}px;height:${height}px;position:absolute;left:0;top:0;overflow:hidden`;
|
|
9
|
+
const host = document.createElement("div");
|
|
10
|
+
host.style.cssText = "width:100%;height:100%";
|
|
11
|
+
wrapper.append(host);
|
|
12
|
+
document.body.append(wrapper);
|
|
13
|
+
return host;
|
|
14
|
+
}
|
|
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
|
+
function fail(message) {
|
|
22
|
+
throw new Error(`modeler-api conformance: ${message}`);
|
|
23
|
+
}
|
|
24
|
+
var skip = (reason) => ({ skipped: reason });
|
|
25
|
+
function exportOrFail(modeler, when) {
|
|
26
|
+
try {
|
|
27
|
+
return modeler.exportText();
|
|
28
|
+
} catch (e) {
|
|
29
|
+
fail(`exportText threw ${when}: ${String(e)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
var same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
33
|
+
function assertFinite(state, when) {
|
|
34
|
+
const json = JSON.stringify(
|
|
35
|
+
state,
|
|
36
|
+
(_k, v) => typeof v === "number" && !Number.isFinite(v) ? "__non_finite__" : v
|
|
37
|
+
);
|
|
38
|
+
if (json?.includes("__non_finite__")) {
|
|
39
|
+
fail(
|
|
40
|
+
`getViewState() ${when} contains a non-finite number \u2014 the host has no size (mount into attachedHost())`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
var defaultSettle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
45
|
+
var PRE_IMPORT_CALL_BOUND_MS = 2e3;
|
|
46
|
+
async function toleratedCall(action, ms) {
|
|
47
|
+
let timer;
|
|
48
|
+
const call = Promise.resolve().then(action).catch(() => void 0);
|
|
49
|
+
const bound = new Promise((resolve) => {
|
|
50
|
+
timer = setTimeout(resolve, ms);
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
await Promise.race([call, bound]);
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function changeEvents(harness, modeler, action) {
|
|
59
|
+
const settle = harness.settle ?? defaultSettle;
|
|
60
|
+
let calls = 0;
|
|
61
|
+
let exportAtEmit;
|
|
62
|
+
const off = modeler.onContentChanged(() => {
|
|
63
|
+
calls += 1;
|
|
64
|
+
exportAtEmit = modeler.exportText();
|
|
65
|
+
});
|
|
66
|
+
try {
|
|
67
|
+
await action();
|
|
68
|
+
await settle();
|
|
69
|
+
} finally {
|
|
70
|
+
off();
|
|
71
|
+
}
|
|
72
|
+
return { calls, exportAtEmit };
|
|
73
|
+
}
|
|
74
|
+
var lettersAndDigits = (value) => value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
75
|
+
var secondText = (h) => h.canonicalTexts[1] ?? h.canonicalTexts[0] ?? "";
|
|
76
|
+
var mutationsOf = (h) => [
|
|
77
|
+
{ name: "mutate (element command)", run: (m) => h.mutate(m) },
|
|
78
|
+
...h.configMutations ?? []
|
|
79
|
+
];
|
|
80
|
+
var sortedIds = (m) => m.elements ? [...m.elements.list().map((e) => e.id)].sort() : void 0;
|
|
81
|
+
var namesById = (m) => m.elements ? new Map(m.elements.list().map((e) => [e.id, e.name])) : void 0;
|
|
82
|
+
async function importOrFail(m, text, failure) {
|
|
83
|
+
await m.importText(text).catch((e) => fail(`${failure}: ${String(e)}`));
|
|
84
|
+
}
|
|
85
|
+
var describeProbe = (probe) => {
|
|
86
|
+
const excerpt = probe.text.length > 40 ? `${probe.text.slice(0, 40)}\u2026` : probe.text;
|
|
87
|
+
return `${probe.label} (${JSON.stringify(excerpt)})`;
|
|
88
|
+
};
|
|
89
|
+
function probesOf(h) {
|
|
90
|
+
const probes = [];
|
|
91
|
+
h.canonicalTexts.forEach((text, index) => {
|
|
92
|
+
const source = `canonicalTexts[${index}]`;
|
|
93
|
+
const codePoints = Array.from(text);
|
|
94
|
+
const cut = (length) => codePoints.slice(0, length).join("");
|
|
95
|
+
probes.push(
|
|
96
|
+
{ label: `the first half of ${source}`, text: cut(Math.floor(codePoints.length / 2)) },
|
|
97
|
+
{ label: `the first third of ${source}`, text: cut(Math.floor(codePoints.length / 3)) },
|
|
98
|
+
{ label: `${source} without its last character`, text: cut(codePoints.length - 1) },
|
|
99
|
+
{ label: `${source} followed by a NUL character`, text: `${text}\0` }
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
(h.probeTexts ?? []).forEach((text, index) => probes.push({ label: `probeTexts[${index}]`, text }));
|
|
103
|
+
const seen = /* @__PURE__ */ new Set();
|
|
104
|
+
return probes.filter((probe) => {
|
|
105
|
+
if (seen.has(probe.text)) return false;
|
|
106
|
+
seen.add(probe.text);
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async function importContentText(h, m) {
|
|
111
|
+
for (const text of h.canonicalTexts) {
|
|
112
|
+
await m.importText(text);
|
|
113
|
+
if (!m.elements || m.elements.list().length > 0) return text;
|
|
114
|
+
}
|
|
115
|
+
fail("L5 needs a canonical text that renders at least one element \u2014 an empty document has no fitted view");
|
|
116
|
+
}
|
|
117
|
+
function conformanceCases() {
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
name: "meta declares the notation, editability and a compatible apiVersion",
|
|
121
|
+
async run(h) {
|
|
122
|
+
const m = await h.create();
|
|
123
|
+
try {
|
|
124
|
+
if (!m.meta.notation) fail("meta.notation is empty");
|
|
125
|
+
if (m.meta.editable !== true)
|
|
126
|
+
fail("create() must return an EDITABLE modeler (meta.editable === true)");
|
|
127
|
+
if (!apiVersionsCompatible(m.meta.apiVersion, MODELER_API_VERSION)) {
|
|
128
|
+
fail(`apiVersion ${m.meta.apiVersion} is not compatible with ${MODELER_API_VERSION}`);
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
m.destroy();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "calls before the first import never wedge the instance",
|
|
137
|
+
async run(h) {
|
|
138
|
+
const m = await h.create();
|
|
139
|
+
let control;
|
|
140
|
+
try {
|
|
141
|
+
try {
|
|
142
|
+
m.exportText();
|
|
143
|
+
} catch {
|
|
144
|
+
}
|
|
145
|
+
await toleratedCall(() => h.mutate(m), PRE_IMPORT_CALL_BOUND_MS);
|
|
146
|
+
await (h.settle ?? defaultSettle)();
|
|
147
|
+
control = await h.create();
|
|
148
|
+
const texts = h.canonicalTexts.length > 0 ? h.canonicalTexts.slice(0, 2) : [""];
|
|
149
|
+
let last = "";
|
|
150
|
+
for (const [index, text] of texts.entries()) {
|
|
151
|
+
const source = `canonicalTexts[${index}]`;
|
|
152
|
+
await control.importText(text);
|
|
153
|
+
const expected = exportOrFail(control, `after importing ${source} into a fresh instance`);
|
|
154
|
+
await importOrFail(
|
|
155
|
+
m,
|
|
156
|
+
text,
|
|
157
|
+
`importText(${source}) rejected on an instance that ran exportText() and a user command before its first import (wedged)`
|
|
158
|
+
);
|
|
159
|
+
last = exportOrFail(m, `after importing ${source} into an instance used before its first import`);
|
|
160
|
+
if (last !== expected) {
|
|
161
|
+
fail(
|
|
162
|
+
`importText(${source}) on an instance that ran exportText() and a user command before its first import did not render that document:
|
|
163
|
+
--- expected (a fresh instance's export)
|
|
164
|
+
${expected}
|
|
165
|
+
--- got
|
|
166
|
+
${last}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const { calls } = await changeEvents(h, m, () => h.mutate(m));
|
|
171
|
+
if (calls === 0) {
|
|
172
|
+
fail(
|
|
173
|
+
"a user mutation fired no onContentChanged on an instance used before its first import (wedged)"
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (exportOrFail(m, "after a user mutation on an instance used before its first import") === last) {
|
|
177
|
+
fail("a user mutation did not apply on an instance used before its first import (wedged)");
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
m.destroy();
|
|
181
|
+
control?.destroy();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "L1: importText is silent (no change events, for canonical, loose and foreign inputs)",
|
|
187
|
+
async run(h) {
|
|
188
|
+
const m = await h.create();
|
|
189
|
+
try {
|
|
190
|
+
const texts = [
|
|
191
|
+
...h.canonicalTexts,
|
|
192
|
+
...h.looseTexts ?? [],
|
|
193
|
+
...(h.foreignTexts ?? []).map((f) => f.text)
|
|
194
|
+
];
|
|
195
|
+
for (const text of texts) {
|
|
196
|
+
const { calls } = await changeEvents(h, m, () => m.importText(text).then(() => void 0));
|
|
197
|
+
if (calls > 0) fail(`importText fired onContentChanged ${calls}x`);
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
m.destroy();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: "L1: importText over EXISTING undo history is silent too",
|
|
206
|
+
async run(h) {
|
|
207
|
+
const m = await h.create();
|
|
208
|
+
try {
|
|
209
|
+
await m.importText(h.canonicalTexts[0] ?? "");
|
|
210
|
+
await h.mutate(m);
|
|
211
|
+
await (h.settle ?? defaultSettle)();
|
|
212
|
+
const { calls } = await changeEvents(h, m, () => m.importText(secondText(h)).then(() => void 0));
|
|
213
|
+
if (calls > 0) fail(`importText over a non-empty undo history fired onContentChanged ${calls}x`);
|
|
214
|
+
} finally {
|
|
215
|
+
m.destroy();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "L1: an interaction open during importText never commits",
|
|
221
|
+
async run(h) {
|
|
222
|
+
if (!h.openInteraction) {
|
|
223
|
+
return skip(
|
|
224
|
+
"openInteraction is undefined \u2014 an interaction open during an import (a label editor with typed input) was not verified"
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const openInteraction = h.openInteraction;
|
|
228
|
+
const first = h.canonicalTexts[0] ?? "";
|
|
229
|
+
const second = h.canonicalTexts[1];
|
|
230
|
+
const m = await h.create();
|
|
231
|
+
try {
|
|
232
|
+
const legs = [];
|
|
233
|
+
if (second !== void 0) {
|
|
234
|
+
await m.importText(second);
|
|
235
|
+
const expected = exportOrFail(m, "after importing canonicalTexts[1]");
|
|
236
|
+
legs.push({ text: second, expected, what: "a different document (canonicalTexts[1])" });
|
|
237
|
+
}
|
|
238
|
+
await m.importText(first);
|
|
239
|
+
const firstExport = exportOrFail(m, "after importing canonicalTexts[0]");
|
|
240
|
+
legs.push({
|
|
241
|
+
text: first,
|
|
242
|
+
expected: firstExport,
|
|
243
|
+
what: "the same document again (canonicalTexts[0])"
|
|
244
|
+
});
|
|
245
|
+
let advice;
|
|
246
|
+
for (const [index, leg] of legs.entries()) {
|
|
247
|
+
await m.importText(first);
|
|
248
|
+
await openInteraction(m);
|
|
249
|
+
await (h.settle ?? defaultSettle)();
|
|
250
|
+
const opened = h.interactionState?.(m);
|
|
251
|
+
if (h.interactionState && opened === void 0) {
|
|
252
|
+
fail("interactionState reports no interaction after openInteraction \u2014 the case proves nothing");
|
|
253
|
+
}
|
|
254
|
+
const { calls } = await changeEvents(h, m, () => m.importText(leg.text).then(() => void 0));
|
|
255
|
+
if (calls > 0) {
|
|
256
|
+
fail(
|
|
257
|
+
`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)`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const reopened = h.interactionState?.(m);
|
|
261
|
+
if (exportOrFail(m, `after importing ${leg.what} over an open interaction`) !== leg.expected) {
|
|
262
|
+
fail(
|
|
263
|
+
`importing ${leg.what} over an open interaction did not leave that document \u2014 the interaction's input was applied to it`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (h.undo) {
|
|
267
|
+
await h.undo(m);
|
|
268
|
+
await (h.settle ?? defaultSettle)();
|
|
269
|
+
if (exportOrFail(m, `after an undo that followed importing ${leg.what}`) !== leg.expected) {
|
|
270
|
+
fail(
|
|
271
|
+
`undo after importing ${leg.what} over an open interaction changed the document \u2014 the interaction was committed onto the fresh undo history (L4)`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const sameDocument = index === legs.length - 1;
|
|
276
|
+
const kept = reopened?.elementId === opened?.elementId && reopened?.text === opened?.text;
|
|
277
|
+
if (sameDocument && opened !== void 0 && !kept) {
|
|
278
|
+
const input = opened.text === void 0 ? "" : ` with the input ${JSON.stringify(opened.text)}`;
|
|
279
|
+
const after = reopened === void 0 ? "closed it" : `left ${JSON.stringify(reopened)} open`;
|
|
280
|
+
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`;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const unverified = [
|
|
284
|
+
...second === void 0 ? [
|
|
285
|
+
"canonicalTexts holds one text \u2014 importing a DIFFERENT document over an open interaction was not verified (add a second canonical text)"
|
|
286
|
+
] : [],
|
|
287
|
+
...h.interactionState ? [] : [
|
|
288
|
+
"interactionState is undefined \u2014 the RECOMMENDED keep-and-reopen of an interaction across an import was not checked (pass interactionState to check it)"
|
|
289
|
+
]
|
|
290
|
+
];
|
|
291
|
+
return {
|
|
292
|
+
...unverified.length > 0 ? { skipped: unverified.join("; ") } : {},
|
|
293
|
+
...advice === void 0 ? {} : { advisories: [advice] }
|
|
294
|
+
};
|
|
295
|
+
} finally {
|
|
296
|
+
m.destroy();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
name: "L2: canonical texts are byte fixpoints of import \u2192 export",
|
|
302
|
+
async run(h) {
|
|
303
|
+
if (h.canonicalTexts.length === 0) fail("harness provides no canonicalTexts");
|
|
304
|
+
const m = await h.create();
|
|
305
|
+
try {
|
|
306
|
+
for (const text of h.canonicalTexts) {
|
|
307
|
+
await m.importText(text);
|
|
308
|
+
const out = m.exportText();
|
|
309
|
+
if (out !== text)
|
|
310
|
+
fail(`export after import differs from the canonical input:
|
|
311
|
+
--- in
|
|
312
|
+
${text}
|
|
313
|
+
--- out
|
|
314
|
+
${out}`);
|
|
315
|
+
if (m.exportText() !== out)
|
|
316
|
+
fail("exportText is not deterministic \u2014 two exports of one document differ");
|
|
317
|
+
}
|
|
318
|
+
} finally {
|
|
319
|
+
m.destroy();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: "L2: canonicalization of loose input is idempotent",
|
|
325
|
+
async run(h) {
|
|
326
|
+
if (h.looseTexts === void 0)
|
|
327
|
+
return skip("looseTexts is undefined \u2014 pass [] if the notation has no loose form");
|
|
328
|
+
if (h.looseTexts.length === 0) return;
|
|
329
|
+
const m = await h.create();
|
|
330
|
+
try {
|
|
331
|
+
for (const text of h.looseTexts) {
|
|
332
|
+
await m.importText(text);
|
|
333
|
+
const canonical = m.exportText();
|
|
334
|
+
await m.importText(canonical);
|
|
335
|
+
const again = m.exportText();
|
|
336
|
+
if (again !== canonical) fail("canonicalization is not idempotent on loose input");
|
|
337
|
+
}
|
|
338
|
+
} finally {
|
|
339
|
+
m.destroy();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: "L3: a scripted user mutation fires onContentChanged, and the export already reflects it inside the listener",
|
|
345
|
+
async run(h) {
|
|
346
|
+
const m = await h.create();
|
|
347
|
+
try {
|
|
348
|
+
await m.importText(h.canonicalTexts[0] ?? "");
|
|
349
|
+
const before = m.exportText();
|
|
350
|
+
const { calls, exportAtEmit } = await changeEvents(h, m, () => h.mutate(m));
|
|
351
|
+
if (calls === 0) fail("a user mutation fired no onContentChanged");
|
|
352
|
+
const after = m.exportText();
|
|
353
|
+
if (after === before)
|
|
354
|
+
fail("the harness mutation did not change the export \u2014 the case proves nothing");
|
|
355
|
+
if (exportAtEmit !== after) {
|
|
356
|
+
fail(
|
|
357
|
+
"exportText() inside the onContentChanged listener did not return the post-change document (emit before apply)"
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
} finally {
|
|
361
|
+
m.destroy();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: "L3: config/axis edits fire onContentChanged too, with the export already updated",
|
|
367
|
+
async run(h) {
|
|
368
|
+
if (h.configMutations === void 0) {
|
|
369
|
+
return skip(
|
|
370
|
+
"configMutations is undefined \u2014 pass [] if the notation has no config/axis/title surface"
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
for (const mutation of h.configMutations) {
|
|
374
|
+
const m = await h.create();
|
|
375
|
+
try {
|
|
376
|
+
await m.importText(h.canonicalTexts[0] ?? "");
|
|
377
|
+
const before = m.exportText();
|
|
378
|
+
const { calls, exportAtEmit } = await changeEvents(h, m, () => mutation.run(m));
|
|
379
|
+
if (calls === 0) fail(`config mutation "${mutation.name}" fired no onContentChanged`);
|
|
380
|
+
const after = m.exportText();
|
|
381
|
+
if (after === before) {
|
|
382
|
+
fail(`config mutation "${mutation.name}" did not change the export \u2014 the case proves nothing`);
|
|
383
|
+
}
|
|
384
|
+
if (exportAtEmit !== after) {
|
|
385
|
+
fail(
|
|
386
|
+
`exportText() inside the listener of config mutation "${mutation.name}" did not return the post-change document`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
} finally {
|
|
390
|
+
m.destroy();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: "L3: unsubscribe stops delivery",
|
|
397
|
+
async run(h) {
|
|
398
|
+
const m = await h.create();
|
|
399
|
+
try {
|
|
400
|
+
await m.importText(h.canonicalTexts[0] ?? "");
|
|
401
|
+
let calls = 0;
|
|
402
|
+
const off = m.onContentChanged(() => {
|
|
403
|
+
calls += 1;
|
|
404
|
+
});
|
|
405
|
+
off();
|
|
406
|
+
await h.mutate(m);
|
|
407
|
+
await (h.settle ?? defaultSettle)();
|
|
408
|
+
if (calls > 0) fail("onContentChanged delivered after unsubscribe");
|
|
409
|
+
} finally {
|
|
410
|
+
m.destroy();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
name: "L3: undo and redo fire onContentChanged and restore the export",
|
|
416
|
+
async run(h) {
|
|
417
|
+
if (!h.undo) return skip("undo is undefined \u2014 the notation declares no undo");
|
|
418
|
+
const undo = h.undo;
|
|
419
|
+
const m = await h.create();
|
|
420
|
+
try {
|
|
421
|
+
await m.importText(h.canonicalTexts[0] ?? "");
|
|
422
|
+
const before = m.exportText();
|
|
423
|
+
await h.mutate(m);
|
|
424
|
+
await (h.settle ?? defaultSettle)();
|
|
425
|
+
const mutated = m.exportText();
|
|
426
|
+
const undone = await changeEvents(h, m, () => undo(m));
|
|
427
|
+
if (undone.calls === 0)
|
|
428
|
+
fail("undo fired no onContentChanged \u2014 the host would never export the undone state");
|
|
429
|
+
if (m.exportText() !== before)
|
|
430
|
+
fail("undo of a single mutation did not restore the pre-mutation export");
|
|
431
|
+
if (undone.exportAtEmit !== before)
|
|
432
|
+
fail("exportText() inside the undo listener did not return the undone document");
|
|
433
|
+
if (!h.redo)
|
|
434
|
+
return skip(
|
|
435
|
+
"redo is undefined \u2014 the redo half of this case was not verified (pass redo to verify it)"
|
|
436
|
+
);
|
|
437
|
+
const redo = h.redo;
|
|
438
|
+
const redone = await changeEvents(h, m, () => redo(m));
|
|
439
|
+
if (redone.calls === 0) fail("redo fired no onContentChanged");
|
|
440
|
+
if (m.exportText() !== mutated) fail("redo did not restore the mutated export");
|
|
441
|
+
if (redone.exportAtEmit !== mutated)
|
|
442
|
+
fail("exportText() inside the redo listener did not return the redone document");
|
|
443
|
+
} finally {
|
|
444
|
+
m.destroy();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: "L4: undo directly after an import is a no-op",
|
|
450
|
+
async run(h) {
|
|
451
|
+
if (!h.undo) return skip("undo is undefined \u2014 the notation declares no undo");
|
|
452
|
+
const first = h.canonicalTexts[0];
|
|
453
|
+
const second = h.canonicalTexts[1];
|
|
454
|
+
if (first === void 0 || second === void 0 || first === second) {
|
|
455
|
+
fail("the L4 case needs TWO DISTINCT canonicalTexts \u2014 with one, stale undo is undetectable");
|
|
456
|
+
}
|
|
457
|
+
const m = await h.create();
|
|
458
|
+
try {
|
|
459
|
+
await m.importText(first);
|
|
460
|
+
await h.mutate(m);
|
|
461
|
+
await h.mutate(m);
|
|
462
|
+
const mutated = m.exportText();
|
|
463
|
+
await m.importText(mutated);
|
|
464
|
+
const baselineSame = m.exportText();
|
|
465
|
+
const idsSame = sortedIds(m);
|
|
466
|
+
const { calls } = await changeEvents(h, m, () => h.undo(m));
|
|
467
|
+
if (calls > 0)
|
|
468
|
+
fail(
|
|
469
|
+
"undo directly after importText fired onContentChanged \u2014 history housekeeping is not a change"
|
|
470
|
+
);
|
|
471
|
+
if (m.exportText() !== baselineSame || idsSame && !same(idsSame, sortedIds(m))) {
|
|
472
|
+
fail(
|
|
473
|
+
"undo after importText removed a re-imported element by id coincidence (stale history applied)"
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
await h.mutate(m);
|
|
477
|
+
await m.importText(second);
|
|
478
|
+
const baseline = m.exportText();
|
|
479
|
+
await h.undo(m);
|
|
480
|
+
await h.undo(m);
|
|
481
|
+
if (m.exportText() !== baseline) {
|
|
482
|
+
fail("undo after importText mutated the freshly imported document (stale history applied)");
|
|
483
|
+
}
|
|
484
|
+
} finally {
|
|
485
|
+
m.destroy();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
name: "L5: getViewState never throws before the first import",
|
|
491
|
+
async run(h) {
|
|
492
|
+
const m = await h.create();
|
|
493
|
+
try {
|
|
494
|
+
let state;
|
|
495
|
+
try {
|
|
496
|
+
state = m.getViewState();
|
|
497
|
+
} catch (e) {
|
|
498
|
+
fail(`getViewState threw before the first import: ${String(e)}`);
|
|
499
|
+
}
|
|
500
|
+
if (state !== void 0) assertFinite(state, "before the first import");
|
|
501
|
+
} finally {
|
|
502
|
+
m.destroy();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
name: "L5: view state round-trips across a re-import, an import fits the view \u2014 and none of it is document content",
|
|
508
|
+
async run(h) {
|
|
509
|
+
if (h.viewState === void 0) {
|
|
510
|
+
return skip(
|
|
511
|
+
"viewState is undefined \u2014 supply a panned/zoomed state, a no-op setViewState is otherwise undetectable"
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const alt = h.viewState;
|
|
515
|
+
const m = await h.create();
|
|
516
|
+
try {
|
|
517
|
+
const text = await importContentText(h, m);
|
|
518
|
+
const doc = m.exportText();
|
|
519
|
+
const fitted = m.getViewState();
|
|
520
|
+
assertFinite(fitted, "after the first import");
|
|
521
|
+
m.setViewState(alt);
|
|
522
|
+
const applied = m.getViewState();
|
|
523
|
+
assertFinite(applied, "after setViewState");
|
|
524
|
+
if (same(applied, fitted)) {
|
|
525
|
+
fail("setViewState(viewState) had no effect \u2014 supply a state that differs from the fitted view");
|
|
526
|
+
}
|
|
527
|
+
await m.importText(text);
|
|
528
|
+
if (!same(m.getViewState(), fitted)) {
|
|
529
|
+
fail(
|
|
530
|
+
"an import must leave the view FITTED to the content (the same state the first import produced)"
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const { calls } = await changeEvents(h, m, () => m.setViewState(applied));
|
|
534
|
+
if (calls > 0) fail("setViewState fired onContentChanged \u2014 view state is not document content");
|
|
535
|
+
if (m.exportText() !== doc)
|
|
536
|
+
fail("setViewState changed the export \u2014 view state leaked into the document");
|
|
537
|
+
if (!same(m.getViewState(), applied)) {
|
|
538
|
+
fail(
|
|
539
|
+
"view state did not survive re-import + restore (setting the modeler's own readback must yield that readback)"
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
} finally {
|
|
543
|
+
m.destroy();
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
name: "elements (when present): stable ids across re-imports and unrelated edits, reveal and selection semantics",
|
|
549
|
+
async run(h) {
|
|
550
|
+
const m = await h.create();
|
|
551
|
+
try {
|
|
552
|
+
const text = h.canonicalTexts[0] ?? "";
|
|
553
|
+
await m.importText(text);
|
|
554
|
+
const elements = m.elements;
|
|
555
|
+
if (!elements)
|
|
556
|
+
return skip("the modeler exposes no elements surface (optional) \u2014 nothing to verify");
|
|
557
|
+
const ids = elements.list().map((e) => e.id);
|
|
558
|
+
if (new Set(ids).size !== ids.length) fail("element ids are not unique");
|
|
559
|
+
await m.importText(text);
|
|
560
|
+
const again = elements.list().map((e) => e.id);
|
|
561
|
+
if (!same([...ids].sort(), [...again].sort()))
|
|
562
|
+
fail("element ids are not stable across re-imports of the same text");
|
|
563
|
+
if (elements.reveal("__modeler_api_no_such_id__") !== false)
|
|
564
|
+
fail("reveal(unknown id) must return false");
|
|
565
|
+
const firstId = ids[0];
|
|
566
|
+
if (firstId !== void 0) {
|
|
567
|
+
const deliveries = [];
|
|
568
|
+
const off = elements.onSelection((selected) => deliveries.push([...selected]));
|
|
569
|
+
const { calls } = await changeEvents(h, m, () => {
|
|
570
|
+
if (elements.reveal(firstId) !== true) fail("reveal(known id) must return true");
|
|
571
|
+
});
|
|
572
|
+
if (calls > 0) fail("reveal fired onContentChanged \u2014 revealing is view behavior, not an edit");
|
|
573
|
+
if (!deliveries.some((d) => d.includes(firstId))) {
|
|
574
|
+
fail(
|
|
575
|
+
`reveal('${firstId}') delivered no selection containing the id \u2014 is it a selectable element (not a root or a label)?`
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
for (const d of deliveries) {
|
|
579
|
+
for (const id of d) {
|
|
580
|
+
if (!again.includes(id)) {
|
|
581
|
+
fail(
|
|
582
|
+
`onSelection reported an id that is not an element: '${id}' (label ids must resolve to their target)`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (!elements.selection().includes(firstId)) fail("selection() does not contain the revealed id");
|
|
588
|
+
const seen = deliveries.length;
|
|
589
|
+
off();
|
|
590
|
+
const otherId = ids[1];
|
|
591
|
+
if (otherId !== void 0) elements.reveal(otherId);
|
|
592
|
+
else await h.mutate(m);
|
|
593
|
+
await (h.settle ?? defaultSettle)();
|
|
594
|
+
if (deliveries.length !== seen) fail("onSelection delivered after unsubscribe");
|
|
595
|
+
}
|
|
596
|
+
const namesBefore = new Map(elements.list().map((e) => [e.id, e.name]));
|
|
597
|
+
await h.mutate(m);
|
|
598
|
+
await (h.settle ?? defaultSettle)();
|
|
599
|
+
const afterEdit = new Map(elements.list().map((e) => [e.id, e.name]));
|
|
600
|
+
if (afterEdit.size <= namesBefore.size) {
|
|
601
|
+
fail(
|
|
602
|
+
"the harness mutate must ADD an element (the elements case verifies ids under an unrelated edit)"
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
for (const [id, name] of namesBefore) {
|
|
606
|
+
if (!afterEdit.has(id)) {
|
|
607
|
+
fail(
|
|
608
|
+
`element id '${id}' vanished after an unrelated edit \u2014 ids must survive edits to other elements`
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
if (name !== void 0 && afterEdit.get(id) !== name) {
|
|
612
|
+
fail(
|
|
613
|
+
`element id '${id}' names a different element after an unrelated edit ('${name}' \u2192 '${String(afterEdit.get(id))}')`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} finally {
|
|
618
|
+
m.destroy();
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
name: "elements (when present): ids and names survive export \u2192 import, in this and in a fresh instance",
|
|
624
|
+
async run(h) {
|
|
625
|
+
const m = await h.create();
|
|
626
|
+
const others = [];
|
|
627
|
+
try {
|
|
628
|
+
await m.importText(h.canonicalTexts[0] ?? "");
|
|
629
|
+
const elements = m.elements;
|
|
630
|
+
if (!elements)
|
|
631
|
+
return skip("the modeler exposes no elements surface (optional) \u2014 nothing to verify");
|
|
632
|
+
await h.mutate(m);
|
|
633
|
+
await (h.settle ?? defaultSettle)();
|
|
634
|
+
const session = new Map(elements.list().map((e) => [e.id, e.name]));
|
|
635
|
+
const exported = exportOrFail(m, "after a user mutation");
|
|
636
|
+
const expectSessionIds = (target, where) => {
|
|
637
|
+
const after = namesById(target);
|
|
638
|
+
if (!after) fail(`${where} exposes no elements surface \u2014 the editor does`);
|
|
639
|
+
for (const [id, name] of session) {
|
|
640
|
+
if (!after.has(id)) {
|
|
641
|
+
fail(
|
|
642
|
+
`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)`
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
if (name !== void 0 && (after.get(id) ?? "") !== name) {
|
|
646
|
+
fail(
|
|
647
|
+
`element id '${id}' names a different element after export \u2192 import ${where} ('${name}' \u2192 '${String(after.get(id))}')`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
await importOrFail(m, exported, "importText rejected the modeler's own export");
|
|
653
|
+
expectSessionIds(m, "into the same instance");
|
|
654
|
+
const fresh = await h.create();
|
|
655
|
+
others.push(fresh);
|
|
656
|
+
await importOrFail(fresh, exported, "a fresh instance rejected the modeler's own export");
|
|
657
|
+
expectSessionIds(fresh, "into a fresh instance");
|
|
658
|
+
if (h.createReadonly) {
|
|
659
|
+
const viewer = await h.createReadonly();
|
|
660
|
+
others.push(viewer);
|
|
661
|
+
await importOrFail(viewer, exported, "the viewer rejected the editor's export");
|
|
662
|
+
if (viewer.elements) expectSessionIds(viewer, "into the viewer");
|
|
663
|
+
}
|
|
664
|
+
} finally {
|
|
665
|
+
m.destroy();
|
|
666
|
+
for (const other of others) other.destroy();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
name: "elements (when present, recommended): ids created on two peers at the same time do not collide",
|
|
672
|
+
async run(h) {
|
|
673
|
+
const text = h.canonicalTexts[0] ?? "";
|
|
674
|
+
const peers = [];
|
|
675
|
+
try {
|
|
676
|
+
const created = [];
|
|
677
|
+
for (let count = 0; count < 2; count += 1) {
|
|
678
|
+
const peer = await h.create();
|
|
679
|
+
peers.push(peer);
|
|
680
|
+
await peer.importText(text);
|
|
681
|
+
if (!peer.elements)
|
|
682
|
+
return skip("the modeler exposes no elements surface (optional) \u2014 nothing to verify");
|
|
683
|
+
const before = new Set(peer.elements.list().map((e) => e.id));
|
|
684
|
+
await h.mutate(peer);
|
|
685
|
+
await (h.settle ?? defaultSettle)();
|
|
686
|
+
const added = new Map(
|
|
687
|
+
peer.elements.list().filter((e) => !before.has(e.id)).map((e) => [e.id, e.name])
|
|
688
|
+
);
|
|
689
|
+
if (added.size === 0) {
|
|
690
|
+
fail(
|
|
691
|
+
"the harness mutate must ADD an element (the elements cases compare the ids two instances allocate)"
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
created.push(added);
|
|
695
|
+
}
|
|
696
|
+
const [
|
|
697
|
+
onFirst = /* @__PURE__ */ new Map(),
|
|
698
|
+
onSecond = /* @__PURE__ */ new Map()
|
|
699
|
+
] = created;
|
|
700
|
+
const shared = [...onFirst.keys()].filter((id) => onSecond.has(id));
|
|
701
|
+
const derivedFromContent = (id) => {
|
|
702
|
+
const name = onFirst.get(id) ?? "";
|
|
703
|
+
if (name === "" || name !== (onSecond.get(id) ?? "")) return false;
|
|
704
|
+
const spelled = lettersAndDigits(name);
|
|
705
|
+
return spelled !== "" && lettersAndDigits(id).includes(spelled);
|
|
706
|
+
};
|
|
707
|
+
const allocated = shared.filter((id) => !derivedFromContent(id));
|
|
708
|
+
const quoted = (ids) => ids.map((id) => `'${id}'`).join(", ");
|
|
709
|
+
if (allocated.length > 0) {
|
|
710
|
+
return {
|
|
711
|
+
advisories: [
|
|
712
|
+
`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`
|
|
713
|
+
]
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
if (shared.length > 0) {
|
|
717
|
+
return skip(
|
|
718
|
+
`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)`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
} finally {
|
|
722
|
+
for (const peer of peers) peer.destroy();
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
name: "L6: export is total after imports, user mutations and undo",
|
|
728
|
+
async run(h) {
|
|
729
|
+
const m = await h.create();
|
|
730
|
+
try {
|
|
731
|
+
await m.importText(h.canonicalTexts[0] ?? "");
|
|
732
|
+
for (const mutation of mutationsOf(h)) {
|
|
733
|
+
await mutation.run(m);
|
|
734
|
+
exportOrFail(m, `after ${mutation.name}`);
|
|
735
|
+
}
|
|
736
|
+
if (h.undo) {
|
|
737
|
+
await h.undo(m);
|
|
738
|
+
exportOrFail(m, "after an undo");
|
|
739
|
+
}
|
|
740
|
+
} finally {
|
|
741
|
+
m.destroy();
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
name: "L6: foreign content survives an import \u2192 export round-trip",
|
|
747
|
+
async run(h) {
|
|
748
|
+
if (h.foreignTexts === void 0) {
|
|
749
|
+
return skip("foreignTexts is undefined \u2014 pass [] if the notation has no foreign form");
|
|
750
|
+
}
|
|
751
|
+
if (h.foreignTexts.length === 0) return;
|
|
752
|
+
const m = await h.create();
|
|
753
|
+
try {
|
|
754
|
+
await foreignRoundTrips(h, m, "");
|
|
755
|
+
} finally {
|
|
756
|
+
m.destroy();
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
name: "L7: a rejected import leaves no trace",
|
|
762
|
+
async run(h) {
|
|
763
|
+
if (h.invalidTexts === void 0) {
|
|
764
|
+
return skip("invalidTexts is undefined \u2014 pass [] if the notation has no non-importable text");
|
|
765
|
+
}
|
|
766
|
+
if (h.invalidTexts.length === 0) return;
|
|
767
|
+
const m = await h.create();
|
|
768
|
+
try {
|
|
769
|
+
const text = h.canonicalTexts[0] ?? "";
|
|
770
|
+
await m.importText(text);
|
|
771
|
+
const beforeEdit = m.exportText();
|
|
772
|
+
if (h.undo) {
|
|
773
|
+
await h.mutate(m);
|
|
774
|
+
await (h.settle ?? defaultSettle)();
|
|
775
|
+
}
|
|
776
|
+
const doc = m.exportText();
|
|
777
|
+
const ids = sortedIds(m);
|
|
778
|
+
for (const bad of h.invalidTexts) {
|
|
779
|
+
let rejected = false;
|
|
780
|
+
const { calls } = await changeEvents(
|
|
781
|
+
h,
|
|
782
|
+
m,
|
|
783
|
+
() => m.importText(bad).then(
|
|
784
|
+
() => void 0,
|
|
785
|
+
() => {
|
|
786
|
+
rejected = true;
|
|
787
|
+
}
|
|
788
|
+
)
|
|
789
|
+
);
|
|
790
|
+
if (!rejected) fail("importText resolved for a non-importable input \u2014 it must REJECT");
|
|
791
|
+
if (calls > 0) fail("a rejected import fired onContentChanged");
|
|
792
|
+
if (m.exportText() !== doc)
|
|
793
|
+
fail("a rejected import changed the document \u2014 the previous state must stay intact");
|
|
794
|
+
if (ids && !same(ids, sortedIds(m))) {
|
|
795
|
+
fail(
|
|
796
|
+
"a rejected import changed the rendered elements \u2014 the canvas must keep the previous document"
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (h.undo) {
|
|
801
|
+
await h.undo(m);
|
|
802
|
+
if (m.exportText() !== beforeEdit) {
|
|
803
|
+
fail(
|
|
804
|
+
"a rejected import erased the undo history \u2014 the previous document's history must stay intact"
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
} finally {
|
|
809
|
+
m.destroy();
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
name: "L7: the modeler stays usable after a rejected import",
|
|
815
|
+
async run(h) {
|
|
816
|
+
if (h.invalidTexts === void 0) {
|
|
817
|
+
return skip("invalidTexts is undefined \u2014 pass [] if the notation has no non-importable text");
|
|
818
|
+
}
|
|
819
|
+
const bad = h.invalidTexts[0];
|
|
820
|
+
if (bad === void 0) return;
|
|
821
|
+
const m = await h.create();
|
|
822
|
+
try {
|
|
823
|
+
await m.importText(h.canonicalTexts[0] ?? "");
|
|
824
|
+
await m.importText(bad).then(
|
|
825
|
+
() => fail("importText resolved for a non-importable input \u2014 it must REJECT"),
|
|
826
|
+
() => void 0
|
|
827
|
+
);
|
|
828
|
+
const next = secondText(h);
|
|
829
|
+
await m.importText(next).catch((e) => fail(`the next VALID import after a rejection did not render: ${String(e)}`));
|
|
830
|
+
if (m.exportText() !== next)
|
|
831
|
+
fail("the next valid import after a rejection did not replace the document");
|
|
832
|
+
const before = m.exportText();
|
|
833
|
+
const { calls } = await changeEvents(h, m, () => h.mutate(m));
|
|
834
|
+
if (calls === 0) fail("a user mutation after a rejected import fired no onContentChanged (wedged)");
|
|
835
|
+
if (exportOrFail(m, "after a mutation that followed a rejected import") === before) {
|
|
836
|
+
fail("a user mutation after a rejected import did not apply (wedged)");
|
|
837
|
+
}
|
|
838
|
+
if (h.undo) {
|
|
839
|
+
await h.undo(m);
|
|
840
|
+
exportOrFail(m, "after an undo that followed a rejected import");
|
|
841
|
+
}
|
|
842
|
+
} finally {
|
|
843
|
+
m.destroy();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
name: "L7: every rejection of a derived probe leaves no trace",
|
|
849
|
+
async run(h) {
|
|
850
|
+
const base = h.canonicalTexts[0] ?? "";
|
|
851
|
+
const m = await h.create();
|
|
852
|
+
try {
|
|
853
|
+
await m.importText(base);
|
|
854
|
+
const baseline = exportOrFail(m, "after importing canonicalTexts[0]");
|
|
855
|
+
const baseIds = sortedIds(m);
|
|
856
|
+
let previous;
|
|
857
|
+
const reimportBase = async () => {
|
|
858
|
+
if (previous === void 0) return;
|
|
859
|
+
const after = `after importing ${describeProbe(previous)}`;
|
|
860
|
+
await importOrFail(m, base, `the next valid import ${after} did not render`);
|
|
861
|
+
const doc = exportOrFail(m, `on the next valid import ${after}`);
|
|
862
|
+
if (doc !== baseline || baseIds && !same(baseIds, sortedIds(m))) {
|
|
863
|
+
fail(`the next valid import ${after} did not replace the document (wedged)`);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
const mutateAndExpectApplied = async () => {
|
|
867
|
+
await h.mutate(m);
|
|
868
|
+
await (h.settle ?? defaultSettle)();
|
|
869
|
+
const doc = exportOrFail(m, "after a user mutation");
|
|
870
|
+
if (doc === baseline) {
|
|
871
|
+
fail(
|
|
872
|
+
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)`
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
return doc;
|
|
876
|
+
};
|
|
877
|
+
for (const probe of probesOf(h)) {
|
|
878
|
+
await reimportBase();
|
|
879
|
+
const doc = await mutateAndExpectApplied();
|
|
880
|
+
const ids = sortedIds(m);
|
|
881
|
+
const what = `importText(${describeProbe(probe)})`;
|
|
882
|
+
let rejected = false;
|
|
883
|
+
const { calls } = await changeEvents(
|
|
884
|
+
h,
|
|
885
|
+
m,
|
|
886
|
+
() => m.importText(probe.text).then(
|
|
887
|
+
() => void 0,
|
|
888
|
+
() => {
|
|
889
|
+
rejected = true;
|
|
890
|
+
}
|
|
891
|
+
)
|
|
892
|
+
);
|
|
893
|
+
if (rejected) {
|
|
894
|
+
if (calls > 0) fail(`a rejected ${what} fired onContentChanged ${calls}x (L7)`);
|
|
895
|
+
if (exportOrFail(m, `after a rejected ${what}`) !== doc) {
|
|
896
|
+
fail(`a rejected ${what} changed the document \u2014 the previous state must stay intact (L7)`);
|
|
897
|
+
}
|
|
898
|
+
if (ids && !same(ids, sortedIds(m))) {
|
|
899
|
+
fail(
|
|
900
|
+
`a rejected ${what} changed the rendered elements \u2014 the canvas must keep the previous document (L7)`
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
if (h.undo) {
|
|
904
|
+
await h.undo(m);
|
|
905
|
+
if (exportOrFail(m, `after an undo that followed a rejected ${what}`) !== baseline) {
|
|
906
|
+
fail(
|
|
907
|
+
`a rejected ${what} erased the undo history \u2014 the previous document's history must stay intact (L7)`
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
} else {
|
|
912
|
+
if (calls > 0)
|
|
913
|
+
fail(`${what} resolved and fired onContentChanged ${calls}x \u2014 an import is silent (L1)`);
|
|
914
|
+
exportOrFail(m, `after ${what} resolved (L6)`);
|
|
915
|
+
}
|
|
916
|
+
previous = probe;
|
|
917
|
+
}
|
|
918
|
+
await reimportBase();
|
|
919
|
+
await mutateAndExpectApplied();
|
|
920
|
+
} finally {
|
|
921
|
+
m.destroy();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
name: "viewer (when shipped): read-only, silent, canonical, fits, reveals, keeps foreign content, unshaken by a rejected import",
|
|
927
|
+
async run(h) {
|
|
928
|
+
if (!h.createReadonly) return skip("createReadonly is undefined \u2014 the package ships no viewer mount");
|
|
929
|
+
const v = await h.createReadonly();
|
|
930
|
+
try {
|
|
931
|
+
if (v.meta.editable !== false)
|
|
932
|
+
fail("createReadonly() must return a viewer (meta.editable === false)");
|
|
933
|
+
try {
|
|
934
|
+
const fresh = v.getViewState();
|
|
935
|
+
if (fresh !== void 0) assertFinite(fresh, "on the viewer before the first import");
|
|
936
|
+
} catch (e) {
|
|
937
|
+
fail(`the viewer's getViewState threw before the first import: ${String(e)}`);
|
|
938
|
+
}
|
|
939
|
+
let calls = 0;
|
|
940
|
+
const off = v.onContentChanged(() => {
|
|
941
|
+
calls += 1;
|
|
942
|
+
});
|
|
943
|
+
try {
|
|
944
|
+
for (const text of h.canonicalTexts) {
|
|
945
|
+
await v.importText(text).catch((e) => fail(`the viewer rejected a canonical text: ${String(e)}`));
|
|
946
|
+
if (exportOrFail(v, "on the viewer") !== text)
|
|
947
|
+
fail("the viewer's export is not the canonical text");
|
|
948
|
+
}
|
|
949
|
+
const doc = v.exportText();
|
|
950
|
+
const ids = sortedIds(v);
|
|
951
|
+
if (v.elements) {
|
|
952
|
+
const firstId = v.elements.list()[0]?.id;
|
|
953
|
+
if (firstId !== void 0) {
|
|
954
|
+
const deliveries = [];
|
|
955
|
+
const offSel = v.elements.onSelection((s) => deliveries.push([...s]));
|
|
956
|
+
if (v.elements.reveal(firstId) !== true)
|
|
957
|
+
fail("the viewer's reveal(known id) must return true");
|
|
958
|
+
await (h.settle ?? defaultSettle)();
|
|
959
|
+
if (!deliveries.some((d) => d.includes(firstId)))
|
|
960
|
+
fail("the viewer's reveal delivered no selection containing the id");
|
|
961
|
+
if (!v.elements.selection().includes(firstId))
|
|
962
|
+
fail("the viewer's selection() does not contain the revealed id");
|
|
963
|
+
offSel();
|
|
964
|
+
}
|
|
965
|
+
if (v.elements.reveal("__modeler_api_no_such_id__") !== false)
|
|
966
|
+
fail("the viewer's reveal(unknown id) must return false");
|
|
967
|
+
}
|
|
968
|
+
for (const bad of h.invalidTexts ?? []) {
|
|
969
|
+
await v.importText(bad).then(
|
|
970
|
+
() => fail("the viewer resolved a non-importable input \u2014 it must REJECT"),
|
|
971
|
+
() => void 0
|
|
972
|
+
);
|
|
973
|
+
if (v.exportText() !== doc) fail("a rejected import changed the viewer's document");
|
|
974
|
+
if (ids && !same(ids, sortedIds(v)))
|
|
975
|
+
fail("a rejected import changed the viewer's rendered elements");
|
|
976
|
+
}
|
|
977
|
+
await foreignRoundTrips(h, v, "the viewer's ");
|
|
978
|
+
if (h.viewState !== void 0) {
|
|
979
|
+
const text = await importContentText(h, v);
|
|
980
|
+
const fitted = v.getViewState();
|
|
981
|
+
assertFinite(fitted, "on the viewer after import");
|
|
982
|
+
v.setViewState(h.viewState);
|
|
983
|
+
const applied = v.getViewState();
|
|
984
|
+
if (same(applied, fitted)) fail("the viewer's setViewState had no effect");
|
|
985
|
+
await v.importText(text);
|
|
986
|
+
if (!same(v.getViewState(), fitted))
|
|
987
|
+
fail("the viewer's import must leave the view fitted (L5)");
|
|
988
|
+
v.setViewState(applied);
|
|
989
|
+
if (!same(v.getViewState(), applied))
|
|
990
|
+
fail("setting the viewer's own readback did not yield that readback");
|
|
991
|
+
}
|
|
992
|
+
await (h.settle ?? defaultSettle)();
|
|
993
|
+
if (calls > 0)
|
|
994
|
+
fail(`the viewer fired onContentChanged ${calls}x \u2014 a viewer never changes content`);
|
|
995
|
+
} finally {
|
|
996
|
+
off();
|
|
997
|
+
}
|
|
998
|
+
} finally {
|
|
999
|
+
v.destroy();
|
|
1000
|
+
v.destroy();
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
name: "destroy is safe to call twice",
|
|
1006
|
+
async run(h) {
|
|
1007
|
+
const m = await h.create();
|
|
1008
|
+
m.destroy();
|
|
1009
|
+
m.destroy();
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
];
|
|
1013
|
+
}
|
|
1014
|
+
async function foreignRoundTrips(h, m, who) {
|
|
1015
|
+
for (const { text, mustSurvive } of h.foreignTexts ?? []) {
|
|
1016
|
+
if (mustSurvive.length === 0) fail("a foreignTexts entry must name at least one fragment in mustSurvive");
|
|
1017
|
+
await m.importText(text);
|
|
1018
|
+
const first = exportOrFail(m, `${who}after importing a document with foreign content`);
|
|
1019
|
+
for (const fragment of mustSurvive) {
|
|
1020
|
+
if (!first.includes(fragment))
|
|
1021
|
+
fail(`${who}foreign content was dropped on import \u2192 export: '${fragment}' is missing`);
|
|
1022
|
+
}
|
|
1023
|
+
await m.importText(first);
|
|
1024
|
+
const second = exportOrFail(m, `${who}after re-importing its own export of foreign content`);
|
|
1025
|
+
if (second !== first) fail(`${who}foreign content did not survive a second import \u2192 export round-trip`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
function registerConformance(harness, test, options = {}) {
|
|
1029
|
+
const onSkip = options.onSkip ?? ((name, reason) => {
|
|
1030
|
+
console.warn(`modeler-api conformance: SKIPPED "${name}" \u2014 ${reason}`);
|
|
1031
|
+
});
|
|
1032
|
+
const onAdvisory = options.onAdvisory ?? ((name, advice) => {
|
|
1033
|
+
console.warn(`modeler-api conformance: ADVISORY "${name}" \u2014 ${advice}`);
|
|
1034
|
+
});
|
|
1035
|
+
for (const conformanceCase of conformanceCases()) {
|
|
1036
|
+
test(`conformance: ${conformanceCase.name}`, async () => {
|
|
1037
|
+
const outcome = await conformanceCase.run(harness);
|
|
1038
|
+
if (outcome?.skipped) onSkip(conformanceCase.name, outcome.skipped);
|
|
1039
|
+
for (const advice of outcome?.advisories ?? []) onAdvisory(conformanceCase.name, advice);
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
export {
|
|
1044
|
+
apiVersionsCompatible,
|
|
1045
|
+
attachedHost,
|
|
1046
|
+
conformanceCases,
|
|
1047
|
+
registerConformance
|
|
1048
|
+
};
|
|
1049
|
+
//# sourceMappingURL=conformance.js.map
|