67e8d5b558
Fix two wizard FSM bugs reported after v3.9.4: 1. Capacity waitlist buttons could still advance the draft without a numeric MaxPlayers value. The final submit validation then rejected the draft with 'Не заполнены поля: лимит мест'. Now waitlist:on/off stay on Capacity until MaxPlayers is set; users must either enter a numeric limit or explicitly choose '♾ Без лимита'. 2. PickClub computed NextAfterVisibility before SetClubId, so the first club click left the wizard on PickClub and the second click advanced. Now ClubId is saved first and NextAfterVisibility is evaluated after that mutation, so a valid club click advances on the first try. TDD: - WaitlistChoiceWithoutCapacity_StaysOnCapacityStep covers waitlist:on/off. - PickClub_ValidGuid_AdvancesToPublishOnFirstClick covers the single-click club path. - Stale Capacity waitlist callback test updated to the safer no-advance contract. Closes #127
208 lines
7.5 KiB
C#
208 lines
7.5 KiB
C#
using System;
|
|
using System.Text.Json;
|
|
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.HandleInteractionAsync(TextInteraction(" ", ownerId: draft.OwnerId), 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', WizardStepLimits.MaxTitleLength + 1);
|
|
await wizard.HandleInteractionAsync(TextInteraction(tooLong, ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("not a date", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("not a url", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("https://example.com/x.jpg", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
|
|
|
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("waitlist:on")]
|
|
[InlineData("waitlist:off")]
|
|
public async Task WaitlistChoiceWithoutCapacity_StaysOnCapacityStep(string choice)
|
|
{
|
|
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);
|
|
|
|
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, choice);
|
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("CustomSystem", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
|
|
|
Assert.Equal(WizardStepNames.Duration, draft.Step);
|
|
}
|
|
}
|