85ff3a7faf
PR Checks / test-and-build (pull_request) Successful in 9m54s
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 -
280 lines
13 KiB
C#
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;
|
|
}
|
|
}
|