Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2241568bac | |||
| 37ed697696 | |||
| 320ec18ab0 | |||
| 4424d8faad | |||
| 1f3fb6e89e | |||
| e3e6e841b8 | |||
| a0a84965b3 | |||
| 67e8d5b558 | |||
| 593f8a62fb | |||
| aee0ac1e6c | |||
| 68945d931f | |||
| 3db2b703d6 | |||
| 3c3ef8db5a | |||
| 5c0397a5e6 | |||
| 15040eb954 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 3.9.2
|
VERSION: 3.9.7
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.9.2</Version>
|
<Version>3.9.7</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.2
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.7
|
||||||
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.2
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.7
|
||||||
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.2
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.7
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
|
|||||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Устанавливаем wget для healthcheck
|
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
# и HTTPS-handshake Telegram.Bot (без неё long-polling падает на первом запросе).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
wget libgssapi-krb5-2 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Копируем только AOT-результаты из билда
|
# Копируем только AOT-результаты из билда
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ public sealed class CreateSessionHandler
|
|||||||
draft,
|
draft,
|
||||||
p,
|
p,
|
||||||
new[] { p.Single?.ScheduledAt ?? default },
|
new[] { p.Single?.ScheduledAt ?? default },
|
||||||
p.Single?.MaxPlayers ?? 0,
|
p.Single?.MaxPlayers,
|
||||||
isOneShot: true),
|
isOneShot: true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -178,11 +178,11 @@ public sealed class CreateSessionHandler
|
|||||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||||
|
|
||||||
private static CreateSessionCommand BuildCommand(
|
internal static CreateSessionCommand BuildCommand(
|
||||||
WizardDraft draft,
|
WizardDraft draft,
|
||||||
WizardPayload p,
|
WizardPayload p,
|
||||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||||
int maxPlayers,
|
int? maxPlayers,
|
||||||
bool isOneShot)
|
bool isOneShot)
|
||||||
{
|
{
|
||||||
var user = new PlatformUser(
|
var user = new PlatformUser(
|
||||||
@@ -229,7 +229,8 @@ public sealed class CreateSessionHandler
|
|||||||
if (p.Type == WizardCreationType.Single)
|
if (p.Type == WizardCreationType.Single)
|
||||||
{
|
{
|
||||||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
||||||
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
|
// MaxPlayers = null is a valid "♾ Без лимита" choice
|
||||||
|
// (see GameCreationWizard.ApplyCapacityChoice "no_limit").
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -252,11 +252,12 @@ public static class DiscordWizardStep
|
|||||||
|
|
||||||
private static DiscordWizardRender RenderCapacity() => new(
|
private static DiscordWizardRender RenderCapacity() => new(
|
||||||
"👥 Лимит мест",
|
"👥 Лимит мест",
|
||||||
"Введите лимит (1..50) и выберите waitlist.",
|
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
|
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
|
||||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
|
ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
|
||||||
|
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.Capacity, "no_limit", ButtonStyle.Primary)),
|
||||||
Row(ControlBtn("⬅️ Назад", "back"),
|
Row(ControlBtn("⬅️ Назад", "back"),
|
||||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||||
},
|
},
|
||||||
@@ -371,11 +372,12 @@ public static class DiscordWizardStep
|
|||||||
|
|
||||||
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
|
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
|
||||||
"👥 Лимит слотов",
|
"👥 Лимит слотов",
|
||||||
"Введите лимит (1..50) и выберите waitlist.",
|
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
|
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
|
||||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
|
ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
|
||||||
|
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.PoolSlotCapacity, "no_limit", ButtonStyle.Primary)),
|
||||||
Row(ControlBtn("⬅️ Назад", "back"),
|
Row(ControlBtn("⬅️ Назад", "back"),
|
||||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||||
},
|
},
|
||||||
@@ -417,6 +419,10 @@ public static class DiscordWizardStep
|
|||||||
{
|
{
|
||||||
sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
||||||
}
|
}
|
||||||
|
else if (p.Type == WizardCreationType.Single)
|
||||||
|
{
|
||||||
|
sb.Append("👥 Без лимита, waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
||||||
|
}
|
||||||
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
|
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ public sealed class DiscordWizardSubmitter
|
|||||||
draft,
|
draft,
|
||||||
p,
|
p,
|
||||||
new[] { p.Single?.ScheduledAt ?? default },
|
new[] { p.Single?.ScheduledAt ?? default },
|
||||||
p.Single?.MaxPlayers ?? 0,
|
p.Single?.MaxPlayers,
|
||||||
isOneShot: true),
|
isOneShot: true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -142,11 +142,11 @@ public sealed class DiscordWizardSubmitter
|
|||||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||||
|
|
||||||
private static CreateSessionCommand BuildCommand(
|
internal static CreateSessionCommand BuildCommand(
|
||||||
WizardDraft draft,
|
WizardDraft draft,
|
||||||
WizardPayload p,
|
WizardPayload p,
|
||||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||||
int maxPlayers,
|
int? maxPlayers,
|
||||||
bool isOneShot)
|
bool isOneShot)
|
||||||
{
|
{
|
||||||
var user = new PlatformUser(
|
var user = new PlatformUser(
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
|
||||||
@@ -298,12 +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)),
|
{
|
||||||
_ => (null, "Неизвестный выбор"),
|
return (WizardStepNames.Visibility, SetMaxPlayers(p, 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
|
||||||
{
|
{
|
||||||
@@ -315,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
|
||||||
{
|
{
|
||||||
@@ -416,7 +435,7 @@ public sealed class GameCreationWizard
|
|||||||
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
|
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
|
||||||
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
|
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
|
||||||
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
|
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
|
||||||
private static string? SetMaxPlayers(WizardPayload p, int v)
|
private static string? SetMaxPlayers(WizardPayload p, int? v)
|
||||||
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
|
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
|
||||||
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
|
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
|
||||||
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
|
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
|
||||||
|
|||||||
@@ -97,11 +97,12 @@ public static class WizardStepViewBuilder
|
|||||||
BackCancel());
|
BackCancel());
|
||||||
|
|
||||||
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
|
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
|
||||||
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
|
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist. Или сразу «♾ Без лимита».",
|
||||||
new List<WizardAction>
|
new List<WizardAction>
|
||||||
{
|
{
|
||||||
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
|
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
|
||||||
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
|
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||||
|
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
|
||||||
});
|
});
|
||||||
|
|
||||||
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v3.9.2</div>
|
<div class="nav-version">v3.9.7</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using NetCord.Rest;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renderer tests for the Discord wizard's Capacity / PoolSlotCapacity steps.
|
||||||
|
/// Locks in the presence of the "♾ Без лимита" button so the user can pick
|
||||||
|
/// a session with no player cap (null in <c>sessions.max_players</c>), the
|
||||||
|
/// same affordance the Telegram wizard provides.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DiscordWizardStepCapacityRenderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RenderCapacity_ContainsNoLimitButton()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft { Step = WizardStepNames.Capacity };
|
||||||
|
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||||
|
|
||||||
|
var labels = ExtractButtonLabels(render);
|
||||||
|
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderPoolSlotCapacity_ContainsNoLimitButton()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft { Step = WizardStepNames.PoolSlotCapacity };
|
||||||
|
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||||
|
|
||||||
|
var labels = ExtractButtonLabels(render);
|
||||||
|
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(WizardStepNames.Capacity, "wizard:btn:choice:Capacity:no_limit")]
|
||||||
|
[InlineData(WizardStepNames.PoolSlotCapacity, "wizard:btn:choice:PoolSlotCapacity:no_limit")]
|
||||||
|
public void Render_NoLimitButton_HasChoiceCustomIdForNoLimit(string step, string expectedCustomIdPrefix)
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft { Step = step };
|
||||||
|
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||||
|
|
||||||
|
var buttons = ExtractButtons(render);
|
||||||
|
var noLimit = buttons.SingleOrDefault(b => b.Label?.Contains("Без лимита", System.StringComparison.Ordinal) == true);
|
||||||
|
Assert.NotNull(noLimit);
|
||||||
|
Assert.StartsWith(expectedCustomIdPrefix, noLimit!.CustomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static System.Collections.Generic.List<string> ExtractButtonLabels(
|
||||||
|
DiscordWizardStep.DiscordWizardRender render) =>
|
||||||
|
render.Components
|
||||||
|
.OfType<ActionRowProperties>()
|
||||||
|
.SelectMany(r => r.Components)
|
||||||
|
.OfType<ButtonProperties>()
|
||||||
|
.Select(b => b.Label ?? string.Empty)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static System.Collections.Generic.List<ButtonProperties> ExtractButtons(
|
||||||
|
DiscordWizardStep.DiscordWizardRender render) =>
|
||||||
|
render.Components
|
||||||
|
.OfType<ActionRowProperties>()
|
||||||
|
.SelectMany(r => r.Components)
|
||||||
|
.OfType<ButtonProperties>()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for <see cref="DiscordWizardSubmitter"/>'s
|
||||||
|
/// <c>BuildCommand</c>: when the wizard payload carries no player limit
|
||||||
|
/// (user picked «♾ Без лимита» on the Capacity step), the resulting
|
||||||
|
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never
|
||||||
|
/// <c>0</c>. <c>0</c> would violate the DB CHECK
|
||||||
|
/// <c>ck_sessions_max_players</c> in V006 and the contract that
|
||||||
|
/// <c>null</c> means "no limit".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DiscordWizardSubmitterBuildCommandTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Null(cmd.MaxPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Equal(5, cmd.MaxPlayers);
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for <see cref="CreateSessionHandler.BuildCommand"/>:
|
||||||
|
/// when the wizard payload carries no player limit (e.g. user picked
|
||||||
|
/// «♾ Без лимита» on the Capacity step), the resulting
|
||||||
|
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never <c>0</c>.
|
||||||
|
/// <c>0</c> would violate the DB CHECK constraint
|
||||||
|
/// (<c>ck_sessions_max_players</c> in V006) by inserting <c>0</c> instead of
|
||||||
|
/// <c>NULL</c>, which is the wire-level representation of "no limit".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateSessionHandlerBuildCommandTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = CreateSessionHandler.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Null(cmd.MaxPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = CreateSessionHandler.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Equal(5, cmd.MaxPlayers);
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -146,4 +146,47 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Assert.Single(messenger.Edits);
|
Assert.Single(messenger.Edits);
|
||||||
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitDraftAsync_SingleWithNoLimit_DoesNotReportMaxPlayersAsMissing()
|
||||||
|
{
|
||||||
|
// Regression for #131: pressing "♾ Без лимита" sets MaxPlayers = null.
|
||||||
|
// IsComplete must NOT flag that as a missing field; null means
|
||||||
|
// "no player limit" and is a valid final state.
|
||||||
|
var drafts = new FakeWizardDraftRepository();
|
||||||
|
var messenger = new FakeWizardMessenger();
|
||||||
|
|
||||||
|
var sut = new CreateSessionHandler(
|
||||||
|
drafts,
|
||||||
|
shared: null!,
|
||||||
|
messenger,
|
||||||
|
NullLogger<CreateSessionHandler>.Instance);
|
||||||
|
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||||
|
MaxPlayers = null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var draft = NewDraft(WizardStepNames.Confirm, payload);
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
await sut.SubmitDraftAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
|
// Validation must let the no-limit payload through. The shared
|
||||||
|
// handler is null, so anything that reached the database call would
|
||||||
|
// throw a NullReferenceException — that is caught by the retry
|
||||||
|
// path and reported as a "💥 Ошибка:" edit, not a missing-fields
|
||||||
|
// edit. Therefore we assert that NO edit mentions a missing field.
|
||||||
|
Assert.NotEmpty(messenger.Edits);
|
||||||
|
var lastEdit = messenger.Edits[^1].Text;
|
||||||
|
Assert.DoesNotContain("Не заполнены", lastEdit, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-18
@@ -20,9 +20,8 @@ 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, "no_limit", WizardStepNames.Visibility)]
|
||||||
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
|
|
||||||
// Visibility → Publish (public, no club)
|
// Visibility → Publish (public, no club)
|
||||||
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
|
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
|
||||||
// Visibility → PickClub
|
// Visibility → PickClub
|
||||||
@@ -71,13 +70,37 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
|
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
|
||||||
{
|
{
|
||||||
// The wizard's callback parser uses the step encoded in the callback
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
// (not the draft's current step) to drive transitions. So a stale
|
var draft = NewDraft(WizardStepNames.Capacity, PayloadForStep(WizardStepNames.Capacity));
|
||||||
// "Capacity" button pressed while the user is on System will in fact
|
drafts.Seed(draft);
|
||||||
// move the draft forward as if they had pressed it on Capacity. We
|
|
||||||
// lock that behaviour in.
|
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
|
||||||
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||||
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.True(root.TryGetProperty("single", out var single));
|
||||||
|
// WizardPayloadJsonContext имеет DefaultIgnoreCondition=WhenWritingNull,
|
||||||
|
// поэтому null-MaxPlayers просто не пишется. Оба варианта
|
||||||
|
// (отсутствует / JsonValueKind.Null) десериализуются обратно в null
|
||||||
|
// и уйдут в БД как NULL — то есть «без лимита».
|
||||||
|
if (single.TryGetProperty("maxPlayers", out var maxPlayers))
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
maxPlayers.ValueKind == JsonValueKind.Null,
|
||||||
|
$"expected maxPlayers to be null (no limit), got {maxPlayers.ValueKind}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StaleCapacityWaitlistCallback_WithoutCapacity_StaysOnCurrentStep()
|
||||||
|
{
|
||||||
|
// A stale waitlist button from Capacity must not move a draft forward
|
||||||
|
// unless MaxPlayers is already set. Otherwise users can reach Confirm
|
||||||
|
// with a missing capacity and get "Не заполнены поля: лимит мест".
|
||||||
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);
|
||||||
@@ -85,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
|
||||||
{
|
{
|
||||||
@@ -111,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]
|
||||||
|
|||||||
+23
@@ -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")]
|
||||||
|
|||||||
+1
@@ -76,6 +76,7 @@ public sealed class WizardStepRenderTests
|
|||||||
var labels = ButtonLabels(kb);
|
var labels = ButtonLabels(kb);
|
||||||
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
|
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
|
||||||
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
|
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(labels, l => l.Contains("Без лимита", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ public sealed class CampaignTemplatesNavigationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
||||||
{
|
{
|
||||||
|
// Read the version from Directory.Build.props (the canonical source of
|
||||||
|
// truth) so the test doesn't need to be hand-edited on every version
|
||||||
|
// bump. Asserting the rendered NavMenu matches the canonical version
|
||||||
|
// catches real bugs (e.g. someone bumps Directory.Build.props but
|
||||||
|
// forgets to update NavMenu.razor) without false alarms from a stale
|
||||||
|
// hard-coded literal.
|
||||||
|
var propsPath = FindRepositoryFile("Directory.Build.props");
|
||||||
|
var version = ReadVersionFromProps(propsPath);
|
||||||
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
||||||
Assert.Contains("v3.9.2", navMenu, StringComparison.Ordinal);
|
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -68,4 +76,23 @@ public sealed class CampaignTemplatesNavigationTests
|
|||||||
|
|
||||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse the <c><Version>...</Version></c> element from
|
||||||
|
/// <c>Directory.Build.props</c>. Tolerant of whitespace, comments and
|
||||||
|
/// attribute shuffling — the MSBuild schema for <c>Version</c> is just
|
||||||
|
/// a plain element with a string body.
|
||||||
|
/// </summary>
|
||||||
|
private static string ReadVersionFromProps(string propsPath)
|
||||||
|
{
|
||||||
|
var doc = System.Xml.Linq.XDocument.Load(propsPath);
|
||||||
|
var versionElement = doc.Descendants()
|
||||||
|
.FirstOrDefault(e => e.Name.LocalName == "Version");
|
||||||
|
Assert.NotNull(versionElement);
|
||||||
|
var version = versionElement!.Value.Trim();
|
||||||
|
Assert.False(
|
||||||
|
string.IsNullOrEmpty(version),
|
||||||
|
$"<Version> in {propsPath} is empty");
|
||||||
|
return version;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user