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:
2026-06-08 18:17:01 +03:00
parent 99a58d7835
commit 15040eb954
11 changed files with 283 additions and 10 deletions
@@ -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);
}
}