From 14b9bf15f2bbc91368ca307a68c6718369185b99 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 07:57:23 +0000 Subject: [PATCH] =?UTF-8?q?refactor(#22):=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=8C=20SessionBatchRenderer=20=D0=BD=D0=B0?= =?UTF-8?q?=20neutral=20view=20=D0=B8=20Telegram=20renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SessionBatchViewBuilder в Shared собирает нейтральную view model - TelegramSessionBatchRenderer в Bot/Web рендерит HTML + InlineKeyboardMarkup - DiscordSessionBatchRenderer заглушка подготовлена - BatchMessageEditor перенесён из Shared в Bot/Web - Удалён SessionBatchRenderer, убран Telegram.Bot из Shared.csproj - Обновлены все вызовы (7 handler-ов + Web SessionService + smoke tests) - Новые тесты на builder и Telegram renderer --- .hermes/plans/gmrelay-issue-19.md | 250 ++++++++++++++++++ Directory.Build.props | 2 +- compose.yaml | 4 +- .../CreateSession/CancelSessionHandler.cs | 4 +- .../CreateSession/CreateSessionHandler.cs | 4 +- .../CreateSession/JoinSessionHandler.cs | 4 +- .../CreateSession/LeaveSessionHandler.cs | 4 +- .../PromoteWaitlistedPlayerHandler.cs | 4 +- .../HandleRescheduleTimeInputHandler.cs | 5 +- .../RescheduleVotingDeadlineService.cs | 4 +- .../Telegram/BatchMessageEditor.cs | 51 ++++ .../Telegram/TelegramSessionBatchRenderer.cs | 57 ++++ src/GmRelay.Shared/GmRelay.Shared.csproj | 4 - .../Rendering/DiscordSessionBatchRenderer.cs | 13 + .../Rendering/SessionBatchDto.cs | 4 + .../Rendering/SessionBatchRenderer.cs | 81 ------ .../Rendering/SessionBatchViewBuilder.cs | 59 +++++ .../Rendering/SessionBatchViewModel.cs | 27 ++ .../Services}/BatchMessageEditor.cs | 2 +- src/GmRelay.Web/Services/SessionService.cs | 10 +- .../Services/TelegramSessionBatchRenderer.cs | 56 ++++ .../TelegramLandingPromisesSmokeTests.cs | 4 +- .../Rendering/SessionBatchRendererTests.cs | 56 ---- .../Rendering/SessionBatchViewBuilderTests.cs | 113 ++++++++ .../TelegramSessionBatchRendererTests.cs | 76 ++++++ 25 files changed, 741 insertions(+), 157 deletions(-) create mode 100644 .hermes/plans/gmrelay-issue-19.md create mode 100644 src/GmRelay.Bot/Infrastructure/Telegram/BatchMessageEditor.cs create mode 100644 src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs create mode 100644 src/GmRelay.Shared/Rendering/DiscordSessionBatchRenderer.cs create mode 100644 src/GmRelay.Shared/Rendering/SessionBatchDto.cs delete mode 100644 src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs create mode 100644 src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs create mode 100644 src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs rename src/{GmRelay.Shared/Rendering => GmRelay.Web/Services}/BatchMessageEditor.cs (98%) create mode 100644 src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs delete mode 100644 tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Rendering/SessionBatchViewBuilderTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs diff --git a/.hermes/plans/gmrelay-issue-19.md b/.hermes/plans/gmrelay-issue-19.md new file mode 100644 index 0000000..f039191 --- /dev/null +++ b/.hermes/plans/gmrelay-issue-19.md @@ -0,0 +1,250 @@ +# Issue #19: Выровнять /newsession с batch-сценарием лендинга + +> **Goal:** Сделать `/newsession` атомарным: либо весь batch создаётся целиком, либо ничего — с понятным сообщением об ошибках. Убрать создание частичных сессий при наличии любых ошибок ввода. + +**Architecture:** Изменить `NewSessionParseResult.IsValid` так, чтобы он учитывал ЛЮБЫЕ parse-ошибки (некорректные даты, лимиты, повторы, прошедшие даты). Обновить `CreateSessionHandler`, чтобы при `!IsValid` отправлялось одно детальное сообщение с перечислением ошибок и help-шаблоном, и creation прерывался до транзакции БД. + +**Tech Stack:** C#, .NET, Dapper, Telegram.Bot, xUnit + +--- + +### Task 1: Добавить `HasErrors` и ужесточить `IsValid` + +**Objective:** Запретить частичное создание: `IsValid == false`, если есть хоть одна ошибка парсинга. + +**Files:** +- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs` +- Test: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs` + +**Step 1: RED — написать failing test** + +Добавить в конец `NewSessionCommandParserTests.cs`: + +```csharp +[Fact] +public void Parse_ShouldBeInvalid_WhenAnyErrorsPresent() +{ + var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero); + var text = """ + Название: Delta Green + Время: 25.04.2026 19:30 + Время: 31.04.2026 19:30 + Ссылка: https://example.test/dg + """; + + var result = NewSessionCommandParser.Parse(text, nowUtc); + + Assert.True(result.HasErrors); + Assert.False(result.IsValid); +} +``` + +**Step 2: Run test and verify RED** + +```bash +cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~NewSessionCommandParserTests" -v n +``` +Expected: `HasErrors` property not found → compile error or FAIL. + +**Step 3: GREEN — minimal implementation** + +В `NewSessionCommandParser.cs`, в `NewSessionParseResult`, заменить `IsValid` на: + +```csharp +public bool HasErrors => + PastTimeInputs.Count > 0 || + InvalidTimeInputs.Count > 0 || + InvalidSeatLimitInputs.Count > 0 || + InvalidRecurringInputs.Count > 0; + +public bool IsValid => + !string.IsNullOrWhiteSpace(Title) && + !string.IsNullOrWhiteSpace(Link) && + ScheduledTimes.Count > 0 && + !HasErrors; +``` + +**Step 4: Run test and verify GREEN** + +```bash +cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~NewSessionCommandParserTests" -v n +``` +Expected: 7 passed (включая новый). + +**Step 5: Commit** + +```bash +git add src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs +git add tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs +git commit -m "feat(#19): add HasErrors to prevent partial batch creation" +``` + +--- + +### Task 2: Обновить существующий тест на Past/Invalid times + +**Objective:** Старый тест ожидал `IsValid == true` при partial invalid — теперь это баг, нужно обновить assertion. + +**Files:** +- Modify: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs:114` + +**Step 1: Write failing expectation first** + +Заменить в методе `Parse_ShouldCollectPastAndInvalidTimes` (примерно строка 114): + +```csharp +// Было: +Assert.True(result.IsValid); +// Стало: +Assert.False(result.IsValid); +``` + +**Step 2: Run test and verify RED** + +```bash +cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "Parse_ShouldCollectPastAndInvalidTimes" -v n +``` +Expected: FAIL — expected False, actual True. + +**Step 3: Fix already done in Task 1** + +Task 1 уже изменил `IsValid`. Перезапустить тест. + +**Step 4: Run test and verify GREEN** + +```bash +cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "Parse_ShouldCollectPastAndInvalidTimes" -v n +``` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs +git commit -m "test(#19): update assertion - partial invalid means invalid" +``` + +--- + +### Task 3: Обновить `CreateSessionHandler` для атомарной валидации и единого сообщения об ошибках + +**Objective:** Убрать разбросанные warning-сообщения. При любых ошибках — одно сообщение с перечнем ошибок + help-шаблон. Не создавать сессии. + +**Files:** +- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs` + +**Step 1: RED — напишем интеграционный поведенческий тест (опционально, landing test уже покрывает happy path)** + +Просто запустим landing promise smoke test и убедимся, что он ещё green (happy path не сломан). + +```bash +cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "TelegramLandingPromisesSmokeTests" -v n +``` +Expected: PASS. + +**Step 2: GREEN — minimal change in handler** + +В `CreateSessionHandler.cs`, метод `HandleAsync`, заменить начало (строки 19–59) на: + +```csharp +var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow); + +var errorMessages = new List(); + +foreach (var timeInput in parseResult.PastTimeInputs) + errorMessages.Add($"⚠️ Дата {timeInput} находится в прошлом и будет пропущена."); + +foreach (var timeInput in parseResult.InvalidTimeInputs) + errorMessages.Add($"⚠️ Некорректный формат времени '{timeInput}'. Пропущено."); + +foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs) + errorMessages.Add($"⚠️ Некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0."); + +foreach (var recurringInput in parseResult.InvalidRecurringInputs) + errorMessages.Add($"⚠️ Некорректный повтор расписания '{recurringInput}'. Укажите число игр 1–52 и шаг 1–365 дней."); + +if (!parseResult.IsValid) +{ + var helpText = """ + ❌ Не удалось распознать формат. Пожалуйста, используйте шаблон: + + /newsession + Название: My Game + Время: 15.05.2026 19:30 + Время: 22.05.2026 19:30 + Мест: 4 + Ссылка: https://link + Картинка: https://cover + + Для повтора можно указать одну дату и строки: + Игр: 4 + Интервал: 7 + """; + + if (errorMessages.Count > 0) + { + helpText = string.Join('\n', errorMessages) + "\n\n" + helpText; + } + + await botClient.SendMessage( + chatId: message.Chat.Id, + text: helpText, + cancellationToken: cancellationToken); + return; +} +``` + +**Step 3: Build and verify** + +```bash +cd /repo && dotnet build src/GmRelay.Bot +``` +Expected: Build succeeded. + +**Step 4: Run all relevant tests** + +```bash +cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~CreateSession|FullyQualifiedName~Landing" -v n +``` +Expected: All passed. + +**Step 5: Commit** + +```bash +git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +git commit -m "feat(#19): atomic validation with detailed error message in /newsession" +``` + +--- + +### Task 4: Проверить полный test suite на регрессии + +**Objective:** Убедиться, что изменения не сломали существующие тесты. + +**Files:** +- Test: все тесты проекта `GmRelay.Bot.Tests` + +**Step 1: Run full test suite** + +```bash +cd /repo && dotnet test tests/GmRelay.Bot.Tests -v n +``` +Expected: All passed. + +**Step 2: Commit (if any test baseline updated)** + +Если всё зелёное — commit message: +```bash +git commit --allow-empty -m "test(#19): verify full suite green after atomic validation" +``` + +--- + +## Verification Checklist + +- [ ] `NewSessionCommandParser.Parse` возвращает `IsValid = false` при любых ошибках (past/invalid times, seat limits, recurring). +- [ ] `HasErrors` true ↔ есть хотя бы одна ошибка. +- [ ] `CreateSessionHandler` не открывает БД-транзакцию, если `!IsValid`. +- [ ] Пользователь получает одно сообщение с перечислением ошибок + help. +- [ ] Landing promise smoke test проходит (happy path не сломан). +- [ ] Полный test suite зелёный. diff --git a/Directory.Build.props b/Directory.Build.props index 2eda9c7..f5c440c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.9.9 + 1.10.0 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index 791836d..e380308 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.9 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.10.0 restart: always depends_on: db: @@ -30,7 +30,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.9 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.10.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs index 71057cb..d0b40db 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs @@ -5,6 +5,7 @@ using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types; +using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.CreateSession; @@ -102,7 +103,8 @@ public sealed class CancelSessionHandler( await transaction.CommitAsync(ct); // 4. Перерисовываем сообщение - var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList()); + var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList()); + var renderResult = TelegramSessionBatchRenderer.Render(view); try { diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 853da9b..cf442b8 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -4,6 +4,7 @@ using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types; +using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.CreateSession; @@ -183,7 +184,8 @@ public sealed class CreateSessionHandler( await transaction.CommitAsync(cancellationToken); logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId); - var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty()); + var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty()); + var renderResult = TelegramSessionBatchRenderer.Render(view); Message batchMessage; diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs index a7aec54..27aa360 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs @@ -4,6 +4,7 @@ using Telegram.Bot; using Telegram.Bot.Types; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; +using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.CreateSession; @@ -136,7 +137,8 @@ public sealed class JoinSessionHandler( transactionCommitted = true; // 4. Перерисовываем сообщение - var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); + var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); + var renderResult = TelegramSessionBatchRenderer.Render(view); await BatchMessageEditor.EditBatchMessageAsync( bot, diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs index 9ba1415..d8c4258 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs @@ -3,6 +3,7 @@ using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; +using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.CreateSession; @@ -182,7 +183,8 @@ public sealed class LeaveSessionHandler( await transaction.CommitAsync(ct); transactionCommitted = true; - var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants); + var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants); + var renderResult = TelegramSessionBatchRenderer.Render(view); await BatchMessageEditor.EditBatchMessageAsync( bot, diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs index 7d0c650..2850de6 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs @@ -3,6 +3,7 @@ using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; +using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.CreateSession; @@ -162,7 +163,8 @@ public sealed class PromoteWaitlistedPlayerHandler( await transaction.CommitAsync(ct); transactionCommitted = true; - var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants); + var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants); + var renderResult = TelegramSessionBatchRenderer.Render(view); await BatchMessageEditor.EditBatchMessageAsync( bot, diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index 078c8cc..9e12054 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -6,6 +6,7 @@ using Npgsql; using Telegram.Bot; using Telegram.Bot.Types; using Telegram.Bot.Types.ReplyMarkups; +using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; @@ -375,8 +376,8 @@ public sealed class HandleRescheduleTimeInputHandler( if (proposal.BatchMessageId.HasValue) { - var renderResult = SessionBatchRenderer.Render( - proposal.Title, batchSessions, batchParticipants); + var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants); + var renderResult = TelegramSessionBatchRenderer.Render(view); await BatchMessageEditor.EditBatchMessageAsync( bot, diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index a678649..3a4a847 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -5,6 +5,7 @@ using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types.Enums; +using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; @@ -304,7 +305,8 @@ public sealed class RescheduleVotingDeadlineService( if (proposal.BatchMessageId.HasValue) { - var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants); + var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants); + var renderResult = TelegramSessionBatchRenderer.Render(view); await BatchMessageEditor.EditBatchMessageAsync( bot, diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/BatchMessageEditor.cs b/src/GmRelay.Bot/Infrastructure/Telegram/BatchMessageEditor.cs new file mode 100644 index 0000000..96a51cc --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Telegram/BatchMessageEditor.cs @@ -0,0 +1,51 @@ +using Telegram.Bot; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace GmRelay.Bot.Infrastructure.Telegram; + +/// +/// Handles editing batch messages that may be either text or photo messages. +/// When the batch was created with SendPhoto (image + caption), we need +/// EditMessageCaption instead of EditMessageText. +/// +public static class BatchMessageEditor +{ + /// + /// Edits a batch message, automatically detecting whether it is a text or photo message. + /// Tries EditMessageText first; on failure falls back to EditMessageCaption. + /// + public static async Task EditBatchMessageAsync( + ITelegramBotClient bot, + long chatId, + int messageId, + string text, + InlineKeyboardMarkup? replyMarkup, + CancellationToken ct = default) + { + try + { + await bot.EditMessageText( + chatId: chatId, + messageId: messageId, + text: text, + parseMode: ParseMode.Html, + replyMarkup: replyMarkup, + cancellationToken: ct); + } + catch (Telegram.Bot.Exceptions.ApiRequestException ex) + when (ex.Message.Contains("there is no text in the message", StringComparison.OrdinalIgnoreCase)) + { + // The batch message is a photo — use EditMessageCaption instead. + // Caption is limited to 1024 chars; if text exceeds that, truncate gracefully. + var caption = text.Length <= 1024 ? text : text[..1021] + "..."; + await bot.EditMessageCaption( + chatId: chatId, + messageId: messageId, + caption: caption, + parseMode: ParseMode.Html, + replyMarkup: replyMarkup, + cancellationToken: ct); + } + } +} diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs new file mode 100644 index 0000000..c5b8cdd --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs @@ -0,0 +1,57 @@ +// NOTE: duplicated in GmRelay.Web/Services/TelegramSessionBatchRenderer.cs +using GmRelay.Shared.Rendering; +using Telegram.Bot.Types.ReplyMarkups; + +namespace GmRelay.Bot.Infrastructure.Telegram; + +public static class TelegramSessionBatchRenderer +{ + public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view) + { + var messageText = $"🎲 Новые игры: {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" + + $"Расписание:\n\n"; + + var buttons = new List(); + + foreach (var session in view.Sessions) + { + messageText += $"📅 {session.ScheduledAt.FormatMoscow()}\n"; + messageText += session.MaxPlayers.HasValue + ? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n" + : $"👥 Игроки ({session.ActivePlayerCount}):\n"; + + if (session.ActivePlayers.Count > 0) + { + messageText += string.Join("\n", session.ActivePlayers.Select(p => + $" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n"; + } + else + { + messageText += " Пока никто не записался\n"; + } + + if (session.WaitlistedPlayers.Count > 0) + { + messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n"; + messageText += string.Join("\n", session.WaitlistedPlayers.Select(p => + $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n"; + } + + if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status)) + { + messageText += "❌ Сессия отменена\n\n"; + } + else + { + messageText += "\n"; + var actionRow = session.AvailableActions + .Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}")) + .ToArray(); + if (actionRow.Length > 0) + buttons.Add(actionRow); + } + } + + return (messageText, new InlineKeyboardMarkup(buttons)); + } +} diff --git a/src/GmRelay.Shared/GmRelay.Shared.csproj b/src/GmRelay.Shared/GmRelay.Shared.csproj index a14b1b5..97acdba 100644 --- a/src/GmRelay.Shared/GmRelay.Shared.csproj +++ b/src/GmRelay.Shared/GmRelay.Shared.csproj @@ -7,8 +7,4 @@ preview - - - - diff --git a/src/GmRelay.Shared/Rendering/DiscordSessionBatchRenderer.cs b/src/GmRelay.Shared/Rendering/DiscordSessionBatchRenderer.cs new file mode 100644 index 0000000..a796f16 --- /dev/null +++ b/src/GmRelay.Shared/Rendering/DiscordSessionBatchRenderer.cs @@ -0,0 +1,13 @@ +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/src/GmRelay.Shared/Rendering/SessionBatchDto.cs b/src/GmRelay.Shared/Rendering/SessionBatchDto.cs new file mode 100644 index 0000000..658ed43 --- /dev/null +++ b/src/GmRelay.Shared/Rendering/SessionBatchDto.cs @@ -0,0 +1,4 @@ +namespace GmRelay.Shared.Rendering; + +public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers); +public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus); diff --git a/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs b/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs deleted file mode 100644 index 6564a4f..0000000 --- a/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs +++ /dev/null @@ -1,81 +0,0 @@ -using GmRelay.Shared.Domain; -using Telegram.Bot.Types.ReplyMarkups; - -namespace GmRelay.Shared.Rendering; - -public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers); -public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus); - -public static class SessionBatchRenderer -{ - public static (string Text, InlineKeyboardMarkup Markup) Render( - string title, - IReadOnlyList sessions, - IReadOnlyList participants) - { - var activeSessions = sessions.OrderBy(s => s.ScheduledAt).ToList(); - - var messageText = $"🎲 Новые игры: {System.Net.WebUtility.HtmlEncode(title)}\n\n" + - $"Расписание:\n\n"; - - var buttons = new List(); - - foreach (var session in activeSessions) - { - var sessionPlayers = participants - .Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active) - .ToList(); - var waitlistedPlayers = participants - .Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted) - .ToList(); - - messageText += $"📅 {session.ScheduledAt.FormatMoscow()}\n"; - messageText += session.MaxPlayers.HasValue - ? $"👥 Места: {sessionPlayers.Count}/{session.MaxPlayers.Value}\n" - : $"👥 Игроки ({sessionPlayers.Count}):\n"; - - if (sessionPlayers.Count > 0) - { - messageText += string.Join("\n", sessionPlayers.Select(p => $" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n"; - } - else - { - messageText += " Пока никто не записался\n"; - } - - if (waitlistedPlayers.Count > 0) - { - messageText += $"⏳ Лист ожидания ({waitlistedPlayers.Count}):\n"; - messageText += string.Join("\n", waitlistedPlayers.Select(p => $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n"; - } - - if (SessionStatus.IsCancelled(session.Status)) - { - messageText += "❌ Сессия отменена\n\n"; - } - else - { - messageText += "\n"; - var dateTitle = session.ScheduledAt.FormatMoscowShort(); - buttons.Add(new[] - { - InlineKeyboardButton.WithCallbackData(GetJoinButtonText(session, sessionPlayers.Count, dateTitle), $"join_session:{session.SessionId}"), - InlineKeyboardButton.WithCallbackData($"🚪 Выйти {dateTitle}", $"leave_session:{session.SessionId}") - }); - - } - } - - return (messageText, new InlineKeyboardMarkup(buttons)); - } - - private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle) - { - if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value) - { - return $"⏳ В лист ожидания {dateTitle}"; - } - - return $"✋ На {dateTitle}"; - } -} diff --git a/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs b/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs new file mode 100644 index 0000000..b7bf2a6 --- /dev/null +++ b/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs @@ -0,0 +1,59 @@ +using GmRelay.Shared.Domain; + +namespace GmRelay.Shared.Rendering; + +public static class SessionBatchViewBuilder +{ + public static SessionBatchViewModel Build( + string title, + IReadOnlyList sessions, + IReadOnlyList participants) + { + var orderedSessions = sessions.OrderBy(s => s.ScheduledAt).ToList(); + var sessionItems = new List(); + + foreach (var session in orderedSessions) + { + var activePlayers = participants + .Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active) + .Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus)) + .ToList(); + + var waitlistedPlayers = participants + .Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted) + .Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus)) + .ToList(); + + var actions = new List(); + if (!SessionStatus.IsCancelled(session.Status)) + { + var dateTitle = session.ScheduledAt.FormatMoscowShort(); + var joinLabel = GetJoinButtonText(session, activePlayers.Count, dateTitle); + actions.Add(new AvailableAction("join_session", joinLabel, session.SessionId)); + actions.Add(new AvailableAction("leave_session", $"🚪 Выйти {dateTitle}", session.SessionId)); + } + + sessionItems.Add(new SessionViewItem( + session.SessionId, + session.ScheduledAt, + session.Status, + session.MaxPlayers, + activePlayers.Count, + activePlayers, + waitlistedPlayers, + actions)); + } + + return new SessionBatchViewModel(title, sessionItems); + } + + private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle) + { + if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value) + { + return $"⏳ В лист ожидания {dateTitle}"; + } + + return $"✋ На {dateTitle}"; + } +} diff --git a/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs b/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs new file mode 100644 index 0000000..95191d5 --- /dev/null +++ b/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs @@ -0,0 +1,27 @@ +using GmRelay.Shared.Domain; + +namespace GmRelay.Shared.Rendering; + +public sealed record SessionBatchViewModel( + string Title, + IReadOnlyList Sessions); + +public sealed record SessionViewItem( + Guid SessionId, + DateTime ScheduledAt, + string Status, + int? MaxPlayers, + int ActivePlayerCount, + IReadOnlyList ActivePlayers, + IReadOnlyList WaitlistedPlayers, + IReadOnlyList AvailableActions); + +public sealed record PlayerViewItem( + string DisplayName, + string? TelegramUsername, + string RegistrationStatus); + +public sealed record AvailableAction( + string ActionKey, + string Label, + Guid SessionId); diff --git a/src/GmRelay.Shared/Rendering/BatchMessageEditor.cs b/src/GmRelay.Web/Services/BatchMessageEditor.cs similarity index 98% rename from src/GmRelay.Shared/Rendering/BatchMessageEditor.cs rename to src/GmRelay.Web/Services/BatchMessageEditor.cs index 194b119..0906c75 100644 --- a/src/GmRelay.Shared/Rendering/BatchMessageEditor.cs +++ b/src/GmRelay.Web/Services/BatchMessageEditor.cs @@ -2,7 +2,7 @@ using Telegram.Bot; using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.ReplyMarkups; -namespace GmRelay.Shared.Rendering; +namespace GmRelay.Web.Services; /// /// Handles editing batch messages that may be either text or photo messages. diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index e9ba54d..88145ee 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -3,6 +3,7 @@ using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; +using GmRelay.Web.Services; namespace GmRelay.Web.Services; @@ -883,7 +884,8 @@ public sealed class SessionService( await transaction.CommitAsync(); - var renderResult = SessionBatchRenderer.Render(batchTitle, renderedSessions, Array.Empty()); + var view = SessionBatchViewBuilder.Build(batchTitle, renderedSessions, Array.Empty()); + var renderResult = TelegramSessionBatchRenderer.Render(view); var batchMessage = await bot.SendMessage( chatId: chatId, messageThreadId: threadId, @@ -1091,7 +1093,8 @@ public sealed class SessionService( await transaction.CommitAsync(); - var renderResult = SessionBatchRenderer.Render(template.Title, renderedSessions, Array.Empty()); + var view = SessionBatchViewBuilder.Build(template.Title, renderedSessions, Array.Empty()); + var renderResult = TelegramSessionBatchRenderer.Render(view); var batchMessage = await bot.SendMessage( chatId: group.TelegramChatId, text: renderResult.Text, @@ -1198,7 +1201,8 @@ public sealed class SessionService( ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC", new { BatchId = batchId })).ToList(); - var renderResult = SessionBatchRenderer.Render(title, sessions, participants); + var view = SessionBatchViewBuilder.Build(title, sessions, participants); + var renderResult = TelegramSessionBatchRenderer.Render(view); await BatchMessageEditor.EditBatchMessageAsync( bot, diff --git a/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs b/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs new file mode 100644 index 0000000..28919f7 --- /dev/null +++ b/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs @@ -0,0 +1,56 @@ +using GmRelay.Shared.Rendering; +using Telegram.Bot.Types.ReplyMarkups; + +namespace GmRelay.Web.Services; + +public static class TelegramSessionBatchRenderer +{ + public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view) + { + var messageText = $"🎲 Новые игры: {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" + + $"Расписание:\n\n"; + + var buttons = new List(); + + foreach (var session in view.Sessions) + { + messageText += $"📅 {session.ScheduledAt.FormatMoscow()}\n"; + messageText += session.MaxPlayers.HasValue + ? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n" + : $"👥 Игроки ({session.ActivePlayerCount}):\n"; + + if (session.ActivePlayers.Count > 0) + { + messageText += string.Join("\n", session.ActivePlayers.Select(p => + $" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n"; + } + else + { + messageText += " Пока никто не записался\n"; + } + + if (session.WaitlistedPlayers.Count > 0) + { + messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n"; + messageText += string.Join("\n", session.WaitlistedPlayers.Select(p => + $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n"; + } + + if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status)) + { + messageText += "❌ Сессия отменена\n\n"; + } + else + { + messageText += "\n"; + var actionRow = session.AvailableActions + .Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}")) + .ToArray(); + if (actionRow.Length > 0) + buttons.Add(actionRow); + } + } + + return (messageText, new InlineKeyboardMarkup(buttons)); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs b/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs index 87932b8..b4f91ad 100644 --- a/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs @@ -3,6 +3,7 @@ using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Telegram.Bot.Types.ReplyMarkups; +using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Tests.Features.Landing; @@ -310,7 +311,7 @@ public sealed class TelegramLandingPromisesSmokeTests private void RenderBatch() { - var renderResult = SessionBatchRenderer.Render( + var view = SessionBatchViewBuilder.Build( Title, sessions .Select(session => new SessionBatchDto( @@ -326,6 +327,7 @@ public sealed class TelegramLandingPromisesSmokeTests participant.TelegramUsername, participant.RegistrationStatus)) .ToList()); + var renderResult = TelegramSessionBatchRenderer.Render(view); if (Messenger.HasPublishedMessage) { diff --git a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs deleted file mode 100644 index 918c27b..0000000 --- a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs +++ /dev/null @@ -1,56 +0,0 @@ -using GmRelay.Shared.Domain; -using GmRelay.Shared.Rendering; - -namespace GmRelay.Bot.Tests.Rendering; - -public sealed class SessionBatchRendererTests -{ - [Fact] - public void Render_ShouldOrderSessionsAndSkipButtonsForCancelledSessions() - { - 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), - 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) - }; - 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 result = SessionBatchRenderer.Render("Campaign", sessions, participants); - var text = result.Text; - var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList(); - - var firstIndex = text.IndexOf(sessions[2].ScheduledAt.FormatMoscow(), StringComparison.Ordinal); - var secondIndex = text.IndexOf(sessions[0].ScheduledAt.FormatMoscow(), StringComparison.Ordinal); - var thirdIndex = text.IndexOf(sessions[1].ScheduledAt.FormatMoscow(), StringComparison.Ordinal); - - Assert.Contains("Campaign", text); - Assert.True(firstIndex < secondIndex); - Assert.True(secondIndex < thirdIndex); - Assert.Contains("Места: 0/2", text); - Assert.Contains("Места: 1/4", text); - Assert.Contains("@alice", text); - Assert.Contains("Лист ожидания (1)", text); - Assert.Contains("Charlie", text); - Assert.Contains("Bob", text); - Assert.Equal(2, result.Markup.InlineKeyboard.Count()); - Assert.Collection( - buttons.Select(button => button.CallbackData), - callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData), - callbackData => Assert.Equal($"leave_session:{firstSessionId}", callbackData), - callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData), - callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData)); - Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("cancel_session:", StringComparison.Ordinal) == true); - Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("reschedule_session:", StringComparison.Ordinal) == true); - Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("promote_waitlist:", StringComparison.Ordinal) == true); - } -} diff --git a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchViewBuilderTests.cs b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchViewBuilderTests.cs new file mode 100644 index 0000000..12c9d91 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchViewBuilderTests.cs @@ -0,0 +1,113 @@ +using GmRelay.Shared.Domain; +using GmRelay.Shared.Rendering; + +namespace GmRelay.Bot.Tests.Rendering; + +public sealed class SessionBatchViewBuilderTests +{ + [Fact] + public void Build_ShouldOrderSessionsByDate() + { + 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), + 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) + }; + 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 result = SessionBatchViewBuilder.Build("Campaign", sessions, participants); + + Assert.Equal("Campaign", result.Title); + Assert.Equal(3, result.Sessions.Count); + Assert.Equal(firstSessionId, result.Sessions[0].SessionId); + Assert.Equal(secondSessionId, result.Sessions[1].SessionId); + Assert.Equal(cancelledSessionId, result.Sessions[2].SessionId); + } + + [Fact] + public void Build_ShouldCalculatePlayerCounts() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) }; + var participants = new[] + { + new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active), + new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Active), + new ParticipantBatchDto(sessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted) + }; + + var result = SessionBatchViewBuilder.Build("Test", sessions, participants); + var session = result.Sessions[0]; + + Assert.Equal(2, session.ActivePlayerCount); + Assert.Equal(4, session.MaxPlayers); + Assert.Equal(2, session.ActivePlayers.Count); + Assert.Equal(1, session.WaitlistedPlayers.Count); + } + + [Fact] + public void Build_ShouldIncludeActionsForActiveSessions() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) }; + var participants = Array.Empty(); + + var result = SessionBatchViewBuilder.Build("Test", sessions, participants); + var actions = result.Sessions[0].AvailableActions; + + Assert.Equal(2, actions.Count); + Assert.Equal("join_session", actions[0].ActionKey); + Assert.Equal("leave_session", actions[1].ActionKey); + Assert.Equal(sessionId, actions[0].SessionId); + Assert.Equal(sessionId, actions[1].SessionId); + } + + [Fact] + public void Build_ShouldNotIncludeActionsForCancelledSessions() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) }; + var participants = Array.Empty(); + + var result = SessionBatchViewBuilder.Build("Test", sessions, participants); + + Assert.Empty(result.Sessions[0].AvailableActions); + } + + [Fact] + public void Build_ShouldMarkWaitlistActionWhenFull() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) }; + var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) }; + + var result = SessionBatchViewBuilder.Build("Test", sessions, participants); + var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session"); + + Assert.Contains("ожидания", joinAction.Label); + } + + [Fact] + public void Build_ShouldIncludePlayerUsernames() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, null) }; + var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) }; + + var result = SessionBatchViewBuilder.Build("Test", sessions, participants); + var player = result.Sessions[0].ActivePlayers[0]; + + Assert.Equal("Alice", player.DisplayName); + Assert.Equal("alice", player.TelegramUsername); + } +} diff --git a/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs new file mode 100644 index 0000000..bfcd891 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs @@ -0,0 +1,76 @@ +using GmRelay.Shared.Domain; +using GmRelay.Shared.Rendering; +using Telegram.Bot.Types.ReplyMarkups; + +namespace GmRelay.Bot.Tests.Rendering; + +public sealed class TelegramSessionBatchRendererTests +{ + [Fact] + public void Render_ShouldProduceCorrectHtmlAndButtons() + { + 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), + 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) + }; + 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 (text, markup) = TelegramSessionBatchRenderer.Render(view); + + Assert.Contains("Campaign", text); + Assert.Contains("@alice", text); + Assert.Contains("Charlie", text); + Assert.Contains("Bob", text); + Assert.Contains("Сессия отменена", text); + + var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList(); + Assert.Equal(4, buttons.Count); // 2 sessions x 2 buttons each + + Assert.Contains(buttons, b => b.CallbackData == $"join_session:{firstSessionId}"); + Assert.Contains(buttons, b => b.CallbackData == $"leave_session:{firstSessionId}"); + Assert.Contains(buttons, b => b.CallbackData == $"join_session:{secondSessionId}"); + Assert.Contains(buttons, b => b.CallbackData == $"leave_session:{secondSessionId}"); + Assert.DoesNotContain(buttons, b => b.CallbackData?.StartsWith("cancel") == true); + Assert.DoesNotContain(buttons, b => b.CallbackData?.StartsWith("reschedule") == true); + } + + [Fact] + public void Render_ShouldSkipButtonsForCancelledSessions() + { + 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 (_, markup) = TelegramSessionBatchRenderer.Render(view); + + Assert.Empty(markup.InlineKeyboard); + } + + [Fact] + public void Render_ShouldShowWaitlistButtonWhenFull() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) }; + var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) }; + + var view = SessionBatchViewBuilder.Build("Test", sessions, participants); + var (_, markup) = TelegramSessionBatchRenderer.Render(view); + var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList(); + var joinButton = buttons.First(b => b.CallbackData?.StartsWith("join_session:") == true); + + Assert.Contains("ожидания", joinButton.Text); + } +}