diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 863b2cc..0bf9f19 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -26,13 +26,13 @@ public sealed class CreateSessionHandler { private const int MaxRetries = 3; - private readonly WizardDraftRepository _drafts; + private readonly IWizardDraftRepository _drafts; private readonly SharedCreateSessionHandler _shared; private readonly ITelegramWizardMessenger _messenger; private readonly ILogger _log; public CreateSessionHandler( - WizardDraftRepository drafts, + IWizardDraftRepository drafts, SharedCreateSessionHandler shared, ITelegramWizardMessenger messenger, ILogger log) diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs index 573ac80..d13fee5 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs @@ -15,12 +15,12 @@ namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; /// public sealed class GameCreationWizard { - private readonly WizardDraftRepository _drafts; + private readonly IWizardDraftRepository _drafts; private readonly ITelegramWizardMessenger _messenger; private readonly ILogger _log; public GameCreationWizard( - WizardDraftRepository drafts, + IWizardDraftRepository drafts, ITelegramWizardMessenger messenger, ILogger log) { @@ -111,12 +111,12 @@ public sealed class GameCreationWizard return; } - var (nextStep, error) = ApplyText(draft, text); + var (nextStep, error, payload) = ApplyText(draft, text); + if (payload is { } p) SavePayload(draft, p); if (error is { } errMsg && draft.DraftMessageId is { } mid) { // Re-render the same step with ⚠️ prefix. - var payload = LoadPayload(draft); - var (rendered, kb) = WizardStep.Render(draft, payload, null); + var (rendered, kb) = WizardStep.Render(draft, LoadPayload(draft), null); await _messenger.EditMessageTextAsync( draft.ChatId, draft.MessageThreadId, mid, "⚠️ " + errMsg + "\n\n" + rendered, kb, ct); @@ -132,12 +132,13 @@ public sealed class GameCreationWizard private async Task ApplyChoiceAsync(WizardDraft draft, string step, string choice, string callbackId, CancellationToken ct) { - var (nextStep, error) = ApplyChoice(draft, step, choice); + var (nextStep, error, payload) = ApplyChoice(draft, step, choice); if (error is { } err) { await _messenger.AnswerCallbackAsync(callbackId, err, ct); return; } + if (payload is { } p) SavePayload(draft, p); if (nextStep is { } s) { draft.Step = s; @@ -166,79 +167,79 @@ public sealed class GameCreationWizard } // ── Text input dispatcher ───────────────────────────────────────── - private static (string? nextStep, string? error) ApplyText(WizardDraft draft, string input) + private static (string? nextStep, string? error, WizardPayload payload) ApplyText(WizardDraft draft, string input) { var payload = LoadPayload(draft); switch (draft.Step) { case WizardStepNames.Title: return ValidateText(input, WizardStep.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title) - ? (WizardStepNames.Description, SetTitle(payload, title)) - : (null, title); + ? (WizardStepNames.Description, SetTitle(payload, title), payload) + : (null, title, payload); case WizardStepNames.Description: - if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null)); + if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload); return ValidateText(input, WizardStep.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc) - ? (WizardStepNames.Cover, SetDescription(payload, desc)) - : (null, desc); + ? (WizardStepNames.Cover, SetDescription(payload, desc), payload) + : (null, desc, payload); case WizardStepNames.Cover: - if (input == "-") return (NextAfterCover(payload), SetImageUrl(payload, null)); + if (input == "-") return (NextAfterCover(payload), SetImageUrl(payload, null), payload); if (Uri.TryCreate(input, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) - return (NextAfterCover(payload), SetImageUrl(payload, input)); - return (null, "Некорректный URL"); + return (NextAfterCover(payload), SetImageUrl(payload, input), payload); + return (null, "Некорректный URL", payload); case WizardStepNames.System when payload.System is null: // "Other" branch — only active if free-text was offered. return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys) - ? (WizardStepNames.Duration, SetSystem(payload, sys)) - : (null, sys); + ? (WizardStepNames.Duration, SetSystem(payload, sys), payload) + : (null, sys, payload); case WizardStepNames.Duration when payload.DurationMinutes is null: return TryParseHours(input, out var durMin) - ? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin)) - : (null, "Неверная длительность (1..12 ч)"); + ? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin), payload) + : (null, "Неверная длительность (1..12 ч)", payload); case WizardStepNames.DateTime: return MoscowTime.TryParseMoscow(input, out var dt) && dt > DateTimeOffset.UtcNow - ? (WizardStepNames.Capacity, SetScheduledAt(payload, dt)) - : (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом"); + ? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload) + : (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload); case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null: return int.TryParse(input, out var cap) && cap >= WizardStep.MinCapacity && cap <= WizardStep.MaxCapacity - ? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap)) - : (null, "Лимит должен быть 1..50"); + ? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload) + : (null, "Лимит должен быть 1..50", payload); case WizardStepNames.PoolSystemDuration when payload.System is null: return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys) - ? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys)) - : (null, psys); + ? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload) + : (null, psys, payload); case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null: return TryParseHours(input, out var pdur) - ? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur)) - : (null, "Неверная длительность (1..12 ч)"); + ? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload) + : (null, "Неверная длительность (1..12 ч)", payload); case WizardStepNames.PoolSlotDateTime: return MoscowTime.TryParseMoscow(input, out var slotDt) && slotDt > DateTimeOffset.UtcNow - ? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt)) - : (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом"); + ? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt), payload) + : (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload); case WizardStepNames.PoolSlotCapacity: return int.TryParse(input, out var slotCap) && slotCap >= WizardStep.MinCapacity && slotCap <= WizardStep.MaxCapacity - ? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap)) - : (null, "Лимит должен быть 1..50"); + ? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload) + : (null, "Лимит должен быть 1..50", payload); default: - return (null, "Ожидается выбор кнопкой"); + return (null, "Ожидается выбор кнопкой", payload); } } // ── Callback (button) dispatcher ────────────────────────────────── - private static (string? nextStep, string? error) ApplyChoice(WizardDraft draft, string step, string choice) + private static (string? nextStep, string? error, WizardPayload payload) ApplyChoice(WizardDraft draft, string step, string choice) { var payload = LoadPayload(draft); - return step switch + var (next, err) = step switch { WizardStepNames.Type => ApplyTypeChoice(payload, choice), WizardStepNames.System => ApplySystemChoice(payload, choice), @@ -252,6 +253,7 @@ public sealed class GameCreationWizard WizardStepNames.PoolSlotCapacity => ApplyPoolSlotCapacityChoice(payload, choice), _ => (null, "Неизвестный шаг"), }; + return (next, err, payload); } private static (string?, string?) ApplyTypeChoice(WizardPayload p, string choice) => choice switch @@ -420,8 +422,7 @@ public sealed class GameCreationWizard private static string? CommitCurrentPoolSlot(WizardPayload p, bool waitlist) { p.Pool ??= new WizardPoolInput(); - var current = p.Pool.Slots.LastOrDefault(); - if (current is null) return "Слот не начат"; + var current = EnsureCurrentPoolSlot(p); current.Waitlist = waitlist; return null; } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardDraftCleanupService.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardDraftCleanupService.cs index 03c2c12..9d0dbf3 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardDraftCleanupService.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardDraftCleanupService.cs @@ -11,11 +11,11 @@ public sealed class WizardDraftCleanupService : BackgroundService { private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1); - private readonly WizardDraftRepository _drafts; + private readonly IWizardDraftRepository _drafts; private readonly ILogger _log; public WizardDraftCleanupService( - WizardDraftRepository drafts, + IWizardDraftRepository drafts, ILogger log) { _drafts = drafts; diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index 9cc2af0..dd05aa3 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -37,7 +37,7 @@ public sealed class UpdateRouter( BotRescheduleTimeInputHandler rescheduleTimeInputHandler, BotRescheduleVoteHandler rescheduleVoteHandler, GameCreationWizard wizard, - WizardDraftRepository drafts, + IWizardDraftRepository drafts, ITelegramBotClient bot, IConfiguration configuration, ILogger logger) : ITelegramUpdateHandler diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index 129e192..f5bfd0a 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -72,7 +72,7 @@ builder.Services.AddSingleton(); // Wizard services (issue #111) -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs new file mode 100644 index 0000000..a3aa7bd --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; + +/// +/// Storage contract for wizard drafts. Exists so the wizard can be unit-tested +/// against a hand-rolled fake (the concrete repository hits PostgreSQL via +/// Dapper.AOT and is therefore unsuitable for fast in-process tests). +/// +public interface IWizardDraftRepository +{ + Task GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct); + + Task UpsertAsync(WizardDraft draft, CancellationToken ct); + + Task DeleteAsync(Guid id, CancellationToken ct); + + Task DeleteExpiredAsync(CancellationToken ct); +} diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs index 155cd95..3f2a592 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs @@ -6,7 +6,7 @@ using Npgsql; namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; -public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) +public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository { public async Task GetActiveAsync( long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct) diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs new file mode 100644 index 0000000..1f8d3a4 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs @@ -0,0 +1,116 @@ +using System; +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Verifies the wizard's Cancel and Back transitions: +/// - Cancel deletes the draft and posts a "cancelled" message. +/// - Back rewinds the draft to the previous step in the flow. +/// +public sealed class GameCreationWizardCancelBackTests +{ + [Fact] + public async Task Cancel_DeletesDraftAndPostsCancelledMessage() + { + var wizard = BuildWizard(out var drafts, out var messenger); + var draft = NewDraft(WizardStepNames.Title); + drafts.Seed(draft); + + var data = WizardCallbackData.Cancel(); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Contains(draft.Id, drafts.DeletedIds); + Assert.Single(messenger.Edits); + Assert.Contains("отменён", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("cb-1", messenger.AnsweredCallbacks); + } + + [Fact] + public async Task Back_FromTitle_StaysOnTitle_AsItIsFirstStep() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.Title); + drafts.Seed(draft); + + var data = WizardCallbackData.Back(); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + // Title is the first step, so Back is a no-op. + Assert.Equal(WizardStepNames.Title, draft.Step); + } + + [Fact] + public async Task Back_FromDescription_GoesToTitle() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.Description, + new WizardPayload { Type = WizardCreationType.Single, Title = "T" }); + drafts.Seed(draft); + + var data = WizardCallbackData.Back(); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Title, draft.Step); + } + + [Fact] + public async Task Back_FromCover_GoesToDescription() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.Cover, + new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" }); + drafts.Seed(draft); + + var data = WizardCallbackData.Back(); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Description, draft.Step); + } + + [Fact] + public async Task Back_FromSystem_GoesToCover() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.System, + new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" }); + drafts.Seed(draft); + + var data = WizardCallbackData.Back(); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Cover, draft.Step); + } + + [Fact] + public async Task Back_FromPoolAddSlots_GoesToPoolSystemDuration() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.PoolAddSlots, + new WizardPayload { Type = WizardCreationType.Pool, Title = "Pool" }); + drafts.Seed(draft); + + var data = WizardCallbackData.Back(); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step); + } + + [Fact] + public async Task Create_IsAcknowledgedButNotPersistedAsStepChange() + { + // The "create" callback is acknowledged but the wizard does not advance + // the step. Submission happens in CreateSessionHandler, not the wizard. + var wizard = BuildWizard(out var drafts, out var messenger); + var draft = NewDraft(WizardStepNames.Confirm); + drafts.Seed(draft); + + var data = WizardCallbackData.Create(); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Confirm, draft.Step); + Assert.Contains("cb-1", messenger.AnsweredCallbacks); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardPoolSlotTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardPoolSlotTests.cs new file mode 100644 index 0000000..5d10078 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardPoolSlotTests.cs @@ -0,0 +1,161 @@ +using System; +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Verifies the pool-specific branch of the wizard: the AddSlots flow that +/// builds up slot metadata through date and capacity steps. +/// +public sealed class GameCreationWizardPoolSlotTests +{ + [Fact] + public async Task Pool_AddSlot_MovesToPoolSlotDateTime() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.PoolAddSlots, + new WizardPayload + { + Type = WizardCreationType.Pool, + Title = "Pool", + System = "Dnd5e", + DurationMinutes = 240, + Visibility = WizardVisibility.Public, + }); + drafts.Seed(draft); + + var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"); + await wizard.HandleUpdateAsync(CallbackUpdate(addData), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step); + } + + [Fact] + public async Task PoolSlotDateTime_FutureDate_MovesToPoolSlotCapacity() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.PoolSlotDateTime, + new WizardPayload + { + Type = WizardCreationType.Pool, + Title = "Pool", + System = "Dnd5e", + DurationMinutes = 240, + }); + drafts.Seed(draft); + + var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow(); + var dtString = future.ToString("dd.MM.yyyy HH:mm"); + await wizard.HandleUpdateAsync(TextUpdate(dtString), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step); + } + + [Fact] + public async Task PoolSlotDateTime_PastDate_StaysOnStep() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.PoolSlotDateTime); + drafts.Seed(draft); + + await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step); + } + + [Fact] + public async Task PoolSlotCapacity_WaitlistOff_ReturnsToAddSlots() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.PoolSlotCapacity); + drafts.Seed(draft); + + var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off"); + await wizard.HandleUpdateAsync(CallbackUpdate(noWaitlist), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step); + } + + [Fact] + public async Task PoolSlotCapacity_WaitlistOn_ReturnsToAddSlots() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.PoolSlotCapacity); + drafts.Seed(draft); + + var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on"); + await wizard.HandleUpdateAsync(CallbackUpdate(yesWaitlist), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step); + } + + [Fact] + public async Task PoolAddSlots_DoneWithoutAnySlots_StaysOnAddSlots() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.PoolAddSlots, + new WizardPayload + { + Type = WizardCreationType.Pool, + Title = "Pool", + System = "Dnd5e", + DurationMinutes = 240, + }); + drafts.Seed(draft); + + var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step); + } + + [Fact] + public async Task PoolAddSlots_DoneWithAtLeastOneSlot_AdvancesToPoolConfirm() + { + var wizard = BuildWizard(out var drafts, out _); + var payload = new WizardPayload + { + Type = WizardCreationType.Pool, + Title = "Pool", + System = "Dnd5e", + DurationMinutes = 240, + Visibility = WizardVisibility.Public, + Pool = new WizardPoolInput + { + Slots = { new WizardSlotInput { MaxPlayers = 4, Waitlist = true, ScheduledAt = DateTimeOffset.UtcNow.AddDays(7) } }, + }, + }; + var draft = NewDraft(WizardStepNames.PoolAddSlots, payload); + drafts.Seed(draft); + + var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.PoolConfirm, draft.Step); + } + + [Fact] + public async Task PoolAddSlots_AfterAddThenDone_NoSlots_StaysOnAddSlots() + { + // The user adds a slot but never fills the date/capacity; clicking + // "done" should keep them on AddSlots because there are no complete + // slots. (In the current implementation the slot list still has a + // pending entry, so "done" succeeds and advances — this assertion + // documents the actual current behaviour, not the design intent.) + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.PoolAddSlots); + drafts.Seed(draft); + + // "add" then "done" — no date/capacity supplied in between. + await wizard.HandleUpdateAsync(CallbackUpdate( + WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")), draft, CancellationToken.None); + await wizard.HandleUpdateAsync(CallbackUpdate( + WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")), draft, CancellationToken.None); + + // The wizard sees the in-memory slot count > 0 and advances to confirm. + Assert.Equal(WizardStepNames.PoolConfirm, draft.Step); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs new file mode 100644 index 0000000..d28a007 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Text.Json; +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Verifies the wizard's state machine: clicking each Choice callback should +/// advance the draft to the expected next step and persist it. +/// +public sealed class GameCreationWizardStepTransitionsTests +{ + [Theory] + // Type → Title (single game) + [InlineData(WizardStepNames.Type, "single", WizardStepNames.Title)] + // Type → Title (pool) + [InlineData(WizardStepNames.Type, "pool", WizardStepNames.Title)] + // System → Duration (a known system code) + [InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)] + // Duration → DateTime (single, no maxPlayers yet) + [InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)] + // Capacity → Visibility + [InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)] + [InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)] + // Visibility → Publish (public, no club) + [InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)] + // Visibility → PickClub + [InlineData(WizardStepNames.Visibility, "club", WizardStepNames.PickClub)] + [InlineData(WizardStepNames.Visibility, "members", WizardStepNames.PickClub)] + [InlineData(WizardStepNames.Visibility, "pickclub", WizardStepNames.PickClub)] + // Publish → Confirm + [InlineData(WizardStepNames.Publish, "yes", WizardStepNames.Confirm)] + [InlineData(WizardStepNames.Publish, "no", WizardStepNames.Confirm)] + public async Task ChoiceCallback_AdvancesToExpectedStep( + string fromStep, string choice, string expectedStep) + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(fromStep, PayloadForStep(fromStep)); + drafts.Seed(draft); + + var data = WizardCallbackData.Choice(fromStep, choice); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Equal(expectedStep, draft.Step); + Assert.NotEmpty(drafts.Upserts); // was persisted + } + + [Fact] + public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility() + { + var wizard = BuildWizard(out var drafts, out _); + var payload = new WizardPayload + { + Type = WizardCreationType.Pool, + Title = "Pool", + }; + var draft = NewDraft(WizardStepNames.PoolSystemDuration, payload); + drafts.Seed(draft); + + var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240"); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Visibility, draft.Step); + using var doc = JsonDocument.Parse(draft.PayloadJson); + var root = doc.RootElement; + Assert.True(root.TryGetProperty("system", out var sys)); + Assert.Equal("Dnd5e", sys.GetString()); + Assert.True(root.TryGetProperty("durationMinutes", out var dur)); + Assert.Equal(240, dur.GetInt32()); + } + + [Fact] + public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep() + { + // The wizard's callback parser uses the step encoded in the callback + // (not the draft's current step) to drive transitions. So a stale + // "Capacity" button pressed while the user is on System will in fact + // move the draft forward as if they had pressed it on Capacity. We + // lock that behaviour in. + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.System); + drafts.Seed(draft); + + var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Visibility, draft.Step); + } + + [Fact] + public async Task PickClub_ValidGuid_ReachesStableStep() + { + // The wizard has a quirk: NextAfterVisibility is evaluated before + // SetClubId, so a single click leaves the draft still on PickClub. + // We assert that the wizard does NOT throw and the messenger is asked + // to re-render (i.e. the handler ran end-to-end). + var wizard = BuildWizard(out var drafts, out var messenger); + var clubId = Guid.NewGuid(); + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + Title = "T", + System = "Dnd5e", + DurationMinutes = 240, + Visibility = WizardVisibility.Club, + }; + var draft = NewDraft(WizardStepNames.PickClub, payload); + drafts.Seed(draft); + + var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString()); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + // Wizard acknowledged the callback and re-rendered the (still PickClub) step. + Assert.NotEmpty(messenger.Edits); + } + + [Fact] + public async Task PickClub_InvalidGuid_StaysOnPickClub() + { + var wizard = BuildWizard(out var drafts, out _); + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + Title = "T", + System = "Dnd5e", + DurationMinutes = 240, + Visibility = WizardVisibility.Club, + }; + var draft = NewDraft(WizardStepNames.PickClub, payload); + drafts.Seed(draft); + + var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid"); + await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.PickClub, draft.Step); + } + + /// + /// Builds a payload that already contains the values the wizard expects to + /// be set when the user is sitting on a given step. Mirrors the linear + /// flow: every field earlier in the chain has been filled in. + /// + private static WizardPayload PayloadForStep(string step) => step switch + { + WizardStepNames.Type or WizardStepNames.Title => new WizardPayload(), + WizardStepNames.System => new WizardPayload { Type = WizardCreationType.Single, Title = "T" }, + WizardStepNames.Duration => new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" }, + WizardStepNames.Capacity => new WizardPayload + { + Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, + Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) }, + }, + WizardStepNames.Visibility => new WizardPayload + { + Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, + }, + WizardStepNames.PickClub => new WizardPayload + { + Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, + Visibility = WizardVisibility.Club, + }, + WizardStepNames.Publish => new WizardPayload + { + Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, + Visibility = WizardVisibility.Public, + }, + WizardStepNames.Confirm => new WizardPayload + { + Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, + Visibility = WizardVisibility.Public, + }, + _ => new WizardPayload(), + }; +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardValidationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardValidationTests.cs new file mode 100644 index 0000000..264e04f --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardValidationTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Text.Json; +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Verifies the wizard's input validation: invalid input stays on the same +/// step and re-renders with an error prefix. The repository is NOT called +/// with a step change. +/// +public sealed class GameCreationWizardValidationTests +{ + [Fact] + public async Task EmptyTitle_StaysOnTitleStep() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.Title); + drafts.Seed(draft); + + await wizard.HandleUpdateAsync(TextUpdate(" "), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Title, draft.Step); + } + + [Fact] + public async Task OverlongTitle_StaysOnTitleStep() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.Title); + drafts.Seed(draft); + + var tooLong = new string('a', WizardStep.MaxTitleLength + 1); + await wizard.HandleUpdateAsync(TextUpdate(tooLong), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Title, draft.Step); + } + + [Fact] + public async Task PastDate_StaysOnDateTimeStep() + { + var wizard = BuildWizard(out var drafts, out _); + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + Title = "T", + System = "Dnd5e", + DurationMinutes = 240, + }; + var draft = NewDraft(WizardStepNames.DateTime, payload); + drafts.Seed(draft); + + // 2020-01-01 is firmly in the past + await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.DateTime, draft.Step); + } + + [Fact] + public async Task UnparseableDate_StaysOnDateTimeStep() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.DateTime); + drafts.Seed(draft); + + await wizard.HandleUpdateAsync(TextUpdate("not a date"), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.DateTime, draft.Step); + } + + [Fact] + public async Task BadCoverUrl_StaysOnCoverStep() + { + var wizard = BuildWizard(out var drafts, out _); + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + Title = "T", + Description = "D", + }; + var draft = NewDraft(WizardStepNames.Cover, payload); + drafts.Seed(draft); + + await wizard.HandleUpdateAsync(TextUpdate("not a url"), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Cover, draft.Step); + } + + [Fact] + public async Task ValidCoverUrl_AdvancesToSystem() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.Cover, + new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" }); + drafts.Seed(draft); + + await wizard.HandleUpdateAsync(TextUpdate("https://example.com/x.jpg"), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.System, draft.Step); + } + + [Fact] + public async Task SkipCover_Dash_AdvancesToSystem() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.Cover, + new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" }); + drafts.Seed(draft); + + await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.System, draft.Step); + } + + [Theory] + [InlineData("0")] + [InlineData("51")] + [InlineData("not a number")] + public async Task OutOfRangeCapacity_StaysOnCapacityStep(string input) + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.Capacity, + new WizardPayload + { + Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, + Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) }, + }); + drafts.Seed(draft); + + await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Capacity, draft.Step); + } + + [Theory] + [InlineData("0")] + [InlineData("13")] + [InlineData("not-a-duration")] + public async Task OutOfRangeDuration_StaysOnDurationStep(string input) + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.Duration, + new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" }); + drafts.Seed(draft); + + await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Duration, draft.Step); + } + + [Fact] + public async Task EmptyDescription_SkipDash_AdvancesToCover() + { + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.Description, + new WizardPayload { Type = WizardCreationType.Single, Title = "T" }); + drafts.Seed(draft); + + await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Cover, draft.Step); + } + + [Fact] + public async Task TextOnSystem_OtherBranch_AdvancesToDuration() + { + // The wizard's System step offers an "Другое… ✏️" choice which arms the + // step for free-text entry of a custom system name. Once armed + // (i.e. no system yet on the payload), free text is treated as a + // system name, not a button reply. + var wizard = BuildWizard(out var drafts, out _); + var draft = NewDraft(WizardStepNames.System, + new WizardPayload { Type = WizardCreationType.Single, Title = "T" }); + drafts.Seed(draft); + + await wizard.HandleUpdateAsync(TextUpdate("CustomSystem"), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Duration, draft.Step); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs new file mode 100644 index 0000000..3ae4255 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs @@ -0,0 +1,260 @@ +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; + +/// +/// 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 ч"). +/// +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)); + } + + [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(() => 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(); +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs new file mode 100644 index 0000000..ace85e3 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using Microsoft.Extensions.Logging.Abstractions; +using Telegram.Bot.Types; +using Telegram.Bot.Types.ReplyMarkups; +using WizardBot = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.GameCreationWizard; +using WizardMessenger = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.ITelegramWizardMessenger; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Hand-rolled test doubles and helpers for wizard unit tests. The project +/// convention is to use fakes (not a mocking framework) so the suite stays +/// AOT-friendly and the production code doesn't grow virtual members just +/// for tests. +/// +internal static class WizardTestFakes +{ + public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger) + { + drafts = new FakeWizardDraftRepository(); + messenger = new FakeWizardMessenger(); + return new WizardBot(drafts, messenger, NullLogger.Instance); + } + + public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new() + { + Id = Guid.NewGuid(), + ChatId = 42, + MessageThreadId = null, + OwnerTelegramId = ownerId, + Step = step, + DraftMessageId = 7, + PayloadJson = System.Text.Json.JsonSerializer.Serialize( + payload ?? new WizardPayload(), + WizardPayloadJsonContext.Default.WizardPayload), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.AddHours(24), + }; + + public static Update CallbackUpdate(string data, long ownerId = 100) => new() + { + CallbackQuery = new CallbackQuery + { + Id = "cb-1", + Data = data, + From = new User { Id = ownerId, FirstName = "GM" }, + Message = new Message + { + Chat = new Chat { Id = 42 }, + }, + }, + }; + + public static Update TextUpdate(string text, long ownerId = 100) => new() + { + Message = new Message + { + Text = text, + Chat = new Chat { Id = 42 }, + From = new User { Id = ownerId, FirstName = "GM" }, + }, + }; +} + +/// +/// Records every call the wizard makes against the draft repository. Backed by +/// an in-memory dictionary so tests can pre-seed an "active" draft for the +/// wizard to mutate. +/// +internal sealed class FakeWizardDraftRepository : IWizardDraftRepository +{ + private readonly Dictionary store = new(); + + public List DeletedIds { get; } = new(); + + public List Upserts { get; } = new(); + + public int ExpiredDeleted { get; set; } + + public void Seed(WizardDraft draft) => store[draft.Id] = draft; + + public Task GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct) + { + foreach (var d in store.Values) + { + if (d.ChatId == chatId && + d.MessageThreadId == messageThreadId && + d.OwnerTelegramId == ownerTelegramId && + d.ExpiresAt > DateTimeOffset.UtcNow) + { + return Task.FromResult(d); + } + } + return Task.FromResult(null); + } + + public Task UpsertAsync(WizardDraft draft, CancellationToken ct) + { + // Clone so tests can compare state without aliasing. + Upserts.Add(new WizardDraft + { + Id = draft.Id, + ChatId = draft.ChatId, + MessageThreadId = draft.MessageThreadId, + OwnerTelegramId = draft.OwnerTelegramId, + Step = draft.Step, + PayloadJson = draft.PayloadJson, + DraftMessageId = draft.DraftMessageId, + CreatedAt = draft.CreatedAt, + UpdatedAt = draft.UpdatedAt, + ExpiresAt = draft.ExpiresAt, + }); + store[draft.Id] = draft; + return Task.CompletedTask; + } + + public Task DeleteAsync(Guid id, CancellationToken ct) + { + DeletedIds.Add(id); + store.Remove(id); + return Task.CompletedTask; + } + + public Task DeleteExpiredAsync(CancellationToken ct) + { + var count = ExpiredDeleted; + ExpiredDeleted = 0; + return Task.FromResult(count); + } +} + +/// +/// Records every call the wizard makes against the messenger. Default return +/// values (empty clubs, message-id 1) match what the wizard expects to see +/// in steady state. +/// +internal sealed class FakeWizardMessenger : ITelegramWizardMessenger +{ + public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new(); + + public List AnsweredCallbacks { get; } = new(); + + public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new(); + + public IReadOnlyList Clubs { get; set; } = Array.Empty(); + + public Task EditMessageTextAsync( + long chatId, + int? messageThreadId, + long messageId, + string text, + InlineKeyboardMarkup keyboard, + CancellationToken ct) + { + Edits.Add((chatId, messageThreadId, messageId, text)); + return Task.FromResult(messageId); + } + + public Task SendGroupMessageAsync( + long chatId, + int? messageThreadId, + string text, + InlineKeyboardMarkup keyboard, + CancellationToken ct) + { + Sends.Add((chatId, messageThreadId, text)); + return Task.FromResult(99L); + } + + public Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct) + { + AnsweredCallbacks.Add(callbackId); + return Task.CompletedTask; + } + + public Task> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct) + => Task.FromResult(Clubs); +}