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.
Files changed (95) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-XKMlcUZz.cjs} +376 -100
  4. package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-BZgJyfZS.cjs} +145 -36
  5. package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-CgfPQrNl.cjs} +219 -63
  7. package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-BUAR1WYG.cjs} +10 -5
  8. package/dist/cjs/common/index.cjs +3 -3
  9. package/dist/cjs/core/index.cjs +185 -168
  10. package/dist/cjs/decorator/index.cjs +21 -6
  11. package/dist/cjs/testing/index.cjs +1594 -286
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/generator.js +4 -5
  14. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  15. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  16. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  17. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  18. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  19. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  20. package/dist/esm/bin/meocord.js +84 -25
  21. package/dist/esm/build/load-config.pre-entry.js +13 -3
  22. package/dist/esm/build/rsbuild-config.js +16 -12
  23. package/dist/esm/build/typed-externals.js +36 -0
  24. package/dist/esm/common/cooldown-store.js +10 -2
  25. package/dist/esm/common/errors.js +25 -0
  26. package/dist/esm/common/meocord-text.js +11 -8
  27. package/dist/esm/common/response/call-order.js +9 -4
  28. package/dist/esm/common/response/presenter.js +33 -6
  29. package/dist/esm/common/response/response-state.js +84 -10
  30. package/dist/esm/common/route.js +11 -4
  31. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  32. package/dist/esm/common/translator.js +37 -6
  33. package/dist/esm/core/command-registration.js +93 -24
  34. package/dist/esm/core/command-routes.js +2 -2
  35. package/dist/esm/core/cooldown-runner.js +29 -8
  36. package/dist/esm/core/fallback.js +20 -11
  37. package/dist/esm/core/guard-runner.js +16 -3
  38. package/dist/esm/core/handler-input.js +4 -3
  39. package/dist/esm/core/lifecycle-hooks.js +134 -47
  40. package/dist/esm/core/meocord-factory.js +55 -32
  41. package/dist/esm/core/meocord.app.js +116 -75
  42. package/dist/esm/core/providers.js +7 -2
  43. package/dist/esm/core/running-calls.js +43 -0
  44. package/dist/esm/core/shard-context.js +5 -6
  45. package/dist/esm/core/shard-manager.js +16 -10
  46. package/dist/esm/core/startup-roots.js +21 -1
  47. package/dist/esm/core/theme-resolvers.js +4 -1
  48. package/dist/esm/core/theme-scope.js +3 -1
  49. package/dist/esm/core/theme-validation.js +17 -10
  50. package/dist/esm/decorator/controller.decorator.js +56 -20
  51. package/dist/esm/decorator/cooldown.decorator.js +17 -0
  52. package/dist/esm/decorator/guard.decorator.js +3 -2
  53. package/dist/esm/package.json.js +1 -1
  54. package/dist/esm/testing/catalog.js +26 -10
  55. package/dist/esm/testing/cooldown-store-suite.js +67 -5
  56. package/dist/esm/testing/discord-defaults.js +205 -30
  57. package/dist/esm/testing/meocord-testing-module.js +134 -28
  58. package/dist/esm/testing/mock-fn.js +107 -26
  59. package/dist/esm/testing/mock-interaction.js +1026 -172
  60. package/dist/esm/testing/snowflake.js +9 -2
  61. package/dist/esm/testing/strict-mocks.js +37 -17
  62. package/dist/esm/util/common.util.js +47 -19
  63. package/dist/esm/util/dev-runner.util.js +8 -8
  64. package/dist/esm/util/generator-cli.util.js +4 -5
  65. package/dist/esm/util/interaction.util.js +2 -2
  66. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  67. package/dist/esm/util/meocord-config-validation.util.js +3 -5
  68. package/dist/esm/util/meocord-source-config.util.js +65 -20
  69. package/dist/esm/util/method-wrapper.util.js +16 -0
  70. package/dist/esm/util/platform.util.js +10 -10
  71. package/dist/esm/util/shard-process.util.js +4 -1
  72. package/dist/esm/util/sharding-mode.util.js +5 -7
  73. package/dist/esm/util/shared-state.util.js +21 -0
  74. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  75. package/dist/esm/util/tsconfig.util.js +10 -10
  76. package/dist/esm/util/value.util.js +12 -1
  77. package/dist/types/common/index.d.cts +6 -6
  78. package/dist/types/common/index.d.ts +6 -6
  79. package/dist/types/core/index.d.cts +3 -2
  80. package/dist/types/core/index.d.ts +3 -2
  81. package/dist/types/decorator/index.d.cts +1 -0
  82. package/dist/types/decorator/index.d.ts +1 -0
  83. package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
  84. package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
  85. package/dist/types/interface/index.d.cts +1 -1
  86. package/dist/types/interface/index.d.ts +1 -1
  87. package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
  88. package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
  89. package/dist/types/testing/index.d.cts +175 -53
  90. package/dist/types/testing/index.d.ts +175 -53
  91. package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  92. package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  93. package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
  94. package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
  95. 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-ChvMqnLd.cjs');
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-CRn9kWob.cjs');
9
- var startupRoots = require('../_shared/startup-roots-CQo8mSP8.cjs');
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-DhDVbT5M.cjs');
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
- // Every mock createMockFn made, held weakly so the mocks of a finished test can still be collected
53
- const created = new Set();
54
- const collected = new FinalizationRegistry((ref)=>created.delete(ref));
55
- // The runner's factory useMockFn registered, and for each of its mocks how to give back the implementation it was
56
- // created with, through the runner's own setter, which a mock's behaviour may wrap
57
- let runnerFactory;
58
- const restoreImpl = new WeakMap();
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 probe = factory();
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 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.');
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
- calls.length = 0;
289
- results.length = 0;
290
- instances.length = 0;
371
+ forgetCalls();
291
372
  return mockFn;
292
373
  };
293
374
  mockFn.mockReset = ()=>{
294
- calls.length = 0;
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
- // Whether useStrictMocks() is on, and whether a mock was made before it could be
411
- let strict = false;
412
- let mockMade = false;
413
- /** Whether mocks compute discord.js's state, as {@link useStrictMocks} turns on. */ const strictMocks = ()=>strict;
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, and
423
- * `partial` on users, channels and reactions then run discord.js's own getters against the mock, and `message.thread`
424
- * is `null` when the channel caches no thread under the message's id. No placeholder warning is logged.
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
- * the server's member cache from the start, @everyone has the permissions Discord gives it in a new server, and a
429
- * channel or thread made without a server has one of its own, a thread with a text channel as its parent. A message
430
- * has empty `content`, a thread is neither archived nor locked, and a reaction's `count` is 1. Give the bot's member
431
- * a role 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
432
- * computed one. The next major version (5.0) computes these values without the call.
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: snowflake,
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: snowflake,
736
+ commandId: nextSnowflake,
564
737
  commandGuildId: ()=>null
565
738
  }),
566
739
  defaultsOf(discord_js.AutocompleteInteraction, {
567
- commandId: snowflake,
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: snowflake,
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
- memberPermissions: (interaction)=>{
577
- const { guildId } = interaction;
578
- const member = interaction.member;
579
- if (!guildId) return null;
580
- if (member instanceof discord_js.GuildMember) return member.permissions;
581
- // A raw member's permissions are the string Discord sends, read as discord.js reads them
582
- return isRawMember(member) && !interaction.guild ? new discord_js.PermissionsBitField(BigInt(member.permissions)).freeze() : null;
583
- },
584
- // The bot's permissions where the interaction was made: its member's in the channel, or none outside a server
585
- appPermissions: (interaction)=>{
586
- const { guild, channel } = interaction;
587
- const me = guild instanceof discord_js.Guild ? guild.members.me : null;
588
- return (me && channel instanceof discord_js.GuildChannel ? channel.permissionsFor(me) : null) ?? noPermissions();
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 : snowflake(),
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: snowflake,
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)) return stubs.get(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
- warnBooleanPlaceholder(target, key);
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
- Object.defineProperty(target, prop, {
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
- function warnBooleanPlaceholder(target, key) {
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} = false, to test either way, or call useStrictMocks() to have the mock compute it now.`);
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, id ? {
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(id ? {
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(id ? {
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 && (key === 'create' || key === 'edit')) return createMockFn(async ()=>item(undefined, receiver()));
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
- /** Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order. */ const RESPONSE_LOG = Symbol('response log');
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. `withResponse` is how
1463
- * a call asks discord.js for the message back, not part of what it sends, so it is left out.
1464
- */ function recordedMock(method, impl, log) {
1465
- const record = (behaviour)=>function(...args) {
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
- call.error = error;
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 = nextSnowflake())=>stubDeep(Object.assign(Object.create(discord_js.User.prototype), {
1529
- id,
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) !== null) stubs.set(name, createMockFn(check));
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
- const alreadyReplied = ()=>new Error('The reply to this interaction has already been sent or deferred.');
1568
- const notYetReplied = (method)=>new Error(`Cannot call ${method}() before replying or deferring.`);
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
- if (!options) return false;
1573
- const { flags } = options;
1574
- if (typeof flags === 'number') return (flags & 64) !== 0;
1575
- if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
1576
- return false;
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 (...args)=>{
1585
- if (instance.deferred || instance.replied) throw alreadyReplied();
1586
- instance.replied = true;
1587
- if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
1588
- });
1589
- answer('deferReply', async (...args)=>{
1590
- if (instance.deferred || instance.replied) throw alreadyReplied();
1591
- instance.deferred = true;
1592
- if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
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
- return createMockMessage();
1598
- });
1599
- // The interaction's own message as Discord holds it: the message a component is on, or the original response.
1600
- // An edit replaces it in the shape Discord stores, with ids and resolved media, stamped with when it was edited,
1601
- // and fetchReply() reads that back, so code comparing the two sees what it would against Discord.
1602
- let held;
1603
- const current = ()=>held ?? (held = heldFrom(instance.message));
1604
- // A command answered with a modal has no original response, and Discord refuses one asked for
1605
- let modalAnswered = false;
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
- components: held.returned ?? held.components
2160
+ id
1616
2161
  });
1617
- });
1618
- stubs.set('fetchReply', createMockFn(async ()=>{
1619
- if (modalAnswered) throw unknownMessage();
1620
- return messageFrom(current());
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 (!instance.deferred && !instance.replied) throw notYetReplied('deleteReply');
1624
- if (modalAnswered) throw unknownMessage();
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 || instance.type === discord_js.InteractionType.MessageComponent) {
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 (instance.type === discord_js.InteractionType.MessageComponent || instance.type === discord_js.InteractionType.ModalSubmit) {
1636
- answer('update', async ()=>{
1637
- if (instance.deferred || instance.replied) throw alreadyReplied();
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 new Error('The reply to this interaction has already been sent or deferred.');
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: one of its server's, or the user's DM
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
- const nested = stubCallable();
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
- */ const createMockUser = (props = {})=>createMockInteraction(discord_js.User, {
1974
- id: nextSnowflake(),
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
- set: (value)=>own = value,
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
- instance.id = nextSnowflake();
2158
- if (overrides.id !== undefined) instance.id = overrides.id;
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
- instance.id = nextSnowflake();
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
- const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
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
- ...strictMocks() ? {
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. A DM sent to the member goes through its
2466
- * user's `send()` and the user's one DM channel.
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 = nextSnowflake()) {
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
- guild.id = id;
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
- /** The message a reply method starts from: the one a component is on, as it was, or an empty original response. */ function heldFrom(message) {
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 createMockMessage({
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; anything else as
2737
- * its API JSON at the time of the call, behind `toJSON()`.
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
- const json = structuredClone(typeof value.toJSON === 'function' ? value.toJSON() : value);
2741
- return {
2742
- toJSON: ()=>json
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, and `edit()`
2753
- * and `reply()` resolve to a new mock message. Without overrides the message is empty, in a server, from a new
2754
- * person who is its `member`; with `guild: null` it is a direct message. Give `author` to send several messages as
2755
- * one user, such as to reach a per-user cooldown. An author given, and the users, roles and channels the content
2756
- * mentions, are cached as the gateway delivers them. It is typed as discord.js emits a message, never in a group DM,
2757
- * so it fits `messageCreate` and the other message events.
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 channel = overrides.channel ?? channelFor(guild, guild?.id ?? null, nextSnowflake(), instance.author);
2796
- Object.defineProperty(instance, 'channel', {
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 does
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.resolve(instance.author) : null,
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.id;
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
- stubs.set('edit', createMockFn(async ()=>{
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
- return createMockMessage();
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 createMockMessage();
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
- return stubDeep(instance, stubs);
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
- const option = toOptionData(focused, values[focused] ?? null, memberOf);
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 hooks it started, so it shuts down whatever they constructed
3264
- await this.readying?.catch(()=>undefined);
3265
- 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
3266
- .map((unit)=>startupRoots.lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
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 same pipeline the app
3528
- * runs them in: the global guards of the module's `app`, then each handler's own. Handlers on the
3529
- * module's controllers, class providers and their dependencies all receive it. A `@Once` handler
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: with that
3535
- * error when one handler failed, or an `AggregateError` of them when several did.
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: this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.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
- // Bind guard overrides — prevents inversify from auto-wiring guard dependencies
3870
- for (const [guardClass, stub] of this.guardOverrides){
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
- return new TestingModuleBuilder(options);
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 isPlural = (value)=>typeof value === 'object' && value !== null && typeof value.other === 'string';
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
- function at(catalog, key) {
4380
- return key.split('.').reduce((current, part)=>current?.[part], catalog);
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, and each `{param}` a translation uses that the message it translates doesn't take. It reads the catalogs'
4404
- * own strings, so it checks the params of a catalog from a plain variable or a JSON file too, which the compiler
4405
- * can't.
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
- if (plural && isPlural(message)) {
4457
- const missing = categories.filter((category)=>typeof message[category] !== 'string');
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 call still in the window, calls in the same millisecond kept apart, and concurrent
4494
- * calls at the limit where exactly one may pass. They use real time, with windows short enough that the suite takes
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
- test('releases a call it counted when the verdict gives release, or keeps it counted without one', async (store)=>{
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: 1,
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) {