< Summary

Information
Class: Orchestrator.Commands.Operations.CollectContext.CollectContextLineupsCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/CollectContext/CollectContextLineupsCommand.cs
Line coverage
91%
Covered lines: 249
Uncovered lines: 24
Coverable lines: 273
Total lines: 488
Line coverage: 91.2%
Branch coverage
65%
Covered branches: 68
Total branches: 104
Branch coverage: 65.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%
ExecuteAsync()100%11100%
ExecuteWithSettingsAsync()72.73%222293.75%
ApplyFreshnessDatesAsync()100%44100%
ApplyExistingFreshnessDates(...)100%1414100%
GetRowKey()100%11100%
FindExistingEntry()43.75%281664.29%
HasNonDateChange(...)50%1010100%
ReadLineupRows(...)60%101096.55%
RenderLineupRows(...)100%44100%
ValidateLineupColumns(...)100%22100%
ValidateLineupRow(...)66.67%6678.95%
GetTrimmedField(...)50%22100%
PrintHeaderOnlyReport(...)100%44100%
PrintMissingSourceDataReport(...)10%481027.27%
.ctor(...)100%11100%
.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.";
 121    private static readonly IReadOnlyList<string> LineupColumns =
 122    [
 123        "Team",
 124        "Data_Collected_At",
 125        "Role",
 126        "Name",
 127        "Age",
 128        "Position",
 129        "Market_Value_EUR"
 130    ];
 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
 138    public CollectContextLineupsCommand(
 139        IAnsiConsole console,
 140        IFirebaseServiceFactory firebaseServiceFactory,
 141        IWm26LineupSource lineupSource,
 142        TimeProvider timeProvider,
 143        ILogger<CollectContextLineupsCommand> logger)
 44    {
 145        _console = console;
 146        _firebaseServiceFactory = firebaseServiceFactory;
 147        _lineupSource = lineupSource;
 148        _timeProvider = timeProvider;
 149        _logger = logger;
 150    }
 51
 52    protected override async Task<int> ExecuteAsync(
 53        CommandContext context,
 54        CollectContextLineupsSettings settings,
 55        CancellationToken cancellationToken)
 56    {
 157        return await ExecuteWithSettingsAsync(settings, cancellationToken);
 158    }
 59
 60    internal async Task<int> ExecuteWithSettingsAsync(
 61        CollectContextLineupsSettings settings,
 62        CancellationToken cancellationToken = default)
 63    {
 64        try
 65        {
 166            if (string.IsNullOrWhiteSpace(settings.CommunityContext))
 67            {
 068                _console.MarkupLine("[red]Error: Community context is required[/]");
 069                return 1;
 70            }
 71
 172            var communityContext = settings.CommunityContext.Trim();
 173            var competition = CompetitionResolver.ResolveCompetition(settings.Competition, communityContext, communityCo
 174            var repositoryCompetition = CompetitionResolver.ToRepositoryCompetitionArgument(competition);
 75
 176            _console.MarkupLine("[green]Collect-context lineups command initialized[/]");
 177            _console.MarkupLine($"[blue]Using community context:[/] [yellow]{Markup.Escape(communityContext)}[/]");
 178            _console.MarkupLine($"[blue]Using competition:[/] [yellow]{Markup.Escape(competition)}[/]");
 179            _console.MarkupLine($"[blue]Using lineup seed:[/] [yellow]{Markup.Escape(settings.Seed)}[/]");
 180            _console.MarkupLine($"[blue]Using team manifest:[/] [yellow]{Markup.Escape(settings.Teams)}[/]");
 81
 182            if (settings.Verbose)
 83            {
 184                _console.MarkupLine("[dim]Verbose mode enabled[/]");
 85            }
 86
 187            if (settings.DryRun)
 88            {
 189                _console.MarkupLine("[magenta]Dry run mode enabled - no changes will be made to database[/]");
 90            }
 91
 192            var source = await _lineupSource.CollectAsync(
 193                new Wm26LineupSourceRequest(settings.Seed, settings.Teams, settings.DuckDbPath),
 194                cancellationToken);
 95
 196            _console.MarkupLine($"[blue]Resolved lineup seed:[/] [yellow]{Markup.Escape(source.SeedPath)}[/]");
 197            _console.MarkupLine($"[blue]Resolved team manifest:[/] [yellow]{Markup.Escape(source.TeamsPath)}[/]");
 198            _console.MarkupLine($"[blue]Using Transfermarkt DuckDB:[/] [yellow]{Markup.Escape(source.DuckDbPath)}[/]");
 199            _console.MarkupLine($"[blue]Seed rows:[/] [yellow]{source.SeedRowCount}[/]");
 1100            _console.MarkupLine($"[blue]Generated lineup context documents:[/] [yellow]{source.ContextDocuments.Count}[/
 1101            PrintHeaderOnlyReport(source);
 1102            PrintMissingSourceDataReport(source);
 103
 1104            var contextRepository = _firebaseServiceFactory.CreateContextRepository(repositoryCompetition);
 1105            var collectionDate = DateOnly.FromDateTime(_timeProvider.GetUtcNow().UtcDateTime);
 1106            var freshenedSource = await ApplyFreshnessDatesAsync(
 1107                source,
 1108                contextRepository,
 1109                communityContext,
 1110                collectionDate,
 1111                cancellationToken);
 112
 1113            if (settings.DryRun)
 114            {
 1115                foreach (var document in freshenedSource.ContextDocuments)
 116                {
 1117                    _console.MarkupLine($"[magenta]  Dry run - would save context document:[/] {Markup.Escape(document.D
 118                }
 119
 1120                _console.MarkupLine($"[magenta]  Dry run - would save KPI document:[/] {LineupsDocumentName}");
 1121                _console.MarkupLine($"[magenta]✓ Dry run completed - would have processed {freshenedSource.ContextDocume
 1122                return 0;
 123            }
 124
 1125            var kpiRepository = _firebaseServiceFactory.CreateKpiRepository(repositoryCompetition);
 126
 1127            var savedContextCount = 0;
 1128            var skippedContextCount = 0;
 1129            foreach (var document in freshenedSource.ContextDocuments)
 130            {
 1131                var savedVersion = await contextRepository.SaveContextDocumentAsync(
 1132                    document.DocumentName,
 1133                    document.Content,
 1134                    communityContext,
 1135                    cancellationToken);
 136
 1137                if (savedVersion.HasValue)
 138                {
 1139                    savedContextCount++;
 1140                    if (settings.Verbose)
 141                    {
 1142                        _console.MarkupLine($"[green]  ✓ Saved {Markup.Escape(document.DocumentName)} as version {savedV
 143                    }
 144                }
 145                else
 146                {
 0147                    skippedContextCount++;
 0148                    if (settings.Verbose)
 149                    {
 0150                        _console.MarkupLine($"[dim]  - Skipped {Markup.Escape(document.DocumentName)} (content unchanged
 151                    }
 152                }
 1153            }
 154
 1155            var existingKpiDocument = await kpiRepository.GetKpiDocumentAsync(
 1156                LineupsDocumentName,
 1157                communityContext,
 1158                cancellationToken);
 1159            var savedKpiVersion = await kpiRepository.SaveKpiDocumentAsync(
 1160                LineupsDocumentName,
 1161                freshenedSource.KpiContent,
 1162                LineupsDescription,
 1163                communityContext,
 1164                cancellationToken);
 1165            var kpiChanged = existingKpiDocument is null
 1166                             || !string.Equals(existingKpiDocument.Content, freshenedSource.KpiContent, StringComparison
 167
 1168            _console.MarkupLine("[green]✓ WM26 lineup context collection completed![/]");
 1169            _console.MarkupLine($"[green]  Saved: {savedContextCount} context documents[/]");
 1170            _console.MarkupLine($"[dim]  Skipped: {skippedContextCount} context documents (unchanged)[/]");
 1171            _console.MarkupLine(kpiChanged
 1172                ? $"[green]  KPI document {LineupsDocumentName} saved as version {savedKpiVersion}[/]"
 1173                : $"[dim]  KPI document {LineupsDocumentName} unchanged at version {savedKpiVersion}[/]");
 174
 1175            return 0;
 176        }
 1177        catch (Exception ex)
 178        {
 1179            _logger.LogError(ex, "Error executing collect-context lineups command");
 1180            _console.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
 1181            return 1;
 182        }
 1183    }
 184
 185    private static async Task<FreshenedLineupCollection> ApplyFreshnessDatesAsync(
 186        Wm26LineupCollection source,
 187        IContextRepository contextRepository,
 188        string communityContext,
 189        DateOnly collectionDate,
 190        CancellationToken cancellationToken)
 191    {
 1192        var documents = new List<Wm26LineupDocument>();
 1193        var aggregateRows = new List<LineupCsvRow>();
 1194        var collectionDateText = collectionDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
 195
 1196        foreach (var document in source.ContextDocuments)
 197        {
 1198            var currentRows = ReadLineupRows(document.DocumentName, document.Content, "Generated lineup context document
 1199            var existingDocument = await contextRepository.GetLatestContextDocumentAsync(
 1200                document.DocumentName,
 1201                communityContext,
 1202                cancellationToken);
 1203            var adjustedRows = existingDocument is null
 1204                ? currentRows
 1205                : ApplyExistingFreshnessDates(
 1206                    currentRows,
 1207                    ReadLineupRows(document.DocumentName, existingDocument.Content, "Existing lineup context document"),
 1208                    collectionDateText);
 209
 1210            aggregateRows.AddRange(adjustedRows);
 1211            documents.Add(document with { Content = RenderLineupRows(adjustedRows) });
 1212        }
 213
 1214        return new FreshenedLineupCollection(documents, RenderLineupRows(aggregateRows));
 1215    }
 216
 217    private static IReadOnlyList<LineupCsvRow> ApplyExistingFreshnessDates(
 218        IReadOnlyList<LineupCsvRow> currentRows,
 219        IReadOnlyList<LineupCsvRow> existingRows,
 220        string collectionDate)
 221    {
 1222        var existingEntries = existingRows
 1223            .Select((row, index) => new ExistingLineupRow(row, index))
 1224            .ToList();
 1225        var existingEntriesByKey = existingEntries
 1226            .GroupBy(entry => GetRowKey(entry.Row))
 1227            .ToDictionary(
 1228                group => group.Key,
 1229                group => new Queue<ExistingLineupRow>(group));
 1230        var matchedExistingIndexes = new HashSet<int>();
 1231        var adjustedRows = new List<LineupCsvRow>(currentRows.Count);
 232
 1233        for (var index = 0; index < currentRows.Count; index++)
 234        {
 1235            var currentRow = currentRows[index];
 1236            var existingEntry = FindExistingEntry(currentRow, index);
 1237            if (existingEntry is null)
 238            {
 1239                adjustedRows.Add(currentRow);
 1240                continue;
 241            }
 242
 1243            var existingRow = existingEntry.Value.Row;
 1244            adjustedRows.Add(HasNonDateChange(currentRow, existingRow)
 1245                ? currentRow with { DataCollectedAt = collectionDate }
 1246                : currentRow with { DataCollectedAt = existingRow.DataCollectedAt });
 247        }
 248
 1249        return adjustedRows;
 250
 251        static LineupRowKey GetRowKey(LineupCsvRow row)
 252        {
 1253            return new LineupRowKey(row.Team, row.Role, row.Name);
 254        }
 255
 256        ExistingLineupRow? FindExistingEntry(LineupCsvRow currentRow, int index)
 257        {
 1258            if (existingEntriesByKey.TryGetValue(GetRowKey(currentRow), out var candidates))
 259            {
 1260                while (candidates.Count > 0)
 261                {
 1262                    var candidate = candidates.Dequeue();
 1263                    if (matchedExistingIndexes.Add(candidate.Index))
 264                    {
 1265                        return candidate;
 266                    }
 267                }
 268            }
 269
 1270            if (currentRows.Count == existingRows.Count
 1271                && index < existingRows.Count
 1272                && matchedExistingIndexes.Add(index))
 273            {
 0274                var candidateRow = existingRows[index];
 0275                if (string.Equals(currentRow.Team, candidateRow.Team, StringComparison.Ordinal)
 0276                    && string.Equals(currentRow.Role, candidateRow.Role, StringComparison.Ordinal))
 277                {
 0278                    return new ExistingLineupRow(candidateRow, index);
 279                }
 280
 0281                matchedExistingIndexes.Remove(index);
 282            }
 283
 1284            return null;
 285        }
 286    }
 287
 288    private static bool HasNonDateChange(LineupCsvRow currentRow, LineupCsvRow existingRow)
 289    {
 1290        return !string.Equals(currentRow.Team, existingRow.Team, StringComparison.Ordinal)
 1291               || !string.Equals(currentRow.Role, existingRow.Role, StringComparison.Ordinal)
 1292               || !string.Equals(currentRow.Name, existingRow.Name, StringComparison.Ordinal)
 1293               || !string.Equals(currentRow.Age, existingRow.Age, StringComparison.Ordinal)
 1294               || !string.Equals(currentRow.Position, existingRow.Position, StringComparison.Ordinal)
 1295               || !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        {
 1305            using var reader = new StringReader(content);
 1306            using var csv = new CsvReader(
 1307                reader,
 1308                new CsvConfiguration(CultureInfo.InvariantCulture)
 1309                {
 1310                    BadDataFound = null,
 1311                    MissingFieldFound = null,
 1312                    TrimOptions = TrimOptions.Trim
 1313                });
 314
 1315            if (!csv.Read())
 316            {
 0317                throw new InvalidOperationException("missing header row");
 318            }
 319
 1320            csv.ReadHeader();
 1321            ValidateLineupColumns(csv.HeaderRecord ?? [], label, documentName);
 322
 1323            var rows = new List<LineupCsvRow>();
 1324            while (csv.Read())
 325            {
 1326                var row = new LineupCsvRow(
 1327                    GetTrimmedField(csv, "Team"),
 1328                    GetTrimmedField(csv, "Data_Collected_At"),
 1329                    GetTrimmedField(csv, "Role"),
 1330                    GetTrimmedField(csv, "Name"),
 1331                    GetTrimmedField(csv, "Age"),
 1332                    GetTrimmedField(csv, "Position"),
 1333                    GetTrimmedField(csv, "Market_Value_EUR"));
 1334                ValidateLineupRow(row, csv.Context?.Parser?.Row ?? 0, label, documentName);
 1335                rows.Add(row);
 336            }
 337
 1338            return rows;
 339        }
 1340        catch (Exception ex) when (ex is CsvHelperException or InvalidOperationException)
 341        {
 1342            throw new InvalidOperationException($"{label} {documentName} is malformed: {ex.Message}", ex);
 343        }
 1344    }
 345
 346    private static string RenderLineupRows(IEnumerable<LineupCsvRow> rows)
 347    {
 1348        using var writer = new StringWriter(CultureInfo.InvariantCulture);
 1349        using var csv = new CsvWriter(
 1350            writer,
 1351            new CsvConfiguration(CultureInfo.InvariantCulture)
 1352            {
 1353                NewLine = "\r\n"
 1354            });
 355
 1356        foreach (var column in LineupColumns)
 357        {
 1358            csv.WriteField(column);
 359        }
 360
 1361        csv.NextRecord();
 362
 1363        foreach (var row in rows)
 364        {
 1365            csv.WriteField(row.Team);
 1366            csv.WriteField(row.DataCollectedAt);
 1367            csv.WriteField(row.Role);
 1368            csv.WriteField(row.Name);
 1369            csv.WriteField(row.Age);
 1370            csv.WriteField(row.Position);
 1371            csv.WriteField(row.MarketValueEur);
 1372            csv.NextRecord();
 373        }
 374
 1375        return writer.ToString();
 1376    }
 377
 378    private static void ValidateLineupColumns(
 379        IReadOnlyList<string> headers,
 380        string label,
 381        string documentName)
 382    {
 1383        var missing = LineupColumns
 1384            .Where(column => !headers.Contains(column, StringComparer.Ordinal))
 1385            .ToList();
 386
 1387        if (missing.Count > 0)
 388        {
 1389            throw new InvalidOperationException(
 1390                $"{label} {documentName} is missing required column(s): {string.Join(", ", missing)}");
 391        }
 1392    }
 393
 394    private static void ValidateLineupRow(
 395        LineupCsvRow row,
 396        int lineNumber,
 397        string label,
 398        string documentName)
 399    {
 1400        foreach (var (column, value) in new[]
 1401                 {
 1402                     ("Team", row.Team),
 1403                     ("Data_Collected_At", row.DataCollectedAt),
 1404                     ("Role", row.Role),
 1405                     ("Name", row.Name)
 1406                 })
 407        {
 1408            if (string.IsNullOrWhiteSpace(value))
 409            {
 0410                throw new InvalidOperationException(
 0411                    $"{label} {documentName} line {lineNumber}: missing {column}");
 412            }
 413        }
 414
 1415        if (!DateOnly.TryParseExact(
 1416                row.DataCollectedAt,
 1417                "yyyy-MM-dd",
 1418                CultureInfo.InvariantCulture,
 1419                DateTimeStyles.None,
 1420                out _))
 421        {
 0422            throw new InvalidOperationException(
 0423                $"{label} {documentName} line {lineNumber}: Data_Collected_At must use YYYY-MM-DD, got {row.DataCollecte
 424        }
 1425    }
 426
 427    private static string GetTrimmedField(CsvReader csv, string column)
 428    {
 1429        return (csv.GetField(column) ?? string.Empty).Trim();
 430    }
 431
 432    private void PrintHeaderOnlyReport(Wm26LineupCollection source)
 433    {
 1434        if (source.HeaderOnlyTeams.Count == 0)
 435        {
 1436            _console.MarkupLine("[green]Header-only lineup context payloads: none[/]");
 1437            return;
 438        }
 439
 1440        _console.MarkupLine($"[yellow]Header-only lineup context payloads:[/] {source.HeaderOnlyTeams.Count}");
 1441        foreach (var team in source.HeaderOnlyTeams)
 442        {
 1443            _console.MarkupLine($"[yellow]  - {Markup.Escape(team.Name)} ({Markup.Escape(team.Slug)})[/]");
 444        }
 1445    }
 446
 447    private void PrintMissingSourceDataReport(Wm26LineupCollection source)
 448    {
 1449        if (source.MissingSourceData.Count == 0)
 450        {
 1451            _console.MarkupLine("[green]Missing lineup source data: none[/]");
 1452            return;
 453        }
 454
 0455        _console.MarkupLine("[yellow]Missing lineup source data detected:[/]");
 0456        foreach (var group in source.MissingSourceData.GroupBy(item => (item.TeamSlug, item.TeamName)))
 457        {
 0458            var players = string.Join(
 0459                ", ",
 0460                group.Select(item => $"{item.PlayerName} ({string.Join(", ", item.Fields)})"));
 0461            var plural = group.Count() == 1 ? "player" : "players";
 0462            _console.MarkupLine(
 0463                $"[yellow]  - {Markup.Escape(group.Key.TeamName)} ({Markup.Escape(group.Key.TeamSlug)}): supplemental da
 464        }
 0465    }
 466
 1467    private sealed record FreshenedLineupCollection(
 1468        IReadOnlyList<Wm26LineupDocument> ContextDocuments,
 1469        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}

Methods/Properties

.cctor()
.ctor(Spectre.Console.IAnsiConsole, Orchestrator.Infrastructure.Factories.IFirebaseServiceFactory, Orchestrator.Commands.Operations.CollectContext.IWm26LineupSource, System.TimeProvider, Microsoft.Extensions.Logging.ILogger<Orchestrator.Commands.Operations.CollectContext.CollectContextLineupsCommand>)
ExecuteAsync()
ExecuteWithSettingsAsync()
ApplyFreshnessDatesAsync()
ApplyExistingFreshnessDates(System.Collections.Generic.IReadOnlyList<Orchestrator.Commands.Operations.CollectContext.CollectContextLineupsCommand.LineupCsvRow>, System.Collections.Generic.IReadOnlyList<Orchestrator.Commands.Operations.CollectContext.CollectContextLineupsCommand.LineupCsvRow>, string)
GetRowKey()
FindExistingEntry()
HasNonDateChange(Orchestrator.Commands.Operations.CollectContext.CollectContextLineupsCommand.LineupCsvRow, Orchestrator.Commands.Operations.CollectContext.CollectContextLineupsCommand.LineupCsvRow)
ReadLineupRows(string, string, string)
RenderLineupRows(System.Collections.Generic.IEnumerable<Orchestrator.Commands.Operations.CollectContext.CollectContextLineupsCommand.LineupCsvRow>)
ValidateLineupColumns(System.Collections.Generic.IReadOnlyList<string>, string, string)
ValidateLineupRow(Orchestrator.Commands.Operations.CollectContext.CollectContextLineupsCommand.LineupCsvRow, int, string, string)
GetTrimmedField(CsvHelper.CsvReader, string)
PrintHeaderOnlyReport(Orchestrator.Commands.Operations.CollectContext.Wm26LineupCollection)
PrintMissingSourceDataReport(Orchestrator.Commands.Operations.CollectContext.Wm26LineupCollection)
.ctor(System.Collections.Generic.IReadOnlyList<Orchestrator.Commands.Operations.CollectContext.Wm26LineupDocument>, string)
.ctor(string, string, string, string, string, string, string)