< Summary

Information
Class: Orchestrator.Commands.Operations.CollectContext.CollectContextLineupsCommand.LineupCsvRow
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/CollectContext/CollectContextLineupsCommand.cs
Line coverage
100%
Covered lines: 8
Uncovered lines: 0
Coverable lines: 8
Total lines: 488
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/CollectContextLineupsCommand.cs

#LineLine coverage
 1using System.Globalization;
 2using CsvHelper;
 3using CsvHelper.Configuration;
 4using EHonda.KicktippAi.Core;
 5using Microsoft.Extensions.Logging;
 6using Orchestrator.Infrastructure;
 7using Orchestrator.Infrastructure.Factories;
 8using Spectre.Console;
 9using Spectre.Console.Cli;
 10
 11namespace Orchestrator.Commands.Operations.CollectContext;
 12
 13/// <summary>
 14/// Command for uploading WM26 lineup context and KPI documents.
 15/// </summary>
 16public sealed class CollectContextLineupsCommand : AsyncCommand<CollectContextLineupsSettings>
 17{
 18    private const string LineupsDocumentName = "lineups";
 19    private const string LineupsDescription =
 20        "WM26 lineups for all participants, used for the top scorer team bonus question.";
 21    private static readonly IReadOnlyList<string> LineupColumns =
 22    [
 23        "Team",
 24        "Data_Collected_At",
 25        "Role",
 26        "Name",
 27        "Age",
 28        "Position",
 29        "Market_Value_EUR"
 30    ];
 31
 32    private readonly IAnsiConsole _console;
 33    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 34    private readonly IWm26LineupSource _lineupSource;
 35    private readonly TimeProvider _timeProvider;
 36    private readonly ILogger<CollectContextLineupsCommand> _logger;
 37
 38    public CollectContextLineupsCommand(
 39        IAnsiConsole console,
 40        IFirebaseServiceFactory firebaseServiceFactory,
 41        IWm26LineupSource lineupSource,
 42        TimeProvider timeProvider,
 43        ILogger<CollectContextLineupsCommand> logger)
 44    {
 45        _console = console;
 46        _firebaseServiceFactory = firebaseServiceFactory;
 47        _lineupSource = lineupSource;
 48        _timeProvider = timeProvider;
 49        _logger = logger;
 50    }
 51
 52    protected override async Task<int> ExecuteAsync(
 53        CommandContext context,
 54        CollectContextLineupsSettings settings,
 55        CancellationToken cancellationToken)
 56    {
 57        return await ExecuteWithSettingsAsync(settings, cancellationToken);
 58    }
 59
 60    internal async Task<int> ExecuteWithSettingsAsync(
 61        CollectContextLineupsSettings settings,
 62        CancellationToken cancellationToken = default)
 63    {
 64        try
 65        {
 66            if (string.IsNullOrWhiteSpace(settings.CommunityContext))
 67            {
 68                _console.MarkupLine("[red]Error: Community context is required[/]");
 69                return 1;
 70            }
 71
 72            var communityContext = settings.CommunityContext.Trim();
 73            var competition = CompetitionResolver.ResolveCompetition(settings.Competition, communityContext, communityCo
 74            var repositoryCompetition = CompetitionResolver.ToRepositoryCompetitionArgument(competition);
 75
 76            _console.MarkupLine("[green]Collect-context lineups command initialized[/]");
 77            _console.MarkupLine($"[blue]Using community context:[/] [yellow]{Markup.Escape(communityContext)}[/]");
 78            _console.MarkupLine($"[blue]Using competition:[/] [yellow]{Markup.Escape(competition)}[/]");
 79            _console.MarkupLine($"[blue]Using lineup seed:[/] [yellow]{Markup.Escape(settings.Seed)}[/]");
 80            _console.MarkupLine($"[blue]Using team manifest:[/] [yellow]{Markup.Escape(settings.Teams)}[/]");
 81
 82            if (settings.Verbose)
 83            {
 84                _console.MarkupLine("[dim]Verbose mode enabled[/]");
 85            }
 86
 87            if (settings.DryRun)
 88            {
 89                _console.MarkupLine("[magenta]Dry run mode enabled - no changes will be made to database[/]");
 90            }
 91
 92            var source = await _lineupSource.CollectAsync(
 93                new Wm26LineupSourceRequest(settings.Seed, settings.Teams, settings.DuckDbPath),
 94                cancellationToken);
 95
 96            _console.MarkupLine($"[blue]Resolved lineup seed:[/] [yellow]{Markup.Escape(source.SeedPath)}[/]");
 97            _console.MarkupLine($"[blue]Resolved team manifest:[/] [yellow]{Markup.Escape(source.TeamsPath)}[/]");
 98            _console.MarkupLine($"[blue]Using Transfermarkt DuckDB:[/] [yellow]{Markup.Escape(source.DuckDbPath)}[/]");
 99            _console.MarkupLine($"[blue]Seed rows:[/] [yellow]{source.SeedRowCount}[/]");
 100            _console.MarkupLine($"[blue]Generated lineup context documents:[/] [yellow]{source.ContextDocuments.Count}[/
 101            PrintHeaderOnlyReport(source);
 102            PrintMissingSourceDataReport(source);
 103
 104            var contextRepository = _firebaseServiceFactory.CreateContextRepository(repositoryCompetition);
 105            var collectionDate = DateOnly.FromDateTime(_timeProvider.GetUtcNow().UtcDateTime);
 106            var freshenedSource = await ApplyFreshnessDatesAsync(
 107                source,
 108                contextRepository,
 109                communityContext,
 110                collectionDate,
 111                cancellationToken);
 112
 113            if (settings.DryRun)
 114            {
 115                foreach (var document in freshenedSource.ContextDocuments)
 116                {
 117                    _console.MarkupLine($"[magenta]  Dry run - would save context document:[/] {Markup.Escape(document.D
 118                }
 119
 120                _console.MarkupLine($"[magenta]  Dry run - would save KPI document:[/] {LineupsDocumentName}");
 121                _console.MarkupLine($"[magenta]✓ Dry run completed - would have processed {freshenedSource.ContextDocume
 122                return 0;
 123            }
 124
 125            var kpiRepository = _firebaseServiceFactory.CreateKpiRepository(repositoryCompetition);
 126
 127            var savedContextCount = 0;
 128            var skippedContextCount = 0;
 129            foreach (var document in freshenedSource.ContextDocuments)
 130            {
 131                var savedVersion = await contextRepository.SaveContextDocumentAsync(
 132                    document.DocumentName,
 133                    document.Content,
 134                    communityContext,
 135                    cancellationToken);
 136
 137                if (savedVersion.HasValue)
 138                {
 139                    savedContextCount++;
 140                    if (settings.Verbose)
 141                    {
 142                        _console.MarkupLine($"[green]  ✓ Saved {Markup.Escape(document.DocumentName)} as version {savedV
 143                    }
 144                }
 145                else
 146                {
 147                    skippedContextCount++;
 148                    if (settings.Verbose)
 149                    {
 150                        _console.MarkupLine($"[dim]  - Skipped {Markup.Escape(document.DocumentName)} (content unchanged
 151                    }
 152                }
 153            }
 154
 155            var existingKpiDocument = await kpiRepository.GetKpiDocumentAsync(
 156                LineupsDocumentName,
 157                communityContext,
 158                cancellationToken);
 159            var savedKpiVersion = await kpiRepository.SaveKpiDocumentAsync(
 160                LineupsDocumentName,
 161                freshenedSource.KpiContent,
 162                LineupsDescription,
 163                communityContext,
 164                cancellationToken);
 165            var kpiChanged = existingKpiDocument is null
 166                             || !string.Equals(existingKpiDocument.Content, freshenedSource.KpiContent, StringComparison
 167
 168            _console.MarkupLine("[green]✓ WM26 lineup context collection completed![/]");
 169            _console.MarkupLine($"[green]  Saved: {savedContextCount} context documents[/]");
 170            _console.MarkupLine($"[dim]  Skipped: {skippedContextCount} context documents (unchanged)[/]");
 171            _console.MarkupLine(kpiChanged
 172                ? $"[green]  KPI document {LineupsDocumentName} saved as version {savedKpiVersion}[/]"
 173                : $"[dim]  KPI document {LineupsDocumentName} unchanged at version {savedKpiVersion}[/]");
 174
 175            return 0;
 176        }
 177        catch (Exception ex)
 178        {
 179            _logger.LogError(ex, "Error executing collect-context lineups command");
 180            _console.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
 181            return 1;
 182        }
 183    }
 184
 185    private static async Task<FreshenedLineupCollection> ApplyFreshnessDatesAsync(
 186        Wm26LineupCollection source,
 187        IContextRepository contextRepository,
 188        string communityContext,
 189        DateOnly collectionDate,
 190        CancellationToken cancellationToken)
 191    {
 192        var documents = new List<Wm26LineupDocument>();
 193        var aggregateRows = new List<LineupCsvRow>();
 194        var collectionDateText = collectionDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
 195
 196        foreach (var document in source.ContextDocuments)
 197        {
 198            var currentRows = ReadLineupRows(document.DocumentName, document.Content, "Generated lineup context document
 199            var existingDocument = await contextRepository.GetLatestContextDocumentAsync(
 200                document.DocumentName,
 201                communityContext,
 202                cancellationToken);
 203            var adjustedRows = existingDocument is null
 204                ? currentRows
 205                : ApplyExistingFreshnessDates(
 206                    currentRows,
 207                    ReadLineupRows(document.DocumentName, existingDocument.Content, "Existing lineup context document"),
 208                    collectionDateText);
 209
 210            aggregateRows.AddRange(adjustedRows);
 211            documents.Add(document with { Content = RenderLineupRows(adjustedRows) });
 212        }
 213
 214        return new FreshenedLineupCollection(documents, RenderLineupRows(aggregateRows));
 215    }
 216
 217    private static IReadOnlyList<LineupCsvRow> ApplyExistingFreshnessDates(
 218        IReadOnlyList<LineupCsvRow> currentRows,
 219        IReadOnlyList<LineupCsvRow> existingRows,
 220        string collectionDate)
 221    {
 222        var existingEntries = existingRows
 223            .Select((row, index) => new ExistingLineupRow(row, index))
 224            .ToList();
 225        var existingEntriesByKey = existingEntries
 226            .GroupBy(entry => GetRowKey(entry.Row))
 227            .ToDictionary(
 228                group => group.Key,
 229                group => new Queue<ExistingLineupRow>(group));
 230        var matchedExistingIndexes = new HashSet<int>();
 231        var adjustedRows = new List<LineupCsvRow>(currentRows.Count);
 232
 233        for (var index = 0; index < currentRows.Count; index++)
 234        {
 235            var currentRow = currentRows[index];
 236            var existingEntry = FindExistingEntry(currentRow, index);
 237            if (existingEntry is null)
 238            {
 239                adjustedRows.Add(currentRow);
 240                continue;
 241            }
 242
 243            var existingRow = existingEntry.Value.Row;
 244            adjustedRows.Add(HasNonDateChange(currentRow, existingRow)
 245                ? currentRow with { DataCollectedAt = collectionDate }
 246                : currentRow with { DataCollectedAt = existingRow.DataCollectedAt });
 247        }
 248
 249        return adjustedRows;
 250
 251        static LineupRowKey GetRowKey(LineupCsvRow row)
 252        {
 253            return new LineupRowKey(row.Team, row.Role, row.Name);
 254        }
 255
 256        ExistingLineupRow? FindExistingEntry(LineupCsvRow currentRow, int index)
 257        {
 258            if (existingEntriesByKey.TryGetValue(GetRowKey(currentRow), out var candidates))
 259            {
 260                while (candidates.Count > 0)
 261                {
 262                    var candidate = candidates.Dequeue();
 263                    if (matchedExistingIndexes.Add(candidate.Index))
 264                    {
 265                        return candidate;
 266                    }
 267                }
 268            }
 269
 270            if (currentRows.Count == existingRows.Count
 271                && index < existingRows.Count
 272                && matchedExistingIndexes.Add(index))
 273            {
 274                var candidateRow = existingRows[index];
 275                if (string.Equals(currentRow.Team, candidateRow.Team, StringComparison.Ordinal)
 276                    && string.Equals(currentRow.Role, candidateRow.Role, StringComparison.Ordinal))
 277                {
 278                    return new ExistingLineupRow(candidateRow, index);
 279                }
 280
 281                matchedExistingIndexes.Remove(index);
 282            }
 283
 284            return null;
 285        }
 286    }
 287
 288    private static bool HasNonDateChange(LineupCsvRow currentRow, LineupCsvRow existingRow)
 289    {
 290        return !string.Equals(currentRow.Team, existingRow.Team, StringComparison.Ordinal)
 291               || !string.Equals(currentRow.Role, existingRow.Role, StringComparison.Ordinal)
 292               || !string.Equals(currentRow.Name, existingRow.Name, StringComparison.Ordinal)
 293               || !string.Equals(currentRow.Age, existingRow.Age, StringComparison.Ordinal)
 294               || !string.Equals(currentRow.Position, existingRow.Position, StringComparison.Ordinal)
 295               || !string.Equals(currentRow.MarketValueEur, existingRow.MarketValueEur, StringComparison.Ordinal);
 296    }
 297
 298    private static IReadOnlyList<LineupCsvRow> ReadLineupRows(
 299        string documentName,
 300        string content,
 301        string label)
 302    {
 303        try
 304        {
 305            using var reader = new StringReader(content);
 306            using var csv = new CsvReader(
 307                reader,
 308                new CsvConfiguration(CultureInfo.InvariantCulture)
 309                {
 310                    BadDataFound = null,
 311                    MissingFieldFound = null,
 312                    TrimOptions = TrimOptions.Trim
 313                });
 314
 315            if (!csv.Read())
 316            {
 317                throw new InvalidOperationException("missing header row");
 318            }
 319
 320            csv.ReadHeader();
 321            ValidateLineupColumns(csv.HeaderRecord ?? [], label, documentName);
 322
 323            var rows = new List<LineupCsvRow>();
 324            while (csv.Read())
 325            {
 326                var row = new LineupCsvRow(
 327                    GetTrimmedField(csv, "Team"),
 328                    GetTrimmedField(csv, "Data_Collected_At"),
 329                    GetTrimmedField(csv, "Role"),
 330                    GetTrimmedField(csv, "Name"),
 331                    GetTrimmedField(csv, "Age"),
 332                    GetTrimmedField(csv, "Position"),
 333                    GetTrimmedField(csv, "Market_Value_EUR"));
 334                ValidateLineupRow(row, csv.Context?.Parser?.Row ?? 0, label, documentName);
 335                rows.Add(row);
 336            }
 337
 338            return rows;
 339        }
 340        catch (Exception ex) when (ex is CsvHelperException or InvalidOperationException)
 341        {
 342            throw new InvalidOperationException($"{label} {documentName} is malformed: {ex.Message}", ex);
 343        }
 344    }
 345
 346    private static string RenderLineupRows(IEnumerable<LineupCsvRow> rows)
 347    {
 348        using var writer = new StringWriter(CultureInfo.InvariantCulture);
 349        using var csv = new CsvWriter(
 350            writer,
 351            new CsvConfiguration(CultureInfo.InvariantCulture)
 352            {
 353                NewLine = "\r\n"
 354            });
 355
 356        foreach (var column in LineupColumns)
 357        {
 358            csv.WriteField(column);
 359        }
 360
 361        csv.NextRecord();
 362
 363        foreach (var row in rows)
 364        {
 365            csv.WriteField(row.Team);
 366            csv.WriteField(row.DataCollectedAt);
 367            csv.WriteField(row.Role);
 368            csv.WriteField(row.Name);
 369            csv.WriteField(row.Age);
 370            csv.WriteField(row.Position);
 371            csv.WriteField(row.MarketValueEur);
 372            csv.NextRecord();
 373        }
 374
 375        return writer.ToString();
 376    }
 377
 378    private static void ValidateLineupColumns(
 379        IReadOnlyList<string> headers,
 380        string label,
 381        string documentName)
 382    {
 383        var missing = LineupColumns
 384            .Where(column => !headers.Contains(column, StringComparer.Ordinal))
 385            .ToList();
 386
 387        if (missing.Count > 0)
 388        {
 389            throw new InvalidOperationException(
 390                $"{label} {documentName} is missing required column(s): {string.Join(", ", missing)}");
 391        }
 392    }
 393
 394    private static void ValidateLineupRow(
 395        LineupCsvRow row,
 396        int lineNumber,
 397        string label,
 398        string documentName)
 399    {
 400        foreach (var (column, value) in new[]
 401                 {
 402                     ("Team", row.Team),
 403                     ("Data_Collected_At", row.DataCollectedAt),
 404                     ("Role", row.Role),
 405                     ("Name", row.Name)
 406                 })
 407        {
 408            if (string.IsNullOrWhiteSpace(value))
 409            {
 410                throw new InvalidOperationException(
 411                    $"{label} {documentName} line {lineNumber}: missing {column}");
 412            }
 413        }
 414
 415        if (!DateOnly.TryParseExact(
 416                row.DataCollectedAt,
 417                "yyyy-MM-dd",
 418                CultureInfo.InvariantCulture,
 419                DateTimeStyles.None,
 420                out _))
 421        {
 422            throw new InvalidOperationException(
 423                $"{label} {documentName} line {lineNumber}: Data_Collected_At must use YYYY-MM-DD, got {row.DataCollecte
 424        }
 425    }
 426
 427    private static string GetTrimmedField(CsvReader csv, string column)
 428    {
 429        return (csv.GetField(column) ?? string.Empty).Trim();
 430    }
 431
 432    private void PrintHeaderOnlyReport(Wm26LineupCollection source)
 433    {
 434        if (source.HeaderOnlyTeams.Count == 0)
 435        {
 436            _console.MarkupLine("[green]Header-only lineup context payloads: none[/]");
 437            return;
 438        }
 439
 440        _console.MarkupLine($"[yellow]Header-only lineup context payloads:[/] {source.HeaderOnlyTeams.Count}");
 441        foreach (var team in source.HeaderOnlyTeams)
 442        {
 443            _console.MarkupLine($"[yellow]  - {Markup.Escape(team.Name)} ({Markup.Escape(team.Slug)})[/]");
 444        }
 445    }
 446
 447    private void PrintMissingSourceDataReport(Wm26LineupCollection source)
 448    {
 449        if (source.MissingSourceData.Count == 0)
 450        {
 451            _console.MarkupLine("[green]Missing lineup source data: none[/]");
 452            return;
 453        }
 454
 455        _console.MarkupLine("[yellow]Missing lineup source data detected:[/]");
 456        foreach (var group in source.MissingSourceData.GroupBy(item => (item.TeamSlug, item.TeamName)))
 457        {
 458            var players = string.Join(
 459                ", ",
 460                group.Select(item => $"{item.PlayerName} ({string.Join(", ", item.Fields)})"));
 461            var plural = group.Count() == 1 ? "player" : "players";
 462            _console.MarkupLine(
 463                $"[yellow]  - {Markup.Escape(group.Key.TeamName)} ({Markup.Escape(group.Key.TeamSlug)}): supplemental da
 464        }
 465    }
 466
 467    private sealed record FreshenedLineupCollection(
 468        IReadOnlyList<Wm26LineupDocument> ContextDocuments,
 469        string KpiContent);
 470
 1471    private sealed record LineupCsvRow(
 1472        string Team,
 1473        string DataCollectedAt,
 1474        string Role,
 1475        string Name,
 1476        string Age,
 1477        string Position,
 1478        string MarketValueEur);
 479
 480    private readonly record struct LineupRowKey(
 481        string Team,
 482        string Role,
 483        string Name);
 484
 485    private readonly record struct ExistingLineupRow(
 486        LineupCsvRow Row,
 487        int Index);
 488}