< Summary

Information
Class: Orchestrator.Commands.Operations.CollectContext.FifaRankingSource
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/CollectContext/FifaRankingSource.cs
Line coverage
91%
Covered lines: 155
Uncovered lines: 14
Coverable lines: 169
Total lines: 479
Line coverage: 91.7%
Branch coverage
83%
Covered branches: 50
Total branches: 60
Branch coverage: 83.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
CollectLatestAsync()90%101090%
SelectLatestApprovedSchedule(...)100%1010100%
TryParsePublicationDate(...)50%22100%
BuildRankingRowLookup(...)93.75%251666.67%
BuildRankingEntries(...)68.75%181681.82%
WriteRankingCsv(...)100%22100%
AppendCsvField(...)50%3242.86%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/CollectContext/FifaRankingSource.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Net.Http.Json;
 3using System.Text;
 4using System.Text.Json;
 5using CsvHelper;
 6using EHonda.KicktippAi.Core;
 7using Microsoft.Extensions.Logging;
 8
 9namespace Orchestrator.Commands.Operations.CollectContext;
 10
 11public interface IFifaRankingSource
 12{
 13    Task<FifaRankingCollection> CollectLatestAsync(
 14        DateOnly collectionDate,
 15        CancellationToken cancellationToken = default);
 16}
 17
 18internal interface IFifaRankingApiClient
 19{
 20    Task<IReadOnlyList<FifaRankingScheduleDto>> GetRankingSchedulesAsync(CancellationToken cancellationToken = default);
 21
 22    Task<IReadOnlyList<FifaRankingRowDto>> GetRankingRowsAsync(
 23        string rankingScheduleId,
 24        CancellationToken cancellationToken = default);
 25}
 26
 27internal sealed class FifaRankingSource : IFifaRankingSource
 28{
 29    private const int MinimumExpectedRankingRows = 200;
 30
 131    private static readonly IReadOnlyList<Wm26FifaTeam> Wm26Teams =
 132    [
 133        new("EGY", "Ägypten", "agypten"),
 134        new("ALG", "Algerien", "algerien"),
 135        new("ARG", "Argentinien", "argentinien"),
 136        new("AUS", "Australien", "australien"),
 137        new("BEL", "Belgien", "belgien"),
 138        new("BIH", "Bosnien-Herzegowina", "bosnien-herzegowina"),
 139        new("BRA", "Brasilien", "brasilien"),
 140        new("CUW", "Curaçao", "curacao"),
 141        new("GER", "Deutschland", "deutschland"),
 142        new("COD", "DR Kongo", "dr-kongo"),
 143        new("ECU", "Ecuador", "ecuador"),
 144        new("CIV", "Elfenbeinküste", "elfenbeinkuste"),
 145        new("ENG", "England", "england"),
 146        new("FRA", "Frankreich", "frankreich"),
 147        new("GHA", "Ghana", "ghana"),
 148        new("HAI", "Haiti", "haiti"),
 149        new("IRQ", "Irak", "irak"),
 150        new("IRN", "Iran", "iran"),
 151        new("JPN", "Japan", "japan"),
 152        new("JOR", "Jordanien", "jordanien"),
 153        new("CAN", "Kanada", "kanada"),
 154        new("CPV", "Kap Verde", "kap-verde"),
 155        new("QAT", "Katar", "katar"),
 156        new("COL", "Kolumbien", "kolumbien"),
 157        new("CRO", "Kroatien", "kroatien"),
 158        new("MAR", "Marokko", "marokko"),
 159        new("MEX", "Mexiko", "mexiko"),
 160        new("NZL", "Neuseeland", "neuseeland"),
 161        new("NED", "Niederlande", "niederlande"),
 162        new("NOR", "Norwegen", "norwegen"),
 163        new("AUT", "Österreich", "osterreich"),
 164        new("PAN", "Panama", "panama"),
 165        new("PAR", "Paraguay", "paraguay"),
 166        new("POR", "Portugal", "portugal"),
 167        new("KSA", "Saudi-Arabien", "saudi-arabien"),
 168        new("SCO", "Schottland", "schottland"),
 169        new("SWE", "Schweden", "schweden"),
 170        new("SUI", "Schweiz", "schweiz"),
 171        new("SEN", "Senegal", "senegal"),
 172        new("ESP", "Spanien", "spanien"),
 173        new("RSA", "Südafrika", "sudafrika"),
 174        new("KOR", "Südkorea", "sudkorea"),
 175        new("CZE", "Tschechien", "tschechien"),
 176        new("TUN", "Tunesien", "tunesien"),
 177        new("TUR", "Türkei", "turkei"),
 178        new("URU", "Uruguay", "uruguay"),
 179        new("USA", "USA", "usa"),
 180        new("UZB", "Usbekistan", "usbekistan")
 181    ];
 82
 83    private readonly IFifaRankingApiClient _apiClient;
 84
 185    public FifaRankingSource(IFifaRankingApiClient apiClient)
 86    {
 187        _apiClient = apiClient;
 188    }
 89
 90    public async Task<FifaRankingCollection> CollectLatestAsync(
 91        DateOnly collectionDate,
 92        CancellationToken cancellationToken = default)
 93    {
 194        var schedules = await _apiClient.GetRankingSchedulesAsync(cancellationToken);
 195        var latestSchedule = SelectLatestApprovedSchedule(schedules);
 196        var rows = await _apiClient.GetRankingRowsAsync(latestSchedule.Id, cancellationToken);
 97
 198        if (rows.Count < MinimumExpectedRankingRows)
 99        {
 0100            throw new InvalidOperationException(
 0101                $"FIFA ranking response returned {rows.Count} rows; expected at least {MinimumExpectedRankingRows}.");
 102        }
 103
 1104        var rowsByCountry = BuildRankingRowLookup(rows);
 1105        var rankingEntries = BuildRankingEntries(rowsByCountry, latestSchedule.PublicationDateUtc);
 1106        var contextDocuments = rankingEntries
 1107            .OrderBy(entry => entry.Team.Slug, StringComparer.OrdinalIgnoreCase)
 1108            .Select(entry => new FifaRankingDocument(
 1109                $"fifa-ranking-{entry.Team.Slug}.csv",
 1110                WriteRankingCsv([entry]),
 1111                entry.Team.DisplayName,
 1112                entry.Rank,
 1113                entry.Points))
 1114            .ToList();
 115
 1116        var kpiContent = WriteRankingCsv(
 1117            rankingEntries
 1118                .OrderBy(entry => entry.Rank)
 1119                .ThenBy(entry => entry.Team.DisplayName, StringComparer.Ordinal));
 120
 1121        return new FifaRankingCollection(
 1122            latestSchedule.Id,
 1123            latestSchedule.PublicationDateUtc,
 1124            collectionDate,
 1125            rows.Count,
 1126            contextDocuments,
 1127            kpiContent);
 1128    }
 129
 130    private static SelectedFifaRankingSchedule SelectLatestApprovedSchedule(
 131        IReadOnlyList<FifaRankingScheduleDto> schedules)
 132    {
 1133        var candidates = schedules
 1134            .Where(schedule => schedule.RankingApproved == true)
 1135            .Select(schedule => new
 1136            {
 1137                Schedule = schedule,
 1138                ParsedPublicationDate = TryParsePublicationDate(schedule.PublicationDateUTC)
 1139            })
 1140            .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Schedule.IdRankingSchedule)
 1141                                && candidate.ParsedPublicationDate.HasValue)
 1142            .OrderByDescending(candidate => candidate.ParsedPublicationDate!.Value)
 1143            .ToList();
 144
 1145        if (candidates.Count == 0)
 146        {
 1147            throw new InvalidOperationException(
 1148                "No approved FIFA ranking schedule with a publication date was found.");
 149        }
 150
 1151        var latest = candidates[0];
 1152        return new SelectedFifaRankingSchedule(
 1153            latest.Schedule.IdRankingSchedule!.Trim(),
 1154            latest.ParsedPublicationDate!.Value);
 155    }
 156
 157    private static DateTimeOffset? TryParsePublicationDate(string? value)
 158    {
 1159        return DateTimeOffset.TryParse(
 1160            value,
 1161            CultureInfo.InvariantCulture,
 1162            DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
 1163            out var parsed)
 1164            ? parsed
 1165            : null;
 166    }
 167
 168    private static IReadOnlyDictionary<string, FifaRankingRowDto> BuildRankingRowLookup(
 169        IReadOnlyList<FifaRankingRowDto> rows)
 170    {
 1171        var duplicateCountryCodes = rows
 1172            .Where(row => !string.IsNullOrWhiteSpace(row.IdCountry))
 1173            .GroupBy(row => row.IdCountry!.Trim(), StringComparer.OrdinalIgnoreCase)
 1174            .Where(group => group.Count() > 1)
 0175            .Select(group => group.Key)
 0176            .OrderBy(code => code, StringComparer.OrdinalIgnoreCase)
 1177            .ToList();
 178
 1179        if (duplicateCountryCodes.Count > 0)
 180        {
 0181            throw new InvalidOperationException(
 0182                $"FIFA ranking response contains duplicate country codes: {string.Join(", ", duplicateCountryCodes)}.");
 183        }
 184
 1185        return rows
 1186            .Where(row => !string.IsNullOrWhiteSpace(row.IdCountry))
 1187            .ToDictionary(row => row.IdCountry!.Trim(), StringComparer.OrdinalIgnoreCase);
 188    }
 189
 190    private static IReadOnlyList<FifaRankingEntry> BuildRankingEntries(
 191        IReadOnlyDictionary<string, FifaRankingRowDto> rowsByCountry,
 192        DateTimeOffset publicationDateUtc)
 193    {
 1194        var entries = new List<FifaRankingEntry>();
 1195        var missingTeamCodes = new List<string>();
 1196        var invalidRows = new List<string>();
 197
 1198        foreach (var team in Wm26Teams)
 199        {
 1200            if (!rowsByCountry.TryGetValue(team.IdCountry, out var row))
 201            {
 1202                missingTeamCodes.Add($"{team.IdCountry} ({team.DisplayName})");
 1203                continue;
 204            }
 205
 1206            if (row.Rank is null or <= 0 || row.TotalPoints is null)
 207            {
 0208                invalidRows.Add($"{team.IdCountry} ({team.DisplayName})");
 0209                continue;
 210            }
 211
 1212            entries.Add(new FifaRankingEntry(
 1213                team,
 1214                row.Rank.Value,
 1215                decimal.Round(row.TotalPoints.Value, 2, MidpointRounding.AwayFromZero),
 1216                publicationDateUtc));
 217        }
 218
 1219        if (missingTeamCodes.Count > 0)
 220        {
 1221            throw new InvalidOperationException(
 1222                $"FIFA ranking response is missing WM26 teams: {string.Join(", ", missingTeamCodes)}.");
 223        }
 224
 1225        if (invalidRows.Count > 0)
 226        {
 0227            throw new InvalidOperationException(
 0228                $"FIFA ranking response has invalid rank or points for WM26 teams: {string.Join(", ", invalidRows)}.");
 229        }
 230
 1231        return entries;
 232    }
 233
 234    private static string WriteRankingCsv(IEnumerable<FifaRankingEntry> entries)
 235    {
 1236        var builder = new StringBuilder();
 1237        builder.AppendLine($"Rank,Team,ELO,{FifaRankingCsvUtility.PublishedAtColumnName}");
 238
 1239        foreach (var entry in entries)
 240        {
 1241            AppendCsvField(builder, entry.Rank.ToString(CultureInfo.InvariantCulture));
 1242            builder.Append(',');
 1243            AppendCsvField(builder, entry.Team.DisplayName);
 1244            builder.Append(',');
 1245            AppendCsvField(builder, entry.Points.ToString("0.00", CultureInfo.InvariantCulture));
 1246            builder.Append(',');
 1247            AppendCsvField(builder, entry.PublishedAt.ToString("O", CultureInfo.InvariantCulture));
 1248            builder.AppendLine();
 249        }
 250
 1251        return builder.ToString();
 252    }
 253
 254    private static void AppendCsvField(StringBuilder builder, string value)
 255    {
 1256        if (value.IndexOfAny([',', '"', '\r', '\n']) < 0)
 257        {
 1258            builder.Append(value);
 1259            return;
 260        }
 261
 0262        builder.Append('"');
 0263        builder.Append(value.Replace("\"", "\"\"", StringComparison.Ordinal));
 0264        builder.Append('"');
 0265    }
 266
 1267    private sealed record Wm26FifaTeam(string IdCountry, string DisplayName, string Slug);
 268
 1269    private sealed record SelectedFifaRankingSchedule(string Id, DateTimeOffset PublicationDateUtc);
 270
 1271    private sealed record FifaRankingEntry(
 1272        Wm26FifaTeam Team,
 1273        int Rank,
 1274        decimal Points,
 1275        DateTimeOffset PublishedAt);
 276}
 277
 278internal static class FifaRankingCsvUtility
 279{
 280    internal const string PublishedAtColumnName = "Published_At";
 281
 282    internal static string PreserveExistingContentWhenRankingUnchanged(string newContent, string? existingContent)
 283    {
 284        if (string.IsNullOrWhiteSpace(existingContent))
 285        {
 286            return newContent;
 287        }
 288
 289        if (!TryParseRows(newContent, requirePublishedAtHeader: true, out var newRows) ||
 290            !TryParseRows(existingContent, requirePublishedAtHeader: true, out var existingRows))
 291        {
 292            return newContent;
 293        }
 294
 295        if (newRows.Count != existingRows.Count)
 296        {
 297            return newContent;
 298        }
 299
 300        var existingByTeam = existingRows.ToDictionary(row => row.Team, StringComparer.Ordinal);
 301        foreach (var newRow in newRows)
 302        {
 303            if (!existingByTeam.TryGetValue(newRow.Team, out var existingRow) ||
 304                existingRow.Rank != newRow.Rank ||
 305                existingRow.Elo != newRow.Elo)
 306            {
 307                return newContent;
 308            }
 309        }
 310
 311        return existingContent;
 312    }
 313
 314    private static bool TryParseRows(
 315        string content,
 316        bool requirePublishedAtHeader,
 317        out IReadOnlyList<FifaRankingCsvRow> rows)
 318    {
 319        rows = [];
 320
 321        if (string.IsNullOrWhiteSpace(content))
 322        {
 323            return false;
 324        }
 325
 326        try
 327        {
 328            using var reader = new StringReader(content);
 329            using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
 330
 331            if (!csv.Read())
 332            {
 333                return false;
 334            }
 335
 336            csv.ReadHeader();
 337            var header = csv.HeaderRecord ?? [];
 338
 339            if (!header.Contains("Rank", StringComparer.Ordinal) ||
 340                !header.Contains("Team", StringComparer.Ordinal) ||
 341                !header.Contains("ELO", StringComparer.Ordinal) ||
 342                (requirePublishedAtHeader && !header.Contains(PublishedAtColumnName, StringComparer.Ordinal)))
 343            {
 344                return false;
 345            }
 346
 347            var parsedRows = new List<FifaRankingCsvRow>();
 348            while (csv.Read())
 349            {
 350                var team = csv.GetField("Team");
 351                var rankText = csv.GetField("Rank");
 352                var eloText = csv.GetField("ELO");
 353
 354                if (string.IsNullOrWhiteSpace(team) &&
 355                    string.IsNullOrWhiteSpace(rankText) &&
 356                    string.IsNullOrWhiteSpace(eloText))
 357                {
 358                    continue;
 359                }
 360
 361                if (!int.TryParse(rankText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rank) ||
 362                    !decimal.TryParse(eloText, NumberStyles.Number, CultureInfo.InvariantCulture, out var elo))
 363                {
 364                    return false;
 365                }
 366
 367                parsedRows.Add(new FifaRankingCsvRow(team ?? string.Empty, rank, elo));
 368            }
 369
 370            rows = parsedRows;
 371            return true;
 372        }
 373        catch
 374        {
 375            return false;
 376        }
 377    }
 378
 379    private sealed record FifaRankingCsvRow(string Team, int Rank, decimal Elo);
 380}
 381
 382internal sealed class FifaRankingApiClient : IFifaRankingApiClient
 383{
 384    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
 385    {
 386        PropertyNameCaseInsensitive = true
 387    };
 388
 389    private readonly HttpClient _httpClient;
 390    private readonly ILogger<FifaRankingApiClient> _logger;
 391
 392    public FifaRankingApiClient(HttpClient httpClient, ILogger<FifaRankingApiClient> logger)
 393    {
 394        _httpClient = httpClient;
 395        _logger = logger;
 396    }
 397
 398    public async Task<IReadOnlyList<FifaRankingScheduleDto>> GetRankingSchedulesAsync(
 399        CancellationToken cancellationToken = default)
 400    {
 401        return await GetResultsAsync<FifaRankingScheduleDto>(
 402            "fifarankings/rankingschedules/all?type=0&gender=1&sportType=0&language=de",
 403            cancellationToken);
 404    }
 405
 406    public async Task<IReadOnlyList<FifaRankingRowDto>> GetRankingRowsAsync(
 407        string rankingScheduleId,
 408        CancellationToken cancellationToken = default)
 409    {
 410        ArgumentException.ThrowIfNullOrWhiteSpace(rankingScheduleId);
 411
 412        var path =
 413            $"fifarankings/rankings/rankingsbyschedule?rankingScheduleId={Uri.EscapeDataString(rankingScheduleId)}&count
 414        return await GetResultsAsync<FifaRankingRowDto>(path, cancellationToken);
 415    }
 416
 417    private async Task<IReadOnlyList<T>> GetResultsAsync<T>(
 418        string relativePath,
 419        CancellationToken cancellationToken)
 420    {
 421        using var response = await _httpClient.GetAsync(relativePath, cancellationToken);
 422        if (!response.IsSuccessStatusCode)
 423        {
 424            var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
 425            _logger.LogWarning(
 426                "FIFA ranking API request failed with {StatusCode}: {ResponseBody}",
 427                response.StatusCode,
 428                responseBody);
 429            response.EnsureSuccessStatusCode();
 430        }
 431
 432        var payload = await response.Content.ReadFromJsonAsync<FifaApiResponse<T>>(
 433            SerializerOptions,
 434            cancellationToken);
 435
 436        return payload?.Results ?? [];
 437    }
 438}
 439
 440public sealed record FifaRankingCollection(
 441    string ScheduleId,
 442    DateTimeOffset PublicationDateUtc,
 443    DateOnly CollectionDate,
 444    int SourceRowCount,
 445    IReadOnlyList<FifaRankingDocument> ContextDocuments,
 446    string KpiContent)
 447{
 448    public int MappedTeamCount => ContextDocuments.Count;
 449}
 450
 451public sealed record FifaRankingDocument(
 452    string DocumentName,
 453    string Content,
 454    string TeamName,
 455    int Rank,
 456    decimal Points);
 457
 458internal sealed record FifaApiResponse<T>
 459{
 460    public List<T>? Results { get; init; }
 461}
 462
 463internal sealed record FifaRankingScheduleDto
 464{
 465    public string? IdRankingSchedule { get; init; }
 466
 467    public bool? RankingApproved { get; init; }
 468
 469    public string? PublicationDateUTC { get; init; }
 470}
 471
 472internal sealed record FifaRankingRowDto
 473{
 474    public string? IdCountry { get; init; }
 475
 476    public int? Rank { get; init; }
 477
 478    public decimal? TotalPoints { get; init; }
 479}