< Summary

Information
Class: Orchestrator.Commands.Operations.CollectContext.FifaRankingSource.Wm26FifaTeam
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/CollectContext/FifaRankingSource.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 479
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.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
 31    private static readonly IReadOnlyList<Wm26FifaTeam> Wm26Teams =
 32    [
 33        new("EGY", "Ägypten", "agypten"),
 34        new("ALG", "Algerien", "algerien"),
 35        new("ARG", "Argentinien", "argentinien"),
 36        new("AUS", "Australien", "australien"),
 37        new("BEL", "Belgien", "belgien"),
 38        new("BIH", "Bosnien-Herzegowina", "bosnien-herzegowina"),
 39        new("BRA", "Brasilien", "brasilien"),
 40        new("CUW", "Curaçao", "curacao"),
 41        new("GER", "Deutschland", "deutschland"),
 42        new("COD", "DR Kongo", "dr-kongo"),
 43        new("ECU", "Ecuador", "ecuador"),
 44        new("CIV", "Elfenbeinküste", "elfenbeinkuste"),
 45        new("ENG", "England", "england"),
 46        new("FRA", "Frankreich", "frankreich"),
 47        new("GHA", "Ghana", "ghana"),
 48        new("HAI", "Haiti", "haiti"),
 49        new("IRQ", "Irak", "irak"),
 50        new("IRN", "Iran", "iran"),
 51        new("JPN", "Japan", "japan"),
 52        new("JOR", "Jordanien", "jordanien"),
 53        new("CAN", "Kanada", "kanada"),
 54        new("CPV", "Kap Verde", "kap-verde"),
 55        new("QAT", "Katar", "katar"),
 56        new("COL", "Kolumbien", "kolumbien"),
 57        new("CRO", "Kroatien", "kroatien"),
 58        new("MAR", "Marokko", "marokko"),
 59        new("MEX", "Mexiko", "mexiko"),
 60        new("NZL", "Neuseeland", "neuseeland"),
 61        new("NED", "Niederlande", "niederlande"),
 62        new("NOR", "Norwegen", "norwegen"),
 63        new("AUT", "Österreich", "osterreich"),
 64        new("PAN", "Panama", "panama"),
 65        new("PAR", "Paraguay", "paraguay"),
 66        new("POR", "Portugal", "portugal"),
 67        new("KSA", "Saudi-Arabien", "saudi-arabien"),
 68        new("SCO", "Schottland", "schottland"),
 69        new("SWE", "Schweden", "schweden"),
 70        new("SUI", "Schweiz", "schweiz"),
 71        new("SEN", "Senegal", "senegal"),
 72        new("ESP", "Spanien", "spanien"),
 73        new("RSA", "Südafrika", "sudafrika"),
 74        new("KOR", "Südkorea", "sudkorea"),
 75        new("CZE", "Tschechien", "tschechien"),
 76        new("TUN", "Tunesien", "tunesien"),
 77        new("TUR", "Türkei", "turkei"),
 78        new("URU", "Uruguay", "uruguay"),
 79        new("USA", "USA", "usa"),
 80        new("UZB", "Usbekistan", "usbekistan")
 81    ];
 82
 83    private readonly IFifaRankingApiClient _apiClient;
 84
 85    public FifaRankingSource(IFifaRankingApiClient apiClient)
 86    {
 87        _apiClient = apiClient;
 88    }
 89
 90    public async Task<FifaRankingCollection> CollectLatestAsync(
 91        DateOnly collectionDate,
 92        CancellationToken cancellationToken = default)
 93    {
 94        var schedules = await _apiClient.GetRankingSchedulesAsync(cancellationToken);
 95        var latestSchedule = SelectLatestApprovedSchedule(schedules);
 96        var rows = await _apiClient.GetRankingRowsAsync(latestSchedule.Id, cancellationToken);
 97
 98        if (rows.Count < MinimumExpectedRankingRows)
 99        {
 100            throw new InvalidOperationException(
 101                $"FIFA ranking response returned {rows.Count} rows; expected at least {MinimumExpectedRankingRows}.");
 102        }
 103
 104        var rowsByCountry = BuildRankingRowLookup(rows);
 105        var rankingEntries = BuildRankingEntries(rowsByCountry, latestSchedule.PublicationDateUtc);
 106        var contextDocuments = rankingEntries
 107            .OrderBy(entry => entry.Team.Slug, StringComparer.OrdinalIgnoreCase)
 108            .Select(entry => new FifaRankingDocument(
 109                $"fifa-ranking-{entry.Team.Slug}.csv",
 110                WriteRankingCsv([entry]),
 111                entry.Team.DisplayName,
 112                entry.Rank,
 113                entry.Points))
 114            .ToList();
 115
 116        var kpiContent = WriteRankingCsv(
 117            rankingEntries
 118                .OrderBy(entry => entry.Rank)
 119                .ThenBy(entry => entry.Team.DisplayName, StringComparer.Ordinal));
 120
 121        return new FifaRankingCollection(
 122            latestSchedule.Id,
 123            latestSchedule.PublicationDateUtc,
 124            collectionDate,
 125            rows.Count,
 126            contextDocuments,
 127            kpiContent);
 128    }
 129
 130    private static SelectedFifaRankingSchedule SelectLatestApprovedSchedule(
 131        IReadOnlyList<FifaRankingScheduleDto> schedules)
 132    {
 133        var candidates = schedules
 134            .Where(schedule => schedule.RankingApproved == true)
 135            .Select(schedule => new
 136            {
 137                Schedule = schedule,
 138                ParsedPublicationDate = TryParsePublicationDate(schedule.PublicationDateUTC)
 139            })
 140            .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Schedule.IdRankingSchedule)
 141                                && candidate.ParsedPublicationDate.HasValue)
 142            .OrderByDescending(candidate => candidate.ParsedPublicationDate!.Value)
 143            .ToList();
 144
 145        if (candidates.Count == 0)
 146        {
 147            throw new InvalidOperationException(
 148                "No approved FIFA ranking schedule with a publication date was found.");
 149        }
 150
 151        var latest = candidates[0];
 152        return new SelectedFifaRankingSchedule(
 153            latest.Schedule.IdRankingSchedule!.Trim(),
 154            latest.ParsedPublicationDate!.Value);
 155    }
 156
 157    private static DateTimeOffset? TryParsePublicationDate(string? value)
 158    {
 159        return DateTimeOffset.TryParse(
 160            value,
 161            CultureInfo.InvariantCulture,
 162            DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
 163            out var parsed)
 164            ? parsed
 165            : null;
 166    }
 167
 168    private static IReadOnlyDictionary<string, FifaRankingRowDto> BuildRankingRowLookup(
 169        IReadOnlyList<FifaRankingRowDto> rows)
 170    {
 171        var duplicateCountryCodes = rows
 172            .Where(row => !string.IsNullOrWhiteSpace(row.IdCountry))
 173            .GroupBy(row => row.IdCountry!.Trim(), StringComparer.OrdinalIgnoreCase)
 174            .Where(group => group.Count() > 1)
 175            .Select(group => group.Key)
 176            .OrderBy(code => code, StringComparer.OrdinalIgnoreCase)
 177            .ToList();
 178
 179        if (duplicateCountryCodes.Count > 0)
 180        {
 181            throw new InvalidOperationException(
 182                $"FIFA ranking response contains duplicate country codes: {string.Join(", ", duplicateCountryCodes)}.");
 183        }
 184
 185        return rows
 186            .Where(row => !string.IsNullOrWhiteSpace(row.IdCountry))
 187            .ToDictionary(row => row.IdCountry!.Trim(), StringComparer.OrdinalIgnoreCase);
 188    }
 189
 190    private static IReadOnlyList<FifaRankingEntry> BuildRankingEntries(
 191        IReadOnlyDictionary<string, FifaRankingRowDto> rowsByCountry,
 192        DateTimeOffset publicationDateUtc)
 193    {
 194        var entries = new List<FifaRankingEntry>();
 195        var missingTeamCodes = new List<string>();
 196        var invalidRows = new List<string>();
 197
 198        foreach (var team in Wm26Teams)
 199        {
 200            if (!rowsByCountry.TryGetValue(team.IdCountry, out var row))
 201            {
 202                missingTeamCodes.Add($"{team.IdCountry} ({team.DisplayName})");
 203                continue;
 204            }
 205
 206            if (row.Rank is null or <= 0 || row.TotalPoints is null)
 207            {
 208                invalidRows.Add($"{team.IdCountry} ({team.DisplayName})");
 209                continue;
 210            }
 211
 212            entries.Add(new FifaRankingEntry(
 213                team,
 214                row.Rank.Value,
 215                decimal.Round(row.TotalPoints.Value, 2, MidpointRounding.AwayFromZero),
 216                publicationDateUtc));
 217        }
 218
 219        if (missingTeamCodes.Count > 0)
 220        {
 221            throw new InvalidOperationException(
 222                $"FIFA ranking response is missing WM26 teams: {string.Join(", ", missingTeamCodes)}.");
 223        }
 224
 225        if (invalidRows.Count > 0)
 226        {
 227            throw new InvalidOperationException(
 228                $"FIFA ranking response has invalid rank or points for WM26 teams: {string.Join(", ", invalidRows)}.");
 229        }
 230
 231        return entries;
 232    }
 233
 234    private static string WriteRankingCsv(IEnumerable<FifaRankingEntry> entries)
 235    {
 236        var builder = new StringBuilder();
 237        builder.AppendLine($"Rank,Team,ELO,{FifaRankingCsvUtility.PublishedAtColumnName}");
 238
 239        foreach (var entry in entries)
 240        {
 241            AppendCsvField(builder, entry.Rank.ToString(CultureInfo.InvariantCulture));
 242            builder.Append(',');
 243            AppendCsvField(builder, entry.Team.DisplayName);
 244            builder.Append(',');
 245            AppendCsvField(builder, entry.Points.ToString("0.00", CultureInfo.InvariantCulture));
 246            builder.Append(',');
 247            AppendCsvField(builder, entry.PublishedAt.ToString("O", CultureInfo.InvariantCulture));
 248            builder.AppendLine();
 249        }
 250
 251        return builder.ToString();
 252    }
 253
 254    private static void AppendCsvField(StringBuilder builder, string value)
 255    {
 256        if (value.IndexOfAny([',', '"', '\r', '\n']) < 0)
 257        {
 258            builder.Append(value);
 259            return;
 260        }
 261
 262        builder.Append('"');
 263        builder.Append(value.Replace("\"", "\"\"", StringComparison.Ordinal));
 264        builder.Append('"');
 265    }
 266
 1267    private sealed record Wm26FifaTeam(string IdCountry, string DisplayName, string Slug);
 268
 269    private sealed record SelectedFifaRankingSchedule(string Id, DateTimeOffset PublicationDateUtc);
 270
 271    private sealed record FifaRankingEntry(
 272        Wm26FifaTeam Team,
 273        int Rank,
 274        decimal Points,
 275        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}

Methods/Properties

.ctor(string, string, string)