< Summary

Information
Class: Orchestrator.Commands.Operations.Wm26RecentHistory.Wm26RecentHistoryApplyDateMapCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/Wm26RecentHistory/Wm26RecentHistoryApplyDateMapCommand.cs
Line coverage
92%
Covered lines: 168
Uncovered lines: 14
Coverable lines: 182
Total lines: 344
Line coverage: 92.3%
Branch coverage
94%
Covered branches: 64
Total branches: 68
Branch coverage: 94.1%
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()95.45%464489.81%
CreateApplyOptions(...)100%22100%
BuildPredictionDateEntriesAsync()100%1212100%
FormatPlayedAt(...)100%11100%
PrintMissingEntries(...)75%4488.89%
PrintMissingPredictionEntries(...)75%4488.89%
IsRecentHistoryDocument(...)100%22100%
.ctor(...)100%11100%
.ctor(...)100%11100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/Wm26RecentHistory/Wm26RecentHistoryApplyDateMapCommand.cs

#LineLine coverage
 1using System.Globalization;
 2using EHonda.KicktippAi.Core;
 3using Microsoft.Extensions.Logging;
 4using NodaTime;
 5using Orchestrator.Infrastructure;
 6using Orchestrator.Infrastructure.Factories;
 7using Spectre.Console;
 8using Spectre.Console.Cli;
 9
 10namespace Orchestrator.Commands.Operations.Wm26RecentHistory;
 11
 12public sealed class Wm26RecentHistoryApplyDateMapCommand
 13    : AsyncCommand<Wm26RecentHistoryApplyDateMapSettings>
 14{
 115    private static readonly DateTimeZone BerlinTimeZone = DateTimeZoneProviders.Tzdb["Europe/Berlin"];
 16
 17    private readonly IAnsiConsole _console;
 18    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 19    private readonly ILogger<Wm26RecentHistoryApplyDateMapCommand> _logger;
 20
 121    public Wm26RecentHistoryApplyDateMapCommand(
 122        IAnsiConsole console,
 123        IFirebaseServiceFactory firebaseServiceFactory,
 124        ILogger<Wm26RecentHistoryApplyDateMapCommand> logger)
 25    {
 126        _console = console;
 127        _firebaseServiceFactory = firebaseServiceFactory;
 128        _logger = logger;
 129    }
 30
 31    protected override async Task<int> ExecuteAsync(
 32        CommandContext context,
 33        Wm26RecentHistoryApplyDateMapSettings settings,
 34        CancellationToken cancellationToken)
 35    {
 136        return await ExecuteWithSettingsAsync(settings, cancellationToken);
 137    }
 38
 39    internal async Task<int> ExecuteWithSettingsAsync(
 40        Wm26RecentHistoryApplyDateMapSettings settings,
 41        CancellationToken cancellationToken = default)
 42    {
 43        try
 44        {
 145            if (!File.Exists(settings.Input))
 46            {
 047                _console.MarkupLine($"[red]Date map not found:[/] {settings.Input}");
 048                return 1;
 49            }
 50
 151            var dateMapContent = await File.ReadAllTextAsync(settings.Input, cancellationToken);
 152            var dateMapEntries = HistoryCsvUtility.ReadDateMapEntries(dateMapContent);
 153            if (dateMapEntries.Count == 0)
 54            {
 055                _console.MarkupLine("[red]Date map has no rows[/]");
 056                return 1;
 57            }
 58
 159            var competition = CompetitionResolver.ResolveCompetition(
 160                settings.Competition,
 161                communityContext: settings.CommunityContext);
 162            var repositoryCompetition = CompetitionResolver.ToRepositoryCompetitionArgument(competition);
 163            var contextRepository = _firebaseServiceFactory.CreateContextRepository(repositoryCompetition);
 164            var applyOptions = CreateApplyOptions(settings);
 165            var predictionRepository = settings.ApplyKnownOnly && applyOptions.PreserveCollectedOnOrAfter.HasValue
 166                ? _firebaseServiceFactory.CreatePredictionRepository(repositoryCompetition)
 167                : null;
 168            var predictionLookupCache = new Dictionary<PredictionLookupKey, Match?>();
 69
 170            _console.MarkupLine($"[green]Applying WM26 recent-history date map for:[/] [yellow]{settings.CommunityContex
 171            _console.MarkupLine($"[blue]Using competition:[/] [yellow]{competition}[/]");
 172            if (settings.ApplyKnownOnly)
 73            {
 174                _console.MarkupLine("[blue]Apply-known-only mode enabled - unmapped rows will be preserved[/]");
 175                if (applyOptions.PreserveCollectedOnOrAfter.HasValue)
 76                {
 177                    _console.MarkupLine(
 178                        $"[blue]Resolving WM tournament date-only rows dated on or after from stored predictions:[/] [ye
 79                }
 80            }
 81
 182            if (settings.DryRun)
 83            {
 184                _console.MarkupLine("[magenta]Dry run mode enabled - no Firestore documents will be written[/]");
 85            }
 86
 187            var documentNames = await contextRepository.GetContextDocumentNamesAsync(
 188                settings.CommunityContext,
 189                cancellationToken);
 190            var historyDocumentNames = documentNames
 191                .Where(IsRecentHistoryDocument)
 092                .OrderBy(name => name, StringComparer.Ordinal)
 193                .ToList();
 94
 195            if (historyDocumentNames.Count == 0)
 96            {
 197                _console.MarkupLine("[yellow]No recent-history documents found[/]");
 198                return settings.ApplyKnownOnly ? 0 : 1;
 99            }
 100
 1101            var plannedUpdates = new List<PlannedUpdate>();
 1102            var missingEntries = new List<HistoryDateMapEntry>();
 1103            var missingPredictionEntries = new List<HistoryDateMapEntry>();
 104
 1105            foreach (var documentName in historyDocumentNames)
 106            {
 1107                var document = await contextRepository.GetLatestContextDocumentAsync(
 1108                    documentName,
 1109                    settings.CommunityContext,
 1110                    cancellationToken);
 1111                if (document is null)
 112                {
 113                    continue;
 114                }
 115
 1116                var predictionDateEntries = await BuildPredictionDateEntriesAsync(
 1117                    documentName,
 1118                    document.Content,
 1119                    applyOptions,
 1120                    predictionRepository,
 1121                    settings.CommunityContext,
 1122                    predictionLookupCache,
 1123                    cancellationToken);
 1124                var documentApplyOptions = applyOptions with { PredictionDateEntries = predictionDateEntries };
 1125                var result = HistoryCsvUtility.ApplyDateMap(documentName, document.Content, dateMapEntries, documentAppl
 1126                if (result.MissingEntries.Count > 0)
 127                {
 1128                    missingEntries.AddRange(result.MissingEntries);
 129                }
 130
 1131                if (result.MissingPredictionEntries.Count > 0)
 132                {
 1133                    missingPredictionEntries.AddRange(result.MissingPredictionEntries);
 134                }
 135
 1136                plannedUpdates.Add(new PlannedUpdate(documentName, document, result));
 137
 1138                if (settings.Verbose)
 139                {
 1140                    _console.MarkupLine(
 1141                        $"[dim]  Checked {documentName}: {result.RowCount} row(s), " +
 1142                        $"{result.UpdatedRowCount} updated, {result.PreservedRowCount} preserved, " +
 1143                        $"{result.SkippedRowCount} skipped[/]");
 144                }
 1145            }
 146
 1147            if (missingEntries.Count > 0)
 148            {
 1149                PrintMissingEntries(missingEntries);
 1150                return 1;
 151            }
 152
 1153            if (missingPredictionEntries.Count > 0)
 154            {
 1155                PrintMissingPredictionEntries(missingPredictionEntries);
 1156                return 1;
 157            }
 158
 1159            var savedCount = 0;
 1160            var unchangedCount = 0;
 161
 1162            foreach (var update in plannedUpdates)
 163            {
 1164                if (update.Document.Content == update.Result.Content)
 165                {
 1166                    unchangedCount++;
 1167                    if (settings.Verbose)
 168                    {
 1169                        _console.MarkupLine($"[dim]  Skipped {update.DocumentName} (content unchanged)[/]");
 170                    }
 171
 1172                    continue;
 173                }
 174
 1175                if (settings.DryRun)
 176                {
 1177                    _console.MarkupLine($"[magenta]  Dry run - would save:[/] {update.DocumentName}");
 1178                    savedCount++;
 1179                    continue;
 180                }
 181
 1182                var savedVersion = await contextRepository.SaveContextDocumentAsync(
 1183                    update.DocumentName,
 1184                    update.Result.Content,
 1185                    settings.CommunityContext,
 1186                    cancellationToken);
 187
 1188                if (savedVersion.HasValue)
 189                {
 1190                    savedCount++;
 1191                    if (settings.Verbose)
 192                    {
 1193                        _console.MarkupLine($"[green]  Saved {update.DocumentName} as version {savedVersion.Value}[/]");
 194                    }
 195                }
 196                else
 197                {
 0198                    unchangedCount++;
 0199                    if (settings.Verbose)
 200                    {
 0201                        _console.MarkupLine($"[dim]  Skipped {update.DocumentName} (repository reported unchanged)[/]");
 202                    }
 203                }
 1204            }
 205
 1206            var completionMessage = settings.DryRun
 1207                ? $"[magenta]Date-map dry run completed - {savedCount} document(s) would be saved[/]"
 1208                : $"[green]Date-map apply completed - saved {savedCount} document(s)[/]";
 1209            _console.MarkupLine(completionMessage);
 1210            _console.MarkupLine($"[dim]Unchanged: {unchangedCount} document(s)[/]");
 211
 1212            return 0;
 213        }
 0214        catch (Exception ex)
 215        {
 0216            _logger.LogError(ex, "Failed to apply WM26 recent-history date map");
 0217            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 0218            return 1;
 219        }
 1220    }
 221
 222    private static HistoryDateMapApplyOptions CreateApplyOptions(Wm26RecentHistoryApplyDateMapSettings settings)
 223    {
 1224        DateOnly? preserveCollectedOnOrAfter = null;
 1225        if (!string.IsNullOrWhiteSpace(settings.PreserveCollectedOnOrAfter))
 226        {
 1227            preserveCollectedOnOrAfter = DateOnly.ParseExact(
 1228                settings.PreserveCollectedOnOrAfter.Trim(),
 1229                "yyyy-MM-dd",
 1230                CultureInfo.InvariantCulture);
 231        }
 232
 1233        return new HistoryDateMapApplyOptions(
 1234            settings.ApplyKnownOnly,
 1235            preserveCollectedOnOrAfter);
 236    }
 237
 238    private async Task<IReadOnlyList<HistoryDateMapEntry>> BuildPredictionDateEntriesAsync(
 239        string documentName,
 240        string content,
 241        HistoryDateMapApplyOptions applyOptions,
 242        IPredictionRepository? predictionRepository,
 243        string communityContext,
 244        Dictionary<PredictionLookupKey, Match?> predictionLookupCache,
 245        CancellationToken cancellationToken)
 246    {
 1247        if (!applyOptions.PreserveCollectedOnOrAfter.HasValue || predictionRepository is null)
 248        {
 1249            return Array.Empty<HistoryDateMapEntry>();
 250        }
 251
 1252        var rows = HistoryCsvUtility.ExtractRowsRequiringPredictionPlayedAt(
 1253            documentName,
 1254            content,
 1255            applyOptions.PreserveCollectedOnOrAfter.Value);
 1256        if (rows.Count == 0)
 257        {
 1258            return Array.Empty<HistoryDateMapEntry>();
 259        }
 260
 1261        var entries = new List<HistoryDateMapEntry>();
 1262        foreach (var row in rows)
 263        {
 1264            var lookupKey = new PredictionLookupKey(row.HomeTeam.Trim(), row.AwayTeam.Trim());
 1265            if (!predictionLookupCache.TryGetValue(lookupKey, out var match))
 266            {
 1267                match = await predictionRepository.GetLatestPredictedMatchByTeamsAsync(
 1268                    row.HomeTeam,
 1269                    row.AwayTeam,
 1270                    communityContext,
 1271                    cancellationToken);
 1272                predictionLookupCache[lookupKey] = match;
 273            }
 274
 1275            if (match is null)
 276            {
 277                continue;
 278            }
 279
 1280            entries.Add(row with { PlayedAt = FormatPlayedAt(match.StartsAt) });
 1281        }
 282
 1283        return entries;
 1284    }
 285
 286    private static string FormatPlayedAt(ZonedDateTime startsAt)
 287    {
 1288        var local = startsAt.WithZone(BerlinTimeZone);
 1289        var dateTimeOffset = new DateTimeOffset(
 1290            local.LocalDateTime.ToDateTimeUnspecified(),
 1291            local.Offset.ToTimeSpan());
 292
 1293        return dateTimeOffset.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture);
 294    }
 295
 296    private void PrintMissingEntries(IReadOnlyList<HistoryDateMapEntry> missingEntries)
 297    {
 1298        _console.MarkupLine($"[red]Date map is missing exact Played_At values for {missingEntries.Count} row(s)[/]");
 299
 1300        foreach (var entry in missingEntries.Take(20))
 301        {
 1302            _console.MarkupLine(
 1303                "[red]  Missing:[/] " +
 1304                $"{entry.DocumentName} | {entry.Competition} | {entry.HomeTeam} vs {entry.AwayTeam} | " +
 1305                $"{entry.Score} | {entry.Annotation}");
 306        }
 307
 1308        if (missingEntries.Count > 20)
 309        {
 0310            _console.MarkupLine($"[dim]  ... and {missingEntries.Count - 20} more[/]");
 311        }
 1312    }
 313
 314    private void PrintMissingPredictionEntries(IReadOnlyList<HistoryDateMapEntry> missingEntries)
 315    {
 1316        _console.MarkupLine($"[red]Missing stored predictions for {missingEntries.Count} unmapped WM tournament recent-h
 317
 1318        foreach (var entry in missingEntries.Take(20))
 319        {
 1320            _console.MarkupLine(
 1321                "[red]  Missing prediction:[/] " +
 1322                $"{entry.DocumentName} | {entry.Competition} | {entry.HomeTeam} vs {entry.AwayTeam} | " +
 1323                $"{entry.Score} | {entry.Annotation} | collected {entry.PlayedAt}");
 324        }
 325
 1326        if (missingEntries.Count > 20)
 327        {
 0328            _console.MarkupLine($"[dim]  ... and {missingEntries.Count - 20} more[/]");
 329        }
 1330    }
 331
 332    private static bool IsRecentHistoryDocument(string documentName)
 333    {
 1334        return documentName.StartsWith("recent-history-", StringComparison.OrdinalIgnoreCase)
 1335               && documentName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase);
 336    }
 337
 1338    private sealed record PlannedUpdate(
 1339        string DocumentName,
 1340        ContextDocument Document,
 1341        HistoryDateMapApplyResult Result);
 342
 1343    private sealed record PredictionLookupKey(string HomeTeam, string AwayTeam);
 344}