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;
}
}