Compare commits

...

2 Commits

Author SHA1 Message Date
Toutsu de121d7523 chore(version): bump version to 3.11.0
PR Checks / test-and-build (pull_request) Failing after 23m14s
Synchronized version across Directory.Build.props, compose.yaml,
.gitea/workflows/deploy.yml, and NavMenu.razor.
2026-06-13 10:56:18 +03:00
Toutsu 3c967dc3e3 feat(rendering): display description, system, duration, format, type and location in Telegram game card 2026-06-13 10:55:03 +03:00
16 changed files with 304 additions and 46 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 3.10.0 VERSION: 3.11.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>3.10.0</Version> <Version>3.11.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.10.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.0
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.10.0 image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.0
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.10.0 image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -70,7 +70,17 @@ public sealed class CancelSessionHandler(
// 3. Загружаем весь батч для перерисовки // 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"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 FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at", ORDER BY scheduled_at",
@@ -141,7 +141,11 @@ public sealed class PromoteWaitlistedPlayerHandler(
max_players AS MaxPlayers, max_players AS MaxPlayers,
join_link AS JoinLink, join_link AS JoinLink,
format AS Format, 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 FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at ORDER BY scheduled_at
@@ -162,7 +162,7 @@ public sealed class RescheduleVotingDeadlineService(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>( var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"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(); new { result.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
@@ -17,22 +17,49 @@ public static class TelegramSessionBatchRenderer
foreach (var session in view.Sessions) foreach (var session in view.Sessions)
{ {
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n"; messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (!string.IsNullOrEmpty(session.JoinLink)) var tags = new List<string>();
if (!string.IsNullOrWhiteSpace(session.System))
tags.Add($"<b>Система:</b> {System.Net.WebUtility.HtmlEncode(session.System)}");
if (!string.IsNullOrWhiteSpace(session.Format))
tags.Add($"<b>Формат:</b> {System.Net.WebUtility.HtmlEncode(session.Format)}");
tags.Add($"<b>Тип:</b> {(session.IsOneShot ? "One-shot" : "Кампания")}");
if (tags.Count > 0)
{
messageText += "🏷 " + string.Join(" · ", tags) + "\n";
}
if (session.DurationMinutes.HasValue)
{
messageText += $"⏱ <b>Длительность:</b> {FormatDuration(session.DurationMinutes.Value)}\n";
}
if (!string.IsNullOrWhiteSpace(session.Description))
{
messageText += $"📝 <b>Описание:</b>\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); var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
messageText += $"🔗 Ссылка на игру: <a href=\"{encodedLink}\">{encodedLink}</a>\n"; messageText += $"🔗 <b>Ссылка:</b> <a href=\"{encodedLink}\">{encodedLink}</a>\n";
} }
if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) && if ((isOffline || isHybrid) && !string.IsNullOrWhiteSpace(session.LocationAddress))
!string.IsNullOrWhiteSpace(session.LocationAddress))
{ {
messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n"; messageText += $"📍 <b>Адрес:</b> {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
} }
messageText += session.MaxPlayers.HasValue
? $"👥 <b>Места:</b> {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 <b>Игроки ({session.ActivePlayerCount}):</b>\n";
if (session.ActivePlayers.Count > 0) if (session.ActivePlayers.Count > 0)
{ {
messageText += string.Join("\n", session.ActivePlayers.Select(p => messageText += string.Join("\n", session.ActivePlayers.Select(p =>
@@ -45,7 +72,7 @@ public static class TelegramSessionBatchRenderer
if (session.WaitlistedPlayers.Count > 0) if (session.WaitlistedPlayers.Count > 0)
{ {
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n"; messageText += $"⏳ <b>Лист ожидания ({session.WaitlistedPlayers.Count}):</b>\n";
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p => messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n"; $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
} }
@@ -67,4 +94,14 @@ public static class TelegramSessionBatchRenderer
return (messageText, new InlineKeyboardMarkup(buttons)); 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} мин";
}
} }
@@ -154,7 +154,11 @@ public sealed class CreateSessionHandler(
command.MaxPlayers, command.MaxPlayers,
command.Link, command.Link,
command.Format, command.Format,
command.LocationAddress)); command.LocationAddress,
command.Description,
command.System?.ToString(),
command.DurationMinutes,
command.IsOneShot));
} }
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
@@ -7,5 +7,9 @@ public sealed record SessionBatchDto(
int? MaxPlayers, int? MaxPlayers,
string JoinLink, string JoinLink,
string? Format = null, 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); public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
@@ -41,6 +41,10 @@ public static class SessionBatchViewBuilder
session.JoinLink, session.JoinLink,
session.Format, session.Format,
session.LocationAddress, session.LocationAddress,
session.Description,
session.System,
session.DurationMinutes,
session.IsOneShot,
activePlayers.Count, activePlayers.Count,
activePlayers, activePlayers,
waitlistedPlayers, waitlistedPlayers,
@@ -14,6 +14,10 @@ public sealed record SessionViewItem(
string JoinLink, string JoinLink,
string? Format, string? Format,
string? LocationAddress, string? LocationAddress,
string? Description,
string? System,
int? DurationMinutes,
bool IsOneShot,
int ActivePlayerCount, int ActivePlayerCount,
IReadOnlyList<PlayerViewItem> ActivePlayers, IReadOnlyList<PlayerViewItem> ActivePlayers,
IReadOnlyList<PlayerViewItem> WaitlistedPlayers, IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
@@ -82,7 +82,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v3.10.0</div> <div class="nav-version">v3.11.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
+57 -8
View File
@@ -119,7 +119,14 @@ internal sealed record WebBatchSessionRow(
long TelegramChatId, long TelegramChatId,
int? ThreadId, int? ThreadId,
string NotificationMode, 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 WebTemplateGroupDto(long TelegramChatId);
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot); internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
internal sealed record WebPublicGroupRow( internal sealed record WebPublicGroupRow(
@@ -1508,7 +1515,14 @@ public sealed class SessionService(
g.external_group_id::BIGINT AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot, 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 FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.batch_id = @BatchId WHERE s.batch_id = @BatchId
@@ -1536,8 +1550,14 @@ public sealed class SessionService(
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval); var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
var sessionId = await conn.ExecuteScalarAsync<Guid>( var sessionId = await conn.ExecuteScalarAsync<Guid>(
""" """
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode) INSERT INTO sessions (
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode) 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 RETURNING id
""", """,
new new
@@ -1551,11 +1571,29 @@ public sealed class SessionService(
ThreadId = threadId, ThreadId = threadId,
sourceSession.TopicCreatedByBot, sourceSession.TopicCreatedByBot,
sourceSession.MaxPlayers, 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); 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(); await transaction.CommitAsync();
@@ -1770,7 +1808,18 @@ public sealed class SessionService(
}, },
transaction); 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(); await transaction.CommitAsync();
@@ -1897,7 +1946,7 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var sessions = (await conn.QueryAsync<SessionBatchDto>( var sessions = (await conn.QueryAsync<SessionBatchDto>(
"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(); new { BatchId = batchId })).ToList();
var participants = (await conn.QueryAsync<ParticipantBatchDto>( var participants = (await conn.QueryAsync<ParticipantBatchDto>(
@@ -16,22 +16,49 @@ public static class TelegramSessionBatchRenderer
foreach (var session in view.Sessions) foreach (var session in view.Sessions)
{ {
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n"; messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (!string.IsNullOrEmpty(session.JoinLink)) var tags = new List<string>();
if (!string.IsNullOrWhiteSpace(session.System))
tags.Add($"<b>Система:</b> {System.Net.WebUtility.HtmlEncode(session.System)}");
if (!string.IsNullOrWhiteSpace(session.Format))
tags.Add($"<b>Формат:</b> {System.Net.WebUtility.HtmlEncode(session.Format)}");
tags.Add($"<b>Тип:</b> {(session.IsOneShot ? "One-shot" : "Кампания")}");
if (tags.Count > 0)
{
messageText += "🏷 " + string.Join(" · ", tags) + "\n";
}
if (session.DurationMinutes.HasValue)
{
messageText += $"⏱ <b>Длительность:</b> {FormatDuration(session.DurationMinutes.Value)}\n";
}
if (!string.IsNullOrWhiteSpace(session.Description))
{
messageText += $"📝 <b>Описание:</b>\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); var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
messageText += $"🔗 Ссылка на игру: <a href=\"{encodedLink}\">{encodedLink}</a>\n"; messageText += $"🔗 <b>Ссылка:</b> <a href=\"{encodedLink}\">{encodedLink}</a>\n";
} }
if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) && if ((isOffline || isHybrid) && !string.IsNullOrWhiteSpace(session.LocationAddress))
!string.IsNullOrWhiteSpace(session.LocationAddress))
{ {
messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n"; messageText += $"📍 <b>Адрес:</b> {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
} }
messageText += session.MaxPlayers.HasValue
? $"👥 <b>Места:</b> {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 <b>Игроки ({session.ActivePlayerCount}):</b>\n";
if (session.ActivePlayers.Count > 0) if (session.ActivePlayers.Count > 0)
{ {
messageText += string.Join("\n", session.ActivePlayers.Select(p => messageText += string.Join("\n", session.ActivePlayers.Select(p =>
@@ -44,7 +71,7 @@ public static class TelegramSessionBatchRenderer
if (session.WaitlistedPlayers.Count > 0) if (session.WaitlistedPlayers.Count > 0)
{ {
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n"; messageText += $"⏳ <b>Лист ожидания ({session.WaitlistedPlayers.Count}):</b>\n";
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p => messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n"; $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
} }
@@ -66,4 +93,14 @@ public static class TelegramSessionBatchRenderer
return (messageText, new InlineKeyboardMarkup(buttons)); 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} мин";
}
} }
@@ -149,4 +149,36 @@ public sealed class SessionBatchViewBuilderTests
var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session"); var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session");
Assert.DoesNotContain("ожидания", joinAction.Label); 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<ParticipantBatchDto>();
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);
}
} }
@@ -16,9 +16,9 @@ public sealed class TelegramSessionBatchRendererTests
var sessions = new[] 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(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[] var participants = new[]
{ {
@@ -35,7 +35,7 @@ public sealed class TelegramSessionBatchRendererTests
Assert.Contains("Charlie", text); Assert.Contains("Charlie", text);
Assert.Contains("Bob", text); Assert.Contains("Bob", text);
Assert.Contains("Сессия отменена", text); Assert.Contains("Сессия отменена", text);
Assert.Contains("Ссылка на игру", text); Assert.Contains("Ссылка:", text);
Assert.Contains("https://example.com/game1", text); Assert.Contains("https://example.com/game1", text);
Assert.Contains("https://example.com/game2", text); Assert.Contains("https://example.com/game2", text);
@@ -67,7 +67,7 @@ public sealed class TelegramSessionBatchRendererTests
public void Render_ShouldShowWaitlistButtonWhenFull() public void Render_ShouldShowWaitlistButtonWhenFull()
{ {
var sessionId = Guid.NewGuid(); 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 participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
var view = SessionBatchViewBuilder.Build("Test", sessions, participants); var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
@@ -130,7 +130,7 @@ public sealed class TelegramSessionBatchRendererTests
var (text, markup) = TelegramSessionBatchRenderer.Render(view); var (text, markup) = TelegramSessionBatchRenderer.Render(view);
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList(); var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.DoesNotContain("Ссылка на игру", text); Assert.DoesNotContain("Ссылка:", text);
Assert.Contains("📅", text); Assert.Contains("📅", text);
Assert.Equal(2, buttons.Count); Assert.Equal(2, buttons.Count);
} }
@@ -155,9 +155,9 @@ public sealed class TelegramSessionBatchRendererTests
var view = SessionBatchViewBuilder.Build("Offline Test", sessions, participants); var view = SessionBatchViewBuilder.Build("Offline Test", sessions, participants);
var (text, _) = TelegramSessionBatchRenderer.Render(view); var (text, _) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("📍 Адрес:", text); Assert.Contains("📍 <b>Адрес:</b>", text);
Assert.Contains("Москва, ул. Кубиков, 12", text); Assert.Contains("Москва, ул. Кубиков, 12", text);
Assert.DoesNotContain("Ссылка на игру", text); Assert.DoesNotContain("Ссылка:", text);
} }
[Fact] [Fact]
@@ -180,7 +180,7 @@ public sealed class TelegramSessionBatchRendererTests
var view = SessionBatchViewBuilder.Build("Online Test", sessions, participants); var view = SessionBatchViewBuilder.Build("Online Test", sessions, participants);
var (text, _) = TelegramSessionBatchRenderer.Render(view); var (text, _) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("🔗 Ссылка на игру", text); Assert.Contains("🔗 <b>Ссылка:</b>", text);
Assert.Contains("https://vtt.example/game", text); Assert.Contains("https://vtt.example/game", text);
Assert.DoesNotContain("📍 Адрес:", text); Assert.DoesNotContain("📍 Адрес:", text);
} }
@@ -189,7 +189,7 @@ public sealed class TelegramSessionBatchRendererTests
public void Render_ShouldEncodeHtmlInJoinLink() public void Render_ShouldEncodeHtmlInJoinLink()
{ {
var sessionId = Guid.NewGuid(); 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<ParticipantBatchDto>(); var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants); var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
@@ -198,4 +198,77 @@ public sealed class TelegramSessionBatchRendererTests
Assert.Contains("a=1&amp;b=2", text); Assert.Contains("a=1&amp;b=2", text);
Assert.DoesNotContain("a=1&b=2" + "\"", text); // make sure & is encoded 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<ParticipantBatchDto>();
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);
}
} }