Files
GmRelayBot/tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs
Toutsu 85ff3a7faf
PR Checks / test-and-build (pull_request) Successful in 9m54s
fix(discord): address code-review findings on wizard adapter (issue #112)
VERDICT from verifier (D:\Projects\Game\docs\review-report.md):
REQUEST_CHANGES — wizard was functionally broken at runtime.

## Critical

C-1. Choice-button customId was missing the 'choice:' segment.
    ButtonCustomId emitted 'wizard:btn:<step>:<value>' but the
    dispatcher's switch matches parts[1] == 'choice'. Every choice
    button (D&D 5e, Pathfinder, Waitlist, Publish, Confirm) fell
    into the default branch and showed 'Unknown button'.

    Fix: split into 3 customId helpers:
      ChoiceButtonCustomId(step, value)       -> 'wizard:btn:choice:<step>:<value>'
      ControlButtonCustomId(action)            -> 'wizard:btn:<action>:1'  (back/cancel/skip/create)
      ModalTriggerButtonCustomId(modalStep)    -> 'wizard:btn:modal:<modalStep>'
    Bulk-rewrote all 66 Btn() call sites in DiscordWizardStep.cs.

C-2. "Другое…" modal-trigger buttons were unrouted in dispatcher.
    Added 'parts[1] == "modal"' branch that opens the modal via
    InteractionCallback.Modal(BuildModal(parts[2], draft.ChatId)).

C-3. DiscordWizardSubmitter was leaking ex.Message from
    CreateSessionHandler to the user-visible draft embed. Postgres
    exceptions expose schema/constraint names. Replaced with
    generic user-facing error; full exception still logged
    server-side on the existing catch block.

## I-3 — parser-roundtrip tests (the gap that let C-1/C-2 through)

Added two real behavioural tests (not string-grep) to
DiscordWizardInteractionModuleSourceTests:
  - Renderer_And_Dispatcher_Agree_On_Wire_Format
  - ControlButtons_Are_Parsed_As_Control_Not_Choice
These mirror NetCord's [ComponentInteraction("wizard")] prefix
strip, run the parser, and assert the dispatcher would route to
the right branch. Catches the entire class of 'renderer and
dispatcher disagree on the wire format' regressions.

## I-6 — BuildResumeRow (cascading fix from C-1)

After C-1, BuildResumeRow's ButtonCustomId('cancel', '1') would
emit the wrong format. Switched to direct format strings
('wizard:btn:cancel:1', 'wizard:btn:resume:continue', etc.) which
match the dispatcher's 'back'/'cancel'/'create'/'resume' cases
directly, not the 'choice' prefix.

## Version sync (3.8.0 -> 3.9.0)

Directory.Build.props: <Version>3.9.0</Version>
compose.yaml: all 3 image tags -> 3.9.0
Version_ShouldBeSynchronizedForDiscordFeatureRelease test now green.

## Stats

build: 0 warnings, 0 errors
format: 0 of 279 files need changes
tests: 583 passed, 2 skipped (pre-existing), 0 failed
files: 7 changed, 226 +, 79 -
2026-06-05 23:09:24 +03:00

280 lines
13 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Reflection;
using GmRelay.DiscordBot.Features.Sessions.Wizard;
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);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Renderer_And_Dispatcher_Agree_On_Wire_Format()
{
// Choice button: dispatcher expects `btn:choice:<step>:<value>`.
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:<action>: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:<modalStep>`.
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}"));
}
/// <summary>
/// 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.
/// </summary>
[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]);
}
}
/// <summary>Mirror NetCord's [ComponentInteraction("wizard")] prefix strip.</summary>
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;
}
}