using System; using System.IO; using System.Linq; using System.Reflection; namespace GmRelay.Bot.Tests.Discord; /// /// 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. /// 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 for each // supported component type. Assert.Contains("ComponentInteractionModule", source, StringComparison.Ordinal); Assert.Contains("ComponentInteractionModule", source, StringComparison.Ordinal); Assert.Contains("ComponentInteractionModule", 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 // per supported interaction type. The wizard needs all three: // buttons, StringSelectMenus, and modal submits. Assert.Contains("AddComponentInteractions", source, StringComparison.Ordinal); Assert.Contains("AddComponentInteractions", source, StringComparison.Ordinal); Assert.Contains("AddComponentInteractions", 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; } }