meocord 4.2.2 → 4.2.3

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 (136) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/dist/cjs/_shared/{controller.decorator-U2NBWcoU.cjs → controller.decorator-hp5xdJU4.cjs} +512 -183
  3. package/dist/cjs/_shared/{startup-roots-lA5xkBFq.cjs → event-sources-DzqTODXg.cjs} +265 -80
  4. package/dist/cjs/_shared/{providers-B4MOFBrW.cjs → providers-BBvbnmLP.cjs} +780 -281
  5. package/dist/cjs/_shared/{sharded-cooldown-store-HTUb3Ii_.cjs → sharded-cooldown-store-B62g5O88.cjs} +11 -9
  6. package/dist/cjs/_shared/{translate-error-CbBHUu5P.cjs → translate-error-wsgfsUET.cjs} +1 -1
  7. package/dist/cjs/common/index.cjs +4 -104
  8. package/dist/cjs/core/index.cjs +432 -181
  9. package/dist/cjs/decorator/index.cjs +85 -120
  10. package/dist/cjs/testing/index.cjs +1664 -784
  11. package/dist/cli.json +1 -1
  12. package/dist/esm/bin/app-template/package.json.template +9 -9
  13. package/dist/esm/bin/app-template/src/main.ts.template +3 -2
  14. package/dist/esm/bin/generator.js +6 -4
  15. package/dist/esm/bin/helper/filter-generator.helper.js +6 -4
  16. package/dist/esm/bin/helper/guard-generator.helper.js +6 -4
  17. package/dist/esm/bin/helper/interceptor-generator.helper.js +6 -4
  18. package/dist/esm/bin/helper/observer-generator.helper.js +6 -4
  19. package/dist/esm/bin/helper/pipe-generator.helper.js +6 -4
  20. package/dist/esm/bin/helper/service-generator.helper.js +6 -4
  21. package/dist/esm/bin/meocord.js +106 -48
  22. package/dist/esm/build/native-addons.js +75 -19
  23. package/dist/esm/build/rsbuild-config.js +11 -3
  24. package/dist/esm/build/typed-externals.js +15 -2
  25. package/dist/esm/common/cooldown-store.js +38 -14
  26. package/dist/esm/common/deprecation.js +4 -1
  27. package/dist/esm/common/execution-context.js +3 -1
  28. package/dist/esm/common/explained-error.js +7 -3
  29. package/dist/esm/common/log-level.js +13 -9
  30. package/dist/esm/common/logger.js +4 -2
  31. package/dist/esm/common/meocord-text.js +11 -6
  32. package/dist/esm/common/redis-cooldown-store.js +3 -7
  33. package/dist/esm/common/response/flags.js +17 -1
  34. package/dist/esm/common/response/install-context.js +27 -16
  35. package/dist/esm/common/response/presenter.js +7 -3
  36. package/dist/esm/common/response/response-state.js +27 -5
  37. package/dist/esm/common/sharded-cooldown-store.js +11 -9
  38. package/dist/esm/common/theme.js +3 -1
  39. package/dist/esm/common/translator.js +17 -8
  40. package/dist/esm/core/autocomplete-routes.js +26 -0
  41. package/dist/esm/core/command-conflicts.js +15 -7
  42. package/dist/esm/core/command-registration.js +15 -1
  43. package/dist/esm/core/cooldown-runner.js +7 -9
  44. package/dist/esm/core/defer.js +2 -5
  45. package/dist/esm/core/dispatcher.js +83 -58
  46. package/dist/esm/core/event-requirements.js +67 -19
  47. package/dist/esm/core/event-sources.js +100 -0
  48. package/dist/esm/core/fallback.js +3 -1
  49. package/dist/esm/core/filter-runner.js +4 -4
  50. package/dist/esm/core/guard-runner.js +173 -52
  51. package/dist/esm/core/handler-input.js +24 -3
  52. package/dist/esm/core/handler-pipeline.js +44 -28
  53. package/dist/esm/core/handler-registry.js +51 -21
  54. package/dist/esm/core/input-runner.js +7 -8
  55. package/dist/esm/core/interceptor-runner.js +4 -5
  56. package/dist/esm/core/lifecycle-hooks.js +3 -8
  57. package/dist/esm/core/lifecycle-order.js +1 -15
  58. package/dist/esm/core/meocord-classes.js +14 -1
  59. package/dist/esm/core/meocord-factory.js +80 -53
  60. package/dist/esm/core/meocord.app.js +174 -89
  61. package/dist/esm/core/message-help.js +5 -9
  62. package/dist/esm/core/message-params.js +126 -46
  63. package/dist/esm/core/message-routes.js +225 -45
  64. package/dist/esm/core/message-words.js +30 -2
  65. package/dist/esm/core/observer-runner.js +2 -1
  66. package/dist/esm/core/process-state.js +19 -0
  67. package/dist/esm/core/providers.js +28 -3
  68. package/dist/esm/core/scalar-types.js +57 -16
  69. package/dist/esm/core/shard-context.js +11 -10
  70. package/dist/esm/core/shard-exit.js +3 -3
  71. package/dist/esm/core/shard-manager.js +16 -6
  72. package/dist/esm/core/startup-roots.js +28 -8
  73. package/dist/esm/core/theme-resolvers.js +26 -11
  74. package/dist/esm/core/theme-runtime.js +23 -13
  75. package/dist/esm/core/theme-scope.js +27 -29
  76. package/dist/esm/core/theme-validation.js +1 -1
  77. package/dist/esm/decorator/app.decorator.js +6 -1
  78. package/dist/esm/decorator/command-builder.decorator.js +14 -0
  79. package/dist/esm/decorator/controller-class.decorator.js +1 -1
  80. package/dist/esm/decorator/controller.decorator.js +48 -19
  81. package/dist/esm/decorator/defer.decorator.js +2 -2
  82. package/dist/esm/decorator/event.decorator.js +4 -5
  83. package/dist/esm/decorator/filter.decorator.js +2 -2
  84. package/dist/esm/decorator/guard.decorator.js +4 -61
  85. package/dist/esm/decorator/inject.decorator.js +4 -1
  86. package/dist/esm/decorator/interceptor.decorator.js +2 -2
  87. package/dist/esm/decorator/theme.decorator.js +4 -3
  88. package/dist/esm/decorator/validation.decorator.js +5 -5
  89. package/dist/esm/package.json.js +1 -1
  90. package/dist/esm/testing/catalog.js +29 -13
  91. package/dist/esm/testing/cooldown-store-suite.js +3 -3
  92. package/dist/esm/testing/discord-defaults.js +449 -355
  93. package/dist/esm/testing/inspect-handler.js +91 -10
  94. package/dist/esm/testing/meocord-testing-module.js +195 -77
  95. package/dist/esm/testing/mock-fn.js +8 -2
  96. package/dist/esm/testing/mock-interaction.js +639 -113
  97. package/dist/esm/testing/response-log.js +8 -0
  98. package/dist/esm/testing/response.js +7 -5
  99. package/dist/esm/testing/routing.js +48 -6
  100. package/dist/esm/testing/strict-mocks.js +1 -0
  101. package/dist/esm/testing/theme.js +3 -2
  102. package/dist/esm/util/common.util.js +30 -9
  103. package/dist/esm/util/generator-cli.util.js +6 -4
  104. package/dist/esm/util/inherited-env.util.js +72 -13
  105. package/dist/esm/util/json.util.js +10 -3
  106. package/dist/esm/util/meocord-cli.util.js +3 -2
  107. package/dist/esm/util/meocord-config-loader.util.js +14 -1
  108. package/dist/esm/util/meocord-config-validation.util.js +13 -3
  109. package/dist/esm/util/meocord-source-config.util.js +13 -3
  110. package/dist/esm/util/platform.util.js +3 -2
  111. package/dist/esm/util/refusal.util.js +29 -11
  112. package/dist/esm/util/registration-mode.util.js +14 -1
  113. package/dist/esm/util/timer-limit.util.js +4 -1
  114. package/dist/esm/util/tsconfig-paths.util.js +38 -7
  115. package/dist/esm/util/tsconfig.util.js +16 -58
  116. package/dist/esm/util/user-text.util.js +8 -4
  117. package/dist/esm/util/value.util.js +15 -4
  118. package/dist/types/common/index.d.cts +13 -9
  119. package/dist/types/common/index.d.ts +13 -9
  120. package/dist/types/core/index.d.cts +21 -11
  121. package/dist/types/core/index.d.ts +21 -11
  122. package/dist/types/decorator/index.d.cts +63 -63
  123. package/dist/types/decorator/index.d.ts +63 -63
  124. package/dist/types/{index-DKJclIi-.d.ts → index-Brkd6QGL.d.ts} +81 -35
  125. package/dist/types/{index-Dlf0ZZtS.d.cts → index-CqXImwF_.d.cts} +81 -35
  126. package/dist/types/interface/index.d.cts +1 -1
  127. package/dist/types/interface/index.d.ts +1 -1
  128. package/dist/types/{route-Bh1U13yy.d.ts → route-IQrK-MN2.d.cts} +11 -3
  129. package/dist/types/{route-Bh1U13yy.d.cts → route-bI-okq7g.d.ts} +11 -3
  130. package/dist/types/testing/index.d.cts +137 -54
  131. package/dist/types/testing/index.d.ts +137 -54
  132. package/dist/types/{theme-resolvers-CyEz9pnv.d.cts → theme-resolvers-AtgeaYqF.d.cts} +1 -1
  133. package/dist/types/{theme-resolvers-DA204O-q.d.ts → theme-resolvers-BgqysV8d.d.ts} +1 -1
  134. package/dist/types/{translator-Bg7hwxDF.d.cts → translator-D8aydlCE.d.ts} +12 -11
  135. package/dist/types/{translator-CeP4bCzp.d.ts → translator-iJhDlgwL.d.cts} +12 -11
  136. package/package.json +5 -2
@@ -2,26 +2,26 @@
2
2
 
3
3
  require('reflect-metadata');
4
4
  var inversify = require('inversify');
5
- var providers = require('../_shared/providers-B4MOFBrW.cjs');
5
+ var providers = require('../_shared/providers-BBvbnmLP.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-U2NBWcoU.cjs');
9
- var startupRoots = require('../_shared/startup-roots-lA5xkBFq.cjs');
8
+ var controller_decorator = require('../_shared/controller.decorator-hp5xdJU4.cjs');
9
+ var eventSources = require('../_shared/event-sources-DzqTODXg.cjs');
10
10
  var node_crypto = require('node:crypto');
11
11
  require('node:async_hooks');
12
12
  require('crypto');
13
13
  require('fs');
14
14
  require('path');
15
15
  require('cli-table3');
16
- require('node:fs');
17
- require('node:url');
18
16
  require('node:util');
19
17
  require('dayjs');
20
18
  require('dayjs/plugin/utc.js');
21
19
  require('dayjs/plugin/timezone.js');
22
20
  require('module');
21
+ require('node:fs');
23
22
  require('chalk');
24
- require('../_shared/translate-error-CbBHUu5P.cjs');
23
+ require('node:url');
24
+ require('../_shared/translate-error-wsgfsUET.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.
@@ -236,7 +236,13 @@ const collected = new FinalizationRegistry((ref)=>mocks$1.created.delete(ref));
236
236
  instances.push(this);
237
237
  contexts.push(this);
238
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
239
+ // Recorded as the call starts, so each call's results sit at its own index however calls nest, and filled in as it
240
+ // ends: a promise's outcome shows in settledResults once it resolves or rejects
241
+ const result = {
242
+ type: 'incomplete',
243
+ value: undefined
244
+ };
245
+ results.push(result);
240
246
  const settled = {
241
247
  type: 'incomplete',
242
248
  value: undefined
@@ -284,7 +290,7 @@ const collected = new FinalizationRegistry((ref)=>mocks$1.created.delete(ref));
284
290
  });
285
291
  throw err;
286
292
  } finally{
287
- results.push({
293
+ Object.assign(result, {
288
294
  type,
289
295
  value
290
296
  });
@@ -389,6 +395,11 @@ const collected = new FinalizationRegistry((ref)=>mocks$1.created.delete(ref));
389
395
  return track(mockFn);
390
396
  }
391
397
 
398
+ /**
399
+ * Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order, under a key
400
+ * both builds of this meocord version read.
401
+ */ const RESPONSE_LOG = Symbol.for(controller_decorator.sharedName('responseLog'));
402
+
392
403
  const VISIBLE = new Set([
393
404
  'reply',
394
405
  'update',
@@ -400,9 +411,10 @@ const VISIBLE = new Set([
400
411
  * Reports how an interaction was answered: where its answer stands, and every answer call it got, in order.
401
412
  *
402
413
  * Use it after `invoke` or `dispatch` to check what the member sees. A mock interaction records each `reply`,
403
- * `deferReply`, `editReply`, `followUp`, `deleteReply`, `update`, `deferUpdate` and `showModal`, whether the handler
404
- * made it through `respond()` or with discord.js directly, so both are reported, each once. For a message or a
405
- * reaction, read the mock's own methods.
414
+ * `deferReply`, `editReply`, `followUp`, `deleteReply`, `update`, `deferUpdate` and `showModal`, and under
415
+ * `useStrictMocks()` each `launchActivity`, `sendPremiumRequired` and autocomplete `respond` too, whether the handler made
416
+ * it through `respond()` or with discord.js directly, so both are reported, each once. For a message or a reaction, read
417
+ * the mock's own methods.
406
418
  *
407
419
  * @param interaction - The interaction a handler answered.
408
420
  * @returns The state, whether anything the member can see was sent, and the calls, in order.
@@ -447,7 +459,8 @@ const VISIBLE = new Set([
447
459
  sent,
448
460
  calls: calls
449
461
  };
450
- const answered = interaction.isRepliable() ? interaction.replied : false;
462
+ // An autocomplete's choices are its answer, as observers read it, where its mock records them, under strict mocks
463
+ const answered = interaction.isRepliable() ? interaction.replied : Boolean(log) && interaction.isAutocomplete() && interaction.responded;
451
464
  const deferred = interaction.isRepliable() ? interaction.deferred : false;
452
465
  return {
453
466
  state: answered ? 'replied' : deferred ? 'deferred' : 'unanswered',
@@ -486,6 +499,72 @@ const VISIBLE = new Set([
486
499
  }, code, 400, 'POST', 'https://discord.com/api/v10/interactions', {});
487
500
  }
488
501
 
502
+ /**
503
+ * Builds the `fields` of a submitted form, as discord.js does when a user submits one.
504
+ *
505
+ * Use it for a mock `ModalSubmitInteraction`: discord.js keeps the `ModalSubmitFields` constructor private, so a test
506
+ * cannot build one directly.
507
+ *
508
+ * @param values - Each field's value, keyed by its customId: a string for a text input, an array of strings for a
509
+ * select's chosen values, or an array of `Attachment`s for a file upload. An empty array is a select with nothing
510
+ * chosen.
511
+ * @returns Fields that `getTextInputValue`, `getStringSelectValues`, `getUploadedFiles` and a handler's params all
512
+ * read.
513
+ *
514
+ * @example
515
+ * ```ts
516
+ * import { expect } from 'vitest'
517
+ *
518
+ * @Controller()
519
+ * class FeedbackController {
520
+ * @Command('feedback/{topic}', CommandType.MODAL_SUBMIT)
521
+ * async submit(interaction: ModalSubmitInteraction, { topic, body }: { topic: string; body: string }) {
522
+ * await respond(interaction).send(`Thanks: ${topic}, ${body}`)
523
+ * }
524
+ * }
525
+ * const module = MeoCordTestingModule.create({ controllers: [FeedbackController] }).compile()
526
+ * const fields = createModalFields({ body: 'It crashed' })
527
+ * const interaction = createMockInteraction(ModalSubmitInteraction, { customId: 'feedback/bugs', fields })
528
+ * await module.invoke(FeedbackController, 'submit', interaction)
529
+ * expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: 'Thanks: bugs, It crashed' })
530
+ * ```
531
+ *
532
+ * @group Testing
533
+ * @category Mocks
534
+ * @see {@link createMockInteraction}
535
+ */ function createModalFields(values) {
536
+ const component = (customId, value)=>{
537
+ if (!Array.isArray(value)) return {
538
+ type: discord_js.ComponentType.TextInput,
539
+ customId,
540
+ value
541
+ };
542
+ if (!value.some((item)=>item instanceof discord_js.Attachment)) return {
543
+ type: discord_js.ComponentType.StringSelect,
544
+ customId,
545
+ values: value
546
+ };
547
+ const attachments = new discord_js.Collection(value.map((file)=>[
548
+ file.id,
549
+ file
550
+ ]));
551
+ return {
552
+ type: discord_js.ComponentType.FileUpload,
553
+ customId,
554
+ values: [
555
+ ...attachments.keys()
556
+ ],
557
+ attachments
558
+ };
559
+ };
560
+ const components = Object.entries(values).map(([customId, value])=>({
561
+ type: discord_js.ComponentType.Label,
562
+ component: component(customId, value)
563
+ }));
564
+ const Fields = discord_js.ModalSubmitFields;
565
+ return new Fields(components);
566
+ }
567
+
489
568
  /**
490
569
  * The id of the bot every mock client is logged in as, so a test can mention it. Fixed, and below the
491
570
  * ids other mocks take, so it is the same on every run and in any test order, and no mock shares it.
@@ -539,6 +618,7 @@ const settings = controller_decorator.shared('strictMocks', ()=>({
539
618
  *
540
619
  * `invoke` also refuses a call that gives a handler fewer arguments than it declares where it builds none, such as a
541
620
  * reaction handler given its reaction without its `ReactionEvent`; without strict mocks, it warns once and calls it.
621
+ * It refuses an autocomplete that dispatch gives to another `@Autocomplete` handler, or to none, the same way.
542
622
  *
543
623
  * `compile()` also refuses an `overrideGuard`, `overrideInterceptor` or `overrideFilter` stub without the stage's
544
624
  * method, `canActivate`, `intercept` or `catch`; without strict mocks, it warns and builds the module.
@@ -564,48 +644,6 @@ const settings = controller_decorator.shared('strictMocks', ()=>({
564
644
  }
565
645
 
566
646
  const mockLogger$1 = new controller_decorator.Logger('Mocks');
567
- /**
568
- * A member's permissions where an interaction was made, as Discord computes them: under strict mocks, every permission
569
- * for an Administrator or the owner, else the channel's overwrites on top of `member.permissions`, so a test that sets
570
- * those keeps the base; otherwise `member.permissions`, with a warning once where strict mocks would read otherwise.
571
- */ function inChannel(interaction, member) {
572
- const base = member.permissions;
573
- // Only a server the mock caches has the overwrites to apply
574
- const { guild } = interaction;
575
- if (!(guild instanceof discord_js.Guild)) return base;
576
- 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);
577
- if (strictMocks()) return computed;
578
- if (!computed.equals(base)) {
579
- const lost = new discord_js.PermissionsBitField(base).remove(computed).toArray();
580
- const gained = new discord_js.PermissionsBitField(computed).remove(base).toArray();
581
- const change = lost.length > 0 ? [
582
- `without ${lost.join(', ')}`
583
- ] : [];
584
- if (gained.length > 0) change.push(`with ${gained.join(', ')}`);
585
- 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.');
586
- }
587
- // Unless the test set them, a member's permissions come from its roles, @everyone's included
588
- if (!Object.prototype.hasOwnProperty.call(member, 'permissions')) warnOnUnsetEveryone(interaction, guild, 'memberPermissions', base);
589
- return base;
590
- }
591
- /**
592
- * Warns once, as `key`, where `permissions` read a server's @everyone role default mode made with none, and lack what
593
- * strict mocks give @everyone in a new server.
594
- */ function warnOnUnsetEveryone(interaction, guild, key, permissions) {
595
- const everyone = guild.roles.everyone;
596
- if (!everyone || !isUnsetEveryone(everyone) || permissions.has(NEW_SERVER_EVERYONE_PERMISSIONS)) return;
597
- 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.');
598
- }
599
- /**
600
- * The channel whose overwrites apply where an interaction was made, read without making or caching one: a thread's
601
- * parent, or undefined where the channel, or a thread's parent, is one the mock would make, which has none.
602
- */ function overwritingChannel(interaction, guild) {
603
- const channel = heldChannelOf(interaction, guild);
604
- // 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
605
- const parentId = (thread)=>Object.prototype.hasOwnProperty.call(thread, 'parentId') ? thread.parentId : madeParentIds.get(thread);
606
- const at = channel instanceof discord_js.ThreadChannel ? guild.channels.cache.get(parentId(channel) ?? '') : channel;
607
- return at instanceof discord_js.GuildChannel ? at : undefined;
608
- }
609
647
  // The parent id strict mocks made for each thread made without one, once read
610
648
  const madeParentIds = new WeakMap();
611
649
  // Private in discord.js's typings, though they are what its own permissionsFor computes with; called on the prototype
@@ -623,305 +661,7 @@ const channelPermissions = discord_js.GuildChannel.prototype;
623
661
  Class.prototype,
624
662
  defaults
625
663
  ];
626
- /**
627
- * What a mock reads for a data property discord.js always sets, where the test gave none: a value in Discord's shape,
628
- * such as `'en-US'` for a locale, `null` for what may be absent and `false` for a flag, or the property's own getter
629
- * for one discord.js computes. The classes a mock descends from are read nearest first.
630
- */ const DEFAULTS = new Map([
631
- defaultsOf(discord_js.Guild, {
632
- name: ()=>'Guild',
633
- preferredLocale: ()=>discord_js.Locale.EnglishUS,
634
- ownerId: nextSnowflake,
635
- memberCount: ()=>1,
636
- available: ()=>true,
637
- large: ()=>false,
638
- features: ()=>[],
639
- description: ()=>null,
640
- icon: ()=>null,
641
- banner: ()=>null,
642
- splash: ()=>null,
643
- systemChannelId: ()=>null,
644
- shardId: ()=>0,
645
- nsfwLevel: ()=>discord_js.GuildNSFWLevel.Default,
646
- verificationLevel: ()=>discord_js.GuildVerificationLevel.None,
647
- presences: (guild)=>ownedManager(discord_js.PresenceManager.prototype, {
648
- guild
649
- }),
650
- premiumTier: ()=>discord_js.GuildPremiumTier.None,
651
- createdTimestamp: REAL_GETTER,
652
- createdAt: REAL_GETTER
653
- }),
654
- defaultsOf(discord_js.ClientUser, {
655
- username: ()=>'bot'
656
- }),
657
- defaultsOf(discord_js.User, {
658
- username: ()=>'user',
659
- bot: ()=>false,
660
- globalName: ()=>null,
661
- discriminator: ()=>'0',
662
- avatar: ()=>null,
663
- system: ()=>false,
664
- flags: ()=>new discord_js.UserFlagsBitField(),
665
- tag: REAL_GETTER,
666
- displayName: REAL_GETTER,
667
- defaultAvatarURL: REAL_GETTER,
668
- createdTimestamp: REAL_GETTER,
669
- createdAt: REAL_GETTER
670
- }),
671
- defaultsOf(discord_js.GuildMember, {
672
- user: ()=>createMockUser(),
673
- id: REAL_GETTER,
674
- nickname: ()=>null,
675
- avatar: ()=>null,
676
- // In the server since the mock was made, as a member Discord sends always is
677
- joinedTimestamp: ()=>Date.now(),
678
- joinedAt: REAL_GETTER,
679
- partial: REAL_GETTER,
680
- premiumSince: REAL_GETTER,
681
- communicationDisabledUntil: REAL_GETTER,
682
- premiumSinceTimestamp: ()=>null,
683
- communicationDisabledUntilTimestamp: ()=>null,
684
- pending: ()=>false,
685
- flags: ()=>new discord_js.GuildMemberFlagsBitField().freeze(),
686
- displayName: REAL_GETTER,
687
- roles: (member)=>memberRoles(member, []),
688
- permissions: REAL_GETTER
689
- }),
690
- // A server's emoji, sticker, scheduled event and AutoMod rule are named, and what discord.js formats from them
691
- // computes: an emoji's mention and identifier, an event's and an invite's link
692
- defaultsOf(discord_js.GuildEmoji, {
693
- name: ()=>'emoji',
694
- animated: ()=>false,
695
- identifier: REAL_GETTER
696
- }),
697
- defaultsOf(discord_js.Sticker, {
698
- name: ()=>'sticker'
699
- }),
700
- defaultsOf(discord_js.GuildScheduledEvent, {
701
- name: ()=>'event',
702
- url: REAL_GETTER
703
- }),
704
- defaultsOf(discord_js.AutoModerationRule, {
705
- name: ()=>'rule'
706
- }),
707
- defaultsOf(discord_js.Invite, {
708
- url: REAL_GETTER
709
- }),
710
- defaultsOf(discord_js.Role, {
711
- name: ()=>'role',
712
- // Above @everyone, which a server's role manager makes at position 0
713
- position: ()=>1,
714
- // Every role has a server; one made without is in one of its own, unless a guild given it takes it in
715
- guild: ()=>createMockGuild(),
716
- rawPosition: (role)=>role.position,
717
- permissions: noPermissions,
718
- hoist: ()=>false,
719
- managed: ()=>false,
720
- mentionable: ()=>false,
721
- icon: ()=>null,
722
- unicodeEmoji: ()=>null,
723
- tags: ()=>null,
724
- colors: ()=>({
725
- primaryColor: 0,
726
- secondaryColor: null,
727
- tertiaryColor: null
728
- }),
729
- hexColor: REAL_GETTER,
730
- createdTimestamp: REAL_GETTER,
731
- createdAt: REAL_GETTER
732
- }),
733
- // A message's text says which handler it is for, so it stays the test's to give, as a command's name does
734
- defaultsOf(discord_js.Message, {
735
- pinned: ()=>false,
736
- tts: ()=>false,
737
- type: ()=>discord_js.MessageType.Default,
738
- system: ()=>false,
739
- webhookId: ()=>null,
740
- applicationId: ()=>null,
741
- nonce: ()=>null,
742
- position: ()=>null,
743
- activity: ()=>null,
744
- reference: ()=>null,
745
- poll: ()=>null,
746
- interactionMetadata: ()=>null,
747
- stickers: ()=>new discord_js.Collection(),
748
- messageSnapshots: ()=>new discord_js.Collection(),
749
- reactions: (message)=>ownedManager(discord_js.ReactionManager.prototype, {
750
- message
751
- }),
752
- url: REAL_GETTER
753
- }),
754
- // A command's name and a component's customId say which handler a call is for, so they stay the test's to give
755
- defaultsOf(discord_js.ChatInputCommandInteraction, {
756
- options: ()=>createChatInputOptions()
757
- }),
758
- defaultsOf(discord_js.CommandInteraction, {
759
- commandId: nextSnowflake,
760
- commandGuildId: ()=>null
761
- }),
762
- defaultsOf(discord_js.AutocompleteInteraction, {
763
- commandId: nextSnowflake,
764
- commandGuildId: ()=>null,
765
- options: ()=>createChatInputOptions()
766
- }),
767
- defaultsOf(discord_js.MessageReaction, {
768
- users: (reaction)=>ownedManager(discord_js.ReactionUserManager.prototype, {
769
- reaction
770
- })
771
- }),
772
- defaultsOf(discord_js.BaseInteraction, {
773
- applicationId: nextSnowflake,
774
- token: ()=>'mock-interaction-token',
775
- version: ()=>1,
776
- context: ()=>null,
777
- entitlements: ()=>new discord_js.Collection(),
778
- // What Discord sends with an interaction in a server, the member's permissions there; typed for a cached one
779
- // Each reads where the interaction is as its own, and warns by its own name where strict mocks place it otherwise
780
- memberPermissions: (interaction)=>readPlaceAs(interaction, 'memberPermissions', ()=>{
781
- const { guildId } = interaction;
782
- const member = interaction.member;
783
- if (!guildId) return null;
784
- if (member instanceof discord_js.GuildMember) return inChannel(interaction, member);
785
- // A raw member's permissions are the string Discord sends, read as discord.js reads them
786
- return isRawMember(member) && !interaction.guild ? new discord_js.PermissionsBitField(BigInt(member.permissions)).freeze() : null;
787
- }),
788
- // The bot's permissions where the interaction was made: its member's in the channel, or in a thread its parent, as
789
- // discord.js computes them; none outside a server
790
- appPermissions: (interaction)=>readPlaceAs(interaction, 'appPermissions', ()=>{
791
- const { guild } = interaction;
792
- const me = guild instanceof discord_js.Guild ? botMemberOf(guild) : null;
793
- if (!(me instanceof discord_js.GuildMember) || heldChannelOf(interaction, guild) === null) return noPermissions();
794
- // discord.js's own computation, given its own overwritesFor over a test's stub on the channel; a channel the
795
- // mock would make has no overwrites
796
- const at = overwritingChannel(interaction, guild) ?? {
797
- guild,
798
- permissionOverwrites: {
799
- cache: new discord_js.Collection()
800
- }
801
- };
802
- const channel = Object.create(at, {
803
- overwritesFor: {
804
- value: channelPermissions.overwritesFor
805
- }
806
- });
807
- const permissions = channelPermissions.memberPermissions.call(channel, me, true);
808
- // The bot's member has @everyone's too
809
- warnOnUnsetEveryone(interaction, guild, 'appPermissions', permissions);
810
- return permissions;
811
- }, // A server the bot isn't in has no guild in either mode, so no permissions in both
812
- (place)=>!place.raw)
813
- }),
814
- defaultsOf(discord_js.TextChannel, {
815
- type: ()=>discord_js.ChannelType.GuildText,
816
- nsfw: ()=>false,
817
- topic: ()=>null,
818
- rateLimitPerUser: ()=>0
819
- }),
820
- defaultsOf(discord_js.NewsChannel, {
821
- type: ()=>discord_js.ChannelType.GuildAnnouncement,
822
- nsfw: ()=>false,
823
- topic: ()=>null
824
- }),
825
- defaultsOf(discord_js.VoiceChannel, {
826
- type: ()=>discord_js.ChannelType.GuildVoice,
827
- nsfw: ()=>false
828
- }),
829
- defaultsOf(discord_js.ForumChannel, {
830
- type: ()=>discord_js.ChannelType.GuildForum,
831
- nsfw: ()=>false,
832
- topic: ()=>null
833
- }),
834
- defaultsOf(discord_js.MediaChannel, {
835
- type: ()=>discord_js.ChannelType.GuildMedia,
836
- nsfw: ()=>false,
837
- topic: ()=>null
838
- }),
839
- defaultsOf(discord_js.ThreadChannel, {
840
- type: ()=>discord_js.ChannelType.PublicThread,
841
- name: ()=>'thread'
842
- }),
843
- defaultsOf(discord_js.DMChannel, {
844
- type: ()=>discord_js.ChannelType.DM
845
- }),
846
- defaultsOf(discord_js.GuildChannel, {
847
- name: ()=>'channel',
848
- guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id : nextSnowflake(),
849
- position: ()=>0,
850
- rawPosition: ()=>0,
851
- parentId: ()=>null
852
- }),
853
- defaultsOf(discord_js.BaseChannel, {
854
- createdTimestamp: REAL_GETTER,
855
- createdAt: REAL_GETTER
856
- }),
857
- defaultsOf(discord_js.Client, {
858
- readyTimestamp: ()=>Date.now(),
859
- readyAt: REAL_GETTER,
860
- uptime: REAL_GETTER,
861
- token: ()=>null
862
- }),
863
- defaultsOf(discord_js.BaseGuild, {
864
- nameAcronym: REAL_GETTER
865
- })
866
- ]);
867
- /**
868
- * What strict mocks also read as Discord sends it, for discord.js's getters that read it: a server for a channel or
869
- * thread made without one, a thread's parent text channel in it, and data that is otherwise a placeholder.
870
- */ const STRICT_DEFAULTS = new Map([
871
- defaultsOf(discord_js.Message, {
872
- content: ()=>'',
873
- editedTimestamp: ()=>null,
874
- editedAt: REAL_GETTER
875
- }),
876
- defaultsOf(discord_js.MessageMentions, {
877
- repliedUser: ()=>null
878
- }),
879
- defaultsOf(discord_js.Guild, {
880
- rulesChannelId: ()=>null,
881
- publicUpdatesChannelId: ()=>null,
882
- systemChannel: REAL_GETTER
883
- }),
884
- defaultsOf(discord_js.BaseGuild, {
885
- verified: REAL_GETTER
886
- }),
887
- defaultsOf(discord_js.GuildMember, {
888
- presence: REAL_GETTER
889
- }),
890
- defaultsOf(discord_js.ModalSubmitInteraction, {
891
- message: ()=>null
892
- }),
893
- defaultsOf(discord_js.ThreadChannel, {
894
- guild: ()=>createMockGuild(),
895
- guildId: (thread)=>thread.guild.id,
896
- parentId: (thread)=>{
897
- const parent = createMockChannel(discord_js.TextChannel, {
898
- guild: thread.guild
899
- });
900
- thread.guild.channels.cache.set(parent.id, parent);
901
- madeParentIds.set(thread, parent.id);
902
- return parent.id;
903
- },
904
- parent: REAL_GETTER,
905
- ownerId: nextSnowflake,
906
- archived: ()=>false,
907
- locked: ()=>false
908
- }),
909
- defaultsOf(discord_js.GuildChannel, {
910
- guild: ()=>createMockGuild(),
911
- parent: REAL_GETTER
912
- }),
913
- // Full once its members reach a user limit, which a voice channel has none of unless given one
914
- defaultsOf(discord_js.BaseGuildVoiceChannel, {
915
- userLimit: ()=>0,
916
- full: REAL_GETTER
917
- }),
918
- // A reaction's message, as discord.js always has one, is a whole one
919
- defaultsOf(discord_js.MessageReaction, {
920
- count: ()=>1,
921
- me: ()=>false,
922
- message: ()=>createMockMessage()
923
- })
924
- ]);
664
+ /** A channel's or thread's server id, its guild's, as discord.js sets it; one of its own for a guild the mock lacks. */ const guildIdOf = (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id : nextSnowflake();
925
665
  /**
926
666
  * What a mock reads as a placeholder unless strict mocks read it as discord.js gives it, by the value a test sets for
927
667
  * that, which the warning on reading one names.
@@ -983,19 +723,453 @@ const channelPermissions = discord_js.GuildChannel.prototype;
983
723
  }
984
724
  return undefined;
985
725
  }
986
- /** 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) {
987
- for (const defaults of strictMocks() ? [
988
- STRICT_DEFAULTS,
989
- DEFAULTS
990
- ] : [
991
- DEFAULTS
992
- ]){
993
- for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
994
- const found = defaults.get(proto)?.[key];
995
- if (found !== undefined) return found;
726
+ /**
727
+ * The defaults a mock reads for a property the test gave none, made with `makers`: for `key` on a mock of `target`'s
728
+ * class, the default of the nearest class that has one, strict ones first.
729
+ */ function discordDefaults(makers) {
730
+ const { botMemberOf, createChatInputOptions, createMockChannel, createMockGuild, createMockMessage, createMockUser, heldChannelOf, isRawMember, isUnsetEveryone, madeAt, memberRoles, NEW_SERVER_EVERYONE_PERMISSIONS, ownedManager, readPlaceAs, readPlaceQuietly } = makers;
731
+ /**
732
+ * A member's permissions where an interaction was made, as Discord computes them: under strict mocks, every permission
733
+ * for an Administrator or the owner, else the channel's overwrites on top of `member.permissions`, so a test that sets
734
+ * those keeps the base; otherwise `member.permissions`, with a warning once where strict mocks would read otherwise.
735
+ */ function inChannel(interaction, member) {
736
+ const base = member.permissions;
737
+ // Only a server the mock caches has the overwrites to apply
738
+ const { guild } = interaction;
739
+ if (!(guild instanceof discord_js.Guild)) return base;
740
+ 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);
741
+ if (strictMocks()) return computed;
742
+ if (!computed.equals(base)) {
743
+ const lost = new discord_js.PermissionsBitField(base).remove(computed).toArray();
744
+ const gained = new discord_js.PermissionsBitField(computed).remove(base).toArray();
745
+ const change = lost.length > 0 ? [
746
+ `without ${lost.join(', ')}`
747
+ ] : [];
748
+ if (gained.length > 0) change.push(`with ${gained.join(', ')}`);
749
+ 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.');
996
750
  }
751
+ // Unless the test set them, a member's permissions come from its roles, @everyone's included
752
+ if (!Object.prototype.hasOwnProperty.call(member, 'permissions')) warnOnUnsetEveryone(interaction, guild, 'memberPermissions', base);
753
+ return base;
997
754
  }
998
- return undefined;
755
+ /**
756
+ * Warns once, as `key`, where `permissions` read a server's @everyone role default mode made with none, and lack what
757
+ * strict mocks give @everyone in a new server.
758
+ */ function warnOnUnsetEveryone(interaction, guild, key, permissions) {
759
+ const everyone = guild.roles.everyone;
760
+ if (!everyone || !isUnsetEveryone(everyone) || permissions.has(NEW_SERVER_EVERYONE_PERMISSIONS)) return;
761
+ 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.');
762
+ }
763
+ /**
764
+ * The channel whose overwrites apply where an interaction was made, read without making or caching one: a thread's
765
+ * parent, or undefined where the channel, or a thread's parent, is one the mock would make, which has none.
766
+ */ function overwritingChannel(interaction, guild) {
767
+ const channel = heldChannelOf(interaction, guild);
768
+ // 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
769
+ const parentId = (thread)=>Object.prototype.hasOwnProperty.call(thread, 'parentId') ? thread.parentId : madeParentIds.get(thread);
770
+ const at = channel instanceof discord_js.ThreadChannel ? guild.channels.cache.get(parentId(channel) ?? '') : channel;
771
+ return at instanceof discord_js.GuildChannel ? at : undefined;
772
+ }
773
+ /**
774
+ * A member's join or a client's ready time: under strict mocks when the mock was made, as Discord sends it, so time a
775
+ * test advances afterwards counts. Default mode stamps it at the first read and warns once.
776
+ */ function stampedAt(mock, name) {
777
+ if (strictMocks()) return madeAt(mock);
778
+ controller_decorator.warnOnce(mockLogger$1, `${name} here is the time it was first read; under useStrictMocks() it is the time the mock was made, so time a ` + 'test advances afterwards counts.');
779
+ return Date.now();
780
+ }
781
+ /**
782
+ * The server of a channel or thread made without one: the one its own guildId names, else one of its own. Its own
783
+ * value is read, not its default, which reads the guild in turn.
784
+ */ function guildFromOwnId(channel) {
785
+ const id = Object.getOwnPropertyDescriptor(channel, 'guildId')?.value;
786
+ return createMockGuild(typeof id === 'string' ? {
787
+ id
788
+ } : {});
789
+ }
790
+ /** The bot's message a component interaction was used on, in the interaction's channel, holding that component. */ function clickedMessage(interaction) {
791
+ const channel = readPlaceQuietly(()=>interaction.channel);
792
+ const bot = interaction.client?.user;
793
+ const { componentType: type } = interaction;
794
+ // A placeholder customId, for an interaction made without one, has no place in the component's data
795
+ const customId = typeof interaction.customId === 'string' ? interaction.customId : undefined;
796
+ const component = type === discord_js.ComponentType.Button ? {
797
+ type,
798
+ custom_id: customId,
799
+ style: discord_js.ButtonStyle.Primary
800
+ } : type === discord_js.ComponentType.StringSelect ? {
801
+ type,
802
+ custom_id: customId,
803
+ options: []
804
+ } : typeof type === 'number' ? {
805
+ type,
806
+ custom_id: customId
807
+ } : undefined;
808
+ return createMockMessage({
809
+ ...channel ? {
810
+ channel: channel
811
+ } : {},
812
+ ...bot instanceof discord_js.User ? {
813
+ author: bot
814
+ } : {},
815
+ components: component ? [
816
+ {
817
+ type: discord_js.ComponentType.ActionRow,
818
+ components: [
819
+ component
820
+ ]
821
+ }
822
+ ] : []
823
+ });
824
+ }
825
+ /**
826
+ * What a mock reads for a data property discord.js always sets, where the test gave none: a value in Discord's shape,
827
+ * such as `'en-US'` for a locale, `null` for what may be absent and `false` for a flag, or the property's own getter
828
+ * for one discord.js computes. The classes a mock descends from are read nearest first.
829
+ */ const DEFAULTS = new Map([
830
+ defaultsOf(discord_js.Guild, {
831
+ name: ()=>'Guild',
832
+ preferredLocale: ()=>discord_js.Locale.EnglishUS,
833
+ ownerId: nextSnowflake,
834
+ memberCount: ()=>1,
835
+ available: ()=>true,
836
+ large: ()=>false,
837
+ features: ()=>[],
838
+ description: ()=>null,
839
+ icon: ()=>null,
840
+ banner: ()=>null,
841
+ splash: ()=>null,
842
+ systemChannelId: ()=>null,
843
+ shardId: ()=>0,
844
+ nsfwLevel: ()=>discord_js.GuildNSFWLevel.Default,
845
+ verificationLevel: ()=>discord_js.GuildVerificationLevel.None,
846
+ presences: (guild)=>ownedManager(discord_js.PresenceManager.prototype, {
847
+ guild
848
+ }),
849
+ premiumTier: ()=>discord_js.GuildPremiumTier.None,
850
+ createdTimestamp: REAL_GETTER,
851
+ createdAt: REAL_GETTER
852
+ }),
853
+ defaultsOf(discord_js.ClientUser, {
854
+ username: ()=>'bot'
855
+ }),
856
+ defaultsOf(discord_js.User, {
857
+ username: ()=>'user',
858
+ bot: ()=>false,
859
+ globalName: ()=>null,
860
+ discriminator: ()=>'0',
861
+ avatar: ()=>null,
862
+ system: ()=>false,
863
+ flags: ()=>new discord_js.UserFlagsBitField(),
864
+ tag: REAL_GETTER,
865
+ displayName: REAL_GETTER,
866
+ defaultAvatarURL: REAL_GETTER,
867
+ createdTimestamp: REAL_GETTER,
868
+ createdAt: REAL_GETTER
869
+ }),
870
+ defaultsOf(discord_js.GuildMember, {
871
+ user: ()=>createMockUser(),
872
+ id: REAL_GETTER,
873
+ nickname: ()=>null,
874
+ avatar: ()=>null,
875
+ joinedTimestamp: (member)=>stampedAt(member, 'GuildMember.joinedTimestamp'),
876
+ joinedAt: REAL_GETTER,
877
+ partial: REAL_GETTER,
878
+ premiumSince: REAL_GETTER,
879
+ communicationDisabledUntil: REAL_GETTER,
880
+ premiumSinceTimestamp: ()=>null,
881
+ communicationDisabledUntilTimestamp: ()=>null,
882
+ pending: ()=>false,
883
+ flags: ()=>new discord_js.GuildMemberFlagsBitField().freeze(),
884
+ displayName: REAL_GETTER,
885
+ // The colour of its highest coloured role, as discord.js computes it from the roles in its cache
886
+ displayColor: REAL_GETTER,
887
+ displayHexColor: REAL_GETTER,
888
+ roles: (member)=>memberRoles(member, []),
889
+ permissions: REAL_GETTER
890
+ }),
891
+ // A server's emoji, sticker, scheduled event and AutoMod rule are named, and what discord.js formats from them
892
+ // computes: an emoji's mention and identifier, an event's and an invite's link
893
+ defaultsOf(discord_js.GuildEmoji, {
894
+ name: ()=>'emoji',
895
+ animated: ()=>false,
896
+ identifier: REAL_GETTER
897
+ }),
898
+ defaultsOf(discord_js.Sticker, {
899
+ name: ()=>'sticker'
900
+ }),
901
+ defaultsOf(discord_js.GuildScheduledEvent, {
902
+ name: ()=>'event',
903
+ url: REAL_GETTER
904
+ }),
905
+ defaultsOf(discord_js.AutoModerationRule, {
906
+ name: ()=>'rule'
907
+ }),
908
+ defaultsOf(discord_js.Invite, {
909
+ url: REAL_GETTER
910
+ }),
911
+ defaultsOf(discord_js.Role, {
912
+ name: ()=>'role',
913
+ // Above @everyone, which a server's role manager makes at position 0
914
+ position: ()=>1,
915
+ // Every role has a server; one made without is in one of its own, unless a guild given it takes it in
916
+ guild: ()=>createMockGuild(),
917
+ rawPosition: (role)=>role.position,
918
+ permissions: noPermissions,
919
+ hoist: ()=>false,
920
+ managed: ()=>false,
921
+ mentionable: ()=>false,
922
+ icon: ()=>null,
923
+ unicodeEmoji: ()=>null,
924
+ tags: ()=>null,
925
+ colors: ()=>({
926
+ primaryColor: 0,
927
+ secondaryColor: null,
928
+ tertiaryColor: null
929
+ }),
930
+ hexColor: REAL_GETTER,
931
+ createdTimestamp: REAL_GETTER,
932
+ createdAt: REAL_GETTER
933
+ }),
934
+ // A message's text says which handler it is for, so it stays the test's to give, as a command's name does
935
+ defaultsOf(discord_js.Message, {
936
+ pinned: ()=>false,
937
+ tts: ()=>false,
938
+ type: ()=>discord_js.MessageType.Default,
939
+ system: ()=>false,
940
+ webhookId: ()=>null,
941
+ applicationId: ()=>null,
942
+ nonce: ()=>null,
943
+ position: ()=>null,
944
+ activity: ()=>null,
945
+ reference: ()=>null,
946
+ poll: ()=>null,
947
+ interactionMetadata: ()=>null,
948
+ stickers: ()=>new discord_js.Collection(),
949
+ messageSnapshots: ()=>new discord_js.Collection(),
950
+ reactions: (message)=>ownedManager(discord_js.ReactionManager.prototype, {
951
+ message
952
+ }),
953
+ url: REAL_GETTER
954
+ }),
955
+ // A command's name and a component's customId say which handler a call is for, so they stay the test's to give
956
+ defaultsOf(discord_js.ChatInputCommandInteraction, {
957
+ options: ()=>createChatInputOptions()
958
+ }),
959
+ defaultsOf(discord_js.CommandInteraction, {
960
+ commandId: nextSnowflake,
961
+ commandGuildId: ()=>null
962
+ }),
963
+ defaultsOf(discord_js.AutocompleteInteraction, {
964
+ commandId: nextSnowflake,
965
+ commandGuildId: ()=>null,
966
+ options: ()=>createChatInputOptions()
967
+ }),
968
+ defaultsOf(discord_js.MessageReaction, {
969
+ users: (reaction)=>ownedManager(discord_js.ReactionUserManager.prototype, {
970
+ reaction
971
+ })
972
+ }),
973
+ // The bot's message the component was used on, holding it, in the interaction's channel, as Discord always sends it;
974
+ // the component discord.js finds there by its customId
975
+ defaultsOf(discord_js.MessageComponentInteraction, {
976
+ message: clickedMessage,
977
+ component: REAL_GETTER
978
+ }),
979
+ // A modal submitted with no fields given has none
980
+ defaultsOf(discord_js.ModalSubmitInteraction, {
981
+ fields: ()=>createModalFields({}),
982
+ components: ()=>[]
983
+ }),
984
+ defaultsOf(discord_js.BaseInteraction, {
985
+ applicationId: nextSnowflake,
986
+ token: ()=>'mock-interaction-token',
987
+ version: ()=>1,
988
+ context: ()=>null,
989
+ entitlements: ()=>new discord_js.Collection(),
990
+ // What Discord sends with an interaction in a server, the member's permissions there; typed for a cached one
991
+ // Each reads where the interaction is as its own, and warns by its own name where strict mocks place it otherwise
992
+ memberPermissions: (interaction)=>readPlaceAs(interaction, 'memberPermissions', ()=>{
993
+ const { guildId } = interaction;
994
+ const member = interaction.member;
995
+ if (!guildId) return null;
996
+ if (member instanceof discord_js.GuildMember) return inChannel(interaction, member);
997
+ // A raw member's permissions are the string Discord sends, read as discord.js reads them
998
+ return isRawMember(member) && !interaction.guild ? new discord_js.PermissionsBitField(BigInt(member.permissions)).freeze() : null;
999
+ }),
1000
+ // The bot's permissions where the interaction was made: its member's in the channel, or in a thread its parent, as
1001
+ // discord.js computes them; none outside a server
1002
+ appPermissions: (interaction)=>readPlaceAs(interaction, 'appPermissions', ()=>{
1003
+ const { guild } = interaction;
1004
+ const me = guild instanceof discord_js.Guild ? botMemberOf(guild) : null;
1005
+ if (!(me instanceof discord_js.GuildMember) || heldChannelOf(interaction, guild) === null) return noPermissions();
1006
+ // discord.js's own computation, given its own overwritesFor over a test's stub on the channel; a channel the
1007
+ // mock would make has no overwrites
1008
+ const at = overwritingChannel(interaction, guild) ?? {
1009
+ guild,
1010
+ permissionOverwrites: {
1011
+ cache: new discord_js.Collection()
1012
+ }
1013
+ };
1014
+ const channel = Object.create(at, {
1015
+ overwritesFor: {
1016
+ value: channelPermissions.overwritesFor
1017
+ }
1018
+ });
1019
+ const permissions = channelPermissions.memberPermissions.call(channel, me, true);
1020
+ // The bot's member has @everyone's too
1021
+ warnOnUnsetEveryone(interaction, guild, 'appPermissions', permissions);
1022
+ return permissions;
1023
+ }, // A server the bot isn't in has no guild in either mode, so no permissions in both
1024
+ (place)=>!place.raw)
1025
+ }),
1026
+ defaultsOf(discord_js.TextChannel, {
1027
+ type: ()=>discord_js.ChannelType.GuildText,
1028
+ nsfw: ()=>false,
1029
+ topic: ()=>null,
1030
+ rateLimitPerUser: ()=>0
1031
+ }),
1032
+ defaultsOf(discord_js.NewsChannel, {
1033
+ type: ()=>discord_js.ChannelType.GuildAnnouncement,
1034
+ nsfw: ()=>false,
1035
+ topic: ()=>null
1036
+ }),
1037
+ defaultsOf(discord_js.VoiceChannel, {
1038
+ type: ()=>discord_js.ChannelType.GuildVoice,
1039
+ nsfw: ()=>false
1040
+ }),
1041
+ defaultsOf(discord_js.ForumChannel, {
1042
+ type: ()=>discord_js.ChannelType.GuildForum,
1043
+ nsfw: ()=>false,
1044
+ topic: ()=>null
1045
+ }),
1046
+ defaultsOf(discord_js.MediaChannel, {
1047
+ type: ()=>discord_js.ChannelType.GuildMedia,
1048
+ nsfw: ()=>false,
1049
+ topic: ()=>null
1050
+ }),
1051
+ defaultsOf(discord_js.StageChannel, {
1052
+ type: ()=>discord_js.ChannelType.GuildStageVoice,
1053
+ nsfw: ()=>false,
1054
+ topic: ()=>null
1055
+ }),
1056
+ defaultsOf(discord_js.CategoryChannel, {
1057
+ type: ()=>discord_js.ChannelType.GuildCategory
1058
+ }),
1059
+ defaultsOf(discord_js.DirectoryChannel, {
1060
+ type: ()=>discord_js.ChannelType.GuildDirectory
1061
+ }),
1062
+ // A thread's server is its guild's, as discord.js sets it from the thread's guild
1063
+ defaultsOf(discord_js.ThreadChannel, {
1064
+ type: ()=>discord_js.ChannelType.PublicThread,
1065
+ name: ()=>'thread',
1066
+ guildId: guildIdOf
1067
+ }),
1068
+ defaultsOf(discord_js.DMChannel, {
1069
+ type: ()=>discord_js.ChannelType.DM
1070
+ }),
1071
+ defaultsOf(discord_js.PartialGroupDMChannel, {
1072
+ type: ()=>discord_js.ChannelType.GroupDM
1073
+ }),
1074
+ defaultsOf(discord_js.GuildChannel, {
1075
+ name: ()=>'channel',
1076
+ guildId: guildIdOf,
1077
+ position: ()=>0,
1078
+ rawPosition: ()=>0,
1079
+ parentId: ()=>null
1080
+ }),
1081
+ defaultsOf(discord_js.BaseChannel, {
1082
+ createdTimestamp: REAL_GETTER,
1083
+ createdAt: REAL_GETTER
1084
+ }),
1085
+ defaultsOf(discord_js.Client, {
1086
+ readyTimestamp: (client)=>stampedAt(client, 'Client.readyTimestamp'),
1087
+ readyAt: REAL_GETTER,
1088
+ uptime: REAL_GETTER,
1089
+ token: ()=>null
1090
+ }),
1091
+ // What a member's roles show, computed from the roles in the cache as discord.js does
1092
+ defaultsOf(discord_js.GuildMemberRoleManager, {
1093
+ color: REAL_GETTER,
1094
+ hoist: REAL_GETTER,
1095
+ icon: REAL_GETTER
1096
+ }),
1097
+ defaultsOf(discord_js.BaseGuild, {
1098
+ nameAcronym: REAL_GETTER
1099
+ })
1100
+ ]);
1101
+ /**
1102
+ * What strict mocks also read as Discord sends it, for discord.js's getters that read it: a server for a channel or
1103
+ * thread made without one, a thread's parent text channel in it, and data that is otherwise a placeholder.
1104
+ */ const STRICT_DEFAULTS = new Map([
1105
+ defaultsOf(discord_js.Message, {
1106
+ content: ()=>'',
1107
+ editedTimestamp: ()=>null,
1108
+ editedAt: REAL_GETTER
1109
+ }),
1110
+ defaultsOf(discord_js.MessageMentions, {
1111
+ repliedUser: ()=>null
1112
+ }),
1113
+ defaultsOf(discord_js.Guild, {
1114
+ rulesChannelId: ()=>null,
1115
+ publicUpdatesChannelId: ()=>null,
1116
+ systemChannel: REAL_GETTER
1117
+ }),
1118
+ defaultsOf(discord_js.BaseGuild, {
1119
+ verified: REAL_GETTER
1120
+ }),
1121
+ defaultsOf(discord_js.GuildMember, {
1122
+ presence: REAL_GETTER
1123
+ }),
1124
+ defaultsOf(discord_js.ModalSubmitInteraction, {
1125
+ message: ()=>null
1126
+ }),
1127
+ defaultsOf(discord_js.ThreadChannel, {
1128
+ guild: guildFromOwnId,
1129
+ guildId: (thread)=>thread.guild.id,
1130
+ parentId: (thread)=>{
1131
+ const parent = createMockChannel(discord_js.TextChannel, {
1132
+ guild: thread.guild
1133
+ });
1134
+ thread.guild.channels.cache.set(parent.id, parent);
1135
+ madeParentIds.set(thread, parent.id);
1136
+ return parent.id;
1137
+ },
1138
+ parent: REAL_GETTER,
1139
+ ownerId: nextSnowflake,
1140
+ archived: ()=>false,
1141
+ locked: ()=>false
1142
+ }),
1143
+ defaultsOf(discord_js.GuildChannel, {
1144
+ guild: guildFromOwnId,
1145
+ parent: REAL_GETTER
1146
+ }),
1147
+ // Full once its members reach a user limit, which a voice channel has none of unless given one
1148
+ defaultsOf(discord_js.BaseGuildVoiceChannel, {
1149
+ userLimit: ()=>0,
1150
+ full: REAL_GETTER
1151
+ }),
1152
+ // A reaction's message, as discord.js always has one, is a whole one
1153
+ defaultsOf(discord_js.MessageReaction, {
1154
+ count: ()=>1,
1155
+ me: ()=>false,
1156
+ message: ()=>createMockMessage()
1157
+ })
1158
+ ]);
1159
+ return (target, key)=>{
1160
+ for (const defaults of strictMocks() ? [
1161
+ STRICT_DEFAULTS,
1162
+ DEFAULTS
1163
+ ] : [
1164
+ DEFAULTS
1165
+ ]){
1166
+ for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
1167
+ const found = defaults.get(proto)?.[key];
1168
+ if (found !== undefined) return found;
1169
+ }
1170
+ }
1171
+ return undefined;
1172
+ };
999
1173
  }
1000
1174
 
1001
1175
  /** Where a mock resolves unfurled media to, as Discord's media proxy would. */ const PROXY = 'https://images-ext-1.discordapp.net/external/mock';
@@ -1131,8 +1305,12 @@ const SKIP = new Set([
1131
1305
  const mockTargets = new WeakMap();
1132
1306
  // Warnings a read answered by the stub logs first, by the mock's instance and the property
1133
1307
  const stubReadWarnings = new WeakMap();
1308
+ // When each mock was made, which what Discord stamps when a structure arrives, such as a member's join, reads
1309
+ const madeTimes = new WeakMap();
1310
+ /** When `mock` was made: the time a value Discord stamps as it sends the structure reads. */ const madeAt = (mock)=>madeTimes.get(mock) ?? Date.now();
1134
1311
  function stubDeep(instance, externalStubs, onSet) {
1135
1312
  noteMockMade();
1313
+ const made = Date.now();
1136
1314
  const stubs = externalStubs ?? new Map();
1137
1315
  // Placeholders read so far, by the value strict mocks give: a read of a kept one still warns, once
1138
1316
  const placeholders = new Map();
@@ -1197,7 +1375,7 @@ function stubDeep(instance, externalStubs, onSet) {
1197
1375
  }
1198
1376
  // A client the mock's structures share: its guild's, or one of its own
1199
1377
  if (key === 'client' && target instanceof discord_js.Base) return clientOf(target);
1200
- const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>quietly(target, key, ()=>protoValue.apply(proxy, args))) : methodStub(target, key, protoValue, ()=>proxy) : stubDeep({});
1378
+ const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>lacksServer(target, key, proxy) ? undefined : quietly(target, key, ()=>protoValue.apply(proxy, args))) : methodStub(target, key, protoValue, ()=>proxy) : autoStub();
1201
1379
  stubs.set(key, stub);
1202
1380
  return stub;
1203
1381
  },
@@ -1220,6 +1398,7 @@ function stubDeep(instance, externalStubs, onSet) {
1220
1398
  }
1221
1399
  });
1222
1400
  mockTargets.set(proxy, instance);
1401
+ madeTimes.set(proxy, made);
1223
1402
  return proxy;
1224
1403
  }
1225
1404
  // discord.js methods that read only what the mock holds, so they run for real: looking items up in a manager's cache,
@@ -1293,6 +1472,32 @@ const STRICT_REAL_METHODS = new Map([
1293
1472
  ])
1294
1473
  ]
1295
1474
  ]);
1475
+ // A channel's permission methods, each of which reads the channel's server
1476
+ const SERVER_PERMISSION_METHODS = new Set([
1477
+ 'permissionsFor',
1478
+ 'overwritesFor',
1479
+ 'memberPermissions',
1480
+ 'rolePermissions'
1481
+ ]);
1482
+ // The stubs a mock makes for what a test never gave it, as opposed to anything a test passes or sets
1483
+ const autoStubs = new WeakSet();
1484
+ function autoStub() {
1485
+ const stub = stubDeep({});
1486
+ autoStubs.add(stub);
1487
+ return stub;
1488
+ }
1489
+ /**
1490
+ * Whether a channel's permission method has no server to read: in default mode a channel made without one holds the
1491
+ * mock's own stub for it, so the method returns `undefined`, as a thread's does, and says so once. A server the test
1492
+ * gave, of whatever class, is read as it is. Strict mocks give the channel one.
1493
+ */ function lacksServer(target, key, channel) {
1494
+ if (strictMocks() || !(target instanceof discord_js.GuildChannel) || !SERVER_PERMISSION_METHODS.has(key)) return false;
1495
+ const guild = readPlaceQuietly(()=>channel.guild);
1496
+ if (typeof guild !== 'object' || guild === null || !autoStubs.has(guild)) return false;
1497
+ const name = Object.getPrototypeOf(target).constructor.name;
1498
+ controller_decorator.warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock gives the channel a server and computes it', `Give the channel a guild, such as createMockChannel(${name}, { guild }), or call useStrictMocks() to have the mock compute it now.`);
1499
+ return true;
1500
+ }
1296
1501
  function isRealMethod(target, key) {
1297
1502
  for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
1298
1503
  if (REAL_METHODS.get(proto)?.has(key) || strictMocks() && STRICT_REAL_METHODS.get(proto)?.has(key)) return true;
@@ -1437,28 +1642,68 @@ function clientOf(target) {
1437
1642
  // ---------------------------------------------------------------------------
1438
1643
  // methodStub — what an auto-stubbed discord.js method returns
1439
1644
  // ---------------------------------------------------------------------------
1440
- // discord.js methods not declared `async` that still return a promise; `set*` setters are matched by name
1645
+ // discord.js methods not declared `async` that still return a promise; `set*` setters are matched by name.
1646
+ // mock-promise-methods.spec checks this list against discord.js's typings
1441
1647
  const PROMISE_METHODS = new Set([
1648
+ 'awaitMessageComponent',
1649
+ 'awaitMessages',
1650
+ 'awaitReactions',
1442
1651
  'ban',
1652
+ 'bulkBan',
1443
1653
  'clone',
1654
+ 'create',
1444
1655
  'createDM',
1445
1656
  'createInvite',
1657
+ 'createStageInstance',
1658
+ 'createWebhook',
1659
+ 'delete',
1660
+ 'deleteDM',
1446
1661
  'disableCommunicationUntil',
1662
+ 'disconnect',
1447
1663
  'edit',
1448
1664
  'fetch',
1665
+ 'fetchAuthor',
1449
1666
  'fetchFlags',
1450
1667
  'fetchInvites',
1451
1668
  'fetchMe',
1452
1669
  'fetchReply',
1670
+ 'fetchSubscribers',
1671
+ 'fetchUser',
1672
+ 'fetchVoters',
1453
1673
  'fetchWebhooks',
1454
1674
  'fetchWidget',
1455
1675
  'forward',
1676
+ 'kick',
1456
1677
  'pin',
1678
+ 'rawFetchGuildActiveThreads',
1457
1679
  'removeAttachments',
1458
1680
  'suppressEmbeds',
1459
1681
  'timeout',
1682
+ 'unban',
1460
1683
  'unpin'
1461
1684
  ]);
1685
+ // Methods that return a promise on one class only, as their name is synchronous on another: a role manager's set, and
1686
+ // a scheduled event's setStatus, where a client user's setStatus applies at once
1687
+ const PROMISE_METHODS_OF = [
1688
+ [
1689
+ discord_js.GuildEmojiRoleManager,
1690
+ new Set([
1691
+ 'set'
1692
+ ])
1693
+ ],
1694
+ [
1695
+ discord_js.GuildMemberRoleManager,
1696
+ new Set([
1697
+ 'set'
1698
+ ])
1699
+ ],
1700
+ [
1701
+ discord_js.GuildScheduledEvent,
1702
+ new Set([
1703
+ 'setStatus'
1704
+ ])
1705
+ ]
1706
+ ];
1462
1707
  // The client user's presence setters, which apply at once and return the presence
1463
1708
  const SYNC_SETTERS = new Set([
1464
1709
  'setActivity',
@@ -1483,27 +1728,129 @@ const SELF_METHODS = new Set([
1483
1728
  'disableCommunicationUntil',
1484
1729
  'edit',
1485
1730
  'fetch',
1731
+ 'kick',
1486
1732
  'pin',
1487
1733
  'timeout',
1488
1734
  'unpin'
1489
1735
  ]);
1736
+ // Collectors that resolve to what they collected: nothing, for a mock no one answers
1737
+ const COLLECTING_METHODS = new Set([
1738
+ 'awaitMessages',
1739
+ 'awaitReactions'
1740
+ ]);
1490
1741
  // The DM channel of each user, made once, as Discord keeps one per user
1491
1742
  const dmChannels = new WeakMap();
1492
- function dmChannelOf(user) {
1743
+ /**
1744
+ * A user's one DM channel: its recipient is the user, and it is the user's `dmChannel`. Under strict mocks its client
1745
+ * is the user's; default mode keeps a client of its own, and a read of it warns once where it isn't the user's.
1746
+ */ function dmChannelOf(user) {
1493
1747
  let channel = dmChannels.get(user);
1494
1748
  if (!channel) {
1495
- channel = createMockChannel(discord_js.DMChannel);
1496
- channel.recipientId = user.id;
1749
+ const made = createMockChannel(discord_js.DMChannel);
1750
+ // Getters in discord.js, so set through the mock, which defines them
1751
+ made.recipientId = user.id;
1752
+ if (user instanceof discord_js.User) {
1753
+ // Not enumerated, so a DM channel's own keys, and a user's, stay those a test may compare
1754
+ const place = (target, key, value)=>Object.defineProperty(target, key, {
1755
+ value,
1756
+ writable: true,
1757
+ configurable: true
1758
+ });
1759
+ place(made, 'recipient', user);
1760
+ place(user, 'dmChannel', made);
1761
+ if (user.client instanceof discord_js.Client) {
1762
+ if (strictMocks()) place(made, 'client', user.client);
1763
+ else warnOnClientRead(made, user.client);
1764
+ }
1765
+ }
1766
+ channel = made;
1497
1767
  dmChannels.set(user, channel);
1498
1768
  }
1499
1769
  return channel;
1500
1770
  }
1771
+ /**
1772
+ * Has a read of a default-mode DM channel's `client`, its own where strict mocks give the user's, warn once, so a test
1773
+ * that reads it hears of the difference and the mock's own reads, as a send through the channel makes, stay quiet.
1774
+ */ function warnOnClientRead(channel, userClient) {
1775
+ if (readPlaceQuietly(()=>channel.client) === userClient) return;
1776
+ // The read is answered by the channel's stub, which warns by the mock's own instance
1777
+ warnOnRead(mockTargets.get(channel) ?? channel, 'client', ()=>{
1778
+ if (readingPlace > 0) return;
1779
+ controller_decorator.warnOnce(mockLogger, "A mock user's DM channel here has a client of its own, not the user's; under useStrictMocks() it shares the user's " + 'client.');
1780
+ });
1781
+ }
1782
+ // What default mode's user manager DMs do apart from discord.js's, warned of once each
1783
+ const USER_MANAGER_DM_WARNINGS = {
1784
+ createDM: 'UserManager.createDM() here gives every user the same DM channel; under useStrictMocks() each user has their own, ' + 'as Discord keeps one per user.',
1785
+ send: "UserManager.send() here resolves to a message that doesn't hold what was sent; under useStrictMocks() it sends " + "through the user's DM channel, as discord.js does."
1786
+ };
1787
+ /** A user made for a manager, on the manager's client, as discord.js makes one. */ function userOf(manager, id) {
1788
+ const user = createMockUser(id ? {
1789
+ id
1790
+ } : {});
1791
+ const client = manager.client;
1792
+ if (client instanceof discord_js.Client) Object.defineProperty(user, 'client', {
1793
+ value: client,
1794
+ writable: true,
1795
+ configurable: true
1796
+ });
1797
+ return user;
1798
+ }
1501
1799
  /** The guild a manager belongs to, when it is a mock guild's. */ const guildOf = (manager)=>{
1502
1800
  const guild = manager.guild;
1503
1801
  return guild instanceof discord_js.Guild ? guild : undefined;
1504
1802
  };
1505
- // The item each manager fetches, creates and edits: with the id asked for, but for a ban or a thread member, and a
1506
- // member or channel in the manager's guild
1803
+ // The class discord.js makes for each type of channel a server's channel manager creates; a text channel by default
1804
+ const CHANNEL_CLASSES = {
1805
+ [discord_js.ChannelType.GuildText]: discord_js.TextChannel,
1806
+ [discord_js.ChannelType.GuildVoice]: discord_js.VoiceChannel,
1807
+ [discord_js.ChannelType.GuildCategory]: discord_js.CategoryChannel,
1808
+ [discord_js.ChannelType.GuildAnnouncement]: discord_js.NewsChannel,
1809
+ [discord_js.ChannelType.GuildStageVoice]: discord_js.StageChannel,
1810
+ [discord_js.ChannelType.GuildDirectory]: discord_js.DirectoryChannel,
1811
+ [discord_js.ChannelType.GuildForum]: discord_js.ForumChannel,
1812
+ [discord_js.ChannelType.GuildMedia]: discord_js.MediaChannel
1813
+ };
1814
+ /**
1815
+ * The class of a channel a server's channel manager makes for `type`: under strict mocks the one discord.js makes,
1816
+ * while default mode keeps a text channel and warns once for another type.
1817
+ */ function createdChannelClass(type) {
1818
+ const Class = CHANNEL_CLASSES[type ?? discord_js.ChannelType.GuildText] ?? discord_js.TextChannel;
1819
+ if (Class === discord_js.TextChannel || strictMocks()) return Class;
1820
+ controller_decorator.warnOnce(mockLogger, 'GuildChannelManager.create() here makes a text channel whatever type its options name; under useStrictMocks() it ' + 'makes the channel discord.js makes for that type.');
1821
+ return discord_js.TextChannel;
1822
+ }
1823
+ /** A thread a channel's thread manager makes: in that channel, as its parent, and in its server. */ function threadIn(id, manager) {
1824
+ const channel = manager.channel;
1825
+ if (!(channel instanceof discord_js.GuildChannel)) return createMockChannel(discord_js.ThreadChannel, id ? {
1826
+ id
1827
+ } : {});
1828
+ const guild = channel.guild;
1829
+ const thread = createMockChannel(discord_js.ThreadChannel, id ? {
1830
+ id
1831
+ } : {});
1832
+ // Not enumerated, so a mock thread's own keys stay those of one made alone, as a test may compare
1833
+ const place = (key, value)=>Object.defineProperty(thread, key, {
1834
+ value,
1835
+ writable: true,
1836
+ configurable: true
1837
+ });
1838
+ place('parentId', channel.id);
1839
+ if (!(guild instanceof discord_js.Guild)) return thread;
1840
+ place('guild', guild);
1841
+ place('guildId', guild.id);
1842
+ // The parent is found through its server's channels, as discord.js finds it, by the thread's parentId. Strict mocks
1843
+ // cache the channel there; default mode leaves the server's cache as it was and reads the channel itself
1844
+ if (strictMocks()) cached(cacheOf(guild.channels), channel.id, ()=>channel);
1845
+ Object.defineProperty(thread, 'parent', {
1846
+ get: ()=>{
1847
+ const parentId = thread.parentId ?? '';
1848
+ return cacheOf(guild.channels)?.get(parentId) ?? (parentId === channel.id ? channel : null);
1849
+ },
1850
+ configurable: true
1851
+ });
1852
+ return thread;
1853
+ }
1507
1854
  const MANAGER_ITEMS = [
1508
1855
  [
1509
1856
  discord_js.UserManager,
@@ -1545,9 +1892,10 @@ const MANAGER_ITEMS = [
1545
1892
  ()=>createMockInteraction(discord_js.GuildBan)
1546
1893
  ],
1547
1894
  // A message fetched from a channel is in it, and another user's
1895
+ // A message made in the channel is the one fetched by its id, as Discord has it
1548
1896
  [
1549
1897
  discord_js.GuildMessageManager,
1550
- (id, manager)=>createMockMessage({
1898
+ (id, manager)=>madeIn(manager, id) ?? createMockMessage({
1551
1899
  ...id ? {
1552
1900
  id
1553
1901
  } : {},
@@ -1556,7 +1904,7 @@ const MANAGER_ITEMS = [
1556
1904
  ],
1557
1905
  [
1558
1906
  discord_js.DMMessageManager,
1559
- (id, manager)=>createMockMessage({
1907
+ (id, manager)=>madeIn(manager, id) ?? createMockMessage({
1560
1908
  ...id ? {
1561
1909
  id
1562
1910
  } : {},
@@ -1565,15 +1913,11 @@ const MANAGER_ITEMS = [
1565
1913
  ],
1566
1914
  [
1567
1915
  discord_js.GuildTextThreadManager,
1568
- (id)=>createMockChannel(discord_js.ThreadChannel, id ? {
1569
- id
1570
- } : {})
1916
+ threadIn
1571
1917
  ],
1572
1918
  [
1573
1919
  discord_js.GuildForumThreadManager,
1574
- (id)=>createMockChannel(discord_js.ThreadChannel, id ? {
1575
- id
1576
- } : {})
1920
+ threadIn
1577
1921
  ],
1578
1922
  [
1579
1923
  discord_js.ThreadMemberManager,
@@ -1599,7 +1943,7 @@ const MANAGER_ITEMS = [
1599
1943
  ],
1600
1944
  [
1601
1945
  discord_js.GuildChannelManager,
1602
- (id, manager)=>createMockChannel(discord_js.TextChannel, {
1946
+ (id, manager, options)=>createMockChannel(createdChannelClass(options?.type), {
1603
1947
  ...id ? {
1604
1948
  id
1605
1949
  } : {},
@@ -1650,7 +1994,22 @@ const MANAGER_ITEMS = [
1650
1994
  } : {}
1651
1995
  });
1652
1996
  }
1653
- const returnsPromise = (key, method)=>method.constructor.name === 'AsyncFunction' || PROMISE_METHODS.has(key) || /^set[A-Z]/.test(key) && !SYNC_SETTERS.has(key);
1997
+ /**
1998
+ * Names what a manager's create() or edit() made the `name` its options give, as discord.js does, under strict mocks;
1999
+ * default mode keeps the name it has, with a warning once.
2000
+ */ function withName(manager, method, item, options) {
2001
+ const name = options?.name;
2002
+ const current = item.name;
2003
+ if (typeof name !== 'string' || current === name) return item;
2004
+ if (strictMocks()) {
2005
+ item.name = name;
2006
+ return item;
2007
+ }
2008
+ const Manager = Object.getPrototypeOf(manager).constructor.name;
2009
+ controller_decorator.warnOnce(mockLogger, `${Manager}.${method}() here leaves the item with the mock's name, not the name its options give, as discord.js names it; ` + 'in the next major version (5.0) the mock names it so. Call useStrictMocks() to have it named now.');
2010
+ return item;
2011
+ }
2012
+ /** Whether a discord.js method on `target` returns a promise: an async one, or one the lists above name. */ const returnsPromise = (target, key, method)=>method.constructor.name === 'AsyncFunction' || PROMISE_METHODS.has(key) || PROMISE_METHODS_OF.some(([Class, keys])=>keys.has(key) && Class.prototype.isPrototypeOf(target)) || /^set[A-Z]/.test(key) && !SYNC_SETTERS.has(key);
1654
2013
  // The ids a fetch asks for: an id, a discord.js object, or options naming one or several, such as `{ user: id }` or
1655
2014
  // `{ user: [ids] }`; undefined for a fetch of a list
1656
2015
  /** The channel a message manager belongs to, as `createMockMessage` takes it. */ const channelOfManager = (manager)=>{
@@ -1678,18 +2037,58 @@ function fetchedIds(args) {
1678
2037
  }
1679
2038
  return undefined;
1680
2039
  }
2040
+ // How many messages discord.js's MessageManager.fetch() asks Discord for when its options give no limit
2041
+ const DEFAULT_FETCH_LIMIT = 50;
2042
+ /** Which of two messages is newer, by snowflake id, else by when the mock was made: negative when `a` is. */ function newerFirst(a, b) {
2043
+ if (isSnowflake(a.id) && isSnowflake(b.id) && a.id !== b.id) return BigInt(b.id) > BigInt(a.id) ? 1 : -1;
2044
+ return b.createdTimestamp - a.createdTimestamp;
2045
+ }
2046
+ /**
2047
+ * A channel's messages as `messages.fetch(options)` lists them under strict mocks: those its cache holds, newest first,
2048
+ * before, after or around the message `options` names, at most `limit` of them.
2049
+ */ function recentMessages(manager, options) {
2050
+ const { limit = DEFAULT_FETCH_LIMIT, before, after, around } = options ?? {};
2051
+ const all = [
2052
+ ...cacheOf(manager)?.values() ?? []
2053
+ ];
2054
+ const sorted = all.sort(newerFirst);
2055
+ const at = (id)=>sorted.findIndex((message)=>message.id === id);
2056
+ let window = sorted;
2057
+ if (before !== undefined) window = at(before) === -1 ? [] : sorted.slice(at(before) + 1);
2058
+ else if (after !== undefined) window = at(after) === -1 ? [] : sorted.slice(0, at(after)).slice(-limit);
2059
+ else if (around !== undefined) window = at(around) === -1 ? [] : sorted.slice(Math.max(0, at(around) - Math.floor(limit / 2)));
2060
+ return new discord_js.Collection(window.slice(0, limit).map((message)=>[
2061
+ message.id,
2062
+ message
2063
+ ]));
2064
+ }
1681
2065
  /**
1682
2066
  * The mock function for a method found on a discord.js prototype. One that returns a promise in discord.js resolves:
1683
- * `createDM` to the user's DM channel, `send` and its kin to a message, a manager's `fetch`, `create` and `edit` to its
1684
- * item (a collection of each for a fetch of several, an empty one for a list fetch), a structure's own `edit`, `fetch`,
1685
- * `delete`, `ban`, `pin`, `unpin`, `timeout`, `disableCommunicationUntil` and setters to the structure, and the rest to
1686
- * `undefined`. Any other method returns `undefined`.
2067
+ * `createDM` to the user's DM channel, `send` and its kin to a message, `awaitMessages` and `awaitReactions` to an
2068
+ * empty collection, a manager's `fetch`, `create` and `edit` to its item (a collection of each for a fetch of several,
2069
+ * an empty one for a list fetch), a structure's own `edit`, `fetch`, `delete`, `ban`, `kick`, `pin`, `unpin`, `timeout`,
2070
+ * `disableCommunicationUntil` and setters to the structure, and the rest to `undefined`. Any other method returns
2071
+ * `undefined`.
1687
2072
  */ function methodStub(target, key, method, receiver) {
1688
- if (!returnsPromise(key, method)) return createMockFn();
2073
+ if (!returnsPromise(target, key, method)) return createMockFn();
1689
2074
  // A member's DMs go through its user, and a user's through its one DM channel, as discord.js sends them
1690
2075
  if (target instanceof discord_js.GuildMember && (key === 'createDM' || key === 'send')) {
1691
2076
  return createMockFn(async (...args)=>receiver().user[key](...args));
1692
2077
  }
2078
+ // Under strict mocks a user manager's DMs go to the user named, its cached one or one made and cached, as discord.js
2079
+ // resolves it; default mode keeps one DM channel for the manager and a send that resolves to an empty message
2080
+ if (target instanceof discord_js.UserManager && (key === 'createDM' || key === 'send')) {
2081
+ return createMockFn(async (user, ...args)=>{
2082
+ if (!strictMocks()) {
2083
+ controller_decorator.warnOnce(mockLogger, USER_MANAGER_DM_WARNINGS[key]);
2084
+ return key === 'createDM' ? dmChannelOf(receiver()) : createMockMessage();
2085
+ }
2086
+ const named = user instanceof discord_js.GuildMember ? user.user : user;
2087
+ const id = typeof named === 'string' ? named : named?.id;
2088
+ const resolved = named instanceof discord_js.User ? named : cached(cacheOf(receiver()), id ?? nextSnowflake(), ()=>userOf(receiver(), id));
2089
+ return key === 'createDM' ? resolved.createDM() : resolved.send(...args);
2090
+ });
2091
+ }
1693
2092
  if (key === 'createDM') return createMockFn(async ()=>dmChannelOf(receiver()));
1694
2093
  if (key === 'send' && target instanceof discord_js.User) {
1695
2094
  return createMockFn(async (...args)=>(await receiver().createDM()).send(...args));
@@ -1699,6 +2098,7 @@ function fetchedIds(args) {
1699
2098
  return createMockFn(async (options)=>sentMessage(sent(options, false), receiver()));
1700
2099
  }
1701
2100
  if (MESSAGE_METHODS.has(key)) return createMockFn(async ()=>createMockMessage());
2101
+ if (COLLECTING_METHODS.has(key)) return createMockFn(async ()=>new discord_js.Collection());
1702
2102
  if (target instanceof discord_js.BaseManager) {
1703
2103
  const item = MANAGER_ITEMS.find(([Manager])=>Manager.prototype.isPrototypeOf(target))?.[1];
1704
2104
  // A fetch of one item finds it in the cache first, as discord.js does, and caches one it makes; a fetch of
@@ -1711,10 +2111,27 @@ function fetchedIds(args) {
1711
2111
  id,
1712
2112
  one(id)
1713
2113
  ]));
1714
- return ids === undefined ? new discord_js.Collection() : one(ids);
2114
+ if (ids !== undefined) return one(ids);
2115
+ // Under strict mocks a channel's fetch() lists the messages it holds, as discord.js lists its recent ones;
2116
+ // default mode resolves to none, and warns once for a channel that strict mocks would list messages of
2117
+ const messages = target instanceof discord_js.GuildMessageManager || target instanceof discord_js.DMMessageManager;
2118
+ if (messages && strictMocks()) return recentMessages(receiver(), args[0]);
2119
+ if (messages) warnUnlisted(receiver());
2120
+ return new discord_js.Collection();
2121
+ });
2122
+ }
2123
+ // Under strict mocks an edit reaches the item named, the cached one or one made and cached under its id, as
2124
+ // discord.js patches it; default mode resolves to a new item and warns once
2125
+ if (item && key === 'edit') {
2126
+ return createMockFn(async (named, options)=>{
2127
+ const id = typeof named === 'string' ? named : named?.id;
2128
+ if (id !== undefined && !strictMocks()) {
2129
+ controller_decorator.warnOnce(mockLogger, `${Object.getPrototypeOf(receiver()).constructor.name}.edit() here ` + 'resolves to a new item with an id of its own, not the item named; under useStrictMocks() it resolves to ' + 'the item with that id, the cached one or one made under it.');
2130
+ }
2131
+ const edited = id === undefined || !strictMocks() ? item(undefined, receiver()) : cached(cacheOf(receiver()), id, ()=>item(id, receiver()));
2132
+ return withName(receiver(), 'edit', edited, options);
1715
2133
  });
1716
2134
  }
1717
- if (item && key === 'edit') return createMockFn(async ()=>item(undefined, receiver()));
1718
2135
  // A ban resolves to the member, user or id it was given, as discord.js's does, and caches nothing
1719
2136
  if (key === 'create' && target instanceof discord_js.GuildBanManager) {
1720
2137
  return createMockFn(async (user)=>{
@@ -1738,8 +2155,8 @@ function fetchedIds(args) {
1738
2155
  // Every other manager here caches what its create() makes, as discord.js's does; check a manager's create when
1739
2156
  // adding it to MANAGER_ITEMS
1740
2157
  if (item && key === 'create') {
1741
- return createMockFn(async ()=>{
1742
- const made = item(undefined, receiver());
2158
+ return createMockFn(async (options)=>{
2159
+ const made = withName(receiver(), 'create', item(undefined, receiver(), options), options);
1743
2160
  if (typeof made.id === 'string') cacheOf(receiver())?.set(made.id, made);
1744
2161
  return made;
1745
2162
  });
@@ -1931,10 +2348,6 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1931
2348
  });
1932
2349
  }
1933
2350
  }
1934
- /**
1935
- * Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order, under a key
1936
- * both builds of this meocord version read.
1937
- */ const RESPONSE_LOG = Symbol.for(controller_decorator.sharedName('responseLog'));
1938
2351
  /**
1939
2352
  * A mock of an answer method, `impl` its behaviour, that records each call in `log` as it is made, with what it sent
1940
2353
  * and, once it settles, what it rejected with. The record wraps every behaviour the mock is given, its own and any a
@@ -2023,11 +2436,33 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
2023
2436
  if (message !== undefined) error.message = message;
2024
2437
  return error;
2025
2438
  }
2439
+ // Why default mode runs an answer discord.js refuses, as the warning words it
2440
+ const SET_BY_TEST = 'a test set this answer or the one before it';
2441
+ /**
2442
+ * Refuses an answer as discord.js does. Where `lenient` says why the mock ran it so far, such as a behaviour a test
2443
+ * set, default mode warns and lets it run; strict mocks refuse it.
2444
+ */ function refuseAnswer(method, error, lenient) {
2445
+ if (lenient === undefined || strictMocks()) throw error;
2446
+ controller_decorator.warnOnce(mockLogger, `${method}() here is refused by discord.js with "${error.message}", but the mock runs it, as ${lenient}. In the ` + 'next major version (5.0) the mock refuses it; call useStrictMocks() to have it refused now.');
2447
+ }
2026
2448
  /** 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);
2027
2449
  // In discord.js's error codes, though missing from its typings
2028
2450
  const INVALID_CHANNEL_TYPE = 'CommandInteractionOptionInvalidChannelType';
2029
- /** Records the interaction a mock options resolver belongs to. */ const ownOptions = (interaction, options)=>{
2030
- if (typeof options === 'object' && options !== null) optionOwners.set(options, interaction);
2451
+ /** The names of a mock options resolver's entity options, for one given to an autocomplete. */ const optionEntities = new WeakMap();
2452
+ /** The resolvers whose entity options a warning has named. */ const warnedEntities = new WeakSet();
2453
+ /** Whether a mock options resolver belongs to an autocomplete, whose options Discord sends unresolved. */ const ownedByAutocomplete = (options)=>optionOwners.get(options)?.type === discord_js.InteractionType.ApplicationCommandAutocomplete;
2454
+ /**
2455
+ * Records the interaction a mock options resolver belongs to. Given to an autocomplete, an entity option reads as its
2456
+ * id under strict mocks, as Discord sends it; otherwise it keeps the object given, with a warning once per resolver.
2457
+ */ const ownOptions = (interaction, options)=>{
2458
+ if (typeof options !== 'object' || options === null) return;
2459
+ optionOwners.set(options, interaction);
2460
+ const entities = optionEntities.get(options);
2461
+ if (!entities?.length || strictMocks() || warnedEntities.has(options) || !ownedByAutocomplete(options)) return;
2462
+ warnedEntities.add(options);
2463
+ const names = entities.map((name)=>`"${name}"`).join(', ');
2464
+ const many = entities.length > 1;
2465
+ mockLogger.warn(`The autocomplete option${many ? 's' : ''} ${names} ${many ? 'hold resolved objects' : 'holds a resolved object'} here, where Discord ` + `sends an autocomplete only ${many ? 'their ids' : 'its id'}, so a bot's handler gets the id and getUser() and the like return null. ` + `Call useStrictMocks(), which reads ${many ? 'them as their ids' : 'it as its id'}, as Discord sends ${many ? 'them' : 'it'}. A bare id string reads as ` + 'a string option.');
2031
2466
  };
2032
2467
  /** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id)=>{
2033
2468
  const generatedId = id === undefined ? nextSnowflake() : undefined;
@@ -2135,11 +2570,15 @@ function createMockInteraction(Class, props) {
2135
2570
  const followUps = new Map();
2136
2571
  // Whether the last answer ran a behaviour a test set, after which a second answer runs with a warning by default
2137
2572
  let answeredByTest = false;
2138
- // A command answered with a modal has no original response, and Discord refuses one asked for
2139
- let modalAnswered = false;
2573
+ // Whether default mode ran a launchActivity() or sendPremiumRequired() before any answer, which it doesn't count as one
2574
+ let uncounted = false;
2575
+ // A command answered without a message, as with a modal, has no original response, and Discord refuses one asked for
2576
+ let noOriginal = false;
2140
2577
  const isComponent = instance.type === discord_js.InteractionType.MessageComponent;
2141
2578
  // The original response of a component, or of a modal submitted from one, is the message it's on
2142
2579
  const componentMessage = ()=>heldFrom(instance.message);
2580
+ // Read when answered, after a test's props are applied: a modal submitted from a command has no message
2581
+ const onMessage = ()=>isComponent || instance.type === discord_js.InteractionType.ModalSubmit && Boolean(instance.message);
2143
2582
  const snapshot = ()=>{
2144
2583
  const saved = {
2145
2584
  replied: instance.replied,
@@ -2151,7 +2590,7 @@ function createMockInteraction(Class, props) {
2151
2590
  originalGone,
2152
2591
  followUps: new Map(followUps),
2153
2592
  answeredByTest,
2154
- modalAnswered
2593
+ noOriginal
2155
2594
  };
2156
2595
  return ()=>{
2157
2596
  Object.assign(instance, {
@@ -2159,19 +2598,29 @@ function createMockInteraction(Class, props) {
2159
2598
  deferred: saved.deferred
2160
2599
  });
2161
2600
  decideEphemeral(saved.ephemeral, saved.ephemeralDecided);
2162
- ({ original, originalGone, answeredByTest, answeredByMock, modalAnswered } = saved);
2601
+ ({ original, originalGone, answeredByTest, answeredByMock, noOriginal } = saved);
2163
2602
  followUps.clear();
2164
2603
  for (const [id, held] of saved.followUps)followUps.set(id, held);
2165
2604
  };
2166
2605
  };
2167
- /**
2168
- * Refuses an answer as discord.js does. Where the refusal turns on a behaviour a test set, this answer's or the
2169
- * one before it, which ran without one, default mode warns and lets it run; strict mocks refuse it.
2170
- */ const refuse = (method, error, byTest)=>{
2171
- if (!(byTest || answeredByTest) || strictMocks()) throw error;
2172
- 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.');
2173
- };
2606
+ // Refused as discord.js does, leniently in default mode where this answer or the one before it ran a test's behaviour
2607
+ const refuse = (method, error, byTest)=>refuseAnswer(method, error, byTest || answeredByTest ? SET_BY_TEST : undefined);
2174
2608
  const answered = ()=>instance.deferred || instance.replied;
2609
+ // Default mode's launchActivity() and sendPremiumRequired(), which leave the interaction as it was, as before
2610
+ const countedOnlyWhenStrict = (method)=>{
2611
+ if (answered()) {
2612
+ controller_decorator.warnOnce(mockLogger, `${method}() here runs though the interaction was answered; under useStrictMocks() this is refused with ` + 'InteractionAlreadyReplied, as discord.js refuses it.');
2613
+ return;
2614
+ }
2615
+ uncounted = true;
2616
+ controller_decorator.warnOnce(mockLogger, `${method}() here leaves the interaction unanswered, and getResponse() doesn't report it; under useStrictMocks() this ` + "is the interaction's first answer, as in discord.js: it marks the interaction replied, and an answer before or " + 'after it is refused.');
2617
+ };
2618
+ // What Discord answers here isn't known, so the refusal names the cause rather than a code
2619
+ const refuseModalUpdate = ()=>{
2620
+ const reason = 'update() needs the message a component opened this modal from, and this modal came from a command. Use ' + 'reply() or deferReply() instead.';
2621
+ if (strictMocks()) throw new Error(reason);
2622
+ controller_decorator.warnOnce(mockLogger, `${reason} The mock runs it; in the next major version (5.0) it refuses it. Call useStrictMocks() to have it refused now.`);
2623
+ };
2175
2624
  /**
2176
2625
  * What an answer sends, built now, as discord.js builds it when sending. One it refuses to build is refused under
2177
2626
  * strict mocks; default mode warns and holds it to build when first read.
@@ -2249,20 +2698,26 @@ function createMockInteraction(Class, props) {
2249
2698
  settle: ()=>void unsettled.splice(unsettled.indexOf(method), 1)
2250
2699
  };
2251
2700
  };
2252
- /** A later answer: refused before the interaction has one. */ const later = (method, then)=>(args, byTest)=>{
2253
- if (!answered()) refuse(method, notYetReplied(method), byTest);
2701
+ /**
2702
+ * A later answer: refused before the interaction has one. One that `replies` marks the interaction replied, under
2703
+ * strict mocks once it lands, as discord.js does once the request resolves; default mode marks it at once.
2704
+ */ const later = (method, then, replies = false)=>(args, byTest)=>{
2705
+ // A follow-up after an answer default mode doesn't count is sent, as it is once strict mocks count that answer
2706
+ if (!answered() && !(method === 'followUp' && uncounted)) refuse(method, notYetReplied(method), byTest);
2254
2707
  if (unsettled.length > 0) {
2255
2708
  warnInFlight(`${unsettled[0]}() had not resolved when ${method}() was called; on a bot this throws InteractionNotReplied. ` + `Await ${unsettled[0]}() first, or call useStrictMocks() to have the mock refuse it now.`);
2256
2709
  }
2257
2710
  const undo = snapshot();
2258
2711
  try {
2259
2712
  then(args);
2713
+ if (replies && !strict) instance.replied = true;
2260
2714
  } catch (error) {
2261
2715
  undo();
2262
2716
  throw error;
2263
2717
  }
2264
2718
  return {
2265
- undo: undoneInTurn(undo)
2719
+ undo: undoneInTurn(undo),
2720
+ land: replies && strict ? ()=>void (instance.replied = true) : undefined
2266
2721
  };
2267
2722
  };
2268
2723
  /** The id an edit, fetch or delete names, `undefined` for the original response. */ const targetId = (message)=>{
@@ -2275,7 +2730,7 @@ function createMockInteraction(Class, props) {
2275
2730
  if (held) followUps.set(id, built(held));
2276
2731
  return held && built(held);
2277
2732
  }
2278
- if (originalGone || modalAnswered) return undefined;
2733
+ if (originalGone || noOriginal) return undefined;
2279
2734
  // Answered without one of the mock's calls, as after a 40060 or a test setting `replied`: the original is there
2280
2735
  if (!original && answered()) original = componentMessage();
2281
2736
  if (original) original = built(original);
@@ -2314,7 +2769,6 @@ function createMockInteraction(Class, props) {
2314
2769
  id: followUpMade
2315
2770
  }) : messageFrom(held, madeFor(held.id), ...answeredIn());
2316
2771
  }, later('followUp', ([options])=>{
2317
- instance.replied = true;
2318
2772
  // Held as it is when sent, as Discord stores it, so a later change to what was passed isn't seen; its id taken
2319
2773
  // once built, so one that fails to build under strict mocks takes none
2320
2774
  const held = hold('followUp', ()=>sent(options, false));
@@ -2328,7 +2782,7 @@ function createMockInteraction(Class, props) {
2328
2782
  id
2329
2783
  });
2330
2784
  followUpMade = id;
2331
- }));
2785
+ }, true));
2332
2786
  // An edit replaces the message in the shape Discord stores, with ids and resolved media, stamped with when it was
2333
2787
  // edited, and fetchReply() reads that back, so code comparing the two sees what it would against Discord
2334
2788
  answer('editReply', async ()=>{
@@ -2343,11 +2797,9 @@ function createMockInteraction(Class, props) {
2343
2797
  const held = target(id);
2344
2798
  made = held && edited(held, options);
2345
2799
  if (!made) return;
2346
- if (id === undefined) {
2347
- original = made;
2348
- instance.replied = true;
2349
- } else followUps.set(id, made);
2350
- }));
2800
+ if (id === undefined) original = made;
2801
+ else followUps.set(id, made);
2802
+ }, true));
2351
2803
  stubs.set('fetchReply', createMockFn(async (message)=>{
2352
2804
  const held = target(targetId(message));
2353
2805
  if (!held) throw unknownMessage();
@@ -2362,16 +2814,31 @@ function createMockInteraction(Class, props) {
2362
2814
  if (id === undefined) originalGone = true;
2363
2815
  else followUps.delete(id);
2364
2816
  }));
2817
+ // A first answer that sends no message: the original response stays the message the interaction is on, if any
2818
+ const sendsNoMessage = ()=>{
2819
+ if (onMessage()) original = componentMessage();
2820
+ else noOriginal = true;
2821
+ };
2365
2822
  // showModal — the first response of a command or a component, like reply()
2366
2823
  if (instance.type === discord_js.InteractionType.ApplicationCommand || isComponent) {
2367
- answer('showModal', async ()=>undefined, first('showModal', 'replied', ()=>{
2368
- modalAnswered = instance.type === discord_js.InteractionType.ApplicationCommand;
2369
- if (isComponent) original = componentMessage();
2370
- }));
2824
+ answer('showModal', async ()=>undefined, first('showModal', 'replied', sendsNoMessage));
2825
+ }
2826
+ // An activity launched, or the upgrade button sent, is a first answer under strict mocks, as reply() is, on every
2827
+ // repliable interaction. Default mode leaves the interaction unanswered and unreported, and warns once where they differ
2828
+ for (const method of [
2829
+ 'launchActivity',
2830
+ 'sendPremiumRequired'
2831
+ ]){
2832
+ if (strict) answer(method, async ()=>undefined, first(method, 'replied', sendsNoMessage));
2833
+ else stubs.set(method, recordedMock(method, async ()=>undefined, [], ()=>(countedOnlyWhenStrict(method), {})));
2371
2834
  }
2372
2835
  // deferUpdate / update — components, and modals submitted from a message's component
2373
2836
  if (isComponent || instance.type === discord_js.InteractionType.ModalSubmit) {
2374
- answer('update', async ()=>undefined, first('update', 'replied', ([options])=>original = hold('update', ()=>edited(heldFrom(instance.message, false), options))));
2837
+ answer('update', async ()=>undefined, first('update', 'replied', ([options])=>{
2838
+ // Discord updates only a message a component is on; a modal from a command has none
2839
+ if (!onMessage()) refuseModalUpdate();
2840
+ original = hold('update', ()=>edited(heldFrom(instance.message, false), options));
2841
+ }));
2375
2842
  answer('deferUpdate', async ()=>undefined, first('deferUpdate', 'deferred', ()=>original = componentMessage()));
2376
2843
  }
2377
2844
  }
@@ -2381,11 +2848,25 @@ function createMockInteraction(Class, props) {
2381
2848
  // it before answering would decide the window was already closed.
2382
2849
  if (instance.type === discord_js.InteractionType.ApplicationCommandAutocomplete) {
2383
2850
  instance.responded = false;
2384
- // Under strict mocks responded is set once respond() lands, as discord.js sets it once the request resolves, and a
2385
- // second one sent while the first is in flight is refused by Discord with 40060; default mode sets it at once
2851
+ const strict = strictMocks();
2852
+ const log = [];
2853
+ // getResponse reports each respond() under strict mocks; default mode reports none, as before
2854
+ if (strict) Object.defineProperty(instance, RESPONSE_LOG, {
2855
+ value: log
2856
+ });
2857
+ // Responds whose change stands, and what `responded` read before the first of them, for a failed one to undo
2858
+ let standing = 0;
2859
+ let before = false;
2386
2860
  let respondsInFlight = 0;
2387
2861
  let respondWarned = false;
2388
- stubs.set('respond', createMockFn(async (choices)=>{
2862
+ // Under strict mocks responded is set once respond() lands, as discord.js sets it once the request resolves, and a
2863
+ // second one sent while the first is in flight is refused by Discord with 40060; default mode sets it at once
2864
+ const gate = (_args, byTest)=>{
2865
+ // Default mode leaves a behaviour a test set to itself, as before: responded stays as it was
2866
+ if (!strict && byTest) {
2867
+ controller_decorator.warnOnce(mockLogger, 'respond() here runs a behaviour a test set and leaves responded as it was; under useStrictMocks() this marks the ' + 'autocomplete responded once it resolves, as discord.js does, and a second respond() is refused.');
2868
+ return {};
2869
+ }
2389
2870
  if (instance.responded) {
2390
2871
  if (respondsInFlight > 0 && !respondWarned) {
2391
2872
  respondWarned = true;
@@ -2393,21 +2874,32 @@ function createMockInteraction(Class, props) {
2393
2874
  }
2394
2875
  throw discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
2395
2876
  }
2877
+ respondsInFlight++;
2878
+ const settle = ()=>void respondsInFlight--;
2879
+ if (strict) {
2880
+ return {
2881
+ settle,
2882
+ land: ()=>{
2883
+ if (instance.responded) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
2884
+ instance.responded = true;
2885
+ }
2886
+ };
2887
+ }
2888
+ if (standing++ === 0) before = instance.responded;
2889
+ instance.responded = true;
2890
+ return {
2891
+ settle,
2892
+ undo: ()=>{
2893
+ if (--standing === 0) instance.responded = before;
2894
+ }
2895
+ };
2896
+ };
2897
+ stubs.set('respond', recordedMock('respond', async (choices)=>{
2396
2898
  // Discord refuses a list longer than its limit, and the menu shows nothing
2397
2899
  if (Array.isArray(choices) && choices.length > MAX_AUTOCOMPLETE_CHOICES) {
2398
2900
  throw createDiscordError(50035, `Invalid Form Body\ndata.choices[BASE_TYPE_MAX_LENGTH]: Must be ${MAX_AUTOCOMPLETE_CHOICES} or fewer in length.`);
2399
2901
  }
2400
- respondsInFlight++;
2401
- if (!strictMocks()) instance.responded = true;
2402
- try {
2403
- await undefined;
2404
- } finally{
2405
- respondsInFlight--;
2406
- }
2407
- if (!strictMocks()) return;
2408
- if (instance.responded) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
2409
- instance.responded = true;
2410
- }));
2902
+ }, log, gate));
2411
2903
  }
2412
2904
  // Applied last so an explicit prop wins over the type fields and the reply
2413
2905
  // state machine. defineProperty rather than assignment for the same reason the
@@ -2540,6 +3032,9 @@ function createMockInteraction(Class, props) {
2540
3032
  configurable: true
2541
3033
  });
2542
3034
  }
3035
+ // Default mode reads a guildId alone as a placeholder server with that id, as its warning says, answered in place of
3036
+ // the mock's own stub, so where the interaction is still reads as given. Strict mocks read null, as discord.js does
3037
+ if (strictPlace?.given === 'guildId' && !strictMocks() && unset('guild')) stubs.set('guild', guildPlaceholder(own('guildId')));
2543
3038
  // The channel it came from, as the gateway caches it for an interaction: its server's cached one, looked up on each
2544
3039
  // read so a channel the test caches later is the one read, or the user's DM
2545
3040
  let channel;
@@ -2581,6 +3076,11 @@ function createMockInteraction(Class, props) {
2581
3076
  // and in a server that user's member; each read live, so a targetId set later picks its user, and assignable
2582
3077
  if (discord_js.UserContextMenuCommandInteraction.prototype.isPrototypeOf(instance)) {
2583
3078
  if (unset('targetId')) instance.targetId = own('targetUser')?.id ?? nextSnowflake();
3079
+ // Under strict mocks a target user given is the client's cached one, as discord.js resolves it, so its member is
3080
+ // that user's; default mode keeps the member's own user, and a read of targetMember warns once
3081
+ const givenTarget = own('targetUser');
3082
+ const users = cacheOf(instance.client.users);
3083
+ if (strictMocks() && typeof givenTarget === 'object' && givenTarget !== null) users?.set(givenTarget.id, givenTarget);
2584
3084
  const targetUser = ()=>{
2585
3085
  const id = own('targetId');
2586
3086
  return cached(cacheOf(instance.client.users), id, ()=>mockUser(id));
@@ -2598,7 +3098,15 @@ function createMockInteraction(Class, props) {
2598
3098
  });
2599
3099
  if (unset('targetUser')) live('targetUser', targetUser);
2600
3100
  if (unset('targetMember')) {
2601
- live('targetMember', ()=>own('guildId') ? memberFor(own('guild'), targetUser(), true, own('guildId')) : null);
3101
+ live('targetMember', ()=>{
3102
+ if (!own('guildId')) return null;
3103
+ const member = memberFor(own('guild'), targetUser(), true, own('guildId'));
3104
+ // Only the mock reads this for the test, so the warning comes at the test's read
3105
+ if (givenTarget && member.user !== givenTarget) {
3106
+ controller_decorator.warnOnce(mockLogger, "UserContextMenuCommandInteraction.targetMember here is the member of another user with the targetUser's " + "id; under useStrictMocks() the targetUser given is cached on the client, so targetMember is that user's member.");
3107
+ }
3108
+ return member;
3109
+ });
2602
3110
  }
2603
3111
  }
2604
3112
  // A message context menu's target: the message given, else one made for its targetId. The message is the one
@@ -2929,8 +3437,12 @@ const HOLDS = [
2929
3437
  discord_js.Role
2930
3438
  ],
2931
3439
  [
2932
- discord_js.GuildChannelManager.prototype,
2933
- discord_js.GuildChannel
3440
+ discord_js.GuildMemberRoleManager.prototype,
3441
+ discord_js.Role
3442
+ ],
3443
+ [
3444
+ discord_js.GuildChannelManager.prototype,
3445
+ discord_js.GuildChannel
2934
3446
  ],
2935
3447
  [
2936
3448
  discord_js.UserManager.prototype,
@@ -3005,16 +3517,24 @@ const HOLDS = [
3005
3517
  discord_js.Invite
3006
3518
  ]
3007
3519
  ];
3520
+ /** What a manager of `prototype` holds, as discord.js's resolve() checks an item against. */ const holdsOf = (prototype)=>HOLDS.find(([Manager])=>Manager === prototype || Object.prototype.isPrototypeOf.call(Manager, prototype))?.[1];
3008
3521
  /**
3009
3522
  * A manager whose `cache` is a real collection of `items`, by id, empty without them, and whose methods are stubs but
3010
- * for `resolve` and `resolveId`, which read the cache. Its `client` is the one its guild or channel has.
3011
- */ function managerWith(prototype, items) {
3523
+ * for `resolve` and `resolveId`, which read the cache. Its `client` is the one its guild or channel has. `onCacheRead`,
3524
+ * when given, runs on each read of `cache`, before it is returned.
3525
+ */ function managerWith(prototype, items, onCacheRead) {
3012
3526
  const manager = Object.create(prototype);
3013
3527
  const cache = new discord_js.Collection((items ?? []).map((item)=>[
3014
3528
  String(item.id ?? item.user?.id),
3015
3529
  item
3016
3530
  ]));
3017
- Object.defineProperty(manager, 'cache', {
3531
+ if (onCacheRead) {
3532
+ let current = cache;
3533
+ Object.defineProperty(manager, 'cache', {
3534
+ get: ()=>(onCacheRead(), current),
3535
+ set: (value)=>void (current = value)
3536
+ });
3537
+ } else Object.defineProperty(manager, 'cache', {
3018
3538
  value: cache,
3019
3539
  writable: true
3020
3540
  });
@@ -3023,7 +3543,7 @@ const HOLDS = [
3023
3543
  value: cache,
3024
3544
  writable: true
3025
3545
  });
3026
- const holds = HOLDS.find(([Manager])=>Manager === prototype || Object.prototype.isPrototypeOf.call(Manager, prototype))?.[1];
3546
+ const holds = holdsOf(prototype);
3027
3547
  if (holds) Object.defineProperty(manager, 'holds', {
3028
3548
  value: holds
3029
3549
  });
@@ -3044,6 +3564,51 @@ const HOLDS = [
3044
3564
  });
3045
3565
  return stubDeep(manager);
3046
3566
  }
3567
+ // Set while the mock reads a manager's cache itself
3568
+ let readingOwnCache = 0;
3569
+ // The messages made in each channel, by its messages manager, for a fetch to find and strict mocks to cache
3570
+ const messagesMade = new WeakMap();
3571
+ const madeIn = (manager, id)=>id === undefined ? undefined : messagesMade.get(manager)?.get(id);
3572
+ // The message managers whose cache read has warned
3573
+ const uncachedWarned = new WeakSet();
3574
+ /**
3575
+ * A channel's empty messages manager. Default mode caches none of the messages made in the channel, where strict mocks
3576
+ * and discord.js cache each, so a test's first read of the cache warns once while one of them is missing from it.
3577
+ */ function messageManager(prototype) {
3578
+ const manager = managerWith(prototype, undefined, ()=>{
3579
+ const made = messagesMade.get(manager);
3580
+ if (readingOwnCache > 0 || !made || strictMocks() || uncachedWarned.has(manager)) return;
3581
+ // Read as the mock's own, so this check doesn't call itself
3582
+ const cache = cacheOf(manager);
3583
+ if (![
3584
+ ...made.values()
3585
+ ].some((each)=>!cache?.has(each.id))) return;
3586
+ uncachedWarned.add(manager);
3587
+ mockLogger.warn(`${prototype.constructor.name}.cache here lacks messages made in its channel, where ` + 'discord.js caches each message as it arrives and each one the bot sends; in the next major version (5.0) the mock caches ' + 'them. Fetch the message, or call useStrictMocks() to have them cached now.');
3588
+ });
3589
+ return manager;
3590
+ }
3591
+ // The message managers whose fetch() with no id has warned
3592
+ const unlistedWarned = new WeakSet();
3593
+ /**
3594
+ * Warns once for a channel whose messages.fetch() with no id resolves to none in default mode, though strict mocks
3595
+ * would list messages there: those its cache holds, read as the mock's own, and those made or sent in it, which they
3596
+ * cache.
3597
+ */ function warnUnlisted(manager) {
3598
+ if (unlistedWarned.has(manager)) return;
3599
+ if (!cacheOf(manager)?.size && !messagesMade.get(manager)?.size) return;
3600
+ unlistedWarned.add(manager);
3601
+ mockLogger.warn(`${Object.getPrototypeOf(manager).constructor.name}.fetch() here resolves to no ` + "messages, though messages were made, sent or cached in its channel, where discord.js lists a channel's recent " + 'messages; under useStrictMocks() fetch() lists them.');
3602
+ }
3603
+ /** Notes a message made in a channel, and caches it there under strict mocks, as discord.js caches what arrives. */ function noteMadeIn(channel, message) {
3604
+ if (!(channel instanceof discord_js.BaseChannel)) return;
3605
+ const manager = Reflect.getOwnPropertyDescriptor(channel, 'messages')?.value;
3606
+ const cache = manager && cacheOf(manager);
3607
+ if (!manager || !cache) return;
3608
+ if (!messagesMade.has(manager)) messagesMade.set(manager, new Map());
3609
+ messagesMade.get(manager).set(message.id, message);
3610
+ if (strictMocks()) cache.set(message.id, message);
3611
+ }
3047
3612
  /** An empty manager of `prototype` that `owner` names its own, such as `{ message }` for a message's reactions. */ function ownedManager(prototype, owner) {
3048
3613
  const manager = managerWith(prototype, undefined);
3049
3614
  Object.assign(manager, owner);
@@ -3063,14 +3628,16 @@ const HOLDS = [
3063
3628
  * A manager's `fetch(id)` resolves to its cached item with that id, as discord.js looks there first, or to a new one it
3064
3629
  * caches under that id: a member, channel, emoji, sticker, scheduled event or AutoMod rule in this guild, a role with
3065
3630
  * that id, a ban, or an invite with that code. `members.fetch({ user: ids })` resolves to a collection of each of those
3066
- * members, found or made the same way; `create()` and `edit()` resolve to a mock of its item, which `create()` caches
3067
- * as discord.js does, but for an invite, made for the channel given and cached nowhere; a list fetch resolves to an
3068
- * empty collection. Members, roles and channels given are put in
3069
- * their managers' caches, where dispatch looks first when it resolves a message's typed params; a member
3070
- * {@link createMockMember} made without a server is in this one. Its `roles.everyone` is the role given with the
3071
- * guild's id, or else an @everyone role of its own at position 0 with no permissions, in `roles.cache` as Discord has
3072
- * it. The guild is named `'Guild'` and its `preferredLocale` is `'en-US'` unless given, so `t.forGuild(guild)`
3073
- * translates as for a new English server.
3631
+ * members, found or made the same way; `create()` resolves to a mock of its item, a channel of the type its options
3632
+ * name under `useStrictMocks()`, which it caches as discord.js does, but for an invite, made for the channel given and
3633
+ * cached nowhere; `edit()` resolves under `useStrictMocks()` to the item with the id given, found or made the same way,
3634
+ * and under `useStrictMocks()` either takes the `name` its options give. A list fetch resolves to an empty collection.
3635
+ * Members, roles and channels given are put in their managers' caches, where dispatch looks first when it resolves a
3636
+ * message's typed params; a member {@link createMockMember} made without a server is in this one. Its `roles.everyone`
3637
+ * is the role given with the guild's id, or else an @everyone role of its own at position 0, in `roles.cache` as
3638
+ * Discord has it. That role has no permissions in default mode; under `useStrictMocks()` it has the ones Discord
3639
+ * gives @everyone in a new server. The guild is named `'Guild'` and its `preferredLocale` is `'en-US'` unless given,
3640
+ * so `t.forGuild(guild)` translates as for a new English server.
3074
3641
  *
3075
3642
  * The mock makes a member for any id it's asked for, so a test of an id that isn't a member rejects the fetch, as
3076
3643
  * `guild.members.fetch.mockRejectedValue(createDiscordError(10007))`, or resolves a collection without that member.
@@ -3140,7 +3707,7 @@ const HOLDS = [
3140
3707
  const is = (Base)=>Base.prototype.isPrototypeOf(Class.prototype) || Class === Base;
3141
3708
  // Text and announcement channels: messages, and threads made in the channel
3142
3709
  if (is(discord_js.TextChannel) || is(discord_js.NewsChannel)) {
3143
- instance.messages = managerWith(discord_js.GuildMessageManager.prototype, undefined);
3710
+ instance.messages = messageManager(discord_js.GuildMessageManager.prototype);
3144
3711
  instance.threads = managerWith(discord_js.GuildTextThreadManager.prototype, undefined);
3145
3712
  }
3146
3713
  // Forum and media channels hold posts, each a thread started with its first message
@@ -3150,15 +3717,15 @@ const HOLDS = [
3150
3717
  }
3151
3718
  // Voice and stage channels carry a text chat of their own, and a bitrate, which discord.js tells them apart by
3152
3719
  if (is(discord_js.BaseGuildVoiceChannel)) {
3153
- instance.messages = managerWith(discord_js.GuildMessageManager.prototype, undefined);
3720
+ instance.messages = messageManager(discord_js.GuildMessageManager.prototype);
3154
3721
  instance.bitrate = 64000;
3155
3722
  instance.userLimit = 0;
3156
3723
  }
3157
3724
  if (is(discord_js.DMChannel)) {
3158
- instance.messages = managerWith(discord_js.DMMessageManager.prototype, undefined);
3725
+ instance.messages = messageManager(discord_js.DMMessageManager.prototype);
3159
3726
  }
3160
3727
  if (is(discord_js.ThreadChannel)) {
3161
- instance.messages = managerWith(discord_js.GuildMessageManager.prototype, undefined);
3728
+ instance.messages = messageManager(discord_js.GuildMessageManager.prototype);
3162
3729
  instance.members = managerWith(discord_js.ThreadMemberManager.prototype, undefined);
3163
3730
  }
3164
3731
  // A server's channel has its overwrites, none until a test puts some in the cache, which its permissions read
@@ -3263,6 +3830,7 @@ const CHANNEL_TYPE_GUARDS = [
3263
3830
  id: entry
3264
3831
  }) : entry);
3265
3832
  const manager = Object.create(discord_js.GuildMemberRoleManager.prototype);
3833
+ // Its member's server and client, as discord.js's constructor sets them, and what it holds, so resolve() reads it
3266
3834
  Object.defineProperties(manager, {
3267
3835
  cache: {
3268
3836
  get: cache
@@ -3270,6 +3838,15 @@ const CHANNEL_TYPE_GUARDS = [
3270
3838
  member: {
3271
3839
  value: member
3272
3840
  },
3841
+ guild: {
3842
+ get: ()=>member.guild
3843
+ },
3844
+ client: {
3845
+ get: ()=>member.client
3846
+ },
3847
+ holds: {
3848
+ value: holdsOf(discord_js.GuildMemberRoleManager.prototype)
3849
+ },
3273
3850
  highest: {
3274
3851
  get: highest
3275
3852
  }
@@ -3594,7 +4171,7 @@ let placeReadsSeen;
3594
4171
  "Give the member's guild too, or call useStrictMocks() to place it there now."
3595
4172
  ],
3596
4173
  guildId: [
3597
- 'a server the bot is in, though the mock was given only a guildId',
4174
+ 'a placeholder server with that id, though the mock was given only a guildId',
3598
4175
  "a guildId alone is a server the bot isn't in: guild and channel are null, and the member is a raw one",
3599
4176
  'Give a guild too, or call useStrictMocks() to read it so now.'
3600
4177
  ]
@@ -3823,9 +4400,38 @@ const GUILD_MANAGERS = [
3823
4400
  };
3824
4401
  }
3825
4402
  /** A cache's collection, or undefined for a stub a test put in place of the manager. */ const cacheOf = (manager)=>{
3826
- const cache = manager?.cache;
3827
- return cache instanceof discord_js.Collection ? cache : undefined;
4403
+ // The mock's own read, which a message cache's warning leaves to the test's reads
4404
+ readingOwnCache++;
4405
+ try {
4406
+ const cache = manager?.cache;
4407
+ return cache instanceof discord_js.Collection ? cache : undefined;
4408
+ } finally{
4409
+ readingOwnCache--;
4410
+ }
3828
4411
  };
4412
+ // The managers of a server whose caches a placeholder one holds empty
4413
+ const PLACEHOLDER_GUILD_MANAGERS = [
4414
+ 'members',
4415
+ 'roles',
4416
+ 'channels',
4417
+ 'emojis',
4418
+ 'stickers',
4419
+ 'bans',
4420
+ 'invites',
4421
+ 'scheduledEvents',
4422
+ 'autoModerationRules'
4423
+ ];
4424
+ /** A server default mode reads for a guildId alone: no `Guild`, so it answers no server checks, but with that id and empty caches. */ function guildPlaceholder(id) {
4425
+ return stubDeep({
4426
+ id,
4427
+ ...Object.fromEntries(PLACEHOLDER_GUILD_MANAGERS.map((key)=>[
4428
+ key,
4429
+ stubDeep({
4430
+ cache: new discord_js.Collection()
4431
+ })
4432
+ ]))
4433
+ });
4434
+ }
3829
4435
  /** The cached item under `id`, put there by `make` when the cache lacks it. */ function cached(cache, id, make) {
3830
4436
  const existing = cache?.get(id);
3831
4437
  if (existing !== undefined) return existing;
@@ -3853,19 +4459,22 @@ const GUILD_MANAGERS = [
3853
4459
  id,
3854
4460
  cached(guildCaches.members, id, ()=>memberIn(guild, id, cached(userCache, id, ()=>mockUser(id))))
3855
4461
  ])) : null,
4462
+ // Full mocks, as the gateway delivers them: a role of the server, and a channel with its managers and overwrites
3856
4463
  roles: new discord_js.Collection(guildCaches ? roles.map((id)=>[
3857
4464
  id,
3858
- cached(guildCaches.roles, id, ()=>stubDeep(Object.assign(Object.create(discord_js.Role.prototype), {
4465
+ cached(guildCaches.roles, id, ()=>createMockInteraction(discord_js.Role, {
3859
4466
  id,
3860
4467
  guild
3861
- })))
4468
+ }))
3862
4469
  ]) : []),
3863
4470
  channels: new discord_js.Collection(channels.map((id)=>[
3864
4471
  id,
3865
- cached(guildCaches ? guildCaches.channels : cacheOf(client.channels), id, ()=>stubDeep(Object.assign(Object.create(discord_js.TextChannel.prototype), {
4472
+ cached(guildCaches ? guildCaches.channels : cacheOf(client.channels), id, ()=>createMockChannel(discord_js.TextChannel, guild ? {
3866
4473
  id,
3867
4474
  guild
3868
- })))
4475
+ } : {
4476
+ id
4477
+ }))
3869
4478
  ]))
3870
4479
  };
3871
4480
  // Own values, so the prototype's getters, which read the raw mention data, are not reached
@@ -4148,6 +4757,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4148
4757
  }));
4149
4758
  if (sentBy) warnOnSentReads(instance, stubs, sentBy);
4150
4759
  const message = stubDeep(instance, stubs);
4760
+ if (channel) noteMadeIn(channel, message);
4151
4761
  return message;
4152
4762
  }
4153
4763
  /**
@@ -4196,7 +4806,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4196
4806
  * 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
4197
4807
  * a DM send in a server of its own and keeps another author, with a warning when read.
4198
4808
  */ function sentMessage(held, channel, client, fields = {}, made) {
4199
- const by = client ?? channel?.client;
4809
+ // The mock's own read of where the message was sent
4810
+ const by = client ?? readPlaceQuietly(()=>channel?.client);
4200
4811
  const inChannel = channel instanceof discord_js.BaseChannel;
4201
4812
  const dm = inChannel && channel.isDMBased();
4202
4813
  const strict = strictMocks();
@@ -4237,6 +4848,11 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4237
4848
  if (typeof value === 'object' && value !== null) return discord_js.ApplicationCommandOptionType.Mentionable;
4238
4849
  return discord_js.ApplicationCommandOptionType.String;
4239
4850
  }
4851
+ /** The option types a user types into, which an autocomplete's focused option is one of. */ const TYPED = [
4852
+ discord_js.ApplicationCommandOptionType.String,
4853
+ discord_js.ApplicationCommandOptionType.Integer,
4854
+ discord_js.ApplicationCommandOptionType.Number
4855
+ ];
4240
4856
  /** The discord.js classes an entity option's value is an instance of, each read by its own getters. */ const ENTITY_CLASSES = [
4241
4857
  discord_js.User,
4242
4858
  discord_js.GuildMember,
@@ -4289,7 +4905,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4289
4905
  * An entity option arrives as a snowflake in `value` *and* as the resolved object on
4290
4906
  * its own field, and code that reads only one of the two is exactly what this lets a
4291
4907
  * test catch — so both are set. A user option has its user, and in a server its member too.
4292
- */ function toOptionData(name, value, memberOf) {
4908
+ */ function toOptionData(name, value, memberOf, unresolved) {
4293
4909
  const type = optionTypeOf(value);
4294
4910
  const isEntity = typeof value === 'object' && value !== null;
4295
4911
  const option = {
@@ -4302,7 +4918,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4302
4918
  // Read once the options are given to an interaction, which says whether it was made in a server
4303
4919
  if (memberOf) Object.defineProperty(option, 'member', {
4304
4920
  get: ()=>memberOf(name, value) ?? undefined,
4305
- enumerable: true
4921
+ enumerable: true,
4922
+ configurable: true
4306
4923
  });
4307
4924
  } else if (value instanceof discord_js.GuildMember) {
4308
4925
  option.user = value.user;
@@ -4311,6 +4928,25 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4311
4928
  else if (value instanceof discord_js.BaseChannel) option.channel = value;
4312
4929
  else if (value instanceof discord_js.Attachment) option.attachment = value;
4313
4930
  else if (isEntity) option.user = value;
4931
+ // An autocomplete's entity option is its id alone, as Discord sends it, when the options belong to one
4932
+ if (unresolved) {
4933
+ for (const field of [
4934
+ 'user',
4935
+ 'member',
4936
+ 'role',
4937
+ 'channel',
4938
+ 'attachment'
4939
+ ]){
4940
+ const descriptor = Object.getOwnPropertyDescriptor(option, field);
4941
+ if (!descriptor) continue;
4942
+ const read = descriptor.get ?? (()=>descriptor.value);
4943
+ Object.defineProperty(option, field, {
4944
+ get: ()=>unresolved() ? undefined : read(),
4945
+ enumerable: true,
4946
+ configurable: true
4947
+ });
4948
+ }
4949
+ }
4314
4950
  return option;
4315
4951
  }
4316
4952
  /**
@@ -4336,8 +4972,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4336
4972
  /**
4337
4973
  * Nests the supplied options under the subcommand path they were invoked through,
4338
4974
  * matching the shape Discord sends rather than a flat list.
4339
- */ function buildOptionData(subcommandGroup, subcommand, values, memberOf, focused) {
4340
- const leaves = Object.entries(values).map(([name, value])=>name === focused ? focusedOptionData(name, value) : toOptionData(name, value, memberOf));
4975
+ */ function buildOptionData(subcommandGroup, subcommand, values, memberOf, focused, unresolved) {
4976
+ const leaves = Object.entries(values).map(([name, value])=>name === focused ? focusedOptionData(name, value) : toOptionData(name, value, memberOf, unresolved));
4341
4977
  if (focused !== null && !Object.hasOwn(values, focused)) leaves.push(focusedOptionData(focused, null));
4342
4978
  if (subcommand === null) return leaves;
4343
4979
  const sub = {
@@ -4372,7 +5008,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4372
5008
  * entity option carries its id in `value` and the object itself, as the gateway sends it: a user option its `user`,
4373
5009
  * and in a server its `member` too. A user option's
4374
5010
  * `getMember()` is the user's member in the server of the interaction the options are given to, and `null` in a DM;
4375
- * a member given resolves `getUser()` to its user. A getter throws discord.js's type error for an option of another
5011
+ * a member given resolves `getUser()` to its user. Under `useStrictMocks()` a user option's `getMentionable()` is its
5012
+ * member too where there is one, as discord.js reads it; default mode returns the user, with a warning once. A getter throws discord.js's type error for an option of another
4376
5013
  * type, as discord.js does, required or not. Only a user or member read as a role, or a role read as a user or member,
4377
5014
  * is `null`, or that error when required, as the option may be a mentionable one; a plain `{ id }` reads as any entity.
4378
5015
  * A whole number is an Integer option and a fraction a Number one, so
@@ -4380,7 +5017,13 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4380
5017
  *
4381
5018
  * The `focused` option reads as Discord sends it, through `getFocused()`, `getFocused(true)` and `data` alike: a
4382
5019
  * string, `''` when no value is given, and marked `focused` in `data`. A number given for it is its digits under
4383
- * `useStrictMocks()`; otherwise it stays the number, with a warning when read.
5020
+ * `useStrictMocks()`; otherwise it stays the number, with a warning when read. Under `useStrictMocks()` `get()` and the
5021
+ * typed getters read it by name from that same option, as discord.js does; one given no value takes the string,
5022
+ * integer and number getters alike. Default mode reads what was given, `null` for no value, with a warning once.
5023
+ *
5024
+ * Given to an `AutocompleteInteraction`, a user, member, role, channel, mentionable or attachment option reads as
5025
+ * Discord sends an autocomplete's options under `useStrictMocks()`: its id alone, in `data` and the handler's params,
5026
+ * with the entity getters returning `null`. Otherwise it keeps the object given, with a warning once.
4384
5027
  *
4385
5028
  * @typeParam Cached - `any` by default, to match `createMockInteraction(ChatInputCommandInteraction)`; pass it when the
4386
5029
  * interaction under test is cache-pinned.
@@ -4402,6 +5045,10 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4402
5045
  * @see {@link ChatInputOptions}
4403
5046
  */ function createChatInputOptions(opts = {}) {
4404
5047
  const { subcommandGroup = null, subcommand = null, focused = null, ...values } = opts;
5048
+ // The option the user is typing, as Discord sent it among the others, which every getter reads in discord.js
5049
+ const focusedOption = ()=>base._hoistedOptions.find((each)=>each.focused);
5050
+ // Default mode reads the focused option from what the test gave, and says once what discord.js reads instead
5051
+ const warnFocused = (name)=>controller_decorator.warnOnce(mockLogger, `The focused option "${name}" is read here from what the test gave, where discord.js reads the option Discord sent: ` + "marked focused, its value a string ('' before anything is typed). In the next major version (5.0) the mock reads " + 'it so; call useStrictMocks() to have it read so now.');
4405
5052
  function resolveOrThrow(name, value, required) {
4406
5053
  {
4407
5054
  if (required === true) throw optionError(discord_js.DiscordjsErrorCodes.CommandInteractionOptionNotFound, name);
@@ -4418,14 +5065,36 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4418
5065
  // As in discord.js, a subcommand is required unless told otherwise, and a group is not
4419
5066
  base.getSubcommandGroup = createMockFn((required = false)=>resolveSubEntry(subcommandGroup, discord_js.DiscordjsErrorCodes.CommandInteractionOptionNoSubcommandGroup, required));
4420
5067
  base.getSubcommand = createMockFn((required = true)=>resolveSubEntry(subcommand, discord_js.DiscordjsErrorCodes.CommandInteractionOptionNoSubcommand, required));
5068
+ // Under strict mocks, options given to an autocomplete hold their entities as ids alone, as Discord sends them
5069
+ const unresolved = ()=>strictMocks() && ownedByAutocomplete(resolver);
4421
5070
  // As discord.js reads an option by its type: another type throws its type error, required or not. A value whose
4422
5071
  // option may be of the getter's type, as a user may be a mentionable option's, reads as null if the getter doesn't
4423
5072
  // take it, and throws when required
4424
5073
  const readOption = (name, expected, takes, required)=>{
5074
+ if (name === focused && strictMocks()) {
5075
+ // discord.js reads the focused option as Discord sent it: a string, its own type kept. One given no value may be
5076
+ // any type the user can type into, as the mock doesn't know which it was declared as
5077
+ const option = focusedOption();
5078
+ const types = values[name] === undefined || values[name] === null ? TYPED : [
5079
+ option.type
5080
+ ];
5081
+ if (!types.some((type)=>expected.includes(type))) {
5082
+ throw optionError(discord_js.DiscordjsErrorCodes.CommandInteractionOptionType, name, option.type, expected.join(', '));
5083
+ }
5084
+ return option.value;
5085
+ }
4425
5086
  const value = values[name];
5087
+ if (name === focused && (value === undefined || value === null)) warnFocused(name);
5088
+ // Read through the option Discord sent, whose number warns where discord.js reads a string
5089
+ if (name === focused && typeof value === 'number') void focusedOption().value;
4426
5090
  if (value === undefined || value === null) return resolveOrThrow(name, null, required);
4427
5091
  const typeError = ()=>optionError(discord_js.DiscordjsErrorCodes.CommandInteractionOptionType, name, optionTypeOf(value), expected.join(', '));
4428
5092
  if (!possibleTypesOf(value).some((type)=>expected.includes(type))) throw typeError();
5093
+ // An entity in an autocomplete's options is its id alone, so a getter finds nothing, as in discord.js
5094
+ if (typeof value === 'object' && unresolved()) {
5095
+ if (required === true) throw optionError(discord_js.DiscordjsErrorCodes.CommandInteractionOptionEmpty, name, optionTypeOf(value));
5096
+ return null;
5097
+ }
4429
5098
  if (takes(value)) return value;
4430
5099
  if (required === true) throw typeError();
4431
5100
  return null;
@@ -4486,15 +5155,25 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4486
5155
  ], plainOr(discord_js.User, discord_js.GuildMember));
4487
5156
  return value instanceof discord_js.User ? memberOf(name, value) : value;
4488
5157
  });
4489
- base.getMentionable = createMockFn((name, required)=>readOption(name, [
5158
+ // A user's member in a server, as discord.js reads `member ?? user`; default mode keeps the user, with a warning
5159
+ base.getMentionable = createMockFn((name, required)=>{
5160
+ const value = readOption(name, [
4490
5161
  MENTIONABLE
4491
- ], plainOr(discord_js.User, discord_js.GuildMember, discord_js.Role), required));
5162
+ ], plainOr(discord_js.User, discord_js.GuildMember, discord_js.Role), required);
5163
+ if (!(value instanceof discord_js.User)) return value;
5164
+ const member = memberOf(name, value);
5165
+ if (member === null || strictMocks()) return member ?? value;
5166
+ controller_decorator.warnOnce(mockLogger, `getMentionable('${name}') returns the user here, where discord.js returns the user's member in a server; in the next ` + 'major version (5.0) the mock returns the member. Call useStrictMocks() to have it returned now.');
5167
+ return value;
5168
+ });
4492
5169
  base.getAttachment = createMockFn((name, required)=>readOption(name, [
4493
5170
  ATTACHMENT
4494
5171
  ], plainOr(discord_js.Attachment), required));
4495
5172
  base.get = createMockFn((name, required)=>{
5173
+ if (name === focused && strictMocks()) return focusedOption();
5174
+ if (name === focused) warnFocused(name);
4496
5175
  const value = values[name];
4497
- return value === undefined || value === null ? resolveOrThrow(name, null, required) : toOptionData(name, value, memberOf);
5176
+ return value === undefined || value === null ? resolveOrThrow(name, null, required) : toOptionData(name, value, memberOf, unresolved);
4498
5177
  });
4499
5178
  // A message option is a message context menu's own, never a slash command's
4500
5179
  base.getMessage = createMockFn((name, required)=>readOption(name, [
@@ -4503,21 +5182,216 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4503
5182
  base.getFocused = createMockFn((getFull)=>{
4504
5183
  if (focused === null) throw optionError(discord_js.DiscordjsErrorCodes.AutocompleteInteractionOptionNoFocusedOption);
4505
5184
  // The one options.data holds, so the two agree
4506
- const option = base._hoistedOptions.find((each)=>each.focused);
5185
+ const option = focusedOption();
4507
5186
  return getFull === true ? {
4508
5187
  ...option
4509
5188
  } : option.value;
4510
5189
  });
4511
5190
  // `data` is what the framework reads to build a handler's params, so it is materialised here rather than
4512
5191
  // auto-stubbed, or every params assertion would see an empty record.
4513
- base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf, focused);
5192
+ base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf, focused, unresolved);
4514
5193
  // The fields discord.js's constructor sets from it, which its toString() reads
4515
5194
  base._group = subcommandGroup;
4516
5195
  base._subcommand = subcommand;
4517
5196
  base._hoistedOptions = subcommand === null ? base.data : (subcommandGroup === null ? base.data[0] : base.data[0].options[0]).options;
4518
5197
  const resolver = stubDeep(base);
5198
+ const entities = Object.entries(values).filter(([name, value])=>name !== focused && typeof value === 'object' && value !== null);
5199
+ optionEntities.set(resolver, entities.map(([name])=>name));
4519
5200
  return resolver;
4520
5201
  }
5202
+ // What a mock reads for a property the test gave none, made with this module's factories and helpers. Built last, as it
5203
+ // reads them, and only read once a mock is made
5204
+ const discordDefault = discordDefaults({
5205
+ botMemberOf,
5206
+ createChatInputOptions,
5207
+ createMockChannel,
5208
+ createMockGuild,
5209
+ createMockMessage,
5210
+ createMockUser,
5211
+ heldChannelOf,
5212
+ isRawMember,
5213
+ isUnsetEveryone,
5214
+ madeAt,
5215
+ memberRoles,
5216
+ NEW_SERVER_EVERYONE_PERMISSIONS,
5217
+ ownedManager,
5218
+ readPlaceAs,
5219
+ readPlaceQuietly
5220
+ });
5221
+
5222
+ const logger = new controller_decorator.Logger('resolveRoute');
5223
+ /** Why an app import reads `undefined`, for its refusal. */ const CIRCULAR_IMPORT_HINT = ' An app import that reads undefined is often a circular import.';
5224
+ /** Why an app is an object, for its refusal. */ const MODULE_HINT = ' An object here is often a module namespace, as `import * as App` gives: pass the class it exports.';
5225
+ /** An app that isn't a class, as its refusal names it: the value, and its likely cause where there is one. */ function notAClass(app) {
5226
+ const hint = app === undefined ? CIRCULAR_IMPORT_HINT : typeof app === 'object' && app !== null ? MODULE_HINT : '';
5227
+ return {
5228
+ name: controller_decorator.describeValue(app),
5229
+ hint
5230
+ };
5231
+ }
5232
+ /** Whether a message gives a route's `member`, `role` or `channel` flag, which dispatch refuses in a DM. */ const givesGuildFlag = (route, params)=>route.flags.some((flag)=>flag.type !== undefined && providers.isGuildType(flag.type) && Object.hasOwn(params, flag.flag));
5233
+ /** The options `@MeoCord` declares on an application class. */ function appOptionsOf(app) {
5234
+ // An app that isn't a class at all, as a circular import gives, has no metadata to read
5235
+ if (typeof app !== 'function') {
5236
+ const { name, hint } = notAClass(app);
5237
+ throw new TypeError(`${name} is not decorated with @MeoCord().${hint}`);
5238
+ }
5239
+ const options = Reflect.getMetadata(controller_decorator.META.appOptions, app);
5240
+ if (!options) throw new TypeError(`${app.name || 'The given class'} is not decorated with @MeoCord().`);
5241
+ return options;
5242
+ }
5243
+ /** The controllers `@MeoCord({ controllers })` registers on an application class. */ function controllersOf(app) {
5244
+ return appOptionsOf(app).controllers ?? [];
5245
+ }
5246
+ /**
5247
+ * Resolves which handler a component's `customId` or a message's content reaches, as dispatch routes it.
5248
+ *
5249
+ * Use it to check routing alone, in a plain unit test: it reads decorator metadata, with no client, config or
5250
+ * container, and runs no guard. To run the handler it reaches, use {@link TestingModule.dispatch}.
5251
+ *
5252
+ * @remarks
5253
+ * It routes across every controller the app registers, most specific pattern first: a component within its type, a
5254
+ * message after the app's prefix, and never to a `@MessageHandler()` listener, which runs for every message.
5255
+ *
5256
+ * @param app - The application class decorated with `@MeoCord`.
5257
+ * @param input - The component type and its `customId`, or the message's content with its prefix and the bot's id.
5258
+ * @returns The handler that runs, with the params it captures, or `undefined` when no route handles the input.
5259
+ * @throws TypeError for a message to an app whose prefix is a function, when no `prefix` is given.
5260
+ *
5261
+ * @example
5262
+ * ```ts
5263
+ * import { expect } from 'vitest'
5264
+ *
5265
+ * @Controller()
5266
+ * class ProfileController {
5267
+ * @Command('profile/{uid}', CommandType.BUTTON)
5268
+ * async show(interaction: ButtonInteraction, { uid }: { uid: string }) {
5269
+ * await respond(interaction).send(`Profile ${uid}`)
5270
+ * }
5271
+ * }
5272
+ * @MeoCord({ controllers: [ProfileController], clientOptions: { intents: [] } })
5273
+ * class App {}
5274
+ * const route = resolveRoute(App, { type: CommandType.BUTTON, customId: 'profile/8000' })
5275
+ * expect([route?.handler, route?.params]).toEqual([ProfileController.prototype.show, { uid: '8000' }])
5276
+ * ```
5277
+ *
5278
+ * @group Testing
5279
+ * @category Inspection
5280
+ * @see {@link findRouteConflicts}
5281
+ * @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
5282
+ */ function resolveRoute(app, input) {
5283
+ if ('content' in input) {
5284
+ const { messages = {} } = appOptionsOf(app);
5285
+ const starts = providers.staticMessageStarts(app, messages, input);
5286
+ const routes = providers.buildMessageRoutes(controllersOf(app), messages);
5287
+ let matched = providers.matchMessageRoute(routes, input.content, starts);
5288
+ const named = (route)=>`${route.controllerClass.name}.${String(route.method)}`;
5289
+ // Without `dm`: under strict mocks, read as sent in a server, as dispatch routes such a message whatever the
5290
+ // controllers' order; otherwise as from anywhere, warning where that answers differently
5291
+ if (starts.inGuild === undefined) {
5292
+ const inServer = providers.matchMessageRoute(routes, input.content, {
5293
+ ...starts,
5294
+ inGuild: true
5295
+ });
5296
+ if (strictMocks()) matched = inServer && providers.fitsScope(inServer.route.scope, true) ? inServer : undefined;
5297
+ else if (matched && inServer && matched.route !== inServer.route && !providers.fitsScope(matched.route.scope, true)) {
5298
+ controller_decorator.warnOnce(logger, `resolveRoute resolved a message without dm to ${named(matched.route)}, which works in a DM alone, where dispatch runs ` + `${named(inServer.route)} for a message sent in a server. Pass dm: true for a DM. Under useStrictMocks() it resolves to ` + `${named(inServer.route)}.`);
5299
+ } else if (matched && !providers.fitsScope(matched.route.scope, true)) {
5300
+ // Only a handler that works in a DM alone takes it, which dispatch runs in a DM only
5301
+ controller_decorator.warnOnce(logger, `resolveRoute found only ${named(matched.route)}, which works in a DM alone, for a message without dm; dispatch runs it ` + 'only in a DM. Pass dm: true. Under useStrictMocks() this resolves to undefined.');
5302
+ }
5303
+ }
5304
+ if (!matched) return undefined;
5305
+ const { route, params } = matched;
5306
+ // Dispatch never runs a route that does not work in a DM; one sent with a prefix or mention is told it works in a server only
5307
+ if (starts.inGuild === false && (!providers.fitsScope(route.scope, false) || providers.takesGuildParam(route))) return undefined;
5308
+ // Nor one with a required flag only a server has, or given one: refused under strict mocks, warned about otherwise
5309
+ if (starts.inGuild === false && (providers.needsGuild(route) || givesGuildFlag(route, params))) {
5310
+ if (strictMocks()) return undefined;
5311
+ controller_decorator.warnOnce(logger, `resolveRoute resolved a DM to ${named(route)}, which dispatch answers "This command ` + 'works in a server only.": a member, role or channel flag is found only in a server. Under useStrictMocks() this ' + 'resolves to undefined.');
5312
+ }
5313
+ return {
5314
+ controller: route.controllerClass,
5315
+ method: route.method,
5316
+ handler: route.controllerClass.prototype[route.method],
5317
+ params
5318
+ };
5319
+ }
5320
+ if (!controller_decorator.isCustomIdRouted(input.type)) {
5321
+ throw new TypeError(`${input.type} commands are routed by name, not by customId.`);
5322
+ }
5323
+ const routes = eventSources.buildComponentRoutes(controllersOf(app));
5324
+ const matched = eventSources.matchComponentRoute(routes, (type)=>type === input.type, input.customId);
5325
+ if (!matched) return undefined;
5326
+ const { route, params, text } = matched;
5327
+ const method = route.meta.methodName;
5328
+ const typed = Object.keys(route.types).length > 0;
5329
+ // The routes ranked behind it that the customId matches too, which dispatch never reaches for it
5330
+ const behind = routes.slice(routes.indexOf(route) + 1);
5331
+ const alsoMatches = behind.filter((other)=>other.meta.type === input.type && eventSources.readCustomId(other.pattern, other.meta.regex, input.customId)).map((other)=>other.pattern);
5332
+ const resolved = {
5333
+ controller: route.controllerClass,
5334
+ method,
5335
+ handler: route.controllerClass.prototype[method],
5336
+ params: text,
5337
+ ...typed && {
5338
+ values: params
5339
+ }
5340
+ };
5341
+ return alsoMatches.length > 0 ? readableOnly(resolved, {
5342
+ alsoMatches
5343
+ }) : resolved;
5344
+ }
5345
+ /**
5346
+ * Finds component patterns that rank equally and can match the same `customId`, which MeoCord otherwise only warns
5347
+ * about at startup.
5348
+ *
5349
+ * Use it in a test to keep the warning from reaching production. Patterns rank segment by segment, a literal before a
5350
+ * param and then the narrower type, so two collide only with the same literals and equally narrow params at each
5351
+ * position, such as `{action:close|reopen}` and `{step:close|confirm}`, which both take `close`; the one listed
5352
+ * first runs. Patterns are compared within a component type.
5353
+ *
5354
+ * @param app - The application class decorated with `@MeoCord`.
5355
+ * @returns Each pair of patterns that rank equally and can match the same `customId`, with its component type.
5356
+ *
5357
+ * @example
5358
+ * ```ts
5359
+ * import { expect } from 'vitest'
5360
+ *
5361
+ * @Controller()
5362
+ * class TicketController {
5363
+ * @Command('ticket/{action:close|reopen}', CommandType.BUTTON)
5364
+ * async act(interaction: ButtonInteraction) { await respond(interaction).send('Done.') }
5365
+ * @Command('ticket/{step:close|confirm}', CommandType.BUTTON)
5366
+ * async step(interaction: ButtonInteraction) { await respond(interaction).send('Next.') }
5367
+ * }
5368
+ * @MeoCord({ controllers: [TicketController], clientOptions: { intents: [] } })
5369
+ * class App {}
5370
+ * expect(findRouteConflicts(App)).toHaveLength(1)
5371
+ * ```
5372
+ *
5373
+ * @group Testing
5374
+ * @category Inspection
5375
+ * @see {@link resolveRoute}
5376
+ */ function findRouteConflicts(app) {
5377
+ return eventSources.findComponentRouteConflicts(eventSources.buildComponentRoutes(controllersOf(app))).map(({ type, patterns, runs })=>readableOnly({
5378
+ type: type,
5379
+ patterns
5380
+ }, {
5381
+ runs
5382
+ }));
5383
+ }
5384
+ /**
5385
+ * `target` with `fields` added as properties a test reads but `toEqual` and its kin, which compare enumerable keys,
5386
+ * don't see, so an assertion written for the fields it had before still holds.
5387
+ */ function readableOnly(target, fields) {
5388
+ for (const [key, value] of Object.entries(fields))Object.defineProperty(target, key, {
5389
+ value,
5390
+ enumerable: false,
5391
+ configurable: true
5392
+ });
5393
+ return target;
5394
+ }
4521
5395
 
4522
5396
  /**
4523
5397
  * A compiled testing module, which runs handlers as the bot does and resolves the classes it built.
@@ -4583,7 +5457,13 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4583
5457
  * ```
4584
5458
  */ async init(options = {}) {
4585
5459
  this.resolving ?? (this.resolving = providers.resolveProviders(this.container, this.providers, this.order).then(()=>{
5460
+ // As the bot reads them, once every provided value is made and before the services that could list them
5461
+ this.sources ?? (this.sources = eventSources.eventSources(this.container, this.providers, this.lifecycle, this.eventClasses, (text)=>new controller_decorator.Logger('TestingModule').warn(text)));
5462
+ providers.listEventSources(this.container.get(providers.HandlerRegistry), this.sources);
5463
+ providers.prepareLateThemes(this.container, this.sources.map(({ cls })=>cls));
4586
5464
  for (const service of this.services)this.container.get(service);
5465
+ // After the providers and listed services, as the bot makes it, so a provided value it injects is made
5466
+ if (this.app) providers.bindAppPresenter(this.container, this.app);
4587
5467
  }));
4588
5468
  await this.resolving;
4589
5469
  if (options.ready) {
@@ -4639,10 +5519,10 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4639
5519
  // down whatever they constructed
4640
5520
  await this.resolving?.catch(()=>undefined);
4641
5521
  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
4642
- .map((unit)=>startupRoots.lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
5522
+ .map((unit)=>eventSources.lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
4643
5523
  const failures = [];
4644
- await startupRoots.runShutdownSequence(this.container, entries, {
4645
- drainCalls: ()=>startupRoots.callsSettled(this.calls),
5524
+ await eventSources.runShutdownSequence(this.container, entries, {
5525
+ drainCalls: ()=>eventSources.callsSettled(this.calls),
4646
5526
  readyPass: this.readyPass,
4647
5527
  timeoutMs: this.shutdownTimeout,
4648
5528
  hookFailed: (name, error)=>failures.push({
@@ -4671,10 +5551,9 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4671
5551
  error
4672
5552
  });
4673
5553
  // No warning for a hook whose dependency failed: the test sees the dependency's own error
4674
- // What close() shuts down is what was constructed, so the entries the runner records are not kept
4675
- await startupRoots.runReadyHooks(this.container, this.lifecycle, client, {
5554
+ await eventSources.runReadyHooks(this.container, this.lifecycle, client, {
4676
5555
  primary
4677
- }, [], {
5556
+ }, {
4678
5557
  resolveFailed: failed,
4679
5558
  hookFailed: failed
4680
5559
  }, {
@@ -4741,16 +5620,24 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4741
5620
  * command's or an autocomplete's options, or the handler's customId params with a modal's fields or
4742
5621
  * a select menu's choices.
4743
5622
  * An interaction's customId or command name must be one dispatch routes to the handler, ranking
4744
- * every handler of the module as the bot does; a mock built without one is not checked. With a message alone, a patterned `@MessageHandler` gets the
4745
- * params its pattern captures from the content, after the prefix of the module's `app`, with typed
4746
- * params resolved as dispatch resolves them, from the message's guild caches first; a message
4747
- * without content gets `{}`. A word that is not a value of its type, and a prefixed message that names
4748
- * the command but leaves out a param, go through the handler's filters as a `MessageUsageError`, as
4749
- * dispatch answers them.
5623
+ * every handler of the module as the bot does; a mock built without one is not checked. An autocomplete is
5624
+ * checked by its command path and its focused option: one dispatch gives to another `@Autocomplete` handler,
5625
+ * or to none, is refused under `useStrictMocks()`, and otherwise warned about once and run.
5626
+ * With a message alone, a patterned `@MessageHandler` gets the params its pattern captures from the content,
5627
+ * after the prefix of the module's `app`, with typed params resolved as dispatch resolves them, from the
5628
+ * message's guild caches first. A message whose `content` isn't a string, as a mock's without
5629
+ * `useStrictMocks()`, gets `{}`. Under strict mocks a message has empty `content`, and a patterned handler
5630
+ * rejects it, as dispatch runs no handler for a message with no text: give the mock the `content` the handler
5631
+ * takes. A word that is not a value of its type, and a prefixed message that names the command but leaves out
5632
+ * a param, go through the handler's filters as a `MessageUsageError`, as dispatch answers them.
5633
+ * An `@On` or `@Once` handler no route reaches runs as {@link emit} runs it, as an event with the arguments given,
5634
+ * under `useStrictMocks()`; without strict mocks it runs as its first argument's type, and `invoke` warns once
5635
+ * where that differs.
4750
5636
  * @returns Whether the handler ran, and the error a filter handled, if any. Rejects with an error no
4751
5637
  * filter handles, or with the error a filter throws: the built-in fallback, which answers such
4752
5638
  * errors in the bot, does not run here. Rejects before running anything with an interaction or a
4753
- * message the handler's route does not match, naming both. Given fewer arguments than the handler
5639
+ * message the handler's route does not match, naming both, and under `useStrictMocks()` with such an
5640
+ * autocomplete. Given fewer arguments than the handler
4754
5641
  * declares, with a first that is neither an interaction nor a message, such as a reaction without its
4755
5642
  * `ReactionEvent`, it rejects under `useStrictMocks()`, and otherwise warns once and runs the handler.
4756
5643
  *
@@ -4782,15 +5669,29 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4782
5669
  const instance = this.container.get(controller);
4783
5670
  if (typeof instance[methodName] !== 'function') throw new Error(`${controller.name}.${methodName} is not a method.`);
4784
5671
  const [first] = args;
5672
+ const event = eventHandlerOf(controller, methodName);
5673
+ // As emit and the bot run it; without strict mocks it runs as before, as a test that passes that way could fail
5674
+ if (event && strictMocks()) {
5675
+ this.checkArgumentCount(controller, methodName, instance[methodName], first, args.length);
5676
+ this.registerEventClients(args);
5677
+ const { ran, error } = await this.track(this.runEventHandler(instance, methodName, args));
5678
+ return error === undefined ? {
5679
+ ran
5680
+ } : {
5681
+ ran,
5682
+ error
5683
+ };
5684
+ }
4785
5685
  const interaction = first instanceof discord_js.BaseInteraction ? first : undefined;
4786
5686
  // The handler dispatch gives the customId to, among every handler of the module
4787
- const component = interaction && controller_decorator.hasCustomId(interaction) ? startupRoots.componentRouteFor(this.componentRoutes(), controller, methodName, interaction) : undefined;
4788
- const mismatch = component && 'mismatch' in component ? component.mismatch : interaction && startupRoots.commandMismatch(this.controllers, controller, methodName, interaction);
5687
+ const component = interaction && controller_decorator.hasCustomId(interaction) ? eventSources.componentRouteFor(this.componentRoutes(), controller, methodName, interaction) : undefined;
5688
+ const mismatch = component && 'mismatch' in component ? component.mismatch : interaction && eventSources.commandMismatch(this.controllers, controller, methodName, interaction);
4789
5689
  if (mismatch) throw new Error(mismatch);
5690
+ if (interaction) this.checkAutocompleteRoute(controller, methodName, interaction);
4790
5691
  let hooks = {};
4791
5692
  let callArgs = args.length === 1 && interaction ? [
4792
5693
  interaction,
4793
- startupRoots.handlerInput(interaction, component && 'params' in component ? component.params : {}).params
5694
+ eventSources.handlerInput(interaction, component && 'params' in component ? component.params : {}).params
4794
5695
  ] : args;
4795
5696
  if (args.length === 1 && first instanceof discord_js.Message) {
4796
5697
  const input = await providers.messageParamsFor(controller, methodName, first, this.messageOptions, this.controllers);
@@ -4805,10 +5706,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4805
5706
  }
4806
5707
  }
4807
5708
  this.checkArgumentCount(controller, methodName, instance[methodName], first, callArgs.length);
4808
- const presenter = providers.appPresenterOf(this.container);
4809
- // A message command's errors are drawn by the app's presenter too, as an interaction's are
4810
- const client = first instanceof discord_js.BaseInteraction || first instanceof discord_js.Message ? first.client : undefined;
4811
- if (presenter && client) controller_decorator.setPresenter(client, presenter);
5709
+ if (event) this.warnEventRun(controller, methodName, event, first, callArgs.length !== args.length);
4812
5710
  this.registerClient(first);
4813
5711
  const { ran, error } = await this.track(providers.runHandler(this.container, instance, methodName, callArgs, {
4814
5712
  awaitObservers: true,
@@ -4822,6 +5720,36 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4822
5720
  };
4823
5721
  }
4824
5722
  /**
5723
+ * Warns once, without strict mocks, that `invoke` runs an event handler other than as the bot does: as the type its
5724
+ * first argument gives, or with params it added. Only `invoke` warns, on the test's own call.
5725
+ */ warnEventRun(controller, methodName, event, first, paramsAdded) {
5726
+ const type = controller_decorator.inferContextType(first);
5727
+ // invoke adds params only for an interaction or a message, whose type is never 'event'
5728
+ if (type === 'event') return;
5729
+ const key = `${controller.name}.${methodName}`;
5730
+ if (this.warnedEventRuns.has(key)) return;
5731
+ this.warnedEventRuns.add(key);
5732
+ const call = `${controller_decorator.withArticle(type)} call${paramsAdded ? ' with its params added' : ''}`;
5733
+ new controller_decorator.Logger('TestingModule').warn(`invoke runs ${key}, an @${event.once ? 'Once' : 'On'}('${event.event}') handler, as ${call}; the bot runs it as ` + 'an event with its arguments as given, and useStrictMocks() runs it so.');
5734
+ }
5735
+ /** Runs an `@On` or `@Once` handler as the bot and `emit` do: as an event, with its arguments as given. */ runEventHandler(instance, method, args) {
5736
+ return providers.runHandler(this.container, instance, method, args, {
5737
+ type: 'event',
5738
+ awaitObservers: true
5739
+ });
5740
+ }
5741
+ /**
5742
+ * Refuses under strict mocks, and otherwise warns once, an autocomplete dispatch gives to another handler or to none.
5743
+ * Without strict mocks the handler still runs, since a test that passes that way would fail.
5744
+ */ checkAutocompleteRoute(controller, methodName, interaction) {
5745
+ const mismatch = eventSources.autocompleteMismatch(this.controllers, controller, methodName, interaction);
5746
+ if (!mismatch) return;
5747
+ if (strictMocks()) throw new Error(mismatch);
5748
+ if (this.warnedAutocompleteRoutes.has(mismatch)) return;
5749
+ this.warnedAutocompleteRoutes.add(mismatch);
5750
+ new controller_decorator.Logger('TestingModule').warn(`${mismatch} Under useStrictMocks(), invoke rejects it.`);
5751
+ }
5752
+ /**
4825
5753
  * Refuses under `useStrictMocks()`, or warns once otherwise, a call that gives a handler fewer arguments than it
4826
5754
  * declares when `invoke` builds none for it, as for a reaction given without its `ReactionEvent`.
4827
5755
  */ checkArgumentCount(controller, methodName, method, first, given) {
@@ -4840,34 +5768,70 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4840
5768
  return call;
4841
5769
  }
4842
5770
  /**
4843
- * Makes this module the app of the input's client, as the bot is of its own, so `respond()` outside any call,
4844
- * as in a collector's callback on that client, answers in this module's theme.
5771
+ * Makes this module the app of the input's client, as the bot is of its own: its answers are drawn by this
5772
+ * module's presenter, in its theme and language, `respond()` outside any call included, as in a collector's callback
5773
+ * on that client. A module without a presenter clears the client's under strict mocks, so the default draws; default
5774
+ * mode leaves another module's in place and warns once.
4845
5775
  */ registerClient(input) {
4846
5776
  const client = input?.client;
4847
5777
  if (!client || typeof client !== 'object') return;
5778
+ const presenter = providers.appPresenterOf(this.container);
5779
+ if (presenter || strictMocks()) controller_decorator.setPresenter(client, presenter);
5780
+ else if (controller_decorator.clientPresenter(client)) {
5781
+ controller_decorator.warnOnce(new controller_decorator.Logger('TestingModule'), "A module without a presenter here answers with the presenter another module set on the same client; under " + "useStrictMocks() this module's own presenter, or the default, is used.");
5782
+ }
4848
5783
  providers.registerClientTheme(client, this.container);
4849
5784
  controller_decorator.registerClientTranslator(client, this.container.isBound(controller_decorator.Translator) ? this.container.get(controller_decorator.Translator) : undefined);
4850
5785
  }
5786
+ /**
5787
+ * Makes this module the app of each event argument's client, as the bot is of its own, under strict mocks; default
5788
+ * mode leaves the clients as they are and warns once where this module would draw answers otherwise.
5789
+ */ registerEventClients(args) {
5790
+ const withClient = args.filter((arg)=>typeof arg?.client === 'object');
5791
+ if (withClient.length === 0) return;
5792
+ if (strictMocks()) {
5793
+ for (const arg of withClient)this.registerClient(arg);
5794
+ return;
5795
+ }
5796
+ const ownTheme = providers.appTheme(this.container) !== controller_decorator.defaultTheme();
5797
+ if (!providers.appPresenterOf(this.container) && !this.container.isBound(controller_decorator.Translator) && !ownTheme) return;
5798
+ controller_decorator.warnOnce(new controller_decorator.Logger('TestingModule'), "emit() here leaves the client's presenter, theme and translator as they were, where the bot draws the answers to " + "an event with its app's; in the next major version (5.0) emit sets the module's on the client. Call " + 'useStrictMocks() to have it set them now.');
5799
+ }
4851
5800
  /** The module's component routes, ranked as dispatch ranks them; throws, as the bot would, for two of one shape. */ componentRoutes() {
4852
- return this.builtComponentRoutes ?? (this.builtComponentRoutes = startupRoots.buildComponentRoutes(this.controllers));
5801
+ return this.builtComponentRoutes ?? (this.builtComponentRoutes = eventSources.buildComponentRoutes(this.controllers));
4853
5802
  }
4854
5803
  /** The dispatcher the bot would build from this module's controllers and `app`. */ dispatcherOf() {
4855
5804
  if (this.dispatcher) return this.dispatcher;
4856
5805
  const logger = new controller_decorator.Logger('TestingModule');
4857
5806
  const warnUnanswered = this.appWarnUnanswered ?? process.env.NODE_ENV === 'development';
4858
- this.dispatcher = new startupRoots.Dispatcher({
5807
+ const notices = {
5808
+ cooldownNotice: (refusal)=>providers.claimCooldownNotice(this.container, refusal),
5809
+ storeDownNotice: (who)=>providers.claimStoreDownNotice(this.container, who)
5810
+ };
5811
+ // Strict mocks log an answer MeoCord fails to build and end the call in that fault, as a bot does; default mode
5812
+ // rethrows it from the fallback, so observers hear the call's own outcome, and an interaction's again as an error
5813
+ const answer = eventSources.createFallback(logger, ()=>this.messageOptions, notices);
5814
+ const rethrowing = eventSources.createFallback(logger, ()=>this.messageOptions, {
5815
+ ...notices,
5816
+ strict: true
5817
+ });
5818
+ const fallback = async (error, context)=>{
5819
+ if (strictMocks()) return answer(error, context);
5820
+ try {
5821
+ return await rethrowing(error, context);
5822
+ } catch (failure) {
5823
+ controller_decorator.warnOnce(logger, "dispatch() here reports a call whose answer MeoCord couldn't build with the call's own outcome, and an " + "interaction's a second time as an error; under useStrictMocks() the call is reported once, as 'error'.");
5824
+ throw failure;
5825
+ }
5826
+ };
5827
+ this.dispatcher = new eventSources.Dispatcher({
4859
5828
  container: this.container,
4860
5829
  controllerClasses: this.controllers,
4861
5830
  messageOptions: this.messageOptions,
4862
5831
  logger,
4863
5832
  // A test's fake clock can pass 5 s inside a handler, so the slow-handler warning waits to be asked for
4864
5833
  warnSlowMessageHandlers: false,
4865
- // Strict: an answer MeoCord fails to build rejects the dispatch, where a bot logs it and carries on
4866
- fallback: startupRoots.createFallback(logger, ()=>this.messageOptions, {
4867
- strict: true,
4868
- cooldownNotice: (refusal)=>providers.claimCooldownNotice(this.container, refusal),
4869
- storeDownNotice: (who)=>providers.claimStoreDownNotice(this.container, who)
4870
- }),
5834
+ fallback,
4871
5835
  // A mock's client is the one bot every mock client is, so a mention of it starts a command
4872
5836
  botUserId: (event)=>{
4873
5837
  const id = event.client?.user?.id;
@@ -4885,6 +5849,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4885
5849
  const dispatcher = this.dispatcherOf();
4886
5850
  const handlers = [];
4887
5851
  const unhandled = [];
5852
+ // The fault each error's answer failed with, which the call ends in in that error's place
5853
+ const unanswered = new Map();
4888
5854
  const record = {
4889
5855
  settled: (controller, method, { ran, error })=>handlers.push({
4890
5856
  controller,
@@ -4894,7 +5860,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4894
5860
  error
4895
5861
  }
4896
5862
  }),
4897
- unhandled: (error)=>unhandled.push(error)
5863
+ unhandled: (error)=>unhandled.push(error),
5864
+ unanswered: (error, failure)=>unanswered.set(error, failure)
4898
5865
  };
4899
5866
  if (options) {
4900
5867
  noteReactionDispatch(input);
@@ -4903,18 +5870,19 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4903
5870
  action: options.action ?? enum_index.ReactionHandlerAction.ADD
4904
5871
  }, record));
4905
5872
  } else if (input instanceof discord_js.BaseInteraction) {
4906
- const presenter = providers.appPresenterOf(this.container);
4907
- if (presenter) controller_decorator.setPresenter(input.client, presenter);
4908
5873
  await this.track(dispatcher.interaction(input, record));
4909
5874
  } else if (input instanceof discord_js.Message) {
4910
- const presenter = providers.appPresenterOf(this.container);
4911
- if (presenter) controller_decorator.setPresenter(input.client, presenter);
4912
5875
  await this.track(dispatcher.message(input, record));
4913
5876
  } else {
4914
5877
  throw new TypeError('dispatch takes an interaction, a message, or a reaction with { user }.');
4915
5878
  }
4916
- // What the fallback answers as the user's own outcome, such as a usage reply or a refusal, is an outcome to assert on
4917
- const failures = unhandled.filter((error)=>!controller_decorator.isUserOutcome(error, options ? undefined : input));
5879
+ // What the fallback answers as the user's own outcome, such as a usage reply or a refusal, is an outcome to assert on,
5880
+ // unless its answer failed to build, which is the call's fault, as on a bot
5881
+ const failures = unhandled.flatMap((error)=>unanswered.has(error) ? [
5882
+ unanswered.get(error)
5883
+ ] : controller_decorator.isUserOutcome(error, options ? undefined : input) ? [] : [
5884
+ error
5885
+ ]);
4918
5886
  if (failures.length === 1) throw failures[0];
4919
5887
  if (failures.length > 1) throw new AggregateError(failures, `${failures.length} errors were left to the fallback.`);
4920
5888
  const error = handlers.find((handler)=>handler.error !== undefined)?.error ?? unhandled[0];
@@ -4929,8 +5897,9 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4929
5897
  /**
4930
5898
  * Emits a client event to the module's `@On` and `@Once` handlers, through the pipeline the app runs
4931
5899
  * them in up to its event fallback: the global guards of the module's `app`, then each handler's own.
4932
- * Handlers on the module's controllers, class providers and their dependencies all receive it. A
4933
- * `@Once` handler handles only the first event, as it would on a client.
5900
+ * As on the bot, every instance the module runs lifecycle hooks on, the cooldown store aside, receives
5901
+ * it: the controllers and services, what they and the stages inject, and each provided class or value.
5902
+ * A `@Once` handler handles only the first event, as it would on a client.
4934
5903
  *
4935
5904
  * The bot's event fallback doesn't run here. On a bot, a guard's `GuardDeniedError` is logged and the
4936
5905
  * event skipped, and a handler's `UserError` is logged and, when the event carries a message, answered
@@ -4954,22 +5923,21 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4954
5923
  */ async emit(event, ...args) {
4955
5924
  this.warnIfClosed();
4956
5925
  await this.init();
5926
+ this.registerEventClients(args);
4957
5927
  const calls = [];
4958
- for (const cls of this.eventClasses){
4959
- for (const handler of providers.getEventHandlers(cls.prototype)){
5928
+ for (const [index, source] of (this.sources ?? []).entries()){
5929
+ for (const handler of source.handlers){
4960
5930
  if (handler.event !== event) continue;
4961
5931
  if (handler.once) {
4962
- const key = `${cls.name}.${handler.method}:${event}`;
5932
+ const key = `${index}.${handler.method}:${event}`;
4963
5933
  if (this.firedOnce.has(key)) continue;
4964
5934
  this.firedOnce.add(key);
4965
5935
  }
4966
5936
  // Resolved inside the call, so a class that cannot be resolved fails as its handler would
4967
5937
  const run = async ()=>{
4968
- const instance = this.container.get(cls);
4969
- const { ran } = await providers.runHandler(this.container, instance, handler.method, args, {
4970
- type: 'event',
4971
- awaitObservers: true
4972
- });
5938
+ const instance = source.resolve();
5939
+ if (!instance) return false;
5940
+ const { ran } = await this.runEventHandler(instance, handler.method, args);
4973
5941
  return ran;
4974
5942
  };
4975
5943
  calls.push(this.track(run()));
@@ -4985,7 +5953,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4985
5953
  ran: results.filter((result)=>result.status === 'fulfilled' && result.value).length
4986
5954
  };
4987
5955
  }
4988
- constructor(container, controllers = [], eventClasses = [], providers = new Map(), order = [], messageOptions = {}, lifecycle = [], /** The lifecycle units the container has constructed, which `close()` shuts down. */ constructed = new Set(), /** The `app`'s `warnUnanswered`, which dispatch follows as the bot does. */ appWarnUnanswered, /** The app's listed services, made at `init()` as the bot makes them before it logs in. */ services = [], /** How long `close()` waits for the `onShutdown` hooks, as the bot's `shutdownTimeout` does. */ shutdownTimeout = startupRoots.DEFAULT_SHUTDOWN_TIMEOUT_MS){
5956
+ constructor(container, controllers = [], eventClasses = [], providers = new Map(), order = [], messageOptions = {}, lifecycle = [], /** The lifecycle units the container has constructed, which `close()` shuts down. */ constructed = new Set(), /** The `app`'s `warnUnanswered`, which dispatch follows as the bot does. */ appWarnUnanswered, /** The app's listed services, made at `init()` as the bot makes them before it logs in. */ services = [], /** How long `close()` waits for the `onShutdown` hooks, as the bot's `shutdownTimeout` does. */ shutdownTimeout = eventSources.DEFAULT_SHUTDOWN_TIMEOUT_MS, /** The `app`, whose presenter `init()` makes after the providers, as the bot does. */ app){
4989
5957
  this.container = container;
4990
5958
  this.controllers = controllers;
4991
5959
  this.eventClasses = eventClasses;
@@ -4997,10 +5965,13 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
4997
5965
  this.appWarnUnanswered = appWarnUnanswered;
4998
5966
  this.services = services;
4999
5967
  this.shutdownTimeout = shutdownTimeout;
5000
- /** The `onReady` hooks' pass, which `close()` waits for, less one whose `onReady` called `close()`. */ this.readyPass = new startupRoots.ReadyPass();
5968
+ this.app = app;
5969
+ /** The `onReady` hooks' pass, which `close()` waits for, less one whose `onReady` called `close()`. */ this.readyPass = new eventSources.ReadyPass();
5001
5970
  this.warnedClosed = false;
5002
5971
  /** The `@Once` handlers that have already handled their event, as a client forgets its once listeners. */ this.firedOnce = new Set();
5003
5972
  /** The handlers already warned about for being given fewer arguments than they take, so each is named once. */ this.warnedArgumentCounts = new Set();
5973
+ /** The `@On` and `@Once` handlers already warned about for running other than as an event, so each is named once. */ this.warnedEventRuns = new Set();
5974
+ /** The autocomplete mismatches already warned about without strict mocks, so each handler and route warns once. */ this.warnedAutocompleteRoutes = new Set();
5004
5975
  /** The calls under way, each removed once it settles: what `close()` lets finish before the cooldown store stops. */ this.calls = new Set();
5005
5976
  }
5006
5977
  }
@@ -5010,6 +5981,10 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5010
5981
  throw new AggregateError(failures.map(({ error })=>error), `${failures.length} ${hook} hooks threw: ${failures.map(({ name })=>name).join(', ')}.`);
5011
5982
  }
5012
5983
  }
5984
+ /** A method's `@On` or `@Once` handler, when no route also reaches it: a route's input decides how that one runs. */ function eventHandlerOf(controller, methodName) {
5985
+ if (controller_decorator.getHandlerRoutes(controller.prototype).some((route)=>route.method === methodName)) return undefined;
5986
+ return providers.getEventHandlers(controller.prototype).find((handler)=>handler.method === methodName);
5987
+ }
5013
5988
  /** The app's `messages` options, when the testing module is given an app. */ function messagesOf(app) {
5014
5989
  return app && Reflect.getMetadata(controller_decorator.META.appOptions, app)?.messages;
5015
5990
  }
@@ -5044,7 +6019,9 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5044
6019
  /**
5045
6020
  * Replaces a provider with a test double.
5046
6021
  *
5047
- * The double needs only the members the test uses; misspelled member names are still rejected.
6022
+ * The double needs only the members the test uses; misspelled member names are still rejected. A token MeoCord binds
6023
+ * itself, such as `ShardContext` or `HandlerRegistry`, takes the double in place of MeoCord's own, so a test can stub
6024
+ * cross-shard calls.
5048
6025
  *
5049
6026
  * @param token - The provider to replace.
5050
6027
  * @example
@@ -5207,7 +6184,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5207
6184
  * @throws Error for the first startup error the checks find.
5208
6185
  */ compile() {
5209
6186
  // The bound the bot's config gives it, with the same words
5210
- const shutdownTimeout = this.options.shutdownTimeout === undefined ? undefined : startupRoots.shutdownTimeoutProblem(this.options.shutdownTimeout);
6187
+ const shutdownTimeout = this.options.shutdownTimeout === undefined ? undefined : eventSources.shutdownTimeoutProblem(this.options.shutdownTimeout);
5211
6188
  if (shutdownTimeout) throw new TypeError(`shutdownTimeout ${shutdownTimeout}.`);
5212
6189
  // With startupErrors: 'all', what the decorators kept on the classes this module runs
5213
6190
  const declared = controller_decorator.declaredErrorsOf([
@@ -5228,7 +6205,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5228
6205
  // Bound first, as in the app, so a class that injects it gets this instance
5229
6206
  const appClasses = [];
5230
6207
  const translator = ()=>container.isBound(controller_decorator.Translator) ? container.get(controller_decorator.Translator) : undefined;
5231
- container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
6208
+ container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, messagesOf(this.options.app), translator, this.options.controllers ?? []));
5232
6209
  // A testing module runs as one process, so a cross-shard call runs once, here
5233
6210
  container.bind(providers.ShardContext).toConstantValue(new providers.ShardContext(undefined, providers.shardCallHandler(container, ()=>[
5234
6211
  ...appClasses,
@@ -5241,6 +6218,17 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5241
6218
  // Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
5242
6219
  // by token, then the overrides, which win
5243
6220
  const providers$1 = providers.providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
6221
+ // The app's own providers checked as the bot checks them. A token the module binds itself above fails at its
6222
+ // first call, so it is refused; another the bot binds before its providers is refused under strict mocks, and
6223
+ // runs here in default mode with a warning
6224
+ const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
6225
+ const botBinds = providers.boundBeforeProviders(i18n !== undefined);
6226
+ for (const token of providers$1.keys()){
6227
+ const refusal = providers.appProviderRefusal(`${this.options.app?.name}`, token, (own)=>botBinds.includes(own), new controller_decorator.Logger('TestingModule'));
6228
+ if (!refusal) continue;
6229
+ if (container.isBound(token) || strictMocks()) controller_decorator.startupError(refusal);
6230
+ else controller_decorator.warnOnce(new controller_decorator.Logger('TestingModule'), `${refusal.message} The testing module runs it; under useStrictMocks() compile() refuses it, as the bot does.`);
6231
+ }
5244
6232
  for (const [token, provider] of providers.providerMap(this.options.providers ?? [], "the testing module's providers"))providers$1.set(token, provider);
5245
6233
  for (const [token, override] of this.overrides)providers$1.set(token, override);
5246
6234
  // A stage override stands in for its class wherever the class is bound. A stage also provided takes the stub in
@@ -5296,9 +6284,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5296
6284
  // The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
5297
6285
  const themeResolver = controller_decorator.themeResolverClass(stages?.themeFor?.resolvers);
5298
6286
  // Every class the module runs, as the app finds them, except stage classes an override stands in for
5299
- const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
5300
6287
  const appPresenterClass = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter;
5301
- const startup = startupRoots.startupClasses({
6288
+ const startup = eventSources.startupClasses({
5302
6289
  controllers: (this.options.controllers ?? []).filter(controller_decorator.isConstructor),
5303
6290
  services,
5304
6291
  providers: providers$1,
@@ -5314,7 +6301,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5314
6301
  ]
5315
6302
  });
5316
6303
  const hasTranslator = i18n !== undefined || providers$1.has(controller_decorator.Translator);
5317
- startupRoots.assertStartupClasses(startup, {
6304
+ eventSources.assertStartupClasses(startup, {
5318
6305
  translator: hasTranslator
5319
6306
  });
5320
6307
  // Those of the stages, guards and other classes they reach
@@ -5323,7 +6310,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5323
6310
  providers.markStartupChecked(container, [
5324
6311
  ...startup.classes,
5325
6312
  ...providers.meocordClasses()
5326
- ], startupRoots.lateGuardCheck(providers$1, hasTranslator));
6313
+ ], eventSources.lateGuardCheck(providers$1, hasTranslator));
5327
6314
  // The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
5328
6315
  const needClient = startup.classes.filter((cls)=>providers.injectedTokens(cls).includes(discord_js.Client));
5329
6316
  if (needClient.length > 0 && !providers$1.has(discord_js.Client)) {
@@ -5350,8 +6337,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5350
6337
  container.bind(cls).toSelf().inSingletonScope();
5351
6338
  // By constructor type or @inject token, as the app binds them
5352
6339
  for (const dep of providers.injectedTokens(cls)){
5353
- if (dep === controller_decorator.Translator && !container.isBound(controller_decorator.Translator)) controller_decorator.startupError(controller_decorator.missingTranslatorError(cls));
5354
- else if (providers.isAppClassToken(dep)) bindClass(dep);
6340
+ if (providers.isAppClassToken(dep)) bindClass(dep);
5355
6341
  }
5356
6342
  };
5357
6343
  // The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
@@ -5365,8 +6351,12 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5365
6351
  }
5366
6352
  providers.bindsOwnToken(container, controller_decorator.CooldownStore, store);
5367
6353
  }
5368
- // Before the controllers, so a class token that is provided is not also bound as itself
5369
- for (const provider of providers$1.values())providers.bindProvider(container, provider, bindClass);
6354
+ // Before the controllers, so a class token that is provided is not also bound as itself. A test's provider of a
6355
+ // token the module bound above, as HandlerRegistry or ShardContext, stands in for that binding
6356
+ for (const provider of providers$1.values()){
6357
+ if (container.isBound(provider.provide)) container.unbind(provider.provide);
6358
+ providers.bindProvider(container, provider, bindClass);
6359
+ }
5370
6360
  for (const ctrl of this.options.controllers ?? [])bindClass(ctrl);
5371
6361
  for (const service of services)bindClass(service);
5372
6362
  if (themeResolver) bindClass(themeResolver);
@@ -5394,13 +6384,13 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5394
6384
  // As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
5395
6385
  providers.buildMessageRoutes(this.options.controllers ?? [], messages);
5396
6386
  // As the app does when it is created, so a test sees the refusal the bot would give
5397
- startupRoots.assertDistinctCommands(this.options.controllers ?? []);
6387
+ eventSources.assertDistinctCommands(this.options.controllers ?? []);
5398
6388
  if (this.options.app) providers.prepareAppPresenter(container, this.options.app);
5399
6389
  providers.bindObservers(container, observers);
5400
6390
  // The store calls ask: the test's own when it provides CooldownStore, else the app's
5401
6391
  const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
5402
6392
  // What the stages and presenter inject, as the bot makes it; a stage the test replaced contributes nothing
5403
- const stageServices = startupRoots.stageDependencies(startup, providers$1, appPresenterClass);
6393
+ const stageServices = eventSources.stageDependencies(startup, providers$1, appPresenterClass);
5404
6394
  for (const service of stageServices)bindClass(service);
5405
6395
  // The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
5406
6396
  // after what it injects
@@ -5424,39 +6414,34 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5424
6414
  cooldownStore: true
5425
6415
  }
5426
6416
  }));
6417
+ // Recorded as the container makes each one, before anything can be made, so close() shuts down exactly what exists
6418
+ const constructed = new Set();
6419
+ for (const { token } of lifecycle){
6420
+ container.onActivation(token, (_context, instance)=>{
6421
+ constructed.add(token);
6422
+ return instance;
6423
+ });
6424
+ }
5427
6425
  // Every check has run, and touched only the container: from here the module is made, once none found an error
5428
6426
  controller_decorator.stopOnStartupErrors();
5429
6427
  // Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
5430
- for (const cls of [
5431
- ...this.options.controllers ?? [],
5432
- ...startup.classes
5433
- ])Reflect.defineMetadata(controller_decorator.META.container, container, cls);
5434
- // And each instance with the module that made it, as another module made from the same classes stamps them again
5435
- providers.recordInstanceContainers(container, [
6428
+ providers.prepareDirectCalls(container, [
5436
6429
  ...this.options.controllers ?? [],
5437
6430
  ...startup.classes
5438
6431
  ]);
5439
6432
  providers.prepareThemes(container, appClasses);
5440
6433
  // A handler with no builder is how a fixture is written, so only what is always a mistake is named
5441
- startupRoots.warnUnregisteredCommands(this.options.controllers ?? [], {
6434
+ eventSources.warnUnregisteredCommands(this.options.controllers ?? [], {
5442
6435
  missingBuilders: false
5443
6436
  });
5444
- startupRoots.warnInheritedRoutes(this.options.controllers ?? []);
5445
- startupRoots.warnOverlappingPatterns(this.options.controllers ?? []);
5446
- startupRoots.warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
5447
- if (this.options.app) providers.bindAppPresenter(container, this.options.app);
5448
- // Recorded as the container makes each one, so close() shuts down exactly what exists
5449
- const constructed = new Set();
5450
- for (const { token } of lifecycle){
5451
- container.onActivation(token, (_context, instance)=>{
5452
- constructed.add(token);
5453
- return instance;
5454
- });
5455
- }
6437
+ eventSources.warnInheritedRoutes(this.options.controllers ?? []);
6438
+ eventSources.warnOverlappingPatterns(this.options.controllers ?? []);
6439
+ eventSources.warnHandlersOffControllers(this.options.controllers ?? [], startup.classes);
6440
+ for (const warning of eventSources.perCallEventWarnings(startup, providers$1, appPresenterClass))new controller_decorator.Logger('TestingModule').warn(warning);
5456
6441
  const warnUnanswered = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.warnUnanswered;
5457
6442
  return new TestingModule(container, [
5458
6443
  ...this.options.controllers ?? []
5459
- ], appClasses, providers$1, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? startupRoots.DEFAULT_SHUTDOWN_TIMEOUT_MS);
6444
+ ], appClasses, providers$1, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? eventSources.DEFAULT_SHUTDOWN_TIMEOUT_MS, this.options.app);
5460
6445
  });
5461
6446
  } catch (error) {
5462
6447
  if (controller_decorator.isRefusal(error) && !controller_decorator.isExplainedError(error)) {
@@ -5576,6 +6561,11 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5576
6561
  * expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: '1 notes' })
5577
6562
  * ```
5578
6563
  */ static fromApp(app, options = {}) {
6564
+ // An app that isn't a class at all, as a circular import gives, has no metadata to read
6565
+ if (typeof app !== 'function') {
6566
+ const { name, hint } = notAClass(app);
6567
+ throw new TypeError(`${name} is not a @MeoCord app: fromApp takes the class @MeoCord decorates.${hint}`);
6568
+ }
5579
6569
  const appOptions = Reflect.getMetadata(controller_decorator.META.appOptions, app);
5580
6570
  if (!appOptions) {
5581
6571
  // A refused @MeoCord stores no options, so its own errors, kept with startupErrors: 'all', say why
@@ -5629,227 +6619,33 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
5629
6619
  controller_decorator.setStartupErrorsMode('all');
5630
6620
  }
5631
6621
 
5632
- /** The options `@MeoCord` declares on an application class. */ function appOptionsOf(app) {
5633
- const options = Reflect.getMetadata(controller_decorator.META.appOptions, app);
5634
- if (!options) throw new TypeError(`${app.name || 'The given class'} is not decorated with @MeoCord().`);
5635
- return options;
5636
- }
5637
- /** The controllers `@MeoCord({ controllers })` registers on an application class. */ function controllersOf(app) {
5638
- return appOptionsOf(app).controllers ?? [];
5639
- }
5640
- /**
5641
- * Resolves which handler a component's `customId` or a message's content reaches, as dispatch routes it.
5642
- *
5643
- * Use it to check routing alone, in a plain unit test: it reads decorator metadata, with no client, config or
5644
- * container, and runs no guard. To run the handler it reaches, use {@link TestingModule.dispatch}.
5645
- *
5646
- * @remarks
5647
- * It routes across every controller the app registers, most specific pattern first: a component within its type, a
5648
- * message after the app's prefix, and never to a `@MessageHandler()` listener, which runs for every message.
5649
- *
5650
- * @param app - The application class decorated with `@MeoCord`.
5651
- * @param input - The component type and its `customId`, or the message's content with its prefix and the bot's id.
5652
- * @returns The handler that runs, with the params it captures, or `undefined` when no route handles the input.
5653
- * @throws TypeError for a message to an app whose prefix is a function, when no `prefix` is given.
5654
- *
5655
- * @example
5656
- * ```ts
5657
- * import { expect } from 'vitest'
5658
- *
5659
- * @Controller()
5660
- * class ProfileController {
5661
- * @Command('profile/{uid}', CommandType.BUTTON)
5662
- * async show(interaction: ButtonInteraction, { uid }: { uid: string }) {
5663
- * await respond(interaction).send(`Profile ${uid}`)
5664
- * }
5665
- * }
5666
- * @MeoCord({ controllers: [ProfileController], clientOptions: { intents: [] } })
5667
- * class App {}
5668
- * const route = resolveRoute(App, { type: CommandType.BUTTON, customId: 'profile/8000' })
5669
- * expect([route?.handler, route?.params]).toEqual([ProfileController.prototype.show, { uid: '8000' }])
5670
- * ```
5671
- *
5672
- * @group Testing
5673
- * @category Inspection
5674
- * @see {@link findRouteConflicts}
5675
- * @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
5676
- */ function resolveRoute(app, input) {
5677
- if ('content' in input) {
5678
- const { messages = {} } = appOptionsOf(app);
5679
- const starts = providers.staticMessageStarts(app, messages, input);
5680
- const matched = providers.matchMessageRoute(providers.buildMessageRoutes(controllersOf(app), messages), input.content, starts);
5681
- if (!matched) return undefined;
5682
- const { route, params } = matched;
5683
- // Dispatch answers a route that does not work in a DM with its usage, and never runs it
5684
- if (starts.inGuild === false && (!providers.fitsScope(route.scope, false) || providers.needsGuild(route))) return undefined;
5685
- return {
5686
- controller: route.controllerClass,
5687
- method: route.method,
5688
- handler: route.controllerClass.prototype[route.method],
5689
- params
5690
- };
5691
- }
5692
- if (!controller_decorator.isCustomIdRouted(input.type)) {
5693
- throw new TypeError(`${input.type} commands are routed by name, not by customId.`);
5694
- }
5695
- const routes = startupRoots.buildComponentRoutes(controllersOf(app));
5696
- const matched = startupRoots.matchComponentRoute(routes, (type)=>type === input.type, input.customId);
5697
- if (!matched) return undefined;
5698
- const { route, params, text } = matched;
5699
- const method = route.meta.methodName;
5700
- const typed = Object.keys(route.types).length > 0;
5701
- // The routes ranked behind it that the customId matches too, which dispatch never reaches for it
5702
- const behind = routes.slice(routes.indexOf(route) + 1);
5703
- const alsoMatches = behind.filter((other)=>other.meta.type === input.type && startupRoots.readCustomId(other.pattern, other.meta.regex, input.customId)).map((other)=>other.pattern);
5704
- const resolved = {
5705
- controller: route.controllerClass,
5706
- method,
5707
- handler: route.controllerClass.prototype[method],
5708
- params: text,
5709
- ...typed && {
5710
- values: params
5711
- }
5712
- };
5713
- return alsoMatches.length > 0 ? readableOnly(resolved, {
5714
- alsoMatches
5715
- }) : resolved;
5716
- }
5717
- /**
5718
- * Finds component patterns that rank equally and can match the same `customId`, which MeoCord otherwise only warns
5719
- * about at startup.
5720
- *
5721
- * Use it in a test to keep the warning from reaching production. Patterns rank segment by segment, a literal before a
5722
- * param and then the narrower type, so two collide only with the same literals and equally narrow params at each
5723
- * position, such as `{action:close|reopen}` and `{step:close|confirm}`, which both take `close`; the one listed
5724
- * first runs. Patterns are compared within a component type.
5725
- *
5726
- * @param app - The application class decorated with `@MeoCord`.
5727
- * @returns Each pair of patterns that rank equally and can match the same `customId`, with its component type.
5728
- *
5729
- * @example
5730
- * ```ts
5731
- * import { expect } from 'vitest'
5732
- *
5733
- * @Controller()
5734
- * class TicketController {
5735
- * @Command('ticket/{action:close|reopen}', CommandType.BUTTON)
5736
- * async act(interaction: ButtonInteraction) { await respond(interaction).send('Done.') }
5737
- * @Command('ticket/{step:close|confirm}', CommandType.BUTTON)
5738
- * async step(interaction: ButtonInteraction) { await respond(interaction).send('Next.') }
5739
- * }
5740
- * @MeoCord({ controllers: [TicketController], clientOptions: { intents: [] } })
5741
- * class App {}
5742
- * expect(findRouteConflicts(App)).toHaveLength(1)
5743
- * ```
5744
- *
5745
- * @group Testing
5746
- * @category Inspection
5747
- * @see {@link resolveRoute}
5748
- */ function findRouteConflicts(app) {
5749
- return startupRoots.findComponentRouteConflicts(startupRoots.buildComponentRoutes(controllersOf(app))).map(({ type, patterns, runs })=>readableOnly({
5750
- type: type,
5751
- patterns
5752
- }, {
5753
- runs
5754
- }));
5755
- }
5756
- /**
5757
- * `target` with `fields` added as properties a test reads but `toEqual` and its kin, which compare enumerable keys,
5758
- * don't see, so an assertion written for the fields it had before still holds.
5759
- */ function readableOnly(target, fields) {
5760
- for (const [key, value] of Object.entries(fields))Object.defineProperty(target, key, {
5761
- value,
5762
- enumerable: false,
5763
- configurable: true
5764
- });
5765
- return target;
5766
- }
5767
-
5768
6622
  /**
5769
- * Builds the `fields` of a submitted form, as discord.js does when a user submits one.
5770
- *
5771
- * Use it for a mock `ModalSubmitInteraction`: discord.js keeps the `ModalSubmitFields` constructor private, so a test
5772
- * cannot build one directly.
5773
- *
5774
- * @param values - Each field's value, keyed by its customId: a string for a text input, an array of strings for a
5775
- * select's chosen values, or an array of `Attachment`s for a file upload. An empty array is a select with nothing
5776
- * chosen.
5777
- * @returns Fields that `getTextInputValue`, `getStringSelectValues`, `getUploadedFiles` and a handler's params all
5778
- * read.
5779
- *
5780
- * @example
5781
- * ```ts
5782
- * import { expect } from 'vitest'
5783
- *
5784
- * @Controller()
5785
- * class FeedbackController {
5786
- * @Command('feedback/{topic}', CommandType.MODAL_SUBMIT)
5787
- * async submit(interaction: ModalSubmitInteraction, { topic, body }: { topic: string; body: string }) {
5788
- * await respond(interaction).send(`Thanks: ${topic}, ${body}`)
5789
- * }
5790
- * }
5791
- * const module = MeoCordTestingModule.create({ controllers: [FeedbackController] }).compile()
5792
- * const fields = createModalFields({ body: 'It crashed' })
5793
- * const interaction = createMockInteraction(ModalSubmitInteraction, { customId: 'feedback/bugs', fields })
5794
- * await module.invoke(FeedbackController, 'submit', interaction)
5795
- * expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: 'Thanks: bugs, It crashed' })
5796
- * ```
5797
- *
5798
- * @group Testing
5799
- * @category Mocks
5800
- * @see {@link createMockInteraction}
5801
- */ function createModalFields(values) {
5802
- const component = (customId, value)=>{
5803
- if (!Array.isArray(value)) return {
5804
- type: discord_js.ComponentType.TextInput,
5805
- customId,
5806
- value
5807
- };
5808
- if (!value.some((item)=>item instanceof discord_js.Attachment)) return {
5809
- type: discord_js.ComponentType.StringSelect,
5810
- customId,
5811
- values: value
5812
- };
5813
- const attachments = new discord_js.Collection(value.map((file)=>[
5814
- file.id,
5815
- file
5816
- ]));
5817
- return {
5818
- type: discord_js.ComponentType.FileUpload,
5819
- customId,
5820
- values: [
5821
- ...attachments.keys()
5822
- ],
5823
- attachments
5824
- };
5825
- };
5826
- const components = Object.entries(values).map(([customId, value])=>({
5827
- type: discord_js.ComponentType.Label,
5828
- component: component(customId, value)
5829
- }));
5830
- const Fields = discord_js.ModalSubmitFields;
5831
- return new Fields(components);
5832
- }
5833
-
5834
- /** Every message key of a catalog, and whether it is a plural. */ function leaves(catalog, prefix = '') {
6623
+ * Every message key of a catalog, and whether it is a plural. `reference` decides whether an object is a plural or a
6624
+ * group, as the translator reads a translation by the default catalog's shapes; where it lacks a key, the object's own
6625
+ * keys decide.
6626
+ */ function leaves(catalog, prefix = '', reference = catalog) {
5835
6627
  return Object.entries(catalog).flatMap(([name, value])=>{
5836
6628
  const key = `${prefix}${name}`;
5837
- if (typeof value === 'string') return [
6629
+ // A null message is one where it stands, which the checks below read as missing
6630
+ if (typeof value === 'string' || value === null) return [
5838
6631
  {
5839
6632
  key,
5840
6633
  plural: false
5841
6634
  }
5842
6635
  ];
5843
- if (controller_decorator.isPluralShape(value)) return [
6636
+ const shape = typeof reference === 'object' && reference !== null && Object.hasOwn(reference, name) ? reference[name] : undefined;
6637
+ const plural = typeof shape === 'object' && shape !== null ? controller_decorator.isPluralShape(shape) : controller_decorator.isPluralShape(value);
6638
+ if (plural) return [
5844
6639
  {
5845
6640
  key,
5846
6641
  plural: true
5847
6642
  }
5848
6643
  ];
5849
- return leaves(value, `${key}.`);
6644
+ return leaves(value, `${key}.`, shape);
5850
6645
  });
5851
6646
  }
5852
6647
  /** 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 !== '';
6648
+ /** A message the translator reads as missing: none, `null` or `''`, as tools export one untranslated. */ const isMissing = (value)=>value === undefined || value === null || value === '';
5853
6649
  /** 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) {
5854
6650
  return key.split('.').reduce((current, part)=>typeof current === 'object' && current !== null && Object.hasOwn(current, part) ? current[part] : undefined, catalog);
5855
6651
  }
@@ -5874,11 +6670,13 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
5874
6670
  *
5875
6671
  * Use it in a test for a team that wants no fallback in production. It reports the messages a locale lacks, the
5876
6672
  * messages the default catalog doesn't have, the plural forms a language needs but a plural lacks, such as `few` for
5877
- * Russian, a translation of another shape than the default's, a text for a plural or a plural for a text, and each
5878
- * `{param}` a translation uses that the message it translates doesn't take. An empty translation, `''`, counts as
5879
- * lacking, as the translator reads it as missing. It reads the catalogs' own strings, so it checks the params of a
5880
- * catalog from a plain variable or a JSON file too, which the compiler can't. An object counts as a plural only when
5881
- * its every key is a plural category, so a group with an `other` key is a group.
6673
+ * Russian, a translation of another shape than the default's, a text for a plural or a group, or a plural for a text, and each
6674
+ * `{param}` a translation uses that the message it translates doesn't take. An empty translation, `''` or `null`, counts
6675
+ * as lacking, as the translator reads it as missing. It reads the catalogs' own strings, so it checks the params of a
6676
+ * catalog from a plain variable or a JSON file too, which the compiler can't. An object in the default catalog counts as
6677
+ * a plural only when its every key is a plural category, so a group with an `other` key is a group, and a translation
6678
+ * is read by the default catalog's shapes, as the translator reads it: one that keeps only a group's `other` message
6679
+ * lacks the group's other messages.
5882
6680
  *
5883
6681
  * @remarks
5884
6682
  * MeoCord's own texts, a catalog's `meocord` group, fall back to English by design, so only a key MeoCord lacks, and
@@ -5925,7 +6723,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
5925
6723
  const categories = new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
5926
6724
  for (const { key, plural } of reference){
5927
6725
  const message = at(catalog, key);
5928
- if (message === undefined || message === '') {
6726
+ if (isMissing(message)) {
5929
6727
  problems.push(`missing ${key}`);
5930
6728
  continue;
5931
6729
  }
@@ -5943,9 +6741,11 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
5943
6741
  for (const name of stray)problems.push(`${key} takes no {${name}}; the default is "${shown(original)}"`);
5944
6742
  }
5945
6743
  if (options.meocord && !locale.startsWith('en-')) {
5946
- for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined || at(catalog, key) === '') problems.push(`missing ${key}`);
6744
+ for (const key of MEOCORD_KEYS)if (isMissing(at(catalog, key))) problems.push(`missing ${key}`);
5947
6745
  }
5948
- for (const { key } of leaves(catalog)){
6746
+ for (const { key } of leaves(catalog, '', defaultCatalog)){
6747
+ // A message left out, as a null one is, that the default has is reported missing above, and is nothing extra
6748
+ if (isMissing(at(catalog, key)) && at(defaultCatalog, key) !== undefined) continue;
5949
6749
  if (isMeoCordKey(key)) {
5950
6750
  if (!MEOCORD_KEYS.has(key)) problems.push(`${key} is not one of MeoCord's texts`);
5951
6751
  else {
@@ -5953,7 +6753,11 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
5953
6753
  const stray = strayParams(at(catalog, key), english);
5954
6754
  for (const name of stray)problems.push(`${key} takes no {${name}}: MeoCord's English is "${shown(english)}"`);
5955
6755
  }
5956
- } else if (!referenceKeys.has(key)) problems.push(`${key} is not in the default catalog`);
6756
+ } else if (!referenceKeys.has(key)) {
6757
+ // A text where the default has a group: the key is the default's, in another shape
6758
+ const original = at(defaultCatalog, key);
6759
+ problems.push(typeof original === 'object' && original !== null ? `${key} should be a group, as the default is` : `${key} is not in the default catalog`);
6760
+ }
5957
6761
  }
5958
6762
  if (problems.length > 0) gaps.push(` ${locale}: ${problems.join('; ')}`);
5959
6763
  }
@@ -6367,10 +7171,10 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
6367
7171
  });
6368
7172
  }
6369
7173
  /** Whether a store overrides `consumeMany`, as a store that counts a batch at once does. */ function overridesConsumeMany(store) {
6370
- return store.consumeMany !== controller_decorator.CooldownStore.prototype.consumeMany;
7174
+ return !controller_decorator.isDefaultBatchMethod(store.consumeMany);
6371
7175
  }
6372
7176
  /** Whether a store overrides `peekMany`, as a store that can check a batch without recording it does. */ function overridesPeekMany(store) {
6373
- return store.peekMany !== controller_decorator.CooldownStore.prototype.peekMany;
7177
+ return !controller_decorator.isDefaultBatchMethod(store.peekMany);
6374
7178
  }
6375
7179
 
6376
7180
  /**
@@ -6427,8 +7231,10 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
6427
7231
  * Reports what runs when a handler is dispatched, and the metadata declared on it, without running anything.
6428
7232
  *
6429
7233
  * Use it to check that decorators set a handler up as intended: its guards with their params, its interceptors,
6430
- * filters and cooldowns, in the order dispatch applies them. To check what a handler does, run it with
6431
- * {@link TestingModule.invoke}.
7234
+ * filters and cooldowns, in the order dispatch applies them. Under `useStrictMocks()`, the guards, interceptors and
7235
+ * observers are those dispatch runs for the handler's context types: a stage declared with `types` is listed only for
7236
+ * a handler of one of them, and an autocomplete lists no interceptors. Default mode lists every one, with a warning
7237
+ * once where dispatch runs fewer. To check what a handler does, run it with {@link TestingModule.invoke}.
6432
7238
  *
6433
7239
  * @param controller - The controller class declaring the handler.
6434
7240
  * @param methodName - The handler method's name.
@@ -6464,14 +7270,20 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
6464
7270
  });
6465
7271
  const globals = options.app ? providers.appStages(options.app) : undefined;
6466
7272
  const { guards, interceptors, filters } = providers.handlerStages(controller.prototype, methodName, globals);
7273
+ const observers = options.app ? providers.appObservers(options.app) : [];
7274
+ const stages = runStages(controller.prototype, methodName, {
7275
+ guards,
7276
+ interceptors,
7277
+ observers
7278
+ });
6467
7279
  const inspection = {
6468
7280
  controller,
6469
7281
  methodName,
6470
7282
  guards: Object.freeze([
6471
- ...guards
7283
+ ...stages.guards
6472
7284
  ]),
6473
7285
  interceptors: Object.freeze([
6474
- ...interceptors
7286
+ ...stages.interceptors
6475
7287
  ]),
6476
7288
  filters: Object.freeze(filters.flat()),
6477
7289
  cooldowns: Object.freeze(providers.handlerCooldowns(controller.prototype, methodName).map(({ seconds, uses, per, bypass, by })=>Object.freeze({
@@ -6482,31 +7294,99 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
6482
7294
  by: by !== undefined
6483
7295
  }))),
6484
7296
  pattern: controller_decorator.getMessageHandlers(controller.prototype).find((handler)=>handler.method === methodName)?.pattern,
6485
- observers: Object.freeze(options.app ? providers.appObservers(options.app) : []),
7297
+ observers: Object.freeze([
7298
+ ...stages.observers
7299
+ ]),
6486
7300
  get: (metadata)=>context.get(metadata),
6487
7301
  getAll: (metadata)=>context.getAll(metadata)
6488
7302
  };
6489
7303
  // Read by a test but not compared by toEqual, so an assertion on the result's other fields holds
7304
+ const routes = Object.freeze(inheritedRoutesOf(controller.prototype, methodName, false));
7305
+ const withEvents = Object.freeze(inheritedRoutesOf(controller.prototype, methodName, true));
6490
7306
  Object.defineProperty(inspection, 'inheritedRoutes', {
6491
- value: Object.freeze(inheritedRoutesOf(controller.prototype, methodName)),
7307
+ get: ()=>{
7308
+ if (strictMocks()) return withEvents;
7309
+ if (withEvents.length > routes.length) {
7310
+ controller_decorator.warnOnce(new controller_decorator.Logger('inspectHandler'), `inspectHandler() here leaves the @On and @Once events ${controller.name}.${methodName} inherits out of ` + 'inheritedRoutes, though the bot answers them; under useStrictMocks() it lists them.');
7311
+ }
7312
+ return routes;
7313
+ },
6492
7314
  enumerable: false
6493
7315
  });
6494
7316
  return inspection;
6495
7317
  }
6496
- /** The routes `methodName` answers that the class declaring it last doesn't declare for it: those it inherits. */ function inheritedRoutesOf(prototype, methodName) {
7318
+ /** The context types dispatch calls `methodName` with: one for each kind of route it answers. */ function handlerContextTypes(prototype, methodName) {
7319
+ const answers = [
7320
+ [
7321
+ 'interaction',
7322
+ Object.values(controller_decorator.getCommandMap(prototype) ?? {}).some((metas)=>metas.some((meta)=>meta.methodName === methodName))
7323
+ ],
7324
+ [
7325
+ 'autocomplete',
7326
+ controller_decorator.getAutocompleteHandlers(prototype).some((handler)=>handler.methodName === methodName)
7327
+ ],
7328
+ [
7329
+ 'message',
7330
+ controller_decorator.getMessageHandlers(prototype).some((handler)=>handler.method === methodName)
7331
+ ],
7332
+ [
7333
+ 'reaction',
7334
+ controller_decorator.getReactionHandlers(prototype).some((handler)=>handler.method === methodName)
7335
+ ],
7336
+ [
7337
+ 'event',
7338
+ providers.getEventHandlers(prototype).some((handler)=>handler.method === methodName)
7339
+ ]
7340
+ ];
7341
+ return answers.filter(([, answered])=>answered).map(([type])=>type);
7342
+ }
7343
+ /**
7344
+ * The guards, interceptors and observers dispatch runs for the handler, by the same `appliesTo` it uses: those for one
7345
+ * of its context types, and no interceptors for an autocomplete. Default mode lists every one, and warns once where
7346
+ * that lists more; a method no route reaches lists every one in both modes.
7347
+ */ function runStages(prototype, methodName, all) {
7348
+ const types = handlerContextTypes(prototype, methodName);
7349
+ if (types.length === 0) return all;
7350
+ const forTypes = (entries, among)=>entries.filter((entry)=>among.some((type)=>providers.appliesTo(entry, type)));
7351
+ const run = {
7352
+ ...all,
7353
+ guards: forTypes(all.guards, types),
7354
+ // Autocomplete answers within three seconds and has no reply to shape, so it runs no interceptors
7355
+ interceptors: forTypes(all.interceptors, types.filter((type)=>type !== 'autocomplete')),
7356
+ observers: forTypes(all.observers, types)
7357
+ };
7358
+ if (strictMocks()) return run;
7359
+ const handlerName = `${prototype.constructor.name}.${methodName}`;
7360
+ const count = (stages)=>stages.guards.length + stages.interceptors.length + stages.observers.length;
7361
+ if (count(run) < count(all)) {
7362
+ controller_decorator.warnOnce(new controller_decorator.Logger('inspectHandler'), `inspectHandler() here lists the guards, interceptors and observers of every context type for ${handlerName}, ` + "where dispatch runs only those of its own; under useStrictMocks() it lists the ones dispatch runs.");
7363
+ }
7364
+ return all;
7365
+ }
7366
+ /**
7367
+ * The routes `methodName` answers that the class declaring it last doesn't declare for it: those it inherits, its
7368
+ * `@On` and `@Once` events among them when `events` is set.
7369
+ */ function inheritedRoutesOf(prototype, methodName, events) {
7370
+ const labels = (routes)=>routes.filter((route)=>route.method === methodName).map((route)=>route.label);
6497
7371
  const answered = [
6498
- ...new Set(controller_decorator.getHandlerRoutes(prototype).filter((route)=>route.method === methodName).map((route)=>route.label))
7372
+ ...new Set(labels([
7373
+ ...controller_decorator.getHandlerRoutes(prototype),
7374
+ ...events ? providers.getEventHandlers(prototype).map(controller_decorator.eventRoute) : []
7375
+ ]))
6499
7376
  ];
6500
7377
  // The nearest class with its own decorators on the method; a class that only overrides it declares none
6501
7378
  for(let at = prototype; at && at !== Object.prototype; at = Object.getPrototypeOf(at)){
6502
- const own = controller_decorator.getDeclaredRoutes(at).filter((route)=>route.method === methodName).map((route)=>route.label);
7379
+ const own = labels([
7380
+ ...controller_decorator.getDeclaredRoutes(at),
7381
+ ...events ? controller_decorator.ownEventHandlers(at).map(controller_decorator.eventRoute) : []
7382
+ ]);
6503
7383
  if (own.length > 0) return at === prototype ? answered.filter((label)=>!own.includes(label)) : answered;
6504
7384
  }
6505
7385
  return [];
6506
7386
  }
6507
7387
 
6508
- // Themes createMockTheme made, which withTheme uses as they are
6509
- const mocks = new WeakSet();
7388
+ // Themes createMockTheme made through either build of this version, which withTheme uses as they are
7389
+ const mocks = controller_decorator.shared('mockThemes', ()=>new WeakSet());
6510
7390
  function mockTheme(overrides, where) {
6511
7391
  if (overrides === undefined) return controller_decorator.defaultTheme();
6512
7392
  controller_decorator.assertValidTheme(overrides, where);