From c0147fd310e9a35b37622db490f917948d23356a Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 18 May 2026 17:56:48 +0300 Subject: [PATCH 1/3] test: add DiscordSessionBatchRenderer tests (RED) Co-Authored-By: Claude Opus 4.7 --- .../DiscordSessionBatchRendererTests.cs | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs diff --git a/tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs new file mode 100644 index 0000000..febf077 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs @@ -0,0 +1,139 @@ +using GmRelay.DiscordBot.Rendering; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Rendering; +using NetCord; +using NetCord.Rest; + +namespace GmRelay.Bot.Tests.Rendering; + +public sealed class DiscordSessionBatchRendererTests +{ + [Fact] + public void Render_ShouldProduceEmbedsAndButtonsForMultipleSessions() + { + var firstSessionId = Guid.NewGuid(); + var secondSessionId = Guid.NewGuid(); + var cancelledSessionId = Guid.NewGuid(); + + var sessions = new[] + { + new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game2"), + new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null, ""), + new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game1") + }; + var participants = new[] + { + new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active), + new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted), + new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active) + }; + + var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants); + var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view); + + Assert.Equal(3, embeds.Count); + Assert.Equal(2, actionRows.Count); // cancelled skipped + + // Embed titles contain game title and Moscow date + Assert.Contains(embeds, e => e.Title.Contains("Campaign") && e.Title.Contains("26")); + Assert.Contains(embeds, e => e.Title.Contains("Campaign") && e.Title.Contains("27")); + Assert.Contains(embeds, e => e.Title.Contains("Campaign") && e.Title.Contains("28")); + + // Cancelled session embed description indicates cancellation + var cancelledEmbed = embeds.First(e => e.Description.Contains("отменена") || e.Description.Contains("Отменена")); + Assert.NotNull(cancelledEmbed); + + // Active session embeds contain player names + Assert.Contains(embeds, e => e.Description != null && e.Description.Contains("Alice")); + Assert.Contains(embeds, e => e.Description != null && e.Description.Contains("Charlie")); + + // Buttons for active sessions + var allButtons = actionRows.SelectMany(r => r).OfType().ToList(); + Assert.Contains(allButtons, b => b.CustomId == $"join_session:{firstSessionId}"); + Assert.Contains(allButtons, b => b.CustomId == $"leave_session:{firstSessionId}"); + Assert.Contains(allButtons, b => b.CustomId == $"join_session:{secondSessionId}"); + Assert.Contains(allButtons, b => b.CustomId == $"leave_session:{secondSessionId}"); + } + + [Fact] + public void Render_ShouldSkipActionRowsForCancelledSessions() + { + var cancelledSessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow, SessionStatus.Cancelled, null, "") }; + var participants = Array.Empty(); + + var view = SessionBatchViewBuilder.Build("Test", sessions, participants); + var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view); + + Assert.Single(embeds); + Assert.Empty(actionRows); + } + + [Fact] + public void Render_ShouldShowWaitlistButtonWhenFull() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") }; + var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) }; + + var view = SessionBatchViewBuilder.Build("Test", sessions, participants); + var (_, actionRows) = DiscordSessionBatchRenderer.Render(view); + var buttons = actionRows.SelectMany(r => r).OfType().ToList(); + var joinButton = buttons.First(b => b.CustomId == $"join_session:{sessionId}"); + + Assert.Contains("ожидания", joinButton.Label); + } + + [Fact] + public void Render_ShouldUseRedColorForCancelledSessions() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Cancelled, null, "") }; + var participants = Array.Empty(); + + var view = SessionBatchViewBuilder.Build("Test", sessions, participants); + var (embeds, _) = DiscordSessionBatchRenderer.Render(view); + + Assert.Equal(0xED4245, embeds[0].Color!.Value.RawValue); + } + + [Fact] + public void Render_ShouldUseGreenColorForOpenSessions() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/game") }; + var participants = Array.Empty(); + + var view = SessionBatchViewBuilder.Build("Test", sessions, participants); + var (embeds, _) = DiscordSessionBatchRenderer.Render(view); + + Assert.Equal(0x57F287, embeds[0].Color!.Value.RawValue); + } + + [Fact] + public void Render_ShouldUseYellowColorForFullSessions() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") }; + var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) }; + + var view = SessionBatchViewBuilder.Build("Test", sessions, participants); + var (embeds, _) = DiscordSessionBatchRenderer.Render(view); + + Assert.Equal(0xFEE75C, embeds[0].Color!.Value.RawValue); + } + + [Fact] + public void Render_ShouldHandleRescheduleStatus() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, "Rescheduled", 4, "") }; + var participants = Array.Empty(); + + var view = SessionBatchViewBuilder.Build("Test", sessions, participants); + var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view); + + Assert.Single(embeds); + Assert.Single(actionRows); // not cancelled → actions present + } +} From 1c75994722575673a2cbb190b95cdfea2dcfe49a Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 18 May 2026 18:05:35 +0300 Subject: [PATCH 2/3] feat: implement DiscordSessionBatchRenderer for Embed and Buttons - Render SessionBatchViewModel into NetCord EmbedProperties + ActionRowProperties - One embed per session with game title, Moscow date, players, capacity, waitlist, status - Buttons map AvailableAction to ButtonProperties with platform-neutral custom IDs - Cancelled sessions get embed but no action row - Full sessions trigger waitlist button label - 7 tests covering open/full/waitlist/cancelled/reschedule states Closes #27 Co-Authored-By: Claude Opus 4.7 --- .../Rendering/DiscordSessionBatchRenderer.cs | 125 ++++++++++++++++++ .../Rendering/DiscordSessionBatchRenderer.cs | 13 -- .../DiscordSessionBatchRendererTests.cs | 18 +-- 3 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 src/GmRelay.DiscordBot/Rendering/DiscordSessionBatchRenderer.cs delete mode 100644 src/GmRelay.Shared/Rendering/DiscordSessionBatchRenderer.cs diff --git a/src/GmRelay.DiscordBot/Rendering/DiscordSessionBatchRenderer.cs b/src/GmRelay.DiscordBot/Rendering/DiscordSessionBatchRenderer.cs new file mode 100644 index 0000000..81aecf0 --- /dev/null +++ b/src/GmRelay.DiscordBot/Rendering/DiscordSessionBatchRenderer.cs @@ -0,0 +1,125 @@ +using GmRelay.Shared.Domain; +using GmRelay.Shared.Rendering; +using NetCord; +using NetCord.Rest; + +namespace GmRelay.DiscordBot.Rendering; + +public static class DiscordSessionBatchRenderer +{ + public static (IReadOnlyList Embeds, IReadOnlyList ActionRows) Render(SessionBatchViewModel view) + { + var embeds = new List(); + var actionRows = new List(); + + foreach (var session in view.Sessions) + { + var embed = BuildEmbed(view.Title, session); + embeds.Add(embed); + + if (session.AvailableActions.Count > 0) + { + var actionRow = new ActionRowProperties(); + foreach (var action in session.AvailableActions) + { + actionRow.Add(new ButtonProperties( + $"{action.ActionKey}:{action.SessionId}", + action.Label, + ButtonStyle.Primary)); + } + actionRows.Add(actionRow); + } + } + + return (embeds, actionRows); + } + + private static EmbedProperties BuildEmbed(string title, SessionViewItem session) + { + var embed = new EmbedProperties() + .WithTitle($"{title} — {session.ScheduledAt.FormatMoscow()}"); + + if (SessionStatus.IsCancelled(session.Status)) + { + embed = embed.WithDescription("❌ Сессия отменена"); + } + else + { + embed = embed.WithDescription(BuildPlayerDescription(session)); + } + + var fields = new List + { + new EmbedFieldProperties() + .WithName("👥 Заполненность") + .WithValue(session.MaxPlayers.HasValue + ? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}" + : $"{session.ActivePlayerCount}") + .WithInline(), + + new EmbedFieldProperties() + .WithName("⏳ Лист ожидания") + .WithValue(session.WaitlistedPlayers.Count > 0 + ? session.WaitlistedPlayers.Count.ToString() + : "—") + .WithInline(), + + new EmbedFieldProperties() + .WithName("📊 Статус") + .WithValue(FormatStatus(session.Status)) + .WithInline() + }; + + if (!string.IsNullOrEmpty(session.JoinLink)) + { + embed = embed.WithUrl(session.JoinLink); + } + + embed = embed.WithColor(GetColor(session)); + embed = embed.AddFields(fields); + + return embed; + } + + private static string BuildPlayerDescription(SessionViewItem session) + { + if (session.ActivePlayers.Count == 0) + return "👥 Пока никто не записался"; + + var lines = session.ActivePlayers + .Select(p => $"• {p.DisplayName}") + .ToList(); + + if (session.WaitlistedPlayers.Count > 0) + { + lines.Add(""); + lines.Add($"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):"); + lines.AddRange(session.WaitlistedPlayers.Select(p => $"• {p.DisplayName}")); + } + + return string.Join('\n', lines); + } + + private static string FormatStatus(string status) => status switch + { + SessionStatus.Planned => "Запланирована", + SessionStatus.ConfirmationSent => "Ожидает подтверждения", + SessionStatus.Confirmed => "Подтверждена", + SessionStatus.Cancelled => "Отменена", + _ => status + }; + + private static Color GetColor(SessionViewItem session) + { + if (SessionStatus.IsCancelled(session.Status)) + return new Color(0xED4245); + + if (session.Status == SessionStatus.Confirmed) + return new Color(0x5865F2); + + if (session.MaxPlayers.HasValue && session.ActivePlayerCount >= session.MaxPlayers.Value) + return new Color(0xFEE75C); + + return new Color(0x57F287); + } +} diff --git a/src/GmRelay.Shared/Rendering/DiscordSessionBatchRenderer.cs b/src/GmRelay.Shared/Rendering/DiscordSessionBatchRenderer.cs deleted file mode 100644 index a796f16..0000000 --- a/src/GmRelay.Shared/Rendering/DiscordSessionBatchRenderer.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace GmRelay.Shared.Rendering; - -/// -/// Заглушка для Discord-рендерера. -/// Реальная реализация будет добавлена в проект GmRelay.DiscordBot (issue #26). -/// -public static class DiscordSessionBatchRenderer -{ - public static object Render(SessionBatchViewModel view) - { - throw new NotImplementedException("Discord renderer will be implemented in issue #26."); - } -} diff --git a/tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs index febf077..b8be82e 100644 --- a/tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs +++ b/tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs @@ -35,17 +35,17 @@ public sealed class DiscordSessionBatchRendererTests Assert.Equal(2, actionRows.Count); // cancelled skipped // Embed titles contain game title and Moscow date - Assert.Contains(embeds, e => e.Title.Contains("Campaign") && e.Title.Contains("26")); - Assert.Contains(embeds, e => e.Title.Contains("Campaign") && e.Title.Contains("27")); - Assert.Contains(embeds, e => e.Title.Contains("Campaign") && e.Title.Contains("28")); + Assert.Contains(embeds, e => e.Title!.Contains("Campaign") && e.Title.Contains("26")); + Assert.Contains(embeds, e => e.Title!.Contains("Campaign") && e.Title.Contains("27")); + Assert.Contains(embeds, e => e.Title!.Contains("Campaign") && e.Title.Contains("28")); // Cancelled session embed description indicates cancellation - var cancelledEmbed = embeds.First(e => e.Description.Contains("отменена") || e.Description.Contains("Отменена")); + var cancelledEmbed = embeds.First(e => e.Description!.Contains("отменена") || e.Description.Contains("Отменена")); Assert.NotNull(cancelledEmbed); // Active session embeds contain player names - Assert.Contains(embeds, e => e.Description != null && e.Description.Contains("Alice")); - Assert.Contains(embeds, e => e.Description != null && e.Description.Contains("Charlie")); + Assert.Contains(embeds, e => e.Description!.Contains("Alice")); + Assert.Contains(embeds, e => e.Description!.Contains("Charlie")); // Buttons for active sessions var allButtons = actionRows.SelectMany(r => r).OfType().ToList(); @@ -94,7 +94,7 @@ public sealed class DiscordSessionBatchRendererTests var view = SessionBatchViewBuilder.Build("Test", sessions, participants); var (embeds, _) = DiscordSessionBatchRenderer.Render(view); - Assert.Equal(0xED4245, embeds[0].Color!.Value.RawValue); + Assert.Equal(0xED4245, embeds[0].Color.RawValue); } [Fact] @@ -107,7 +107,7 @@ public sealed class DiscordSessionBatchRendererTests var view = SessionBatchViewBuilder.Build("Test", sessions, participants); var (embeds, _) = DiscordSessionBatchRenderer.Render(view); - Assert.Equal(0x57F287, embeds[0].Color!.Value.RawValue); + Assert.Equal(0x57F287, embeds[0].Color.RawValue); } [Fact] @@ -120,7 +120,7 @@ public sealed class DiscordSessionBatchRendererTests var view = SessionBatchViewBuilder.Build("Test", sessions, participants); var (embeds, _) = DiscordSessionBatchRenderer.Render(view); - Assert.Equal(0xFEE75C, embeds[0].Color!.Value.RawValue); + Assert.Equal(0xFEE75C, embeds[0].Color.RawValue); } [Fact] From 5dddf992885da3a0c67afaf0ae6cc5241b18b613 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 18 May 2026 18:08:12 +0300 Subject: [PATCH 3/3] chore: bump version to 2.3.0 Synchronized across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor, and DiscordProjectStructureTests. Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- compose.yaml | 6 +++--- src/GmRelay.Web/Components/Layout/NavMenu.razor | 2 +- .../Discord/DiscordProjectStructureTests.cs | 14 +++++++------- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 5ce555f..a5e9bfc 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 2.2.0 + VERSION: 2.3.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index f4d5dd2..a6bc8ab 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.2.0 + 2.3.0 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index 48b7056..6044e1f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:2.2.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:2.3.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.2.0 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.3.0 restart: always depends_on: db: @@ -79,7 +79,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:2.2.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:2.3.0 restart: always depends_on: db: diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 6cf09e9..5306eb6 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs index 7f98738..03da6c9 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs @@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); - Assert.Contains("gmrelay-discord-bot:2.2.0", compose); + Assert.Contains("gmrelay-discord-bot:2.3.0", compose); Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); Assert.Contains("DISCORD_BOT_TOKEN", deploy); @@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests { var repoRoot = GetRepoRoot(); - Assert.Contains("2.2.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); - Assert.Contains("VERSION: 2.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); - Assert.Contains("gmrelay-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-web:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-discord-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("2.3.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); + Assert.Contains("VERSION: 2.3.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); + Assert.Contains("gmrelay-bot:2.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-web:2.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-discord-bot:2.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains( - "v2.2.0", + "v2.3.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); } }