@muspellheim/shared 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/lib/util.js ADDED
@@ -0,0 +1,361 @@
1
+ // Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ /**
4
+ * Contains several miscellaneous utility classes.
5
+ *
6
+ * Portated from
7
+ * [Java Util](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/package-summary.html).
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import { Clock } from './time.js';
13
+
14
+ // TODO check if import from time.js is needed
15
+ // TODO deep copy
16
+ // TODO deep equals
17
+
18
+ export function deepMerge(source, target) {
19
+ if (target === undefined) {
20
+ return source;
21
+ }
22
+
23
+ if (typeof target !== 'object' || target === null) {
24
+ return target;
25
+ }
26
+
27
+ if (Array.isArray(source) && Array.isArray(target)) {
28
+ for (const item of target) {
29
+ const element = deepMerge(undefined, item);
30
+ source.push(element);
31
+ }
32
+ return source;
33
+ }
34
+
35
+ for (const key in target) {
36
+ if (typeof source !== 'object' || source === null) {
37
+ source = {};
38
+ }
39
+
40
+ source[key] = deepMerge(source[key], target[key]);
41
+ }
42
+
43
+ return source;
44
+ }
45
+
46
+ /**
47
+ * An instance of `Random` is used to generate random numbers.
48
+ */
49
+ export class Random {
50
+ static create() {
51
+ return new Random();
52
+ }
53
+
54
+ /** @hideconstructor */
55
+ constructor() {}
56
+
57
+ /**
58
+ * Returns a random boolean value.
59
+ *
60
+ * @param {number} [probabilityOfUndefined=0.0] The probability of returning
61
+ * `undefined`.
62
+ * @return {boolean|undefined} A random boolean between `origin` (inclusive)
63
+ * and `bound` (exclusive) or undefined.
64
+ */
65
+ nextBoolean(probabilityOfUndefined = 0.0) {
66
+ return this.#randomOptional(
67
+ () => Math.random() < 0.5,
68
+ probabilityOfUndefined,
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Returns a random integer between `origin` (inclusive) and `bound`
74
+ * (exclusive).
75
+ *
76
+ * @param {number} [origin=0] The least value that can be returned.
77
+ * @param {number} [bound=1] The upper bound (exclusive) for the returned
78
+ * value.
79
+ * @param {number} [probabilityOfUndefined=0.0] The probability of returning
80
+ * `undefined`.
81
+ * @return {number|undefined} A random integer between `origin` (inclusive)
82
+ * and `bound` (exclusive) or undefined.
83
+ */
84
+ nextInt(origin = 0, bound = 1, probabilityOfUndefined = 0.0) {
85
+ return this.#randomOptional(
86
+ () => Math.floor(this.nextFloat(origin, bound)),
87
+ probabilityOfUndefined,
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Returns a random float between `origin` (inclusive) and `bound`
93
+ * (exclusive).
94
+ *
95
+ * @param {number} [origin=0.0] The least value that can be returned.
96
+ * @param {number} [bound=1.0] The upper bound (exclusive) for the returned
97
+ * value.
98
+ * @param {number} [probabilityOfUndefined=0.0] The probability of returning
99
+ * `undefined`.
100
+ * @return {number|undefined} A random float between `origin` (inclusive) and
101
+ * `bound` (exclusive) or undefined.
102
+ */
103
+ nextFloat(origin = 0.0, bound = 1.0, probabilityOfUndefined = 0.0) {
104
+ return this.#randomOptional(
105
+ () => Math.random() * (bound - origin) + origin,
106
+ probabilityOfUndefined,
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Returns a random timestamp with optional random offset.
112
+ *
113
+ * @param {number} [maxMillis=0] The maximum offset in milliseconds.
114
+ * @param {number} [probabilityOfUndefined=0.0] The probability of returning
115
+ * `undefined`.
116
+ * @return {Date|undefined} A random timestamp or `undefined`.
117
+ */
118
+ nextDate(maxMillis = 0, probabilityOfUndefined = 0.0) {
119
+ return this.#randomOptional(() => {
120
+ const now = new Date();
121
+ let t = now.getTime();
122
+ const r = Math.random();
123
+ t += r * maxMillis;
124
+ return new Date(t);
125
+ }, probabilityOfUndefined);
126
+ }
127
+
128
+ /**
129
+ * Returns a random value from an array.
130
+ *
131
+ * @param {Array} [values=[]] The array of values.
132
+ * @param {number} [probabilityOfUndefined=0.0] The probability of returning
133
+ * `undefined`.
134
+ * @return {*|undefined} A random value from the array or `undefined`.
135
+ */
136
+ nextValue(values = [], probabilityOfUndefined = 0.0) {
137
+ return this.#randomOptional(() => {
138
+ const index = new Random().nextInt(0, values.length - 1);
139
+ return values[index];
140
+ }, probabilityOfUndefined);
141
+ }
142
+
143
+ #randomOptional(randomFactory, probabilityOfUndefined) {
144
+ const r = Math.random();
145
+ return r < probabilityOfUndefined ? undefined : randomFactory();
146
+ }
147
+ }
148
+
149
+ const TASK_CREATED = 'created';
150
+ const TASK_SCHEDULED = 'scheduled';
151
+ const TASK_EXECUTED = 'executed';
152
+ const TASK_CANCELLED = 'cancelled';
153
+
154
+ /**
155
+ * A task that can be scheduled by a {@link Timer}.
156
+ */
157
+ export class TimerTask {
158
+ _state = TASK_CREATED;
159
+ _nextExecutionTime = 0;
160
+ _period = 0;
161
+
162
+ /**
163
+ * Runs the task.
164
+ *
165
+ * @abstract
166
+ */
167
+ run() {
168
+ throw new Error('Method not implemented.');
169
+ }
170
+
171
+ /**
172
+ * Cancels the task.
173
+ *
174
+ * @return {boolean} `true` if this task was scheduled for one-time execution
175
+ * and has not yet run, or this task was scheduled for repeated execution.
176
+ * Return `false` if the task was scheduled for one-time execution and has
177
+ * already run, or if the task was never scheduled, or if the task was
178
+ * already cancelled.
179
+ */
180
+ cancel() {
181
+ const result = this._state === TASK_SCHEDULED;
182
+ this._state = TASK_CANCELLED;
183
+ return result;
184
+ }
185
+
186
+ /**
187
+ * Returns scheduled execution time of the most recent actual execution of
188
+ * this task.
189
+ *
190
+ * Example usage:
191
+ *
192
+ * ```javascript
193
+ * run() {
194
+ * if (Date.now() - scheduledExecutionTime() >= MAX_TARDINESS) {
195
+ * return; // Too late; skip this execution.
196
+ * }
197
+ * // Perform the task
198
+ * }
199
+ *
200
+ * ```
201
+ *
202
+ * @return {number} The time in milliseconds since the epoch, undefined if
203
+ * the task has not yet run for the first time.
204
+ */
205
+ scheduledExecutionTime() {
206
+ return this._period < 0
207
+ ? this._nextExecutionTime + this._period
208
+ : this._nextExecutionTime - this._period;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * A timer that schedules and cancels tasks.
214
+ *
215
+ * Tasks may be scheduled for one-time execution or for repeated execution at
216
+ * regular intervals.
217
+ */
218
+ export class Timer extends EventTarget {
219
+ /**
220
+ * Returns a new `Timer`.
221
+ */
222
+ static create() {
223
+ return new Timer(Clock.system(), globalThis);
224
+ }
225
+
226
+ /**
227
+ * Returns a new `Timer` for testing without side effects.
228
+ */
229
+ static createNull({ clock = Clock.fixed() } = {}) {
230
+ return new Timer(clock, new TimeoutStub(clock));
231
+ }
232
+
233
+ #clock;
234
+ #global;
235
+ _queue;
236
+
237
+ /**
238
+ * Returns a new `Timer`.
239
+ */
240
+ constructor(
241
+ /** @type {Clock} */ clock = Clock.system(),
242
+ /** @type {globalThis} */ global = globalThis,
243
+ ) {
244
+ super();
245
+ this.#clock = clock;
246
+ this.#global = global;
247
+ this._queue = [];
248
+ }
249
+
250
+ /**
251
+ * Schedules a task for repeated execution at regular intervals.
252
+ *
253
+ * @param {TimerTask} task The task to execute.
254
+ * @param {number|Date} delayOrTime The delay before the first execution, in
255
+ * milliseconds or the time of the first execution.
256
+ * @param {number} [period=0] The interval between executions, in
257
+ * milliseconds; 0 means single execution.
258
+ */
259
+ schedule(task, delayOrTime, period = 0) {
260
+ this.#doSchedule(task, delayOrTime, -period);
261
+ }
262
+
263
+ /**
264
+ * Schedule a task for repeated fixed-rate execution.
265
+ *
266
+ * @param {TimerTask} task The task to execute.
267
+ * @param {number|Date} delayOrTime The delay before the first execution, in
268
+ * milliseconds or the time of the first.
269
+ * @param {number} period The interval between executions, in milliseconds.
270
+ */
271
+ scheduleAtFixedRate(task, delayOrTime, period) {
272
+ this.#doSchedule(task, delayOrTime, period);
273
+ }
274
+
275
+ /**
276
+ * Cancels all scheduled tasks.
277
+ */
278
+ cancel() {
279
+ for (const task of this._queue) {
280
+ task.cancel();
281
+ }
282
+ this._queue = [];
283
+ }
284
+
285
+ /**
286
+ * Removes all cancelled tasks from the task queue.
287
+ *
288
+ * @return {number} The number of tasks removed from the task queue.
289
+ */
290
+ purge() {
291
+ let result = 0;
292
+ for (let i = 0; i < this._queue.length; i++) {
293
+ if (this._queue[i]._state === TASK_CANCELLED) {
294
+ this._queue.splice(i, 1);
295
+ i--;
296
+ result++;
297
+ }
298
+ }
299
+ return result;
300
+ }
301
+
302
+ /**
303
+ * Simulates the execution of a task.
304
+ *
305
+ * @param {object} options The simulation options.
306
+ * @param {number} [options.ticks=1000] The number of milliseconds to advance
307
+ * the clock.
308
+ */
309
+ simulateTaskExecution({ ticks = 1000 } = {}) {
310
+ this.#clock.add(ticks);
311
+ this.#runMainLoop();
312
+ }
313
+
314
+ #doSchedule(task, delayOrTime, period) {
315
+ if (delayOrTime instanceof Date) {
316
+ task._nextExecutionTime = delayOrTime.getTime();
317
+ } else {
318
+ task._nextExecutionTime = this.#clock.millis() + delayOrTime;
319
+ }
320
+ task._period = period;
321
+ task._state = TASK_SCHEDULED;
322
+ this._queue.push(task);
323
+ this._queue.sort((a, b) => b._nextExecutionTime - a._nextExecutionTime);
324
+ if (this._queue[0] === task) {
325
+ this.#runMainLoop();
326
+ }
327
+ }
328
+
329
+ #runMainLoop() {
330
+ if (this._queue.length === 0) {
331
+ return;
332
+ }
333
+
334
+ /** @type {TimerTask} */ const task = this._queue[0];
335
+ if (task._state === TASK_CANCELLED) {
336
+ this._queue.shift();
337
+ return this.#runMainLoop();
338
+ }
339
+
340
+ const now = this.#clock.millis();
341
+ const executionTime = task._nextExecutionTime;
342
+ const taskFired = executionTime <= now;
343
+ if (taskFired) {
344
+ if (task._period === 0) {
345
+ this._queue.shift();
346
+ task._state = TASK_EXECUTED;
347
+ } else {
348
+ task._nextExecutionTime = task._period < 0
349
+ ? now - task._period
350
+ : executionTime + task._period;
351
+ }
352
+ task.run();
353
+ } else {
354
+ this.#global.setTimeout(() => this.#runMainLoop(), executionTime - now);
355
+ }
356
+ }
357
+ }
358
+
359
+ class TimeoutStub {
360
+ setTimeout() {}
361
+ }
@@ -0,0 +1,299 @@
1
+ // Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ // TODO Use JSON schema to validate like Java Bean Validation?
4
+
5
+ export class ValidationError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = 'ValidationError';
9
+ }
10
+ }
11
+
12
+ /** @return {never} */
13
+ export function ensureUnreachable(message = 'Unreachable code executed.') {
14
+ throw new Error(message);
15
+ }
16
+
17
+ export function ensureThat(
18
+ value,
19
+ predicate,
20
+ message = 'Expected predicate is not true.',
21
+ ) {
22
+ const condition = predicate(value);
23
+ if (!condition) {
24
+ throw new ValidationError(message);
25
+ }
26
+
27
+ return value;
28
+ }
29
+
30
+ export function ensureAnything(value, { name = 'value' } = {}) {
31
+ if (value == null) {
32
+ throw new ValidationError(`The ${name} is required, but it was ${value}.`);
33
+ }
34
+
35
+ return value;
36
+ }
37
+
38
+ export function ensureNonEmpty(value, { name = 'value' } = {}) {
39
+ const valueType = getType(value);
40
+ if (
41
+ (valueType === String && value.length === 0) ||
42
+ (valueType === Array && value.length === 0) ||
43
+ (valueType === Object && Object.keys(value).length === 0)
44
+ ) {
45
+ throw new ValidationError(
46
+ `The ${name} must not be empty, but it was ${JSON.stringify(value)}.`,
47
+ );
48
+ }
49
+
50
+ return value;
51
+ }
52
+
53
+ /*
54
+ * type: undefined | null | Boolean | Number | BigInt | String | Symbol | Function | Object | Array | Enum | constructor | Record<string, type>
55
+ * expectedType: type | [ type ]
56
+ */
57
+
58
+ export function ensureType(value, expectedType, { name = 'value' } = {}) {
59
+ const result = checkType(value, expectedType, { name });
60
+ if (result.error) {
61
+ throw new ValidationError(result.error);
62
+ }
63
+ return result.value;
64
+ }
65
+
66
+ export function ensureItemType(array, expectedType, { name = 'value' } = {}) {
67
+ const result = checkType(array, Array, { name });
68
+ if (result.error) {
69
+ throw new ValidationError(result.error);
70
+ }
71
+
72
+ array.forEach((item, index) => {
73
+ const result = checkType(item, expectedType, {
74
+ name: `${name}.${index}`,
75
+ });
76
+ if (result.error) {
77
+ throw new ValidationError(result.error);
78
+ }
79
+ array[index] = result.value;
80
+ });
81
+ return array;
82
+ }
83
+
84
+ export function ensureArguments(args, expectedTypes = [], names = []) {
85
+ ensureThat(
86
+ expectedTypes,
87
+ Array.isArray,
88
+ 'The expectedTypes must be an array.',
89
+ );
90
+ ensureThat(names, Array.isArray, 'The names must be an array.');
91
+ if (args.length > expectedTypes.length) {
92
+ throw new ValidationError(
93
+ `Too many arguments: expected ${expectedTypes.length}, but got ${args.length}.`,
94
+ );
95
+ }
96
+ expectedTypes.forEach((expectedType, index) => {
97
+ const name = names[index] ? names[index] : `argument #${index + 1}`;
98
+ ensureType(args[index], expectedType, { name });
99
+ });
100
+ }
101
+
102
+ /** @return {{value: ?*, error: ?string}}} */
103
+ function checkType(value, expectedType, { name = 'value' } = {}) {
104
+ const valueType = getType(value);
105
+
106
+ // Check built-in types
107
+ if (
108
+ expectedType === undefined ||
109
+ expectedType === null ||
110
+ expectedType === Boolean ||
111
+ expectedType === Number ||
112
+ expectedType === BigInt ||
113
+ expectedType === String ||
114
+ expectedType === Symbol ||
115
+ expectedType === Function ||
116
+ expectedType === Object ||
117
+ expectedType === Array
118
+ ) {
119
+ if (valueType === expectedType) {
120
+ return { value };
121
+ }
122
+
123
+ return {
124
+ error: `The ${name} must be ${
125
+ describe(expectedType, {
126
+ articles: true,
127
+ })
128
+ }, but it was ${describe(valueType, { articles: true })}.`,
129
+ };
130
+ }
131
+
132
+ // Check enum types
133
+ if (Object.getPrototypeOf(expectedType).name === 'Enum') {
134
+ try {
135
+ return { value: expectedType.valueOf(String(value).toUpperCase()) };
136
+ } catch {
137
+ return {
138
+ error: `The ${name} must be ${
139
+ describe(expectedType, {
140
+ articles: true,
141
+ })
142
+ }, but it was ${describe(valueType, { articles: true })}.`,
143
+ };
144
+ }
145
+ }
146
+
147
+ // Check constructor types
148
+ if (typeof expectedType === 'function') {
149
+ if (value instanceof expectedType) {
150
+ return { value };
151
+ } else {
152
+ const convertedValue = new expectedType(value);
153
+ if (String(convertedValue).toLowerCase().startsWith('invalid')) {
154
+ let error = `The ${name} must be a valid ${
155
+ describe(
156
+ expectedType,
157
+ )
158
+ }, but it was ${describe(valueType, { articles: true })}`;
159
+ if (valueType != null) {
160
+ error += `: ${JSON.stringify(value, { articles: true })}`;
161
+ }
162
+ error += '.';
163
+ return { error };
164
+ }
165
+
166
+ return { value: convertedValue };
167
+ }
168
+ }
169
+
170
+ // Check one of multiple types
171
+ if (Array.isArray(expectedType)) {
172
+ for (const type of expectedType) {
173
+ const result = checkType(value, type, { name });
174
+ if (!result.error) {
175
+ return { value };
176
+ }
177
+ }
178
+
179
+ return {
180
+ error: `The ${name} must be ${
181
+ describe(expectedType, {
182
+ articles: true,
183
+ })
184
+ }, but it was ${describe(valueType, { articles: true })}.`,
185
+ };
186
+ }
187
+
188
+ if (typeof expectedType === 'object') {
189
+ // Check struct types
190
+ const result = checkType(value, Object, { name });
191
+ if (result.error) {
192
+ return result;
193
+ }
194
+
195
+ for (const key in expectedType) {
196
+ const result = checkType(value[key], expectedType[key], {
197
+ name: `${name}.${key}`,
198
+ });
199
+ if (result.error) {
200
+ return result;
201
+ }
202
+ value[key] = result.value;
203
+ }
204
+
205
+ return { value };
206
+ }
207
+
208
+ ensureUnreachable();
209
+ }
210
+
211
+ function getType(value) {
212
+ if (value === null) {
213
+ return null;
214
+ }
215
+ if (Array.isArray(value)) {
216
+ return Array;
217
+ }
218
+ if (Number.isNaN(value)) {
219
+ return NaN;
220
+ }
221
+
222
+ switch (typeof value) {
223
+ case 'undefined':
224
+ return undefined;
225
+ case 'boolean':
226
+ return Boolean;
227
+ case 'number':
228
+ return Number;
229
+ case 'bigint':
230
+ return BigInt;
231
+ case 'string':
232
+ return String;
233
+ case 'symbol':
234
+ return Symbol;
235
+ case 'function':
236
+ return Function;
237
+ case 'object':
238
+ return Object;
239
+ default:
240
+ ensureUnreachable(`Unknown typeof value: ${typeof value}.`);
241
+ }
242
+ }
243
+
244
+ function describe(type, { articles = false } = {}) {
245
+ if (Array.isArray(type)) {
246
+ const types = type.map((t) => describe(t, { articles }));
247
+ if (types.length <= 2) {
248
+ return types.join(' or ');
249
+ } else {
250
+ const allButLast = types.slice(0, -1);
251
+ const last = types.at(-1);
252
+ return allButLast.join(', ') + ', or ' + last;
253
+ }
254
+ }
255
+
256
+ if (Number.isNaN(type)) {
257
+ return 'NaN';
258
+ }
259
+
260
+ let name;
261
+ switch (type) {
262
+ case null:
263
+ return 'null';
264
+ case undefined:
265
+ return 'undefined';
266
+ case Array:
267
+ name = 'array';
268
+ break;
269
+ case Boolean:
270
+ name = 'boolean';
271
+ break;
272
+ case Number:
273
+ name = 'number';
274
+ break;
275
+ case BigInt:
276
+ name = 'bigint';
277
+ break;
278
+ case String:
279
+ name = 'string';
280
+ break;
281
+ case Symbol:
282
+ name = 'symbol';
283
+ break;
284
+ case Function:
285
+ name = 'function';
286
+ break;
287
+ case Object:
288
+ name = 'object';
289
+ break;
290
+ default:
291
+ name = type.name;
292
+ break;
293
+ }
294
+
295
+ if (articles) {
296
+ name = 'aeiou'.includes(name[0].toLowerCase()) ? `an ${name}` : `a ${name}`;
297
+ }
298
+ return name;
299
+ }