fix(bot,discord): allow 'no player limit' option in /newsession wizard
In the session creation wizard (Telegram + Discord), the Capacity step only exposed waitlist on/off buttons. The 'no waitlist' button silently advanced to the next step without setting MaxPlayers, so users who tried to create a session with no player cap were blocked with 'Не заполнены поля: лимит мест'. The DB contract and CreateSessionCommand already supported null MaxPlayers (int?, ck_sessions_max_players check in V006), and the web form already exposes 'Без лимита' as an empty InputNumber — only the wizard flow was broken. Changes: - Add '♾ Без лимита' choice button to Capacity in shared WizardStepViewBuilder.BuildCapacity (Telegram) and to RenderCapacity / RenderPoolSlotCapacity in DiscordWizardStep (Discord). - Add 'no_limit' branch to GameCreationWizard.ApplyCapacityChoice that sets MaxPlayers to null and advances to Visibility. - Change GameCreationWizard.SetMaxPlayers signature from int to int? so the 'no limit' branch compiles. - Change CreateSessionCommand builder in both Telegram and Discord submitters to take int? maxPlayers and drop the '?? 0' that would have turned null into 0 (violating the DB CHECK and the 'no limit' contract). - In Discord BuildConfirmDescription, render '👥 Без лимита, waitlist вкл/выкл' when MaxPlayers is null (the previous code silently omitted the line). - Expose BuildCommand as internal in both submitters and add InternalsVisibleTo('GmRelay.Bot.Tests') to the DiscordBot assembly for unit-test access. Tests (9 new): - WizardStepRenderTests.CapacityStep_HasWaitlistButtons — asserts the 'Без лимита' button is present. - GameCreationWizardStepTransitionsTests.NoLimitCapacityButton_… — asserts the choice advances to Visibility and leaves MaxPlayers null in the JSON draft. - GameCreationWizardStepTransitionsTests.ChoiceCallback_AdvancesToExpectedStep — new Theory row for Capacity/no_limit. - CreateSessionHandlerBuildCommandTests (new) — null/value propagation through the Telegram submitter's BuildCommand. - DiscordWizardStepCapacityRenderTests (new) — 'Без лимита' button is rendered for both Capacity and PoolSlotCapacity, with the expected custom-id shape. - DiscordWizardSubmitterBuildCommandTests (new) — null/value propagation through the Discord submitter's BuildCommand. Closes #123
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using NetCord.Rest;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Renderer tests for the Discord wizard's Capacity / PoolSlotCapacity steps.
|
||||
/// Locks in the presence of the "♾ Без лимита" button so the user can pick
|
||||
/// a session with no player cap (null in <c>sessions.max_players</c>), the
|
||||
/// same affordance the Telegram wizard provides.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardStepCapacityRenderTests
|
||||
{
|
||||
[Fact]
|
||||
public void RenderCapacity_ContainsNoLimitButton()
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.Capacity };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var labels = ExtractButtonLabels(render);
|
||||
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPoolSlotCapacity_ContainsNoLimitButton()
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.PoolSlotCapacity };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var labels = ExtractButtonLabels(render);
|
||||
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderCapacity_NoLimitButton_HasChoiceCustomIdForNoLimit()
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.Capacity };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var buttons = ExtractButtons(render);
|
||||
var noLimit = buttons.SingleOrDefault(b => b.Label?.Contains("Без лимита", System.StringComparison.Ordinal) == true);
|
||||
Assert.NotNull(noLimit);
|
||||
Assert.StartsWith("wizard:btn:choice:Capacity:no_limit", noLimit!.CustomId);
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.List<string> ExtractButtonLabels(
|
||||
DiscordWizardStep.DiscordWizardRender render) =>
|
||||
render.Components
|
||||
.OfType<ActionRowProperties>()
|
||||
.SelectMany(r => r.Components)
|
||||
.OfType<ButtonProperties>()
|
||||
.Select(b => b.Label ?? string.Empty)
|
||||
.ToList();
|
||||
|
||||
private static System.Collections.Generic.List<ButtonProperties> ExtractButtons(
|
||||
DiscordWizardStep.DiscordWizardRender render) =>
|
||||
render.Components
|
||||
.OfType<ActionRowProperties>()
|
||||
.SelectMany(r => r.Components)
|
||||
.OfType<ButtonProperties>()
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for <see cref="DiscordWizardSubmitter"/>'s
|
||||
/// <c>BuildCommand</c>: when the wizard payload carries no player limit
|
||||
/// (user picked «♾ Без лимита» on the Capacity step), the resulting
|
||||
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never
|
||||
/// <c>0</c>. <c>0</c> would violate the DB CHECK
|
||||
/// <c>ck_sessions_max_players</c> in V006 and the contract that
|
||||
/// <c>null</c> means "no limit".
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardSubmitterBuildCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Null(cmd.MaxPlayers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = 5,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Equal(5, cmd.MaxPlayers);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user