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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
+1
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user