using System; using System.IO; using System.Linq; using System.Reflection; using GmRelay.DiscordBot.Features.Sessions.Wizard; 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); } /// /// Roundtrip the renderer output through the dispatcher's parser to /// prove the wire formats agree. This is a real behavioural test /// (not a string-grep) — it actually constructs the ButtonProperties /// that NetCord would send, strips the [ComponentInteraction("wizard")] /// prefix exactly as NetCord does, and asserts the dispatcher's /// switch would route the click to the right branch. Catches the /// class of "renderer and dispatcher disagree on the wire format" /// regressions that the string-grep tests above cannot detect. /// [Fact] public void Renderer_And_Dispatcher_Agree_On_Wire_Format() { // Choice button: dispatcher expects `btn:choice::`. var choice = DiscordWizardStep.ChoiceButtonCustomId("Type", "single"); Assert.Equal("wizard:btn:choice:Type:single", choice); var choiceArgs = StripWizardPrefix(choice); var choiceParts = choiceArgs.Split(':', 4); Assert.Equal("btn", choiceParts[0]); Assert.Equal("choice", choiceParts[1]); Assert.Equal("Type", choiceParts[2]); Assert.Equal("single", choiceParts[3]); // Control button: dispatcher expects `btn::1`. var cancel = DiscordWizardStep.ControlButtonCustomId("cancel"); Assert.Equal("wizard:btn:cancel:1", cancel); var cancelArgs = StripWizardPrefix(cancel); var cancelParts = cancelArgs.Split(':', 3); Assert.Equal("btn", cancelParts[0]); Assert.Equal("cancel", cancelParts[1]); // Modal trigger: dispatcher expects `btn:modal:`. var modal = DiscordWizardStep.ModalTriggerButtonCustomId("SystemFreeText"); Assert.Equal("wizard:btn:modal:SystemFreeText", modal); var modalArgs = StripWizardPrefix(modal); var modalParts = modalArgs.Split(':', 3); Assert.Equal("btn", modalParts[0]); Assert.Equal("modal", modalParts[1]); Assert.Equal("SystemFreeText", modalParts[2]); // All customIds must fit Discord's 100-char limit. Assert.All( new[] { choice, cancel, modal }, cid => Assert.True( cid.Length <= DiscordWizardStep.MaxCustomIdLength, $"CustomId '{cid}' exceeds 100 chars: {cid.Length}")); } /// /// The Create/Back/Cancel/Resume control buttons in the renderer /// (and in BuildResumeRow) must emit the format the dispatcher's /// switch matches directly — NOT the choice-button format. This /// test parses every button's customId and asserts the dispatcher /// would route it to the right branch. /// [Fact] public void ControlButtons_Are_Parsed_As_Control_Not_Choice() { // Real customIds the renderer / BuildResumeRow emit for control actions. var controlIds = new[] { DiscordWizardStep.ControlButtonCustomId("back"), DiscordWizardStep.ControlButtonCustomId("cancel"), "wizard:btn:create:1", "wizard:btn:resume:continue", "wizard:btn:resume:restart", }; foreach (var cid in controlIds) { var parts = StripWizardPrefix(cid).Split(':', 3); Assert.Equal("btn", parts[0]); // The dispatcher's switch matches these as parts[1] == "back"|"cancel"|"create"|"resume". // They must NOT be tagged as "choice" (that would route through the wizard // with a nonsensical step name). Assert.NotEqual("choice", parts[1]); } } /// Mirror NetCord's [ComponentInteraction("wizard")] prefix strip. private static string StripWizardPrefix(string customId) { const string prefix = "wizard:"; return customId.StartsWith(prefix, StringComparison.Ordinal) ? customId[prefix.Length..] : customId; } 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; } }