meocord 4.1.2 → 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 (140) hide show
  1. package/CHANGELOG.md +362 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
  4. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
  5. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
  7. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
  8. package/dist/cjs/common/index.cjs +4 -4
  9. package/dist/cjs/core/index.cjs +365 -310
  10. package/dist/cjs/decorator/index.cjs +85 -61
  11. package/dist/cjs/testing/index.cjs +2236 -496
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/app-template/README.md.template +3 -1
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  15. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  17. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  19. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  20. package/dist/esm/bin/generator.js +4 -5
  21. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  22. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  23. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  24. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  25. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  26. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  27. package/dist/esm/bin/meocord.js +84 -25
  28. package/dist/esm/build/load-config.pre-entry.js +17 -4
  29. package/dist/esm/build/rsbuild-config.js +16 -12
  30. package/dist/esm/build/typed-externals.js +36 -0
  31. package/dist/esm/common/cooldown-store.js +10 -2
  32. package/dist/esm/common/decorator.js +6 -6
  33. package/dist/esm/common/deprecation.js +2 -2
  34. package/dist/esm/common/errors.js +27 -2
  35. package/dist/esm/common/execution-context.js +1 -1
  36. package/dist/esm/common/meocord-text.js +11 -8
  37. package/dist/esm/common/metadata.js +1 -1
  38. package/dist/esm/common/response/call-order.js +9 -4
  39. package/dist/esm/common/response/presenter.js +33 -6
  40. package/dist/esm/common/response/response-state.js +84 -10
  41. package/dist/esm/common/route.js +21 -5
  42. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  43. package/dist/esm/common/translate-error.js +1 -1
  44. package/dist/esm/common/translator.js +37 -6
  45. package/dist/esm/core/command-conflicts.js +34 -34
  46. package/dist/esm/core/command-registration.js +93 -24
  47. package/dist/esm/core/command-routes.js +2 -2
  48. package/dist/esm/core/component-routes.js +74 -31
  49. package/dist/esm/core/cooldown-runner.js +29 -8
  50. package/dist/esm/core/dispatcher.js +51 -7
  51. package/dist/esm/core/fallback.js +20 -11
  52. package/dist/esm/core/filter-runner.js +2 -2
  53. package/dist/esm/core/guard-runner.js +16 -3
  54. package/dist/esm/core/handler-input.js +4 -3
  55. package/dist/esm/core/handler-pipeline.js +27 -14
  56. package/dist/esm/core/handler-registry.js +1 -1
  57. package/dist/esm/core/interceptor-runner.js +2 -2
  58. package/dist/esm/core/lifecycle-hooks.js +134 -47
  59. package/dist/esm/core/meocord-factory.js +241 -179
  60. package/dist/esm/core/meocord.app.js +116 -75
  61. package/dist/esm/core/message-routes.js +4 -3
  62. package/dist/esm/core/observer-runner.js +4 -3
  63. package/dist/esm/core/providers.js +45 -23
  64. package/dist/esm/core/route-specificity.js +4 -4
  65. package/dist/esm/core/running-calls.js +43 -0
  66. package/dist/esm/core/scalar-types.js +15 -3
  67. package/dist/esm/core/shard-context.js +6 -7
  68. package/dist/esm/core/shard-manager.js +16 -10
  69. package/dist/esm/core/startup-roots.js +23 -4
  70. package/dist/esm/core/theme-resolvers.js +4 -1
  71. package/dist/esm/core/theme-runtime.js +2 -2
  72. package/dist/esm/core/theme-scope.js +3 -1
  73. package/dist/esm/core/theme-validation.js +17 -10
  74. package/dist/esm/decorator/app.decorator.js +12 -7
  75. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  76. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  77. package/dist/esm/decorator/controller.decorator.js +162 -83
  78. package/dist/esm/decorator/cooldown.decorator.js +20 -3
  79. package/dist/esm/decorator/defer.decorator.js +4 -4
  80. package/dist/esm/decorator/event.decorator.js +5 -5
  81. package/dist/esm/decorator/filter.decorator.js +7 -7
  82. package/dist/esm/decorator/guard.decorator.js +11 -10
  83. package/dist/esm/decorator/inject.decorator.js +4 -4
  84. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  85. package/dist/esm/decorator/observer.decorator.js +4 -4
  86. package/dist/esm/decorator/service.decorator.js +4 -4
  87. package/dist/esm/decorator/theme.decorator.js +3 -3
  88. package/dist/esm/decorator/validation.decorator.js +10 -10
  89. package/dist/esm/package.json.js +1 -1
  90. package/dist/esm/testing/catalog.js +27 -11
  91. package/dist/esm/testing/cooldown-store-suite.js +85 -15
  92. package/dist/esm/testing/discord-defaults.js +249 -21
  93. package/dist/esm/testing/index.js +4 -2
  94. package/dist/esm/testing/inspect-handler.js +20 -3
  95. package/dist/esm/testing/meocord-testing-module.js +352 -205
  96. package/dist/esm/testing/mock-fn.js +173 -22
  97. package/dist/esm/testing/mock-interaction.js +1214 -209
  98. package/dist/esm/testing/response.js +1 -1
  99. package/dist/esm/testing/routing.js +39 -14
  100. package/dist/esm/testing/snowflake.js +9 -2
  101. package/dist/esm/testing/startup-errors.js +26 -0
  102. package/dist/esm/testing/strict-mocks.js +65 -0
  103. package/dist/esm/testing/theme.js +1 -1
  104. package/dist/esm/util/bundle-entry.util.js +2 -1
  105. package/dist/esm/util/common.util.js +47 -19
  106. package/dist/esm/util/dev-runner.util.js +8 -8
  107. package/dist/esm/util/generator-cli.util.js +4 -5
  108. package/dist/esm/util/interaction.util.js +3 -3
  109. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  110. package/dist/esm/util/meocord-config-validation.util.js +4 -5
  111. package/dist/esm/util/meocord-source-config.util.js +65 -20
  112. package/dist/esm/util/metadata-keys.js +1 -0
  113. package/dist/esm/util/method-wrapper.util.js +16 -0
  114. package/dist/esm/util/platform.util.js +10 -10
  115. package/dist/esm/util/refusal.util.js +148 -8
  116. package/dist/esm/util/shard-process.util.js +4 -1
  117. package/dist/esm/util/sharding-mode.util.js +5 -7
  118. package/dist/esm/util/shared-state.util.js +21 -0
  119. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  120. package/dist/esm/util/tsconfig.util.js +10 -10
  121. package/dist/esm/util/value.util.js +12 -1
  122. package/dist/types/common/index.d.cts +8 -8
  123. package/dist/types/common/index.d.ts +8 -8
  124. package/dist/types/core/index.d.cts +10 -7
  125. package/dist/types/core/index.d.ts +10 -7
  126. package/dist/types/decorator/index.d.cts +31 -29
  127. package/dist/types/decorator/index.d.ts +31 -29
  128. package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
  129. package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
  130. package/dist/types/interface/index.d.cts +1 -1
  131. package/dist/types/interface/index.d.ts +1 -1
  132. package/dist/types/{route-C-aMI7Sl.d.cts → route-Bh1U13yy.d.cts} +8 -4
  133. package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
  134. package/dist/types/testing/index.d.cts +406 -70
  135. package/dist/types/testing/index.d.ts +406 -70
  136. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  137. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  138. package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
  139. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
  140. package/package.json +4 -3
@@ -2,11 +2,11 @@
2
2
 
3
3
  require('reflect-metadata');
4
4
  var inversify = require('inversify');
5
- var providers = require('../_shared/providers-BMMLp2SN.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-BywwkU9O.cjs');
9
- var startupRoots = require('../_shared/startup-roots-lFc69AxC.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-BnM8FbSH.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,15 +47,90 @@ require('../_shared/translate-error-BnM8FbSH.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));
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));
64
+ /**
65
+ * Makes every mock `meocord/testing` creates with the test runner's own mock function, such as Vitest's `vi.fn`.
66
+ *
67
+ * Call it once in the runner's setup file. The runner then treats meocord's mocks as its own: its `clearMocks` and
68
+ * `mockReset` config and `vi.clearAllMocks()` reach them, its matchers read them, and its whole mock API is there.
69
+ * Without it, mocks are {@link createMockFn}'s own, which jest's and Vitest's `expect` read too.
70
+ *
71
+ * @remarks
72
+ * Each mock starts with the runner's mock holding meocord's behaviour, such as an interaction's replies, as its own
73
+ * implementation. {@link resetAllMocks} puts that behaviour back where the runner's `mockReset` drops it, as jest's and
74
+ * bun's do; a runner's own reset-all, such as jest's `resetMocks`, can't, so under jest and bun reset with
75
+ * {@link resetAllMocks}. node:test's `mock.fn` records calls in a shape of its own, so under node:test leave meocord's
76
+ * own function in place. Calling it again with the same factory does nothing; another, once a mock exists, throws.
77
+ *
78
+ * @param factory - The runner's mock function factory: `vi.fn`, `jest.fn` or bun's `mock`.
79
+ * @throws Error when a mock already exists from another factory, or when `factory` doesn't make a mock with jest's and
80
+ * Vitest's API.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * // vitest.setup.ts
85
+ * import { vi } from 'vitest'
86
+ *
87
+ * useMockFn(vi.fn)
88
+ * ```
89
+ *
90
+ * @group Testing
91
+ * @category Mocks
92
+ * @see {@link createMockFn}
93
+ */ function useMockFn(factory) {
94
+ if (factory === mocks$1.runnerFactory) return;
95
+ if ([
96
+ ...mocks$1.created
97
+ ].some((ref)=>ref.deref() !== undefined)) {
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.");
99
+ }
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
+ }
110
+ const api = [
111
+ 'mockImplementation',
112
+ 'mockClear',
113
+ 'mockReset'
114
+ ];
115
+ if (!isMockFunction(probe) || !Array.isArray(probe.mock?.calls) || api.some((name)=>typeof probe[name] !== 'function')) {
116
+ throw refusal();
117
+ }
118
+ mocks$1.runnerFactory = factory;
119
+ }
120
+ /** Whether mocks are made with a runner's function, which {@link useMockFn} registered. */ const usesRunnerMockFn = ()=>mocks$1.runnerFactory !== undefined;
121
+ /** Tracks a mock for clearAllMocks and resetAllMocks. */ function track(mockFn) {
122
+ const ref = new WeakRef(mockFn);
123
+ mocks$1.created.add(ref);
124
+ collected.register(mockFn, ref);
125
+ return mockFn;
126
+ }
55
127
  /**
56
128
  * Clears the calls every mock from `meocord/testing` has recorded, keeping what each was told to do.
57
129
  *
58
- * Vitest's `clearMocks` and jest's `clearAllMocks()` reach only their own `vi.fn()` and `jest.fn()` mocks. This one
59
- * covers `createMockFn` and everything built on it, such as `createMockInteraction` and `createMockClient`. To also
60
- * undo what a test set, use {@link resetAllMocks}.
130
+ * Vitest's `clearMocks` and jest's `clearAllMocks()` reach only their own `vi.fn()` and `jest.fn()` mocks, and
131
+ * meocord's too once {@link useMockFn} makes them with the runner's function. This one covers `createMockFn` and
132
+ * everything built on it, such as `createMockInteraction` and `createMockClient`, either way. To also undo what a test
133
+ * set, use {@link resetAllMocks}.
61
134
  *
62
135
  * @example
63
136
  * ```ts
@@ -70,7 +143,7 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
70
143
  * @category Mocks
71
144
  * @see {@link resetAllMocks}
72
145
  */ function clearAllMocks() {
73
- for (const ref of created)ref.deref()?.mockClear();
146
+ for (const ref of mocks$1.created)ref.deref()?.mockClear();
74
147
  }
75
148
  /**
76
149
  * Clears every mock from `meocord/testing`, and puts each back to the implementation it was created with.
@@ -93,18 +166,33 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
93
166
  * @category Mocks
94
167
  * @see {@link clearAllMocks}
95
168
  */ function resetAllMocks() {
96
- for (const ref of created)ref.deref()?.mockReset();
169
+ for (const ref of mocks$1.created){
170
+ const mockFn = ref.deref();
171
+ if (!mockFn) continue;
172
+ mockFn.mockReset();
173
+ // A runner whose reset drops the implementation the mock was created with, as jest's and bun's do, gets it back
174
+ mocks$1.restoreImpl.get(mockFn)?.();
175
+ }
97
176
  }
98
177
  /**
99
178
  * Creates a mock function that jest's and Vitest's `expect` both read.
100
179
  *
101
180
  * Use it for a function double in code that must not depend on one test runner, such as a shared test helper; in a
102
- * Vitest test, `vi.fn()` works as well. {@link clearAllMocks} and {@link resetAllMocks} reach it.
181
+ * Vitest test, `vi.fn()` works as well. {@link clearAllMocks} and {@link resetAllMocks} reach it. Once
182
+ * {@link useMockFn} registers a runner's function, it returns that runner's mock.
103
183
  *
104
184
  * @remarks
105
185
  * `mockReturnValue`, `mockResolvedValue`, `mockRejectedValue` and `mockImplementation` share one implementation, so
106
186
  * the last one set wins. The `*Once` variants queue, and run first, in the order they were set.
107
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
+ *
108
196
  * @param impl - The implementation to run until another is set.
109
197
  *
110
198
  * @example
@@ -122,6 +210,15 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
122
210
  * @category Mocks
123
211
  * @see {@link MockInstance}
124
212
  */ function createMockFn(impl) {
213
+ const { runnerFactory } = mocks$1;
214
+ if (runnerFactory) {
215
+ const runnerMock = runnerFactory(impl);
216
+ if (impl) {
217
+ const setImpl = runnerMock.mockImplementation.bind(runnerMock);
218
+ mocks$1.restoreImpl.set(runnerMock, ()=>setImpl(impl));
219
+ }
220
+ return track(runnerMock);
221
+ }
125
222
  // One persistent implementation and one queue of single-use ones, as in jest and vitest: the
126
223
  // four setters share the slot, so the last call wins, and the `*Once` variants share the queue,
127
224
  // so they run in declaration order.
@@ -131,9 +228,20 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
131
228
  const calls = [];
132
229
  const results = [];
133
230
  const instances = [];
231
+ const settledResults = [];
232
+ const invocationCallOrder = [];
233
+ const contexts = [];
134
234
  const mockFn = function(...args) {
135
235
  calls.push(args);
136
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);
137
245
  let type = 'return';
138
246
  let value;
139
247
  try {
@@ -144,10 +252,36 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
144
252
  } else {
145
253
  value = undefined;
146
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
+ }
147
277
  return value;
148
278
  } catch (err) {
149
279
  type = 'throw';
150
280
  value = err;
281
+ Object.assign(settled, {
282
+ type: 'rejected',
283
+ value: err
284
+ });
151
285
  throw err;
152
286
  } finally{
153
287
  results.push({
@@ -175,6 +309,18 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
175
309
  return calls.length > 0 ? calls[calls.length - 1] : undefined;
176
310
  }
177
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
+ });
178
324
  Object.defineProperty(mockFn, 'mock', {
179
325
  value: mock,
180
326
  enumerable: false
@@ -211,16 +357,22 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
211
357
  onceQueue.push(fn);
212
358
  return mockFn;
213
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
+ };
214
370
  mockFn.mockClear = ()=>{
215
- calls.length = 0;
216
- results.length = 0;
217
- instances.length = 0;
371
+ forgetCalls();
218
372
  return mockFn;
219
373
  };
220
374
  mockFn.mockReset = ()=>{
221
- calls.length = 0;
222
- results.length = 0;
223
- instances.length = 0;
375
+ forgetCalls();
224
376
  onceQueue = [];
225
377
  currentImpl = impl;
226
378
  return mockFn;
@@ -234,10 +386,7 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
234
386
  name = n;
235
387
  return mockFn;
236
388
  };
237
- const ref = new WeakRef(mockFn);
238
- created.add(ref);
239
- collected.register(mockFn, ref);
240
- return mockFn;
389
+ return track(mockFn);
241
390
  }
242
391
 
243
392
  const VISIBLE = new Set([
@@ -279,7 +428,7 @@ const VISIBLE = new Set([
279
428
  * @group Testing
280
429
  * @category Inspection
281
430
  * @see {@link TestingModule}
282
- * @see {@link https://meocord.dev/docs/4.1/invoke-and-dispatch | Invoke and dispatch}
431
+ * @see {@link https://meocord.dev/docs/4.2/invoke-and-dispatch | Invoke and dispatch}
283
432
  */ function getResponse(interaction) {
284
433
  const state = controller_decorator.existingResponse(interaction);
285
434
  // A mock records every answer it gets; respond()'s edits through the channel are its own, so they are merged in
@@ -337,9 +486,136 @@ const VISIBLE = new Set([
337
486
  }, code, 400, 'POST', 'https://discord.com/api/v10/interactions', {});
338
487
  }
339
488
 
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;
507
+ /** Notes that a mock was made, which useStrictMocks() must come before. */ function noteMockMade() {
508
+ settings.mockMade = true;
509
+ }
510
+ /**
511
+ * Has every mock from `meocord/testing` compute the values discord.js computes, where it reads a placeholder otherwise.
512
+ *
513
+ * Call it once in the test runner's setup file. A message's `editable`, `deletable`, `pinnable`, `crosspostable`,
514
+ * `bulkDeletable`, `hasThread` and `partial`, a member's `manageable`, `kickable`, `bannable` and `moderatable`, a
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.
522
+ *
523
+ * @remarks
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.
542
+ *
543
+ * @throws Error when a mock already exists, which was made without it.
544
+ *
545
+ * @example
546
+ * ```ts
547
+ * // vitest.setup.ts
548
+ * useStrictMocks()
549
+ * ```
550
+ *
551
+ * @group Testing
552
+ * @category Mocks
553
+ * @see {@link useMockFn}
554
+ * @see {@link https://meocord.dev/docs/4.2/mocks | Mocks}
555
+ */ function useStrictMocks() {
556
+ if (settings.strict) return;
557
+ if (settings.mockMade) {
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.");
559
+ }
560
+ settings.strict = true;
561
+ }
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
+ }
340
617
  /** A permission set with none, frozen as discord.js freezes the ones it gives. */ const noPermissions = ()=>new discord_js.PermissionsBitField().freeze();
341
618
  /** A property discord.js computes from the others: its own getter runs, reading the mock's values. */ const REAL_GETTER = Symbol('real getter');
342
- const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
343
619
  /** A class and its defaults, typed together so a default that stops fitting its property fails to compile. */ const defaultsOf = (Class, defaults)=>[
344
620
  Class.prototype,
345
621
  defaults
@@ -352,7 +628,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
352
628
  defaultsOf(discord_js.Guild, {
353
629
  name: ()=>'Guild',
354
630
  preferredLocale: ()=>discord_js.Locale.EnglishUS,
355
- ownerId: snowflake,
631
+ ownerId: nextSnowflake,
356
632
  memberCount: ()=>1,
357
633
  available: ()=>true,
358
634
  large: ()=>false,
@@ -365,6 +641,9 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
365
641
  shardId: ()=>0,
366
642
  nsfwLevel: ()=>discord_js.GuildNSFWLevel.Default,
367
643
  verificationLevel: ()=>discord_js.GuildVerificationLevel.None,
644
+ presences: (guild)=>ownedManager(discord_js.PresenceManager.prototype, {
645
+ guild
646
+ }),
368
647
  premiumTier: ()=>discord_js.GuildPremiumTier.None,
369
648
  createdTimestamp: REAL_GETTER,
370
649
  createdAt: REAL_GETTER
@@ -379,6 +658,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
379
658
  discriminator: ()=>'0',
380
659
  avatar: ()=>null,
381
660
  system: ()=>false,
661
+ flags: ()=>new discord_js.UserFlagsBitField(),
382
662
  tag: REAL_GETTER,
383
663
  displayName: REAL_GETTER,
384
664
  defaultAvatarURL: REAL_GETTER,
@@ -399,12 +679,12 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
399
679
  premiumSinceTimestamp: ()=>null,
400
680
  communicationDisabledUntilTimestamp: ()=>null,
401
681
  pending: ()=>false,
682
+ flags: ()=>new discord_js.GuildMemberFlagsBitField().freeze(),
402
683
  displayName: REAL_GETTER,
403
684
  roles: (member)=>memberRoles(member, []),
404
685
  permissions: REAL_GETTER
405
686
  }),
406
687
  defaultsOf(discord_js.Role, {
407
- id: snowflake,
408
688
  name: ()=>'role',
409
689
  // Above @everyone, which a server's role manager makes at position 0
410
690
  position: ()=>1,
@@ -442,30 +722,71 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
442
722
  poll: ()=>null,
443
723
  interactionMetadata: ()=>null,
444
724
  stickers: ()=>new discord_js.Collection(),
725
+ messageSnapshots: ()=>new discord_js.Collection(),
726
+ reactions: (message)=>ownedManager(discord_js.ReactionManager.prototype, {
727
+ message
728
+ }),
445
729
  url: REAL_GETTER
446
730
  }),
447
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
+ }),
448
735
  defaultsOf(discord_js.CommandInteraction, {
449
- commandId: snowflake,
736
+ commandId: nextSnowflake,
450
737
  commandGuildId: ()=>null
451
738
  }),
452
739
  defaultsOf(discord_js.AutocompleteInteraction, {
453
- commandId: snowflake,
454
- 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
+ })
455
748
  }),
456
749
  defaultsOf(discord_js.BaseInteraction, {
457
- applicationId: snowflake,
750
+ applicationId: nextSnowflake,
458
751
  token: ()=>'mock-interaction-token',
459
752
  version: ()=>1,
460
753
  context: ()=>null,
754
+ entitlements: ()=>new discord_js.Collection(),
461
755
  // What Discord sends with an interaction in a server, the member's permissions there; typed for a cached one
462
- memberPermissions: (interaction)=>interaction.guildId && interaction.member instanceof discord_js.GuildMember ? interaction.member.permissions : null,
463
- // The bot's permissions where the interaction was made: its member's in the channel, or none outside a server
464
- appPermissions: (interaction)=>{
465
- const { guild, channel } = interaction;
466
- const me = guild instanceof discord_js.Guild ? guild.members.me : null;
467
- return (me && channel instanceof discord_js.GuildChannel ? channel.permissionsFor(me) : null) ?? noPermissions();
468
- }
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)
469
790
  }),
470
791
  defaultsOf(discord_js.TextChannel, {
471
792
  type: ()=>discord_js.ChannelType.GuildText,
@@ -501,7 +822,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
501
822
  }),
502
823
  defaultsOf(discord_js.GuildChannel, {
503
824
  name: ()=>'channel',
504
- guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id : snowflake(),
825
+ guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id : nextSnowflake(),
505
826
  position: ()=>0,
506
827
  rawPosition: ()=>0,
507
828
  parentId: ()=>null
@@ -520,20 +841,139 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
520
841
  nameAcronym: REAL_GETTER
521
842
  })
522
843
  ]);
523
- /** The default a mock of `target`'s class has for `key`, read from the nearest class that has one. */ function discordDefault(target, key) {
844
+ /**
845
+ * What strict mocks also read as Discord sends it, for discord.js's getters that read it: a server for a channel or
846
+ * thread made without one, a thread's parent text channel in it, and data that is otherwise a placeholder.
847
+ */ const STRICT_DEFAULTS = new Map([
848
+ defaultsOf(discord_js.Message, {
849
+ content: ()=>'',
850
+ editedTimestamp: ()=>null,
851
+ editedAt: REAL_GETTER
852
+ }),
853
+ defaultsOf(discord_js.MessageMentions, {
854
+ repliedUser: ()=>null
855
+ }),
856
+ defaultsOf(discord_js.Guild, {
857
+ rulesChannelId: ()=>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
869
+ }),
870
+ defaultsOf(discord_js.ThreadChannel, {
871
+ guild: ()=>createMockGuild(),
872
+ guildId: (thread)=>thread.guild.id,
873
+ parentId: (thread)=>{
874
+ const parent = createMockChannel(discord_js.TextChannel, {
875
+ guild: thread.guild
876
+ });
877
+ thread.guild.channels.cache.set(parent.id, parent);
878
+ madeParentIds.set(thread, parent.id);
879
+ return parent.id;
880
+ },
881
+ parent: REAL_GETTER,
882
+ ownerId: nextSnowflake,
883
+ archived: ()=>false,
884
+ locked: ()=>false
885
+ }),
886
+ defaultsOf(discord_js.GuildChannel, {
887
+ guild: ()=>createMockGuild(),
888
+ parent: REAL_GETTER
889
+ }),
890
+ // Full once its members reach a user limit, which a voice channel has none of unless given one
891
+ defaultsOf(discord_js.BaseGuildVoiceChannel, {
892
+ userLimit: ()=>0,
893
+ full: REAL_GETTER
894
+ }),
895
+ // A reaction's message, as discord.js always has one, is a whole one
896
+ defaultsOf(discord_js.MessageReaction, {
897
+ count: ()=>1,
898
+ me: ()=>false,
899
+ message: ()=>createMockMessage()
900
+ })
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) {
524
957
  for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
525
- const found = DEFAULTS.get(proto)?.[key];
526
- if (found !== undefined) return found;
958
+ const value = PLACEHOLDER_VALUES.get(proto)?.[key];
959
+ if (value !== undefined) return value;
960
+ }
961
+ return undefined;
962
+ }
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) {
964
+ for (const defaults of strictMocks() ? [
965
+ STRICT_DEFAULTS,
966
+ DEFAULTS
967
+ ] : [
968
+ DEFAULTS
969
+ ]){
970
+ for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
971
+ const found = defaults.get(proto)?.[key];
972
+ if (found !== undefined) return found;
973
+ }
527
974
  }
528
975
  return undefined;
529
976
  }
530
-
531
- /**
532
- * The id of the bot every mock client is logged in as, so a test can mention it. Fixed, and below the
533
- * ids other mocks take, so it is the same on every run and in any test order, and no mock shares it.
534
- */ const MOCK_BOT_ID = '1300000000000000000';
535
- /** The last id a mock was given; ids count up from a real snowflake, so each mock's is its own. */ let lastSnowflake = 1400000000000000000n;
536
- /** 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);
537
977
 
538
978
  /** Where a mock resolves unfurled media to, as Discord's media proxy would. */ const PROXY = 'https://images-ext-1.discordapp.net/external/mock';
539
979
  /** Unfurled media as Discord stores it: the url given, resolved to a proxied image of a fixed size. */ function resolvedMedia(media) {
@@ -664,8 +1104,15 @@ const SKIP = new Set([
664
1104
  'toJSON',
665
1105
  'then'
666
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();
667
1111
  function stubDeep(instance, externalStubs, onSet) {
1112
+ noteMockMade();
668
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();
669
1116
  const proxy = new Proxy(instance, {
670
1117
  get (target, prop) {
671
1118
  // Always pass through symbols
@@ -679,12 +1126,17 @@ function stubDeep(instance, externalStubs, onSet) {
679
1126
  if (Object.prototype.hasOwnProperty.call(target, key)) {
680
1127
  return Reflect.get(target, prop, target);
681
1128
  }
1129
+ stubReadWarnings.get(target)?.get(key)?.();
682
1130
  // Skip passthrough props — return prototype value as-is
683
1131
  if (SKIP.has(key)) {
684
1132
  return Reflect.get(target, prop, target);
685
1133
  }
686
1134
  // Return cached stub
687
- 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
+ }
688
1140
  // A value discord.js always gives, in its shape, rather than a stub: kept once read, or its getter run live
689
1141
  const known = discordDefault(target, key);
690
1142
  if (known === REAL_GETTER) {
@@ -698,6 +1150,12 @@ function stubDeep(instance, externalStubs, onSet) {
698
1150
  stubs.set(key, value);
699
1151
  return value;
700
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
+ }
701
1159
  // Walk the prototype chain to check if it's a function
702
1160
  let proto = Object.getPrototypeOf(target);
703
1161
  let protoValue;
@@ -705,14 +1163,18 @@ function stubDeep(instance, externalStubs, onSet) {
705
1163
  const desc = Object.getOwnPropertyDescriptor(proto, key);
706
1164
  if (desc !== undefined) {
707
1165
  protoValue = desc.value;
708
- if (desc.get && PLACEHOLDER_BOOLEANS.get(proto)?.has(key)) warnBooleanPlaceholder(target, key);
1166
+ if (desc.get && isPlaceholder(proto, key)) {
1167
+ // Strict mocks run discord.js's getter, read live; otherwise the placeholder, with a warning once
1168
+ if (strictMocks()) return Reflect.get(target, prop, proxy);
1169
+ warnPlaceholderRead(target, key, 'false');
1170
+ }
709
1171
  break;
710
1172
  }
711
1173
  proto = Object.getPrototypeOf(proto);
712
1174
  }
713
1175
  // A client the mock's structures share: its guild's, or one of its own
714
1176
  if (key === 'client' && target instanceof discord_js.Base) return clientOf(target);
715
- 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({});
716
1178
  stubs.set(key, stub);
717
1179
  return stub;
718
1180
  },
@@ -721,7 +1183,10 @@ function stubDeep(instance, externalStubs, onSet) {
721
1183
  // and a plain write against one of those throws. Defining an own
722
1184
  // data property shadows the accessor, which is what test setup means.
723
1185
  set (target, prop, value) {
724
- 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, {
725
1190
  value,
726
1191
  writable: true,
727
1192
  enumerable: true,
@@ -731,6 +1196,7 @@ function stubDeep(instance, externalStubs, onSet) {
731
1196
  return true;
732
1197
  }
733
1198
  });
1199
+ mockTargets.set(proxy, instance);
734
1200
  return proxy;
735
1201
  }
736
1202
  // discord.js methods that read only what the mock holds, so they run for real: looking items up in a manager's cache,
@@ -795,9 +1261,18 @@ const REAL_METHODS = new Map([
795
1261
  ])
796
1262
  ]
797
1263
  ]);
1264
+ // What strict mocks also run for real: a thread's permissions are its parent channel's, as discord.js reads them
1265
+ const STRICT_REAL_METHODS = new Map([
1266
+ [
1267
+ discord_js.ThreadChannel.prototype,
1268
+ new Set([
1269
+ 'permissionsFor'
1270
+ ])
1271
+ ]
1272
+ ]);
798
1273
  function isRealMethod(target, key) {
799
1274
  for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
800
- if (REAL_METHODS.get(proto)?.has(key)) return true;
1275
+ if (REAL_METHODS.get(proto)?.has(key) || strictMocks() && STRICT_REAL_METHODS.get(proto)?.has(key)) return true;
801
1276
  }
802
1277
  return false;
803
1278
  }
@@ -854,7 +1329,14 @@ const PLACEHOLDER_BOOLEANS = new Map([
854
1329
  [
855
1330
  discord_js.BaseGuildVoiceChannel.prototype,
856
1331
  new Set([
857
- 'joinable'
1332
+ 'joinable',
1333
+ 'full'
1334
+ ])
1335
+ ],
1336
+ [
1337
+ discord_js.VoiceChannel.prototype,
1338
+ new Set([
1339
+ 'speakable'
858
1340
  ])
859
1341
  ],
860
1342
  [
@@ -876,11 +1358,49 @@ const PLACEHOLDER_BOOLEANS = new Map([
876
1358
  ])
877
1359
  ]
878
1360
  ]);
1361
+ /** Whether `proto`'s getter for `key` is one of those, or a subclass's override of one, such as a voice channel's `joinable`. */ function isPlaceholder(proto, key) {
1362
+ for(; proto !== null; proto = Object.getPrototypeOf(proto)){
1363
+ if (PLACEHOLDER_BOOLEANS.get(proto)?.has(key)) return true;
1364
+ }
1365
+ return false;
1366
+ }
879
1367
  const mockLogger = new controller_decorator.Logger('Mocks');
880
- 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;
881
1395
  const name = Object.getPrototypeOf(target).constructor.name;
882
- const variable = name.charAt(0).toLowerCase() + name.slice(1);
883
- 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.`);
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
+ }
1401
+ // The class name in camel case, a leading acronym included: dmChannel, guildMember
1402
+ const variable = name.replace(/^[A-Z]+(?=[A-Z][a-z])|^[A-Z]/, (start)=>start.toLowerCase());
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.`);
884
1404
  }
885
1405
  // The client of a structure made without one: the one its guild has, else one of its own, made once
886
1406
  const ownClients = new WeakMap();
@@ -988,25 +1508,37 @@ const MANAGER_ITEMS = [
988
1508
  ],
989
1509
  [
990
1510
  discord_js.RoleManager,
991
- (id)=>createMockInteraction(discord_js.Role, id ? {
992
- id
993
- } : {})
1511
+ (id, manager)=>createMockInteraction(discord_js.Role, {
1512
+ ...id ? {
1513
+ id
1514
+ } : {},
1515
+ ...guildOf(manager) ? {
1516
+ guild: guildOf(manager)
1517
+ } : {}
1518
+ })
994
1519
  ],
995
1520
  [
996
1521
  discord_js.GuildBanManager,
997
1522
  ()=>createMockInteraction(discord_js.GuildBan)
998
1523
  ],
1524
+ // A message fetched from a channel is in it, and another user's
999
1525
  [
1000
1526
  discord_js.GuildMessageManager,
1001
- (id)=>createMockMessage(id ? {
1002
- id
1003
- } : {})
1527
+ (id, manager)=>createMockMessage({
1528
+ ...id ? {
1529
+ id
1530
+ } : {},
1531
+ ...channelOfManager(manager)
1532
+ })
1004
1533
  ],
1005
1534
  [
1006
1535
  discord_js.DMMessageManager,
1007
- (id)=>createMockMessage(id ? {
1008
- id
1009
- } : {})
1536
+ (id, manager)=>createMockMessage({
1537
+ ...id ? {
1538
+ id
1539
+ } : {},
1540
+ ...channelOfManager(manager)
1541
+ })
1010
1542
  ],
1011
1543
  [
1012
1544
  discord_js.GuildTextThreadManager,
@@ -1024,6 +1556,12 @@ const MANAGER_ITEMS = [
1024
1556
  discord_js.ThreadMemberManager,
1025
1557
  ()=>createMockInteraction(discord_js.ThreadMember)
1026
1558
  ],
1559
+ [
1560
+ discord_js.ReactionUserManager,
1561
+ (id)=>createMockUser(id ? {
1562
+ id
1563
+ } : {})
1564
+ ],
1027
1565
  [
1028
1566
  discord_js.ApplicationCommandManager,
1029
1567
  (id)=>createMockInteraction(discord_js.ApplicationCommand, id ? {
@@ -1051,6 +1589,12 @@ const MANAGER_ITEMS = [
1051
1589
  const returnsPromise = (key, method)=>method.constructor.name === 'AsyncFunction' || PROMISE_METHODS.has(key) || /^set[A-Z]/.test(key) && !SYNC_SETTERS.has(key);
1052
1590
  // The ids a fetch asks for: an id, a discord.js object, or options naming one or several, such as `{ user: id }` or
1053
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
+ };
1054
1598
  function fetchedIds(args) {
1055
1599
  const idOf = (value)=>typeof value === 'string' ? value : value instanceof discord_js.Base ? value.id : undefined;
1056
1600
  const [first] = args;
@@ -1086,6 +1630,10 @@ function fetchedIds(args) {
1086
1630
  if (key === 'send' && target instanceof discord_js.User) {
1087
1631
  return createMockFn(async (...args)=>(await receiver().createDM()).send(...args));
1088
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
+ }
1089
1637
  if (MESSAGE_METHODS.has(key)) return createMockFn(async ()=>createMockMessage());
1090
1638
  if (target instanceof discord_js.BaseManager) {
1091
1639
  const item = MANAGER_ITEMS.find(([Manager])=>Manager.prototype.isPrototypeOf(target))?.[1];
@@ -1102,7 +1650,24 @@ function fetchedIds(args) {
1102
1650
  return ids === undefined ? new discord_js.Collection() : one(ids);
1103
1651
  });
1104
1652
  }
1105
- 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
+ }
1106
1671
  } else if (target instanceof discord_js.Base && (SELF_METHODS.has(key) || /^set[A-Z]/.test(key))) {
1107
1672
  return createMockFn(async ()=>receiver());
1108
1673
  }
@@ -1262,23 +1827,46 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1262
1827
  });
1263
1828
  }
1264
1829
  }
1265
- /** Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order. */ const RESPONSE_LOG = Symbol('response log');
1266
- const ANSWER_METHODS = [
1267
- 'reply',
1268
- 'deferReply',
1269
- 'editReply',
1270
- 'followUp',
1271
- 'deleteReply',
1272
- 'update',
1273
- 'deferUpdate',
1274
- 'showModal'
1275
- ];
1276
1830
  /**
1277
- * `mock`, recording each call in `log` as it is made, with what it sent and, once it settles, what it rejected with.
1278
- * `withResponse` is how a call asks discord.js for the message back, not part of what it sends, so it is left out.
1279
- */ function recorded(method, mock, log) {
1280
- return new Proxy(mock, {
1281
- apply (target, self, args) {
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'));
1862
+ /**
1863
+ * A mock of an answer method, `impl` its behaviour, that records each call in `log` as it is made, with what it sent
1864
+ * and, once it settles, what it rejected with. The record wraps every behaviour the mock is given, its own and any a
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) {
1282
1870
  const [payload] = args;
1283
1871
  const sent = payload && typeof payload === 'object' && 'withResponse' in payload ? Object.fromEntries(Object.entries(payload).filter(([key])=>key !== 'withResponse')) : payload;
1284
1872
  const call = {
@@ -1287,34 +1875,81 @@ const ANSWER_METHODS = [
1287
1875
  };
1288
1876
  controller_decorator.stampCall(call);
1289
1877
  log.push(call);
1878
+ let undo;
1290
1879
  try {
1291
- const result = Reflect.apply(target, self, args);
1292
- // Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
1293
- if (result instanceof Promise) {
1294
- return result.then(undefined, (error)=>{
1295
- call.error = error;
1296
- throw error;
1297
- });
1298
- }
1299
- return result;
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);
1300
1882
  } catch (error) {
1883
+ call.error = error;
1884
+ return Promise.reject(error);
1885
+ }
1886
+ const failed = (error)=>{
1887
+ undo?.();
1301
1888
  call.error = error;
1302
1889
  throw error;
1890
+ };
1891
+ try {
1892
+ const result = Reflect.apply(behaviour, this, args);
1893
+ // Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
1894
+ if (result instanceof Promise) return result.then(undefined, failed);
1895
+ return result;
1896
+ } catch (error) {
1897
+ return failed(error);
1303
1898
  }
1304
- }
1305
- });
1899
+ };
1900
+ const mock = createMockFn(record(impl, false));
1901
+ const always = mock.mockImplementation.bind(mock);
1902
+ const once = mock.mockImplementationOnce.bind(mock);
1903
+ // Each way a test sets a behaviour goes through the record, a runner's own extras included where it has them
1904
+ const setters = {
1905
+ mockImplementation: (fn)=>fn,
1906
+ mockReturnValue: (value)=>()=>value,
1907
+ mockResolvedValue: (value)=>()=>Promise.resolve(value),
1908
+ mockRejectedValue: (value)=>()=>Promise.reject(value)
1909
+ };
1910
+ // In place of the mock's own, keeping whether each is among its keys
1911
+ const define = (name, value)=>Object.defineProperty(mock, name, {
1912
+ value,
1913
+ writable: true,
1914
+ enumerable: Object.getOwnPropertyDescriptor(mock, name)?.enumerable ?? false,
1915
+ configurable: true
1916
+ });
1917
+ for (const [name, behaviourOf] of Object.entries(setters)){
1918
+ define(name, (value)=>(always(record(behaviourOf(value))), mock));
1919
+ define(`${name}Once`, (value)=>(once(record(behaviourOf(value))), mock));
1920
+ }
1921
+ if (typeof mock.mockReturnThis === 'function') {
1922
+ define('mockReturnThis', ()=>(always(record(function() {
1923
+ return this;
1924
+ })), mock));
1925
+ }
1926
+ const withImplementation = mock.withImplementation;
1927
+ if (typeof withImplementation === 'function') {
1928
+ define('withImplementation', (fn, callback)=>withImplementation.call(mock, record(fn), callback));
1929
+ }
1930
+ return mock;
1306
1931
  }
1307
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
+ }
1308
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);
1309
1939
  // In discord.js's error codes, though missing from its typings
1310
1940
  const INVALID_CHANNEL_TYPE = 'CommandInteractionOptionInvalidChannelType';
1311
1941
  /** Records the interaction a mock options resolver belongs to. */ const ownOptions = (interaction, options)=>{
1312
1942
  if (typeof options === 'object' && options !== null) optionOwners.set(options, interaction);
1313
1943
  };
1314
- /** 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), {
1315
- 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,
1316
1948
  bot: false
1317
- }));
1949
+ });
1950
+ defineMadeTime(user, generatedId);
1951
+ return stubDeep(user);
1952
+ };
1318
1953
  function createMockInteraction(Class, props) {
1319
1954
  const instance = Object.create(Class.prototype);
1320
1955
  const stubs = new Map();
@@ -1334,14 +1969,36 @@ function createMockInteraction(Class, props) {
1334
1969
  // mock lacks: a guildId is a guild, a guild object with it a cached one, neither a DM. A member
1335
1970
  // not given is the user's on an interaction, and the auto-stub elsewhere, so only one set to null or undefined fails.
1336
1971
  const own = (key)=>Object.prototype.hasOwnProperty.call(instance, key) ? instance[key] : undefined;
1972
+ let strictPlace;
1337
1973
  const hasMember = ()=>!Object.prototype.hasOwnProperty.call(instance, 'member') || Boolean(instance.member);
1338
1974
  const guildChecks = {
1339
1975
  inGuild: ()=>Boolean(own('guildId') && hasMember()),
1340
1976
  inCachedGuild: ()=>Boolean(own('guildId') && own('guild') && hasMember()),
1341
1977
  inRawGuild: ()=>Boolean(own('guildId') && !own('guild') && hasMember())
1342
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
1343
1995
  for (const [name, check] of Object.entries(guildChecks)){
1344
- 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
+ }));
1345
2002
  }
1346
2003
  // Set up reply state machine for repliable interactions
1347
2004
  const isRepliableMethod = findPrototypeMethod(instance, 'isRepliable');
@@ -1350,86 +2007,204 @@ function createMockInteraction(Class, props) {
1350
2007
  instance.replied = false;
1351
2008
  instance.deferred = false;
1352
2009
  instance.ephemeral = false;
1353
- const alreadyReplied = ()=>new Error('The reply to this interaction has already been sent or deferred.');
1354
- 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.`);
1355
2013
  // Only `flags` is read: discord.js deprecates the `ephemeral: true` reply option, and honouring it here would let
1356
- // 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.
1357
2015
  const hasEphemeralFlag = (options)=>{
1358
- if (!options) return false;
1359
- const { flags } = options;
1360
- if (typeof flags === 'number') return (flags & 64) !== 0;
1361
- if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
1362
- 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);
1363
2019
  };
1364
- stubs.set('reply', createMockFn(async (...args)=>{
1365
- if (instance.deferred || instance.replied) throw alreadyReplied();
1366
- instance.replied = true;
1367
- if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
1368
- }));
1369
- stubs.set('deferReply', createMockFn(async (...args)=>{
1370
- if (instance.deferred || instance.replied) throw alreadyReplied();
1371
- instance.deferred = true;
1372
- if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
1373
- }));
1374
- stubs.set('followUp', createMockFn(async ()=>{
1375
- if (!instance.deferred && !instance.replied) throw notYetReplied('followUp');
1376
- instance.replied = true;
1377
- return createMockMessage();
1378
- }));
1379
- // The interaction's own message as Discord holds it: the message a component is on, or the original response.
1380
- // An edit replaces it in the shape Discord stores, with ids and resolved media, stamped with when it was edited,
1381
- // and fetchReply() reads that back, so code comparing the two sees what it would against Discord.
1382
- let held;
1383
- const current = ()=>held ?? (held = heldFrom(instance.message));
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;
1384
2026
  // A command answered with a modal has no original response, and Discord refuses one asked for
1385
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;
2120
+ };
1386
2121
  const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
1387
- stubs.set('editReply', createMockFn(async (options)=>{
1388
- if (!instance.deferred && !instance.replied) throw notYetReplied('editReply');
1389
- if (modalAnswered) throw unknownMessage();
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;
2133
+ // Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
2134
+ const log = [];
2135
+ Object.defineProperty(instance, RESPONSE_LOG, {
2136
+ value: log
2137
+ });
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])=>{
1390
2149
  instance.replied = true;
1391
- held = edited(current(), options);
1392
- // The response has the message as Discord answers the edit, before its uploaded files have loaded again
1393
- 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
+ }) : {
1394
2159
  ...held,
1395
- components: held.returned ?? held.components
2160
+ id
1396
2161
  });
2162
+ followUpMade = id;
1397
2163
  }));
1398
- stubs.set('fetchReply', createMockFn(async ()=>{
1399
- if (modalAnswered) throw unknownMessage();
1400
- return messageFrom(current());
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);
1401
2182
  }));
1402
- stubs.set('deleteReply', createMockFn(async ()=>{
1403
- if (!instance.deferred && !instance.replied) throw notYetReplied('deleteReply');
1404
- if (modalAnswered) throw unknownMessage();
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());
2187
+ }));
2188
+ answer('deleteReply', async ()=>{
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);
1405
2196
  }));
1406
2197
  // showModal — the first response of a command or a component, like reply()
1407
- if (instance.type === discord_js.InteractionType.ApplicationCommand || instance.type === discord_js.InteractionType.MessageComponent) {
1408
- stubs.set('showModal', createMockFn(async ()=>{
1409
- if (instance.deferred || instance.replied) throw alreadyReplied();
1410
- instance.replied = true;
2198
+ if (instance.type === discord_js.InteractionType.ApplicationCommand || isComponent) {
2199
+ answer('showModal', async ()=>undefined, first('showModal', 'replied', ()=>{
1411
2200
  modalAnswered = instance.type === discord_js.InteractionType.ApplicationCommand;
2201
+ if (isComponent) original = componentMessage();
1412
2202
  }));
1413
2203
  }
1414
2204
  // deferUpdate / update — components, and modals submitted from a message's component
1415
- if (instance.type === discord_js.InteractionType.MessageComponent || instance.type === discord_js.InteractionType.ModalSubmit) {
1416
- stubs.set('update', createMockFn(async ()=>{
1417
- if (instance.deferred || instance.replied) throw alreadyReplied();
1418
- instance.replied = true;
1419
- }));
1420
- stubs.set('deferUpdate', createMockFn(async ()=>{
1421
- if (instance.deferred || instance.replied) throw alreadyReplied();
1422
- instance.deferred = true;
1423
- }));
1424
- }
1425
- // Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
1426
- const log = [];
1427
- Object.defineProperty(instance, RESPONSE_LOG, {
1428
- value: log
1429
- });
1430
- for (const method of ANSWER_METHODS){
1431
- const stub = stubs.get(method);
1432
- if (stub) stubs.set(method, recorded(method, stub, log));
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()));
1433
2208
  }
1434
2209
  }
1435
2210
  // Autocomplete is not repliable, but it has a response of its own: Discord accepts
@@ -1439,7 +2214,7 @@ function createMockInteraction(Class, props) {
1439
2214
  if (instance.type === discord_js.InteractionType.ApplicationCommandAutocomplete) {
1440
2215
  instance.responded = false;
1441
2216
  stubs.set('respond', createMockFn(async (choices)=>{
1442
- 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);
1443
2218
  // Discord refuses a list longer than its limit, and the menu shows nothing
1444
2219
  if (Array.isArray(choices) && choices.length > MAX_AUTOCOMPLETE_CHOICES) {
1445
2220
  throw createDiscordError(50035, `Invalid Form Body\ndata.choices[BASE_TYPE_MAX_LENGTH]: Must be ${MAX_AUTOCOMPLETE_CHOICES} or fewer in length.`);
@@ -1468,6 +2243,71 @@ function createMockInteraction(Class, props) {
1468
2243
  const unset = (key)=>!Object.prototype.hasOwnProperty.call(instance, key);
1469
2244
  const generatedId = unset('id') ? instance.id = nextSnowflake() : undefined;
1470
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
+ }
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
2275
+ const raw = own('member');
2276
+ // Given with a guild, a raw-shaped member is kept as the cached server's member it was before raw members
2277
+ if (isRawMember(raw) && own('guild')) {
2278
+ controller_decorator.warnOnce(mockLogger, "The member given is a raw member, from a server the bot isn't in, but the mock was given a guild, so it reads as a " + 'cached server: leave the guild out for a raw member, or give a GuildMember.');
2279
+ } else if (isRawMember(raw)) {
2280
+ const rawOnly = "The member given is a raw member, from a server the bot isn't in";
2281
+ if (!own('guildId')) throw new Error(`${rawOnly}, so the mock needs that server's guildId.`);
2282
+ if (own('channel') !== undefined) throw new Error(`${rawOnly}, where discord.js caches no channel, but the mock was given a channel: leave the channel out.`);
2283
+ const givenUser = own('user');
2284
+ if (givenUser && givenUser.id !== raw.user.id) {
2285
+ throw new Error(`The member given is user ${raw.user.id}'s, but the mock's user is ${givenUser.id}: give the same user, or leave one of them out.`);
2286
+ }
2287
+ if (!givenUser) {
2288
+ const { id, username, global_name: globalName, discriminator, avatar } = raw.user;
2289
+ instance.user = createMockUser({
2290
+ id,
2291
+ username,
2292
+ globalName: globalName ?? null,
2293
+ discriminator,
2294
+ avatar: avatar ?? null
2295
+ });
2296
+ }
2297
+ Object.defineProperty(instance, 'guild', {
2298
+ value: null,
2299
+ writable: true,
2300
+ enumerable: true,
2301
+ configurable: true
2302
+ });
2303
+ // Only its channelId: discord.js caches no channel from a server the bot isn't in
2304
+ Object.defineProperty(instance, 'channel', {
2305
+ value: null,
2306
+ writable: true,
2307
+ enumerable: true,
2308
+ configurable: true
2309
+ });
2310
+ }
1471
2311
  const userGiven = !unset('user');
1472
2312
  if (!userGiven) instance.user = mockUser();
1473
2313
  // Assigned once by discord.js's Base constructor, as on a message, and the gateway caches the user on it
@@ -1513,12 +2353,13 @@ function createMockInteraction(Class, props) {
1513
2353
  configurable: true
1514
2354
  });
1515
2355
  }
1516
- // 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
1517
2358
  let channel;
1518
2359
  if (unset('channel')) {
1519
2360
  Object.defineProperty(instance, 'channel', {
1520
2361
  get: ()=>{
1521
- if (channel === undefined) {
2362
+ if (channel === undefined || own('guildId') && own('guild') instanceof discord_js.Guild) {
1522
2363
  channel = channelFor(own('guild'), own('guildId'), own('channelId'), own('user'));
1523
2364
  cacheChannel(channel, own('guild'), own('client'));
1524
2365
  }
@@ -1573,7 +2414,73 @@ function createMockInteraction(Class, props) {
1573
2414
  live('targetMember', ()=>own('guildId') ? memberFor(own('guild'), targetUser(), true, own('guildId')) : null);
1574
2415
  }
1575
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);
1576
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();
1577
2484
  // Discord sends the user's locale with every interaction, and the server's preferred one with one made in a server
1578
2485
  if (discord_js.BaseInteraction.prototype.isPrototypeOf(instance)) {
1579
2486
  if (own('locale') === undefined) instance.locale = discord_js.Locale.EnglishUS;
@@ -1597,6 +2504,32 @@ function createMockInteraction(Class, props) {
1597
2504
  }
1598
2505
  for (const kind of choices)if (!Object.prototype.hasOwnProperty.call(instance, kind)) instance[kind] = new discord_js.Collection();
1599
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
+ }
1600
2533
  // Options assigned after creation, directly or through Object.assign, belong to it as those given do
1601
2534
  return stubDeep(instance, stubs, (prop, value)=>{
1602
2535
  if (prop === 'options') ownOptions(instance, value);
@@ -1608,31 +2541,36 @@ function createMockInteraction(Class, props) {
1608
2541
  /**
1609
2542
  * A mock function that answers property access with another one, so `cache.store.flush()` works
1610
2543
  * on a double whose shape is an interface with nothing at runtime to read it from.
1611
- */ function stubCallable() {
2544
+ */ function stubCallable(member) {
1612
2545
  const fn = createMockFn();
1613
- const nested = new Map();
1614
- return new Proxy(fn, {
1615
- get (target, prop) {
1616
- if (typeof prop === 'symbol') return Reflect.get(target, prop, target);
1617
- const key = prop;
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
+ });
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
2554
+ // keys. Its prototype makes them rather than a Proxy around it, so it stays the plain mock function matchers read
2555
+ const inherited = Object.getPrototypeOf(fn);
2556
+ Object.setPrototypeOf(fn, new Proxy(inherited, {
2557
+ get (proto, prop, receiver) {
2558
+ if (typeof prop === 'symbol' || prop in proto) return Reflect.get(proto, prop, receiver);
1618
2559
  // Never thenable — otherwise awaiting a mock hangs on itself
1619
- if (key === 'then') return undefined;
1620
- // The mock's own API (`mock`, `mockReturnValue`, `_isMockFunction`, …) and
1621
- // the function intrinsics pass straight through.
1622
- if (key in target) return Reflect.get(target, prop, target);
1623
- if (!nested.has(key)) nested.set(key, stubCallable());
1624
- return nested.get(key);
1625
- },
1626
- set (target, prop, value) {
1627
- Object.defineProperty(target, prop, {
1628
- value,
2560
+ if (prop === 'then') return undefined;
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);
2564
+ Object.defineProperty(receiver, prop, {
2565
+ value: nested,
1629
2566
  writable: true,
1630
- enumerable: true,
2567
+ enumerable: false,
1631
2568
  configurable: true
1632
2569
  });
1633
- return true;
2570
+ return nested;
1634
2571
  }
1635
- });
2572
+ }));
2573
+ return fn;
1636
2574
  }
1637
2575
  /**
1638
2576
  * Creates a mock of any type, with no class needed, for service doubles and interfaces.
@@ -1687,6 +2625,7 @@ function createMockInteraction(Class, props) {
1687
2625
  if (key === 'then') return undefined;
1688
2626
  // An explicitly supplied prop wins over the auto-stub.
1689
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`
1690
2629
  if (!stubs.has(key)) stubs.set(key, stubCallable());
1691
2630
  return stubs.get(key);
1692
2631
  },
@@ -1726,11 +2665,12 @@ function createMockInteraction(Class, props) {
1726
2665
  * @group Testing
1727
2666
  * @category Mocks
1728
2667
  * @see {@link createMockInteraction}
1729
- */ const createMockUser = (props = {})=>createMockInteraction(discord_js.User, {
1730
- id: nextSnowflake(),
2668
+ */ function createMockUser(props = {}) {
2669
+ return createMockInteraction(discord_js.User, {
1731
2670
  bot: false,
1732
2671
  ...props
1733
2672
  });
2673
+ }
1734
2674
  /**
1735
2675
  * Creates a mock {@link Client}, with `users`, `channels`, `guilds` and `application.commands` ready to stub.
1736
2676
  *
@@ -1844,6 +2784,18 @@ const HOLDS = [
1844
2784
  [
1845
2785
  discord_js.PermissionOverwriteManager.prototype,
1846
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
1847
2799
  ]
1848
2800
  ];
1849
2801
  /**
@@ -1859,6 +2811,11 @@ const HOLDS = [
1859
2811
  value: cache,
1860
2812
  writable: true
1861
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
+ });
1862
2819
  const holds = HOLDS.find(([Manager])=>Manager === prototype || Object.prototype.isPrototypeOf.call(Manager, prototype))?.[1];
1863
2820
  if (holds) Object.defineProperty(manager, 'holds', {
1864
2821
  value: holds
@@ -1866,14 +2823,25 @@ const HOLDS = [
1866
2823
  let own;
1867
2824
  Object.defineProperty(manager, 'client', {
1868
2825
  get: ()=>{
1869
- const owner = manager.guild ?? manager.channel ?? manager.thread;
2826
+ const owner = manager.guild ?? manager.channel ?? manager.thread ?? manager.message;
1870
2827
  return owner instanceof discord_js.Base ? owner.client : own ?? (own = createMockClient());
1871
2828
  },
1872
- 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
+ }),
1873
2836
  configurable: true
1874
2837
  });
1875
2838
  return stubDeep(manager);
1876
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
+ }
1877
2845
  /**
1878
2846
  * Creates a mock {@link Guild}, with the `members`, `channels`, `roles` and `bans` managers ready to stub.
1879
2847
  *
@@ -1910,8 +2878,9 @@ const HOLDS = [
1910
2878
  * @see {@link createMockMessage}
1911
2879
  */ function createMockGuild(overrides = {}) {
1912
2880
  const instance = Object.create(discord_js.Guild.prototype);
1913
- instance.id = nextSnowflake();
1914
- 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);
1915
2884
  if (overrides.name !== undefined) instance.name = overrides.name;
1916
2885
  if (overrides.preferredLocale !== undefined) instance.preferredLocale = overrides.preferredLocale;
1917
2886
  instance.members = managerWith(discord_js.GuildMemberManager.prototype, overrides.members);
@@ -1956,7 +2925,8 @@ const HOLDS = [
1956
2925
  * @see {@link createMockGuild}
1957
2926
  */ function createMockChannel(Class, props = {}) {
1958
2927
  const instance = Object.create(Class.prototype);
1959
- instance.id = nextSnowflake();
2928
+ const generatedId = props.id === undefined ? nextSnowflake() : undefined;
2929
+ instance.id = generatedId;
1960
2930
  const is = (Base)=>Base.prototype.isPrototypeOf(Class.prototype) || Class === Base;
1961
2931
  // Text and announcement channels: messages, and threads made in the channel
1962
2932
  if (is(discord_js.TextChannel) || is(discord_js.NewsChannel)) {
@@ -1984,6 +2954,7 @@ const HOLDS = [
1984
2954
  // A server's channel has its overwrites, none until a test puts some in the cache, which its permissions read
1985
2955
  if (is(discord_js.GuildChannel) && !is(discord_js.ThreadChannel)) instance.permissionOverwrites = managerWith(discord_js.PermissionOverwriteManager.prototype, undefined);
1986
2956
  Object.assign(instance, props);
2957
+ defineMadeTime(instance, generatedId);
1987
2958
  const stubs = new Map();
1988
2959
  const channel = stubDeep(instance, stubs);
1989
2960
  // Each manager knows its channel, as discord.js's do, and a thread's members their thread
@@ -2108,11 +3079,25 @@ const CHANNEL_TYPE_GUARDS = [
2108
3079
  });
2109
3080
  return stubDeep(manager);
2110
3081
  }
2111
- /** A server's @everyone role, which has the server's id and ranks lowest, at position 0. */ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
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');
3086
+ // A server's @everyone role, which has the server's id and ranks lowest, at position 0; with strict mocks, the
3087
+ // permissions Discord gives it in a new server
3088
+ function everyoneRole(guildId) {
3089
+ const strict = strictMocks();
3090
+ const role = createMockInteraction(discord_js.Role, {
2112
3091
  id: guildId,
2113
3092
  name: '@everyone',
2114
- position: 0
3093
+ position: 0,
3094
+ ...strict ? {
3095
+ permissions: new discord_js.PermissionsBitField(NEW_SERVER_EVERYONE_PERMISSIONS).freeze()
3096
+ } : {}
2115
3097
  });
3098
+ if (!strict) madeEveryones.add(role);
3099
+ return role;
3100
+ }
2116
3101
  /**
2117
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,
2118
3103
  * or a role of its own at position 0, as every server has one.
@@ -2139,6 +3124,68 @@ const CHANNEL_TYPE_GUARDS = [
2139
3124
  });
2140
3125
  return manager;
2141
3126
  }
3127
+ /**
3128
+ * Creates the member Discord sends with an interaction from a server the bot isn't in, as discord.js keeps it.
3129
+ *
3130
+ * Give it as an interaction's `member`, with the server's `guildId` and no `guild`, to test a user-installed command
3131
+ * run there: the interaction reads `inRawGuild()` true, `guild` and `channel` null, `user` the member's user and
3132
+ * `memberPermissions` the member's, and a user option's member is the plain member Discord resolves too.
3133
+ *
3134
+ * @remarks
3135
+ * It is plain data, not a `GuildMember`: `roles` are role ids, `permissions` is the permission bitfield as a string,
3136
+ * and the rest are as Discord sends them, `null` for what may be absent and `false` for a flag. Code that reads
3137
+ * `member.roles.cache` throws on it, as it does in Discord.
3138
+ *
3139
+ * @param overrides - Fields for the member; see {@link MockRawMemberOverrides}.
3140
+ * @returns The member, as an `APIInteractionGuildMember`.
3141
+ *
3142
+ * @example
3143
+ * ```ts
3144
+ * import { expect } from 'vitest'
3145
+ *
3146
+ * const member = createMockRawMember({ roles: ['300000000000000001'], permissions: [PermissionFlagsBits.SendMessages] })
3147
+ * const command = createMockInteraction(ChatInputCommandInteraction, { guildId: '100000000000000001', member })
3148
+ * expect(command.inRawGuild()).toBe(true)
3149
+ * ```
3150
+ *
3151
+ * @group Testing
3152
+ * @category Mocks
3153
+ * @see {@link createMockMember}
3154
+ */ function createMockRawMember(overrides = {}) {
3155
+ const { permissions, user, ...given } = overrides;
3156
+ return {
3157
+ user: {
3158
+ id: nextSnowflake(),
3159
+ username: 'user',
3160
+ discriminator: '0',
3161
+ global_name: null,
3162
+ avatar: null,
3163
+ ...user
3164
+ },
3165
+ roles: [],
3166
+ nick: null,
3167
+ avatar: null,
3168
+ banner: null,
3169
+ premium_since: null,
3170
+ communication_disabled_until: null,
3171
+ joined_at: new Date().toISOString(),
3172
+ deaf: false,
3173
+ mute: false,
3174
+ pending: false,
3175
+ flags: 0,
3176
+ ...given,
3177
+ permissions: String(new discord_js.PermissionsBitField(permissions ?? NEW_SERVER_EVERYONE_PERMISSIONS).bitfield)
3178
+ };
3179
+ }
3180
+ /** Whether `value` is a member Discord sends from a server the bot isn't in: plain data, not a `GuildMember`. */ function isRawMember(value) {
3181
+ if (typeof value !== 'object' || value === null || value instanceof discord_js.GuildMember) return false;
3182
+ const { roles, permissions, user } = value;
3183
+ return Array.isArray(roles) && typeof permissions === 'string' && typeof user === 'object' && user !== null;
3184
+ }
3185
+ /** A user option's member as Discord resolves it with an interaction from a server the bot isn't in. */ function rawResolvedMember() {
3186
+ const { user: _user, deaf: _deaf, mute: _mute, ...resolved } = createMockRawMember();
3187
+ return resolved;
3188
+ }
2142
3189
  /**
2143
3190
  * Creates a mock {@link GuildMember}: a user in a server, with the roles given.
2144
3191
  *
@@ -2150,8 +3197,9 @@ const CHANNEL_TYPE_GUARDS = [
2150
3197
  * highest, by position, then the lower id. `permissions` are computed as discord.js computes them: every permission for
2151
3198
  * the server's owner, and otherwise its roles' permissions combined, @everyone's included, so a member with no roles has
2152
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
2153
- * server has it as its `member`, and `memberPermissions` are its permissions. A DM sent to the member goes through its
2154
- * 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.
2155
3203
  *
2156
3204
  * @param overrides - The member's user, server, roles and nickname; see {@link MockMemberOverrides}.
2157
3205
  * @returns The mock member.
@@ -2198,6 +3246,218 @@ const CHANNEL_TYPE_GUARDS = [
2198
3246
  return guild ? cached(cacheOf(guild.channels), channelId, make) : make();
2199
3247
  }
2200
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
+ }
2201
3461
  /**
2202
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
2203
3463
  * DM channel, else the server the channel names. A server channel that names none is put in `fallback`'s, and named
@@ -2242,9 +3502,11 @@ const CHANNEL_TYPE_GUARDS = [
2242
3502
  cached(cacheOf(manager), id, ()=>channel);
2243
3503
  }
2244
3504
  }
2245
- /** 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) {
2246
3506
  const guild = Object.create(discord_js.Guild.prototype);
2247
- guild.id = id;
3507
+ const generatedId = id === undefined ? nextSnowflake() : undefined;
3508
+ guild.id = id ?? generatedId;
3509
+ defineMadeTime(guild, generatedId);
2248
3510
  guild.members = managerWith(discord_js.GuildMemberManager.prototype, undefined);
2249
3511
  guild.channels = managerWith(discord_js.GuildChannelManager.prototype, undefined);
2250
3512
  guild.roles = guildRoleManager(guild.id, undefined);
@@ -2253,6 +3515,23 @@ const CHANNEL_TYPE_GUARDS = [
2253
3515
  settleGuild(guild, proxy);
2254
3516
  return proxy;
2255
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
+ }
2256
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) {
2257
3536
  for (const key of [
2258
3537
  'members',
@@ -2274,11 +3553,13 @@ const CHANNEL_TYPE_GUARDS = [
2274
3553
  configurable: true
2275
3554
  });
2276
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));
2277
3561
  Object.defineProperty(members, 'me', {
2278
- get: ()=>{
2279
- const { user } = guild.client;
2280
- return cached(cacheOf(members), user.id, ()=>memberIn(guild, user.id, user));
2281
- },
3562
+ get: ()=>botMember(true),
2282
3563
  configurable: true
2283
3564
  });
2284
3565
  for (const key of [
@@ -2289,6 +3570,8 @@ const CHANNEL_TYPE_GUARDS = [
2289
3570
  if (!Object.prototype.hasOwnProperty.call(item, 'guild')) item.guild = guild;
2290
3571
  }
2291
3572
  }
3573
+ // Strict mocks cache the bot's member from the start, as the gateway does when the bot joins, so lookups find it
3574
+ if (strictMocks()) void members.me;
2292
3575
  }
2293
3576
  /** The ids each kind of mention in `content` names, in order and once each. */ function mentionedIds(content) {
2294
3577
  const ids = (pattern)=>[
@@ -2350,8 +3633,11 @@ const CHANNEL_TYPE_GUARDS = [
2350
3633
  };
2351
3634
  // Own values, so the prototype's getters, which read the raw mention data, are not reached
2352
3635
  const instance = Object.create(discord_js.MessageMentions.prototype);
3636
+ const parsedUsers = new discord_js.Collection(mentioned.users);
2353
3637
  for (const [key, value] of Object.entries({
2354
3638
  ...mentioned,
3639
+ parsedUsers,
3640
+ crosspostedChannels: new discord_js.Collection(),
2355
3641
  everyone: false,
2356
3642
  client,
2357
3643
  guild: guild ?? null
@@ -2364,11 +3650,14 @@ const CHANNEL_TYPE_GUARDS = [
2364
3650
  return stubDeep(instance);
2365
3651
  }
2366
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;
2367
- /** 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) {
2368
3657
  // Read only what the message really has: a mock's unset fields are stubs
2369
3658
  const from = message ?? {};
2370
3659
  return {
2371
- id: typeof from.id === 'string' ? from.id : nextSnowflake(),
3660
+ id: typeof from.id === 'string' ? from.id : allocate ? nextSnowflake() : '',
2372
3661
  content: typeof from.content === 'string' ? from.content : undefined,
2373
3662
  components: Array.isArray(from.components) ? from.components.map(jsonOf) : [],
2374
3663
  embeds: Array.isArray(from.embeds) ? from.embeds.map(jsonOf) : [],
@@ -2376,6 +3665,15 @@ const CHANNEL_TYPE_GUARDS = [
2376
3665
  editedTimestamp: typeof from.editedTimestamp === 'number' ? from.editedTimestamp : null
2377
3666
  };
2378
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
+ };
2379
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) {
2380
3678
  const edit = typeof options === 'string' ? {
2381
3679
  content: options
@@ -2404,29 +3702,28 @@ const CHANNEL_TYPE_GUARDS = [
2404
3702
  editedTimestamp: Math.max(Date.now(), (held.editedTimestamp ?? 0) + 1)
2405
3703
  };
2406
3704
  }
2407
- /** A mock message showing a held message. */ function messageFrom(held) {
2408
- 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, {
2409
3707
  id: held.id,
2410
- ...held.content !== undefined && {
2411
- content: held.content
2412
- },
2413
- components: held.components,
2414
- embeds: held.embeds,
2415
- ...held.flags !== undefined && {
2416
- flags: held.flags
2417
- },
2418
3708
  editedTimestamp: held.editedTimestamp
2419
- });
3709
+ }, made);
2420
3710
  }
2421
3711
  /**
2422
- * A component or embed as a message holds it: a discord.js instance as it is; anything else as
2423
- * its API JSON at the time of the call, behind `toJSON()`.
2424
- */ 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) {
2425
3715
  if (value instanceof discord_js.Component || value instanceof discord_js.Embed) return value;
2426
- const json = structuredClone(typeof value.toJSON === 'function' ? value.toJSON() : value);
2427
- return {
2428
- toJSON: ()=>json
2429
- };
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];
2430
3727
  }
2431
3728
  /**
2432
3729
  * Creates a mock {@link Message} that tracks whether it has been deleted.
@@ -2435,12 +3732,14 @@ const CHANNEL_TYPE_GUARDS = [
2435
3732
  * `@MessageHandler` does not skip it.
2436
3733
  *
2437
3734
  * @remarks
2438
- * `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted, and `edit()`
2439
- * and `reply()` resolve to a new mock message. Without overrides the message is empty, in a server, from a new
2440
- * person who is its `member`; with `guild: null` it is a direct message. Give `author` to send several messages as
2441
- * one user, such as to reach a per-user cooldown. An author given, and the users, roles and channels the content
2442
- * mentions, are cached as the gateway delivers them. It is typed as discord.js emits a message, never in a group DM,
2443
- * 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.
2444
3743
  *
2445
3744
  * @param overrides - The message's content, components and the rest; see {@link MockMessageOverrides}.
2446
3745
  *
@@ -2463,11 +3762,18 @@ const CHANNEL_TYPE_GUARDS = [
2463
3762
  * @see {@link MockMessageOverrides}
2464
3763
  * @see {@link createMockGuild}
2465
3764
  */ function createMockMessage(overrides = {}) {
3765
+ return messageMock(overrides);
3766
+ }
3767
+ function messageMock(overrides = {}, sentBy, made = {}, at) {
3768
+ var _made, _made1, _made2;
2466
3769
  const instance = Object.create(discord_js.Message.prototype);
2467
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);
2468
3774
  instance.deleted = false;
2469
3775
  // Constructor-assigned — set as prototype-based stubs; a user rather than a bot, as dispatch handles only those
2470
- instance.author = overrides.author ?? mockUser();
3776
+ instance.author = overrides.author ?? (_made = made).author ?? (_made.author = mockUser());
2471
3777
  // Getters on the prototype, which the proxy would answer with a stub object: own properties shadow them
2472
3778
  // A channel given says where the message was sent, unless its guild is given too
2473
3779
  const place = overrides.channel ? placeOf(overrides.channel, overrides.guild === undefined ? undefined : overrides.guild?.id ?? null, ()=>{
@@ -2477,22 +3783,37 @@ const CHANNEL_TYPE_GUARDS = [
2477
3783
  guild
2478
3784
  };
2479
3785
  }) : undefined;
2480
- const guild = overrides.guild !== undefined ? overrides.guild : place?.guildId !== undefined ? place.guildId === null ? null : place.guild ?? createMockGuildForMessage(place.guildId) : createMockGuildForMessage();
2481
- const channel = overrides.channel ?? channelFor(guild, guild?.id ?? null, nextSnowflake(), instance.author);
2482
- 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', {
2483
3790
  value: channel,
2484
- writable: true
3791
+ writable: true,
3792
+ configurable: true
2485
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
+ }
2486
3805
  Object.defineProperty(instance, 'guild', {
2487
3806
  value: guild,
2488
- writable: true
3807
+ writable: true,
3808
+ configurable: true
2489
3809
  });
2490
3810
  // The author as a member of the message's server; a direct message has none. A given author's member is the one
2491
3811
  // the server caches, as the gateway resolves it, so every message from that author has the same member
2492
- // 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
2493
3814
  if (guild) memberFor(guild, instance.author, true);
2494
3815
  Object.defineProperty(instance, 'member', {
2495
- get: ()=>guild ? guild.members.resolve(instance.author) : null,
3816
+ get: ()=>instance.guild ? cacheOf(instance.guild.members)?.get(instance.author.id) ?? null : null,
2496
3817
  set: (value)=>Object.defineProperty(instance, 'member', {
2497
3818
  value,
2498
3819
  writable: true,
@@ -2503,15 +3824,16 @@ const CHANNEL_TYPE_GUARDS = [
2503
3824
  configurable: true
2504
3825
  });
2505
3826
  // In a server's text channel, the ids matching the objects; with no guild, a DM
2506
- instance.channelId = channel.id;
2507
- instance.guildId = guild?.id ?? null;
3827
+ instance.channelId = channel?.id ?? at.channelId;
3828
+ instance.guildId = at ? at.guildId : guild?.id ?? null;
2508
3829
  // The thread its channel caches under its id, as discord.js reads it; until 5.0, a placeholder thread otherwise
2509
3830
  let placeholderThread;
2510
3831
  Object.defineProperty(instance, 'thread', {
2511
3832
  get: ()=>{
2512
3833
  const found = cacheOf(instance.channel.threads)?.get(instance.id);
2513
3834
  if (found) return found;
2514
- controller_decorator.warnPlaceholder(mockLogger, 'Message.thread', 'it is null unless the channel caches a thread under the message id, as discord.js reads it', 'Cache one with message.channel.threads.cache.set(message.id, thread).');
3835
+ if (strictMocks()) return null;
3836
+ controller_decorator.warnPlaceholder(mockLogger, 'Message.thread', 'it is null unless the channel caches a thread under the message id, as discord.js reads it', 'Cache one with message.channel.threads.cache.set(message.id, thread), or call useStrictMocks() to have it read null now.');
2515
3837
  return placeholderThread ?? (placeholderThread = stubDeep(Object.create(discord_js.ThreadChannel.prototype)));
2516
3838
  },
2517
3839
  set: (value)=>Object.defineProperty(instance, 'thread', {
@@ -2533,7 +3855,7 @@ const CHANNEL_TYPE_GUARDS = [
2533
3855
  configurable: true
2534
3856
  });
2535
3857
  if (guild instanceof discord_js.Guild) cached(cacheOf(client.guilds), guild.id, ()=>guild);
2536
- cacheChannel(channel, guild, client);
3858
+ if (channel) cacheChannel(channel, guild, client);
2537
3859
  const userCache = cacheOf(client.users);
2538
3860
  for (const user of [
2539
3861
  ...overrides.users ?? [],
@@ -2543,8 +3865,8 @@ const CHANNEL_TYPE_GUARDS = [
2543
3865
  instance.mentions = mentionsOf(overrides.content, client, guild);
2544
3866
  // Data a message always has, real rather than stubbed, so code reading it sees an empty message
2545
3867
  instance.flags = new discord_js.MessageFlagsBitField(overrides.flags);
2546
- instance.components = (overrides.components ?? []).map(asHeld);
2547
- 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'));
2548
3870
  instance.attachments = new discord_js.Collection();
2549
3871
  instance.editedTimestamp = overrides.editedTimestamp ?? null;
2550
3872
  const generatedId = overrides.id === undefined ? nextSnowflake() : undefined;
@@ -2557,14 +3879,27 @@ const CHANNEL_TYPE_GUARDS = [
2557
3879
  if (instance.deleted) throw alreadyDeleted();
2558
3880
  instance.deleted = true;
2559
3881
  }));
2560
- 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)=>{
2561
3884
  if (instance.deleted) throw alreadyDeleted();
2562
- 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;
2563
3895
  }));
2564
- stubs.set('reply', createMockFn(async ()=>{
3896
+ stubs.set('reply', createMockFn(async (options)=>{
2565
3897
  if (instance.deleted) throw alreadyDeleted();
2566
- return createMockMessage();
3898
+ return sentMessage(sent(options, false), instance.channel, instance.client);
2567
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)));
2568
3903
  stubs.set('react', createMockFn(async ()=>{
2569
3904
  if (instance.deleted) throw alreadyDeleted();
2570
3905
  }));
@@ -2574,7 +3909,85 @@ const CHANNEL_TYPE_GUARDS = [
2574
3909
  stubs.set('unpin', createMockFn(async ()=>{
2575
3910
  if (instance.deleted) throw alreadyDeleted();
2576
3911
  }));
2577
- 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;
2578
3991
  }
2579
3992
  /** The option type Discord would have sent for a given JavaScript value. */ function optionTypeOf(value) {
2580
3993
  if (typeof value === 'boolean') return discord_js.ApplicationCommandOptionType.Boolean;
@@ -2663,11 +4076,32 @@ const CHANNEL_TYPE_GUARDS = [
2663
4076
  else if (isEntity) option.user = value;
2664
4077
  return option;
2665
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
+ }
2666
4099
  /**
2667
4100
  * Nests the supplied options under the subcommand path they were invoked through,
2668
4101
  * matching the shape Discord sends rather than a flat list.
2669
- */ function buildOptionData(subcommandGroup, subcommand, values, memberOf) {
2670
- 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));
2671
4105
  if (subcommand === null) return leaves;
2672
4106
  const sub = {
2673
4107
  name: subcommand,
@@ -2707,6 +4141,10 @@ const CHANNEL_TYPE_GUARDS = [
2707
4141
  * A whole number is an Integer option and a fraction a Number one, so
2708
4142
  * `getInteger()` reads only a whole number, and `getNumber()` reads either.
2709
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
+ *
2710
4148
  * @typeParam Cached - `any` by default, to match `createMockInteraction(ChatInputCommandInteraction)`; pass it when the
2711
4149
  * interaction under test is cache-pinned.
2712
4150
  *
@@ -2780,7 +4218,8 @@ const CHANNEL_TYPE_GUARDS = [
2780
4218
  if (!unowned.has(name)) unowned.set(name, memberIn(undefined, user.id, user));
2781
4219
  return unowned.get(name);
2782
4220
  }
2783
- if (!members.has(name)) members.set(name, owner.guildId ? memberFor(owner.guild, user, true) : null);
4221
+ const rawOwner = !owner.guild && isRawMember(Object.getOwnPropertyDescriptor(owner, 'member')?.value);
4222
+ if (!members.has(name)) members.set(name, owner.guildId ? rawOwner ? rawResolvedMember() : memberFor(owner.guild, user, true) : null);
2784
4223
  return members.get(name) ?? null;
2785
4224
  };
2786
4225
  base.getUser = createMockFn((name, required)=>{
@@ -2826,15 +4265,19 @@ const CHANNEL_TYPE_GUARDS = [
2826
4265
  ], ()=>false, required));
2827
4266
  base.getFocused = createMockFn((getFull)=>{
2828
4267
  if (focused === null) throw optionError(discord_js.DiscordjsErrorCodes.AutocompleteInteractionOptionNoFocusedOption);
2829
- 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);
2830
4270
  return getFull === true ? {
2831
- ...option,
2832
- focused: true
4271
+ ...option
2833
4272
  } : option.value;
2834
4273
  });
2835
4274
  // `data` is what the framework reads to build a handler's params, so it is materialised here rather than
2836
4275
  // auto-stubbed, or every params assertion would see an empty record.
2837
- 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;
2838
4281
  const resolver = stubDeep(base);
2839
4282
  return resolver;
2840
4283
  }
@@ -2869,7 +4312,7 @@ const CHANNEL_TYPE_GUARDS = [
2869
4312
  * @category Module
2870
4313
  * @see {@link MeoCordTestingModule}
2871
4314
  * @see {@link getResponse}
2872
- * @see {@link https://meocord.dev/docs/4.1/invoke-and-dispatch | Invoke and dispatch}
4315
+ * @see {@link https://meocord.dev/docs/4.2/invoke-and-dispatch | Invoke and dispatch}
2873
4316
  */ class TestingModule {
2874
4317
  /**
2875
4318
  * Resolves the module's `useFactory` providers, awaiting those that return a promise, in dependency
@@ -2908,7 +4351,7 @@ const CHANNEL_TYPE_GUARDS = [
2908
4351
  await this.resolving;
2909
4352
  if (options.ready) {
2910
4353
  const { client = createMockClient(), primary = true } = options.ready === true ? {} : options.ready;
2911
- this.readying ?? (this.readying = this.runReady(client, primary));
4354
+ this.readying ?? (this.readying = this.readyPass.run(()=>this.runReady(client, primary)));
2912
4355
  await this.readying;
2913
4356
  }
2914
4357
  return this;
@@ -2923,12 +4366,21 @@ const CHANNEL_TYPE_GUARDS = [
2923
4366
  * It first gives up the theme read outside calls, if `init({ ready: true })` made it this module's;
2924
4367
  * reads outside calls then return MeoCord's defaults until another module or app is ready.
2925
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
+ *
2926
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
2927
4374
  * `onShutdown`, the calls `invoke`, `dispatch` and `emit` have under way finish first, then the store operations
2928
4375
  * they started, so the store's last writes still reach it. The module stops waiting for the whole sequence after
2929
4376
  * its `shutdownTimeout`, 10 seconds unless set, and logs that it did. Give a test whose fake store never answers, or
2930
4377
  * whose `onShutdown` never settles, a short `shutdownTimeout`.
2931
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
+ *
2932
4384
  * @returns Once every hook has run. Rejects with the error of a hook that failed, or an
2933
4385
  * `AggregateError` naming each when several did.
2934
4386
  *
@@ -2943,14 +4395,18 @@ const CHANNEL_TYPE_GUARDS = [
2943
4395
  * expect(module.get(POOL).ended).toBe(true)
2944
4396
  * ```
2945
4397
  */ async close() {
4398
+ // An onReady awaiting close() would otherwise hold up the close that waits for it
4399
+ this.readyPass.excuseCurrent();
2946
4400
  this.closing ?? (this.closing = (async ()=>{
2947
- // A close during init waits for the hooks it started, so it shuts down whatever they constructed
2948
- await this.readying?.catch(()=>undefined);
2949
- 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
2950
- .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) ?? {}));
2951
4406
  const failures = [];
2952
4407
  await startupRoots.runShutdownSequence(this.container, entries, {
2953
4408
  drainCalls: ()=>startupRoots.callsSettled(this.calls),
4409
+ readyPass: this.readyPass,
2954
4410
  timeoutMs: this.shutdownTimeout,
2955
4411
  hookFailed: (name, error)=>failures.push({
2956
4412
  name,
@@ -2964,6 +4420,11 @@ const CHANNEL_TYPE_GUARDS = [
2964
4420
  })());
2965
4421
  await this.closing;
2966
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
+ }
2967
4428
  async runReady(client, primary) {
2968
4429
  // As the bot claims it before it comes online, so onReady reads the app's theme
2969
4430
  providers.claimAmbientAppTheme(this.container);
@@ -2979,6 +4440,8 @@ const CHANNEL_TYPE_GUARDS = [
2979
4440
  }, [], {
2980
4441
  resolveFailed: failed,
2981
4442
  hookFailed: failed
4443
+ }, {
4444
+ runOnReady: this.readyPass.runOnReady
2982
4445
  });
2983
4446
  throwFailures('onReady', failures);
2984
4447
  }
@@ -3050,7 +4513,9 @@ const CHANNEL_TYPE_GUARDS = [
3050
4513
  * @returns Whether the handler ran, and the error a filter handled, if any. Rejects with an error no
3051
4514
  * filter handles, or with the error a filter throws: the built-in fallback, which answers such
3052
4515
  * errors in the bot, does not run here. Rejects before running anything with an interaction or a
3053
- * 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.
3054
4519
  *
3055
4520
  * @example
3056
4521
  * ```ts
@@ -3072,6 +4537,7 @@ const CHANNEL_TYPE_GUARDS = [
3072
4537
  * expect(outcome.error).toBeInstanceOf(GuardDeniedError)
3073
4538
  * ```
3074
4539
  */ async invoke(controller, methodName, ...args) {
4540
+ this.warnIfClosed();
3075
4541
  await this.init();
3076
4542
  if (!this.controllers.includes(controller)) {
3077
4543
  throw new Error(`${controller.name} is not a controller of this testing module. Add it to \`controllers\`.`);
@@ -3101,6 +4567,7 @@ const CHANNEL_TYPE_GUARDS = [
3101
4567
  hooks = providers.messageCommandHooks(route, params, first, start, given, this.messageOptions.types);
3102
4568
  }
3103
4569
  }
4570
+ this.checkArgumentCount(controller, methodName, instance[methodName], first, callArgs.length);
3104
4571
  const presenter = providers.appPresenterOf(this.container);
3105
4572
  // A message command's errors are drawn by the app's presenter too, as an interaction's are
3106
4573
  const client = first instanceof discord_js.BaseInteraction || first instanceof discord_js.Message ? first.client : undefined;
@@ -3117,6 +4584,19 @@ const CHANNEL_TYPE_GUARDS = [
3117
4584
  error
3118
4585
  };
3119
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
+ }
3120
4600
  /** Counts `call` as under way until it settles. */ track(call) {
3121
4601
  this.calls.add(call);
3122
4602
  void call.then(()=>this.calls.delete(call), ()=>this.calls.delete(call));
@@ -3143,6 +4623,8 @@ const CHANNEL_TYPE_GUARDS = [
3143
4623
  controllerClasses: this.controllers,
3144
4624
  messageOptions: this.messageOptions,
3145
4625
  logger,
4626
+ // A test's fake clock can pass 5 s inside a handler, so the slow-handler warning waits to be asked for
4627
+ warnSlowMessageHandlers: false,
3146
4628
  // Strict: an answer MeoCord fails to build rejects the dispatch, where a bot logs it and carries on
3147
4629
  fallback: startupRoots.createFallback(logger, ()=>this.messageOptions, {
3148
4630
  strict: true,
@@ -3160,6 +4642,7 @@ const CHANNEL_TYPE_GUARDS = [
3160
4642
  return this.dispatcher;
3161
4643
  }
3162
4644
  async dispatch(input, options) {
4645
+ this.warnIfClosed();
3163
4646
  await this.init();
3164
4647
  this.registerClient(input);
3165
4648
  const dispatcher = this.dispatcherOf();
@@ -3177,6 +4660,7 @@ const CHANNEL_TYPE_GUARDS = [
3177
4660
  unhandled: (error)=>unhandled.push(error)
3178
4661
  };
3179
4662
  if (options) {
4663
+ noteReactionDispatch(input);
3180
4664
  await this.track(dispatcher.reaction(input, {
3181
4665
  user: options.user,
3182
4666
  action: options.action ?? enum_index.ReactionHandlerAction.ADD
@@ -3206,15 +4690,20 @@ const CHANNEL_TYPE_GUARDS = [
3206
4690
  };
3207
4691
  }
3208
4692
  /**
3209
- * Emits a client event to the module's `@On` and `@Once` handlers, through the same pipeline the app
3210
- * runs them in: the global guards of the module's `app`, then each handler's own. Handlers on the
3211
- * module's controllers, class providers and their dependencies all receive it. A `@Once` handler
3212
- * 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`.
3213
4701
  *
3214
4702
  * @param event - The client event, such as `'guildMemberAdd'`.
3215
4703
  * @param args - The event's arguments, typed from discord.js's `ClientEvents`.
3216
- * @returns How many handlers ran. Rejects once every handler has settled if any threw: with that
3217
- * 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.
3218
4707
  *
3219
4708
  * @example
3220
4709
  * ```ts
@@ -3226,6 +4715,7 @@ const CHANNEL_TYPE_GUARDS = [
3226
4715
  * expect(ran).toBe(1)
3227
4716
  * ```
3228
4717
  */ async emit(event, ...args) {
4718
+ this.warnIfClosed();
3229
4719
  await this.init();
3230
4720
  const calls = [];
3231
4721
  for (const cls of this.eventClasses){
@@ -3270,7 +4760,10 @@ const CHANNEL_TYPE_GUARDS = [
3270
4760
  this.appWarnUnanswered = appWarnUnanswered;
3271
4761
  this.services = services;
3272
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;
3273
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();
3274
4767
  /** The calls under way, each removed once it settles: what `close()` lets finish before the cooldown store stops. */ this.calls = new Set();
3275
4768
  }
3276
4769
  }
@@ -3309,7 +4802,7 @@ const CHANNEL_TYPE_GUARDS = [
3309
4802
  * @group Testing
3310
4803
  * @category Module
3311
4804
  * @see {@link MeoCordTestingModule}
3312
- * @see {@link https://meocord.dev/docs/4.1/testing | The testing module}
4805
+ * @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
3313
4806
  */ class TestingModuleBuilder {
3314
4807
  /**
3315
4808
  * Replaces a provider with a test double.
@@ -3335,6 +4828,10 @@ const CHANNEL_TYPE_GUARDS = [
3335
4828
  /**
3336
4829
  * Replaces a guard with a stub wherever it applies, globally or on a controller or handler.
3337
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
+ *
3338
4835
  * @param guard - The guard class to replace.
3339
4836
  * @example
3340
4837
  * ```ts
@@ -3352,6 +4849,10 @@ const CHANNEL_TYPE_GUARDS = [
3352
4849
  * Replaces an interceptor with a stub wherever it applies, globally or on a controller or handler.
3353
4850
  * The stub's `intercept` receives the context and `next`; call `next.handle()` to run the handler.
3354
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
+ *
3355
4856
  * @param interceptor - The interceptor class to replace.
3356
4857
  * @example
3357
4858
  * ```ts
@@ -3369,6 +4870,10 @@ const CHANNEL_TYPE_GUARDS = [
3369
4870
  * Replaces an exception filter with a stub wherever it applies. The filter's `@Catch` still decides
3370
4871
  * which errors reach the stub.
3371
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
+ *
3372
4877
  * @param filter - The filter class to replace.
3373
4878
  * @example
3374
4879
  * ```ts
@@ -3457,199 +4962,271 @@ const CHANNEL_TYPE_GUARDS = [
3457
4962
  * its command's builder does not register, a customId pattern given as a command name, a builder that registers
3458
4963
  * another name, and component patterns that can match the same customId are named, as the bot names them.
3459
4964
  *
4965
+ * Each check reports every startup error it finds, and the first is thrown, unchanged; when there are several, each
4966
+ * is logged first, with the file it comes from. After {@link reportAllStartupErrors}, the errors decorators kept on
4967
+ * the classes the module runs are among them.
4968
+ *
3460
4969
  * @returns The compiled module.
4970
+ * @throws Error for the first startup error the checks find.
3461
4971
  */ compile() {
3462
4972
  // The bound the bot's config gives it, with the same words
3463
4973
  const shutdownTimeout = this.options.shutdownTimeout === undefined ? undefined : startupRoots.shutdownTimeoutProblem(this.options.shutdownTimeout);
3464
4974
  if (shutdownTimeout) throw new TypeError(`shutdownTimeout ${shutdownTimeout}.`);
3465
- const container = new inversify.Container();
3466
- const stages = this.globalStages();
3467
- if (stages) providers.bindGlobalStages(container, stages);
3468
- // Bound first, as in the app, so a class that injects it gets this instance
3469
- const appClasses = [];
3470
- const translator = ()=>container.isBound(controller_decorator.Translator) ? container.get(controller_decorator.Translator) : undefined;
3471
- container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
3472
- // A testing module runs as one process, so a cross-shard call runs once, here
3473
- container.bind(providers.ShardContext).toConstantValue(new providers.ShardContext(undefined, providers.shardCallHandler(container, ()=>[
3474
- ...appClasses,
3475
- ...[
3476
- ...providers$1.keys()
3477
- ].filter(providers.isAppClassToken)
3478
- ], 'this testing module')));
3479
- providers.bindsOwnToken(container, providers.HandlerRegistry);
3480
- providers.bindsOwnToken(container, providers.ShardContext);
3481
- // Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
3482
- // by token, then the overrides, which win
3483
- const providers$1 = providers.providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
3484
- for (const [token, provider] of providers.providerMap(this.options.providers ?? [], "the testing module's providers"))providers$1.set(token, provider);
3485
- for (const [token, override] of this.overrides)providers$1.set(token, override);
3486
- const services = this.wiring?.services ?? [];
3487
- const appOptions = this.options.app ? Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app) : undefined;
3488
- // The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
3489
- // test provides the CooldownStore, so nothing builds the app's or asks for what it injects
3490
- const store = providers$1.has(controller_decorator.CooldownStore) ? undefined : appOptions?.cooldownStore;
3491
- // The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
3492
- const themeResolver = controller_decorator.themeResolverClass(stages?.themeFor?.resolvers);
3493
- // Every class the module runs, as the app finds them, except stage classes an override stands in for
3494
- const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
3495
- const startup = startupRoots.startupClasses({
3496
- controllers: this.options.controllers ?? [],
3497
- services,
3498
- providers: providers$1,
3499
- stages,
3500
- presenter: this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter,
3501
- cooldownStore: store,
3502
- themeFor: stages?.themeFor?.resolvers,
3503
- observers: [
3504
- ...this.options.app ? providers.appObservers(this.options.app) : [],
3505
- ...this.options.observers ?? []
3506
- ],
3507
- stubs: [
3508
- ...this.guardOverrides.keys(),
3509
- ...this.interceptorOverrides.keys(),
3510
- ...this.filterOverrides.keys()
3511
- ]
3512
- });
3513
- const hasTranslator = i18n !== undefined || providers$1.has(controller_decorator.Translator);
3514
- startupRoots.assertStartupClasses(startup, {
3515
- translator: hasTranslator
3516
- });
3517
- providers.markStartupChecked(container, [
3518
- ...startup.classes,
3519
- ...providers.meocordClasses()
3520
- ], startupRoots.lateGuardCheck(providers$1, hasTranslator));
3521
- // The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
3522
- const needClient = startup.classes.filter((cls)=>providers.injectedTokens(cls).includes(discord_js.Client));
3523
- if (needClient.length > 0 && !providers$1.has(discord_js.Client)) {
3524
- container.bind(discord_js.Client).toDynamicValue(()=>{
3525
- 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() }.');
3526
- });
3527
- }
3528
- // Bind guard overrides — prevents inversify from auto-wiring guard dependencies
3529
- for (const [guardClass, stub] of this.guardOverrides){
3530
- container.bind(guardClass).toConstantValue(stub);
3531
- }
3532
- for (const [filterClass, stub] of this.filterOverrides){
3533
- container.bind(filterClass).toConstantValue(stub);
3534
- }
3535
- for (const [interceptorClass, stub] of this.interceptorOverrides){
3536
- container.bind(interceptorClass).toConstantValue(stub);
3537
- }
3538
- // The app's cooldown policy, so a test of a failing store sees what the bot would do
3539
- if (appOptions && !container.isBound(providers.COOLDOWN_POLICY)) container.bind(providers.COOLDOWN_POLICY).toConstantValue(providers.cooldownPolicyFrom(appOptions));
3540
- // The app's translator, unless a provider stands in for it
3541
- if (i18n && !providers$1.has(controller_decorator.Translator)) {
3542
- container.bind(controller_decorator.Translator).toConstantValue(i18n);
3543
- providers.bindsOwnToken(container, controller_decorator.Translator);
3544
- }
3545
- // Recursively bind controllers and their dependencies, skipping already-bound tokens
3546
- const bindClass = (cls)=>{
3547
- // A provided class is bound by its own provider, wherever in the list that provider comes
3548
- if (container.isBound(cls) || providers$1.has(cls)) return;
3549
- if (providers.injectedTokens(cls).includes(controller_decorator.ExecutionContext)) throw providers.singletonContextError(cls);
3550
- providers.assertStartupChecked(container, cls);
3551
- providers.makeInjectable(cls);
3552
- container.bind(cls).toSelf().inSingletonScope();
3553
- // By constructor type or @inject token, as the app binds them
3554
- for (const dep of providers.injectedTokens(cls)){
3555
- if (dep === controller_decorator.Translator && !container.isBound(controller_decorator.Translator)) throw controller_decorator.missingTranslatorError(cls);
3556
- if (providers.isAppClassToken(dep)) bindClass(dep);
3557
- }
3558
- };
3559
- // The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
3560
- // app binds it, so a class that injects the token gets the store
3561
- if (!providers$1.has(controller_decorator.CooldownStore)) {
3562
- if (store) {
3563
- bindClass(store);
3564
- container.bind(controller_decorator.CooldownStore).toService(store);
3565
- } else {
3566
- container.bind(controller_decorator.CooldownStore).toConstantValue(new controller_decorator.MemoryCooldownStore());
3567
- }
3568
- providers.bindsOwnToken(container, controller_decorator.CooldownStore, store);
3569
- }
3570
- // Before the controllers, so a class token that is provided is not also bound as itself
3571
- for (const provider of providers$1.values())providers.bindProvider(container, provider, bindClass);
3572
- for (const ctrl of this.options.controllers ?? []){
3573
- bindClass(ctrl);
3574
- // Stamp container on controller class so @UseGuard works in tests too
3575
- Reflect.defineMetadata(controller_decorator.META.container, container, ctrl);
3576
- }
3577
- for (const service of services)bindClass(service);
3578
- if (themeResolver) bindClass(themeResolver);
3579
- // The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
3580
- // controllers, and what they inject; factories resolve in the same order
3581
- const order = providers.resolutionOrder(container, providers$1, [
3582
- ...providers$1.keys(),
3583
- ...services,
3584
- ...themeResolver ? [
3585
- themeResolver
3586
- ] : [],
3587
- ...this.options.controllers ?? []
3588
- ], {
3589
- followOwnTokens: false
3590
- });
3591
- appClasses.push(...order.filter((token)=>{
3592
- const provider = providers$1.get(token);
3593
- return providers.isAppClassToken(token) && (!provider || providers.isClassProvider(provider) && provider.useClass === token);
3594
- }));
3595
- providers.assertProvided(container, providers$1, startup.classes, "the testing module's providers");
3596
- // Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
3597
- for (const cls of startup.classes)Reflect.defineMetadata(controller_decorator.META.container, container, cls);
3598
- providers.prepareHandlerStages(container, appClasses);
3599
- const messages = messagesOf(this.options.app);
3600
- // As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
3601
- providers.buildMessageRoutes(this.options.controllers ?? [], messages);
3602
- // As the app does when it is created, so a test sees the refusal the bot would give
3603
- startupRoots.assertDistinctCommands(this.options.controllers ?? []);
3604
- // A handler with no builder is how a fixture is written, so only what is always a mistake is named
3605
- startupRoots.warnUnregisteredCommands(this.options.controllers ?? [], {
3606
- missingBuilders: false
3607
- });
3608
- startupRoots.warnInheritedRoutes(this.options.controllers ?? []);
3609
- startupRoots.warnOverlappingPatterns(this.options.controllers ?? []);
3610
- startupRoots.warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
3611
- if (this.options.app) providers.bindAppPresenter(container, this.options.app);
3612
- const observers = [
3613
- ...this.options.app ? providers.appObservers(this.options.app) : [],
3614
- ...this.options.observers ?? []
3615
- ];
3616
- providers.assertObservers("the testing module's observers", observers);
3617
- providers.bindObservers(container, observers);
3618
- // The store calls ask: the test's own when it provides CooldownStore, else the app's
3619
- const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
3620
- // The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
3621
- // after what it injects
3622
- const lifecycle = providers.resolutionOrder(container, providers$1, [
3623
- ...boundStore ? [
3624
- boundStore
3625
- ] : [],
3626
- ...providers$1.keys(),
3627
- ...services,
3628
- ...themeResolver ? [
3629
- themeResolver
4975
+ // With startupErrors: 'all', what the decorators kept on the classes this module runs
4976
+ const declared = controller_decorator.declaredErrorsOf([
4977
+ ...this.options.app ? [
4978
+ this.options.app
3630
4979
  ] : [],
3631
4980
  ...this.options.controllers ?? [],
3632
- ...observers
3633
- ]).map((token)=>({
3634
- token,
3635
- name: providers.tokenName(token),
3636
- dependencies: providers.tokenDependencies(container, providers$1, token),
3637
- ...token === boundStore && {
3638
- cooldownStore: true
4981
+ ...this.wiring?.services ?? [],
4982
+ ...this.options.observers ?? []
4983
+ ]);
4984
+ // Every error each check finds is reported, as the bot reports them, and the first is thrown
4985
+ try {
4986
+ return controller_decorator.collectStartupErrors(()=>{
4987
+ declared.forEach(controller_decorator.startupError);
4988
+ const container = new inversify.Container();
4989
+ const stages = this.globalStages();
4990
+ if (stages) providers.bindGlobalStages(container, stages);
4991
+ // Bound first, as in the app, so a class that injects it gets this instance
4992
+ const appClasses = [];
4993
+ const translator = ()=>container.isBound(controller_decorator.Translator) ? container.get(controller_decorator.Translator) : undefined;
4994
+ container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
4995
+ // A testing module runs as one process, so a cross-shard call runs once, here
4996
+ container.bind(providers.ShardContext).toConstantValue(new providers.ShardContext(undefined, providers.shardCallHandler(container, ()=>[
4997
+ ...appClasses,
4998
+ ...[
4999
+ ...providers$1.keys()
5000
+ ].filter(providers.isAppClassToken)
5001
+ ], 'this testing module')));
5002
+ providers.bindsOwnToken(container, providers.HandlerRegistry);
5003
+ providers.bindsOwnToken(container, providers.ShardContext);
5004
+ // Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
5005
+ // by token, then the overrides, which win
5006
+ const providers$1 = providers.providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
5007
+ for (const [token, provider] of providers.providerMap(this.options.providers ?? [], "the testing module's providers"))providers$1.set(token, provider);
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
+ }
3639
5041
  }
3640
- }));
3641
- // Recorded as the container makes each one, so close() shuts down exactly what exists
3642
- const constructed = new Set();
3643
- for (const { token } of lifecycle){
3644
- container.onActivation(token, (_context, instance)=>{
3645
- constructed.add(token);
3646
- return instance;
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
5043
+ controller_decorator.stopOnStartupErrors();
5044
+ const services = this.wiring?.services ?? [];
5045
+ const appOptions = this.options.app ? Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app) : undefined;
5046
+ // The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
5047
+ // test provides the CooldownStore, so nothing builds the app's or asks for what it injects
5048
+ const store = providers$1.has(controller_decorator.CooldownStore) ? undefined : appOptions?.cooldownStore;
5049
+ // The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
5050
+ const themeResolver = controller_decorator.themeResolverClass(stages?.themeFor?.resolvers);
5051
+ // Every class the module runs, as the app finds them, except stage classes an override stands in for
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;
5054
+ const startup = startupRoots.startupClasses({
5055
+ controllers: this.options.controllers ?? [],
5056
+ services,
5057
+ providers: providers$1,
5058
+ stages,
5059
+ presenter: appPresenterClass,
5060
+ cooldownStore: store,
5061
+ themeFor: stages?.themeFor?.resolvers,
5062
+ observers: [
5063
+ ...this.options.app ? providers.appObservers(this.options.app) : [],
5064
+ ...this.options.observers ?? []
5065
+ ],
5066
+ stubs: [
5067
+ ...this.guardOverrides.keys(),
5068
+ ...this.interceptorOverrides.keys(),
5069
+ ...this.filterOverrides.keys()
5070
+ ]
5071
+ });
5072
+ const hasTranslator = i18n !== undefined || providers$1.has(controller_decorator.Translator);
5073
+ startupRoots.assertStartupClasses(startup, {
5074
+ translator: hasTranslator
5075
+ });
5076
+ // Those of the stages, guards and other classes they reach
5077
+ controller_decorator.declaredErrorsOf(startup.classes).forEach(controller_decorator.startupError);
5078
+ controller_decorator.stopOnStartupErrors();
5079
+ providers.markStartupChecked(container, [
5080
+ ...startup.classes,
5081
+ ...providers.meocordClasses()
5082
+ ], startupRoots.lateGuardCheck(providers$1, hasTranslator));
5083
+ // The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
5084
+ const needClient = startup.classes.filter((cls)=>providers.injectedTokens(cls).includes(discord_js.Client));
5085
+ if (needClient.length > 0 && !providers$1.has(discord_js.Client)) {
5086
+ container.bind(discord_js.Client).toDynamicValue(()=>{
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() }.');
5088
+ });
5089
+ }
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);
5092
+ // The app's cooldown policy, so a test of a failing store sees what the bot would do
5093
+ if (appOptions && !container.isBound(providers.COOLDOWN_POLICY)) container.bind(providers.COOLDOWN_POLICY).toConstantValue(providers.cooldownPolicyFrom(appOptions));
5094
+ // The app's translator, unless a provider stands in for it
5095
+ if (i18n && !providers$1.has(controller_decorator.Translator)) {
5096
+ container.bind(controller_decorator.Translator).toConstantValue(i18n);
5097
+ providers.bindsOwnToken(container, controller_decorator.Translator);
5098
+ }
5099
+ // Recursively bind controllers and their dependencies, skipping already-bound tokens
5100
+ const bindClass = (cls)=>{
5101
+ // A provided class is bound by its own provider, wherever in the list that provider comes
5102
+ if (container.isBound(cls) || providers$1.has(cls)) return;
5103
+ if (providers.injectedTokens(cls).includes(controller_decorator.ExecutionContext)) return controller_decorator.startupError(providers.singletonContextError(cls));
5104
+ providers.assertStartupChecked(container, cls);
5105
+ providers.makeInjectable(cls);
5106
+ container.bind(cls).toSelf().inSingletonScope();
5107
+ // By constructor type or @inject token, as the app binds them
5108
+ for (const dep of providers.injectedTokens(cls)){
5109
+ if (dep === controller_decorator.Translator && !container.isBound(controller_decorator.Translator)) controller_decorator.startupError(controller_decorator.missingTranslatorError(cls));
5110
+ else if (providers.isAppClassToken(dep)) bindClass(dep);
5111
+ }
5112
+ };
5113
+ // The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
5114
+ // app binds it, so a class that injects the token gets the store
5115
+ if (!providers$1.has(controller_decorator.CooldownStore)) {
5116
+ if (store) {
5117
+ bindClass(store);
5118
+ container.bind(controller_decorator.CooldownStore).toService(store);
5119
+ } else {
5120
+ container.bind(controller_decorator.CooldownStore).toConstantValue(new controller_decorator.MemoryCooldownStore());
5121
+ }
5122
+ providers.bindsOwnToken(container, controller_decorator.CooldownStore, store);
5123
+ }
5124
+ // Before the controllers, so a class token that is provided is not also bound as itself
5125
+ for (const provider of providers$1.values())providers.bindProvider(container, provider, bindClass);
5126
+ for (const ctrl of this.options.controllers ?? [])bindClass(ctrl);
5127
+ for (const service of services)bindClass(service);
5128
+ if (themeResolver) bindClass(themeResolver);
5129
+ // What follows walks what these bound, so a class refused here is not reported again as missing
5130
+ controller_decorator.stopOnStartupErrors();
5131
+ // The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
5132
+ // controllers, and what they inject; factories resolve in the same order
5133
+ const order = providers.resolutionOrder(container, providers$1, [
5134
+ ...providers$1.keys(),
5135
+ ...services,
5136
+ ...themeResolver ? [
5137
+ themeResolver
5138
+ ] : [],
5139
+ ...this.options.controllers ?? []
5140
+ ], {
5141
+ followOwnTokens: false
5142
+ });
5143
+ appClasses.push(...order.filter((token)=>{
5144
+ const provider = providers$1.get(token);
5145
+ return providers.isAppClassToken(token) && (!provider || providers.isClassProvider(provider) && provider.useClass === token);
5146
+ }));
5147
+ providers.assertProvided(container, providers$1, startup.classes, "the testing module's providers");
5148
+ providers.prepareHandlerStages(container, appClasses);
5149
+ const messages = messagesOf(this.options.app);
5150
+ // As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
5151
+ providers.buildMessageRoutes(this.options.controllers ?? [], messages);
5152
+ // As the app does when it is created, so a test sees the refusal the bot would give
5153
+ startupRoots.assertDistinctCommands(this.options.controllers ?? []);
5154
+ if (this.options.app) providers.prepareAppPresenter(container, this.options.app);
5155
+ const observers = [
5156
+ ...this.options.app ? providers.appObservers(this.options.app) : [],
5157
+ ...this.options.observers ?? []
5158
+ ];
5159
+ providers.assertObservers("the testing module's observers", observers);
5160
+ // Only @Observer classes are bound as observers
5161
+ controller_decorator.stopOnStartupErrors();
5162
+ providers.bindObservers(container, observers);
5163
+ // The store calls ask: the test's own when it provides CooldownStore, else the app's
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);
5168
+ // The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
5169
+ // after what it injects
5170
+ const lifecycle = providers.resolutionOrder(container, providers$1, [
5171
+ ...boundStore ? [
5172
+ boundStore
5173
+ ] : [],
5174
+ ...providers$1.keys(),
5175
+ ...services,
5176
+ ...stageServices,
5177
+ ...themeResolver ? [
5178
+ themeResolver
5179
+ ] : [],
5180
+ ...this.options.controllers ?? [],
5181
+ ...observers
5182
+ ]).map((token)=>({
5183
+ token,
5184
+ name: providers.tokenName(token),
5185
+ dependencies: providers.tokenDependencies(container, providers$1, token),
5186
+ ...token === boundStore && {
5187
+ cooldownStore: true
5188
+ }
5189
+ }));
5190
+ // Every check has run, and touched only the container: from here the module is made, once none found an error
5191
+ controller_decorator.stopOnStartupErrors();
5192
+ // Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
5193
+ for (const cls of [
5194
+ ...this.options.controllers ?? [],
5195
+ ...startup.classes
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
+ ]);
5202
+ providers.prepareThemes(container, appClasses);
5203
+ // A handler with no builder is how a fixture is written, so only what is always a mistake is named
5204
+ startupRoots.warnUnregisteredCommands(this.options.controllers ?? [], {
5205
+ missingBuilders: false
5206
+ });
5207
+ startupRoots.warnInheritedRoutes(this.options.controllers ?? []);
5208
+ startupRoots.warnOverlappingPatterns(this.options.controllers ?? []);
5209
+ startupRoots.warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
5210
+ if (this.options.app) providers.bindAppPresenter(container, this.options.app);
5211
+ // Recorded as the container makes each one, so close() shuts down exactly what exists
5212
+ const constructed = new Set();
5213
+ for (const { token } of lifecycle){
5214
+ container.onActivation(token, (_context, instance)=>{
5215
+ constructed.add(token);
5216
+ return instance;
5217
+ });
5218
+ }
5219
+ const warnUnanswered = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.warnUnanswered;
5220
+ return new TestingModule(container, [
5221
+ ...this.options.controllers ?? []
5222
+ ], appClasses, providers$1, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? startupRoots.DEFAULT_SHUTDOWN_TIMEOUT_MS);
3647
5223
  });
5224
+ } catch (error) {
5225
+ if (controller_decorator.isRefusal(error) && !controller_decorator.isExplainedError(error)) {
5226
+ if (controller_decorator.logStartupErrors(error, (text)=>new controller_decorator.Logger('TestingModule').error(text), 'the testing module did not compile')) controller_decorator.markExplained(error);
5227
+ }
5228
+ throw error;
3648
5229
  }
3649
- const warnUnanswered = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.warnUnanswered;
3650
- return new TestingModule(container, [
3651
- ...this.options.controllers ?? []
3652
- ], appClasses, providers$1, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? startupRoots.DEFAULT_SHUTDOWN_TIMEOUT_MS);
3653
5230
  }
3654
5231
  constructor(options, wiring){
3655
5232
  this.options = options;
@@ -3687,7 +5264,7 @@ const CHANNEL_TYPE_GUARDS = [
3687
5264
  * @category Module
3688
5265
  * @see {@link TestingModuleBuilder}
3689
5266
  * @see {@link TestingModule}
3690
- * @see {@link https://meocord.dev/docs/4.1/testing | The testing module}
5267
+ * @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
3691
5268
  */ class MeoCordTestingModule {
3692
5269
  /**
3693
5270
  * Starts building a testing module from the classes a test needs.
@@ -3695,7 +5272,15 @@ const CHANNEL_TYPE_GUARDS = [
3695
5272
  * @param options - The controllers, providers, observers and app the module is built from.
3696
5273
  * @returns The builder, to override what the test replaces before `compile()`.
3697
5274
  */ static create(options) {
3698
- 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
+ });
3699
5284
  }
3700
5285
  /**
3701
5286
  * Starts building a testing module from a whole `@MeoCord` app, wired as the bot wires it.
@@ -3777,6 +5362,29 @@ const CHANNEL_TYPE_GUARDS = [
3777
5362
  providers.adviseInPlaceOfInjecting(TestingModuleBuilder, 'build it with MeoCordTestingModule.create(…) or MeoCordTestingModule.fromApp(…)');
3778
5363
  providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingModule.create(…).compile()');
3779
5364
 
5365
+ /**
5366
+ * Has decorators keep the startup errors they find, as a bot built with `startupErrors: 'all'` does, so a testing
5367
+ * module reports every one at once.
5368
+ *
5369
+ * Call it once in the test runner's setup file, before any test imports a controller: a decorator runs when its class
5370
+ * is defined, as its file is imported. A decorator given what it cannot use then throws nothing as the file loads, and
5371
+ * `MeoCordTestingModule.create(...).compile()` logs every startup error of the classes it runs, each with its handler
5372
+ * and file, then throws the first. Without it, a decorator throws its error as its class is defined.
5373
+ *
5374
+ * @example
5375
+ * ```ts
5376
+ * // vitest.setup.ts
5377
+ * reportAllStartupErrors()
5378
+ * ```
5379
+ *
5380
+ * @group Testing
5381
+ * @category Module
5382
+ * @see {@link MeoCordTestingModule}
5383
+ * @see {@link https://meocord.dev/docs/4.2/configuration | Configuration}
5384
+ */ function reportAllStartupErrors() {
5385
+ controller_decorator.setStartupErrorsMode('all');
5386
+ }
5387
+
3780
5388
  /** The options `@MeoCord` declares on an application class. */ function appOptionsOf(app) {
3781
5389
  const options = Reflect.getMetadata(controller_decorator.META.appOptions, app);
3782
5390
  if (!options) throw new TypeError(`${app.name || 'The given class'} is not decorated with @MeoCord().`);
@@ -3820,7 +5428,7 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3820
5428
  * @group Testing
3821
5429
  * @category Inspection
3822
5430
  * @see {@link findRouteConflicts}
3823
- * @see {@link https://meocord.dev/docs/4.1/components | Buttons, selects and modals}
5431
+ * @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
3824
5432
  */ function resolveRoute(app, input) {
3825
5433
  if ('content' in input) {
3826
5434
  const { messages = {} } = appOptionsOf(app);
@@ -3846,7 +5454,10 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3846
5454
  const { route, params, text } = matched;
3847
5455
  const method = route.meta.methodName;
3848
5456
  const typed = Object.keys(route.types).length > 0;
3849
- return {
5457
+ // The routes ranked behind it that the customId matches too, which dispatch never reaches for it
5458
+ const behind = routes.slice(routes.indexOf(route) + 1);
5459
+ const alsoMatches = behind.filter((other)=>other.meta.type === input.type && startupRoots.readCustomId(other.pattern, other.meta.regex, input.customId)).map((other)=>other.pattern);
5460
+ const resolved = {
3850
5461
  controller: route.controllerClass,
3851
5462
  method,
3852
5463
  handler: route.controllerClass.prototype[method],
@@ -3855,28 +5466,34 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3855
5466
  values: params
3856
5467
  }
3857
5468
  };
5469
+ return alsoMatches.length > 0 ? readableOnly(resolved, {
5470
+ alsoMatches
5471
+ }) : resolved;
3858
5472
  }
3859
5473
  /**
3860
- * Finds component patterns that can match the same `customId`, which MeoCord otherwise only warns about at startup.
5474
+ * Finds component patterns that rank equally and can match the same `customId`, which MeoCord otherwise only warns
5475
+ * about at startup.
3861
5476
  *
3862
- * Use it in a test to keep the warning from reaching production: two patterns that trade a literal for a parameter
3863
- * in opposite places both take one id, and neither is more specific. Patterns are compared within a component type.
5477
+ * Use it in a test to keep the warning from reaching production. Patterns rank segment by segment, a literal before a
5478
+ * param and then the narrower type, so two collide only with the same literals and equally narrow params at each
5479
+ * position, such as `{action:close|reopen}` and `{step:close|confirm}`, which both take `close`; the one listed
5480
+ * first runs. Patterns are compared within a component type.
3864
5481
  *
3865
5482
  * @param app - The application class decorated with `@MeoCord`.
3866
- * @returns Each pair of patterns that can match the same `customId`, with its component type.
5483
+ * @returns Each pair of patterns that rank equally and can match the same `customId`, with its component type.
3867
5484
  *
3868
5485
  * @example
3869
5486
  * ```ts
3870
5487
  * import { expect } from 'vitest'
3871
5488
  *
3872
5489
  * @Controller()
3873
- * class CardController {
3874
- * @Command('card/{id}/open', CommandType.BUTTON)
3875
- * async open(interaction: ButtonInteraction) { await respond(interaction).send('Opened.') }
3876
- * @Command('card/new/{kind}', CommandType.BUTTON)
3877
- * async create(interaction: ButtonInteraction) { await respond(interaction).send('Created.') }
5490
+ * class TicketController {
5491
+ * @Command('ticket/{action:close|reopen}', CommandType.BUTTON)
5492
+ * async act(interaction: ButtonInteraction) { await respond(interaction).send('Done.') }
5493
+ * @Command('ticket/{step:close|confirm}', CommandType.BUTTON)
5494
+ * async step(interaction: ButtonInteraction) { await respond(interaction).send('Next.') }
3878
5495
  * }
3879
- * @MeoCord({ controllers: [CardController], clientOptions: { intents: [] } })
5496
+ * @MeoCord({ controllers: [TicketController], clientOptions: { intents: [] } })
3880
5497
  * class App {}
3881
5498
  * expect(findRouteConflicts(App)).toHaveLength(1)
3882
5499
  * ```
@@ -3885,7 +5502,23 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3885
5502
  * @category Inspection
3886
5503
  * @see {@link resolveRoute}
3887
5504
  */ function findRouteConflicts(app) {
3888
- return startupRoots.findComponentRouteConflicts(startupRoots.buildComponentRoutes(controllersOf(app)));
5505
+ return startupRoots.findComponentRouteConflicts(startupRoots.buildComponentRoutes(controllersOf(app))).map(({ type, patterns, runs })=>readableOnly({
5506
+ type: type,
5507
+ patterns
5508
+ }, {
5509
+ runs
5510
+ }));
5511
+ }
5512
+ /**
5513
+ * `target` with `fields` added as properties a test reads but `toEqual` and its kin, which compare enumerable keys,
5514
+ * don't see, so an assertion written for the fields it had before still holds.
5515
+ */ function readableOnly(target, fields) {
5516
+ for (const [key, value] of Object.entries(fields))Object.defineProperty(target, key, {
5517
+ value,
5518
+ enumerable: false,
5519
+ configurable: true
5520
+ });
5521
+ return target;
3889
5522
  }
3890
5523
 
3891
5524
  /**
@@ -3954,7 +5587,15 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3954
5587
  return new Fields(components);
3955
5588
  }
3956
5589
 
3957
- 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));
3958
5599
  /** Every message key of a catalog, and whether it is a plural. */ function leaves(catalog, prefix = '') {
3959
5600
  return Object.entries(catalog).flatMap(([name, value])=>{
3960
5601
  const key = `${prefix}${name}`;
@@ -3973,8 +5614,9 @@ const isPlural = (value)=>typeof value === 'object' && value !== null && typeof
3973
5614
  return leaves(value, `${key}.`);
3974
5615
  });
3975
5616
  }
3976
- function at(catalog, key) {
3977
- 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);
3978
5620
  }
3979
5621
  const MEOCORD_KEYS = new Set(leaves(controller_decorator.MEOCORD_MESSAGES).map(({ key })=>key));
3980
5622
  const isMeoCordKey = (key)=>key.startsWith('meocord.');
@@ -3997,9 +5639,11 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
3997
5639
  *
3998
5640
  * Use it in a test for a team that wants no fallback in production. It reports the messages a locale lacks, the
3999
5641
  * messages the default catalog doesn't have, the plural forms a language needs but a plural lacks, such as `few` for
4000
- * Russian, and each `{param}` a translation uses that the message it translates doesn't take. It reads the catalogs'
4001
- * own strings, so it checks the params of a catalog from a plain variable or a JSON file too, which the compiler
4002
- * 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.
4003
5647
  *
4004
5648
  * @remarks
4005
5649
  * MeoCord's own texts, a catalog's `meocord` group, fall back to English by design, so only a key MeoCord lacks, and
@@ -4032,7 +5676,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
4032
5676
  * @group Testing
4033
5677
  * @category Inspection
4034
5678
  * @see {@link createTranslator}
4035
- * @see {@link https://meocord.dev/docs/4.1/localisation | Localisation}
5679
+ * @see {@link https://meocord.dev/docs/4.2/localisation | Localisation}
4036
5680
  */ function expectCompleteCatalog(translator, options = {}) {
4037
5681
  const catalogs = translator[controller_decorator.CATALOGS];
4038
5682
  if (!catalogs) throw new Error('expectCompleteCatalog takes a translator made by createTranslator.');
@@ -4046,12 +5690,17 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
4046
5690
  const categories = new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
4047
5691
  for (const { key, plural } of reference){
4048
5692
  const message = at(catalog, key);
4049
- if (message === undefined) {
5693
+ if (message === undefined || message === '') {
4050
5694
  problems.push(`missing ${key}`);
4051
5695
  continue;
4052
5696
  }
4053
- if (plural && isPlural(message)) {
4054
- 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]));
4055
5704
  if (missing.length > 0) problems.push(`${key} lacks ${missing.join(', ')}`);
4056
5705
  }
4057
5706
  const original = at(defaultCatalog, key);
@@ -4059,7 +5708,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
4059
5708
  for (const name of stray)problems.push(`${key} takes no {${name}}; the default is "${shown(original)}"`);
4060
5709
  }
4061
5710
  if (options.meocord && !locale.startsWith('en-')) {
4062
- 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}`);
4063
5712
  }
4064
5713
  for (const { key } of leaves(catalog)){
4065
5714
  if (isMeoCordKey(key)) {
@@ -4078,6 +5727,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
4078
5727
 
4079
5728
  /** Long enough for a store over the network to answer each case, short enough that the suite takes seconds. */ const CASE_TIMEOUT_MS = 10000;
4080
5729
  const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
5730
+ /** How far a store's clock may run from this process's, as a store's server keeps its own time. */ const CLOCK_SLACK_MS = 50;
4081
5731
  /**
4082
5732
  * Checks that a `CooldownStore` counts calls as `MemoryCooldownStore` does, as a suite of test cases.
4083
5733
  *
@@ -4086,9 +5736,11 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4086
5736
  *
4087
5737
  * @remarks
4088
5738
  * The cases cover what a shared store most often gets wrong: a sliding window rather than fixed buckets,
4089
- * `retryAfterMs` from the oldest call still in the window, calls in the same millisecond kept apart, and concurrent
4090
- * calls at the limit where exactly one may pass. They use real time, with windows short enough that the suite takes
4091
- * a few seconds, and each case counts under keys of its own. Each window is a whole number of milliseconds, as
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
5742
+ * a few seconds and margins wide enough that a correct store passes when timers fire late, as on a busy CI runner,
5743
+ * and each case counts under keys of its own. Each window is a whole number of milliseconds, as
4092
5744
  * `@Cooldown` gives a store: it rounds its `seconds` to the millisecond.
4093
5745
  *
4094
5746
  * @param name - What the store is called in the report.
@@ -4105,7 +5757,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4105
5757
  * @group Testing
4106
5758
  * @category Inspection
4107
5759
  * @see {@link CooldownStore}
4108
- * @see {@link https://meocord.dev/docs/4.1/recipes/cooldown-stores | Cooldown stores}
5760
+ * @see {@link https://meocord.dev/docs/4.2/recipes/cooldown-stores | Cooldown stores}
4109
5761
  */ function testCooldownStore(name, factory, { describe, it, expect }) {
4110
5762
  const run = node_crypto.randomUUID();
4111
5763
  const key = (label)=>`meocord-suite:${run}:${label}`;
@@ -4128,32 +5780,62 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4128
5780
  expect(refused.retryAfterMs).toBeLessThanOrEqual(limit.windowMs);
4129
5781
  });
4130
5782
  test('slides its window: a call is allowed once the oldest leaves it, not when a bucket resets', async (store)=>{
5783
+ // A second between calls, in a 2 s window, leaves room for a timer that fires late on a busy runner
4131
5784
  const limit = {
4132
5785
  uses: 2,
4133
- windowMs: 500
5786
+ windowMs: 2000
4134
5787
  };
4135
5788
  await store.consume(key('sliding'), limit);
4136
- await sleep(250);
5789
+ await sleep(1000);
4137
5790
  await store.consume(key('sliding'), limit);
4138
5791
  const refused = await store.consume(key('sliding'), limit);
4139
5792
  expect(refused.allowed).toBe(false);
4140
5793
  await sleep(refused.retryAfterMs + 40);
4141
- // The first call has left the window; the second, 250ms younger, still holds its use.
5794
+ // The first call has left the window; the second, a second younger, still holds its use.
4142
5795
  expect((await store.consume(key('sliding'), limit)).allowed).toBe(true);
4143
5796
  expect((await store.consume(key('sliding'), limit)).allowed).toBe(false);
4144
5797
  });
4145
5798
  test('counts retryAfterMs from the oldest call still in the window', async (store)=>{
4146
5799
  const limit = {
4147
5800
  uses: 2,
4148
- windowMs: 1000
5801
+ windowMs: 2000
4149
5802
  };
5803
+ const firstSent = Date.now();
4150
5804
  await store.consume(key('retry'), limit);
4151
- await sleep(300);
5805
+ const firstDone = Date.now();
5806
+ await sleep(1000);
5807
+ const secondSent = Date.now();
4152
5808
  await store.consume(key('retry'), limit);
4153
- // From the oldest call, about 700ms remain; from the newest, about 1000ms would.
4154
5809
  const { retryAfterMs } = await store.consume(key('retry'), limit);
4155
- expect(retryAfterMs).toBeGreaterThan(limit.windowMs - 600);
4156
- expect(retryAfterMs).toBeLessThanOrEqual(limit.windowMs - 200);
5810
+ const thirdDone = Date.now();
5811
+ // What is left of the oldest call's window, by the times measured around each call, as timers may fire late;
5812
+ // counted from the newest call, nearly the whole window would be left instead
5813
+ expect(retryAfterMs).toBeLessThanOrEqual(limit.windowMs - (secondSent - firstDone) + CLOCK_SLACK_MS);
5814
+ expect(retryAfterMs).toBeGreaterThanOrEqual(limit.windowMs - (thirdDone - firstSent) - CLOCK_SLACK_MS);
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);
4157
5839
  });
4158
5840
  test('counts each key on its own', async (store)=>{
4159
5841
  const limit = {
@@ -4216,6 +5898,41 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4216
5898
  expect(refused.retryAfterMs).toBeGreaterThan(short.windowMs);
4217
5899
  expect(refused.retryAfterMs).toBeLessThanOrEqual(long.windowMs);
4218
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
+ });
4219
5936
  // The default consumeMany counts in order and stops at the first refusal, so the entries before it count
4220
5937
  test('records nothing when one cooldown of a batch refuses it, or with the default consumeMany, counts those before it', async (store)=>{
4221
5938
  const limit = {
@@ -4258,10 +5975,11 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4258
5975
  }, ()=>store.consumeMany(batch)));
4259
5976
  expect(verdicts.filter((verdict)=>verdict.allowed).length).toBe(1);
4260
5977
  });
4261
- // A store without release keeps a counted call counted, as the default consumeMany does
4262
- 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)=>{
4263
5981
  const limit = {
4264
- uses: 1,
5982
+ uses: 2,
4265
5983
  windowMs: 2000
4266
5984
  };
4267
5985
  const entries = [
@@ -4274,6 +5992,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4274
5992
  limit
4275
5993
  }
4276
5994
  ];
5995
+ expect((await store.consumeMany(entries)).allowed).toBe(true);
4277
5996
  const counted = await store.consumeMany(entries);
4278
5997
  expect(counted.allowed).toBe(true);
4279
5998
  if (!counted.release) {
@@ -4501,7 +6220,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4501
6220
  * @group Testing
4502
6221
  * @category Inspection
4503
6222
  * @see {@link createExecutionContext}
4504
- * @see {@link https://meocord.dev/docs/4.1/testing-recipes | Testing recipes}
6223
+ * @see {@link https://meocord.dev/docs/4.2/testing-recipes | Testing recipes}
4505
6224
  */ function inspectHandler(controller, methodName, options = {}) {
4506
6225
  const context = new controller_decorator.HandlerExecutionContext({
4507
6226
  controller,
@@ -4510,7 +6229,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4510
6229
  });
4511
6230
  const globals = options.app ? providers.appStages(options.app) : undefined;
4512
6231
  const { guards, interceptors, filters } = providers.handlerStages(controller.prototype, methodName, globals);
4513
- return {
6232
+ const inspection = {
4514
6233
  controller,
4515
6234
  methodName,
4516
6235
  guards: Object.freeze([
@@ -4532,6 +6251,23 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4532
6251
  get: (metadata)=>context.get(metadata),
4533
6252
  getAll: (metadata)=>context.getAll(metadata)
4534
6253
  };
6254
+ // Read by a test but not compared by toEqual, so an assertion on the result's other fields holds
6255
+ Object.defineProperty(inspection, 'inheritedRoutes', {
6256
+ value: Object.freeze(inheritedRoutesOf(controller.prototype, methodName)),
6257
+ enumerable: false
6258
+ });
6259
+ return inspection;
6260
+ }
6261
+ /** The routes `methodName` answers that the class declaring it last doesn't declare for it: those it inherits. */ function inheritedRoutesOf(prototype, methodName) {
6262
+ const answered = [
6263
+ ...new Set(controller_decorator.getHandlerRoutes(prototype).filter((route)=>route.method === methodName).map((route)=>route.label))
6264
+ ];
6265
+ // The nearest class with its own decorators on the method; a class that only overrides it declares none
6266
+ for(let at = prototype; at && at !== Object.prototype; at = Object.getPrototypeOf(at)){
6267
+ const own = controller_decorator.getDeclaredRoutes(at).filter((route)=>route.method === methodName).map((route)=>route.label);
6268
+ if (own.length > 0) return at === prototype ? answered.filter((label)=>!own.includes(label)) : answered;
6269
+ }
6270
+ return [];
4535
6271
  }
4536
6272
 
4537
6273
  // Themes createMockTheme made, which withTheme uses as they are
@@ -4568,7 +6304,7 @@ function mockTheme(overrides, where) {
4568
6304
  * @group Testing
4569
6305
  * @category Mocks
4570
6306
  * @see {@link withTheme}
4571
- * @see {@link https://meocord.dev/docs/4.1/theming | Theming}
6307
+ * @see {@link https://meocord.dev/docs/4.2/theming | Theming}
4572
6308
  */ function createMockTheme(...[overrides]) {
4573
6309
  return mockTheme(overrides, 'createMockTheme');
4574
6310
  }
@@ -4615,6 +6351,7 @@ exports.createMockGuild = createMockGuild;
4615
6351
  exports.createMockInteraction = createMockInteraction;
4616
6352
  exports.createMockMember = createMockMember;
4617
6353
  exports.createMockMessage = createMockMessage;
6354
+ exports.createMockRawMember = createMockRawMember;
4618
6355
  exports.createMockTheme = createMockTheme;
4619
6356
  exports.createMockUser = createMockUser;
4620
6357
  exports.createModalFields = createModalFields;
@@ -4623,7 +6360,10 @@ exports.findRouteConflicts = findRouteConflicts;
4623
6360
  exports.getResponse = getResponse;
4624
6361
  exports.inspectHandler = inspectHandler;
4625
6362
  exports.isMockFunction = isMockFunction;
6363
+ exports.reportAllStartupErrors = reportAllStartupErrors;
4626
6364
  exports.resetAllMocks = resetAllMocks;
4627
6365
  exports.resolveRoute = resolveRoute;
4628
6366
  exports.testCooldownStore = testCooldownStore;
6367
+ exports.useMockFn = useMockFn;
6368
+ exports.useStrictMocks = useStrictMocks;
4629
6369
  exports.withTheme = withTheme;