@dolphy-app/extension-sdk 0.2.0 → 0.4.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 +25 -8
- package/dist/answer-element-BOQcuxYh.js +114 -0
- package/dist/answer-view-D6wnyThb.d.ts +28 -0
- package/dist/index.d.ts +107 -27
- package/dist/index.js +48 -117
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +24 -0
- package/dist/testing.d.ts +330 -11
- package/dist/testing.js +722 -11
- package/docs/debugging.md +210 -0
- package/docs/no-build.md +99 -0
- package/docs/quick-start.md +172 -0
- package/docs/recipe-command-panel.md +211 -0
- package/docs/recipe-event-storage.md +266 -0
- package/docs/recipe-exercise-type.md +379 -0
- package/docs/recipe-import-export.md +241 -0
- package/docs/recipe-settings.md +161 -0
- package/docs/recipe-theme.md +116 -0
- package/docs/recipe-ui-kit.md +172 -0
- package/docs/recipe-when-dependencies.md +158 -0
- package/package.json +10 -4
package/dist/testing.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { t as createAnswerElementClass } from "./answer-element-BOQcuxYh.js";
|
|
2
|
+
import { ANSWER_EVENT, COLOR_SETTING_PATTERN, EXTENSION_COMMAND_LIMITS, EXTENSION_NOTIFICATION_LIMITS, EXTENSION_SECRET_LIMITS, EXTENSION_STATS_LIMITS, EXTENSION_STORAGE_LIMITS, EXTENSION_TRANSFER_LIMITS, InvalidCommandResultError, InvalidTransferResultError, NotificationRateLimitError, PermissionError, SETTING_LIMITS, SecretsUnavailableError, StorageQuotaError, normalizeCommandResult, normalizeExportResult, normalizeImportResult } from "@dolphy-app/extension-api";
|
|
1
3
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
2
4
|
|
|
3
5
|
//#region packages/extension-sdk/src/testing.ts
|
|
@@ -31,6 +33,451 @@ const createMemoryLibrary = (files) => {
|
|
|
31
33
|
}
|
|
32
34
|
};
|
|
33
35
|
};
|
|
36
|
+
/** UTF-8 byte order matches code point order — this is how the engine sorts keys. */
|
|
37
|
+
const compareKeys = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
38
|
+
/**
|
|
39
|
+
* In-memory storage with the same limits and errors as the engine
|
|
40
|
+
* (`EXTENSION_STORAGE_LIMITS`, `StorageQuotaError`): values are stored as
|
|
41
|
+
* JSON text and returned as copies; on rejection nothing changes.
|
|
42
|
+
*/
|
|
43
|
+
const createMemoryStorage = () => {
|
|
44
|
+
const limits = EXTENSION_STORAGE_LIMITS;
|
|
45
|
+
const entries = /* @__PURE__ */ new Map();
|
|
46
|
+
const totalBytes = () => {
|
|
47
|
+
let total = 0;
|
|
48
|
+
for (const text of entries.values()) total += Buffer.byteLength(text);
|
|
49
|
+
return total;
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
get: async (key) => {
|
|
53
|
+
const text = entries.get(key);
|
|
54
|
+
return text === void 0 ? void 0 : JSON.parse(text);
|
|
55
|
+
},
|
|
56
|
+
set: async (key, value) => {
|
|
57
|
+
if (typeof key !== "string" || key.length === 0) throw new Error("storage key must be a non-empty string");
|
|
58
|
+
const text = JSON.stringify(value);
|
|
59
|
+
if (text === void 0) throw new Error("storage value must be JSON");
|
|
60
|
+
const bytes = Buffer.byteLength(text);
|
|
61
|
+
if (key.length > limits.keyLength) throw new StorageQuotaError("key-length", limits.keyLength);
|
|
62
|
+
if (bytes > limits.valueBytes) throw new StorageQuotaError("value-size", limits.valueBytes);
|
|
63
|
+
const existing = entries.get(key);
|
|
64
|
+
if (existing === void 0 && entries.size >= limits.keys) throw new StorageQuotaError("key-count", limits.keys);
|
|
65
|
+
if (totalBytes() - (existing === void 0 ? 0 : Buffer.byteLength(existing)) + bytes > limits.totalBytes) throw new StorageQuotaError("total-size", limits.totalBytes);
|
|
66
|
+
entries.set(key, text);
|
|
67
|
+
},
|
|
68
|
+
delete: async (key) => entries.delete(key),
|
|
69
|
+
keys: async () => [...entries.keys()].sort(compareKeys)
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* In-memory secrets with the engine's limits and errors
|
|
74
|
+
* (`EXTENSION_SECRET_LIMITS`, `StorageQuotaError`, `SecretsUnavailableError`):
|
|
75
|
+
* without a key store `set` and `get` of an existing key throw, `get` of a
|
|
76
|
+
* missing key gives `undefined` and `delete` works.
|
|
77
|
+
*/
|
|
78
|
+
const createMemorySecrets = (options = {}) => {
|
|
79
|
+
const limits = EXTENSION_SECRET_LIMITS;
|
|
80
|
+
const entries = /* @__PURE__ */ new Map();
|
|
81
|
+
let available = options.available ?? true;
|
|
82
|
+
const requireStore = () => {
|
|
83
|
+
if (!available) throw new SecretsUnavailableError();
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
setAvailable: (value) => {
|
|
87
|
+
available = value;
|
|
88
|
+
},
|
|
89
|
+
get: async (key) => {
|
|
90
|
+
const value = entries.get(key);
|
|
91
|
+
if (value !== void 0) requireStore();
|
|
92
|
+
return value;
|
|
93
|
+
},
|
|
94
|
+
set: async (key, value) => {
|
|
95
|
+
if (typeof key !== "string" || key.length === 0) throw new Error("secret key must be a non-empty string");
|
|
96
|
+
if (typeof value !== "string") throw new Error("secret value must be a string");
|
|
97
|
+
if (key.length > limits.keyLength) throw new StorageQuotaError("key-length", limits.keyLength);
|
|
98
|
+
if (Buffer.byteLength(value) > limits.valueBytes) throw new StorageQuotaError("value-size", limits.valueBytes);
|
|
99
|
+
requireStore();
|
|
100
|
+
if (!entries.has(key) && entries.size >= limits.keys) throw new StorageQuotaError("key-count", limits.keys);
|
|
101
|
+
entries.set(key, value);
|
|
102
|
+
},
|
|
103
|
+
delete: async (key) => entries.delete(key)
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
/** The value as the host stores it: a color in lower case, a list copied. */
|
|
107
|
+
const storedForm = (definition, value) => {
|
|
108
|
+
if (definition.type === "color" && typeof value === "string") return value.toLowerCase();
|
|
109
|
+
return Array.isArray(value) ? [...value] : value;
|
|
110
|
+
};
|
|
111
|
+
const sameSettingValue = (a, b) => Array.isArray(a) && Array.isArray(b) ? a.length === b.length && a.every((item, index) => item === b[index]) : Object.is(a, b);
|
|
112
|
+
/** Why a value does not fit the setting definition; `null` if it fits. */
|
|
113
|
+
const findSettingProblem = (definition, value) => {
|
|
114
|
+
switch (definition.type) {
|
|
115
|
+
case "boolean": return typeof value === "boolean" ? null : "must be a boolean";
|
|
116
|
+
case "color": return typeof value === "string" && COLOR_SETTING_PATTERN.test(value) ? null : "must be a color #rrggbb";
|
|
117
|
+
case "list": {
|
|
118
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return "must be an array of strings";
|
|
119
|
+
const maxItems = definition.maxItems ?? SETTING_LIMITS.listItems;
|
|
120
|
+
if (value.length > maxItems) return `has more than ${maxItems} items`;
|
|
121
|
+
const itemMax = definition.itemMaxLength ?? SETTING_LIMITS.listItemLength;
|
|
122
|
+
return value.some((item) => item.length > itemMax) ? `has an item longer than ${itemMax} characters` : null;
|
|
123
|
+
}
|
|
124
|
+
case "text":
|
|
125
|
+
case "string":
|
|
126
|
+
if (typeof value !== "string") return "must be a string";
|
|
127
|
+
return definition.maxLength !== void 0 && value.length > definition.maxLength ? `is longer than ${definition.maxLength} characters` : null;
|
|
128
|
+
case "number":
|
|
129
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "must be a finite number";
|
|
130
|
+
if (definition.integer === true && !Number.isInteger(value)) return "must be an integer";
|
|
131
|
+
if (definition.min !== void 0 && value < definition.min) return `is less than ${definition.min}`;
|
|
132
|
+
return definition.max !== void 0 && value > definition.max ? `is greater than ${definition.max}` : null;
|
|
133
|
+
default: return typeof value === "string" && definition.options.some((option) => option.value === value) ? null : "must be one of the options";
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/** In-memory settings from manifest definitions; `initial` provides user values in place of `default`. */
|
|
137
|
+
const createMemorySettings = (definitions, initial = {}) => {
|
|
138
|
+
const byId = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
139
|
+
const values = new Map(definitions.map((definition) => [definition.id, storedForm(definition, definition.default)]));
|
|
140
|
+
const handlers = /* @__PURE__ */ new Set();
|
|
141
|
+
const known = (id) => {
|
|
142
|
+
const definition = byId.get(id);
|
|
143
|
+
if (definition === void 0) throw new Error(`setting '${id}' is not declared in the manifest`);
|
|
144
|
+
return definition;
|
|
145
|
+
};
|
|
146
|
+
const checked = (id, value) => {
|
|
147
|
+
const problem = findSettingProblem(known(id), value);
|
|
148
|
+
if (problem !== null) throw new Error(`setting '${id}' ${problem}`);
|
|
149
|
+
return storedForm(known(id), value);
|
|
150
|
+
};
|
|
151
|
+
for (const [id, value] of Object.entries(initial)) values.set(id, checked(id, value));
|
|
152
|
+
return {
|
|
153
|
+
get: (id) => storedForm(known(id), values.get(id)),
|
|
154
|
+
onDidChange(handler) {
|
|
155
|
+
handlers.add(handler);
|
|
156
|
+
return { dispose: () => void handlers.delete(handler) };
|
|
157
|
+
},
|
|
158
|
+
async set(id, value) {
|
|
159
|
+
const next = checked(id, value);
|
|
160
|
+
if (sameSettingValue(values.get(id), next)) return;
|
|
161
|
+
values.set(id, next);
|
|
162
|
+
for (const handler of [...handlers]) await handler({
|
|
163
|
+
id,
|
|
164
|
+
value: next
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
/** In-memory learning event subscriptions: one handler per event, as in the host. */
|
|
170
|
+
const createMemoryEvents = (options = {}) => {
|
|
171
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
172
|
+
return {
|
|
173
|
+
on(name, handler) {
|
|
174
|
+
if (options.permitted === false) throw new PermissionError("learning.events");
|
|
175
|
+
if (options.declared !== void 0 && !options.declared.includes(name)) throw new Error(`event '${name}' is not declared in the manifest`);
|
|
176
|
+
if (handlers.has(name)) throw new Error(`event '${name}' is already subscribed`);
|
|
177
|
+
handlers.set(name, handler);
|
|
178
|
+
return { dispose: () => {
|
|
179
|
+
if (handlers.get(name) === handler) handlers.delete(name);
|
|
180
|
+
} };
|
|
181
|
+
},
|
|
182
|
+
async emit(name, payload) {
|
|
183
|
+
await handlers.get(name)?.(payload);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
/** In-memory commands: the same registration rules and result parsing as the host. */
|
|
188
|
+
const createMemoryCommands = (options = {}) => {
|
|
189
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
190
|
+
return {
|
|
191
|
+
register(id, handler) {
|
|
192
|
+
if (options.declaredCommands !== void 0 && !options.declaredCommands.includes(id)) throw new Error(`command '${id}' is not declared in the manifest`);
|
|
193
|
+
if (handlers.has(id)) throw new Error(`command '${id}' is already registered`);
|
|
194
|
+
handlers.set(id, handler);
|
|
195
|
+
return { dispose: () => {
|
|
196
|
+
if (handlers.get(id) === handler) handlers.delete(id);
|
|
197
|
+
} };
|
|
198
|
+
},
|
|
199
|
+
async run(id, args) {
|
|
200
|
+
const handler = handlers.get(id);
|
|
201
|
+
if (handler === void 0) throw new Error(`command '${id}' was not registered`);
|
|
202
|
+
if ((JSON.stringify(args)?.length ?? 0) > EXTENSION_COMMAND_LIMITS.argsChars) throw new Error(`args are longer than ${EXTENSION_COMMAND_LIMITS.argsChars} characters`);
|
|
203
|
+
const result = await handler(args);
|
|
204
|
+
try {
|
|
205
|
+
return normalizeCommandResult(result, options.declaredPanels);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error instanceof InvalidCommandResultError) throw new Error(`invalid command result: ${error.message}`);
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
ids: () => [...handlers.keys()]
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
/** In-memory schedule subscriptions: one handler per schedule and no overlapping firings, as in the host. */
|
|
215
|
+
const createMemorySchedule = (options = {}) => {
|
|
216
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
217
|
+
const running = /* @__PURE__ */ new Set();
|
|
218
|
+
return {
|
|
219
|
+
on(id, handler) {
|
|
220
|
+
if (options.declared !== void 0 && !options.declared.includes(id)) throw new Error(`schedule '${id}' is not declared in the manifest`);
|
|
221
|
+
if (handlers.has(id)) throw new Error(`schedule '${id}' is already subscribed`);
|
|
222
|
+
handlers.set(id, handler);
|
|
223
|
+
return { dispose: () => {
|
|
224
|
+
if (handlers.get(id) === handler) handlers.delete(id);
|
|
225
|
+
} };
|
|
226
|
+
},
|
|
227
|
+
async fire(id) {
|
|
228
|
+
const handler = handlers.get(id);
|
|
229
|
+
if (handler === void 0 || running.has(id)) return false;
|
|
230
|
+
running.add(id);
|
|
231
|
+
try {
|
|
232
|
+
await handler();
|
|
233
|
+
return true;
|
|
234
|
+
} finally {
|
|
235
|
+
running.delete(id);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
ids: () => [...handlers.keys()]
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
/** In-memory importers: the same registration rules and result checks as the host. */
|
|
242
|
+
const createMemoryImporters = (options = {}) => {
|
|
243
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
244
|
+
const declaredOf = (id) => options.declaredImporters?.find((entry) => entry.id === id);
|
|
245
|
+
return {
|
|
246
|
+
register(id, handler) {
|
|
247
|
+
if (options.declaredImporters !== void 0 && declaredOf(id) === void 0) throw new Error(`importer '${id}' is not declared in the manifest`);
|
|
248
|
+
if (handlers.has(id)) throw new Error(`importer '${id}' is already registered`);
|
|
249
|
+
handlers.set(id, handler);
|
|
250
|
+
return { dispose: () => {
|
|
251
|
+
if (handlers.get(id) === handler) handlers.delete(id);
|
|
252
|
+
} };
|
|
253
|
+
},
|
|
254
|
+
async run(id, input) {
|
|
255
|
+
const handler = handlers.get(id);
|
|
256
|
+
if (handler === void 0) throw new Error(`importer '${id}' was not registered`);
|
|
257
|
+
const expected = declaredOf(id)?.input ?? "text";
|
|
258
|
+
if (options.declaredImporters !== void 0 && ("text" in input ? "text" : "bytes") !== expected) throw new Error(`importer '${id}' takes ${expected} input`);
|
|
259
|
+
if (("text" in input ? new TextEncoder().encode(input.text).length : input.bytes.byteLength) > EXTENSION_TRANSFER_LIMITS.inputBytes) throw new Error(`the file is longer than ${EXTENSION_TRANSFER_LIMITS.inputBytes} bytes`);
|
|
260
|
+
const result = await handler(input);
|
|
261
|
+
try {
|
|
262
|
+
return normalizeImportResult(result);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error instanceof InvalidTransferResultError) throw new Error(`invalid import result: ${error.message}`);
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
ids: () => [...handlers.keys()]
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
/** In-memory exporters: the same registration rules and result checks as the host. */
|
|
272
|
+
const createMemoryExporters = (options = {}) => {
|
|
273
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
274
|
+
const declaredOf = (id) => options.declaredExporters?.find((entry) => entry.id === id);
|
|
275
|
+
return {
|
|
276
|
+
register(id, handler) {
|
|
277
|
+
if (options.declaredExporters !== void 0 && declaredOf(id) === void 0) throw new Error(`exporter '${id}' is not declared in the manifest`);
|
|
278
|
+
if (handlers.has(id)) throw new Error(`exporter '${id}' is already registered`);
|
|
279
|
+
handlers.set(id, handler);
|
|
280
|
+
return { dispose: () => {
|
|
281
|
+
if (handlers.get(id) === handler) handlers.delete(id);
|
|
282
|
+
} };
|
|
283
|
+
},
|
|
284
|
+
async run(id, input) {
|
|
285
|
+
const handler = handlers.get(id);
|
|
286
|
+
if (handler === void 0) throw new Error(`exporter '${id}' was not registered`);
|
|
287
|
+
const declared = declaredOf(id);
|
|
288
|
+
if (declared !== void 0 && declared.scope !== input.scope) throw new Error(`exporter '${id}' takes the ${declared.scope} scope`);
|
|
289
|
+
if (input.scope === "course") {
|
|
290
|
+
const encoder = new TextEncoder();
|
|
291
|
+
if (Object.values(input.files).reduce((sum, text) => sum + encoder.encode(text).length, 0) > EXTENSION_TRANSFER_LIMITS.totalBytes) throw new Error(`the course files are longer than ${EXTENSION_TRANSFER_LIMITS.totalBytes} bytes`);
|
|
292
|
+
}
|
|
293
|
+
const result = await handler(input);
|
|
294
|
+
try {
|
|
295
|
+
return normalizeExportResult(result);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (error instanceof InvalidTransferResultError) throw new Error(`invalid export result: ${error.message}`);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
ids: () => [...handlers.keys()]
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
const DAY_MS = 864e5;
|
|
305
|
+
const STATS_CORRECT_GRADE_MIN = 3;
|
|
306
|
+
const dayNumberOf = (year, month, day) => {
|
|
307
|
+
const date = /* @__PURE__ */ new Date(0);
|
|
308
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
309
|
+
return Math.floor(date.getTime() / DAY_MS);
|
|
310
|
+
};
|
|
311
|
+
const dayLabelOf = (dayNumber) => (/* @__PURE__ */ new Date(dayNumber * DAY_MS)).toISOString().slice(0, 10);
|
|
312
|
+
const parseDay = (field, value) => {
|
|
313
|
+
const match = typeof value === "string" ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
|
|
314
|
+
const day = match === null ? null : dayNumberOf(Number(match[1]), Number(match[2]), Number(match[3]));
|
|
315
|
+
if (day === null || dayLabelOf(day) !== value) throw new Error(`'${field}' must be a date as YYYY-MM-DD`);
|
|
316
|
+
return day;
|
|
317
|
+
};
|
|
318
|
+
/**
|
|
319
|
+
* In-memory statistics with the semantics of the app: local days in a time zone,
|
|
320
|
+
* correct at grade 3 or higher, `current` streak not broken while today has no
|
|
321
|
+
* attempts yet, one `daily` entry per date (at most `EXTENSION_STATS_LIMITS.dailyDays`).
|
|
322
|
+
*/
|
|
323
|
+
const createMemoryStats = (options = {}) => {
|
|
324
|
+
const timeZone = options.timeZone ?? new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
325
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
326
|
+
timeZone,
|
|
327
|
+
calendar: "gregory",
|
|
328
|
+
numberingSystem: "latn",
|
|
329
|
+
year: "numeric",
|
|
330
|
+
month: "2-digit",
|
|
331
|
+
day: "2-digit"
|
|
332
|
+
});
|
|
333
|
+
const localDay = (at) => {
|
|
334
|
+
const parts = Object.fromEntries(formatter.formatToParts(at).map(({ type, value }) => [type, value]));
|
|
335
|
+
return dayNumberOf(Number(parts.year), Number(parts.month), Number(parts.day));
|
|
336
|
+
};
|
|
337
|
+
const attempts = [];
|
|
338
|
+
const record = (attempt) => {
|
|
339
|
+
const at = new Date(attempt.at).getTime();
|
|
340
|
+
if (Number.isNaN(at)) throw new Error("attempt.at is not a valid time");
|
|
341
|
+
attempts.push({
|
|
342
|
+
day: localDay(at),
|
|
343
|
+
correct: attempt.grade >= STATS_CORRECT_GRADE_MIN,
|
|
344
|
+
...attempt.courseId !== void 0 && { courseId: attempt.courseId }
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
for (const attempt of options.attempts ?? []) record(attempt);
|
|
348
|
+
const guard = () => {
|
|
349
|
+
if (options.permitted === false) throw new PermissionError("learning.stats");
|
|
350
|
+
};
|
|
351
|
+
const countsOf = (courseId) => {
|
|
352
|
+
const days = /* @__PURE__ */ new Map();
|
|
353
|
+
for (const item of attempts) {
|
|
354
|
+
if (courseId !== void 0 && item.courseId !== courseId) continue;
|
|
355
|
+
const counts = days.get(item.day) ?? {
|
|
356
|
+
attempts: 0,
|
|
357
|
+
correct: 0
|
|
358
|
+
};
|
|
359
|
+
counts.attempts += 1;
|
|
360
|
+
if (item.correct) counts.correct += 1;
|
|
361
|
+
days.set(item.day, counts);
|
|
362
|
+
}
|
|
363
|
+
return days;
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
record,
|
|
367
|
+
async streak(streakOptions) {
|
|
368
|
+
guard();
|
|
369
|
+
const days = countsOf(streakOptions?.courseId);
|
|
370
|
+
const today = localDay((options.now ?? Date.now)());
|
|
371
|
+
let longest = 0;
|
|
372
|
+
let run = 0;
|
|
373
|
+
let previous = NaN;
|
|
374
|
+
for (const day of [...days.keys()].sort((a, b) => a - b)) {
|
|
375
|
+
run = day === previous + 1 ? run + 1 : 1;
|
|
376
|
+
longest = Math.max(longest, run);
|
|
377
|
+
previous = day;
|
|
378
|
+
}
|
|
379
|
+
let cursor = days.has(today) ? today : today - 1;
|
|
380
|
+
let current = 0;
|
|
381
|
+
while (days.has(cursor)) {
|
|
382
|
+
current += 1;
|
|
383
|
+
cursor -= 1;
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
current,
|
|
387
|
+
longest
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
async daily({ from, to, courseId }) {
|
|
391
|
+
guard();
|
|
392
|
+
const first = parseDay("from", from);
|
|
393
|
+
const last = parseDay("to", to);
|
|
394
|
+
if (last < first || last - first + 1 > EXTENSION_STATS_LIMITS.dailyDays) throw new Error(`the range must be ascending and cover at most ${EXTENSION_STATS_LIMITS.dailyDays} dates`);
|
|
395
|
+
const days = countsOf(courseId);
|
|
396
|
+
const result = [];
|
|
397
|
+
for (let day = first; day <= last; day += 1) {
|
|
398
|
+
const { attempts: made, correct } = days.get(day) ?? {
|
|
399
|
+
attempts: 0,
|
|
400
|
+
correct: 0
|
|
401
|
+
};
|
|
402
|
+
result.push({
|
|
403
|
+
date: dayLabelOf(day),
|
|
404
|
+
attempts: made,
|
|
405
|
+
correct,
|
|
406
|
+
accuracy: made === 0 ? null : correct / made
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
};
|
|
413
|
+
/** Control characters (except the line feed), line and paragraph separators and text direction marks. */
|
|
414
|
+
const NOTIFICATION_CONTROL = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u2028\u2029\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
|
|
415
|
+
const notificationText = (field, value, max) => {
|
|
416
|
+
if (typeof value !== "string") throw new Error(`notification ${field} must be a string`);
|
|
417
|
+
const unified = value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ");
|
|
418
|
+
const text = (field === "body" ? unified : unified.replace(/\n/gu, " ")).replace(NOTIFICATION_CONTROL, "").trim();
|
|
419
|
+
if (field === "title" && text === "" || [...text].length > max) throw new Error(`notification ${field} must be ${field === "title" ? "1 to " : "up to "}${max} characters`);
|
|
420
|
+
return text;
|
|
421
|
+
};
|
|
422
|
+
/**
|
|
423
|
+
* In-memory notifications with the engine's rules: the text is sanitized and
|
|
424
|
+
* limited, `perMinute`/`perHour` windows throw `NotificationRateLimitError`,
|
|
425
|
+
* a switched-off extension or an unsupporting system resolves `false` (and
|
|
426
|
+
* does not use the rate limit). `shown` is the log the app's fake notifier
|
|
427
|
+
* would keep.
|
|
428
|
+
*/
|
|
429
|
+
const createMemoryNotifications = (options = {}) => {
|
|
430
|
+
const now = options.now ?? Date.now;
|
|
431
|
+
let enabled = options.enabled !== false;
|
|
432
|
+
let supported = options.supported !== false;
|
|
433
|
+
const shown = [];
|
|
434
|
+
const times = [];
|
|
435
|
+
const limits = EXTENSION_NOTIFICATION_LIMITS;
|
|
436
|
+
return {
|
|
437
|
+
shown,
|
|
438
|
+
setEnabled: (value) => {
|
|
439
|
+
enabled = value;
|
|
440
|
+
},
|
|
441
|
+
setSupported: (value) => {
|
|
442
|
+
supported = value;
|
|
443
|
+
},
|
|
444
|
+
async show(notification) {
|
|
445
|
+
if (options.permitted === false) throw new PermissionError("notifications");
|
|
446
|
+
const title = notificationText("title", notification?.title, limits.titleLength);
|
|
447
|
+
const body = notificationText("body", notification?.body, limits.bodyLength);
|
|
448
|
+
if (!enabled) return false;
|
|
449
|
+
const at = now();
|
|
450
|
+
const recent = times.filter((time) => at - time < 36e5);
|
|
451
|
+
times.length = 0;
|
|
452
|
+
times.push(...recent);
|
|
453
|
+
if (recent.filter((time) => at - time < 6e4).length >= limits.perMinute) throw new NotificationRateLimitError("minute", limits.perMinute);
|
|
454
|
+
if (recent.length >= limits.perHour) throw new NotificationRateLimitError("hour", limits.perHour);
|
|
455
|
+
times.push(at);
|
|
456
|
+
if (!supported) return false;
|
|
457
|
+
shown.push({
|
|
458
|
+
title,
|
|
459
|
+
body
|
|
460
|
+
});
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
};
|
|
465
|
+
const contextOf = (options, registrars) => ({
|
|
466
|
+
extensionId: "test",
|
|
467
|
+
logger: options.logger ?? silentLogger,
|
|
468
|
+
library: options.library ?? createMemoryLibrary({}),
|
|
469
|
+
storage: options.storage ?? createMemoryStorage(),
|
|
470
|
+
secrets: options.secrets ?? createMemorySecrets(),
|
|
471
|
+
settings: options.settings ?? createMemorySettings([]),
|
|
472
|
+
events: options.events ?? createMemoryEvents(),
|
|
473
|
+
stats: options.stats ?? createMemoryStats(),
|
|
474
|
+
notifications: options.notifications ?? createMemoryNotifications(),
|
|
475
|
+
schedule: options.schedule ?? createMemorySchedule(),
|
|
476
|
+
commands: options.commands ?? createMemoryCommands(),
|
|
477
|
+
importers: options.importers ?? createMemoryImporters(),
|
|
478
|
+
exporters: options.exporters ?? createMemoryExporters(),
|
|
479
|
+
...registrars
|
|
480
|
+
});
|
|
34
481
|
const createSchemaValidator = (schema) => {
|
|
35
482
|
const validate = new Ajv2020({
|
|
36
483
|
allErrors: true,
|
|
@@ -86,16 +533,13 @@ const findGradeResultProblem = (result) => {
|
|
|
86
533
|
};
|
|
87
534
|
const loadExerciseType = async (module, type, options = {}) => {
|
|
88
535
|
const handlers = /* @__PURE__ */ new Map();
|
|
89
|
-
const context = {
|
|
90
|
-
extensionId: "test",
|
|
91
|
-
logger: options.logger ?? silentLogger,
|
|
92
|
-
library: options.library ?? createMemoryLibrary({}),
|
|
536
|
+
const context = contextOf(options, {
|
|
93
537
|
registerExerciseType: (registeredType, handler) => {
|
|
94
538
|
handlers.set(registeredType, handler);
|
|
95
539
|
return { dispose: () => void handlers.delete(registeredType) };
|
|
96
540
|
},
|
|
97
541
|
registerGradePolicy: () => ({ dispose: () => void 0 })
|
|
98
|
-
};
|
|
542
|
+
});
|
|
99
543
|
await module.activate(context);
|
|
100
544
|
const handler = handlers.get(type);
|
|
101
545
|
if (handler === void 0) throw new Error(`exercise type '${type}' was not registered`);
|
|
@@ -134,16 +578,13 @@ const loadExerciseType = async (module, type, options = {}) => {
|
|
|
134
578
|
const isGradeValue = (value) => Number.isInteger(value) && value >= 1 && value <= 5;
|
|
135
579
|
const loadGradePolicy = async (module, id, options = {}) => {
|
|
136
580
|
const handlers = /* @__PURE__ */ new Map();
|
|
137
|
-
const context = {
|
|
138
|
-
extensionId: "test",
|
|
139
|
-
logger: options.logger ?? silentLogger,
|
|
140
|
-
library: options.library ?? createMemoryLibrary({}),
|
|
581
|
+
const context = contextOf(options, {
|
|
141
582
|
registerExerciseType: () => ({ dispose: () => void 0 }),
|
|
142
583
|
registerGradePolicy: (registeredId, handler) => {
|
|
143
584
|
handlers.set(registeredId, handler);
|
|
144
585
|
return { dispose: () => void handlers.delete(registeredId) };
|
|
145
586
|
}
|
|
146
|
-
};
|
|
587
|
+
});
|
|
147
588
|
await module.activate(context);
|
|
148
589
|
const handler = handlers.get(id);
|
|
149
590
|
if (handler === void 0) throw new Error(`grade policy '${id}' was not registered`);
|
|
@@ -158,6 +599,276 @@ const loadGradePolicy = async (module, id, options = {}) => {
|
|
|
158
599
|
}
|
|
159
600
|
};
|
|
160
601
|
};
|
|
602
|
+
/**
|
|
603
|
+
* Activates the module with in-memory storage, settings, and events, and lets the test
|
|
604
|
+
* send events and change settings.
|
|
605
|
+
*/
|
|
606
|
+
const loadEvents = async (module, options = {}) => {
|
|
607
|
+
const events = createMemoryEvents(options);
|
|
608
|
+
const storage = options.storage ?? createMemoryStorage();
|
|
609
|
+
const secrets = options.secrets ?? createMemorySecrets();
|
|
610
|
+
const settings = createMemorySettings(options.settings ?? [], options.settingValues);
|
|
611
|
+
const context = contextOf({
|
|
612
|
+
...options.library !== void 0 && { library: options.library },
|
|
613
|
+
...options.logger !== void 0 && { logger: options.logger },
|
|
614
|
+
...options.stats !== void 0 && { stats: options.stats },
|
|
615
|
+
...options.notifications !== void 0 && { notifications: options.notifications },
|
|
616
|
+
...options.schedule !== void 0 && { schedule: options.schedule },
|
|
617
|
+
storage,
|
|
618
|
+
secrets,
|
|
619
|
+
settings,
|
|
620
|
+
events
|
|
621
|
+
}, {
|
|
622
|
+
registerExerciseType: () => ({ dispose: () => void 0 }),
|
|
623
|
+
registerGradePolicy: () => ({ dispose: () => void 0 })
|
|
624
|
+
});
|
|
625
|
+
await module.activate(context);
|
|
626
|
+
return {
|
|
627
|
+
emit: events.emit,
|
|
628
|
+
storage,
|
|
629
|
+
secrets,
|
|
630
|
+
settings,
|
|
631
|
+
dispose: async () => {
|
|
632
|
+
await module.deactivate?.();
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
};
|
|
636
|
+
/** Activates the module with in-memory commands and lets the test invoke them like the host. */
|
|
637
|
+
const loadCommands = async (module, options = {}) => {
|
|
638
|
+
const commands = createMemoryCommands(options);
|
|
639
|
+
const context = contextOf({
|
|
640
|
+
...options.library !== void 0 && { library: options.library },
|
|
641
|
+
...options.logger !== void 0 && { logger: options.logger },
|
|
642
|
+
...options.storage !== void 0 && { storage: options.storage },
|
|
643
|
+
...options.secrets !== void 0 && { secrets: options.secrets },
|
|
644
|
+
...options.settings !== void 0 && { settings: options.settings },
|
|
645
|
+
...options.events !== void 0 && { events: options.events },
|
|
646
|
+
...options.stats !== void 0 && { stats: options.stats },
|
|
647
|
+
...options.notifications !== void 0 && { notifications: options.notifications },
|
|
648
|
+
...options.schedule !== void 0 && { schedule: options.schedule },
|
|
649
|
+
commands
|
|
650
|
+
}, {
|
|
651
|
+
registerExerciseType: () => ({ dispose: () => void 0 }),
|
|
652
|
+
registerGradePolicy: () => ({ dispose: () => void 0 })
|
|
653
|
+
});
|
|
654
|
+
await module.activate(context);
|
|
655
|
+
return {
|
|
656
|
+
run: commands.run,
|
|
657
|
+
ids: commands.ids,
|
|
658
|
+
dispose: async () => {
|
|
659
|
+
await module.deactivate?.();
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
};
|
|
663
|
+
/** The context parts a test replaced, without the keys it left unset. */
|
|
664
|
+
const loadOptionsOf = (options) => ({
|
|
665
|
+
...options.library !== void 0 && { library: options.library },
|
|
666
|
+
...options.logger !== void 0 && { logger: options.logger },
|
|
667
|
+
...options.storage !== void 0 && { storage: options.storage },
|
|
668
|
+
...options.secrets !== void 0 && { secrets: options.secrets },
|
|
669
|
+
...options.settings !== void 0 && { settings: options.settings },
|
|
670
|
+
...options.events !== void 0 && { events: options.events },
|
|
671
|
+
...options.commands !== void 0 && { commands: options.commands },
|
|
672
|
+
...options.stats !== void 0 && { stats: options.stats },
|
|
673
|
+
...options.notifications !== void 0 && { notifications: options.notifications },
|
|
674
|
+
...options.schedule !== void 0 && { schedule: options.schedule }
|
|
675
|
+
});
|
|
676
|
+
/** Activates the module with in-memory importers and lets the test run them like the host. */
|
|
677
|
+
const loadImporters = async (module, options = {}) => {
|
|
678
|
+
const importers = createMemoryImporters(options);
|
|
679
|
+
const context = contextOf({
|
|
680
|
+
...loadOptionsOf(options),
|
|
681
|
+
importers
|
|
682
|
+
}, {
|
|
683
|
+
registerExerciseType: () => ({ dispose: () => void 0 }),
|
|
684
|
+
registerGradePolicy: () => ({ dispose: () => void 0 })
|
|
685
|
+
});
|
|
686
|
+
await module.activate(context);
|
|
687
|
+
return {
|
|
688
|
+
run: importers.run,
|
|
689
|
+
ids: importers.ids,
|
|
690
|
+
dispose: async () => {
|
|
691
|
+
await module.deactivate?.();
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
};
|
|
695
|
+
/** Activates the module with in-memory exporters and lets the test run them like the host; `stats` feeds `ctx.stats` of a progress exporter. */
|
|
696
|
+
const loadExporters = async (module, options = {}) => {
|
|
697
|
+
const exporters = createMemoryExporters(options);
|
|
698
|
+
const context = contextOf({
|
|
699
|
+
...loadOptionsOf(options),
|
|
700
|
+
exporters
|
|
701
|
+
}, {
|
|
702
|
+
registerExerciseType: () => ({ dispose: () => void 0 }),
|
|
703
|
+
registerGradePolicy: () => ({ dispose: () => void 0 })
|
|
704
|
+
});
|
|
705
|
+
await module.activate(context);
|
|
706
|
+
return {
|
|
707
|
+
run: exporters.run,
|
|
708
|
+
ids: exporters.ids,
|
|
709
|
+
dispose: async () => {
|
|
710
|
+
await module.deactivate?.();
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
};
|
|
714
|
+
/** Activates the module with in-memory schedules and lets the test fire them like the host. */
|
|
715
|
+
const loadSchedules = async (module, options = {}) => {
|
|
716
|
+
const schedule = createMemorySchedule(options);
|
|
717
|
+
const context = contextOf({
|
|
718
|
+
...loadOptionsOf(options),
|
|
719
|
+
schedule
|
|
720
|
+
}, {
|
|
721
|
+
registerExerciseType: () => ({ dispose: () => void 0 }),
|
|
722
|
+
registerGradePolicy: () => ({ dispose: () => void 0 })
|
|
723
|
+
});
|
|
724
|
+
await module.activate(context);
|
|
725
|
+
return {
|
|
726
|
+
fire: schedule.fire,
|
|
727
|
+
ids: schedule.ids,
|
|
728
|
+
dispose: async () => {
|
|
729
|
+
await module.deactivate?.();
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
};
|
|
733
|
+
const requireDocument = (helper) => {
|
|
734
|
+
if (typeof document === "undefined") throw new Error(`${helper} needs a DOM: run the test in a DOM environment (happy-dom or jsdom)`);
|
|
735
|
+
return document;
|
|
736
|
+
};
|
|
737
|
+
const microtask = () => Promise.resolve();
|
|
738
|
+
let viewCounter = 0;
|
|
739
|
+
/**
|
|
740
|
+
* Mounts a view from `views[id]` in the test DOM environment with the same element
|
|
741
|
+
* the app creates (test tags are issued; the manifest `element` is not needed).
|
|
742
|
+
*/
|
|
743
|
+
const loadView = async (views, id, options = {}) => {
|
|
744
|
+
const doc = requireDocument("loadView");
|
|
745
|
+
const view = views[id];
|
|
746
|
+
if (view === void 0) throw new Error(`view '${id}' was not exported`);
|
|
747
|
+
const tag = `dolphy-test-view-${++viewCounter}`;
|
|
748
|
+
customElements.define(tag, createAnswerElementClass(tag, view));
|
|
749
|
+
const element = doc.createElement(tag);
|
|
750
|
+
if (options.label !== void 0) element.setAttribute("aria-label", options.label);
|
|
751
|
+
for (const key of [
|
|
752
|
+
"view",
|
|
753
|
+
"value",
|
|
754
|
+
"disabled",
|
|
755
|
+
"verdict"
|
|
756
|
+
]) if (options[key] !== void 0) Object.assign(element, { [key]: options[key] });
|
|
757
|
+
const changes = [];
|
|
758
|
+
let submissions = 0;
|
|
759
|
+
element.addEventListener(ANSWER_EVENT.change, (event) => {
|
|
760
|
+
changes.push(event.detail);
|
|
761
|
+
});
|
|
762
|
+
element.addEventListener(ANSWER_EVENT.submit, () => void (submissions += 1));
|
|
763
|
+
const container = options.container ?? doc.body.appendChild(doc.createElement("div"));
|
|
764
|
+
container.append(element);
|
|
765
|
+
await microtask();
|
|
766
|
+
const root = element.shadowRoot;
|
|
767
|
+
return {
|
|
768
|
+
element,
|
|
769
|
+
root,
|
|
770
|
+
changes,
|
|
771
|
+
get submissions() {
|
|
772
|
+
return submissions;
|
|
773
|
+
},
|
|
774
|
+
update: async (props) => {
|
|
775
|
+
Object.assign(element, props);
|
|
776
|
+
await microtask();
|
|
777
|
+
},
|
|
778
|
+
query: (selector) => root.querySelector(selector),
|
|
779
|
+
queryAll: (selector) => [...root.querySelectorAll(selector)],
|
|
780
|
+
dispose: () => {
|
|
781
|
+
element.remove();
|
|
782
|
+
if (options.container === void 0) container.remove();
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
};
|
|
786
|
+
/** The part of the context a panel and a widget share, with the controls a test needs. */
|
|
787
|
+
const createFrameContext = (options, doc) => {
|
|
788
|
+
const calls = [];
|
|
789
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
790
|
+
const controller = new AbortController();
|
|
791
|
+
const container = options.container ?? doc.body.appendChild(doc.createElement("div"));
|
|
792
|
+
let current = { courseId: options.context?.courseId ?? null };
|
|
793
|
+
return {
|
|
794
|
+
context: {
|
|
795
|
+
get context() {
|
|
796
|
+
return current;
|
|
797
|
+
},
|
|
798
|
+
signal: controller.signal,
|
|
799
|
+
call: async (commandId, args) => {
|
|
800
|
+
calls.push({
|
|
801
|
+
commandId,
|
|
802
|
+
args
|
|
803
|
+
});
|
|
804
|
+
if (options.call === void 0) throw new Error(`command '${commandId}' is not available in this test`);
|
|
805
|
+
return options.call(commandId, args);
|
|
806
|
+
},
|
|
807
|
+
onContextChange: (listener) => {
|
|
808
|
+
listeners.add(listener);
|
|
809
|
+
return () => void listeners.delete(listener);
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
frame: {
|
|
813
|
+
container,
|
|
814
|
+
calls,
|
|
815
|
+
get aborted() {
|
|
816
|
+
return controller.signal.aborted;
|
|
817
|
+
},
|
|
818
|
+
setContext: (next) => {
|
|
819
|
+
current = { courseId: next.courseId };
|
|
820
|
+
for (const listener of [...listeners]) listener(current);
|
|
821
|
+
},
|
|
822
|
+
dispose: () => {
|
|
823
|
+
controller.abort();
|
|
824
|
+
if (options.container === void 0) container.remove();
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
container
|
|
828
|
+
};
|
|
829
|
+
};
|
|
830
|
+
/** Mounts a panel from `panels[id]` in the test DOM environment with the same context the frame provides. */
|
|
831
|
+
const loadPanel = async (panels, id, options = {}) => {
|
|
832
|
+
const doc = requireDocument("loadPanel");
|
|
833
|
+
const panel = panels[id];
|
|
834
|
+
if (panel === void 0) throw new Error(`panel '${id}' was not exported`);
|
|
835
|
+
const { context, frame, container } = createFrameContext(options, doc);
|
|
836
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
837
|
+
await panel.mount(container, {
|
|
838
|
+
get context() {
|
|
839
|
+
return context.context;
|
|
840
|
+
},
|
|
841
|
+
signal: context.signal,
|
|
842
|
+
call: context.call,
|
|
843
|
+
onContextChange: context.onContextChange,
|
|
844
|
+
panelId: id,
|
|
845
|
+
props: options.props,
|
|
846
|
+
onProps: (listener) => {
|
|
847
|
+
listeners.add(listener);
|
|
848
|
+
return () => void listeners.delete(listener);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
return Object.assign(frame, { setProps: (props) => {
|
|
852
|
+
for (const listener of [...listeners]) listener(props);
|
|
853
|
+
} });
|
|
854
|
+
};
|
|
855
|
+
/** Mounts a widget from `widgets[id]` in the test DOM environment with the same context the frame provides. */
|
|
856
|
+
const loadWidget = async (widgets, id, options = {}) => {
|
|
857
|
+
const doc = requireDocument("loadWidget");
|
|
858
|
+
const widget = widgets[id];
|
|
859
|
+
if (widget === void 0) throw new Error(`widget '${id}' was not exported`);
|
|
860
|
+
const { context, frame, container } = createFrameContext(options, doc);
|
|
861
|
+
await widget.mount(container, {
|
|
862
|
+
get context() {
|
|
863
|
+
return context.context;
|
|
864
|
+
},
|
|
865
|
+
signal: context.signal,
|
|
866
|
+
call: context.call,
|
|
867
|
+
onContextChange: context.onContextChange,
|
|
868
|
+
widgetId: id
|
|
869
|
+
});
|
|
870
|
+
return frame;
|
|
871
|
+
};
|
|
161
872
|
|
|
162
873
|
//#endregion
|
|
163
|
-
export { createMemoryLibrary, createSchemaValidator, loadExerciseType, loadGradePolicy };
|
|
874
|
+
export { createMemoryCommands, createMemoryEvents, createMemoryExporters, createMemoryImporters, createMemoryLibrary, createMemoryNotifications, createMemorySchedule, createMemorySecrets, createMemorySettings, createMemoryStats, createMemoryStorage, createSchemaValidator, loadCommands, loadEvents, loadExerciseType, loadExporters, loadGradePolicy, loadImporters, loadPanel, loadSchedules, loadView, loadWidget };
|