Compare commits

...

4 Commits

Author SHA1 Message Date
Toutsu e3e6e841b8 Merge pull request #128: fix(bot): keep Capacity and PickClub wizard steps consistent (v3.9.5)
Deploy Telegram Bot / build-and-push (push) Successful in 7m9s
Deploy Telegram Bot / scan-images (push) Successful in 2m23s
Deploy Telegram Bot / deploy (push) Successful in 54s
Closes #127.
2026-06-08 22:51:04 +03:00
Toutsu a0a84965b3 chore: bump version 3.9.4 -> 3.9.5
PR Checks / test-and-build (pull_request) Successful in 10m22s
Bugfix patch release for issue #127. Sync the four canonical version sources:
- Directory.Build.props
- compose.yaml (bot, discord, web image tags)
- .gitea/workflows/deploy.yml (VERSION env)
- src/GmRelay.Web/Components/Layout/NavMenu.razor (visible nav-version)
2026-06-08 22:34:27 +03:00
Toutsu 67e8d5b558 fix(bot): keep capacity and club wizard steps consistent
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
2026-06-08 22:34:18 +03:00
Toutsu 593f8a62fb Merge pull request #126: test: cleanup follow-up from PR #124 review (v3.9.4)
Deploy Telegram Bot / build-and-push (push) Successful in 6m15s
Deploy Telegram Bot / scan-images (push) Successful in 2m20s
Deploy Telegram Bot / deploy (push) Successful in 46s
Closes #125.
2026-06-08 19:27:58 +03:00
7 changed files with 69 additions and 33 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 3.9.4 VERSION: 3.9.5
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>3.9.4</Version> <Version>3.9.5</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.4 image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.5
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,7 +67,7 @@ services:
retries: 3 retries: 3
discord: discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.4 image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.5
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -86,7 +86,7 @@ services:
retries: 3 retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.4 image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.5
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -298,13 +298,25 @@ public sealed class GameCreationWizard
: (null, "Неверная длительность"), : (null, "Неверная длительность"),
}; };
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice)
{ {
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)), if (choice is "no_limit")
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)), {
"no_limit" => (WizardStepNames.Visibility, SetMaxPlayers(p, null)), return (WizardStepNames.Visibility, SetMaxPlayers(p, null));
_ => (null, "Неизвестный выбор"), }
};
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
{
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
}
return choice switch
{
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
_ => (null, "Неизвестный выбор"),
};
}
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
{ {
@@ -316,9 +328,15 @@ public sealed class GameCreationWizard
}; };
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice) private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
=> Guid.TryParse(choice, out var id) {
? (NextAfterVisibility(p), SetClubId(p, id)) if (!Guid.TryParse(choice, out var id))
: (null, "Неверный идентификатор клуба"); {
return (null, "Неверный идентификатор клуба");
}
var error = SetClubId(p, id);
return (NextAfterVisibility(p), error);
}
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
{ {
@@ -82,7 +82,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v3.9.4</div> <div class="nav-version">v3.9.5</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -20,9 +20,7 @@ public sealed class GameCreationWizardStepTransitionsTests
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)] [InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
// Duration → DateTime (single, no maxPlayers yet) // Duration → DateTime (single, no maxPlayers yet)
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)] [InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
// Capacity → Visibility // Capacity → Visibility (only explicit no-limit can skip numeric capacity)
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)] [InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)]
// Visibility → Publish (public, no club) // Visibility → Publish (public, no club)
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)] [InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
@@ -98,13 +96,11 @@ public sealed class GameCreationWizardStepTransitionsTests
} }
[Fact] [Fact]
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep() public async Task StaleCapacityWaitlistCallback_WithoutCapacity_StaysOnCurrentStep()
{ {
// The wizard's callback parser uses the step encoded in the callback // A stale waitlist button from Capacity must not move a draft forward
// (not the draft's current step) to drive transitions. So a stale // unless MaxPlayers is already set. Otherwise users can reach Confirm
// "Capacity" button pressed while the user is on System will in fact // with a missing capacity and get "Не заполнены поля: лимит мест".
// 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 wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System); var draft = NewDraft(WizardStepNames.System);
drafts.Seed(draft); drafts.Seed(draft);
@@ -112,17 +108,13 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"); var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step); Assert.Equal(WizardStepNames.System, draft.Step);
} }
[Fact] [Fact]
public async Task PickClub_ValidGuid_ReachesStableStep() public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
{ {
// The wizard has a quirk: NextAfterVisibility is evaluated before var wizard = BuildWizard(out var drafts, out _);
// 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 clubId = Guid.NewGuid();
var payload = new WizardPayload var payload = new WizardPayload
{ {
@@ -138,8 +130,11 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString()); var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
// Wizard acknowledged the callback and re-rendered the (still PickClub) step. Assert.Equal(WizardStepNames.Publish, draft.Step);
Assert.NotEmpty(messenger.Edits); using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("clubId", out var clubIdJson));
Assert.Equal(clubId, clubIdJson.GetGuid());
} }
[Fact] [Fact]
@@ -136,6 +136,29 @@ public sealed class GameCreationWizardValidationTests
Assert.Equal(WizardStepNames.Capacity, draft.Step); 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] [Theory]
[InlineData("0")] [InlineData("0")]
[InlineData("13")] [InlineData("13")]