This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.Entity;
|
||||
|
||||
namespace LaDOSE.Business.Service
|
||||
{
|
||||
/// <summary>
|
||||
/// Everything about a spreadsheet export that is not Google-specific: resolving the target,
|
||||
/// validating the tables, and making the tab titles legal and unique. Kept out of the writers
|
||||
/// so the rules are stated once and can be tested without a network, the same way
|
||||
/// StatisticsService keeps its aggregation in a pure static method.
|
||||
/// </summary>
|
||||
public class SheetsExportService : ISheetsExportService
|
||||
{
|
||||
private readonly SheetsSettings _settings;
|
||||
private readonly ISheetsWriter _writer;
|
||||
|
||||
public SheetsExportService(SheetsSettings settings, ISheetsWriter writer)
|
||||
{
|
||||
_settings = settings ?? new SheetsSettings();
|
||||
_writer = writer;
|
||||
}
|
||||
|
||||
public SheetsConfig GetConfig()
|
||||
{
|
||||
return new SheetsConfig
|
||||
{
|
||||
Writer = _writer?.Name ?? "Disabled",
|
||||
Configured = _writer != null
|
||||
&& _writer.IsConfigured
|
||||
&& !string.IsNullOrWhiteSpace(_settings.SpreadsheetId),
|
||||
SpreadsheetId = _settings.SpreadsheetId ?? string.Empty,
|
||||
MaxTabs = _settings.MaxTabs,
|
||||
MaxRowsPerTab = _settings.MaxRowsPerTab
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SheetExportResult> ExportAsync(SheetExportRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (_writer == null || !_writer.IsConfigured)
|
||||
{
|
||||
throw new SheetsExportException(503,
|
||||
"Google Sheets export is not configured on the server.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_settings.SpreadsheetId))
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
"No spreadsheet configured on the server. Set GoogleSheets:SpreadsheetId.");
|
||||
}
|
||||
|
||||
var tabs = request?.Tabs?.Where(tab => tab != null).ToList() ?? new List<SheetTable>();
|
||||
if (tabs.Count == 0)
|
||||
{
|
||||
throw new SheetsExportException(400, "No table to write.");
|
||||
}
|
||||
|
||||
Validate(tabs);
|
||||
|
||||
var warnings = new List<string>();
|
||||
NameTabs(tabs, warnings);
|
||||
|
||||
// The caller never names the spreadsheet; it is resolved here, from configuration.
|
||||
var resolved = new SheetExportRequest
|
||||
{
|
||||
SpreadsheetId = _settings.SpreadsheetId.Trim(),
|
||||
Tabs = tabs
|
||||
};
|
||||
|
||||
var result = await _writer.WriteTablesAsync(resolved, ct);
|
||||
result.Writer = _writer.Name;
|
||||
result.Warnings = (result.Warnings ?? new List<string>()).Concat(warnings).ToList();
|
||||
return result;
|
||||
}
|
||||
|
||||
#region Validation
|
||||
|
||||
private void Validate(List<SheetTable> tabs)
|
||||
{
|
||||
if (tabs.Count > _settings.MaxTabs)
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
$"{tabs.Count} tabs requested, the limit is {_settings.MaxTabs}. Narrow the selection.");
|
||||
}
|
||||
|
||||
foreach (var tab in tabs)
|
||||
{
|
||||
var header = tab.Header ?? new List<string>();
|
||||
|
||||
// "Players", at least one game, "Total".
|
||||
if (header.Count < 3)
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
$"Tab '{tab.Name}' has no game column — nothing was scored for it.");
|
||||
}
|
||||
|
||||
if (header.Count > _settings.MaxColumns)
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
$"Tab '{tab.Name}' has {header.Count} columns, the limit is {_settings.MaxColumns}.");
|
||||
}
|
||||
|
||||
var rows = tab.Rows ?? new List<SheetRow>();
|
||||
if (rows.Count > _settings.MaxRowsPerTab)
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
$"Tab '{tab.Name}' has {rows.Count} rows, the limit is {_settings.MaxRowsPerTab}.");
|
||||
}
|
||||
|
||||
var expected = header.Count - 2;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var points = row?.Points?.Count ?? 0;
|
||||
if (points != expected)
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
$"Tab '{tab.Name}' row '{row?.Player}' has {points} point columns, " +
|
||||
$"header declares {expected}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tab naming
|
||||
|
||||
/// <summary>
|
||||
/// Google rejects these in a tab title. Replaced rather than stripped so "Ranking 13/14"
|
||||
/// stays readable as "Ranking 13-14".
|
||||
/// </summary>
|
||||
private static readonly Regex Forbidden = new Regex(@"[:\\/?*\[\]]", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex Whitespace = new Regex(@"\s+", RegexOptions.Compiled);
|
||||
|
||||
private const int MaxTitleLength = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Makes every title legal and unique, in place, recording each change. Tab identity is
|
||||
/// the title, so a rename means the next export writes somewhere else — which is exactly
|
||||
/// why every rename is reported rather than applied quietly.
|
||||
/// </summary>
|
||||
private static void NameTabs(List<SheetTable> tabs, List<string> warnings)
|
||||
{
|
||||
var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var tab in tabs)
|
||||
{
|
||||
var requested = tab.Name ?? string.Empty;
|
||||
var name = Sanitise(requested, tab.EventId);
|
||||
|
||||
if (!taken.Add(name))
|
||||
{
|
||||
var suffix = 2;
|
||||
string candidate;
|
||||
do
|
||||
{
|
||||
candidate = Truncate($"{name} ({suffix})");
|
||||
suffix++;
|
||||
} while (!taken.Add(candidate));
|
||||
|
||||
name = candidate;
|
||||
}
|
||||
|
||||
if (!string.Equals(name, requested, StringComparison.Ordinal))
|
||||
{
|
||||
warnings.Add($"Tab renamed: '{requested}' -> '{name}'");
|
||||
}
|
||||
|
||||
tab.Name = name;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trim, replace what Google forbids, collapse runs of whitespace, drop leading and
|
||||
/// trailing apostrophes (Sheets uses them to quote a title), cap the length, and fall
|
||||
/// back to the event id when nothing usable survives.
|
||||
/// </summary>
|
||||
public static string Sanitise(string requested, int eventId)
|
||||
{
|
||||
var name = (requested ?? string.Empty).Trim();
|
||||
name = Forbidden.Replace(name, "-");
|
||||
name = Whitespace.Replace(name, " ").Trim();
|
||||
name = name.Trim('\'');
|
||||
name = Truncate(name).Trim();
|
||||
|
||||
return string.IsNullOrWhiteSpace(name) ? $"Event {eventId}" : name;
|
||||
}
|
||||
|
||||
private static string Truncate(string value)
|
||||
{
|
||||
return value.Length <= MaxTitleLength ? value : value.Substring(0, MaxTitleLength);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,15 @@ namespace LaDOSE.Business.Service
|
||||
/// - There is no winner column. The winner is inferred from the scores, and start.gg
|
||||
/// encodes a DQ as -1, so games are clamped at 0.
|
||||
///
|
||||
/// - A Set has no game either. The game belongs to the <see cref="Tournament"/> the set
|
||||
/// was played in, and Tournament.GameId is nullable, so any per-game breakdown has
|
||||
/// to decide what to do with brackets that have none. <see cref="GetVersus"/> drops
|
||||
/// them and reports how many it dropped.
|
||||
///
|
||||
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
|
||||
/// issues one query per table and hands the loaded lists to the pure static
|
||||
/// <see cref="Aggregate"/>, which is unit-testable without a database.
|
||||
/// and <see cref="GetVersus"/> issue one query per table and hand the loaded lists to the pure
|
||||
/// static <see cref="Aggregate"/> / <see cref="AggregateVersus"/>, which are unit-testable
|
||||
/// without a database.
|
||||
/// </summary>
|
||||
public class StatisticsService : IStatisticsService
|
||||
{
|
||||
@@ -78,6 +84,108 @@ namespace LaDOSE.Business.Service
|
||||
return Task.FromResult(Aggregate(requested, events, tournaments, sets, players));
|
||||
}
|
||||
|
||||
public Task<List<PlayerOption>> GetVersusPlayers()
|
||||
{
|
||||
// Same filter GetVersus applies, so the picker cannot offer a player whose
|
||||
// every meeting would then be dropped as "game unknown". Self-sets are
|
||||
// excluded here too, otherwise they would be counted in both slots.
|
||||
var usable = from s in _context.Set
|
||||
join t in _context.Tournament on s.TournamentId equals t.Id
|
||||
where t.GameId != null && s.Player1Id != 0 && s.Player2Id != 0
|
||||
&& s.Player1Id != s.Player2Id
|
||||
select s;
|
||||
|
||||
// Counted in the database, one group-by per slot: the set table is the
|
||||
// largest one here and there is no reason to pull it into memory.
|
||||
var asPlayer1 = usable
|
||||
.GroupBy(s => s.Player1Id)
|
||||
.Select(g => new { PlayerId = g.Key, Sets = g.Count() })
|
||||
.ToList();
|
||||
|
||||
var asPlayer2 = usable
|
||||
.GroupBy(s => s.Player2Id)
|
||||
.Select(g => new { PlayerId = g.Key, Sets = g.Count() })
|
||||
.ToList();
|
||||
|
||||
var setsByPlayer = new Dictionary<int, int>();
|
||||
foreach (var row in asPlayer1.Concat(asPlayer2))
|
||||
{
|
||||
setsByPlayer.TryGetValue(row.PlayerId, out var running);
|
||||
setsByPlayer[row.PlayerId] = running + row.Sets;
|
||||
}
|
||||
|
||||
if (setsByPlayer.Count == 0)
|
||||
{
|
||||
return Task.FromResult(new List<PlayerOption>());
|
||||
}
|
||||
|
||||
var playerIds = setsByPlayer.Keys.ToList();
|
||||
var nameById = _context.Player
|
||||
.Where(p => playerIds.Contains(p.Id))
|
||||
.ToList()
|
||||
.ToDictionary(p => p.Id, DisplayName);
|
||||
|
||||
// A set can reference a player row that no longer exists; keep it as "#id"
|
||||
// rather than hiding a real opponent from the picker.
|
||||
var options = setsByPlayer
|
||||
.Select(pair => new PlayerOption
|
||||
{
|
||||
Id = pair.Key,
|
||||
Name = ResolveName(pair.Key, nameById),
|
||||
Sets = pair.Value
|
||||
})
|
||||
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(p => p.Id)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(options);
|
||||
}
|
||||
|
||||
public Task<PlayerVersus> GetVersus(int playerAId, int playerBId)
|
||||
{
|
||||
// Nothing to look up, and nothing exceptional either: a caller that has not
|
||||
// picked two distinct players gets an empty breakdown, not a 500.
|
||||
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
|
||||
{
|
||||
return Task.FromResult(new PlayerVersus
|
||||
{
|
||||
PlayerAId = playerAId,
|
||||
PlayerBId = playerBId
|
||||
});
|
||||
}
|
||||
|
||||
// Either seating: the set rows record whoever start.gg listed first.
|
||||
var sets = _context.Set
|
||||
.Where(s => (s.Player1Id == playerAId && s.Player2Id == playerBId)
|
||||
|| (s.Player1Id == playerBId && s.Player2Id == playerAId))
|
||||
.ToList();
|
||||
|
||||
var tournamentIds = sets.Select(s => s.TournamentId).Distinct().ToList();
|
||||
var tournaments = tournamentIds.Count == 0
|
||||
? new List<Tournament>()
|
||||
: _context.Tournament
|
||||
.Where(t => tournamentIds.Contains(t.Id))
|
||||
.ToList();
|
||||
|
||||
var gameIds = tournaments
|
||||
.Where(t => t.GameId.HasValue)
|
||||
.Select(t => t.GameId.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var games = gameIds.Count == 0
|
||||
? new List<Game>()
|
||||
: _context.Game
|
||||
.Where(g => gameIds.Contains(g.Id))
|
||||
.ToList();
|
||||
|
||||
var players = _context.Player
|
||||
.Where(p => p.Id == playerAId || p.Id == playerBId)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(AggregateVersus(playerAId, playerBId, sets, tournaments, games, players));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
|
||||
/// </summary>
|
||||
@@ -224,6 +332,142 @@ namespace LaDOSE.Business.Service
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure aggregation of the meetings between two players, per game. No database,
|
||||
/// no I/O, deterministic.
|
||||
///
|
||||
/// Winners are inferred from the scores exactly as in <see cref="Aggregate"/>: equal
|
||||
/// scores mean undecided. Undecided meetings still count in Sets — they happened —
|
||||
/// but contribute to no win and no game count.
|
||||
/// </summary>
|
||||
/// <param name="playerAId">Left-hand player; WinsA is always their side.</param>
|
||||
/// <param name="playerBId">Right-hand player.</param>
|
||||
/// <param name="sets">Candidate sets; the pairing is re-checked here.</param>
|
||||
/// <param name="tournaments">Tournaments of those sets, for Tournament.GameId.</param>
|
||||
/// <param name="games">Games used to resolve names; may be incomplete.</param>
|
||||
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
|
||||
public static PlayerVersus AggregateVersus(
|
||||
int playerAId,
|
||||
int playerBId,
|
||||
IEnumerable<Set> sets,
|
||||
IEnumerable<Tournament> tournaments,
|
||||
IEnumerable<Game> games,
|
||||
IEnumerable<Player> players)
|
||||
{
|
||||
var result = new PlayerVersus
|
||||
{
|
||||
PlayerAId = playerAId,
|
||||
PlayerBId = playerBId
|
||||
};
|
||||
|
||||
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var nameById = new Dictionary<int, string>();
|
||||
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
|
||||
{
|
||||
nameById[player.Id] = DisplayName(player);
|
||||
}
|
||||
|
||||
result.PlayerA = ResolveName(playerAId, nameById);
|
||||
result.PlayerB = ResolveName(playerBId, nameById);
|
||||
|
||||
var gameIdByTournament = new Dictionary<int, int?>();
|
||||
foreach (var tournament in (tournaments ?? Enumerable.Empty<Tournament>()).Where(t => t != null))
|
||||
{
|
||||
gameIdByTournament[tournament.Id] = tournament.GameId;
|
||||
}
|
||||
|
||||
var gameById = new Dictionary<int, Game>();
|
||||
foreach (var game in (games ?? Enumerable.Empty<Game>()).Where(g => g != null))
|
||||
{
|
||||
gameById[game.Id] = game;
|
||||
}
|
||||
|
||||
var perGame = new Dictionary<int, VersusGameStats>();
|
||||
|
||||
foreach (var set in (sets ?? Enumerable.Empty<Set>()).Where(s => s != null))
|
||||
{
|
||||
// The query already restricts the pairing; checking again keeps this
|
||||
// method correct on its own, which is the point of it being pure.
|
||||
var aIsPlayer1 = set.Player1Id == playerAId && set.Player2Id == playerBId;
|
||||
var bIsPlayer1 = set.Player1Id == playerBId && set.Player2Id == playerAId;
|
||||
if (!aIsPlayer1 && !bIsPlayer1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// No game on the bracket, nothing to file this meeting under. Counted so
|
||||
// the caller can say "3 meetings we cannot attribute" instead of losing them.
|
||||
if (!gameIdByTournament.TryGetValue(set.TournamentId, out var gameId) || !gameId.HasValue)
|
||||
{
|
||||
result.UnknownGameSets++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var row = GetOrAddGame(perGame, gameId.Value, gameById);
|
||||
row.Sets++;
|
||||
|
||||
var scoreA = aIsPlayer1 ? set.Player1Score : set.Player2Score;
|
||||
var scoreB = aIsPlayer1 ? set.Player2Score : set.Player1Score;
|
||||
|
||||
// No winner column: equal scores (including 0-0 and -1 / -1) are undecided.
|
||||
if (scoreA == scoreB)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
row.DecidedSets++;
|
||||
// A DQ is stored as -1. Clamp so it never produces negative games.
|
||||
row.GamesWonA += Math.Max(0, scoreA);
|
||||
row.GamesWonB += Math.Max(0, scoreB);
|
||||
if (scoreA > scoreB)
|
||||
{
|
||||
row.WinsA++;
|
||||
}
|
||||
else
|
||||
{
|
||||
row.WinsB++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Games = perGame.Values
|
||||
.OrderByDescending(g => g.Sets)
|
||||
.ThenByDescending(g => g.DecidedSets)
|
||||
.ThenBy(g => g.Game, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.GameId)
|
||||
.ToList();
|
||||
|
||||
// Totals are derived from the rows, so the header cannot disagree with the table.
|
||||
result.Sets = result.Games.Sum(g => g.Sets);
|
||||
result.DecidedSets = result.Games.Sum(g => g.DecidedSets);
|
||||
result.WinsA = result.Games.Sum(g => g.WinsA);
|
||||
result.WinsB = result.Games.Sum(g => g.WinsB);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static VersusGameStats GetOrAddGame(Dictionary<int, VersusGameStats> perGame, int gameId,
|
||||
Dictionary<int, Game> gameById)
|
||||
{
|
||||
if (!perGame.TryGetValue(gameId, out var row))
|
||||
{
|
||||
gameById.TryGetValue(gameId, out var game);
|
||||
var name = string.IsNullOrWhiteSpace(game?.Name) ? $"#{gameId}" : game.Name;
|
||||
row = new VersusGameStats
|
||||
{
|
||||
GameId = gameId,
|
||||
Game = name,
|
||||
GameLongName = string.IsNullOrWhiteSpace(game?.LongName) ? name : game.LongName
|
||||
};
|
||||
perGame[gameId] = row;
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private static PlayerMatchStats GetOrAdd(Dictionary<int, PlayerMatchStats> stats, int playerId,
|
||||
Dictionary<int, string> nameById)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user