From 3c967dc3e3600a48af5cb31d71420911b567df0c Mon Sep 17 00:00:00 2001 From: Toutsu Date: Sat, 13 Jun 2026 10:55:03 +0300 Subject: [PATCH] feat(rendering): display description, system, duration, format, type and location in Telegram game card --- .../CreateSession/CancelSessionHandler.cs | 12 ++- .../PromoteWaitlistedPlayerHandler.cs | 6 +- .../RescheduleVotingDeadlineService.cs | 2 +- .../Telegram/TelegramSessionBatchRenderer.cs | 55 +++++++++-- .../CreateSession/CreateSessionHandler.cs | 6 +- .../Rendering/SessionBatchDto.cs | 6 +- .../Rendering/SessionBatchViewBuilder.cs | 4 + .../Rendering/SessionBatchViewModel.cs | 4 + src/GmRelay.Web/Services/SessionService.cs | 65 +++++++++++-- .../Services/TelegramSessionBatchRenderer.cs | 55 +++++++++-- .../Rendering/SessionBatchViewBuilderTests.cs | 32 +++++++ .../TelegramSessionBatchRendererTests.cs | 91 +++++++++++++++++-- 12 files changed, 298 insertions(+), 40 deletions(-) diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs index acd5776..aa425f3 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs @@ -70,7 +70,17 @@ public sealed class CancelSessionHandler( // 3. Загружаем весь батч для перерисовки var batchSessions = await connection.QueryAsync( - @"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink, format as Format, location_address as LocationAddress + @"SELECT id as SessionId, + scheduled_at as ScheduledAt, + status as Status, + max_players as MaxPlayers, + join_link as JoinLink, + format as Format, + location_address as LocationAddress, + description as Description, + system as System, + duration_minutes as DurationMinutes, + is_one_shot as IsOneShot FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs index fba243f..b21cffb 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs @@ -141,7 +141,11 @@ public sealed class PromoteWaitlistedPlayerHandler( max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, - location_address AS LocationAddress + location_address AS LocationAddress, + description AS Description, + system AS System, + duration_minutes AS DurationMinutes, + is_one_shot AS IsOneShot FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index 4daba17..a25e405 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -162,7 +162,7 @@ public sealed class RescheduleVotingDeadlineService( await using var connection = await dataSource.OpenConnectionAsync(ct); var batchSessions = (await connection.QueryAsync( - "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", + "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress, description AS Description, system AS System, duration_minutes AS DurationMinutes, is_one_shot AS IsOneShot FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", new { result.BatchId })).ToList(); var batchParticipants = (await connection.QueryAsync( diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs index 296e271..1708901 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs @@ -17,22 +17,49 @@ public static class TelegramSessionBatchRenderer 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 (!string.IsNullOrEmpty(session.JoinLink)) + var tags = new List(); + if (!string.IsNullOrWhiteSpace(session.System)) + tags.Add($"Система: {System.Net.WebUtility.HtmlEncode(session.System)}"); + if (!string.IsNullOrWhiteSpace(session.Format)) + tags.Add($"Формат: {System.Net.WebUtility.HtmlEncode(session.Format)}"); + tags.Add($"Тип: {(session.IsOneShot ? "One-shot" : "Кампания")}"); + + if (tags.Count > 0) + { + messageText += "🏷 " + string.Join(" · ", tags) + "\n"; + } + + if (session.DurationMinutes.HasValue) + { + messageText += $"⏱ Длительность: {FormatDuration(session.DurationMinutes.Value)}\n"; + } + + if (!string.IsNullOrWhiteSpace(session.Description)) + { + messageText += $"📝 Описание:\n{System.Net.WebUtility.HtmlEncode(session.Description)}\n\n"; + } + + var format = session.Format ?? string.Empty; + var isOnline = string.Equals(format, "Online", StringComparison.OrdinalIgnoreCase); + var isOffline = string.Equals(format, "Offline", StringComparison.OrdinalIgnoreCase); + var isHybrid = string.Equals(format, "Hybrid", StringComparison.OrdinalIgnoreCase); + + if ((isOnline || isHybrid) && !string.IsNullOrWhiteSpace(session.JoinLink)) { var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink); - messageText += $"🔗 Ссылка на игру: {encodedLink}\n"; + messageText += $"🔗 Ссылка: {encodedLink}\n"; } - if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(session.LocationAddress)) + if ((isOffline || isHybrid) && !string.IsNullOrWhiteSpace(session.LocationAddress)) { - messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n"; + messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\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 => @@ -45,7 +72,7 @@ public static class TelegramSessionBatchRenderer if (session.WaitlistedPlayers.Count > 0) { - messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n"; + messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n"; messageText += string.Join("\n", session.WaitlistedPlayers.Select(p => $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n"; } @@ -67,4 +94,14 @@ public static class TelegramSessionBatchRenderer return (messageText, new InlineKeyboardMarkup(buttons)); } + + private static string FormatDuration(int minutes) + { + if (minutes <= 0) return "0 мин"; + var hours = minutes / 60; + var mins = minutes % 60; + if (hours > 0 && mins > 0) return $"{hours} ч {mins} мин"; + if (hours > 0) return $"{hours} ч"; + return $"{mins} мин"; + } } diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs index 7ef9779..0a2eade 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -154,7 +154,11 @@ public sealed class CreateSessionHandler( command.MaxPlayers, command.Link, command.Format, - command.LocationAddress)); + command.LocationAddress, + command.Description, + command.System?.ToString(), + command.DurationMinutes, + command.IsOneShot)); } await transaction.CommitAsync(ct); diff --git a/src/GmRelay.Shared/Rendering/SessionBatchDto.cs b/src/GmRelay.Shared/Rendering/SessionBatchDto.cs index 5bc4acf..c932dde 100644 --- a/src/GmRelay.Shared/Rendering/SessionBatchDto.cs +++ b/src/GmRelay.Shared/Rendering/SessionBatchDto.cs @@ -7,5 +7,9 @@ public sealed record SessionBatchDto( int? MaxPlayers, string JoinLink, string? Format = null, - string? LocationAddress = null); + string? LocationAddress = null, + string? Description = null, + string? System = null, + int? DurationMinutes = null, + bool IsOneShot = false); public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus); diff --git a/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs b/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs index a4ac299..55dd444 100644 --- a/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs +++ b/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs @@ -41,6 +41,10 @@ public static class SessionBatchViewBuilder session.JoinLink, session.Format, session.LocationAddress, + session.Description, + session.System, + session.DurationMinutes, + session.IsOneShot, activePlayers.Count, activePlayers, waitlistedPlayers, diff --git a/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs b/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs index 7e38f24..5a73ab3 100644 --- a/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs +++ b/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs @@ -14,6 +14,10 @@ public sealed record SessionViewItem( string JoinLink, string? Format, string? LocationAddress, + string? Description, + string? System, + int? DurationMinutes, + bool IsOneShot, int ActivePlayerCount, IReadOnlyList ActivePlayers, IReadOnlyList WaitlistedPlayers, diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 0d62e73..120f645 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -119,7 +119,14 @@ internal sealed record WebBatchSessionRow( long TelegramChatId, int? ThreadId, string NotificationMode, - bool TopicCreatedByBot = false); + bool TopicCreatedByBot = false, + string? Description = null, + string? System = null, + int? DurationMinutes = null, + string? Format = null, + string? LocationAddress = null, + bool IsOneShot = false, + string? CoverImageUrl = null); internal sealed record WebTemplateGroupDto(long TelegramChatId); internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot); internal sealed record WebPublicGroupRow( @@ -1508,7 +1515,14 @@ public sealed class SessionService( g.external_group_id::BIGINT AS TelegramChatId, s.thread_id AS ThreadId, s.topic_created_by_bot AS TopicCreatedByBot, - s.notification_mode AS NotificationMode + s.notification_mode AS NotificationMode, + s.description AS Description, + s.system AS System, + s.duration_minutes AS DurationMinutes, + s.format AS Format, + s.location_address AS LocationAddress, + s.is_one_shot AS IsOneShot, + s.cover_image_url AS CoverImageUrl FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.batch_id = @BatchId @@ -1536,8 +1550,14 @@ public sealed class SessionService( var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval); var sessionId = await conn.ExecuteScalarAsync( """ - INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode) - VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode) + INSERT INTO sessions ( + batch_id, group_id, title, join_link, scheduled_at, status, thread_id, + topic_created_by_bot, max_players, notification_mode, description, system, + duration_minutes, format, location_address, is_one_shot, cover_image_url) + VALUES ( + @BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, + @TopicCreatedByBot, @MaxPlayers, @NotificationMode, @Description, @System, + @DurationMinutes, @Format, @LocationAddress, @IsOneShot, @CoverImageUrl) RETURNING id """, new @@ -1551,11 +1571,29 @@ public sealed class SessionService( ThreadId = threadId, sourceSession.TopicCreatedByBot, sourceSession.MaxPlayers, - sourceSession.NotificationMode + sourceSession.NotificationMode, + Description = sourceSession.Description, + System = sourceSession.System, + DurationMinutes = sourceSession.DurationMinutes, + Format = sourceSession.Format, + LocationAddress = sourceSession.LocationAddress, + IsOneShot = sourceSession.IsOneShot, + CoverImageUrl = sourceSession.CoverImageUrl }, transaction); - renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers, batchJoinLink)); + renderedSessions.Add(new SessionBatchDto( + sessionId, + scheduledAt, + SessionStatus.Planned, + sourceSession.MaxPlayers, + batchJoinLink, + sourceSession.Format, + sourceSession.LocationAddress, + sourceSession.Description, + sourceSession.System, + sourceSession.DurationMinutes, + sourceSession.IsOneShot)); } await transaction.CommitAsync(); @@ -1770,7 +1808,18 @@ public sealed class SessionService( }, transaction); - renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers, template.JoinLink)); + renderedSessions.Add(new SessionBatchDto( + sessionId, + scheduledAt, + SessionStatus.Planned, + template.MaxPlayers, + template.JoinLink, + Format: null, + LocationAddress: null, + Description: null, + System: null, + DurationMinutes: null, + IsOneShot: false)); } await transaction.CommitAsync(); @@ -1897,7 +1946,7 @@ public sealed class SessionService( await using var conn = await dataSource.OpenConnectionAsync(); var sessions = (await conn.QueryAsync( - "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", + "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress, description AS Description, system AS System, duration_minutes AS DurationMinutes, is_one_shot AS IsOneShot FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", new { BatchId = batchId })).ToList(); var participants = (await conn.QueryAsync( diff --git a/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs b/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs index e2d60dd..0dad10d 100644 --- a/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs +++ b/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs @@ -16,22 +16,49 @@ public static class TelegramSessionBatchRenderer 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 (!string.IsNullOrEmpty(session.JoinLink)) + var tags = new List(); + if (!string.IsNullOrWhiteSpace(session.System)) + tags.Add($"Система: {System.Net.WebUtility.HtmlEncode(session.System)}"); + if (!string.IsNullOrWhiteSpace(session.Format)) + tags.Add($"Формат: {System.Net.WebUtility.HtmlEncode(session.Format)}"); + tags.Add($"Тип: {(session.IsOneShot ? "One-shot" : "Кампания")}"); + + if (tags.Count > 0) + { + messageText += "🏷 " + string.Join(" · ", tags) + "\n"; + } + + if (session.DurationMinutes.HasValue) + { + messageText += $"⏱ Длительность: {FormatDuration(session.DurationMinutes.Value)}\n"; + } + + if (!string.IsNullOrWhiteSpace(session.Description)) + { + messageText += $"📝 Описание:\n{System.Net.WebUtility.HtmlEncode(session.Description)}\n\n"; + } + + var format = session.Format ?? string.Empty; + var isOnline = string.Equals(format, "Online", StringComparison.OrdinalIgnoreCase); + var isOffline = string.Equals(format, "Offline", StringComparison.OrdinalIgnoreCase); + var isHybrid = string.Equals(format, "Hybrid", StringComparison.OrdinalIgnoreCase); + + if ((isOnline || isHybrid) && !string.IsNullOrWhiteSpace(session.JoinLink)) { var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink); - messageText += $"🔗 Ссылка на игру: {encodedLink}\n"; + messageText += $"🔗 Ссылка: {encodedLink}\n"; } - if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(session.LocationAddress)) + if ((isOffline || isHybrid) && !string.IsNullOrWhiteSpace(session.LocationAddress)) { - messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n"; + messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\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 => @@ -44,7 +71,7 @@ public static class TelegramSessionBatchRenderer if (session.WaitlistedPlayers.Count > 0) { - messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n"; + messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n"; messageText += string.Join("\n", session.WaitlistedPlayers.Select(p => $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n"; } @@ -66,4 +93,14 @@ public static class TelegramSessionBatchRenderer return (messageText, new InlineKeyboardMarkup(buttons)); } + + private static string FormatDuration(int minutes) + { + if (minutes <= 0) return "0 мин"; + var hours = minutes / 60; + var mins = minutes % 60; + if (hours > 0 && mins > 0) return $"{hours} ч {mins} мин"; + if (hours > 0) return $"{hours} ч"; + return $"{mins} мин"; + } } diff --git a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchViewBuilderTests.cs b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchViewBuilderTests.cs index c778d02..f11862e 100644 --- a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchViewBuilderTests.cs +++ b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchViewBuilderTests.cs @@ -149,4 +149,36 @@ public sealed class SessionBatchViewBuilderTests var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session"); Assert.DoesNotContain("ожидания", joinAction.Label); } + + [Fact] + public void Build_ShouldPassThroughNewFields() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] + { + new SessionBatchDto( + sessionId, + DateTime.UtcNow, + SessionStatus.Planned, + 4, + "https://example.com/game", + "Offline", + "Moscow", + "A short description", + "D\u0026D 5e", + 240, + true) + }; + var participants = Array.Empty(); + + var result = SessionBatchViewBuilder.Build("Test", sessions, participants); + var session = result.Sessions[0]; + + Assert.Equal("A short description", session.Description); + Assert.Equal("D\u0026D 5e", session.System); + Assert.Equal(240, session.DurationMinutes); + Assert.True(session.IsOneShot); + Assert.Equal("Offline", session.Format); + Assert.Equal("Moscow", session.LocationAddress); + } } diff --git a/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs index ef582ac..35b1404 100644 --- a/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs +++ b/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs @@ -16,9 +16,9 @@ public sealed class TelegramSessionBatchRendererTests 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(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game2", "Online", null), 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") + new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game1", "Online", null) }; var participants = new[] { @@ -35,7 +35,7 @@ public sealed class TelegramSessionBatchRendererTests Assert.Contains("Charlie", text); Assert.Contains("Bob", text); Assert.Contains("Сессия отменена", text); - Assert.Contains("Ссылка на игру", text); + Assert.Contains("Ссылка:", text); Assert.Contains("https://example.com/game1", text); Assert.Contains("https://example.com/game2", text); @@ -67,7 +67,7 @@ public sealed class TelegramSessionBatchRendererTests public void Render_ShouldShowWaitlistButtonWhenFull() { var sessionId = Guid.NewGuid(); - var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") }; + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game", "Online", null) }; var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) }; var view = SessionBatchViewBuilder.Build("Test", sessions, participants); @@ -130,7 +130,7 @@ public sealed class TelegramSessionBatchRendererTests var (text, markup) = TelegramSessionBatchRenderer.Render(view); var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList(); - Assert.DoesNotContain("Ссылка на игру", text); + Assert.DoesNotContain("Ссылка:", text); Assert.Contains("📅", text); Assert.Equal(2, buttons.Count); } @@ -155,9 +155,9 @@ public sealed class TelegramSessionBatchRendererTests var view = SessionBatchViewBuilder.Build("Offline Test", sessions, participants); var (text, _) = TelegramSessionBatchRenderer.Render(view); - Assert.Contains("📍 Адрес:", text); + Assert.Contains("📍 Адрес:", text); Assert.Contains("Москва, ул. Кубиков, 12", text); - Assert.DoesNotContain("Ссылка на игру", text); + Assert.DoesNotContain("Ссылка:", text); } [Fact] @@ -180,7 +180,7 @@ public sealed class TelegramSessionBatchRendererTests var view = SessionBatchViewBuilder.Build("Online Test", sessions, participants); var (text, _) = TelegramSessionBatchRenderer.Render(view); - Assert.Contains("🔗 Ссылка на игру", text); + Assert.Contains("🔗 Ссылка:", text); Assert.Contains("https://vtt.example/game", text); Assert.DoesNotContain("📍 Адрес:", text); } @@ -189,7 +189,7 @@ public sealed class TelegramSessionBatchRendererTests public void Render_ShouldEncodeHtmlInJoinLink() { var sessionId = Guid.NewGuid(); - var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/test?a=1&b=2") }; + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/test?a=1&b=2", "Online", null) }; var participants = Array.Empty(); var view = SessionBatchViewBuilder.Build("Test", sessions, participants); @@ -198,4 +198,77 @@ public sealed class TelegramSessionBatchRendererTests Assert.Contains("a=1&b=2", text); Assert.DoesNotContain("a=1&b=2" + "\"", text); // make sure & is encoded } + + [Fact] + public void Render_ShouldShowStructuredGameCard() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] + { + new SessionBatchDto( + sessionId, + new DateTime(2026, 6, 13, 16, 0, 0, DateTimeKind.Utc), + SessionStatus.Planned, + 4, + "https://vtt.example/game", + "Hybrid", + "Moscow, Kubik Bar", + "Mystery one-shot in Bamberg.", + "D\u0026D 5e", + 240, + true) + }; + var participants = new[] + { + new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active), + new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Waitlisted) + }; + + var view = SessionBatchViewBuilder.Build("Structured Test", sessions, participants); + var (text, markup) = TelegramSessionBatchRenderer.Render(view); + + Assert.Contains("🏷", text); + Assert.Contains("Система:", text); + Assert.Contains("D\u0026amp;D 5e", text); + Assert.Contains("Формат:", text); + Assert.Contains("Hybrid", text); + Assert.Contains("Тип:", text); + Assert.Contains("One-shot", text); + Assert.Contains("⏱", text); + Assert.Contains("Длительность:", text); + Assert.Contains("4 ч", text); + Assert.Contains("📝", text); + Assert.Contains("Описание:", text); + Assert.Contains("Mystery one-shot in Bamberg.", text); + Assert.Contains("🔗", text); + Assert.Contains("Ссылка:", text); + Assert.Contains("📍", text); + Assert.Contains("Адрес:", text); + Assert.Contains("@alice", text); + Assert.Contains("Bob", text); + Assert.Contains("Лист ожидания", text); + + var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList(); + Assert.Equal(2, buttons.Count); + } + + [Fact] + public void Render_ShouldHandleMissingOptionalFields() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "") }; + var participants = Array.Empty(); + + var view = SessionBatchViewBuilder.Build("Minimal", sessions, participants); + var (text, _) = TelegramSessionBatchRenderer.Render(view); + + Assert.Contains("📅", text); + Assert.Contains("👥", text); + Assert.DoesNotContain("Система:", text); + Assert.DoesNotContain("Формат:", text); + Assert.DoesNotContain("Длительность:", text); + Assert.DoesNotContain("Описание:", text); + Assert.DoesNotContain("Ссылка:", text); + Assert.DoesNotContain("Адрес:", text); + } }