Files
GmRelayBot/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs
T
Toutsu 15040eb954 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
2026-06-08 18:17:01 +03:00

262 lines
10 KiB
C#

using System;
using System.Linq;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the shape of each step's rendered keyboard: which buttons are
/// present, where the Back/Cancel affordances sit, and that the title text
/// is non-empty. Tests use substring matching so they survive label tweaks
/// (e.g. emoji prefixes, suffix additions like "· 4 ч").
/// </summary>
public sealed class WizardStepRenderTests
{
[Fact]
public void TypeStep_HasBothChoicesAndCancel_ButNoBack()
{
var (text, kb) = Render(WizardStepNames.Type);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Одну игру", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Пул игр", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
Assert.DoesNotContain(labels, l => l.Contains("Назад", StringComparison.Ordinal));
}
[Fact]
public void TitleStep_HasBackAndCancel_ButNoChoiceButtons()
{
var (text, kb) = Render(WizardStepNames.Title);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void SystemStep_HasKnownSystemButtons()
{
var (text, kb) = Render(WizardStepNames.System);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("D&D 5e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Pathfinder 2e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Call of Cthulhu", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("GURPS", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Fate", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Другое", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Пропустить", StringComparison.Ordinal));
}
[Fact]
public void DurationStep_HasPresetButtons()
{
var (text, kb) = Render(WizardStepNames.Duration);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains("3 часа", labels);
Assert.Contains("4 часа", labels);
Assert.Contains("5 часов", labels);
Assert.Contains("6 часов", labels);
}
[Fact]
public void CapacityStep_HasWaitlistButtons()
{
var (text, kb) = Render(WizardStepNames.Capacity);
Assert.False(string.IsNullOrWhiteSpace(text));
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("Без лимита", StringComparison.Ordinal));
}
[Fact]
public void VisibilityStep_HasAllFourVisibilityOptions()
{
var (text, kb) = Render(WizardStepNames.Visibility);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Публичная в общем showcase", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Публичная в витрине клуба", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Только для членов клуба", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Выбрать клуб", StringComparison.Ordinal));
}
[Fact]
public void PickClub_WithoutClubs_RendersEmptyHint()
{
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PickClub),
new WizardPayload(),
clubs: null);
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("нет клубов", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PickClub_WithOneClub_RendersClubButton()
{
var clubId = Guid.NewGuid();
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PickClub),
new WizardPayload(),
new[] { new WizardClubOption(clubId, "Awesome Club") });
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("Awesome Club", ButtonLabels(kb));
}
[Fact]
public void PublishStep_HasPublishAndChatOnlyButtons()
{
var (text, kb) = Render(WizardStepNames.Publish);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Опубликовать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Только в чате", StringComparison.Ordinal));
}
[Fact]
public void ConfirmStep_HasCreateAndCancel()
{
var (text, kb) = Render(WizardStepNames.Confirm, new WizardPayload
{
Type = WizardCreationType.Single,
Title = "My Game",
});
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("My Game", text);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void PoolSystemDuration_HasPresetsAndCustom()
{
var (text, kb) = Render(WizardStepNames.PoolSystemDuration);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("D&D 5e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Pathfinder 2e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Call of Cthulhu", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("GURPS", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Другое", StringComparison.Ordinal));
}
[Fact]
public void PoolAddSlots_HasAddAndDone_AndShowsCurrentCount()
{
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "My Pool",
Pool = new WizardPoolInput
{
Slots =
{
new WizardSlotInput { MaxPlayers = 4 },
new WizardSlotInput { MaxPlayers = 5 },
},
},
};
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PoolAddSlots),
payload);
Assert.Contains("My Pool", text);
Assert.Contains("2", text);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Добавить слот", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Готово", StringComparison.Ordinal));
}
[Fact]
public void PoolSlotDateTime_HasBackAndCancel_ButNoChoiceButtons()
{
var (text, kb) = Render(WizardStepNames.PoolSlotDateTime);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void PoolSlotCapacity_HasWaitlistButtons()
{
var (text, kb) = Render(WizardStepNames.PoolSlotCapacity);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
}
[Fact]
public void PoolConfirm_HasCreatePoolAndCancel()
{
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Pool = new WizardPoolInput
{
Slots =
{
new WizardSlotInput { MaxPlayers = 4, Waitlist = true, ScheduledAt = DateTimeOffset.UtcNow.AddDays(7) },
new WizardSlotInput { MaxPlayers = 5, Waitlist = false, ScheduledAt = DateTimeOffset.UtcNow.AddDays(14) },
},
},
};
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PoolConfirm),
payload);
Assert.Contains("Pool", text);
Assert.Contains("2", text); // slot count
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать пул", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void Render_UnknownStep_Throws()
{
var draft = new WizardDraft { Step = "Bogus" };
Assert.Throws<InvalidOperationException>(() => WizardStep.Render(draft, new WizardPayload()));
}
private static (string text, InlineKeyboardMarkup kb) Render(string step, WizardPayload? payload = null)
=> WizardStep.Render(NewDraft(step), payload ?? new WizardPayload());
private static WizardDraft NewDraft(string step) => new()
{
Id = Guid.NewGuid(),
ChatId = "42",
Step = step,
PayloadJson = "{}",
};
private static string[] ButtonLabels(InlineKeyboardMarkup kb) =>
kb.InlineKeyboard
.SelectMany(row => row.Select(b => b.Text))
.ToArray();
}