meocord 4.2.0 → 4.2.1
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/CHANGELOG.md +264 -0
- package/THIRD_PARTY_NOTICES.md +14 -13
- package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-XKMlcUZz.cjs} +376 -100
- package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-BZgJyfZS.cjs} +145 -36
- package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
- package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-CgfPQrNl.cjs} +219 -63
- package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-BUAR1WYG.cjs} +10 -5
- package/dist/cjs/common/index.cjs +3 -3
- package/dist/cjs/core/index.cjs +185 -168
- package/dist/cjs/decorator/index.cjs +21 -6
- package/dist/cjs/testing/index.cjs +1594 -286
- package/dist/cli.json +1 -1
- package/dist/esm/bin/generator.js +4 -5
- package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
- package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
- package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
- package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
- package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
- package/dist/esm/bin/helper/service-generator.helper.js +4 -5
- package/dist/esm/bin/meocord.js +84 -25
- package/dist/esm/build/load-config.pre-entry.js +13 -3
- package/dist/esm/build/rsbuild-config.js +16 -12
- package/dist/esm/build/typed-externals.js +36 -0
- package/dist/esm/common/cooldown-store.js +10 -2
- package/dist/esm/common/errors.js +25 -0
- package/dist/esm/common/meocord-text.js +11 -8
- package/dist/esm/common/response/call-order.js +9 -4
- package/dist/esm/common/response/presenter.js +33 -6
- package/dist/esm/common/response/response-state.js +84 -10
- package/dist/esm/common/route.js +11 -4
- package/dist/esm/common/sharded-cooldown-store.js +1 -1
- package/dist/esm/common/translator.js +37 -6
- package/dist/esm/core/command-registration.js +93 -24
- package/dist/esm/core/command-routes.js +2 -2
- package/dist/esm/core/cooldown-runner.js +29 -8
- package/dist/esm/core/fallback.js +20 -11
- package/dist/esm/core/guard-runner.js +16 -3
- package/dist/esm/core/handler-input.js +4 -3
- package/dist/esm/core/lifecycle-hooks.js +134 -47
- package/dist/esm/core/meocord-factory.js +55 -32
- package/dist/esm/core/meocord.app.js +116 -75
- package/dist/esm/core/providers.js +7 -2
- package/dist/esm/core/running-calls.js +43 -0
- package/dist/esm/core/shard-context.js +5 -6
- package/dist/esm/core/shard-manager.js +16 -10
- package/dist/esm/core/startup-roots.js +21 -1
- package/dist/esm/core/theme-resolvers.js +4 -1
- package/dist/esm/core/theme-scope.js +3 -1
- package/dist/esm/core/theme-validation.js +17 -10
- package/dist/esm/decorator/controller.decorator.js +56 -20
- package/dist/esm/decorator/cooldown.decorator.js +17 -0
- package/dist/esm/decorator/guard.decorator.js +3 -2
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +26 -10
- package/dist/esm/testing/cooldown-store-suite.js +67 -5
- package/dist/esm/testing/discord-defaults.js +205 -30
- package/dist/esm/testing/meocord-testing-module.js +134 -28
- package/dist/esm/testing/mock-fn.js +107 -26
- package/dist/esm/testing/mock-interaction.js +1026 -172
- package/dist/esm/testing/snowflake.js +9 -2
- package/dist/esm/testing/strict-mocks.js +37 -17
- package/dist/esm/util/common.util.js +47 -19
- package/dist/esm/util/dev-runner.util.js +8 -8
- package/dist/esm/util/generator-cli.util.js +4 -5
- package/dist/esm/util/interaction.util.js +2 -2
- package/dist/esm/util/meocord-config-loader.util.js +10 -2
- package/dist/esm/util/meocord-config-validation.util.js +3 -5
- package/dist/esm/util/meocord-source-config.util.js +65 -20
- package/dist/esm/util/method-wrapper.util.js +16 -0
- package/dist/esm/util/platform.util.js +10 -10
- package/dist/esm/util/shard-process.util.js +4 -1
- package/dist/esm/util/sharding-mode.util.js +5 -7
- package/dist/esm/util/shared-state.util.js +21 -0
- package/dist/esm/util/tsconfig-paths.util.js +29 -0
- package/dist/esm/util/tsconfig.util.js +10 -10
- package/dist/esm/util/value.util.js +12 -1
- package/dist/types/common/index.d.cts +6 -6
- package/dist/types/common/index.d.ts +6 -6
- package/dist/types/core/index.d.cts +3 -2
- package/dist/types/core/index.d.ts +3 -2
- package/dist/types/decorator/index.d.cts +1 -0
- package/dist/types/decorator/index.d.ts +1 -0
- package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
- package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
- package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
- package/dist/types/testing/index.d.cts +175 -53
- package/dist/types/testing/index.d.ts +175 -53
- package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
- package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
- package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
- package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
- package/package.json +2 -2
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require('reflect-metadata');
|
|
4
4
|
var inversify = require('inversify');
|
|
5
|
-
var providers = require('../_shared/providers-
|
|
5
|
+
var providers = require('../_shared/providers-BZgJyfZS.cjs');
|
|
6
6
|
var discord_js = require('discord.js');
|
|
7
7
|
var enum_index = require('../enum/index.cjs');
|
|
8
|
-
var controller_decorator = require('../_shared/controller.decorator-
|
|
9
|
-
var startupRoots = require('../_shared/startup-roots-
|
|
8
|
+
var controller_decorator = require('../_shared/controller.decorator-XKMlcUZz.cjs');
|
|
9
|
+
var startupRoots = require('../_shared/startup-roots-CgfPQrNl.cjs');
|
|
10
10
|
var node_crypto = require('node:crypto');
|
|
11
11
|
require('node:async_hooks');
|
|
12
12
|
require('crypto');
|
|
@@ -21,12 +21,10 @@ require('dayjs/plugin/utc.js');
|
|
|
21
21
|
require('dayjs/plugin/timezone.js');
|
|
22
22
|
require('module');
|
|
23
23
|
require('chalk');
|
|
24
|
-
require('../_shared/translate-error-
|
|
24
|
+
require('../_shared/translate-error-BUAR1WYG.cjs');
|
|
25
25
|
|
|
26
26
|
// Framework-agnostic mock function, so `meocord/testing` depends on neither jest nor vitest.
|
|
27
27
|
// Both detect it through `_isMockFunction` and read `fn.mock.calls` in their assertions.
|
|
28
|
-
// Each call signature of `T`, up to four, as a union: `Parameters` and `ReturnType` read only an overloaded function's
|
|
29
|
-
// last signature, such as the list form of a manager's `fetch`
|
|
30
28
|
/**
|
|
31
29
|
* Tells whether a value is a mock function: one from `meocord/testing`, `jest.fn()` or `vi.fn()`.
|
|
32
30
|
*
|
|
@@ -49,13 +47,20 @@ require('../_shared/translate-error-DhDVbT5M.cjs');
|
|
|
49
47
|
*/ function isMockFunction(fn) {
|
|
50
48
|
return typeof fn === 'function' && '_isMockFunction' in fn && fn._isMockFunction === true;
|
|
51
49
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
/**
|
|
51
|
+
* What `useMockFn` set and the mocks made, kept for both builds of this meocord version, so a setup file in one module
|
|
52
|
+
* format sets them for specs in the other:
|
|
53
|
+
* - `invocationOrder`: when each call of meocord's own mocks came, across all of them, as Vitest numbers its own;
|
|
54
|
+
* - `created`: every mock createMockFn made, held weakly so the mocks of a finished test can still be collected;
|
|
55
|
+
* - `runnerFactory` and `restoreImpl`: the runner's factory useMockFn registered, and for each of its mocks how to
|
|
56
|
+
* give back the implementation it was created with, through the runner's own setter, which a mock's behaviour may wrap.
|
|
57
|
+
*/ const mocks$1 = controller_decorator.shared('mocks', ()=>({
|
|
58
|
+
invocationOrder: 0,
|
|
59
|
+
created: new Set(),
|
|
60
|
+
runnerFactory: undefined,
|
|
61
|
+
restoreImpl: new WeakMap()
|
|
62
|
+
}));
|
|
63
|
+
const collected = new FinalizationRegistry((ref)=>mocks$1.created.delete(ref));
|
|
59
64
|
/**
|
|
60
65
|
* Makes every mock `meocord/testing` creates with the test runner's own mock function, such as Vitest's `vi.fn`.
|
|
61
66
|
*
|
|
@@ -86,26 +91,36 @@ const restoreImpl = new WeakMap();
|
|
|
86
91
|
* @category Mocks
|
|
87
92
|
* @see {@link createMockFn}
|
|
88
93
|
*/ function useMockFn(factory) {
|
|
89
|
-
if (factory === runnerFactory) return;
|
|
94
|
+
if (factory === mocks$1.runnerFactory) return;
|
|
90
95
|
if ([
|
|
91
|
-
...created
|
|
96
|
+
...mocks$1.created
|
|
92
97
|
].some((ref)=>ref.deref() !== undefined)) {
|
|
93
98
|
throw new Error("useMockFn() goes before any mock is made: a mock made before it keeps meocord's own function, and the two kinds " + "would mix. Call it once, in the test runner's setup file.");
|
|
94
99
|
}
|
|
95
|
-
const
|
|
100
|
+
const refusal = (cause)=>new Error("useMockFn() takes a function that makes mocks with jest's and Vitest's API, such as vi.fn, jest.fn or bun's " + "mock. node:test's mock.fn records calls in its own shape, so under node:test leave meocord's own mock function " + 'in place.', cause === undefined ? undefined : {
|
|
101
|
+
cause
|
|
102
|
+
});
|
|
103
|
+
let probe;
|
|
104
|
+
try {
|
|
105
|
+
probe = factory();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// node:test's mock.fn, given on its own, throws for want of its tracker
|
|
108
|
+
throw refusal(error);
|
|
109
|
+
}
|
|
96
110
|
const api = [
|
|
97
111
|
'mockImplementation',
|
|
98
112
|
'mockClear',
|
|
99
113
|
'mockReset'
|
|
100
114
|
];
|
|
101
115
|
if (!isMockFunction(probe) || !Array.isArray(probe.mock?.calls) || api.some((name)=>typeof probe[name] !== 'function')) {
|
|
102
|
-
throw
|
|
116
|
+
throw refusal();
|
|
103
117
|
}
|
|
104
|
-
runnerFactory = factory;
|
|
118
|
+
mocks$1.runnerFactory = factory;
|
|
105
119
|
}
|
|
120
|
+
/** Whether mocks are made with a runner's function, which {@link useMockFn} registered. */ const usesRunnerMockFn = ()=>mocks$1.runnerFactory !== undefined;
|
|
106
121
|
/** Tracks a mock for clearAllMocks and resetAllMocks. */ function track(mockFn) {
|
|
107
122
|
const ref = new WeakRef(mockFn);
|
|
108
|
-
created.add(ref);
|
|
123
|
+
mocks$1.created.add(ref);
|
|
109
124
|
collected.register(mockFn, ref);
|
|
110
125
|
return mockFn;
|
|
111
126
|
}
|
|
@@ -128,7 +143,7 @@ const restoreImpl = new WeakMap();
|
|
|
128
143
|
* @category Mocks
|
|
129
144
|
* @see {@link resetAllMocks}
|
|
130
145
|
*/ function clearAllMocks() {
|
|
131
|
-
for (const ref of created)ref.deref()?.mockClear();
|
|
146
|
+
for (const ref of mocks$1.created)ref.deref()?.mockClear();
|
|
132
147
|
}
|
|
133
148
|
/**
|
|
134
149
|
* Clears every mock from `meocord/testing`, and puts each back to the implementation it was created with.
|
|
@@ -151,12 +166,12 @@ const restoreImpl = new WeakMap();
|
|
|
151
166
|
* @category Mocks
|
|
152
167
|
* @see {@link clearAllMocks}
|
|
153
168
|
*/ function resetAllMocks() {
|
|
154
|
-
for (const ref of created){
|
|
169
|
+
for (const ref of mocks$1.created){
|
|
155
170
|
const mockFn = ref.deref();
|
|
156
171
|
if (!mockFn) continue;
|
|
157
172
|
mockFn.mockReset();
|
|
158
173
|
// A runner whose reset drops the implementation the mock was created with, as jest's and bun's do, gets it back
|
|
159
|
-
restoreImpl.get(mockFn)?.();
|
|
174
|
+
mocks$1.restoreImpl.get(mockFn)?.();
|
|
160
175
|
}
|
|
161
176
|
}
|
|
162
177
|
/**
|
|
@@ -170,6 +185,14 @@ const restoreImpl = new WeakMap();
|
|
|
170
185
|
* `mockReturnValue`, `mockResolvedValue`, `mockRejectedValue` and `mockImplementation` share one implementation, so
|
|
171
186
|
* the last one set wins. The `*Once` variants queue, and run first, in the order they were set.
|
|
172
187
|
*
|
|
188
|
+
* It records what Vitest's `toHaveResolved` and `toHaveBeenCalledBefore` read as well. Its call order is counted
|
|
189
|
+
* across meocord's own mocks, apart from the runner's, so compare the order of two of meocord's mocks, or of two of the
|
|
190
|
+
* runner's, not one of each.
|
|
191
|
+
*
|
|
192
|
+
* A promise the implementation returns is handed back as one that settles the same way, not as the same object: test
|
|
193
|
+
* it with `toEqual`, `toHaveReturnedWith` or by awaiting it, not with `toBe`. A property set on the given promise, such
|
|
194
|
+
* as a `cancel` method, isn't carried over.
|
|
195
|
+
*
|
|
173
196
|
* @param impl - The implementation to run until another is set.
|
|
174
197
|
*
|
|
175
198
|
* @example
|
|
@@ -187,11 +210,12 @@ const restoreImpl = new WeakMap();
|
|
|
187
210
|
* @category Mocks
|
|
188
211
|
* @see {@link MockInstance}
|
|
189
212
|
*/ function createMockFn(impl) {
|
|
213
|
+
const { runnerFactory } = mocks$1;
|
|
190
214
|
if (runnerFactory) {
|
|
191
215
|
const runnerMock = runnerFactory(impl);
|
|
192
216
|
if (impl) {
|
|
193
217
|
const setImpl = runnerMock.mockImplementation.bind(runnerMock);
|
|
194
|
-
restoreImpl.set(runnerMock, ()=>setImpl(impl));
|
|
218
|
+
mocks$1.restoreImpl.set(runnerMock, ()=>setImpl(impl));
|
|
195
219
|
}
|
|
196
220
|
return track(runnerMock);
|
|
197
221
|
}
|
|
@@ -204,9 +228,20 @@ const restoreImpl = new WeakMap();
|
|
|
204
228
|
const calls = [];
|
|
205
229
|
const results = [];
|
|
206
230
|
const instances = [];
|
|
231
|
+
const settledResults = [];
|
|
232
|
+
const invocationCallOrder = [];
|
|
233
|
+
const contexts = [];
|
|
207
234
|
const mockFn = function(...args) {
|
|
208
235
|
calls.push(args);
|
|
209
236
|
instances.push(this);
|
|
237
|
+
contexts.push(this);
|
|
238
|
+
invocationCallOrder.push(++mocks$1.invocationOrder);
|
|
239
|
+
// Updated in place as the call settles, so a promise's outcome shows once it resolves or rejects
|
|
240
|
+
const settled = {
|
|
241
|
+
type: 'incomplete',
|
|
242
|
+
value: undefined
|
|
243
|
+
};
|
|
244
|
+
settledResults.push(settled);
|
|
210
245
|
let type = 'return';
|
|
211
246
|
let value;
|
|
212
247
|
try {
|
|
@@ -217,10 +252,36 @@ const restoreImpl = new WeakMap();
|
|
|
217
252
|
} else {
|
|
218
253
|
value = undefined;
|
|
219
254
|
}
|
|
255
|
+
if (value instanceof Promise) {
|
|
256
|
+
// Returned in its place, so a rejection nobody awaits stays unhandled in the test, as it is on a bot: a handler
|
|
257
|
+
// on the implementation's own promise would mark it handled
|
|
258
|
+
value = value.then((resolved)=>{
|
|
259
|
+
Object.assign(settled, {
|
|
260
|
+
type: 'fulfilled',
|
|
261
|
+
value: resolved
|
|
262
|
+
});
|
|
263
|
+
return resolved;
|
|
264
|
+
}, (rejected)=>{
|
|
265
|
+
Object.assign(settled, {
|
|
266
|
+
type: 'rejected',
|
|
267
|
+
value: rejected
|
|
268
|
+
});
|
|
269
|
+
throw rejected;
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
Object.assign(settled, {
|
|
273
|
+
type: 'fulfilled',
|
|
274
|
+
value
|
|
275
|
+
});
|
|
276
|
+
}
|
|
220
277
|
return value;
|
|
221
278
|
} catch (err) {
|
|
222
279
|
type = 'throw';
|
|
223
280
|
value = err;
|
|
281
|
+
Object.assign(settled, {
|
|
282
|
+
type: 'rejected',
|
|
283
|
+
value: err
|
|
284
|
+
});
|
|
224
285
|
throw err;
|
|
225
286
|
} finally{
|
|
226
287
|
results.push({
|
|
@@ -248,6 +309,18 @@ const restoreImpl = new WeakMap();
|
|
|
248
309
|
return calls.length > 0 ? calls[calls.length - 1] : undefined;
|
|
249
310
|
}
|
|
250
311
|
};
|
|
312
|
+
// Out of the mock's keys, so a test comparing `mock` as a whole sees the shape it always had
|
|
313
|
+
Object.defineProperties(mock, {
|
|
314
|
+
settledResults: {
|
|
315
|
+
get: ()=>settledResults
|
|
316
|
+
},
|
|
317
|
+
invocationCallOrder: {
|
|
318
|
+
get: ()=>invocationCallOrder
|
|
319
|
+
},
|
|
320
|
+
contexts: {
|
|
321
|
+
get: ()=>contexts
|
|
322
|
+
}
|
|
323
|
+
});
|
|
251
324
|
Object.defineProperty(mockFn, 'mock', {
|
|
252
325
|
value: mock,
|
|
253
326
|
enumerable: false
|
|
@@ -284,16 +357,22 @@ const restoreImpl = new WeakMap();
|
|
|
284
357
|
onceQueue.push(fn);
|
|
285
358
|
return mockFn;
|
|
286
359
|
};
|
|
360
|
+
const forgetCalls = ()=>{
|
|
361
|
+
for (const record of [
|
|
362
|
+
calls,
|
|
363
|
+
results,
|
|
364
|
+
instances,
|
|
365
|
+
settledResults,
|
|
366
|
+
invocationCallOrder,
|
|
367
|
+
contexts
|
|
368
|
+
])record.length = 0;
|
|
369
|
+
};
|
|
287
370
|
mockFn.mockClear = ()=>{
|
|
288
|
-
|
|
289
|
-
results.length = 0;
|
|
290
|
-
instances.length = 0;
|
|
371
|
+
forgetCalls();
|
|
291
372
|
return mockFn;
|
|
292
373
|
};
|
|
293
374
|
mockFn.mockReset = ()=>{
|
|
294
|
-
|
|
295
|
-
results.length = 0;
|
|
296
|
-
instances.length = 0;
|
|
375
|
+
forgetCalls();
|
|
297
376
|
onceQueue = [];
|
|
298
377
|
currentImpl = impl;
|
|
299
378
|
return mockFn;
|
|
@@ -407,29 +486,59 @@ const VISIBLE = new Set([
|
|
|
407
486
|
}, code, 400, 'POST', 'https://discord.com/api/v10/interactions', {});
|
|
408
487
|
}
|
|
409
488
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
489
|
+
/**
|
|
490
|
+
* The id of the bot every mock client is logged in as, so a test can mention it. Fixed, and below the
|
|
491
|
+
* ids other mocks take, so it is the same on every run and in any test order, and no mock shares it.
|
|
492
|
+
*/ const MOCK_BOT_ID = '1300000000000000000';
|
|
493
|
+
/**
|
|
494
|
+
* The last id a mock was given, for both builds of this meocord version; ids count up from a real snowflake, so each
|
|
495
|
+
* mock's is its own.
|
|
496
|
+
*/ const ids = controller_decorator.shared('mockIds', ()=>({
|
|
497
|
+
last: 1400000000000000000n
|
|
498
|
+
}));
|
|
499
|
+
/** A snowflake-shaped id nothing else a mock holds in this run has, for a mock or the media it resolves. */ const nextSnowflake = ()=>String(++ids.last);
|
|
500
|
+
|
|
501
|
+
// Whether useStrictMocks() is on, and whether a mock was made before it could be, for both builds of this version
|
|
502
|
+
const settings = controller_decorator.shared('strictMocks', ()=>({
|
|
503
|
+
strict: false,
|
|
504
|
+
mockMade: false
|
|
505
|
+
}));
|
|
506
|
+
/** Whether mocks compute discord.js's state, as {@link useStrictMocks} turns on. */ const strictMocks = ()=>settings.strict;
|
|
414
507
|
/** Notes that a mock was made, which useStrictMocks() must come before. */ function noteMockMade() {
|
|
415
|
-
mockMade = true;
|
|
508
|
+
settings.mockMade = true;
|
|
416
509
|
}
|
|
417
510
|
/**
|
|
418
511
|
* Has every mock from `meocord/testing` compute the values discord.js computes, where it reads a placeholder otherwise.
|
|
419
512
|
*
|
|
420
513
|
* Call it once in the test runner's setup file. A message's `editable`, `deletable`, `pinnable`, `crosspostable`,
|
|
421
514
|
* `bulkDeletable`, `hasThread` and `partial`, a member's `manageable`, `kickable`, `bannable` and `moderatable`, a
|
|
422
|
-
* role's `editable`, a channel's and a thread's `viewable`, `manageable`, `deletable`, `joinable` and the rest,
|
|
423
|
-
* `partial` on users, channels and reactions then run discord.js's own getters against the mock,
|
|
424
|
-
* is `null` when the channel caches no thread under the message's id.
|
|
515
|
+
* role's `editable`, a channel's and a thread's `viewable`, `manageable`, `deletable`, `joinable` and the rest, a voice
|
|
516
|
+
* channel's `full`, and `partial` on users, channels and reactions then run discord.js's own getters against the mock,
|
|
517
|
+
* and `message.thread` is `null` when the channel caches no thread under the message's id. So do a message's
|
|
518
|
+
* `editedAt`, a member's `presence`, a server's `verified` and `systemChannel` and a channel's `parent`, each `null` or
|
|
519
|
+
* `false` until the test gives what it is computed from. A reaction's `me` is `false`, a message's
|
|
520
|
+
* `mentions.repliedUser` and a modal's `message` are `null`, and a reaction has a whole message of its own, so
|
|
521
|
+
* MeoCord's dispatcher fetches neither. No placeholder warning is logged.
|
|
425
522
|
*
|
|
426
523
|
* @remarks
|
|
427
|
-
* Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
* a
|
|
432
|
-
*
|
|
524
|
+
* Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in the
|
|
525
|
+
* server's member cache from the start, @everyone has the permissions Discord gives it in a new server, and a channel
|
|
526
|
+
* or thread made without a server has one of its own, a thread with a text channel as its parent. A message has empty
|
|
527
|
+
* `content`, a thread is neither archived nor locked, and a reaction's `count` is 1. A user, server or channel made
|
|
528
|
+
* with a generated id was created when the mock was made, as a message is. An interaction's `memberPermissions` apply
|
|
529
|
+
* its channel's overwrites, a thread's parent's for a thread, to its member's permissions. An interaction is in its
|
|
530
|
+
* message's channel and server, a guild's or a member's server, or, given a `guildId` alone, a server the bot isn't in.
|
|
531
|
+
* A message the bot sends is its `client.user`'s, and one it sends in a DM is in that DM. Give the bot's member a role
|
|
532
|
+
* to let it do more, such as kick a member its role ranks above. A value the test sets on a mock wins over the computed
|
|
533
|
+
* one. An interaction's answer that discord.js would refuse, such as a second `reply()` after one a test set with
|
|
534
|
+
* `mockResolvedValue`, is refused too, where without it the mock runs it and warns. The next major version (5.0)
|
|
535
|
+
* computes these values, and refuses those answers, without the call.
|
|
536
|
+
*
|
|
537
|
+
* `invoke` also refuses a call that gives a handler fewer arguments than it declares where it builds none, such as a
|
|
538
|
+
* reaction handler given its reaction without its `ReactionEvent`; without strict mocks, it warns once and calls it.
|
|
539
|
+
*
|
|
540
|
+
* `compile()` also refuses an `overrideGuard`, `overrideInterceptor` or `overrideFilter` stub without the stage's
|
|
541
|
+
* method, `canActivate`, `intercept` or `catch`; without strict mocks, it warns and builds the module.
|
|
433
542
|
*
|
|
434
543
|
* @throws Error when a mock already exists, which was made without it.
|
|
435
544
|
*
|
|
@@ -444,16 +553,69 @@ let mockMade = false;
|
|
|
444
553
|
* @see {@link useMockFn}
|
|
445
554
|
* @see {@link https://meocord.dev/docs/4.2/mocks | Mocks}
|
|
446
555
|
*/ function useStrictMocks() {
|
|
447
|
-
if (strict) return;
|
|
448
|
-
if (mockMade) {
|
|
556
|
+
if (settings.strict) return;
|
|
557
|
+
if (settings.mockMade) {
|
|
449
558
|
throw new Error('useStrictMocks() goes before any mock is made: a mock made before it reads placeholders, and the two kinds would ' + "mix. Call it once, in the test runner's setup file.");
|
|
450
559
|
}
|
|
451
|
-
strict = true;
|
|
560
|
+
settings.strict = true;
|
|
452
561
|
}
|
|
453
562
|
|
|
563
|
+
const mockLogger$1 = new controller_decorator.Logger('Mocks');
|
|
564
|
+
/**
|
|
565
|
+
* A member's permissions where an interaction was made, as Discord computes them: under strict mocks, every permission
|
|
566
|
+
* for an Administrator or the owner, else the channel's overwrites on top of `member.permissions`, so a test that sets
|
|
567
|
+
* those keeps the base; otherwise `member.permissions`, with a warning once where strict mocks would read otherwise.
|
|
568
|
+
*/ function inChannel(interaction, member) {
|
|
569
|
+
const base = member.permissions;
|
|
570
|
+
// Only a server the mock caches has the overwrites to apply
|
|
571
|
+
const { guild } = interaction;
|
|
572
|
+
if (!(guild instanceof discord_js.Guild)) return base;
|
|
573
|
+
const computed = base.has(discord_js.PermissionFlagsBits.Administrator) || member.id === guild.ownerId ? new discord_js.PermissionsBitField(discord_js.PermissionsBitField.All).freeze() : withOverwrites(base, overwritingChannel(interaction, guild), member);
|
|
574
|
+
if (strictMocks()) return computed;
|
|
575
|
+
if (!computed.equals(base)) {
|
|
576
|
+
const lost = new discord_js.PermissionsBitField(base).remove(computed).toArray();
|
|
577
|
+
const gained = new discord_js.PermissionsBitField(computed).remove(base).toArray();
|
|
578
|
+
const change = lost.length > 0 ? [
|
|
579
|
+
`without ${lost.join(', ')}`
|
|
580
|
+
] : [];
|
|
581
|
+
if (gained.length > 0) change.push(`with ${gained.join(', ')}`);
|
|
582
|
+
controller_decorator.warnOnce(mockLogger$1, `${interaction.constructor.name}.memberPermissions reads the member's own permissions here, without the ` + "channel's overwrites or every permission an Administrator or the owner has; in the next major version (5.0) " + `it reads them as Discord computes them in the channel, ${change.join(' and ')}. Set memberPermissions on the ` + 'mock, or call useStrictMocks() to compute them now.');
|
|
583
|
+
}
|
|
584
|
+
// Unless the test set them, a member's permissions come from its roles, @everyone's included
|
|
585
|
+
if (!Object.prototype.hasOwnProperty.call(member, 'permissions')) warnOnUnsetEveryone(interaction, guild, 'memberPermissions', base);
|
|
586
|
+
return base;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Warns once, as `key`, where `permissions` read a server's @everyone role default mode made with none, and lack what
|
|
590
|
+
* strict mocks give @everyone in a new server.
|
|
591
|
+
*/ function warnOnUnsetEveryone(interaction, guild, key, permissions) {
|
|
592
|
+
const everyone = guild.roles.everyone;
|
|
593
|
+
if (!everyone || !isUnsetEveryone(everyone) || permissions.has(NEW_SERVER_EVERYONE_PERMISSIONS)) return;
|
|
594
|
+
controller_decorator.warnOnce(mockLogger$1, `${interaction.constructor.name}.${key} reads the server's @everyone role here with no permissions; in the next major ` + 'version (5.0) it has the permissions Discord gives @everyone in a new server, as strict mocks read it. Set ' + 'guild.roles.everyone.permissions, or call useStrictMocks() to read it so now.');
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The channel whose overwrites apply where an interaction was made, read without making or caching one: a thread's
|
|
598
|
+
* parent, or undefined where the channel, or a thread's parent, is one the mock would make, which has none.
|
|
599
|
+
*/ function overwritingChannel(interaction, guild) {
|
|
600
|
+
const channel = heldChannelOf(interaction, guild);
|
|
601
|
+
// A thread's parent by the id the test gave it or one already read, so strict mocks' parent isn't made for this read
|
|
602
|
+
const parentId = (thread)=>Object.prototype.hasOwnProperty.call(thread, 'parentId') ? thread.parentId : madeParentIds.get(thread);
|
|
603
|
+
const at = channel instanceof discord_js.ThreadChannel ? guild.channels.cache.get(parentId(channel) ?? '') : channel;
|
|
604
|
+
return at instanceof discord_js.GuildChannel ? at : undefined;
|
|
605
|
+
}
|
|
606
|
+
// The parent id strict mocks made for each thread made without one, once read
|
|
607
|
+
const madeParentIds = new WeakMap();
|
|
608
|
+
// Private in discord.js's typings, though they are what its own permissionsFor computes with; called on the prototype
|
|
609
|
+
// so a test's stub on the channel stays for its caller
|
|
610
|
+
const channelPermissions = discord_js.GuildChannel.prototype;
|
|
611
|
+
/** `base` with `channel`'s overwrites for `member` applied, in discord.js's order: @everyone's, the roles', the member's. */ function withOverwrites(base, channel, member) {
|
|
612
|
+
if (!channel) return base;
|
|
613
|
+
// Given the member and its roles as verified, as discord.js's memberPermissions does, so it resolves nothing again
|
|
614
|
+
const overwrites = channelPermissions.overwritesFor.call(channel, member, true, member.roles.cache);
|
|
615
|
+
return new discord_js.PermissionsBitField(base).remove(overwrites.everyone?.deny ?? 0n).add(overwrites.everyone?.allow ?? 0n).remove(overwrites.roles.length > 0 ? overwrites.roles.map((role)=>role.deny) : 0n).add(overwrites.roles.length > 0 ? overwrites.roles.map((role)=>role.allow) : 0n).remove(overwrites.member?.deny ?? 0n).add(overwrites.member?.allow ?? 0n).freeze();
|
|
616
|
+
}
|
|
454
617
|
/** A permission set with none, frozen as discord.js freezes the ones it gives. */ const noPermissions = ()=>new discord_js.PermissionsBitField().freeze();
|
|
455
618
|
/** A property discord.js computes from the others: its own getter runs, reading the mock's values. */ const REAL_GETTER = Symbol('real getter');
|
|
456
|
-
const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
457
619
|
/** A class and its defaults, typed together so a default that stops fitting its property fails to compile. */ const defaultsOf = (Class, defaults)=>[
|
|
458
620
|
Class.prototype,
|
|
459
621
|
defaults
|
|
@@ -466,7 +628,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
466
628
|
defaultsOf(discord_js.Guild, {
|
|
467
629
|
name: ()=>'Guild',
|
|
468
630
|
preferredLocale: ()=>discord_js.Locale.EnglishUS,
|
|
469
|
-
ownerId:
|
|
631
|
+
ownerId: nextSnowflake,
|
|
470
632
|
memberCount: ()=>1,
|
|
471
633
|
available: ()=>true,
|
|
472
634
|
large: ()=>false,
|
|
@@ -479,6 +641,9 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
479
641
|
shardId: ()=>0,
|
|
480
642
|
nsfwLevel: ()=>discord_js.GuildNSFWLevel.Default,
|
|
481
643
|
verificationLevel: ()=>discord_js.GuildVerificationLevel.None,
|
|
644
|
+
presences: (guild)=>ownedManager(discord_js.PresenceManager.prototype, {
|
|
645
|
+
guild
|
|
646
|
+
}),
|
|
482
647
|
premiumTier: ()=>discord_js.GuildPremiumTier.None,
|
|
483
648
|
createdTimestamp: REAL_GETTER,
|
|
484
649
|
createdAt: REAL_GETTER
|
|
@@ -493,6 +658,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
493
658
|
discriminator: ()=>'0',
|
|
494
659
|
avatar: ()=>null,
|
|
495
660
|
system: ()=>false,
|
|
661
|
+
flags: ()=>new discord_js.UserFlagsBitField(),
|
|
496
662
|
tag: REAL_GETTER,
|
|
497
663
|
displayName: REAL_GETTER,
|
|
498
664
|
defaultAvatarURL: REAL_GETTER,
|
|
@@ -513,12 +679,12 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
513
679
|
premiumSinceTimestamp: ()=>null,
|
|
514
680
|
communicationDisabledUntilTimestamp: ()=>null,
|
|
515
681
|
pending: ()=>false,
|
|
682
|
+
flags: ()=>new discord_js.GuildMemberFlagsBitField().freeze(),
|
|
516
683
|
displayName: REAL_GETTER,
|
|
517
684
|
roles: (member)=>memberRoles(member, []),
|
|
518
685
|
permissions: REAL_GETTER
|
|
519
686
|
}),
|
|
520
687
|
defaultsOf(discord_js.Role, {
|
|
521
|
-
id: snowflake,
|
|
522
688
|
name: ()=>'role',
|
|
523
689
|
// Above @everyone, which a server's role manager makes at position 0
|
|
524
690
|
position: ()=>1,
|
|
@@ -556,37 +722,71 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
556
722
|
poll: ()=>null,
|
|
557
723
|
interactionMetadata: ()=>null,
|
|
558
724
|
stickers: ()=>new discord_js.Collection(),
|
|
725
|
+
messageSnapshots: ()=>new discord_js.Collection(),
|
|
726
|
+
reactions: (message)=>ownedManager(discord_js.ReactionManager.prototype, {
|
|
727
|
+
message
|
|
728
|
+
}),
|
|
559
729
|
url: REAL_GETTER
|
|
560
730
|
}),
|
|
561
731
|
// A command's name and a component's customId say which handler a call is for, so they stay the test's to give
|
|
732
|
+
defaultsOf(discord_js.ChatInputCommandInteraction, {
|
|
733
|
+
options: ()=>createChatInputOptions()
|
|
734
|
+
}),
|
|
562
735
|
defaultsOf(discord_js.CommandInteraction, {
|
|
563
|
-
commandId:
|
|
736
|
+
commandId: nextSnowflake,
|
|
564
737
|
commandGuildId: ()=>null
|
|
565
738
|
}),
|
|
566
739
|
defaultsOf(discord_js.AutocompleteInteraction, {
|
|
567
|
-
commandId:
|
|
568
|
-
commandGuildId: ()=>null
|
|
740
|
+
commandId: nextSnowflake,
|
|
741
|
+
commandGuildId: ()=>null,
|
|
742
|
+
options: ()=>createChatInputOptions()
|
|
743
|
+
}),
|
|
744
|
+
defaultsOf(discord_js.MessageReaction, {
|
|
745
|
+
users: (reaction)=>ownedManager(discord_js.ReactionUserManager.prototype, {
|
|
746
|
+
reaction
|
|
747
|
+
})
|
|
569
748
|
}),
|
|
570
749
|
defaultsOf(discord_js.BaseInteraction, {
|
|
571
|
-
applicationId:
|
|
750
|
+
applicationId: nextSnowflake,
|
|
572
751
|
token: ()=>'mock-interaction-token',
|
|
573
752
|
version: ()=>1,
|
|
574
753
|
context: ()=>null,
|
|
754
|
+
entitlements: ()=>new discord_js.Collection(),
|
|
575
755
|
// What Discord sends with an interaction in a server, the member's permissions there; typed for a cached one
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
756
|
+
// Each reads where the interaction is as its own, and warns by its own name where strict mocks place it otherwise
|
|
757
|
+
memberPermissions: (interaction)=>readPlaceAs(interaction, 'memberPermissions', ()=>{
|
|
758
|
+
const { guildId } = interaction;
|
|
759
|
+
const member = interaction.member;
|
|
760
|
+
if (!guildId) return null;
|
|
761
|
+
if (member instanceof discord_js.GuildMember) return inChannel(interaction, member);
|
|
762
|
+
// A raw member's permissions are the string Discord sends, read as discord.js reads them
|
|
763
|
+
return isRawMember(member) && !interaction.guild ? new discord_js.PermissionsBitField(BigInt(member.permissions)).freeze() : null;
|
|
764
|
+
}),
|
|
765
|
+
// The bot's permissions where the interaction was made: its member's in the channel, or in a thread its parent, as
|
|
766
|
+
// discord.js computes them; none outside a server
|
|
767
|
+
appPermissions: (interaction)=>readPlaceAs(interaction, 'appPermissions', ()=>{
|
|
768
|
+
const { guild } = interaction;
|
|
769
|
+
const me = guild instanceof discord_js.Guild ? botMemberOf(guild) : null;
|
|
770
|
+
if (!(me instanceof discord_js.GuildMember) || heldChannelOf(interaction, guild) === null) return noPermissions();
|
|
771
|
+
// discord.js's own computation, given its own overwritesFor over a test's stub on the channel; a channel the
|
|
772
|
+
// mock would make has no overwrites
|
|
773
|
+
const at = overwritingChannel(interaction, guild) ?? {
|
|
774
|
+
guild,
|
|
775
|
+
permissionOverwrites: {
|
|
776
|
+
cache: new discord_js.Collection()
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
const channel = Object.create(at, {
|
|
780
|
+
overwritesFor: {
|
|
781
|
+
value: channelPermissions.overwritesFor
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
const permissions = channelPermissions.memberPermissions.call(channel, me, true);
|
|
785
|
+
// The bot's member has @everyone's too
|
|
786
|
+
warnOnUnsetEveryone(interaction, guild, 'appPermissions', permissions);
|
|
787
|
+
return permissions;
|
|
788
|
+
}, // A server the bot isn't in has no guild in either mode, so no permissions in both
|
|
789
|
+
(place)=>!place.raw)
|
|
590
790
|
}),
|
|
591
791
|
defaultsOf(discord_js.TextChannel, {
|
|
592
792
|
type: ()=>discord_js.ChannelType.GuildText,
|
|
@@ -622,7 +822,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
622
822
|
}),
|
|
623
823
|
defaultsOf(discord_js.GuildChannel, {
|
|
624
824
|
name: ()=>'channel',
|
|
625
|
-
guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id :
|
|
825
|
+
guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id : nextSnowflake(),
|
|
626
826
|
position: ()=>0,
|
|
627
827
|
rawPosition: ()=>0,
|
|
628
828
|
parentId: ()=>null
|
|
@@ -646,11 +846,26 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
646
846
|
* thread made without one, a thread's parent text channel in it, and data that is otherwise a placeholder.
|
|
647
847
|
*/ const STRICT_DEFAULTS = new Map([
|
|
648
848
|
defaultsOf(discord_js.Message, {
|
|
649
|
-
content: ()=>''
|
|
849
|
+
content: ()=>'',
|
|
850
|
+
editedTimestamp: ()=>null,
|
|
851
|
+
editedAt: REAL_GETTER
|
|
852
|
+
}),
|
|
853
|
+
defaultsOf(discord_js.MessageMentions, {
|
|
854
|
+
repliedUser: ()=>null
|
|
650
855
|
}),
|
|
651
856
|
defaultsOf(discord_js.Guild, {
|
|
652
857
|
rulesChannelId: ()=>null,
|
|
653
|
-
publicUpdatesChannelId: ()=>null
|
|
858
|
+
publicUpdatesChannelId: ()=>null,
|
|
859
|
+
systemChannel: REAL_GETTER
|
|
860
|
+
}),
|
|
861
|
+
defaultsOf(discord_js.BaseGuild, {
|
|
862
|
+
verified: REAL_GETTER
|
|
863
|
+
}),
|
|
864
|
+
defaultsOf(discord_js.GuildMember, {
|
|
865
|
+
presence: REAL_GETTER
|
|
866
|
+
}),
|
|
867
|
+
defaultsOf(discord_js.ModalSubmitInteraction, {
|
|
868
|
+
message: ()=>null
|
|
654
869
|
}),
|
|
655
870
|
defaultsOf(discord_js.ThreadChannel, {
|
|
656
871
|
guild: ()=>createMockGuild(),
|
|
@@ -660,25 +875,91 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
660
875
|
guild: thread.guild
|
|
661
876
|
});
|
|
662
877
|
thread.guild.channels.cache.set(parent.id, parent);
|
|
878
|
+
madeParentIds.set(thread, parent.id);
|
|
663
879
|
return parent.id;
|
|
664
880
|
},
|
|
665
881
|
parent: REAL_GETTER,
|
|
666
|
-
ownerId:
|
|
882
|
+
ownerId: nextSnowflake,
|
|
667
883
|
archived: ()=>false,
|
|
668
884
|
locked: ()=>false
|
|
669
885
|
}),
|
|
670
886
|
defaultsOf(discord_js.GuildChannel, {
|
|
671
|
-
guild: ()=>createMockGuild()
|
|
887
|
+
guild: ()=>createMockGuild(),
|
|
888
|
+
parent: REAL_GETTER
|
|
672
889
|
}),
|
|
673
890
|
// Full once its members reach a user limit, which a voice channel has none of unless given one
|
|
674
891
|
defaultsOf(discord_js.BaseGuildVoiceChannel, {
|
|
675
892
|
userLimit: ()=>0,
|
|
676
893
|
full: REAL_GETTER
|
|
677
894
|
}),
|
|
895
|
+
// A reaction's message, as discord.js always has one, is a whole one
|
|
678
896
|
defaultsOf(discord_js.MessageReaction, {
|
|
679
|
-
count: ()=>1
|
|
897
|
+
count: ()=>1,
|
|
898
|
+
me: ()=>false,
|
|
899
|
+
message: ()=>createMockMessage()
|
|
680
900
|
})
|
|
681
901
|
]);
|
|
902
|
+
/**
|
|
903
|
+
* What a mock reads as a placeholder unless strict mocks read it as discord.js gives it, by the value a test sets for
|
|
904
|
+
* that, which the warning on reading one names.
|
|
905
|
+
*/ const PLACEHOLDER_VALUES = new Map([
|
|
906
|
+
[
|
|
907
|
+
discord_js.MessageReaction.prototype,
|
|
908
|
+
{
|
|
909
|
+
me: 'false',
|
|
910
|
+
message: 'createMockMessage()'
|
|
911
|
+
}
|
|
912
|
+
],
|
|
913
|
+
[
|
|
914
|
+
discord_js.MessageMentions.prototype,
|
|
915
|
+
{
|
|
916
|
+
repliedUser: 'null'
|
|
917
|
+
}
|
|
918
|
+
],
|
|
919
|
+
[
|
|
920
|
+
discord_js.Message.prototype,
|
|
921
|
+
{
|
|
922
|
+
editedAt: 'null'
|
|
923
|
+
}
|
|
924
|
+
],
|
|
925
|
+
[
|
|
926
|
+
discord_js.GuildMember.prototype,
|
|
927
|
+
{
|
|
928
|
+
presence: 'null'
|
|
929
|
+
}
|
|
930
|
+
],
|
|
931
|
+
[
|
|
932
|
+
discord_js.BaseGuild.prototype,
|
|
933
|
+
{
|
|
934
|
+
verified: 'false'
|
|
935
|
+
}
|
|
936
|
+
],
|
|
937
|
+
[
|
|
938
|
+
discord_js.Guild.prototype,
|
|
939
|
+
{
|
|
940
|
+
systemChannel: 'null'
|
|
941
|
+
}
|
|
942
|
+
],
|
|
943
|
+
[
|
|
944
|
+
discord_js.GuildChannel.prototype,
|
|
945
|
+
{
|
|
946
|
+
parent: 'null'
|
|
947
|
+
}
|
|
948
|
+
],
|
|
949
|
+
[
|
|
950
|
+
discord_js.ModalSubmitInteraction.prototype,
|
|
951
|
+
{
|
|
952
|
+
message: 'null'
|
|
953
|
+
}
|
|
954
|
+
]
|
|
955
|
+
]);
|
|
956
|
+
/** The value a test sets for `key` on a mock of `target`'s class, when a mock reads it as a placeholder. */ function placeholderValue(target, key) {
|
|
957
|
+
for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
958
|
+
const value = PLACEHOLDER_VALUES.get(proto)?.[key];
|
|
959
|
+
if (value !== undefined) return value;
|
|
960
|
+
}
|
|
961
|
+
return undefined;
|
|
962
|
+
}
|
|
682
963
|
/** The default a mock of `target`'s class has for `key`, read from the nearest class that has one, strict ones first. */ function discordDefault(target, key) {
|
|
683
964
|
for (const defaults of strictMocks() ? [
|
|
684
965
|
STRICT_DEFAULTS,
|
|
@@ -694,13 +975,6 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
694
975
|
return undefined;
|
|
695
976
|
}
|
|
696
977
|
|
|
697
|
-
/**
|
|
698
|
-
* The id of the bot every mock client is logged in as, so a test can mention it. Fixed, and below the
|
|
699
|
-
* ids other mocks take, so it is the same on every run and in any test order, and no mock shares it.
|
|
700
|
-
*/ const MOCK_BOT_ID = '1300000000000000000';
|
|
701
|
-
/** The last id a mock was given; ids count up from a real snowflake, so each mock's is its own. */ let lastSnowflake = 1400000000000000000n;
|
|
702
|
-
/** A snowflake-shaped id nothing else a mock holds in this run has, for a mock or the media it resolves. */ const nextSnowflake = ()=>String(++lastSnowflake);
|
|
703
|
-
|
|
704
978
|
/** Where a mock resolves unfurled media to, as Discord's media proxy would. */ const PROXY = 'https://images-ext-1.discordapp.net/external/mock';
|
|
705
979
|
/** Unfurled media as Discord stores it: the url given, resolved to a proxied image of a fixed size. */ function resolvedMedia(media) {
|
|
706
980
|
return {
|
|
@@ -830,9 +1104,15 @@ const SKIP = new Set([
|
|
|
830
1104
|
'toJSON',
|
|
831
1105
|
'then'
|
|
832
1106
|
]);
|
|
1107
|
+
// Each mock's own instance, which the proxy's traps receive as their target
|
|
1108
|
+
const mockTargets = new WeakMap();
|
|
1109
|
+
// Warnings a read answered by the stub logs first, by the mock's instance and the property
|
|
1110
|
+
const stubReadWarnings = new WeakMap();
|
|
833
1111
|
function stubDeep(instance, externalStubs, onSet) {
|
|
834
1112
|
noteMockMade();
|
|
835
1113
|
const stubs = externalStubs ?? new Map();
|
|
1114
|
+
// Placeholders read so far, by the value strict mocks give: a read of a kept one still warns, once
|
|
1115
|
+
const placeholders = new Map();
|
|
836
1116
|
const proxy = new Proxy(instance, {
|
|
837
1117
|
get (target, prop) {
|
|
838
1118
|
// Always pass through symbols
|
|
@@ -846,12 +1126,17 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
846
1126
|
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
|
847
1127
|
return Reflect.get(target, prop, target);
|
|
848
1128
|
}
|
|
1129
|
+
stubReadWarnings.get(target)?.get(key)?.();
|
|
849
1130
|
// Skip passthrough props — return prototype value as-is
|
|
850
1131
|
if (SKIP.has(key)) {
|
|
851
1132
|
return Reflect.get(target, prop, target);
|
|
852
1133
|
}
|
|
853
1134
|
// Return cached stub
|
|
854
|
-
if (stubs.has(key))
|
|
1135
|
+
if (stubs.has(key)) {
|
|
1136
|
+
const kept = placeholders.get(key);
|
|
1137
|
+
if (kept !== undefined) warnPlaceholderRead(target, key, kept);
|
|
1138
|
+
return stubs.get(key);
|
|
1139
|
+
}
|
|
855
1140
|
// A value discord.js always gives, in its shape, rather than a stub: kept once read, or its getter run live
|
|
856
1141
|
const known = discordDefault(target, key);
|
|
857
1142
|
if (known === REAL_GETTER) {
|
|
@@ -865,6 +1150,12 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
865
1150
|
stubs.set(key, value);
|
|
866
1151
|
return value;
|
|
867
1152
|
}
|
|
1153
|
+
// A value strict mocks read as discord.js gives it, which is a placeholder otherwise
|
|
1154
|
+
const strictValue = strictMocks() ? undefined : placeholderValue(target, key);
|
|
1155
|
+
if (strictValue !== undefined) {
|
|
1156
|
+
placeholders.set(key, strictValue);
|
|
1157
|
+
warnPlaceholderRead(target, key, strictValue);
|
|
1158
|
+
}
|
|
868
1159
|
// Walk the prototype chain to check if it's a function
|
|
869
1160
|
let proto = Object.getPrototypeOf(target);
|
|
870
1161
|
let protoValue;
|
|
@@ -875,7 +1166,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
875
1166
|
if (desc.get && isPlaceholder(proto, key)) {
|
|
876
1167
|
// Strict mocks run discord.js's getter, read live; otherwise the placeholder, with a warning once
|
|
877
1168
|
if (strictMocks()) return Reflect.get(target, prop, proxy);
|
|
878
|
-
|
|
1169
|
+
warnPlaceholderRead(target, key, 'false');
|
|
879
1170
|
}
|
|
880
1171
|
break;
|
|
881
1172
|
}
|
|
@@ -883,7 +1174,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
883
1174
|
}
|
|
884
1175
|
// A client the mock's structures share: its guild's, or one of its own
|
|
885
1176
|
if (key === 'client' && target instanceof discord_js.Base) return clientOf(target);
|
|
886
|
-
const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>protoValue.apply(proxy, args)) : methodStub(target, key, protoValue, ()=>proxy) : stubDeep({});
|
|
1177
|
+
const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>quietly(target, key, ()=>protoValue.apply(proxy, args))) : methodStub(target, key, protoValue, ()=>proxy) : stubDeep({});
|
|
887
1178
|
stubs.set(key, stub);
|
|
888
1179
|
return stub;
|
|
889
1180
|
},
|
|
@@ -892,7 +1183,10 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
892
1183
|
// and a plain write against one of those throws. Defining an own
|
|
893
1184
|
// data property shadows the accessor, which is what test setup means.
|
|
894
1185
|
set (target, prop, value) {
|
|
895
|
-
|
|
1186
|
+
// An own accessor with a setter, such as a context menu's target, takes the value itself
|
|
1187
|
+
const setter = Object.getOwnPropertyDescriptor(target, prop)?.set;
|
|
1188
|
+
if (setter) setter.call(target, value);
|
|
1189
|
+
else Object.defineProperty(target, prop, {
|
|
896
1190
|
value,
|
|
897
1191
|
writable: true,
|
|
898
1192
|
enumerable: true,
|
|
@@ -902,6 +1196,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
902
1196
|
return true;
|
|
903
1197
|
}
|
|
904
1198
|
});
|
|
1199
|
+
mockTargets.set(proxy, instance);
|
|
905
1200
|
return proxy;
|
|
906
1201
|
}
|
|
907
1202
|
// discord.js methods that read only what the mock holds, so they run for real: looking items up in a manager's cache,
|
|
@@ -1034,7 +1329,8 @@ const PLACEHOLDER_BOOLEANS = new Map([
|
|
|
1034
1329
|
[
|
|
1035
1330
|
discord_js.BaseGuildVoiceChannel.prototype,
|
|
1036
1331
|
new Set([
|
|
1037
|
-
'joinable'
|
|
1332
|
+
'joinable',
|
|
1333
|
+
'full'
|
|
1038
1334
|
])
|
|
1039
1335
|
],
|
|
1040
1336
|
[
|
|
@@ -1069,11 +1365,42 @@ const PLACEHOLDER_BOOLEANS = new Map([
|
|
|
1069
1365
|
return false;
|
|
1070
1366
|
}
|
|
1071
1367
|
const mockLogger = new controller_decorator.Logger('Mocks');
|
|
1072
|
-
|
|
1368
|
+
// Mock reactions, and their messages, that MeoCord's dispatcher reads `partial` on
|
|
1369
|
+
const dispatchedReactions = new WeakSet();
|
|
1370
|
+
let quiet = 0;
|
|
1371
|
+
/**
|
|
1372
|
+
* Runs a real method, keeping quiet about the placeholders `mentions.has()` reads: a placeholder `repliedUser` matches
|
|
1373
|
+
* no user, so its answer is discord.js's either way.
|
|
1374
|
+
*/ function quietly(target, key, run) {
|
|
1375
|
+
if (!(target instanceof discord_js.MessageMentions && key === 'has')) return run();
|
|
1376
|
+
quiet++;
|
|
1377
|
+
try {
|
|
1378
|
+
return run();
|
|
1379
|
+
} finally{
|
|
1380
|
+
quiet--;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Marks a mock reaction and its message as read by MeoCord's dispatcher, so a placeholder `partial` it reads warns
|
|
1385
|
+
* that MeoCord read it, and fetched for it.
|
|
1386
|
+
*/ function noteReactionDispatch(reaction) {
|
|
1387
|
+
const target = mockTargets.get(reaction);
|
|
1388
|
+
if (!target) return;
|
|
1389
|
+
dispatchedReactions.add(target);
|
|
1390
|
+
const message = mockTargets.get(reaction.message ?? {});
|
|
1391
|
+
if (message) dispatchedReactions.add(message);
|
|
1392
|
+
}
|
|
1393
|
+
function warnPlaceholderRead(target, key, strictValue) {
|
|
1394
|
+
if (quiet > 0) return;
|
|
1073
1395
|
const name = Object.getPrototypeOf(target).constructor.name;
|
|
1396
|
+
if (key === 'partial' && dispatchedReactions.has(target)) {
|
|
1397
|
+
const fetched = target instanceof discord_js.MessageReaction ? 'the reaction' : "the reaction's message";
|
|
1398
|
+
controller_decorator.warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `MeoCord's dispatcher read it while dispatching the reaction, and fetched ${fetched} since the placeholder is truthy. ` + 'Set reaction.partial = false and reaction.message.partial = false on the mock, or call useStrictMocks() to have the mock compute them now.');
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1074
1401
|
// The class name in camel case, a leading acronym included: dmChannel, guildMember
|
|
1075
1402
|
const variable = name.replace(/^[A-Z]+(?=[A-Z][a-z])|^[A-Z]/, (start)=>start.toLowerCase());
|
|
1076
|
-
controller_decorator.warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `Set it on the mock, such as ${variable}.${key} =
|
|
1403
|
+
controller_decorator.warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `Set it on the mock, such as ${variable}.${key} = ${strictValue}, to test either way, or call useStrictMocks() to have the mock compute it now.`);
|
|
1077
1404
|
}
|
|
1078
1405
|
// The client of a structure made without one: the one its guild has, else one of its own, made once
|
|
1079
1406
|
const ownClients = new WeakMap();
|
|
@@ -1181,25 +1508,37 @@ const MANAGER_ITEMS = [
|
|
|
1181
1508
|
],
|
|
1182
1509
|
[
|
|
1183
1510
|
discord_js.RoleManager,
|
|
1184
|
-
(id)=>createMockInteraction(discord_js.Role,
|
|
1185
|
-
id
|
|
1186
|
-
|
|
1511
|
+
(id, manager)=>createMockInteraction(discord_js.Role, {
|
|
1512
|
+
...id ? {
|
|
1513
|
+
id
|
|
1514
|
+
} : {},
|
|
1515
|
+
...guildOf(manager) ? {
|
|
1516
|
+
guild: guildOf(manager)
|
|
1517
|
+
} : {}
|
|
1518
|
+
})
|
|
1187
1519
|
],
|
|
1188
1520
|
[
|
|
1189
1521
|
discord_js.GuildBanManager,
|
|
1190
1522
|
()=>createMockInteraction(discord_js.GuildBan)
|
|
1191
1523
|
],
|
|
1524
|
+
// A message fetched from a channel is in it, and another user's
|
|
1192
1525
|
[
|
|
1193
1526
|
discord_js.GuildMessageManager,
|
|
1194
|
-
(id)=>createMockMessage(
|
|
1195
|
-
id
|
|
1196
|
-
|
|
1527
|
+
(id, manager)=>createMockMessage({
|
|
1528
|
+
...id ? {
|
|
1529
|
+
id
|
|
1530
|
+
} : {},
|
|
1531
|
+
...channelOfManager(manager)
|
|
1532
|
+
})
|
|
1197
1533
|
],
|
|
1198
1534
|
[
|
|
1199
1535
|
discord_js.DMMessageManager,
|
|
1200
|
-
(id)=>createMockMessage(
|
|
1201
|
-
id
|
|
1202
|
-
|
|
1536
|
+
(id, manager)=>createMockMessage({
|
|
1537
|
+
...id ? {
|
|
1538
|
+
id
|
|
1539
|
+
} : {},
|
|
1540
|
+
...channelOfManager(manager)
|
|
1541
|
+
})
|
|
1203
1542
|
],
|
|
1204
1543
|
[
|
|
1205
1544
|
discord_js.GuildTextThreadManager,
|
|
@@ -1217,6 +1556,12 @@ const MANAGER_ITEMS = [
|
|
|
1217
1556
|
discord_js.ThreadMemberManager,
|
|
1218
1557
|
()=>createMockInteraction(discord_js.ThreadMember)
|
|
1219
1558
|
],
|
|
1559
|
+
[
|
|
1560
|
+
discord_js.ReactionUserManager,
|
|
1561
|
+
(id)=>createMockUser(id ? {
|
|
1562
|
+
id
|
|
1563
|
+
} : {})
|
|
1564
|
+
],
|
|
1220
1565
|
[
|
|
1221
1566
|
discord_js.ApplicationCommandManager,
|
|
1222
1567
|
(id)=>createMockInteraction(discord_js.ApplicationCommand, id ? {
|
|
@@ -1244,6 +1589,12 @@ const MANAGER_ITEMS = [
|
|
|
1244
1589
|
const returnsPromise = (key, method)=>method.constructor.name === 'AsyncFunction' || PROMISE_METHODS.has(key) || /^set[A-Z]/.test(key) && !SYNC_SETTERS.has(key);
|
|
1245
1590
|
// The ids a fetch asks for: an id, a discord.js object, or options naming one or several, such as `{ user: id }` or
|
|
1246
1591
|
// `{ user: [ids] }`; undefined for a fetch of a list
|
|
1592
|
+
/** The channel a message manager belongs to, as `createMockMessage` takes it. */ const channelOfManager = (manager)=>{
|
|
1593
|
+
const channel = manager.channel;
|
|
1594
|
+
return channel instanceof discord_js.BaseChannel ? {
|
|
1595
|
+
channel: channel
|
|
1596
|
+
} : {};
|
|
1597
|
+
};
|
|
1247
1598
|
function fetchedIds(args) {
|
|
1248
1599
|
const idOf = (value)=>typeof value === 'string' ? value : value instanceof discord_js.Base ? value.id : undefined;
|
|
1249
1600
|
const [first] = args;
|
|
@@ -1279,6 +1630,10 @@ function fetchedIds(args) {
|
|
|
1279
1630
|
if (key === 'send' && target instanceof discord_js.User) {
|
|
1280
1631
|
return createMockFn(async (...args)=>(await receiver().createDM()).send(...args));
|
|
1281
1632
|
}
|
|
1633
|
+
// A channel's send resolves to the message it sent there
|
|
1634
|
+
if (key === 'send' && target instanceof discord_js.BaseChannel) {
|
|
1635
|
+
return createMockFn(async (options)=>sentMessage(sent(options, false), receiver()));
|
|
1636
|
+
}
|
|
1282
1637
|
if (MESSAGE_METHODS.has(key)) return createMockFn(async ()=>createMockMessage());
|
|
1283
1638
|
if (target instanceof discord_js.BaseManager) {
|
|
1284
1639
|
const item = MANAGER_ITEMS.find(([Manager])=>Manager.prototype.isPrototypeOf(target))?.[1];
|
|
@@ -1295,7 +1650,24 @@ function fetchedIds(args) {
|
|
|
1295
1650
|
return ids === undefined ? new discord_js.Collection() : one(ids);
|
|
1296
1651
|
});
|
|
1297
1652
|
}
|
|
1298
|
-
if (item &&
|
|
1653
|
+
if (item && key === 'edit') return createMockFn(async ()=>item(undefined, receiver()));
|
|
1654
|
+
// A ban resolves to the member, user or id it was given, as discord.js's does, and caches nothing
|
|
1655
|
+
if (key === 'create' && target instanceof discord_js.GuildBanManager) {
|
|
1656
|
+
return createMockFn(async (user)=>{
|
|
1657
|
+
if (user instanceof discord_js.GuildMember) return user;
|
|
1658
|
+
if (user instanceof discord_js.User) return receiver().guild.members.resolve(user) ?? user;
|
|
1659
|
+
return typeof user === 'string' ? user : user?.id;
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
// Every other manager here caches what its create() makes, as discord.js's does; check a manager's create when
|
|
1663
|
+
// adding it to MANAGER_ITEMS
|
|
1664
|
+
if (item && key === 'create') {
|
|
1665
|
+
return createMockFn(async ()=>{
|
|
1666
|
+
const made = item(undefined, receiver());
|
|
1667
|
+
if (typeof made.id === 'string') cacheOf(receiver())?.set(made.id, made);
|
|
1668
|
+
return made;
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1299
1671
|
} else if (target instanceof discord_js.Base && (SELF_METHODS.has(key) || /^set[A-Z]/.test(key))) {
|
|
1300
1672
|
return createMockFn(async ()=>receiver());
|
|
1301
1673
|
}
|
|
@@ -1455,14 +1827,46 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
1455
1827
|
});
|
|
1456
1828
|
}
|
|
1457
1829
|
}
|
|
1458
|
-
/**
|
|
1830
|
+
/**
|
|
1831
|
+
* Gives a user, server or channel mock its creation time. Under strict mocks, a generated id gives the time the mock
|
|
1832
|
+
* was made, as a message's. Otherwise it gives that id's own time, a fixed day in 2025, with a warning once when read.
|
|
1833
|
+
* An id the test gives decides it in both modes, and a value the test sets replaces it.
|
|
1834
|
+
*/ function defineMadeTime(mock, generatedId) {
|
|
1835
|
+
if (strictMocks()) return defineCreatedTime(mock, generatedId);
|
|
1836
|
+
const own = (key)=>Object.prototype.hasOwnProperty.call(mock, key);
|
|
1837
|
+
const name = Object.getPrototypeOf(mock).constructor.name;
|
|
1838
|
+
if (!own('createdTimestamp')) {
|
|
1839
|
+
Object.defineProperty(mock, 'createdTimestamp', {
|
|
1840
|
+
get () {
|
|
1841
|
+
if (this.id === generatedId) {
|
|
1842
|
+
controller_decorator.warnOnce(mockLogger, `${name}.createdTimestamp reads the time of its generated id here, a fixed day in 2025; in the next major version (5.0) ` + 'it is the time the mock was made. Give the mock an id or a createdTimestamp, or call useStrictMocks() to have it read the time the mock was made now.');
|
|
1843
|
+
}
|
|
1844
|
+
return isSnowflake(this.id) ? discord_js.SnowflakeUtil.timestampFrom(this.id) : null;
|
|
1845
|
+
},
|
|
1846
|
+
configurable: true
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
if (!own('createdAt')) {
|
|
1850
|
+
Object.defineProperty(mock, 'createdAt', {
|
|
1851
|
+
get () {
|
|
1852
|
+
return this.createdTimestamp === null ? null : new Date(this.createdTimestamp);
|
|
1853
|
+
},
|
|
1854
|
+
configurable: true
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order, under a key
|
|
1860
|
+
* both builds of this meocord version read.
|
|
1861
|
+
*/ const RESPONSE_LOG = Symbol.for(controller_decorator.sharedName('responseLog'));
|
|
1459
1862
|
/**
|
|
1460
1863
|
* A mock of an answer method, `impl` its behaviour, that records each call in `log` as it is made, with what it sent
|
|
1461
1864
|
* and, once it settles, what it rejected with. The record wraps every behaviour the mock is given, its own and any a
|
|
1462
|
-
* test sets, so the mock stays the plain mock function a runner's matchers read, bun's included
|
|
1463
|
-
* a call asks discord.js for the message back, not part of
|
|
1464
|
-
|
|
1465
|
-
|
|
1865
|
+
* test sets, so the mock stays the plain mock function a runner's matchers read, bun's included, and `gate` keeps the
|
|
1866
|
+
* reply state whichever behaviour runs. `withResponse` is how a call asks discord.js for the message back, not part of
|
|
1867
|
+
* what it sends, so it is left out.
|
|
1868
|
+
*/ function recordedMock(method, impl, log, gate) {
|
|
1869
|
+
const record = (behaviour, byTest = true)=>function(...args) {
|
|
1466
1870
|
const [payload] = args;
|
|
1467
1871
|
const sent = payload && typeof payload === 'object' && 'withResponse' in payload ? Object.fromEntries(Object.entries(payload).filter(([key])=>key !== 'withResponse')) : payload;
|
|
1468
1872
|
const call = {
|
|
@@ -1471,22 +1875,29 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
1471
1875
|
};
|
|
1472
1876
|
controller_decorator.stampCall(call);
|
|
1473
1877
|
log.push(call);
|
|
1878
|
+
let undo;
|
|
1879
|
+
try {
|
|
1880
|
+
// Before the behaviour, as discord.js changes the state at once: a second answer made without awaiting is refused
|
|
1881
|
+
if (gate) undo = gate(args, byTest);
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
call.error = error;
|
|
1884
|
+
return Promise.reject(error);
|
|
1885
|
+
}
|
|
1886
|
+
const failed = (error)=>{
|
|
1887
|
+
undo?.();
|
|
1888
|
+
call.error = error;
|
|
1889
|
+
throw error;
|
|
1890
|
+
};
|
|
1474
1891
|
try {
|
|
1475
1892
|
const result = Reflect.apply(behaviour, this, args);
|
|
1476
1893
|
// Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
|
|
1477
|
-
if (result instanceof Promise)
|
|
1478
|
-
return result.then(undefined, (error)=>{
|
|
1479
|
-
call.error = error;
|
|
1480
|
-
throw error;
|
|
1481
|
-
});
|
|
1482
|
-
}
|
|
1894
|
+
if (result instanceof Promise) return result.then(undefined, failed);
|
|
1483
1895
|
return result;
|
|
1484
1896
|
} catch (error) {
|
|
1485
|
-
|
|
1486
|
-
throw error;
|
|
1897
|
+
return failed(error);
|
|
1487
1898
|
}
|
|
1488
1899
|
};
|
|
1489
|
-
const mock = createMockFn(record(impl));
|
|
1900
|
+
const mock = createMockFn(record(impl, false));
|
|
1490
1901
|
const always = mock.mockImplementation.bind(mock);
|
|
1491
1902
|
const once = mock.mockImplementationOnce.bind(mock);
|
|
1492
1903
|
// Each way a test sets a behaviour goes through the record, a runner's own extras included where it has them
|
|
@@ -1519,16 +1930,26 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
1519
1930
|
return mock;
|
|
1520
1931
|
}
|
|
1521
1932
|
/** The interaction a mock options resolver was given to, for the server a user option's member is in. */ const optionOwners = new WeakMap();
|
|
1933
|
+
/** An error as discord.js throws it, with its code, and with `message` in place of discord.js's own text when given. */ function discordjsError(code, message) {
|
|
1934
|
+
const error = new discord_js.DiscordjsError(code);
|
|
1935
|
+
if (message !== undefined) error.message = message;
|
|
1936
|
+
return error;
|
|
1937
|
+
}
|
|
1522
1938
|
/** An error as discord.js's options resolver throws it: a `TypeError` with discord.js's code and message. */ const optionError = (code, ...args)=>new discord_js.DiscordjsTypeError(code, ...args);
|
|
1523
1939
|
// In discord.js's error codes, though missing from its typings
|
|
1524
1940
|
const INVALID_CHANNEL_TYPE = 'CommandInteractionOptionInvalidChannelType';
|
|
1525
1941
|
/** Records the interaction a mock options resolver belongs to. */ const ownOptions = (interaction, options)=>{
|
|
1526
1942
|
if (typeof options === 'object' && options !== null) optionOwners.set(options, interaction);
|
|
1527
1943
|
};
|
|
1528
|
-
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id
|
|
1529
|
-
|
|
1944
|
+
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id)=>{
|
|
1945
|
+
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
1946
|
+
const user = Object.assign(Object.create(discord_js.User.prototype), {
|
|
1947
|
+
id: id ?? generatedId,
|
|
1530
1948
|
bot: false
|
|
1531
|
-
})
|
|
1949
|
+
});
|
|
1950
|
+
defineMadeTime(user, generatedId);
|
|
1951
|
+
return stubDeep(user);
|
|
1952
|
+
};
|
|
1532
1953
|
function createMockInteraction(Class, props) {
|
|
1533
1954
|
const instance = Object.create(Class.prototype);
|
|
1534
1955
|
const stubs = new Map();
|
|
@@ -1548,14 +1969,36 @@ function createMockInteraction(Class, props) {
|
|
|
1548
1969
|
// mock lacks: a guildId is a guild, a guild object with it a cached one, neither a DM. A member
|
|
1549
1970
|
// not given is the user's on an interaction, and the auto-stub elsewhere, so only one set to null or undefined fails.
|
|
1550
1971
|
const own = (key)=>Object.prototype.hasOwnProperty.call(instance, key) ? instance[key] : undefined;
|
|
1972
|
+
let strictPlace;
|
|
1551
1973
|
const hasMember = ()=>!Object.prototype.hasOwnProperty.call(instance, 'member') || Boolean(instance.member);
|
|
1552
1974
|
const guildChecks = {
|
|
1553
1975
|
inGuild: ()=>Boolean(own('guildId') && hasMember()),
|
|
1554
1976
|
inCachedGuild: ()=>Boolean(own('guildId') && own('guild') && hasMember()),
|
|
1555
1977
|
inRawGuild: ()=>Boolean(own('guildId') && !own('guild') && hasMember())
|
|
1556
1978
|
};
|
|
1979
|
+
// What each answers where strict mocks place the mock: the place's guildId and guild for those the test left out, and
|
|
1980
|
+
// a member wherever there is a guildId, unless the test gave one
|
|
1981
|
+
const given = new Set(Object.keys(props ?? {}));
|
|
1982
|
+
const strictChecks = (place)=>{
|
|
1983
|
+
const pick = (key, placed)=>given.has(key) || placed === undefined ? own(key) : placed;
|
|
1984
|
+
const guildId = pick('guildId', place.guildId);
|
|
1985
|
+
const guild = place.raw ? null : pick('guild', place.guild);
|
|
1986
|
+
const member = given.has('member') ? Boolean(own('member')) : Boolean(guildId);
|
|
1987
|
+
return {
|
|
1988
|
+
inGuild: Boolean(guildId && member),
|
|
1989
|
+
inCachedGuild: Boolean(guildId && guild && member),
|
|
1990
|
+
inRawGuild: Boolean(guildId && !guild && member)
|
|
1991
|
+
};
|
|
1992
|
+
};
|
|
1993
|
+
// Each reads where the mock is as its own, and in default mode warns once, by its own name, where strict mocks answer
|
|
1994
|
+
// otherwise or refuse the mock
|
|
1557
1995
|
for (const [name, check] of Object.entries(guildChecks)){
|
|
1558
|
-
if (findPrototypeMethod(instance, name)
|
|
1996
|
+
if (findPrototypeMethod(instance, name) === null) continue;
|
|
1997
|
+
stubs.set(name, createMockFn(()=>{
|
|
1998
|
+
const answer = readPlaceQuietly(check);
|
|
1999
|
+
if (strictPlace && !strictMocks() && (strictPlace.refused || strictChecks(strictPlace)[name] !== answer)) placeWarners.get(instance)?.warn(`${name}()`);
|
|
2000
|
+
return answer;
|
|
2001
|
+
}));
|
|
1559
2002
|
}
|
|
1560
2003
|
// Set up reply state machine for repliable interactions
|
|
1561
2004
|
const isRepliableMethod = findPrototypeMethod(instance, 'isRepliable');
|
|
@@ -1564,83 +2007,204 @@ function createMockInteraction(Class, props) {
|
|
|
1564
2007
|
instance.replied = false;
|
|
1565
2008
|
instance.deferred = false;
|
|
1566
2009
|
instance.ephemeral = false;
|
|
1567
|
-
|
|
1568
|
-
const
|
|
2010
|
+
// discord.js's errors, with their codes; a call before any answer keeps the message the mock has always given
|
|
2011
|
+
const alreadyReplied = ()=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
2012
|
+
const notYetReplied = (method)=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionNotReplied, `Cannot call ${method}() before replying or deferring.`);
|
|
1569
2013
|
// Only `flags` is read: discord.js deprecates the `ephemeral: true` reply option, and honouring it here would let
|
|
1570
|
-
// a test pass against a deprecated call.
|
|
2014
|
+
// a test pass against a deprecated call. Read as discord.js reads them, so a flag it refuses throws here too.
|
|
1571
2015
|
const hasEphemeralFlag = (options)=>{
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2016
|
+
const flags = options?.flags;
|
|
2017
|
+
if (typeof options !== 'object' || flags === null || flags === undefined) return false;
|
|
2018
|
+
return new discord_js.MessageFlagsBitField(flags).has(discord_js.MessageFlags.Ephemeral);
|
|
2019
|
+
};
|
|
2020
|
+
const built = (held)=>typeof held === 'function' ? held() : held;
|
|
2021
|
+
let original;
|
|
2022
|
+
let originalGone = false;
|
|
2023
|
+
const followUps = new Map();
|
|
2024
|
+
// Whether the last answer ran a behaviour a test set, after which a second answer runs with a warning by default
|
|
2025
|
+
let answeredByTest = false;
|
|
2026
|
+
// A command answered with a modal has no original response, and Discord refuses one asked for
|
|
2027
|
+
let modalAnswered = false;
|
|
2028
|
+
const isComponent = instance.type === discord_js.InteractionType.MessageComponent;
|
|
2029
|
+
// The original response of a component, or of a modal submitted from one, is the message it's on
|
|
2030
|
+
const componentMessage = ()=>heldFrom(instance.message);
|
|
2031
|
+
const snapshot = ()=>{
|
|
2032
|
+
const saved = {
|
|
2033
|
+
replied: instance.replied,
|
|
2034
|
+
deferred: instance.deferred,
|
|
2035
|
+
ephemeral: instance.ephemeral,
|
|
2036
|
+
original,
|
|
2037
|
+
originalGone,
|
|
2038
|
+
followUps: new Map(followUps),
|
|
2039
|
+
answeredByTest,
|
|
2040
|
+
modalAnswered
|
|
2041
|
+
};
|
|
2042
|
+
return ()=>{
|
|
2043
|
+
Object.assign(instance, {
|
|
2044
|
+
replied: saved.replied,
|
|
2045
|
+
deferred: saved.deferred,
|
|
2046
|
+
ephemeral: saved.ephemeral
|
|
2047
|
+
});
|
|
2048
|
+
({ original, originalGone, answeredByTest, modalAnswered } = saved);
|
|
2049
|
+
followUps.clear();
|
|
2050
|
+
for (const [id, held] of saved.followUps)followUps.set(id, held);
|
|
2051
|
+
};
|
|
2052
|
+
};
|
|
2053
|
+
/**
|
|
2054
|
+
* Refuses an answer as discord.js does. Where the refusal turns on a behaviour a test set, this answer's or the
|
|
2055
|
+
* one before it, which ran without one, default mode warns and lets it run; strict mocks refuse it.
|
|
2056
|
+
*/ const refuse = (method, error, byTest)=>{
|
|
2057
|
+
if (!(byTest || answeredByTest) || strictMocks()) throw error;
|
|
2058
|
+
controller_decorator.warnOnce(mockLogger, `${method}() here is refused by discord.js with "${error.message}", but the mock runs it, as a test set this ` + 'answer or the one before it. In the next major version (5.0) the mock refuses it; call useStrictMocks() to ' + 'have it refused now.');
|
|
2059
|
+
};
|
|
2060
|
+
const answered = ()=>instance.deferred || instance.replied;
|
|
2061
|
+
/**
|
|
2062
|
+
* What an answer sends, built now, as discord.js builds it when sending. One it refuses to build is refused under
|
|
2063
|
+
* strict mocks; default mode warns and holds it to build when first read.
|
|
2064
|
+
*/ const hold = (method, build)=>{
|
|
2065
|
+
try {
|
|
2066
|
+
return build();
|
|
2067
|
+
} catch (error) {
|
|
2068
|
+
if (strictMocks()) throw error;
|
|
2069
|
+
controller_decorator.warnOnce(mockLogger, `${method}() here sends what discord.js refuses to build: "${error.message}". The mock sends it and ` + 'builds it when read; in the next major version (5.0) it rejects, as discord.js does. Call useStrictMocks() to ' + 'have it rejected now.');
|
|
2070
|
+
return build;
|
|
2071
|
+
}
|
|
2072
|
+
};
|
|
2073
|
+
/** A first answer: refused once the interaction has one, else it replies or defers. */ const first = (method, kind, then)=>(args, byTest)=>{
|
|
2074
|
+
if (answered()) refuse(method, alreadyReplied(), byTest);
|
|
2075
|
+
// Read first: flags discord.js refuses throw before anything changes
|
|
2076
|
+
const ephemeral = method === 'reply' || method === 'deferReply' ? hasEphemeralFlag(args[0]) : undefined;
|
|
2077
|
+
const undo = snapshot();
|
|
2078
|
+
instance[kind] = true;
|
|
2079
|
+
if (ephemeral) instance.ephemeral = true;
|
|
2080
|
+
answeredByTest = byTest;
|
|
2081
|
+
try {
|
|
2082
|
+
then(args);
|
|
2083
|
+
} catch (error) {
|
|
2084
|
+
undo();
|
|
2085
|
+
throw error;
|
|
2086
|
+
}
|
|
2087
|
+
return undo;
|
|
2088
|
+
};
|
|
2089
|
+
/** A later answer: refused before the interaction has one. */ const later = (method, then)=>(args, byTest)=>{
|
|
2090
|
+
if (!answered()) refuse(method, notYetReplied(method), byTest);
|
|
2091
|
+
const undo = snapshot();
|
|
2092
|
+
try {
|
|
2093
|
+
then(args);
|
|
2094
|
+
} catch (error) {
|
|
2095
|
+
undo();
|
|
2096
|
+
throw error;
|
|
2097
|
+
}
|
|
2098
|
+
return undo;
|
|
2099
|
+
};
|
|
2100
|
+
/** The id an edit, fetch or delete names, `undefined` for the original response. */ const targetId = (message)=>{
|
|
2101
|
+
if (message === undefined || message === '@original') return undefined;
|
|
2102
|
+
return typeof message === 'string' ? message : message.id ?? undefined;
|
|
2103
|
+
};
|
|
2104
|
+
/** The message an edit, fetch or delete reaches, or why Discord refuses it: it is gone or never was. */ const target = (id)=>{
|
|
2105
|
+
if (id !== undefined) {
|
|
2106
|
+
const held = followUps.get(id);
|
|
2107
|
+
if (held) followUps.set(id, built(held));
|
|
2108
|
+
return held && built(held);
|
|
2109
|
+
}
|
|
2110
|
+
if (originalGone || modalAnswered) return undefined;
|
|
2111
|
+
// Answered without one of the mock's calls, as after a 40060 or a test setting `replied`: the original is there
|
|
2112
|
+
if (!original && answered()) original = componentMessage();
|
|
2113
|
+
if (original) original = built(original);
|
|
2114
|
+
// An answer's message gets its id when first read, as it would be built then without strict mocks
|
|
2115
|
+
if (original?.id === '') original = {
|
|
2116
|
+
...original,
|
|
2117
|
+
id: nextSnowflake()
|
|
2118
|
+
};
|
|
2119
|
+
return original;
|
|
1577
2120
|
};
|
|
2121
|
+
const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
|
|
2122
|
+
// Where its answers are sent, read as the mock's own, so a placement warning stays the test's
|
|
2123
|
+
const answeredIn = ()=>readPlaceQuietly(()=>[
|
|
2124
|
+
instance.channel,
|
|
2125
|
+
instance.client
|
|
2126
|
+
]);
|
|
2127
|
+
// What each answer's message made for itself, by its id, so every read of it has the same author and place
|
|
2128
|
+
const madePlaces = new Map();
|
|
2129
|
+
const madeFor = (id)=>madePlaces.get(id) ?? madePlaces.set(id, {}).get(id);
|
|
2130
|
+
// Each call's message, read by its behaviour right after the gate made it
|
|
2131
|
+
let made;
|
|
2132
|
+
let followUpMade;
|
|
1578
2133
|
// Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
|
|
1579
2134
|
const log = [];
|
|
1580
2135
|
Object.defineProperty(instance, RESPONSE_LOG, {
|
|
1581
2136
|
value: log
|
|
1582
2137
|
});
|
|
1583
|
-
const answer = (method, impl)=>stubs.set(method, recordedMock(method, impl, log));
|
|
1584
|
-
answer('reply', async (
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
})
|
|
1594
|
-
answer('followUp', async ()=>{
|
|
1595
|
-
if (!instance.deferred && !instance.replied) throw notYetReplied('followUp');
|
|
2138
|
+
const answer = (method, impl, gate)=>stubs.set(method, recordedMock(method, impl, log, gate));
|
|
2139
|
+
answer('reply', async ()=>undefined, first('reply', 'replied', ([options])=>original = hold('reply', ()=>sent(options, false))));
|
|
2140
|
+
answer('deferReply', async ()=>undefined, first('deferReply', 'deferred', ()=>original = sent(undefined)));
|
|
2141
|
+
answer('followUp', // The follow-up as sent, which an edit, fetch or delete reaches by its id
|
|
2142
|
+
async ()=>{
|
|
2143
|
+
const held = followUps.get(followUpMade);
|
|
2144
|
+
// One held to build when read resolves to its id alone, as building it now would refuse it
|
|
2145
|
+
return typeof held === 'function' ? createMockMessage({
|
|
2146
|
+
id: followUpMade
|
|
2147
|
+
}) : messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
2148
|
+
}, later('followUp', ([options])=>{
|
|
1596
2149
|
instance.replied = true;
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
|
|
1607
|
-
answer('editReply', async (options)=>{
|
|
1608
|
-
if (!instance.deferred && !instance.replied) throw notYetReplied('editReply');
|
|
1609
|
-
if (modalAnswered) throw unknownMessage();
|
|
1610
|
-
instance.replied = true;
|
|
1611
|
-
held = edited(current(), options);
|
|
1612
|
-
// The response has the message as Discord answers the edit, before its uploaded files have loaded again
|
|
1613
|
-
return messageFrom({
|
|
2150
|
+
// Held as it is when sent, as Discord stores it, so a later change to what was passed isn't seen; its id taken
|
|
2151
|
+
// once built, so one that fails to build under strict mocks takes none
|
|
2152
|
+
const held = hold('followUp', ()=>sent(options, false));
|
|
2153
|
+
const id = nextSnowflake();
|
|
2154
|
+
made = undefined;
|
|
2155
|
+
followUps.set(id, typeof held === 'function' ? ()=>({
|
|
2156
|
+
...held(),
|
|
2157
|
+
id
|
|
2158
|
+
}) : {
|
|
1614
2159
|
...held,
|
|
1615
|
-
|
|
2160
|
+
id
|
|
1616
2161
|
});
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
2162
|
+
followUpMade = id;
|
|
2163
|
+
}));
|
|
2164
|
+
// An edit replaces the message in the shape Discord stores, with ids and resolved media, stamped with when it was
|
|
2165
|
+
// edited, and fetchReply() reads that back, so code comparing the two sees what it would against Discord
|
|
2166
|
+
answer('editReply', async ()=>{
|
|
2167
|
+
if (!made) throw unknownMessage();
|
|
2168
|
+
// The response has the message as Discord answers the edit, before its uploaded files have loaded again
|
|
2169
|
+
return messageFrom({
|
|
2170
|
+
...made,
|
|
2171
|
+
components: made.returned ?? made.components
|
|
2172
|
+
}, madeFor(made.id), ...answeredIn());
|
|
2173
|
+
}, later('editReply', ([options])=>{
|
|
2174
|
+
const id = targetId(options?.message);
|
|
2175
|
+
const held = target(id);
|
|
2176
|
+
made = held && edited(held, options);
|
|
2177
|
+
if (!made) return;
|
|
2178
|
+
if (id === undefined) {
|
|
2179
|
+
original = made;
|
|
2180
|
+
instance.replied = true;
|
|
2181
|
+
} else followUps.set(id, made);
|
|
2182
|
+
}));
|
|
2183
|
+
stubs.set('fetchReply', createMockFn(async (message)=>{
|
|
2184
|
+
const held = target(targetId(message));
|
|
2185
|
+
if (!held) throw unknownMessage();
|
|
2186
|
+
return messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
1621
2187
|
}));
|
|
1622
2188
|
answer('deleteReply', async ()=>{
|
|
1623
|
-
if (!
|
|
1624
|
-
|
|
1625
|
-
|
|
2189
|
+
if (!made) throw unknownMessage();
|
|
2190
|
+
}, later('deleteReply', ([message])=>{
|
|
2191
|
+
const id = targetId(message);
|
|
2192
|
+
made = target(id);
|
|
2193
|
+
if (!made) return;
|
|
2194
|
+
if (id === undefined) originalGone = true;
|
|
2195
|
+
else followUps.delete(id);
|
|
2196
|
+
}));
|
|
1626
2197
|
// showModal — the first response of a command or a component, like reply()
|
|
1627
|
-
if (instance.type === discord_js.InteractionType.ApplicationCommand ||
|
|
1628
|
-
answer('showModal', async ()=>{
|
|
1629
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
1630
|
-
instance.replied = true;
|
|
2198
|
+
if (instance.type === discord_js.InteractionType.ApplicationCommand || isComponent) {
|
|
2199
|
+
answer('showModal', async ()=>undefined, first('showModal', 'replied', ()=>{
|
|
1631
2200
|
modalAnswered = instance.type === discord_js.InteractionType.ApplicationCommand;
|
|
1632
|
-
|
|
2201
|
+
if (isComponent) original = componentMessage();
|
|
2202
|
+
}));
|
|
1633
2203
|
}
|
|
1634
2204
|
// deferUpdate / update — components, and modals submitted from a message's component
|
|
1635
|
-
if (
|
|
1636
|
-
answer('update', async ()=>
|
|
1637
|
-
|
|
1638
|
-
instance.replied = true;
|
|
1639
|
-
});
|
|
1640
|
-
answer('deferUpdate', async ()=>{
|
|
1641
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
1642
|
-
instance.deferred = true;
|
|
1643
|
-
});
|
|
2205
|
+
if (isComponent || instance.type === discord_js.InteractionType.ModalSubmit) {
|
|
2206
|
+
answer('update', async ()=>undefined, first('update', 'replied', ([options])=>original = hold('update', ()=>edited(heldFrom(instance.message, false), options))));
|
|
2207
|
+
answer('deferUpdate', async ()=>undefined, first('deferUpdate', 'deferred', ()=>original = componentMessage()));
|
|
1644
2208
|
}
|
|
1645
2209
|
}
|
|
1646
2210
|
// Autocomplete is not repliable, but it has a response of its own: Discord accepts
|
|
@@ -1650,7 +2214,7 @@ function createMockInteraction(Class, props) {
|
|
|
1650
2214
|
if (instance.type === discord_js.InteractionType.ApplicationCommandAutocomplete) {
|
|
1651
2215
|
instance.responded = false;
|
|
1652
2216
|
stubs.set('respond', createMockFn(async (choices)=>{
|
|
1653
|
-
if (instance.responded) throw
|
|
2217
|
+
if (instance.responded) throw discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
1654
2218
|
// Discord refuses a list longer than its limit, and the menu shows nothing
|
|
1655
2219
|
if (Array.isArray(choices) && choices.length > MAX_AUTOCOMPLETE_CHOICES) {
|
|
1656
2220
|
throw createDiscordError(50035, `Invalid Form Body\ndata.choices[BASE_TYPE_MAX_LENGTH]: Must be ${MAX_AUTOCOMPLETE_CHOICES} or fewer in length.`);
|
|
@@ -1679,6 +2243,34 @@ function createMockInteraction(Class, props) {
|
|
|
1679
2243
|
const unset = (key)=>!Object.prototype.hasOwnProperty.call(instance, key);
|
|
1680
2244
|
const generatedId = unset('id') ? instance.id = nextSnowflake() : undefined;
|
|
1681
2245
|
defineCreatedTime(instance, generatedId);
|
|
2246
|
+
// Strict mocks place it where its message, guild or member is, and a guildId alone in a server the bot isn't in
|
|
2247
|
+
// Read as the mock's own, so a message's placement warning stays the test's
|
|
2248
|
+
strictPlace = readPlaceQuietly(()=>strictPlaceOf(own));
|
|
2249
|
+
if (strictPlace && strictMocks()) {
|
|
2250
|
+
if (strictPlace.refused) throw new Error(strictPlace.refused);
|
|
2251
|
+
const set = (key, value)=>Object.defineProperty(instance, key, {
|
|
2252
|
+
value,
|
|
2253
|
+
writable: true,
|
|
2254
|
+
enumerable: true,
|
|
2255
|
+
configurable: true
|
|
2256
|
+
});
|
|
2257
|
+
if (strictPlace.channel && unset('channel')) set('channel', strictPlace.channel);
|
|
2258
|
+
if (strictPlace.guildId !== undefined && unset('guildId')) set('guildId', strictPlace.guildId);
|
|
2259
|
+
if (strictPlace.guild !== undefined && unset('guild')) set('guild', strictPlace.guild);
|
|
2260
|
+
if (strictPlace.raw) {
|
|
2261
|
+
const user = own('user');
|
|
2262
|
+
const rawUser = user && {
|
|
2263
|
+
id: user.id,
|
|
2264
|
+
username: user.username,
|
|
2265
|
+
global_name: user.globalName ?? null,
|
|
2266
|
+
discriminator: user.discriminator,
|
|
2267
|
+
avatar: user.avatar ?? null
|
|
2268
|
+
};
|
|
2269
|
+
set('member', createMockRawMember(rawUser ? {
|
|
2270
|
+
user: rawUser
|
|
2271
|
+
} : {}));
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
1682
2274
|
// A raw member is from a server the bot isn't in: no cached guild, and its user the interaction's, as discord.js reads it
|
|
1683
2275
|
const raw = own('member');
|
|
1684
2276
|
// Given with a guild, a raw-shaped member is kept as the cached server's member it was before raw members
|
|
@@ -1761,12 +2353,13 @@ function createMockInteraction(Class, props) {
|
|
|
1761
2353
|
configurable: true
|
|
1762
2354
|
});
|
|
1763
2355
|
}
|
|
1764
|
-
// The channel it came from, as the gateway caches it for an interaction:
|
|
2356
|
+
// The channel it came from, as the gateway caches it for an interaction: its server's cached one, looked up on each
|
|
2357
|
+
// read so a channel the test caches later is the one read, or the user's DM
|
|
1765
2358
|
let channel;
|
|
1766
2359
|
if (unset('channel')) {
|
|
1767
2360
|
Object.defineProperty(instance, 'channel', {
|
|
1768
2361
|
get: ()=>{
|
|
1769
|
-
if (channel === undefined) {
|
|
2362
|
+
if (channel === undefined || own('guildId') && own('guild') instanceof discord_js.Guild) {
|
|
1770
2363
|
channel = channelFor(own('guild'), own('guildId'), own('channelId'), own('user'));
|
|
1771
2364
|
cacheChannel(channel, own('guild'), own('client'));
|
|
1772
2365
|
}
|
|
@@ -1821,7 +2414,73 @@ function createMockInteraction(Class, props) {
|
|
|
1821
2414
|
live('targetMember', ()=>own('guildId') ? memberFor(own('guild'), targetUser(), true, own('guildId')) : null);
|
|
1822
2415
|
}
|
|
1823
2416
|
}
|
|
2417
|
+
// A message context menu's target: the message given, else one made for its targetId. The message is the one
|
|
2418
|
+
// source: targetId reads its id, and setting either one moves the other with it
|
|
2419
|
+
if (discord_js.MessageContextMenuCommandInteraction.prototype.isPrototypeOf(instance)) {
|
|
2420
|
+
let given = own('targetMessage');
|
|
2421
|
+
let explicitId = own('targetId');
|
|
2422
|
+
if (given && given.id === undefined) given.id = nextSnowflake();
|
|
2423
|
+
if (given && explicitId !== undefined && explicitId !== given.id) {
|
|
2424
|
+
const conflict = `The targetMessage given is message ${given.id}, but the mock's targetId is ${explicitId}: give the same id, or leave one of them out.`;
|
|
2425
|
+
if (strictMocks()) throw new Error(conflict);
|
|
2426
|
+
controller_decorator.warnOnce(mockLogger, `${conflict} The mock reads targetMessage's id; under useStrictMocks(), as in the next major version (5.0), it refuses the pair.`);
|
|
2427
|
+
explicitId = undefined;
|
|
2428
|
+
}
|
|
2429
|
+
const generated = given || explicitId !== undefined ? undefined : nextSnowflake();
|
|
2430
|
+
let made;
|
|
2431
|
+
const accessor = (key, get, set)=>Object.defineProperty(instance, key, {
|
|
2432
|
+
get,
|
|
2433
|
+
set,
|
|
2434
|
+
enumerable: true,
|
|
2435
|
+
configurable: true
|
|
2436
|
+
});
|
|
2437
|
+
const targetId = ()=>explicitId ?? given?.id ?? generated;
|
|
2438
|
+
accessor('targetId', targetId, (value)=>{
|
|
2439
|
+
explicitId = value;
|
|
2440
|
+
given = undefined;
|
|
2441
|
+
});
|
|
2442
|
+
// In the channel the menu was used in, and its server, as the message a menu is used on is: its channel is the
|
|
2443
|
+
// menu's, read live, so making the message makes and caches no channel. Read as the mock's own, so a placement
|
|
2444
|
+
// warning stays the test's
|
|
2445
|
+
const makeTarget = ()=>readPlaceQuietly(()=>{
|
|
2446
|
+
const [guild, guildId] = [
|
|
2447
|
+
own('guild'),
|
|
2448
|
+
own('guildId') ?? null
|
|
2449
|
+
];
|
|
2450
|
+
const message = messageMock({
|
|
2451
|
+
id: targetId(),
|
|
2452
|
+
guild: guildId && guild instanceof discord_js.Guild ? guild : null,
|
|
2453
|
+
client: instance.client
|
|
2454
|
+
}, undefined, {}, {
|
|
2455
|
+
channelId: own('channelId'),
|
|
2456
|
+
guildId,
|
|
2457
|
+
channel: ()=>readPlaceQuietly(()=>instance.channel)
|
|
2458
|
+
});
|
|
2459
|
+
// Where strict mocks place the menu elsewhere, its target's place warns as the menu's does, by the target's name
|
|
2460
|
+
const warner = placeWarners.get(instance);
|
|
2461
|
+
const target = mockTargets.get(message) ?? message;
|
|
2462
|
+
for (const key of warner?.keys ?? [])warnOnRead(target, key, ()=>readingPlace > 0 ? void placeReadsSeen?.add(key) : warner.warn(`targetMessage.${key}`));
|
|
2463
|
+
return message;
|
|
2464
|
+
});
|
|
2465
|
+
accessor('targetMessage', ()=>given ?? (made?.id === targetId() ? made : made = makeTarget()), (value)=>{
|
|
2466
|
+
given = value;
|
|
2467
|
+
if (given && given.id === undefined) given.id = nextSnowflake();
|
|
2468
|
+
explicitId = undefined;
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
// A user has an id of its own, as Discord sends one, and was created when the mock was made under strict mocks
|
|
2473
|
+
if (discord_js.User.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) {
|
|
2474
|
+
const generatedId = instance.id = nextSnowflake();
|
|
2475
|
+
defineMadeTime(instance, generatedId);
|
|
2476
|
+
}
|
|
2477
|
+
// A role too, created when the mock was made in both modes
|
|
2478
|
+
if (discord_js.Role.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) {
|
|
2479
|
+
const generatedId = instance.id = nextSnowflake();
|
|
2480
|
+
defineCreatedTime(instance, generatedId);
|
|
1824
2481
|
}
|
|
2482
|
+
// An attachment too, from the same count as every mock's
|
|
2483
|
+
if (discord_js.Attachment.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) instance.id = nextSnowflake();
|
|
1825
2484
|
// Discord sends the user's locale with every interaction, and the server's preferred one with one made in a server
|
|
1826
2485
|
if (discord_js.BaseInteraction.prototype.isPrototypeOf(instance)) {
|
|
1827
2486
|
if (own('locale') === undefined) instance.locale = discord_js.Locale.EnglishUS;
|
|
@@ -1845,6 +2504,32 @@ function createMockInteraction(Class, props) {
|
|
|
1845
2504
|
}
|
|
1846
2505
|
for (const kind of choices)if (!Object.prototype.hasOwnProperty.call(instance, kind)) instance[kind] = new discord_js.Collection();
|
|
1847
2506
|
}
|
|
2507
|
+
// In default mode, where strict mocks place it elsewhere, reading where it is warns
|
|
2508
|
+
if (strictPlace && !strictMocks()) warnOnPlaceReads(instance, strictPlace, new Set(Object.keys(props ?? {})));
|
|
2509
|
+
// A message whose place the mock made moves to where the test placed the interaction under strict mocks, and is then
|
|
2510
|
+
// one the test placed; in default mode it stays, and reading its place warns
|
|
2511
|
+
const message = own('message');
|
|
2512
|
+
const placed = [
|
|
2513
|
+
'channel',
|
|
2514
|
+
'guild',
|
|
2515
|
+
'guildId'
|
|
2516
|
+
].some((key)=>Object.keys(props ?? {}).includes(key));
|
|
2517
|
+
if (discord_js.BaseInteraction.prototype.isPrototypeOf(instance) && placed && typeof message === 'object' && message !== null && placeWasMade(message)) {
|
|
2518
|
+
if (strictMocks()) {
|
|
2519
|
+
const guild = own('guild');
|
|
2520
|
+
const channel = instance.channel;
|
|
2521
|
+
Object.assign(message, {
|
|
2522
|
+
guild: guild ?? null,
|
|
2523
|
+
guildId: own('guildId') ?? null,
|
|
2524
|
+
...channel && {
|
|
2525
|
+
channel,
|
|
2526
|
+
channelId: channel.id
|
|
2527
|
+
}
|
|
2528
|
+
});
|
|
2529
|
+
if (guild) memberFor(guild, message.author, true);
|
|
2530
|
+
placesMade.delete(mockTargets.get(message) ?? message);
|
|
2531
|
+
} else warnOnMadePlaceReads(mockTargets.get(message) ?? message);
|
|
2532
|
+
}
|
|
1848
2533
|
// Options assigned after creation, directly or through Object.assign, belong to it as those given do
|
|
1849
2534
|
return stubDeep(instance, stubs, (prop, value)=>{
|
|
1850
2535
|
if (prop === 'options') ownOptions(instance, value);
|
|
@@ -1856,8 +2541,15 @@ function createMockInteraction(Class, props) {
|
|
|
1856
2541
|
/**
|
|
1857
2542
|
* A mock function that answers property access with another one, so `cache.store.flush()` works
|
|
1858
2543
|
* on a double whose shape is an interface with nothing at runtime to read it from.
|
|
1859
|
-
*/ function stubCallable() {
|
|
2544
|
+
*/ function stubCallable(member) {
|
|
1860
2545
|
const fn = createMockFn();
|
|
2546
|
+
// jest's mock reads `_protoImpl` from itself whenever it's called, so the prototype below must not answer it
|
|
2547
|
+
if (usesRunnerMockFn()) Object.defineProperty(fn, '_protoImpl', {
|
|
2548
|
+
value: undefined,
|
|
2549
|
+
writable: true,
|
|
2550
|
+
enumerable: false,
|
|
2551
|
+
configurable: true
|
|
2552
|
+
});
|
|
1861
2553
|
// Any property beyond the mock's own API is a nested mock, made on first read and kept on the function, out of its
|
|
1862
2554
|
// keys. Its prototype makes them rather than a Proxy around it, so it stays the plain mock function matchers read
|
|
1863
2555
|
const inherited = Object.getPrototypeOf(fn);
|
|
@@ -1866,7 +2558,9 @@ function createMockInteraction(Class, props) {
|
|
|
1866
2558
|
if (typeof prop === 'symbol' || prop in proto) return Reflect.get(proto, prop, receiver);
|
|
1867
2559
|
// Never thenable — otherwise awaiting a mock hangs on itself
|
|
1868
2560
|
if (prop === 'then') return undefined;
|
|
1869
|
-
|
|
2561
|
+
// jest's matchers take a function whose `calls` has `all` and `count` for a jasmine spy, read before `mock.calls`
|
|
2562
|
+
if (member === 'calls' && (prop === 'all' || prop === 'count')) return undefined;
|
|
2563
|
+
const nested = stubCallable(prop);
|
|
1870
2564
|
Object.defineProperty(receiver, prop, {
|
|
1871
2565
|
value: nested,
|
|
1872
2566
|
writable: true,
|
|
@@ -1931,6 +2625,7 @@ function createMockInteraction(Class, props) {
|
|
|
1931
2625
|
if (key === 'then') return undefined;
|
|
1932
2626
|
// An explicitly supplied prop wins over the auto-stub.
|
|
1933
2627
|
if (Object.prototype.hasOwnProperty.call(instance, key)) return Reflect.get(instance, prop, instance);
|
|
2628
|
+
// The double itself is no function a matcher reads, so a `calls` member here keeps its `all` and `count`
|
|
1934
2629
|
if (!stubs.has(key)) stubs.set(key, stubCallable());
|
|
1935
2630
|
return stubs.get(key);
|
|
1936
2631
|
},
|
|
@@ -1970,11 +2665,12 @@ function createMockInteraction(Class, props) {
|
|
|
1970
2665
|
* @group Testing
|
|
1971
2666
|
* @category Mocks
|
|
1972
2667
|
* @see {@link createMockInteraction}
|
|
1973
|
-
*/
|
|
1974
|
-
|
|
2668
|
+
*/ function createMockUser(props = {}) {
|
|
2669
|
+
return createMockInteraction(discord_js.User, {
|
|
1975
2670
|
bot: false,
|
|
1976
2671
|
...props
|
|
1977
2672
|
});
|
|
2673
|
+
}
|
|
1978
2674
|
/**
|
|
1979
2675
|
* Creates a mock {@link Client}, with `users`, `channels`, `guilds` and `application.commands` ready to stub.
|
|
1980
2676
|
*
|
|
@@ -2088,6 +2784,18 @@ const HOLDS = [
|
|
|
2088
2784
|
[
|
|
2089
2785
|
discord_js.PermissionOverwriteManager.prototype,
|
|
2090
2786
|
discord_js.PermissionOverwrites
|
|
2787
|
+
],
|
|
2788
|
+
[
|
|
2789
|
+
discord_js.ReactionManager.prototype,
|
|
2790
|
+
discord_js.MessageReaction
|
|
2791
|
+
],
|
|
2792
|
+
[
|
|
2793
|
+
discord_js.ReactionUserManager.prototype,
|
|
2794
|
+
discord_js.User
|
|
2795
|
+
],
|
|
2796
|
+
[
|
|
2797
|
+
discord_js.PresenceManager.prototype,
|
|
2798
|
+
discord_js.Presence
|
|
2091
2799
|
]
|
|
2092
2800
|
];
|
|
2093
2801
|
/**
|
|
@@ -2103,6 +2811,11 @@ const HOLDS = [
|
|
|
2103
2811
|
value: cache,
|
|
2104
2812
|
writable: true
|
|
2105
2813
|
});
|
|
2814
|
+
// The same collection where discord.js's own methods read it past a subclass's `cache`, as `super.cache` does
|
|
2815
|
+
Object.defineProperty(manager, '_cache', {
|
|
2816
|
+
value: cache,
|
|
2817
|
+
writable: true
|
|
2818
|
+
});
|
|
2106
2819
|
const holds = HOLDS.find(([Manager])=>Manager === prototype || Object.prototype.isPrototypeOf.call(Manager, prototype))?.[1];
|
|
2107
2820
|
if (holds) Object.defineProperty(manager, 'holds', {
|
|
2108
2821
|
value: holds
|
|
@@ -2110,14 +2823,25 @@ const HOLDS = [
|
|
|
2110
2823
|
let own;
|
|
2111
2824
|
Object.defineProperty(manager, 'client', {
|
|
2112
2825
|
get: ()=>{
|
|
2113
|
-
const owner = manager.guild ?? manager.channel ?? manager.thread;
|
|
2826
|
+
const owner = manager.guild ?? manager.channel ?? manager.thread ?? manager.message;
|
|
2114
2827
|
return owner instanceof discord_js.Base ? owner.client : own ?? (own = createMockClient());
|
|
2115
2828
|
},
|
|
2116
|
-
|
|
2829
|
+
// A client the test sets is the one read from then on, as on any other mock
|
|
2830
|
+
set: (value)=>Object.defineProperty(manager, 'client', {
|
|
2831
|
+
value,
|
|
2832
|
+
writable: true,
|
|
2833
|
+
enumerable: true,
|
|
2834
|
+
configurable: true
|
|
2835
|
+
}),
|
|
2117
2836
|
configurable: true
|
|
2118
2837
|
});
|
|
2119
2838
|
return stubDeep(manager);
|
|
2120
2839
|
}
|
|
2840
|
+
/** An empty manager of `prototype` that `owner` names its own, such as `{ message }` for a message's reactions. */ function ownedManager(prototype, owner) {
|
|
2841
|
+
const manager = managerWith(prototype, undefined);
|
|
2842
|
+
Object.assign(manager, owner);
|
|
2843
|
+
return manager;
|
|
2844
|
+
}
|
|
2121
2845
|
/**
|
|
2122
2846
|
* Creates a mock {@link Guild}, with the `members`, `channels`, `roles` and `bans` managers ready to stub.
|
|
2123
2847
|
*
|
|
@@ -2154,8 +2878,9 @@ const HOLDS = [
|
|
|
2154
2878
|
* @see {@link createMockMessage}
|
|
2155
2879
|
*/ function createMockGuild(overrides = {}) {
|
|
2156
2880
|
const instance = Object.create(discord_js.Guild.prototype);
|
|
2157
|
-
|
|
2158
|
-
|
|
2881
|
+
const generatedId = overrides.id === undefined ? nextSnowflake() : undefined;
|
|
2882
|
+
instance.id = overrides.id ?? generatedId;
|
|
2883
|
+
defineMadeTime(instance, generatedId);
|
|
2159
2884
|
if (overrides.name !== undefined) instance.name = overrides.name;
|
|
2160
2885
|
if (overrides.preferredLocale !== undefined) instance.preferredLocale = overrides.preferredLocale;
|
|
2161
2886
|
instance.members = managerWith(discord_js.GuildMemberManager.prototype, overrides.members);
|
|
@@ -2200,7 +2925,8 @@ const HOLDS = [
|
|
|
2200
2925
|
* @see {@link createMockGuild}
|
|
2201
2926
|
*/ function createMockChannel(Class, props = {}) {
|
|
2202
2927
|
const instance = Object.create(Class.prototype);
|
|
2203
|
-
|
|
2928
|
+
const generatedId = props.id === undefined ? nextSnowflake() : undefined;
|
|
2929
|
+
instance.id = generatedId;
|
|
2204
2930
|
const is = (Base)=>Base.prototype.isPrototypeOf(Class.prototype) || Class === Base;
|
|
2205
2931
|
// Text and announcement channels: messages, and threads made in the channel
|
|
2206
2932
|
if (is(discord_js.TextChannel) || is(discord_js.NewsChannel)) {
|
|
@@ -2228,6 +2954,7 @@ const HOLDS = [
|
|
|
2228
2954
|
// A server's channel has its overwrites, none until a test puts some in the cache, which its permissions read
|
|
2229
2955
|
if (is(discord_js.GuildChannel) && !is(discord_js.ThreadChannel)) instance.permissionOverwrites = managerWith(discord_js.PermissionOverwriteManager.prototype, undefined);
|
|
2230
2956
|
Object.assign(instance, props);
|
|
2957
|
+
defineMadeTime(instance, generatedId);
|
|
2231
2958
|
const stubs = new Map();
|
|
2232
2959
|
const channel = stubDeep(instance, stubs);
|
|
2233
2960
|
// Each manager knows its channel, as discord.js's do, and a thread's members their thread
|
|
@@ -2353,16 +3080,24 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2353
3080
|
return stubDeep(manager);
|
|
2354
3081
|
}
|
|
2355
3082
|
/** The permissions Discord gives a server's @everyone role in a server made today. */ const NEW_SERVER_EVERYONE_PERMISSIONS = 2248473465835073n;
|
|
3083
|
+
// The @everyone roles default mode made with no permissions, where strict mocks give a new server's
|
|
3084
|
+
const madeEveryones = new WeakSet();
|
|
3085
|
+
/** Whether `role` is an @everyone role default mode made, whose permissions the test hasn't set. */ const isUnsetEveryone = (role)=>madeEveryones.has(role) && !Object.prototype.hasOwnProperty.call(role, 'permissions');
|
|
2356
3086
|
// A server's @everyone role, which has the server's id and ranks lowest, at position 0; with strict mocks, the
|
|
2357
3087
|
// permissions Discord gives it in a new server
|
|
2358
|
-
|
|
3088
|
+
function everyoneRole(guildId) {
|
|
3089
|
+
const strict = strictMocks();
|
|
3090
|
+
const role = createMockInteraction(discord_js.Role, {
|
|
2359
3091
|
id: guildId,
|
|
2360
3092
|
name: '@everyone',
|
|
2361
3093
|
position: 0,
|
|
2362
|
-
...
|
|
3094
|
+
...strict ? {
|
|
2363
3095
|
permissions: new discord_js.PermissionsBitField(NEW_SERVER_EVERYONE_PERMISSIONS).freeze()
|
|
2364
3096
|
} : {}
|
|
2365
3097
|
});
|
|
3098
|
+
if (!strict) madeEveryones.add(role);
|
|
3099
|
+
return role;
|
|
3100
|
+
}
|
|
2366
3101
|
/**
|
|
2367
3102
|
* A guild's role manager with `roles` in its cache, and the guild's @everyone role: the one given with the guild's id,
|
|
2368
3103
|
* or a role of its own at position 0, as every server has one.
|
|
@@ -2462,8 +3197,9 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2462
3197
|
* highest, by position, then the lower id. `permissions` are computed as discord.js computes them: every permission for
|
|
2463
3198
|
* the server's owner, and otherwise its roles' permissions combined, @everyone's included, so a member with no roles has
|
|
2464
3199
|
* @everyone's alone. The member is put in its guild's member cache, so an interaction or a message from its user in that
|
|
2465
|
-
* server has it as its `member`, and `memberPermissions` are its permissions
|
|
2466
|
-
* user's `send()` and the user's
|
|
3200
|
+
* server has it as its `member`, and `memberPermissions` are its permissions, with the interaction's channel's
|
|
3201
|
+
* overwrites applied under `useStrictMocks()`. A DM sent to the member goes through its user's `send()` and the user's
|
|
3202
|
+
* one DM channel.
|
|
2467
3203
|
*
|
|
2468
3204
|
* @param overrides - The member's user, server, roles and nickname; see {@link MockMemberOverrides}.
|
|
2469
3205
|
* @returns The mock member.
|
|
@@ -2510,6 +3246,218 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2510
3246
|
return guild ? cached(cacheOf(guild.channels), channelId, make) : make();
|
|
2511
3247
|
}
|
|
2512
3248
|
/** A server's id as a refusal names it, or a DM. */ const placeName = (guildId)=>guildId === null ? 'a DM' : `server ${guildId}`;
|
|
3249
|
+
/**
|
|
3250
|
+
* Where strict mocks place an interaction: in its message's channel and server, in a guild's or a member's server, and
|
|
3251
|
+
* with a guildId alone in a server the bot isn't in. Undefined where a channel given, or nothing, places it.
|
|
3252
|
+
*/ // Mock messages whose channel and server the mock made, as neither was given
|
|
3253
|
+
const placesMade = new WeakSet();
|
|
3254
|
+
// Messages whose place reads already warn, so a message reused for several interactions is wrapped once
|
|
3255
|
+
const madePlaceWarned = new WeakSet();
|
|
3256
|
+
/**
|
|
3257
|
+
* Has a message whose place the mock made warn once per key when its place is read, in default mode, where strict mocks
|
|
3258
|
+
* move it to the interaction it was given to. A read made while reading another of these, or by the mock, stays quiet.
|
|
3259
|
+
*/ function warnOnMadePlaceReads(message) {
|
|
3260
|
+
if (madePlaceWarned.has(message)) return;
|
|
3261
|
+
madePlaceWarned.add(message);
|
|
3262
|
+
for (const key of [
|
|
3263
|
+
'guild',
|
|
3264
|
+
'guildId',
|
|
3265
|
+
'channel',
|
|
3266
|
+
'channelId',
|
|
3267
|
+
'member'
|
|
3268
|
+
]){
|
|
3269
|
+
const descriptor = Object.getOwnPropertyDescriptor(message, key);
|
|
3270
|
+
if (!descriptor) continue;
|
|
3271
|
+
let value = descriptor.value;
|
|
3272
|
+
Object.defineProperty(message, key, {
|
|
3273
|
+
get: ()=>{
|
|
3274
|
+
if (readingPlace === 0) {
|
|
3275
|
+
controller_decorator.warnOnce(mockLogger, `Message.${key} reads the place createMockMessage() made for this message here, though the interaction it was ` + "given to is placed elsewhere; in the next major version (5.0) the message moves to the interaction's place. " + 'Give the message a channel or guild, or call useStrictMocks() to move it now.');
|
|
3276
|
+
}
|
|
3277
|
+
readingPlace++;
|
|
3278
|
+
try {
|
|
3279
|
+
return descriptor.get ? descriptor.get.call(message) : value;
|
|
3280
|
+
} finally{
|
|
3281
|
+
readingPlace--;
|
|
3282
|
+
}
|
|
3283
|
+
},
|
|
3284
|
+
set: (next)=>{
|
|
3285
|
+
if (descriptor.set) descriptor.set.call(message, next);
|
|
3286
|
+
else value = next;
|
|
3287
|
+
},
|
|
3288
|
+
enumerable: true,
|
|
3289
|
+
configurable: true
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
// The DM channel each message the bot sent in a DM in default mode was sent in, where strict mocks place it
|
|
3294
|
+
const sentInDm = new WeakMap();
|
|
3295
|
+
/** Whether a message is one whose channel and server the mock made, which a test placing an interaction overrides. */ const placeWasMade = (message)=>placesMade.has(mockTargets.get(message) ?? message);
|
|
3296
|
+
function strictPlaceOf(own) {
|
|
3297
|
+
const message = own('message');
|
|
3298
|
+
const channel = own('channel');
|
|
3299
|
+
const guild = own('guild');
|
|
3300
|
+
const guildId = own('guildId');
|
|
3301
|
+
const member = own('member');
|
|
3302
|
+
// A message whose place the mock made has none a test meant, so where the test placed the interaction wins
|
|
3303
|
+
const placed = channel !== undefined || guild !== undefined || guildId !== undefined;
|
|
3304
|
+
if (typeof message === 'object' && message !== null && typeof message.channelId === 'string' && !(placed && placeWasMade(message))) {
|
|
3305
|
+
// One the bot sent in a DM is in that DM, as strict mocks place it, though default mode reads a server for it
|
|
3306
|
+
const dm = sentInDm.get(mockTargets.get(message) ?? message);
|
|
3307
|
+
const channelId = dm ? dm.id : message.channelId;
|
|
3308
|
+
const at = dm ? null : message.guildId ?? null;
|
|
3309
|
+
const refused = channel && channel.id !== channelId ? `The message given is in channel ${channelId}, but the mock's channel is ${channel.id}: give the message's channel, or leave one of them out.` : guild !== undefined && (guild?.id ?? null) !== at ? `The message given is in ${placeName(at)}, but the mock's guild is ${placeName(guild?.id ?? null)}: give the message's guild, or leave one of them out.` : guildId !== undefined && guildId !== at ? `The message given is in ${placeName(at)}, but the mock's guildId is ${placeName(guildId)}: give the message's guildId, or leave one of them out.` : undefined;
|
|
3310
|
+
return {
|
|
3311
|
+
given: 'message',
|
|
3312
|
+
channel: dm ?? message.channel,
|
|
3313
|
+
guildId: at,
|
|
3314
|
+
guild: dm ? null : message.guild ?? null,
|
|
3315
|
+
refused
|
|
3316
|
+
};
|
|
3317
|
+
}
|
|
3318
|
+
if (guild instanceof discord_js.Guild && guildId === undefined) return {
|
|
3319
|
+
given: 'guild',
|
|
3320
|
+
guildId: guild.id
|
|
3321
|
+
};
|
|
3322
|
+
if (member instanceof discord_js.GuildMember && member.guild instanceof discord_js.Guild && guild === undefined && guildId === undefined) {
|
|
3323
|
+
return {
|
|
3324
|
+
given: 'member',
|
|
3325
|
+
guild: member.guild,
|
|
3326
|
+
guildId: member.guild.id
|
|
3327
|
+
};
|
|
3328
|
+
}
|
|
3329
|
+
if (typeof guildId === 'string' && guild === undefined && channel === undefined && !(member instanceof discord_js.GuildMember) && !isRawMember(member)) {
|
|
3330
|
+
return {
|
|
3331
|
+
given: 'guildId',
|
|
3332
|
+
raw: true
|
|
3333
|
+
};
|
|
3334
|
+
}
|
|
3335
|
+
return undefined;
|
|
3336
|
+
}
|
|
3337
|
+
// Set while one of the reads below runs, so what it reads in turn doesn't warn again
|
|
3338
|
+
let readingPlace = 0;
|
|
3339
|
+
/** Runs `read` as the mock's own read of where a mock is, which warns about no placement. */ function readPlaceQuietly(read) {
|
|
3340
|
+
readingPlace++;
|
|
3341
|
+
try {
|
|
3342
|
+
return read();
|
|
3343
|
+
} finally{
|
|
3344
|
+
readingPlace--;
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
// Each interaction's strict place and its warning, by the read it names, for the mock's own reads that warn as the test's
|
|
3348
|
+
const placeWarners = new WeakMap();
|
|
3349
|
+
// The place keys a read of the mock's own read while it runs, so it warns by its own name where one would warn
|
|
3350
|
+
let placeReadsSeen;
|
|
3351
|
+
/**
|
|
3352
|
+
* Runs `read`, a value of `mock`'s own that reads where the mock is, quietly; in default mode it warns once, as `key`,
|
|
3353
|
+
* where one of those reads would warn, since strict mocks place the mock otherwise, unless `differs` says the value is
|
|
3354
|
+
* the same in that place.
|
|
3355
|
+
*/ function readPlaceAs(mock, key, read, differs = ()=>true) {
|
|
3356
|
+
const outer = placeReadsSeen;
|
|
3357
|
+
const seen = placeReadsSeen = new Set();
|
|
3358
|
+
try {
|
|
3359
|
+
return readPlaceQuietly(read);
|
|
3360
|
+
} finally{
|
|
3361
|
+
placeReadsSeen = outer;
|
|
3362
|
+
const warner = placeWarners.get(mockTargets.get(mock) ?? mock);
|
|
3363
|
+
if (seen.size > 0 && warner && differs(warner.place)) warner.warn(key);
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
/** What reading `key`, where strict mocks place an interaction otherwise, warns: what it reads now, and in 5.0. */ function placeWarning(name, key, place) {
|
|
3367
|
+
if (place.refused) {
|
|
3368
|
+
return `${name}.${key} reads where the mock's channel, guild or guildId put it, not where its message is; under useStrictMocks(), as in the next major version (5.0), the mock is refused: ${place.refused}`;
|
|
3369
|
+
}
|
|
3370
|
+
const why = {
|
|
3371
|
+
message: [
|
|
3372
|
+
'a place other than its message\'s',
|
|
3373
|
+
"the mock is in its message's channel and server",
|
|
3374
|
+
"Give it the message's channel, or call useStrictMocks() to place it there now."
|
|
3375
|
+
],
|
|
3376
|
+
guild: [
|
|
3377
|
+
"a DM's value, though the mock was given a guild",
|
|
3378
|
+
'the mock is in that guild',
|
|
3379
|
+
'Give its guildId or one of its channels too, or call useStrictMocks() to place it there now.'
|
|
3380
|
+
],
|
|
3381
|
+
member: [
|
|
3382
|
+
"a DM's value, though the mock was given a member of a server",
|
|
3383
|
+
"the mock is in the member's server",
|
|
3384
|
+
"Give the member's guild too, or call useStrictMocks() to place it there now."
|
|
3385
|
+
],
|
|
3386
|
+
guildId: [
|
|
3387
|
+
'a server the bot is in, though the mock was given only a guildId',
|
|
3388
|
+
"a guildId alone is a server the bot isn't in: guild and channel are null, and the member is a raw one",
|
|
3389
|
+
'Give a guild too, or call useStrictMocks() to read it so now.'
|
|
3390
|
+
]
|
|
3391
|
+
}[place.given];
|
|
3392
|
+
return `${name}.${key} reads ${why[0]} here; in the next major version (5.0) ${why[1]}. ${why[2]}`;
|
|
3393
|
+
}
|
|
3394
|
+
/** Has a read of `instance`'s `key` call `warn` first, and what that read reads in turn stay quiet. */ function warnOnRead(instance, key, warn) {
|
|
3395
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
3396
|
+
// One the mock answers with a stub stays unset, as the mock's own checks read it, and warns as the stub is read
|
|
3397
|
+
if (!descriptor) {
|
|
3398
|
+
if (!stubReadWarnings.has(instance)) stubReadWarnings.set(instance, new Map());
|
|
3399
|
+
stubReadWarnings.get(instance).set(key, warn);
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
const read = ()=>descriptor.get ? descriptor.get.call(instance) : descriptor.value;
|
|
3403
|
+
Object.defineProperty(instance, key, {
|
|
3404
|
+
get: ()=>{
|
|
3405
|
+
warn();
|
|
3406
|
+
readingPlace++;
|
|
3407
|
+
try {
|
|
3408
|
+
return read();
|
|
3409
|
+
} finally{
|
|
3410
|
+
readingPlace--;
|
|
3411
|
+
}
|
|
3412
|
+
},
|
|
3413
|
+
set: (next)=>Object.defineProperty(instance, key, {
|
|
3414
|
+
value: next,
|
|
3415
|
+
writable: true,
|
|
3416
|
+
enumerable: true,
|
|
3417
|
+
configurable: true
|
|
3418
|
+
}),
|
|
3419
|
+
enumerable: true,
|
|
3420
|
+
configurable: true
|
|
3421
|
+
});
|
|
3422
|
+
}
|
|
3423
|
+
/**
|
|
3424
|
+
* Has each of an interaction's `guildId`, `guild`, `channelId`, `channel` and `member` that strict mocks read otherwise
|
|
3425
|
+
* warn once when read, unless the test gave it.
|
|
3426
|
+
*/ function warnOnPlaceReads(instance, place, given) {
|
|
3427
|
+
const value = (key)=>{
|
|
3428
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
3429
|
+
return descriptor?.get ? undefined : descriptor?.value;
|
|
3430
|
+
};
|
|
3431
|
+
const keys = place.refused ? [
|
|
3432
|
+
'guildId',
|
|
3433
|
+
'guild',
|
|
3434
|
+
'channelId',
|
|
3435
|
+
'channel'
|
|
3436
|
+
] : place.raw ? [
|
|
3437
|
+
'guild',
|
|
3438
|
+
'channel',
|
|
3439
|
+
'member'
|
|
3440
|
+
] : place.guildId !== value('guildId') ? [
|
|
3441
|
+
'guildId',
|
|
3442
|
+
'guild',
|
|
3443
|
+
'channelId',
|
|
3444
|
+
'channel',
|
|
3445
|
+
'member'
|
|
3446
|
+
] : place.channel && place.channel.id !== value('channelId') ? [
|
|
3447
|
+
'channelId',
|
|
3448
|
+
'channel'
|
|
3449
|
+
] : [];
|
|
3450
|
+
const name = Object.getPrototypeOf(instance).constructor.name;
|
|
3451
|
+
placeWarners.set(instance, {
|
|
3452
|
+
place,
|
|
3453
|
+
keys,
|
|
3454
|
+
warn: (read)=>controller_decorator.warnOnce(mockLogger, placeWarning(name, read, place))
|
|
3455
|
+
});
|
|
3456
|
+
for (const key of keys.filter((each)=>!given.has(each))){
|
|
3457
|
+
// A read the mock makes stays quiet, noted for a read of the mock's own to warn by its name
|
|
3458
|
+
warnOnRead(instance, key, ()=>readingPlace > 0 ? void placeReadsSeen?.add(key) : controller_decorator.warnOnce(mockLogger, placeWarning(name, key, place)));
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
2513
3461
|
/**
|
|
2514
3462
|
* Where a channel a test gives puts the mock made in it, read from the channel as discord.js reads it: no server for a
|
|
2515
3463
|
* DM channel, else the server the channel names. A server channel that names none is put in `fallback`'s, and named
|
|
@@ -2554,9 +3502,11 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2554
3502
|
cached(cacheOf(manager), id, ()=>channel);
|
|
2555
3503
|
}
|
|
2556
3504
|
}
|
|
2557
|
-
/** The guild a mock message carries: a guild with the same stubbed managers as createMockGuild. */ function createMockGuildForMessage(id
|
|
3505
|
+
/** The guild a mock message carries: a guild with the same stubbed managers as createMockGuild. */ function createMockGuildForMessage(id) {
|
|
2558
3506
|
const guild = Object.create(discord_js.Guild.prototype);
|
|
2559
|
-
|
|
3507
|
+
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
3508
|
+
guild.id = id ?? generatedId;
|
|
3509
|
+
defineMadeTime(guild, generatedId);
|
|
2560
3510
|
guild.members = managerWith(discord_js.GuildMemberManager.prototype, undefined);
|
|
2561
3511
|
guild.channels = managerWith(discord_js.GuildChannelManager.prototype, undefined);
|
|
2562
3512
|
guild.roles = guildRoleManager(guild.id, undefined);
|
|
@@ -2565,6 +3515,23 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2565
3515
|
settleGuild(guild, proxy);
|
|
2566
3516
|
return proxy;
|
|
2567
3517
|
}
|
|
3518
|
+
// Each settled guild's bot member as `members.me` reads it, without caching one made for the read
|
|
3519
|
+
const botMembers = new WeakMap();
|
|
3520
|
+
/** The bot's member in `guild` for the mock's own reads: the one `members.me` reads, made without caching it there. */ function botMemberOf(guild) {
|
|
3521
|
+
return botMembers.get(guild)?.() ?? guild.members.me;
|
|
3522
|
+
}
|
|
3523
|
+
/**
|
|
3524
|
+
* The channel an interaction's `channel` reads, for the mock's own reads: `null` in a DM, else one set on it or its
|
|
3525
|
+
* server's cached one, without making or caching one. Undefined where `channel` would make one, which has no overwrites.
|
|
3526
|
+
*/ function heldChannelOf(interaction, guild) {
|
|
3527
|
+
const target = mockTargets.get(interaction) ?? interaction;
|
|
3528
|
+
return readPlaceQuietly(()=>{
|
|
3529
|
+
const set = Object.getOwnPropertyDescriptor(target, 'channel');
|
|
3530
|
+
if (set && 'value' in set) return set.value;
|
|
3531
|
+
const { guildId, channelId } = target;
|
|
3532
|
+
return guildId ? cacheOf(guild.channels)?.get(channelId) : null;
|
|
3533
|
+
});
|
|
3534
|
+
}
|
|
2568
3535
|
/** Gives a guild's managers the guild, as discord.js's do, so what they fetch or make is in it. */ function homeManagers(instance, guild) {
|
|
2569
3536
|
for (const key of [
|
|
2570
3537
|
'members',
|
|
@@ -2586,11 +3553,13 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2586
3553
|
configurable: true
|
|
2587
3554
|
});
|
|
2588
3555
|
const members = instance.members;
|
|
3556
|
+
const botMember = (keep)=>{
|
|
3557
|
+
const { user } = guild.client;
|
|
3558
|
+
return keep ? cached(cacheOf(members), user.id, ()=>memberIn(guild, user.id, user)) : cacheOf(members)?.get(user.id) ?? memberIn(guild, user.id, user);
|
|
3559
|
+
};
|
|
3560
|
+
botMembers.set(guild, ()=>botMember(false));
|
|
2589
3561
|
Object.defineProperty(members, 'me', {
|
|
2590
|
-
get: ()=>
|
|
2591
|
-
const { user } = guild.client;
|
|
2592
|
-
return cached(cacheOf(members), user.id, ()=>memberIn(guild, user.id, user));
|
|
2593
|
-
},
|
|
3562
|
+
get: ()=>botMember(true),
|
|
2594
3563
|
configurable: true
|
|
2595
3564
|
});
|
|
2596
3565
|
for (const key of [
|
|
@@ -2664,8 +3633,11 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2664
3633
|
};
|
|
2665
3634
|
// Own values, so the prototype's getters, which read the raw mention data, are not reached
|
|
2666
3635
|
const instance = Object.create(discord_js.MessageMentions.prototype);
|
|
3636
|
+
const parsedUsers = new discord_js.Collection(mentioned.users);
|
|
2667
3637
|
for (const [key, value] of Object.entries({
|
|
2668
3638
|
...mentioned,
|
|
3639
|
+
parsedUsers,
|
|
3640
|
+
crosspostedChannels: new discord_js.Collection(),
|
|
2669
3641
|
everyone: false,
|
|
2670
3642
|
client,
|
|
2671
3643
|
guild: guild ?? null
|
|
@@ -2678,11 +3650,14 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2678
3650
|
return stubDeep(instance);
|
|
2679
3651
|
}
|
|
2680
3652
|
/** A value as its API JSON: a builder or discord.js structure through `toJSON()`, anything else as it is. */ const jsonOf = (value)=>value && typeof value.toJSON === 'function' ? value.toJSON() : value;
|
|
2681
|
-
/**
|
|
3653
|
+
/**
|
|
3654
|
+
* The message a reply method starts from: the one a component is on, as it was, or an empty original response. Without
|
|
3655
|
+
* `allocate`, one with no id of its own gets `''`, for its holder to give it one when first read.
|
|
3656
|
+
*/ function heldFrom(message, allocate = true) {
|
|
2682
3657
|
// Read only what the message really has: a mock's unset fields are stubs
|
|
2683
3658
|
const from = message ?? {};
|
|
2684
3659
|
return {
|
|
2685
|
-
id: typeof from.id === 'string' ? from.id : nextSnowflake(),
|
|
3660
|
+
id: typeof from.id === 'string' ? from.id : allocate ? nextSnowflake() : '',
|
|
2686
3661
|
content: typeof from.content === 'string' ? from.content : undefined,
|
|
2687
3662
|
components: Array.isArray(from.components) ? from.components.map(jsonOf) : [],
|
|
2688
3663
|
embeds: Array.isArray(from.embeds) ? from.embeds.map(jsonOf) : [],
|
|
@@ -2690,6 +3665,15 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2690
3665
|
editedTimestamp: typeof from.editedTimestamp === 'number' ? from.editedTimestamp : null
|
|
2691
3666
|
};
|
|
2692
3667
|
}
|
|
3668
|
+
/** A message as an answer sends it: a new one, with what the answer gave, never edited. */ const sent = (options, allocate = true)=>{
|
|
3669
|
+
// Its id taken only once it builds, so a payload that fails to build takes none
|
|
3670
|
+
const held = edited(heldFrom(undefined, false), options);
|
|
3671
|
+
return {
|
|
3672
|
+
...held,
|
|
3673
|
+
id: allocate ? nextSnowflake() : '',
|
|
3674
|
+
editedTimestamp: null
|
|
3675
|
+
};
|
|
3676
|
+
};
|
|
2693
3677
|
/** A held message after an edit: what the edit sets replaced in Discord's shape, and a later edit stamp. */ function edited(held, options) {
|
|
2694
3678
|
const edit = typeof options === 'string' ? {
|
|
2695
3679
|
content: options
|
|
@@ -2718,29 +3702,28 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2718
3702
|
editedTimestamp: Math.max(Date.now(), (held.editedTimestamp ?? 0) + 1)
|
|
2719
3703
|
};
|
|
2720
3704
|
}
|
|
2721
|
-
/** A mock message showing a held message. */ function messageFrom(held) {
|
|
2722
|
-
return
|
|
3705
|
+
/** A mock message showing a held message, one the bot sent in `channel` by `client`, with what `made` keeps. */ function messageFrom(held, made, channel, client) {
|
|
3706
|
+
return sentMessage(held, channel, client, {
|
|
2723
3707
|
id: held.id,
|
|
2724
|
-
...held.content !== undefined && {
|
|
2725
|
-
content: held.content
|
|
2726
|
-
},
|
|
2727
|
-
components: held.components,
|
|
2728
|
-
embeds: held.embeds,
|
|
2729
|
-
...held.flags !== undefined && {
|
|
2730
|
-
flags: held.flags
|
|
2731
|
-
},
|
|
2732
3708
|
editedTimestamp: held.editedTimestamp
|
|
2733
|
-
});
|
|
3709
|
+
}, made);
|
|
2734
3710
|
}
|
|
2735
3711
|
/**
|
|
2736
|
-
* A component or embed as a message holds it: a discord.js instance as it is
|
|
2737
|
-
* its API JSON at the time of the call,
|
|
2738
|
-
*/ function asHeld(value) {
|
|
3712
|
+
* A component or embed as a message holds it: a discord.js instance as it is, anything else as the discord.js class
|
|
3713
|
+
* built from its API JSON at the time of the call, as discord.js builds it, an unknown type as a plain `Component`.
|
|
3714
|
+
*/ function asHeld(value, kind) {
|
|
2739
3715
|
if (value instanceof discord_js.Component || value instanceof discord_js.Embed) return value;
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
3716
|
+
// A copy, since discord.js's classes keep what they are given, one level deep
|
|
3717
|
+
const json = structuredClone(jsonOf(value));
|
|
3718
|
+
if (kind === 'embed') return new discord_js.Embed(json);
|
|
3719
|
+
// An action row builds each child with discord.js's own createComponent, which it doesn't export
|
|
3720
|
+
const row = new discord_js.ActionRow({
|
|
3721
|
+
type: discord_js.ComponentType.ActionRow,
|
|
3722
|
+
components: [
|
|
3723
|
+
json
|
|
3724
|
+
]
|
|
3725
|
+
});
|
|
3726
|
+
return row.components[0];
|
|
2744
3727
|
}
|
|
2745
3728
|
/**
|
|
2746
3729
|
* Creates a mock {@link Message} that tracks whether it has been deleted.
|
|
@@ -2749,12 +3732,14 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2749
3732
|
* `@MessageHandler` does not skip it.
|
|
2750
3733
|
*
|
|
2751
3734
|
* @remarks
|
|
2752
|
-
* `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted
|
|
2753
|
-
* and `reply()`
|
|
2754
|
-
*
|
|
2755
|
-
* one
|
|
2756
|
-
*
|
|
2757
|
-
*
|
|
3735
|
+
* `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted. `edit()` changes
|
|
3736
|
+
* this message and resolves to it, and `reply()` resolves to the reply, sent in this message's channel and holding what
|
|
3737
|
+
* was sent, as a channel's `send()` does. Under `useStrictMocks()` a message the bot sends is its `client.user`'s, and
|
|
3738
|
+
* one sent in a DM is in that DM. Without overrides the message is empty, in a server, from a new person who is its
|
|
3739
|
+
* `member`; with `guild: null` it is a direct message. Give `author` to send several messages as one user, such as to
|
|
3740
|
+
* reach a per-user cooldown. An author given, and the users, roles and channels the content mentions, are cached as the
|
|
3741
|
+
* gateway delivers them. It is typed as discord.js emits a message, never in a group DM, so it fits `messageCreate` and
|
|
3742
|
+
* the other message events.
|
|
2758
3743
|
*
|
|
2759
3744
|
* @param overrides - The message's content, components and the rest; see {@link MockMessageOverrides}.
|
|
2760
3745
|
*
|
|
@@ -2777,11 +3762,18 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2777
3762
|
* @see {@link MockMessageOverrides}
|
|
2778
3763
|
* @see {@link createMockGuild}
|
|
2779
3764
|
*/ function createMockMessage(overrides = {}) {
|
|
3765
|
+
return messageMock(overrides);
|
|
3766
|
+
}
|
|
3767
|
+
function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
3768
|
+
var _made, _made1, _made2;
|
|
2780
3769
|
const instance = Object.create(discord_js.Message.prototype);
|
|
2781
3770
|
const stubs = new Map();
|
|
3771
|
+
const unplaced = overrides.channel === undefined && overrides.guild === undefined;
|
|
3772
|
+
// A message the bot sent is placed where it was sent, even where default mode reads a server of its own for a DM
|
|
3773
|
+
if (unplaced && !sentBy) placesMade.add(instance);
|
|
2782
3774
|
instance.deleted = false;
|
|
2783
3775
|
// Constructor-assigned — set as prototype-based stubs; a user rather than a bot, as dispatch handles only those
|
|
2784
|
-
instance.author = overrides.author ?? mockUser();
|
|
3776
|
+
instance.author = overrides.author ?? (_made = made).author ?? (_made.author = mockUser());
|
|
2785
3777
|
// Getters on the prototype, which the proxy would answer with a stub object: own properties shadow them
|
|
2786
3778
|
// A channel given says where the message was sent, unless its guild is given too
|
|
2787
3779
|
const place = overrides.channel ? placeOf(overrides.channel, overrides.guild === undefined ? undefined : overrides.guild?.id ?? null, ()=>{
|
|
@@ -2791,22 +3783,37 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2791
3783
|
guild
|
|
2792
3784
|
};
|
|
2793
3785
|
}) : undefined;
|
|
2794
|
-
const guild = overrides.guild !== undefined ? overrides.guild : place?.guildId !== undefined ? place.guildId === null ? null : place.guild ?? createMockGuildForMessage(place.guildId) : createMockGuildForMessage();
|
|
2795
|
-
const
|
|
2796
|
-
|
|
3786
|
+
const guild = overrides.guild !== undefined ? overrides.guild : place?.guildId !== undefined ? place.guildId === null ? null : place.guild ?? createMockGuildForMessage(place.guildId) : unplaced ? (_made1 = made).guild ?? (_made1.guild = createMockGuildForMessage()) : createMockGuildForMessage();
|
|
3787
|
+
const madeChannel = ()=>channelFor(guild, guild?.id ?? null, nextSnowflake(), instance.author);
|
|
3788
|
+
const channel = at ? undefined : overrides.channel ?? (unplaced ? (_made2 = made).channel ?? (_made2.channel = madeChannel()) : madeChannel());
|
|
3789
|
+
if (channel) Object.defineProperty(instance, 'channel', {
|
|
2797
3790
|
value: channel,
|
|
2798
|
-
writable: true
|
|
3791
|
+
writable: true,
|
|
3792
|
+
configurable: true
|
|
2799
3793
|
});
|
|
3794
|
+
else {
|
|
3795
|
+
Object.defineProperty(instance, 'channel', {
|
|
3796
|
+
get: at.channel,
|
|
3797
|
+
set: (value)=>Object.defineProperty(instance, 'channel', {
|
|
3798
|
+
value,
|
|
3799
|
+
writable: true,
|
|
3800
|
+
configurable: true
|
|
3801
|
+
}),
|
|
3802
|
+
configurable: true
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
2800
3805
|
Object.defineProperty(instance, 'guild', {
|
|
2801
3806
|
value: guild,
|
|
2802
|
-
writable: true
|
|
3807
|
+
writable: true,
|
|
3808
|
+
configurable: true
|
|
2803
3809
|
});
|
|
2804
3810
|
// The author as a member of the message's server; a direct message has none. A given author's member is the one
|
|
2805
3811
|
// the server caches, as the gateway resolves it, so every message from that author has the same member
|
|
2806
|
-
// The author's member, cached as the gateway caches it with the message; `member` reads the cache, as discord.js
|
|
3812
|
+
// The author's member, cached as the gateway caches it with the message; `member` reads the cache, as discord.js
|
|
3813
|
+
// does, without calling the manager's resolve a test may stub
|
|
2807
3814
|
if (guild) memberFor(guild, instance.author, true);
|
|
2808
3815
|
Object.defineProperty(instance, 'member', {
|
|
2809
|
-
get: ()=>guild ? guild.members
|
|
3816
|
+
get: ()=>instance.guild ? cacheOf(instance.guild.members)?.get(instance.author.id) ?? null : null,
|
|
2810
3817
|
set: (value)=>Object.defineProperty(instance, 'member', {
|
|
2811
3818
|
value,
|
|
2812
3819
|
writable: true,
|
|
@@ -2817,8 +3824,8 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2817
3824
|
configurable: true
|
|
2818
3825
|
});
|
|
2819
3826
|
// In a server's text channel, the ids matching the objects; with no guild, a DM
|
|
2820
|
-
instance.channelId = channel.
|
|
2821
|
-
instance.guildId = guild?.id ?? null;
|
|
3827
|
+
instance.channelId = channel?.id ?? at.channelId;
|
|
3828
|
+
instance.guildId = at ? at.guildId : guild?.id ?? null;
|
|
2822
3829
|
// The thread its channel caches under its id, as discord.js reads it; until 5.0, a placeholder thread otherwise
|
|
2823
3830
|
let placeholderThread;
|
|
2824
3831
|
Object.defineProperty(instance, 'thread', {
|
|
@@ -2848,7 +3855,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2848
3855
|
configurable: true
|
|
2849
3856
|
});
|
|
2850
3857
|
if (guild instanceof discord_js.Guild) cached(cacheOf(client.guilds), guild.id, ()=>guild);
|
|
2851
|
-
cacheChannel(channel, guild, client);
|
|
3858
|
+
if (channel) cacheChannel(channel, guild, client);
|
|
2852
3859
|
const userCache = cacheOf(client.users);
|
|
2853
3860
|
for (const user of [
|
|
2854
3861
|
...overrides.users ?? [],
|
|
@@ -2858,8 +3865,8 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2858
3865
|
instance.mentions = mentionsOf(overrides.content, client, guild);
|
|
2859
3866
|
// Data a message always has, real rather than stubbed, so code reading it sees an empty message
|
|
2860
3867
|
instance.flags = new discord_js.MessageFlagsBitField(overrides.flags);
|
|
2861
|
-
instance.components = (overrides.components ?? []).map(asHeld);
|
|
2862
|
-
instance.embeds = (overrides.embeds ?? []).map(asHeld);
|
|
3868
|
+
instance.components = (overrides.components ?? []).map((component)=>asHeld(component, 'component'));
|
|
3869
|
+
instance.embeds = (overrides.embeds ?? []).map((embed)=>asHeld(embed, 'embed'));
|
|
2863
3870
|
instance.attachments = new discord_js.Collection();
|
|
2864
3871
|
instance.editedTimestamp = overrides.editedTimestamp ?? null;
|
|
2865
3872
|
const generatedId = overrides.id === undefined ? nextSnowflake() : undefined;
|
|
@@ -2872,14 +3879,27 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2872
3879
|
if (instance.deleted) throw alreadyDeleted();
|
|
2873
3880
|
instance.deleted = true;
|
|
2874
3881
|
}));
|
|
2875
|
-
|
|
3882
|
+
// An edit changes this message, as Discord edits it in place, and resolves to it
|
|
3883
|
+
stubs.set('edit', createMockFn(async (options)=>{
|
|
2876
3884
|
if (instance.deleted) throw alreadyDeleted();
|
|
2877
|
-
|
|
3885
|
+
const held = edited(heldFrom(undefined, false), options);
|
|
3886
|
+
const edit = typeof options === 'string' ? {
|
|
3887
|
+
content: options
|
|
3888
|
+
} : options ?? {};
|
|
3889
|
+
if (edit.content !== undefined) instance.content = held.content;
|
|
3890
|
+
if (edit.components !== undefined) instance.components = held.components.map((component)=>asHeld(component, 'component'));
|
|
3891
|
+
if (edit.embeds !== undefined) instance.embeds = held.embeds.map((embed)=>asHeld(embed, 'embed'));
|
|
3892
|
+
if (edit.flags !== undefined) instance.flags = new discord_js.MessageFlagsBitField(held.flags);
|
|
3893
|
+
instance.editedTimestamp = Math.max(Date.now(), (instance.editedTimestamp ?? 0) + 1);
|
|
3894
|
+
return message;
|
|
2878
3895
|
}));
|
|
2879
|
-
stubs.set('reply', createMockFn(async ()=>{
|
|
3896
|
+
stubs.set('reply', createMockFn(async (options)=>{
|
|
2880
3897
|
if (instance.deleted) throw alreadyDeleted();
|
|
2881
|
-
return
|
|
3898
|
+
return sentMessage(sent(options, false), instance.channel, instance.client);
|
|
2882
3899
|
}));
|
|
3900
|
+
// Crossposting publishes this message, and forwarding sends one in the channel it is forwarded to
|
|
3901
|
+
stubs.set('crosspost', createMockFn(async ()=>message));
|
|
3902
|
+
stubs.set('forward', createMockFn(async (channel)=>sentMessage(sent(undefined, false), channel, instance.client)));
|
|
2883
3903
|
stubs.set('react', createMockFn(async ()=>{
|
|
2884
3904
|
if (instance.deleted) throw alreadyDeleted();
|
|
2885
3905
|
}));
|
|
@@ -2889,7 +3909,85 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2889
3909
|
stubs.set('unpin', createMockFn(async ()=>{
|
|
2890
3910
|
if (instance.deleted) throw alreadyDeleted();
|
|
2891
3911
|
}));
|
|
2892
|
-
|
|
3912
|
+
if (sentBy) warnOnSentReads(instance, stubs, sentBy);
|
|
3913
|
+
const message = stubDeep(instance, stubs);
|
|
3914
|
+
return message;
|
|
3915
|
+
}
|
|
3916
|
+
/**
|
|
3917
|
+
* Has a message the bot sent warn once where strict mocks read it otherwise: its author, the bot's `client.user` under
|
|
3918
|
+
* strict mocks, and, for one sent in a DM, the server it reads as being in.
|
|
3919
|
+
*/ function warnOnSentReads(instance, stubs, sentBy) {
|
|
3920
|
+
// A read made while reading another of these, as `member` reads `author`, warns only for the outer one
|
|
3921
|
+
let reading = 0;
|
|
3922
|
+
const read = (key, warning)=>{
|
|
3923
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
3924
|
+
Object.defineProperty(instance, key, {
|
|
3925
|
+
get: ()=>{
|
|
3926
|
+
if (reading === 0) controller_decorator.warnOnce(mockLogger, warning);
|
|
3927
|
+
reading++;
|
|
3928
|
+
try {
|
|
3929
|
+
return descriptor?.get ? descriptor.get.call(instance) : descriptor?.value;
|
|
3930
|
+
} finally{
|
|
3931
|
+
reading--;
|
|
3932
|
+
}
|
|
3933
|
+
},
|
|
3934
|
+
set: (next)=>Object.defineProperty(instance, key, {
|
|
3935
|
+
value: next,
|
|
3936
|
+
writable: true,
|
|
3937
|
+
enumerable: true,
|
|
3938
|
+
configurable: true
|
|
3939
|
+
}),
|
|
3940
|
+
enumerable: true,
|
|
3941
|
+
configurable: true
|
|
3942
|
+
});
|
|
3943
|
+
};
|
|
3944
|
+
if (sentBy.bot) {
|
|
3945
|
+
read('author', "Message.author reads a user here, though the bot sent this message; in the next major version (5.0) it is the bot's " + "client.user, as Discord sends it. Call useStrictMocks() to read client.user now.");
|
|
3946
|
+
}
|
|
3947
|
+
if (!sentBy.dm) return;
|
|
3948
|
+
const inDm = (what)=>`Message.${what} reads a server here, though this message was sent in a DM; in the next major version (5.0) it reads ` + "as a DM's, with no server. Call useStrictMocks() to read it so now.";
|
|
3949
|
+
const inGuild = Boolean(instance.guildId);
|
|
3950
|
+
for (const key of [
|
|
3951
|
+
'guild',
|
|
3952
|
+
'guildId',
|
|
3953
|
+
'member'
|
|
3954
|
+
])read(key, inDm(key));
|
|
3955
|
+
stubs.set('inGuild', createMockFn(()=>(controller_decorator.warnOnce(mockLogger, inDm('inGuild()')), inGuild)));
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* A message the bot sent, as Discord returns it: in the channel it was sent in and that channel's server, holding what
|
|
3959
|
+
* was sent. Under strict mocks it is the bot's `client.user`'s, and one sent in a DM is in that DM; default mode places
|
|
3960
|
+
* a DM send in a server of its own and keeps another author, with a warning when read.
|
|
3961
|
+
*/ function sentMessage(held, channel, client, fields = {}, made) {
|
|
3962
|
+
const by = client ?? channel?.client;
|
|
3963
|
+
const inChannel = channel instanceof discord_js.BaseChannel;
|
|
3964
|
+
const dm = inChannel && channel.isDMBased();
|
|
3965
|
+
const strict = strictMocks();
|
|
3966
|
+
const message = messageMock({
|
|
3967
|
+
...held.content !== undefined && {
|
|
3968
|
+
content: held.content
|
|
3969
|
+
},
|
|
3970
|
+
components: held.components,
|
|
3971
|
+
embeds: held.embeds,
|
|
3972
|
+
...held.flags !== undefined && {
|
|
3973
|
+
flags: held.flags
|
|
3974
|
+
},
|
|
3975
|
+
...inChannel && (!dm || strict) && {
|
|
3976
|
+
channel: channel
|
|
3977
|
+
},
|
|
3978
|
+
...by && {
|
|
3979
|
+
client: by
|
|
3980
|
+
},
|
|
3981
|
+
...strict && by?.user && {
|
|
3982
|
+
author: by.user
|
|
3983
|
+
},
|
|
3984
|
+
...fields
|
|
3985
|
+
}, strict ? undefined : {
|
|
3986
|
+
bot: Boolean(by?.user),
|
|
3987
|
+
dm
|
|
3988
|
+
}, made);
|
|
3989
|
+
if (dm && !strict) sentInDm.set(mockTargets.get(message) ?? message, channel);
|
|
3990
|
+
return message;
|
|
2893
3991
|
}
|
|
2894
3992
|
/** The option type Discord would have sent for a given JavaScript value. */ function optionTypeOf(value) {
|
|
2895
3993
|
if (typeof value === 'boolean') return discord_js.ApplicationCommandOptionType.Boolean;
|
|
@@ -2978,11 +4076,32 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2978
4076
|
else if (isEntity) option.user = value;
|
|
2979
4077
|
return option;
|
|
2980
4078
|
}
|
|
4079
|
+
/**
|
|
4080
|
+
* The option an autocomplete's user is typing, as Discord sends it: its value a string, `''` before anything is typed.
|
|
4081
|
+
* A number given for it is its digits under strict mocks; otherwise it stays a number, with a warning when read.
|
|
4082
|
+
*/ function focusedOptionData(name, value) {
|
|
4083
|
+
const option = toOptionData(name, value ?? '');
|
|
4084
|
+
option.focused = true;
|
|
4085
|
+
if (typeof value === 'number') {
|
|
4086
|
+
if (strictMocks()) option.value = String(value);
|
|
4087
|
+
else {
|
|
4088
|
+
Object.defineProperty(option, 'value', {
|
|
4089
|
+
get: ()=>{
|
|
4090
|
+
controller_decorator.warnOnce(mockLogger, `The focused option "${name}" reads the number ${value} here, where Discord sends a string; in the next major version (5.0) ` + `the mock gives it as one. Give it as a string, such as '${value}', or call useStrictMocks() to have the mock give it now.`);
|
|
4091
|
+
return value;
|
|
4092
|
+
},
|
|
4093
|
+
enumerable: true
|
|
4094
|
+
});
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
return option;
|
|
4098
|
+
}
|
|
2981
4099
|
/**
|
|
2982
4100
|
* Nests the supplied options under the subcommand path they were invoked through,
|
|
2983
4101
|
* matching the shape Discord sends rather than a flat list.
|
|
2984
|
-
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf) {
|
|
2985
|
-
const leaves = Object.entries(values).map(([name, value])=>toOptionData(name, value, memberOf));
|
|
4102
|
+
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf, focused) {
|
|
4103
|
+
const leaves = Object.entries(values).map(([name, value])=>name === focused ? focusedOptionData(name, value) : toOptionData(name, value, memberOf));
|
|
4104
|
+
if (focused !== null && !Object.hasOwn(values, focused)) leaves.push(focusedOptionData(focused, null));
|
|
2986
4105
|
if (subcommand === null) return leaves;
|
|
2987
4106
|
const sub = {
|
|
2988
4107
|
name: subcommand,
|
|
@@ -3022,6 +4141,10 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3022
4141
|
* A whole number is an Integer option and a fraction a Number one, so
|
|
3023
4142
|
* `getInteger()` reads only a whole number, and `getNumber()` reads either.
|
|
3024
4143
|
*
|
|
4144
|
+
* The `focused` option reads as Discord sends it, through `getFocused()`, `getFocused(true)` and `data` alike: a
|
|
4145
|
+
* string, `''` when no value is given, and marked `focused` in `data`. A number given for it is its digits under
|
|
4146
|
+
* `useStrictMocks()`; otherwise it stays the number, with a warning when read.
|
|
4147
|
+
*
|
|
3025
4148
|
* @typeParam Cached - `any` by default, to match `createMockInteraction(ChatInputCommandInteraction)`; pass it when the
|
|
3026
4149
|
* interaction under test is cache-pinned.
|
|
3027
4150
|
*
|
|
@@ -3142,15 +4265,19 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3142
4265
|
], ()=>false, required));
|
|
3143
4266
|
base.getFocused = createMockFn((getFull)=>{
|
|
3144
4267
|
if (focused === null) throw optionError(discord_js.DiscordjsErrorCodes.AutocompleteInteractionOptionNoFocusedOption);
|
|
3145
|
-
|
|
4268
|
+
// The one options.data holds, so the two agree
|
|
4269
|
+
const option = base._hoistedOptions.find((each)=>each.focused);
|
|
3146
4270
|
return getFull === true ? {
|
|
3147
|
-
...option
|
|
3148
|
-
focused: true
|
|
4271
|
+
...option
|
|
3149
4272
|
} : option.value;
|
|
3150
4273
|
});
|
|
3151
4274
|
// `data` is what the framework reads to build a handler's params, so it is materialised here rather than
|
|
3152
4275
|
// auto-stubbed, or every params assertion would see an empty record.
|
|
3153
|
-
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf);
|
|
4276
|
+
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf, focused);
|
|
4277
|
+
// The fields discord.js's constructor sets from it, which its toString() reads
|
|
4278
|
+
base._group = subcommandGroup;
|
|
4279
|
+
base._subcommand = subcommand;
|
|
4280
|
+
base._hoistedOptions = subcommand === null ? base.data : (subcommandGroup === null ? base.data[0] : base.data[0].options[0]).options;
|
|
3154
4281
|
const resolver = stubDeep(base);
|
|
3155
4282
|
return resolver;
|
|
3156
4283
|
}
|
|
@@ -3224,7 +4351,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3224
4351
|
await this.resolving;
|
|
3225
4352
|
if (options.ready) {
|
|
3226
4353
|
const { client = createMockClient(), primary = true } = options.ready === true ? {} : options.ready;
|
|
3227
|
-
this.readying ?? (this.readying = this.runReady(client, primary));
|
|
4354
|
+
this.readying ?? (this.readying = this.readyPass.run(()=>this.runReady(client, primary)));
|
|
3228
4355
|
await this.readying;
|
|
3229
4356
|
}
|
|
3230
4357
|
return this;
|
|
@@ -3239,12 +4366,21 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3239
4366
|
* It first gives up the theme read outside calls, if `init({ ready: true })` made it this module's;
|
|
3240
4367
|
* reads outside calls then return MeoCord's defaults until another module or app is ready.
|
|
3241
4368
|
*
|
|
4369
|
+
* A stopped bot runs nothing more. Once `close()` is called, a module still runs what `dispatch`, `invoke` and `emit`
|
|
4370
|
+
* are given, against services shut down, or shutting down, and warns once that it is closed, so a `close()` in the
|
|
4371
|
+
* wrong place shows.
|
|
4372
|
+
*
|
|
3242
4373
|
* The cooldown store and what it injects shut down last, in the same sequence as the bot's. When any of them has an
|
|
3243
4374
|
* `onShutdown`, the calls `invoke`, `dispatch` and `emit` have under way finish first, then the store operations
|
|
3244
4375
|
* they started, so the store's last writes still reach it. The module stops waiting for the whole sequence after
|
|
3245
4376
|
* its `shutdownTimeout`, 10 seconds unless set, and logs that it did. Give a test whose fake store never answers, or
|
|
3246
4377
|
* whose `onShutdown` never settles, a short `shutdownTimeout`.
|
|
3247
4378
|
*
|
|
4379
|
+
* A `close()` while `init({ ready: true })` runs the `onReady` hooks waits for them first, within the same
|
|
4380
|
+
* `shutdownTimeout`. An `onReady` still running once only the hooks' share of it is left is named in a warning, and
|
|
4381
|
+
* its class is shut down all the same, as everything the module constructed is. One that calls `close()` itself isn't
|
|
4382
|
+
* waited for.
|
|
4383
|
+
*
|
|
3248
4384
|
* @returns Once every hook has run. Rejects with the error of a hook that failed, or an
|
|
3249
4385
|
* `AggregateError` naming each when several did.
|
|
3250
4386
|
*
|
|
@@ -3259,14 +4395,18 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3259
4395
|
* expect(module.get(POOL).ended).toBe(true)
|
|
3260
4396
|
* ```
|
|
3261
4397
|
*/ async close() {
|
|
4398
|
+
// An onReady awaiting close() would otherwise hold up the close that waits for it
|
|
4399
|
+
this.readyPass.excuseCurrent();
|
|
3262
4400
|
this.closing ?? (this.closing = (async ()=>{
|
|
3263
|
-
// A close during init waits for the
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
.
|
|
4401
|
+
// A close during init waits for the providers it started, and the sequence for its onReady hooks, so it shuts
|
|
4402
|
+
// down whatever they constructed
|
|
4403
|
+
await this.resolving?.catch(()=>undefined);
|
|
4404
|
+
const entries = ()=>this.lifecycle.filter((unit)=>this.constructed.has(unit.token))// Already made, so this returns the instance; a provided value may be anything, null included
|
|
4405
|
+
.map((unit)=>startupRoots.lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
|
|
3267
4406
|
const failures = [];
|
|
3268
4407
|
await startupRoots.runShutdownSequence(this.container, entries, {
|
|
3269
4408
|
drainCalls: ()=>startupRoots.callsSettled(this.calls),
|
|
4409
|
+
readyPass: this.readyPass,
|
|
3270
4410
|
timeoutMs: this.shutdownTimeout,
|
|
3271
4411
|
hookFailed: (name, error)=>failures.push({
|
|
3272
4412
|
name,
|
|
@@ -3280,6 +4420,11 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3280
4420
|
})());
|
|
3281
4421
|
await this.closing;
|
|
3282
4422
|
}
|
|
4423
|
+
/** Warns once, after `close()`, that a call runs against services already shut down. */ warnIfClosed() {
|
|
4424
|
+
if (this.closing === undefined || this.warnedClosed) return;
|
|
4425
|
+
this.warnedClosed = true;
|
|
4426
|
+
new controller_decorator.Logger('TestingModule').warn('This testing module is closed, and its services are shut down, or shutting down, so dispatch, invoke and emit run ' + 'against that state. Close it after the last call, in afterEach.');
|
|
4427
|
+
}
|
|
3283
4428
|
async runReady(client, primary) {
|
|
3284
4429
|
// As the bot claims it before it comes online, so onReady reads the app's theme
|
|
3285
4430
|
providers.claimAmbientAppTheme(this.container);
|
|
@@ -3295,6 +4440,8 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3295
4440
|
}, [], {
|
|
3296
4441
|
resolveFailed: failed,
|
|
3297
4442
|
hookFailed: failed
|
|
4443
|
+
}, {
|
|
4444
|
+
runOnReady: this.readyPass.runOnReady
|
|
3298
4445
|
});
|
|
3299
4446
|
throwFailures('onReady', failures);
|
|
3300
4447
|
}
|
|
@@ -3366,7 +4513,9 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3366
4513
|
* @returns Whether the handler ran, and the error a filter handled, if any. Rejects with an error no
|
|
3367
4514
|
* filter handles, or with the error a filter throws: the built-in fallback, which answers such
|
|
3368
4515
|
* errors in the bot, does not run here. Rejects before running anything with an interaction or a
|
|
3369
|
-
* message the handler's route does not match, naming both.
|
|
4516
|
+
* message the handler's route does not match, naming both. Given fewer arguments than the handler
|
|
4517
|
+
* declares, with a first that is neither an interaction nor a message, such as a reaction without its
|
|
4518
|
+
* `ReactionEvent`, it rejects under `useStrictMocks()`, and otherwise warns once and runs the handler.
|
|
3370
4519
|
*
|
|
3371
4520
|
* @example
|
|
3372
4521
|
* ```ts
|
|
@@ -3388,6 +4537,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3388
4537
|
* expect(outcome.error).toBeInstanceOf(GuardDeniedError)
|
|
3389
4538
|
* ```
|
|
3390
4539
|
*/ async invoke(controller, methodName, ...args) {
|
|
4540
|
+
this.warnIfClosed();
|
|
3391
4541
|
await this.init();
|
|
3392
4542
|
if (!this.controllers.includes(controller)) {
|
|
3393
4543
|
throw new Error(`${controller.name} is not a controller of this testing module. Add it to \`controllers\`.`);
|
|
@@ -3417,6 +4567,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3417
4567
|
hooks = providers.messageCommandHooks(route, params, first, start, given, this.messageOptions.types);
|
|
3418
4568
|
}
|
|
3419
4569
|
}
|
|
4570
|
+
this.checkArgumentCount(controller, methodName, instance[methodName], first, callArgs.length);
|
|
3420
4571
|
const presenter = providers.appPresenterOf(this.container);
|
|
3421
4572
|
// A message command's errors are drawn by the app's presenter too, as an interaction's are
|
|
3422
4573
|
const client = first instanceof discord_js.BaseInteraction || first instanceof discord_js.Message ? first.client : undefined;
|
|
@@ -3433,6 +4584,19 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3433
4584
|
error
|
|
3434
4585
|
};
|
|
3435
4586
|
}
|
|
4587
|
+
/**
|
|
4588
|
+
* Refuses under `useStrictMocks()`, or warns once otherwise, a call that gives a handler fewer arguments than it
|
|
4589
|
+
* declares when `invoke` builds none for it, as for a reaction given without its `ReactionEvent`.
|
|
4590
|
+
*/ checkArgumentCount(controller, methodName, method, first, given) {
|
|
4591
|
+
if (first instanceof discord_js.BaseInteraction || first instanceof discord_js.Message || method.length <= given) return;
|
|
4592
|
+
const reaction = controller_decorator.getReactionHandlers(controller.prototype).some((handler)=>handler.method === methodName);
|
|
4593
|
+
const problem = `${controller.name}.${methodName} takes ${method.length} arguments, and invoke was given ${given}: ` + (reaction ? 'pass its ReactionEvent after the reaction, as { user, action }.' : 'pass each argument it takes.');
|
|
4594
|
+
if (strictMocks()) throw new Error(problem);
|
|
4595
|
+
const key = `${controller.name}.${methodName}`;
|
|
4596
|
+
if (this.warnedArgumentCounts.has(key)) return;
|
|
4597
|
+
this.warnedArgumentCounts.add(key);
|
|
4598
|
+
new controller_decorator.Logger('TestingModule').warn(problem);
|
|
4599
|
+
}
|
|
3436
4600
|
/** Counts `call` as under way until it settles. */ track(call) {
|
|
3437
4601
|
this.calls.add(call);
|
|
3438
4602
|
void call.then(()=>this.calls.delete(call), ()=>this.calls.delete(call));
|
|
@@ -3478,6 +4642,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3478
4642
|
return this.dispatcher;
|
|
3479
4643
|
}
|
|
3480
4644
|
async dispatch(input, options) {
|
|
4645
|
+
this.warnIfClosed();
|
|
3481
4646
|
await this.init();
|
|
3482
4647
|
this.registerClient(input);
|
|
3483
4648
|
const dispatcher = this.dispatcherOf();
|
|
@@ -3495,6 +4660,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3495
4660
|
unhandled: (error)=>unhandled.push(error)
|
|
3496
4661
|
};
|
|
3497
4662
|
if (options) {
|
|
4663
|
+
noteReactionDispatch(input);
|
|
3498
4664
|
await this.track(dispatcher.reaction(input, {
|
|
3499
4665
|
user: options.user,
|
|
3500
4666
|
action: options.action ?? enum_index.ReactionHandlerAction.ADD
|
|
@@ -3524,15 +4690,20 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3524
4690
|
};
|
|
3525
4691
|
}
|
|
3526
4692
|
/**
|
|
3527
|
-
* Emits a client event to the module's `@On` and `@Once` handlers, through the
|
|
3528
|
-
*
|
|
3529
|
-
* module's controllers, class providers and their dependencies all receive it. A
|
|
3530
|
-
* handles only the first event, as it would on a client.
|
|
4693
|
+
* Emits a client event to the module's `@On` and `@Once` handlers, through the pipeline the app runs
|
|
4694
|
+
* them in up to its event fallback: the global guards of the module's `app`, then each handler's own.
|
|
4695
|
+
* Handlers on the module's controllers, class providers and their dependencies all receive it. A
|
|
4696
|
+
* `@Once` handler handles only the first event, as it would on a client.
|
|
4697
|
+
*
|
|
4698
|
+
* The bot's event fallback doesn't run here. On a bot, a guard's `GuardDeniedError` is logged and the
|
|
4699
|
+
* event skipped, and a handler's `UserError` is logged and, when the event carries a message, answered
|
|
4700
|
+
* as a reply to it; here both reject `emit` as any error does, so a test checks them with `rejects`.
|
|
3531
4701
|
*
|
|
3532
4702
|
* @param event - The client event, such as `'guildMemberAdd'`.
|
|
3533
4703
|
* @param args - The event's arguments, typed from discord.js's `ClientEvents`.
|
|
3534
|
-
* @returns How many handlers ran. Rejects once every handler has settled if any threw
|
|
3535
|
-
*
|
|
4704
|
+
* @returns How many handlers ran. Rejects once every handler has settled if any threw, a guard's
|
|
4705
|
+
* `GuardDeniedError` and a handler's `UserError` included: with that error when one handler failed,
|
|
4706
|
+
* or an `AggregateError` of them when several did.
|
|
3536
4707
|
*
|
|
3537
4708
|
* @example
|
|
3538
4709
|
* ```ts
|
|
@@ -3544,6 +4715,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3544
4715
|
* expect(ran).toBe(1)
|
|
3545
4716
|
* ```
|
|
3546
4717
|
*/ async emit(event, ...args) {
|
|
4718
|
+
this.warnIfClosed();
|
|
3547
4719
|
await this.init();
|
|
3548
4720
|
const calls = [];
|
|
3549
4721
|
for (const cls of this.eventClasses){
|
|
@@ -3588,7 +4760,10 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3588
4760
|
this.appWarnUnanswered = appWarnUnanswered;
|
|
3589
4761
|
this.services = services;
|
|
3590
4762
|
this.shutdownTimeout = shutdownTimeout;
|
|
4763
|
+
/** The `onReady` hooks' pass, which `close()` waits for, less one whose `onReady` called `close()`. */ this.readyPass = new startupRoots.ReadyPass();
|
|
4764
|
+
this.warnedClosed = false;
|
|
3591
4765
|
/** The `@Once` handlers that have already handled their event, as a client forgets its once listeners. */ this.firedOnce = new Set();
|
|
4766
|
+
/** The handlers already warned about for being given fewer arguments than they take, so each is named once. */ this.warnedArgumentCounts = new Set();
|
|
3592
4767
|
/** The calls under way, each removed once it settles: what `close()` lets finish before the cooldown store stops. */ this.calls = new Set();
|
|
3593
4768
|
}
|
|
3594
4769
|
}
|
|
@@ -3653,6 +4828,10 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3653
4828
|
/**
|
|
3654
4829
|
* Replaces a guard with a stub wherever it applies, globally or on a controller or handler.
|
|
3655
4830
|
*
|
|
4831
|
+
* It stands in for the guard wherever the guard is bound, a provider of the same token included, and wins over an
|
|
4832
|
+
* `overrideProvider` of it. The stub needs a `canActivate`, its own or its prototype's: `compile()` refuses one without
|
|
4833
|
+
* it under `useStrictMocks()`, and otherwise warns.
|
|
4834
|
+
*
|
|
3656
4835
|
* @param guard - The guard class to replace.
|
|
3657
4836
|
* @example
|
|
3658
4837
|
* ```ts
|
|
@@ -3670,6 +4849,10 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3670
4849
|
* Replaces an interceptor with a stub wherever it applies, globally or on a controller or handler.
|
|
3671
4850
|
* The stub's `intercept` receives the context and `next`; call `next.handle()` to run the handler.
|
|
3672
4851
|
*
|
|
4852
|
+
* It stands in for the interceptor wherever it is bound, a provider of the same token included, and wins over an
|
|
4853
|
+
* `overrideProvider` of it. A stub without `intercept` is refused by `compile()` under `useStrictMocks()`, and
|
|
4854
|
+
* otherwise warned about.
|
|
4855
|
+
*
|
|
3673
4856
|
* @param interceptor - The interceptor class to replace.
|
|
3674
4857
|
* @example
|
|
3675
4858
|
* ```ts
|
|
@@ -3687,6 +4870,10 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3687
4870
|
* Replaces an exception filter with a stub wherever it applies. The filter's `@Catch` still decides
|
|
3688
4871
|
* which errors reach the stub.
|
|
3689
4872
|
*
|
|
4873
|
+
* It stands in for the filter wherever it is bound, a provider of the same token included, and wins over an
|
|
4874
|
+
* `overrideProvider` of it. A stub without `catch` is refused by `compile()` under `useStrictMocks()`, and otherwise
|
|
4875
|
+
* warned about.
|
|
4876
|
+
*
|
|
3690
4877
|
* @param filter - The filter class to replace.
|
|
3691
4878
|
* @example
|
|
3692
4879
|
* ```ts
|
|
@@ -3819,6 +5006,39 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3819
5006
|
const providers$1 = providers.providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
|
|
3820
5007
|
for (const [token, provider] of providers.providerMap(this.options.providers ?? [], "the testing module's providers"))providers$1.set(token, provider);
|
|
3821
5008
|
for (const [token, override] of this.overrides)providers$1.set(token, override);
|
|
5009
|
+
// A stage override stands in for its class wherever the class is bound. A stage also provided takes the stub in
|
|
5010
|
+
// place of its provider, one binding per token, and runs lifecycle hooks as that provider would; any other is
|
|
5011
|
+
// bound on its own below, as the bot never makes a lifecycle unit of it. A stub that can't run is named here
|
|
5012
|
+
const stageStubs = new Map();
|
|
5013
|
+
for (const [method, overrides, kind] of [
|
|
5014
|
+
[
|
|
5015
|
+
'canActivate',
|
|
5016
|
+
this.guardOverrides,
|
|
5017
|
+
'overrideGuard'
|
|
5018
|
+
],
|
|
5019
|
+
[
|
|
5020
|
+
'intercept',
|
|
5021
|
+
this.interceptorOverrides,
|
|
5022
|
+
'overrideInterceptor'
|
|
5023
|
+
],
|
|
5024
|
+
[
|
|
5025
|
+
'catch',
|
|
5026
|
+
this.filterOverrides,
|
|
5027
|
+
'overrideFilter'
|
|
5028
|
+
]
|
|
5029
|
+
]){
|
|
5030
|
+
for (const [stage, stub] of overrides){
|
|
5031
|
+
if (providers$1.has(stage)) providers$1.set(stage, {
|
|
5032
|
+
provide: stage,
|
|
5033
|
+
useValue: stub
|
|
5034
|
+
});
|
|
5035
|
+
else stageStubs.set(stage, stub);
|
|
5036
|
+
if (typeof stub[method] === 'function') continue;
|
|
5037
|
+
const problem = `${kind}(${stage.name}).useValue(…) has no ${method} method.`;
|
|
5038
|
+
if (strictMocks()) controller_decorator.startupError(new Error(problem));
|
|
5039
|
+
else controller_decorator.warnOnce(new controller_decorator.Logger('TestingModule'), `${problem} A call that reaches ${stage.name} fails; useStrictMocks() refuses it here.`);
|
|
5040
|
+
}
|
|
5041
|
+
}
|
|
3822
5042
|
// Each pass of checks ends before the next step that is no check, so a module with one error stops where it always did
|
|
3823
5043
|
controller_decorator.stopOnStartupErrors();
|
|
3824
5044
|
const services = this.wiring?.services ?? [];
|
|
@@ -3830,12 +5050,13 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3830
5050
|
const themeResolver = controller_decorator.themeResolverClass(stages?.themeFor?.resolvers);
|
|
3831
5051
|
// Every class the module runs, as the app finds them, except stage classes an override stands in for
|
|
3832
5052
|
const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
|
|
5053
|
+
const appPresenterClass = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter;
|
|
3833
5054
|
const startup = startupRoots.startupClasses({
|
|
3834
5055
|
controllers: this.options.controllers ?? [],
|
|
3835
5056
|
services,
|
|
3836
5057
|
providers: providers$1,
|
|
3837
5058
|
stages,
|
|
3838
|
-
presenter:
|
|
5059
|
+
presenter: appPresenterClass,
|
|
3839
5060
|
cooldownStore: store,
|
|
3840
5061
|
themeFor: stages?.themeFor?.resolvers,
|
|
3841
5062
|
observers: [
|
|
@@ -3866,16 +5087,8 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3866
5087
|
throw new Error(`${needClient.map((cls)=>cls.name).join(', ')} ${needClient.length === 1 ? 'injects' : 'inject'} the Discord Client, ` + 'which a testing module does not make: give one in its providers, such as ' + '{ provide: Client, useValue: createMockClient() }.');
|
|
3867
5088
|
});
|
|
3868
5089
|
}
|
|
3869
|
-
//
|
|
3870
|
-
for (const [
|
|
3871
|
-
container.bind(guardClass).toConstantValue(stub);
|
|
3872
|
-
}
|
|
3873
|
-
for (const [filterClass, stub] of this.filterOverrides){
|
|
3874
|
-
container.bind(filterClass).toConstantValue(stub);
|
|
3875
|
-
}
|
|
3876
|
-
for (const [interceptorClass, stub] of this.interceptorOverrides){
|
|
3877
|
-
container.bind(interceptorClass).toConstantValue(stub);
|
|
3878
|
-
}
|
|
5090
|
+
// A stage override nothing provides, bound before the classes so none of them binds the stage itself
|
|
5091
|
+
for (const [stage, stub] of stageStubs)container.bind(stage).toConstantValue(stub);
|
|
3879
5092
|
// The app's cooldown policy, so a test of a failing store sees what the bot would do
|
|
3880
5093
|
if (appOptions && !container.isBound(providers.COOLDOWN_POLICY)) container.bind(providers.COOLDOWN_POLICY).toConstantValue(providers.cooldownPolicyFrom(appOptions));
|
|
3881
5094
|
// The app's translator, unless a provider stands in for it
|
|
@@ -3949,6 +5162,9 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3949
5162
|
providers.bindObservers(container, observers);
|
|
3950
5163
|
// The store calls ask: the test's own when it provides CooldownStore, else the app's
|
|
3951
5164
|
const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
|
|
5165
|
+
// What the stages and presenter inject, as the bot makes it; a stage the test replaced contributes nothing
|
|
5166
|
+
const stageServices = startupRoots.stageDependencies(startup, providers$1, appPresenterClass);
|
|
5167
|
+
for (const service of stageServices)bindClass(service);
|
|
3952
5168
|
// The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
|
|
3953
5169
|
// after what it injects
|
|
3954
5170
|
const lifecycle = providers.resolutionOrder(container, providers$1, [
|
|
@@ -3957,6 +5173,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3957
5173
|
] : [],
|
|
3958
5174
|
...providers$1.keys(),
|
|
3959
5175
|
...services,
|
|
5176
|
+
...stageServices,
|
|
3960
5177
|
...themeResolver ? [
|
|
3961
5178
|
themeResolver
|
|
3962
5179
|
] : [],
|
|
@@ -3977,6 +5194,11 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3977
5194
|
...this.options.controllers ?? [],
|
|
3978
5195
|
...startup.classes
|
|
3979
5196
|
])Reflect.defineMetadata(controller_decorator.META.container, container, cls);
|
|
5197
|
+
// And each instance with the module that made it, as another module made from the same classes stamps them again
|
|
5198
|
+
providers.recordInstanceContainers(container, [
|
|
5199
|
+
...this.options.controllers ?? [],
|
|
5200
|
+
...startup.classes
|
|
5201
|
+
]);
|
|
3980
5202
|
providers.prepareThemes(container, appClasses);
|
|
3981
5203
|
// A handler with no builder is how a fixture is written, so only what is always a mistake is named
|
|
3982
5204
|
startupRoots.warnUnregisteredCommands(this.options.controllers ?? [], {
|
|
@@ -4050,7 +5272,15 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
4050
5272
|
* @param options - The controllers, providers, observers and app the module is built from.
|
|
4051
5273
|
* @returns The builder, to override what the test replaces before `compile()`.
|
|
4052
5274
|
*/ static create(options) {
|
|
4053
|
-
|
|
5275
|
+
// A controller listed twice is one controller, as the bot and fromApp take it
|
|
5276
|
+
return new TestingModuleBuilder({
|
|
5277
|
+
...options,
|
|
5278
|
+
...options.controllers && {
|
|
5279
|
+
controllers: [
|
|
5280
|
+
...new Set(options.controllers)
|
|
5281
|
+
]
|
|
5282
|
+
}
|
|
5283
|
+
});
|
|
4054
5284
|
}
|
|
4055
5285
|
/**
|
|
4056
5286
|
* Starts building a testing module from a whole `@MeoCord` app, wired as the bot wires it.
|
|
@@ -4357,7 +5587,15 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
|
|
|
4357
5587
|
return new Fields(components);
|
|
4358
5588
|
}
|
|
4359
5589
|
|
|
4360
|
-
const
|
|
5590
|
+
const PLURAL_CATEGORIES = new Set([
|
|
5591
|
+
'zero',
|
|
5592
|
+
'one',
|
|
5593
|
+
'two',
|
|
5594
|
+
'few',
|
|
5595
|
+
'many',
|
|
5596
|
+
'other'
|
|
5597
|
+
]);
|
|
5598
|
+
/** A plural: an object whose every key is a plural category, as the translator's types read one; any other is a group. */ const isPlural = (value)=>typeof value === 'object' && value !== null && Object.keys(value).length > 0 && Object.keys(value).every((key)=>PLURAL_CATEGORIES.has(key));
|
|
4361
5599
|
/** Every message key of a catalog, and whether it is a plural. */ function leaves(catalog, prefix = '') {
|
|
4362
5600
|
return Object.entries(catalog).flatMap(([name, value])=>{
|
|
4363
5601
|
const key = `${prefix}${name}`;
|
|
@@ -4376,8 +5614,9 @@ const isPlural = (value)=>typeof value === 'object' && value !== null && typeof
|
|
|
4376
5614
|
return leaves(value, `${key}.`);
|
|
4377
5615
|
});
|
|
4378
5616
|
}
|
|
4379
|
-
|
|
4380
|
-
|
|
5617
|
+
/** A translation's text, which an empty string isn't: the translator reads `''` as missing, as tools export one untranslated. */ const isText = (value)=>typeof value === 'string' && value !== '';
|
|
5618
|
+
/** The value at a key's path, through own properties only, so `constructor` or `toString` is not read off a prototype. */ function at(catalog, key) {
|
|
5619
|
+
return key.split('.').reduce((current, part)=>typeof current === 'object' && current !== null && Object.hasOwn(current, part) ? current[part] : undefined, catalog);
|
|
4381
5620
|
}
|
|
4382
5621
|
const MEOCORD_KEYS = new Set(leaves(controller_decorator.MEOCORD_MESSAGES).map(({ key })=>key));
|
|
4383
5622
|
const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
@@ -4400,9 +5639,11 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
4400
5639
|
*
|
|
4401
5640
|
* Use it in a test for a team that wants no fallback in production. It reports the messages a locale lacks, the
|
|
4402
5641
|
* messages the default catalog doesn't have, the plural forms a language needs but a plural lacks, such as `few` for
|
|
4403
|
-
* Russian,
|
|
4404
|
-
*
|
|
4405
|
-
*
|
|
5642
|
+
* Russian, a translation of another shape than the default's, a text for a plural or a plural for a text, and each
|
|
5643
|
+
* `{param}` a translation uses that the message it translates doesn't take. An empty translation, `''`, counts as
|
|
5644
|
+
* lacking, as the translator reads it as missing. It reads the catalogs' own strings, so it checks the params of a
|
|
5645
|
+
* catalog from a plain variable or a JSON file too, which the compiler can't. An object counts as a plural only when
|
|
5646
|
+
* its every key is a plural category, so a group with an `other` key is a group.
|
|
4406
5647
|
*
|
|
4407
5648
|
* @remarks
|
|
4408
5649
|
* MeoCord's own texts, a catalog's `meocord` group, fall back to English by design, so only a key MeoCord lacks, and
|
|
@@ -4449,12 +5690,17 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
4449
5690
|
const categories = new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
|
|
4450
5691
|
for (const { key, plural } of reference){
|
|
4451
5692
|
const message = at(catalog, key);
|
|
4452
|
-
if (message === undefined) {
|
|
5693
|
+
if (message === undefined || message === '') {
|
|
4453
5694
|
problems.push(`missing ${key}`);
|
|
4454
5695
|
continue;
|
|
4455
5696
|
}
|
|
4456
|
-
|
|
4457
|
-
|
|
5697
|
+
// A translation of another shape than the default's can't be read as it: a text for a plural, or the reverse
|
|
5698
|
+
if (plural !== isPlural(message) || !plural && typeof message !== 'string') {
|
|
5699
|
+
problems.push(`${key} should be a ${plural ? 'plural' : 'text'}, as the default is`);
|
|
5700
|
+
continue;
|
|
5701
|
+
}
|
|
5702
|
+
if (plural) {
|
|
5703
|
+
const missing = categories.filter((category)=>!isText(message[category]));
|
|
4458
5704
|
if (missing.length > 0) problems.push(`${key} lacks ${missing.join(', ')}`);
|
|
4459
5705
|
}
|
|
4460
5706
|
const original = at(defaultCatalog, key);
|
|
@@ -4462,7 +5708,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
4462
5708
|
for (const name of stray)problems.push(`${key} takes no {${name}}; the default is "${shown(original)}"`);
|
|
4463
5709
|
}
|
|
4464
5710
|
if (options.meocord && !locale.startsWith('en-')) {
|
|
4465
|
-
for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined) problems.push(`missing ${key}`);
|
|
5711
|
+
for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined || at(catalog, key) === '') problems.push(`missing ${key}`);
|
|
4466
5712
|
}
|
|
4467
5713
|
for (const { key } of leaves(catalog)){
|
|
4468
5714
|
if (isMeoCordKey(key)) {
|
|
@@ -4490,8 +5736,9 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4490
5736
|
*
|
|
4491
5737
|
* @remarks
|
|
4492
5738
|
* The cases cover what a shared store most often gets wrong: a sliding window rather than fixed buckets,
|
|
4493
|
-
* `retryAfterMs` from the oldest
|
|
4494
|
-
* calls at the limit where exactly one may pass
|
|
5739
|
+
* `retryAfterMs` until a use frees up, from the oldest of the newest `uses` calls in the window, calls in the same
|
|
5740
|
+
* millisecond kept apart, concurrent calls at the limit where exactly one may pass, a batch refusal that names the
|
|
5741
|
+
* longest wait, and a release that frees only the call it gave back. They use real time, with windows short enough that the suite takes
|
|
4495
5742
|
* a few seconds and margins wide enough that a correct store passes when timers fire late, as on a busy CI runner,
|
|
4496
5743
|
* and each case counts under keys of its own. Each window is a whole number of milliseconds, as
|
|
4497
5744
|
* `@Cooldown` gives a store: it rounds its `seconds` to the millisecond.
|
|
@@ -4566,6 +5813,30 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4566
5813
|
expect(retryAfterMs).toBeLessThanOrEqual(limit.windowMs - (secondSent - firstDone) + CLOCK_SLACK_MS);
|
|
4567
5814
|
expect(retryAfterMs).toBeGreaterThanOrEqual(limit.windowMs - (thirdDone - firstSent) - CLOCK_SLACK_MS);
|
|
4568
5815
|
});
|
|
5816
|
+
// @Cooldown keeps the calls counted so far when a limit is lowered, so a key can hold more calls than `uses`
|
|
5817
|
+
test('counts retryAfterMs from the oldest of the newest `uses` calls when a lowered limit leaves more in the window', async (store)=>{
|
|
5818
|
+
const windowMs = 2000;
|
|
5819
|
+
for(let call = 0; call < 3; call += 1)await store.consume(key('lowered'), {
|
|
5820
|
+
uses: 5,
|
|
5821
|
+
windowMs
|
|
5822
|
+
});
|
|
5823
|
+
await sleep(1000);
|
|
5824
|
+
const newerSent = Date.now();
|
|
5825
|
+
for(let call = 0; call < 2; call += 1)await store.consume(key('lowered'), {
|
|
5826
|
+
uses: 5,
|
|
5827
|
+
windowMs
|
|
5828
|
+
});
|
|
5829
|
+
const { allowed, retryAfterMs } = await store.consume(key('lowered'), {
|
|
5830
|
+
uses: 2,
|
|
5831
|
+
windowMs
|
|
5832
|
+
});
|
|
5833
|
+
const refusedDone = Date.now();
|
|
5834
|
+
expect(allowed).toBe(false);
|
|
5835
|
+
// A use frees up when the older of the two newest calls leaves the window; counted from the oldest call, it would
|
|
5836
|
+
// be a second sooner
|
|
5837
|
+
expect(retryAfterMs).toBeGreaterThanOrEqual(windowMs - (refusedDone - newerSent) - CLOCK_SLACK_MS);
|
|
5838
|
+
expect(retryAfterMs).toBeLessThanOrEqual(windowMs + CLOCK_SLACK_MS);
|
|
5839
|
+
});
|
|
4569
5840
|
test('counts each key on its own', async (store)=>{
|
|
4570
5841
|
const limit = {
|
|
4571
5842
|
uses: 1,
|
|
@@ -4627,6 +5898,41 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4627
5898
|
expect(refused.retryAfterMs).toBeGreaterThan(short.windowMs);
|
|
4628
5899
|
expect(refused.retryAfterMs).toBeLessThanOrEqual(long.windowMs);
|
|
4629
5900
|
});
|
|
5901
|
+
// A refusal names the wait until every cooldown of the batch allows the call; the default consumeMany stops at the first
|
|
5902
|
+
test('names the longest wait when several cooldowns refuse a batch, or with the default consumeMany, the first', async (store)=>{
|
|
5903
|
+
const short = {
|
|
5904
|
+
uses: 1,
|
|
5905
|
+
windowMs: 1000
|
|
5906
|
+
};
|
|
5907
|
+
const long = {
|
|
5908
|
+
uses: 1,
|
|
5909
|
+
windowMs: 2000
|
|
5910
|
+
};
|
|
5911
|
+
const batch = [
|
|
5912
|
+
{
|
|
5913
|
+
key: key('both-short'),
|
|
5914
|
+
limit: short
|
|
5915
|
+
},
|
|
5916
|
+
{
|
|
5917
|
+
key: key('both-long'),
|
|
5918
|
+
limit: long
|
|
5919
|
+
}
|
|
5920
|
+
];
|
|
5921
|
+
await store.consume(batch[0].key, short);
|
|
5922
|
+
await store.consume(batch[1].key, long);
|
|
5923
|
+
const refused = await store.consumeMany(batch);
|
|
5924
|
+
expect(refused.allowed).toBe(false);
|
|
5925
|
+
const [blocked, limit] = overridesConsumeMany(store) ? [
|
|
5926
|
+
1,
|
|
5927
|
+
long
|
|
5928
|
+
] : [
|
|
5929
|
+
0,
|
|
5930
|
+
short
|
|
5931
|
+
];
|
|
5932
|
+
expect(refused.blocked).toBe(blocked);
|
|
5933
|
+
expect(refused.retryAfterMs).toBeGreaterThan(limit.windowMs - 1000);
|
|
5934
|
+
expect(refused.retryAfterMs).toBeLessThanOrEqual(limit.windowMs);
|
|
5935
|
+
});
|
|
4630
5936
|
// The default consumeMany counts in order and stops at the first refusal, so the entries before it count
|
|
4631
5937
|
test('records nothing when one cooldown of a batch refuses it, or with the default consumeMany, counts those before it', async (store)=>{
|
|
4632
5938
|
const limit = {
|
|
@@ -4669,10 +5975,11 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4669
5975
|
}, ()=>store.consumeMany(batch)));
|
|
4670
5976
|
expect(verdicts.filter((verdict)=>verdict.allowed).length).toBe(1);
|
|
4671
5977
|
});
|
|
4672
|
-
// A store without release keeps a counted call counted, as the default consumeMany does
|
|
4673
|
-
|
|
5978
|
+
// A store without release keeps a counted call counted, as the default consumeMany does. Two uses tell a release of
|
|
5979
|
+
// the one call apart from one that frees the key's other calls too
|
|
5980
|
+
test('releases only the call it counted when the verdict gives release, or keeps it counted without one', async (store)=>{
|
|
4674
5981
|
const limit = {
|
|
4675
|
-
uses:
|
|
5982
|
+
uses: 2,
|
|
4676
5983
|
windowMs: 2000
|
|
4677
5984
|
};
|
|
4678
5985
|
const entries = [
|
|
@@ -4685,6 +5992,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4685
5992
|
limit
|
|
4686
5993
|
}
|
|
4687
5994
|
];
|
|
5995
|
+
expect((await store.consumeMany(entries)).allowed).toBe(true);
|
|
4688
5996
|
const counted = await store.consumeMany(entries);
|
|
4689
5997
|
expect(counted.allowed).toBe(true);
|
|
4690
5998
|
if (!counted.release) {
|