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
@@ -170,7 +170,7 @@ public sealed class CreateSessionHandler
draft, draft,
p, p,
new[] { p.Single?.ScheduledAt ?? default }, new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers ?? 0, p.Single?.MaxPlayers,
isOneShot: true), isOneShot: true),
}; };
} }
@@ -178,11 +178,11 @@ public sealed class CreateSessionHandler
private static int MaxPlayersForPool(WizardPoolInput pool) => private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers); pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
private static CreateSessionCommand BuildCommand( internal static CreateSessionCommand BuildCommand(
WizardDraft draft, WizardDraft draft,
WizardPayload p, WizardPayload p,
IReadOnlyList<DateTimeOffset> scheduledTimes, IReadOnlyList<DateTimeOffset> scheduledTimes,
int maxPlayers, int? maxPlayers,
bool isOneShot) bool isOneShot)
{ {
var user = new PlatformUser( var user = new PlatformUser(
@@ -252,11 +252,12 @@ public static class DiscordWizardStep
private static DiscordWizardRender RenderCapacity() => new( private static DiscordWizardRender RenderCapacity() => new(
"👥 Лимит мест", "👥 Лимит мест",
"Введите лимит (1..50) и выберите waitlist.", "Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
new[] new[]
{ {
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success), Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)), ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.Capacity, "no_limit", ButtonStyle.Primary)),
Row(ControlBtn("⬅️ Назад", "back"), Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
@@ -371,11 +372,12 @@ public static class DiscordWizardStep
private static DiscordWizardRender RenderPoolSlotCapacity() => new( private static DiscordWizardRender RenderPoolSlotCapacity() => new(
"👥 Лимит слотов", "👥 Лимит слотов",
"Введите лимит (1..50) и выберите waitlist.", "Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
new[] new[]
{ {
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success), Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)), ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.PoolSlotCapacity, "no_limit", ButtonStyle.Primary)),
Row(ControlBtn("⬅️ Назад", "back"), Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
@@ -417,6 +419,10 @@ public static class DiscordWizardStep
{ {
sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine(); sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
} }
else if (p.Type == WizardCreationType.Single)
{
sb.Append("👥 Без лимита, waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
}
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility)); sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
return sb.ToString(); return sb.ToString();
} }
@@ -134,7 +134,7 @@ public sealed class DiscordWizardSubmitter
draft, draft,
p, p,
new[] { p.Single?.ScheduledAt ?? default }, new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers ?? 0, p.Single?.MaxPlayers,
isOneShot: true), isOneShot: true),
}; };
} }
@@ -142,11 +142,11 @@ public sealed class DiscordWizardSubmitter
private static int MaxPlayersForPool(WizardPoolInput pool) => private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers); pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
private static CreateSessionCommand BuildCommand( internal static CreateSessionCommand BuildCommand(
WizardDraft draft, WizardDraft draft,
WizardPayload p, WizardPayload p,
IReadOnlyList<DateTimeOffset> scheduledTimes, IReadOnlyList<DateTimeOffset> scheduledTimes,
int maxPlayers, int? maxPlayers,
bool isOneShot) bool isOneShot)
{ {
var user = new PlatformUser( var user = new PlatformUser(
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
@@ -302,6 +302,7 @@ public sealed class GameCreationWizard
{ {
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)), "waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)), "waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
"no_limit" => (WizardStepNames.Visibility, SetMaxPlayers(p, null)),
_ => (null, "Неизвестный выбор"), _ => (null, "Неизвестный выбор"),
}; };
@@ -416,7 +417,7 @@ public sealed class GameCreationWizard
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; } private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v) private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; } { p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
private static string? SetMaxPlayers(WizardPayload p, int v) private static string? SetMaxPlayers(WizardPayload p, int? v)
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; } { p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; } private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; } private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
@@ -97,11 +97,12 @@ public static class WizardStepViewBuilder
BackCancel()); BackCancel());
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => ( private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.", "👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist. Или сразу «♾ Без лимита».",
new List<WizardAction> new List<WizardAction>
{ {
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success), new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger), new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
}); });
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => ( private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
@@ -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);
}
}
@@ -0,0 +1,85 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Regression coverage for <see cref="CreateSessionHandler.BuildCommand"/>:
/// when the wizard payload carries no player limit (e.g. 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 constraint
/// (<c>ck_sessions_max_players</c> in V006) by inserting <c>0</c> instead of
/// <c>NULL</c>, which is the wire-level representation of "no limit".
/// </summary>
public sealed class CreateSessionHandlerBuildCommandTests
{
[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 = CreateSessionHandler.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 = CreateSessionHandler.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal(5, cmd.MaxPlayers);
}
}
@@ -23,6 +23,7 @@ public sealed class GameCreationWizardStepTransitionsTests
// Capacity → Visibility // Capacity → Visibility
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)] [InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)] [InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)]
// Visibility → Publish (public, no club) // Visibility → Publish (public, no club)
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)] [InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
// Visibility → PickClub // Visibility → PickClub
@@ -70,6 +71,32 @@ public sealed class GameCreationWizardStepTransitionsTests
Assert.Equal(240, dur.GetInt32()); Assert.Equal(240, dur.GetInt32());
} }
[Fact]
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Capacity, PayloadForStep(WizardStepNames.Capacity));
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("single", out var single));
// WizardPayloadJsonContext имеет DefaultIgnoreCondition=WhenWritingNull,
// поэтому null-MaxPlayers просто не пишется. Оба варианта
// (отсутствует / JsonValueKind.Null) десериализуются обратно в null
// и уйдут в БД как NULL — то есть «без лимита».
if (single.TryGetProperty("maxPlayers", out var maxPlayers))
{
Assert.True(
maxPlayers.ValueKind == JsonValueKind.Null,
$"expected maxPlayers to be null (no limit), got {maxPlayers.ValueKind}");
}
}
[Fact] [Fact]
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep() public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
{ {
@@ -76,6 +76,7 @@ public sealed class WizardStepRenderTests
var labels = ButtonLabels(kb); var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal)); Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal)); Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без лимита", StringComparison.Ordinal));
} }
[Fact] [Fact]