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