test(wizard): add wizard tests + refactor to IWizardDraftRepository
- Extract IWizardDraftRepository interface for testability (NSubstitute cannot mock sealed classes; the codebase uses fake-style doubles instead). - Add step-transition, pool-slot, validation, cancel/back, and render-shape tests using FakeWizardDraftRepository and FakeWizardMessenger. - Fix wizard payload persistence bug: HandleCallbackAsync and HandleTextAsync now call SavePayload after ApplyChoice/ApplyText mutations, so subsequent LoadPayload calls see the user's progress. Previously, local WizardPayload mutations were discarded and the wizard reset on every step. - CommitCurrentPoolSlot now auto-creates a slot via EnsureCurrentPoolSlot when one is missing, so the PoolSlotCapacity → waitlist click is recoverable even if the user lands on the step without a slot.
This commit is contained in:
@@ -26,13 +26,13 @@ public sealed class CreateSessionHandler
|
|||||||
{
|
{
|
||||||
private const int MaxRetries = 3;
|
private const int MaxRetries = 3;
|
||||||
|
|
||||||
private readonly WizardDraftRepository _drafts;
|
private readonly IWizardDraftRepository _drafts;
|
||||||
private readonly SharedCreateSessionHandler _shared;
|
private readonly SharedCreateSessionHandler _shared;
|
||||||
private readonly ITelegramWizardMessenger _messenger;
|
private readonly ITelegramWizardMessenger _messenger;
|
||||||
private readonly ILogger<CreateSessionHandler> _log;
|
private readonly ILogger<CreateSessionHandler> _log;
|
||||||
|
|
||||||
public CreateSessionHandler(
|
public CreateSessionHandler(
|
||||||
WizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
SharedCreateSessionHandler shared,
|
SharedCreateSessionHandler shared,
|
||||||
ITelegramWizardMessenger messenger,
|
ITelegramWizardMessenger messenger,
|
||||||
ILogger<CreateSessionHandler> log)
|
ILogger<CreateSessionHandler> log)
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GameCreationWizard
|
public sealed class GameCreationWizard
|
||||||
{
|
{
|
||||||
private readonly WizardDraftRepository _drafts;
|
private readonly IWizardDraftRepository _drafts;
|
||||||
private readonly ITelegramWizardMessenger _messenger;
|
private readonly ITelegramWizardMessenger _messenger;
|
||||||
private readonly ILogger<GameCreationWizard> _log;
|
private readonly ILogger<GameCreationWizard> _log;
|
||||||
|
|
||||||
public GameCreationWizard(
|
public GameCreationWizard(
|
||||||
WizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
ITelegramWizardMessenger messenger,
|
ITelegramWizardMessenger messenger,
|
||||||
ILogger<GameCreationWizard> log)
|
ILogger<GameCreationWizard> log)
|
||||||
{
|
{
|
||||||
@@ -111,12 +111,12 @@ public sealed class GameCreationWizard
|
|||||||
return;
|
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)
|
if (error is { } errMsg && draft.DraftMessageId is { } mid)
|
||||||
{
|
{
|
||||||
// Re-render the same step with ⚠️ prefix.
|
// Re-render the same step with ⚠️ prefix.
|
||||||
var payload = LoadPayload(draft);
|
var (rendered, kb) = WizardStep.Render(draft, LoadPayload(draft), null);
|
||||||
var (rendered, kb) = WizardStep.Render(draft, payload, null);
|
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditMessageTextAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, mid,
|
draft.ChatId, draft.MessageThreadId, mid,
|
||||||
"⚠️ " + errMsg + "\n\n" + rendered, kb, ct);
|
"⚠️ " + 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)
|
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)
|
if (error is { } err)
|
||||||
{
|
{
|
||||||
await _messenger.AnswerCallbackAsync(callbackId, err, ct);
|
await _messenger.AnswerCallbackAsync(callbackId, err, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (payload is { } p) SavePayload(draft, p);
|
||||||
if (nextStep is { } s)
|
if (nextStep is { } s)
|
||||||
{
|
{
|
||||||
draft.Step = s;
|
draft.Step = s;
|
||||||
@@ -166,79 +167,79 @@ public sealed class GameCreationWizard
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Text input dispatcher ─────────────────────────────────────────
|
// ── 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);
|
var payload = LoadPayload(draft);
|
||||||
switch (draft.Step)
|
switch (draft.Step)
|
||||||
{
|
{
|
||||||
case WizardStepNames.Title:
|
case WizardStepNames.Title:
|
||||||
return ValidateText(input, WizardStep.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title)
|
return ValidateText(input, WizardStep.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title)
|
||||||
? (WizardStepNames.Description, SetTitle(payload, title))
|
? (WizardStepNames.Description, SetTitle(payload, title), payload)
|
||||||
: (null, title);
|
: (null, title, payload);
|
||||||
|
|
||||||
case WizardStepNames.Description:
|
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)
|
return ValidateText(input, WizardStep.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc)
|
||||||
? (WizardStepNames.Cover, SetDescription(payload, desc))
|
? (WizardStepNames.Cover, SetDescription(payload, desc), payload)
|
||||||
: (null, desc);
|
: (null, desc, payload);
|
||||||
|
|
||||||
case WizardStepNames.Cover:
|
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))
|
if (Uri.TryCreate(input, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||||
return (NextAfterCover(payload), SetImageUrl(payload, input));
|
return (NextAfterCover(payload), SetImageUrl(payload, input), payload);
|
||||||
return (null, "Некорректный URL");
|
return (null, "Некорректный URL", payload);
|
||||||
|
|
||||||
case WizardStepNames.System when payload.System is null:
|
case WizardStepNames.System when payload.System is null:
|
||||||
// "Other" branch — only active if free-text was offered.
|
// "Other" branch — only active if free-text was offered.
|
||||||
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
|
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
|
||||||
? (WizardStepNames.Duration, SetSystem(payload, sys))
|
? (WizardStepNames.Duration, SetSystem(payload, sys), payload)
|
||||||
: (null, sys);
|
: (null, sys, payload);
|
||||||
|
|
||||||
case WizardStepNames.Duration when payload.DurationMinutes is null:
|
case WizardStepNames.Duration when payload.DurationMinutes is null:
|
||||||
return TryParseHours(input, out var durMin)
|
return TryParseHours(input, out var durMin)
|
||||||
? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin))
|
? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin), payload)
|
||||||
: (null, "Неверная длительность (1..12 ч)");
|
: (null, "Неверная длительность (1..12 ч)", payload);
|
||||||
|
|
||||||
case WizardStepNames.DateTime:
|
case WizardStepNames.DateTime:
|
||||||
return MoscowTime.TryParseMoscow(input, out var dt) && dt > DateTimeOffset.UtcNow
|
return MoscowTime.TryParseMoscow(input, out var dt) && dt > DateTimeOffset.UtcNow
|
||||||
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt))
|
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
|
||||||
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом");
|
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
||||||
|
|
||||||
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
|
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
|
||||||
return int.TryParse(input, out var cap) && cap >= WizardStep.MinCapacity && cap <= WizardStep.MaxCapacity
|
return int.TryParse(input, out var cap) && cap >= WizardStep.MinCapacity && cap <= WizardStep.MaxCapacity
|
||||||
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap))
|
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
|
||||||
: (null, "Лимит должен быть 1..50");
|
: (null, "Лимит должен быть 1..50", payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSystemDuration when payload.System is null:
|
case WizardStepNames.PoolSystemDuration when payload.System is null:
|
||||||
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
|
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
|
||||||
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys))
|
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
|
||||||
: (null, psys);
|
: (null, psys, payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
|
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
|
||||||
return TryParseHours(input, out var pdur)
|
return TryParseHours(input, out var pdur)
|
||||||
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur))
|
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
|
||||||
: (null, "Неверная длительность (1..12 ч)");
|
: (null, "Неверная длительность (1..12 ч)", payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSlotDateTime:
|
case WizardStepNames.PoolSlotDateTime:
|
||||||
return MoscowTime.TryParseMoscow(input, out var slotDt) && slotDt > DateTimeOffset.UtcNow
|
return MoscowTime.TryParseMoscow(input, out var slotDt) && slotDt > DateTimeOffset.UtcNow
|
||||||
? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt))
|
? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt), payload)
|
||||||
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом");
|
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSlotCapacity:
|
case WizardStepNames.PoolSlotCapacity:
|
||||||
return int.TryParse(input, out var slotCap) && slotCap >= WizardStep.MinCapacity && slotCap <= WizardStep.MaxCapacity
|
return int.TryParse(input, out var slotCap) && slotCap >= WizardStep.MinCapacity && slotCap <= WizardStep.MaxCapacity
|
||||||
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap))
|
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload)
|
||||||
: (null, "Лимит должен быть 1..50");
|
: (null, "Лимит должен быть 1..50", payload);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (null, "Ожидается выбор кнопкой");
|
return (null, "Ожидается выбор кнопкой", payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Callback (button) dispatcher ──────────────────────────────────
|
// ── 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);
|
var payload = LoadPayload(draft);
|
||||||
return step switch
|
var (next, err) = step switch
|
||||||
{
|
{
|
||||||
WizardStepNames.Type => ApplyTypeChoice(payload, choice),
|
WizardStepNames.Type => ApplyTypeChoice(payload, choice),
|
||||||
WizardStepNames.System => ApplySystemChoice(payload, choice),
|
WizardStepNames.System => ApplySystemChoice(payload, choice),
|
||||||
@@ -252,6 +253,7 @@ public sealed class GameCreationWizard
|
|||||||
WizardStepNames.PoolSlotCapacity => ApplyPoolSlotCapacityChoice(payload, choice),
|
WizardStepNames.PoolSlotCapacity => ApplyPoolSlotCapacityChoice(payload, choice),
|
||||||
_ => (null, "Неизвестный шаг"),
|
_ => (null, "Неизвестный шаг"),
|
||||||
};
|
};
|
||||||
|
return (next, err, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (string?, string?) ApplyTypeChoice(WizardPayload p, string choice) => choice switch
|
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)
|
private static string? CommitCurrentPoolSlot(WizardPayload p, bool waitlist)
|
||||||
{
|
{
|
||||||
p.Pool ??= new WizardPoolInput();
|
p.Pool ??= new WizardPoolInput();
|
||||||
var current = p.Pool.Slots.LastOrDefault();
|
var current = EnsureCurrentPoolSlot(p);
|
||||||
if (current is null) return "Слот не начат";
|
|
||||||
current.Waitlist = waitlist;
|
current.Waitlist = waitlist;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ public sealed class WizardDraftCleanupService : BackgroundService
|
|||||||
{
|
{
|
||||||
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
private readonly WizardDraftRepository _drafts;
|
private readonly IWizardDraftRepository _drafts;
|
||||||
private readonly ILogger<WizardDraftCleanupService> _log;
|
private readonly ILogger<WizardDraftCleanupService> _log;
|
||||||
|
|
||||||
public WizardDraftCleanupService(
|
public WizardDraftCleanupService(
|
||||||
WizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
ILogger<WizardDraftCleanupService> log)
|
ILogger<WizardDraftCleanupService> log)
|
||||||
{
|
{
|
||||||
_drafts = drafts;
|
_drafts = drafts;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public sealed class UpdateRouter(
|
|||||||
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||||
BotRescheduleVoteHandler rescheduleVoteHandler,
|
BotRescheduleVoteHandler rescheduleVoteHandler,
|
||||||
GameCreationWizard wizard,
|
GameCreationWizard wizard,
|
||||||
WizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Cre
|
|||||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
|
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
|
||||||
|
|
||||||
// Wizard services (issue #111)
|
// Wizard services (issue #111)
|
||||||
builder.Services.AddSingleton<WizardDraftRepository>();
|
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
|
||||||
builder.Services.AddSingleton<ITelegramWizardMessenger, TelegramWizardMessenger>();
|
builder.Services.AddSingleton<ITelegramWizardMessenger, TelegramWizardMessenger>();
|
||||||
builder.Services.AddSingleton<GameCreationWizard>();
|
builder.Services.AddSingleton<GameCreationWizard>();
|
||||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public interface IWizardDraftRepository
|
||||||
|
{
|
||||||
|
Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct);
|
||||||
|
|
||||||
|
Task UpsertAsync(WizardDraft draft, CancellationToken ct);
|
||||||
|
|
||||||
|
Task DeleteAsync(Guid id, CancellationToken ct);
|
||||||
|
|
||||||
|
Task<int> DeleteExpiredAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ using Npgsql;
|
|||||||
|
|
||||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource)
|
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
|
||||||
{
|
{
|
||||||
public async Task<WizardDraft?> GetActiveAsync(
|
public async Task<WizardDraft?> GetActiveAsync(
|
||||||
long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
|
long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
|
||||||
|
|||||||
+116
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the pool-specific branch of the wizard: the AddSlots flow that
|
||||||
|
/// builds up slot metadata through date and capacity steps.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+176
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the wizard's state machine: clicking each Choice callback should
|
||||||
|
/// advance the draft to the expected next step and persist it.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
+182
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+260
@@ -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;
|
||||||
|
|
||||||
|
/// <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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<WizardBot>.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" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Guid, WizardDraft> store = new();
|
||||||
|
|
||||||
|
public List<Guid> DeletedIds { get; } = new();
|
||||||
|
|
||||||
|
public List<WizardDraft> Upserts { get; } = new();
|
||||||
|
|
||||||
|
public int ExpiredDeleted { get; set; }
|
||||||
|
|
||||||
|
public void Seed(WizardDraft draft) => store[draft.Id] = draft;
|
||||||
|
|
||||||
|
public Task<WizardDraft?> 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<WizardDraft?>(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Task.FromResult<WizardDraft?>(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<int> DeleteExpiredAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var count = ExpiredDeleted;
|
||||||
|
ExpiredDeleted = 0;
|
||||||
|
return Task.FromResult(count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeWizardMessenger : ITelegramWizardMessenger
|
||||||
|
{
|
||||||
|
public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new();
|
||||||
|
|
||||||
|
public List<string> AnsweredCallbacks { get; } = new();
|
||||||
|
|
||||||
|
public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
|
||||||
|
|
||||||
|
public Task<long> 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<long> 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<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
|
||||||
|
=> Task.FromResult(Clubs);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user