f0952096f3
Adds the missing inbound handlers for the Discord wizard that the
previous commit (b81d865) left out. Three thin NetCord module classes
share one WizardInteractionDispatcher:
- DiscordWizardButtonModule
- DiscordWizardStringMenuModule
- DiscordWizardModalModule
Each registers a single [ComponentInteraction("wizard")] method that
hands the args string to the dispatcher. The dispatcher parses the
custom-id tail (btn:choice:<step>:<value>, btn:back, btn:cancel,
btn:create, btn:resume:continue, btn:resume:restart, select:<step>,
modal:<step>), looks up the active draft by (platform="Discord",
ownerId=userId), and routes through the shared
GameCreationWizard.HandleInteractionAsync. The "create" callback
delegates to DiscordWizardSubmitter.SubmitAsync (3-retry finalize).
Program.cs gets 4 new singleton registrations (the dispatcher plus
the three module classes) and 2 new AddComponentInteractions calls
(StringMenu + Modal). The existing Button registration is unchanged.
12 new source-level smoke tests in DiscordWizardInteractionModuleSourceTests
cover the file shape: 3 handler classes, 3 base classes, 3
[ComponentInteraction] registrations, all 5 callback kinds parsed,
GameCreationWizard wired in, submitter invoked on create, Program.cs
registers all 3 AddComponentInteractions and all 4 module classes,
draft lookup by GetActiveAsync("Discord", ...), modal walks
Components[0] -> TextInput -> .Value, string menu reads
SelectedValues[0].
Build green. 190/190 Discord+Wizard tests pass (2 pre-existing
skipped). dotnet format clean.
193 lines
9.1 KiB
C#
193 lines
9.1 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
|
|
namespace GmRelay.Bot.Tests.Discord;
|
|
|
|
/// <summary>
|
|
/// Source-level structural smoke tests for the Discord wizard
|
|
/// interaction module. The NetCord component-interaction service
|
|
/// uses dispatch-by-attribute (no public registry), so a runtime
|
|
/// instantiation test would need to spin up the full NetCord host —
|
|
/// overkill for a smoke gate. Instead we assert on the source shape
|
|
/// (custom-id formats, handler method signatures, dispatcher wiring)
|
|
/// so the existing wizard tests catch regressions in the platform-
|
|
/// neutral state machine while this file catches regressions in the
|
|
/// Discord adapter shell.
|
|
/// </summary>
|
|
public sealed class DiscordWizardInteractionModuleSourceTests
|
|
{
|
|
private static string GetRepoRoot()
|
|
{
|
|
var dir = AppContext.BaseDirectory;
|
|
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
|
{
|
|
dir = Directory.GetParent(dir)?.FullName;
|
|
}
|
|
|
|
return dir ?? throw new InvalidOperationException("Could not find repo root");
|
|
}
|
|
|
|
private static string ReadSource(string relativePath)
|
|
{
|
|
var repoRoot = GetRepoRoot();
|
|
var fullPath = Path.Combine(repoRoot, relativePath);
|
|
Assert.True(File.Exists(fullPath), $"Source file {relativePath} should exist.");
|
|
return File.ReadAllText(fullPath);
|
|
}
|
|
|
|
[Fact]
|
|
public void Module_ShouldExist()
|
|
{
|
|
var path = "src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs";
|
|
var source = ReadSource(path);
|
|
Assert.Contains("public sealed class DiscordWizardButtonModule", source, StringComparison.Ordinal);
|
|
Assert.Contains("public sealed class DiscordWizardStringMenuModule", source, StringComparison.Ordinal);
|
|
Assert.Contains("public sealed class DiscordWizardModalModule", source, StringComparison.Ordinal);
|
|
Assert.Contains("public sealed class WizardInteractionDispatcher", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Modules_ShouldBeDerivedFromComponentInteractionModule()
|
|
{
|
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
|
// The dispatching modules are thin shells that inherit from
|
|
// NetCord's ComponentInteractionModule<TContext> for each
|
|
// supported component type.
|
|
Assert.Contains("ComponentInteractionModule<ButtonInteractionContext>", source, StringComparison.Ordinal);
|
|
Assert.Contains("ComponentInteractionModule<StringMenuInteractionContext>", source, StringComparison.Ordinal);
|
|
Assert.Contains("ComponentInteractionModule<ModalInteractionContext>", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Modules_ShouldRegisterWizardComponentInteraction()
|
|
{
|
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
|
// All three modules use the [ComponentInteraction("wizard")]
|
|
// prefix registration; the args string carries the rest of
|
|
// the custom-id (e.g. "btn:choice:Type:single" or
|
|
// "select:Visibility" or "modal:Title").
|
|
var count = CountOccurrences(source, "[ComponentInteraction(\"wizard\")]");
|
|
Assert.Equal(3, count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispatcher_ShouldHandleButtonSelectAndModal()
|
|
{
|
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
|
Assert.Contains("public async Task HandleButtonAsync", source, StringComparison.Ordinal);
|
|
Assert.Contains("public async Task HandleStringMenuAsync", source, StringComparison.Ordinal);
|
|
Assert.Contains("public async Task HandleModalAsync", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispatcher_ShouldParseAllWizardActionKinds()
|
|
{
|
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
|
// The button handler must dispatch on the five action kinds
|
|
// the wizard's callback data format emits: choice, back,
|
|
// cancel, create. The resume flow is a wizard-internal control
|
|
// emitted by the slash command's "Continue / Start over" row.
|
|
Assert.Contains("\"choice\"", source, StringComparison.Ordinal);
|
|
Assert.Contains("\"back\"", source, StringComparison.Ordinal);
|
|
Assert.Contains("\"cancel\"", source, StringComparison.Ordinal);
|
|
Assert.Contains("\"create\"", source, StringComparison.Ordinal);
|
|
Assert.Contains("\"resume\"", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispatcher_ShouldWireWizardStateMachine()
|
|
{
|
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
|
// The dispatcher must call the shared GameCreationWizard's
|
|
// HandleInteractionAsync, which is the same entry point the
|
|
// Telegram bot uses. This is the core invariant of the
|
|
// platform-neutral refactor.
|
|
Assert.Contains("GameCreationWizard", source, StringComparison.Ordinal);
|
|
Assert.Contains("HandleInteractionAsync", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispatcher_ShouldInvokeSubmitterOnCreate()
|
|
{
|
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
|
// The "create" callback must delegate to DiscordWizardSubmitter
|
|
// (the 3-retry finalize loop) rather than call the wizard's
|
|
// render path.
|
|
Assert.Contains("SubmitAsync", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Program_ShouldRegisterAllThreeComponentServices()
|
|
{
|
|
var source = ReadSource("src/GmRelay.DiscordBot/Program.cs");
|
|
// NetCord requires AddComponentInteractions<TInteraction, TContext>
|
|
// per supported interaction type. The wizard needs all three:
|
|
// buttons, StringSelectMenus, and modal submits.
|
|
Assert.Contains("AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>", source, StringComparison.Ordinal);
|
|
Assert.Contains("AddComponentInteractions<StringMenuInteraction, StringMenuInteractionContext>", source, StringComparison.Ordinal);
|
|
Assert.Contains("AddComponentInteractions<ModalInteraction, ModalInteractionContext>", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Program_ShouldRegisterWizardModuleClasses()
|
|
{
|
|
var source = ReadSource("src/GmRelay.DiscordBot/Program.cs");
|
|
// The wizard module classes have constructor dependencies
|
|
// (the dispatcher + shared services) that DI must resolve.
|
|
// AddComponentInteractions only registers the IComponentInteractionService,
|
|
// not the module classes themselves.
|
|
Assert.Contains("WizardInteractionDispatcher", source, StringComparison.Ordinal);
|
|
Assert.Contains("DiscordWizardButtonModule", source, StringComparison.Ordinal);
|
|
Assert.Contains("DiscordWizardStringMenuModule", source, StringComparison.Ordinal);
|
|
Assert.Contains("DiscordWizardModalModule", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispatcher_ShouldLookupDraftByOwner()
|
|
{
|
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
|
// All three handlers must look up the active draft by
|
|
// (platform="Discord", ownerId=userId) — the wizard's
|
|
// invariant is "one active draft per owner", not
|
|
// "draft-id-in-custom-id".
|
|
Assert.Contains("GetActiveAsync(\"Discord\"", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void ModalHandler_ShouldExtractTextFromLabel()
|
|
{
|
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
|
// The wizard's modals wrap a single TextInput in a Label. The
|
|
// handler must walk Components[0] (Label) → .Component (TextInput)
|
|
// → .Value to retrieve the user's text. If this drifts the
|
|
// modal submit silently becomes a no-op.
|
|
Assert.Contains("Components[0]", source, StringComparison.Ordinal);
|
|
Assert.Contains("TextInput", source, StringComparison.Ordinal);
|
|
Assert.Contains(".Value", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void StringMenuHandler_ShouldReadSelectedValues()
|
|
{
|
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
|
// StringSelectMenu interactions expose SelectedValues[0]
|
|
// for our MaxValues=1 menus.
|
|
Assert.Contains("SelectedValues[0]", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static int CountOccurrences(string haystack, string needle)
|
|
{
|
|
if (string.IsNullOrEmpty(needle)) return 0;
|
|
var count = 0;
|
|
var idx = 0;
|
|
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
|
|
{
|
|
count++;
|
|
idx += needle.Length;
|
|
}
|
|
return count;
|
|
}
|
|
}
|