< Summary

Information
Class: Orchestrator.Commands.Operations.Wm26RecentHistory.Wm26RecentHistoryApplyDateMapCommand.PlannedUpdate
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/Wm26RecentHistory/Wm26RecentHistoryApplyDateMapCommand.cs
Line coverage
100%
Covered lines: 4
Uncovered lines: 0
Coverable lines: 4
Total lines: 344
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/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{
 15    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
 21    public Wm26RecentHistoryApplyDateMapCommand(
 22        IAnsiConsole console,
 23        IFirebaseServiceFactory firebaseServiceFactory,
 24        ILogger<Wm26RecentHistoryApplyDateMapCommand> logger)
 25    {
 26        _console = console;
 27        _firebaseServiceFactory = firebaseServiceFactory;
 28        _logger = logger;
 29    }
 30
 31    protected override async Task<int> ExecuteAsync(
 32        CommandContext context,
 33        Wm26RecentHistoryApplyDateMapSettings settings,
 34        CancellationToken cancellationToken)
 35    {
 36        return await ExecuteWithSettingsAsync(settings, cancellationToken);
 37    }
 38
 39    internal async Task<int> ExecuteWithSettingsAsync(
 40        Wm26RecentHistoryApplyDateMapSettings settings,
 41        CancellationToken cancellationToken = default)
 42    {
 43        try
 44        {
 45            if (!File.Exists(settings.Input))
 46            {
 47                _console.MarkupLine($"[red]Date map not found:[/] {settings.Input}");
 48                return 1;
 49            }
 50
 51            var dateMapContent = await File.ReadAllTextAsync(settings.Input, cancellationToken);
 52            var dateMapEntries = HistoryCsvUtility.ReadDateMapEntries(dateMapContent);
 53            if (dateMapEntries.Count == 0)
 54            {
 55                _console.MarkupLine("[red]Date map has no rows[/]");
 56                return 1;
 57            }
 58
 59            var competition = CompetitionResolver.ResolveCompetition(
 60                settings.Competition,
 61                communityContext: settings.CommunityContext);
 62            var repositoryCompetition = CompetitionResolver.ToRepositoryCompetitionArgument(competition);
 63            var contextRepository = _firebaseServiceFactory.CreateContextRepository(repositoryCompetition);
 64            var applyOptions = CreateApplyOptions(settings);
 65            var predictionRepository = settings.ApplyKnownOnly && applyOptions.PreserveCollectedOnOrAfter.HasValue
 66                ? _firebaseServiceFactory.CreatePredictionRepository(repositoryCompetition)
 67                : null;
 68            var predictionLookupCache = new Dictionary<PredictionLookupKey, Match?>();
 69
 70            _console.MarkupLine($"[green]Applying WM26 recent-history date map for:[/] [yellow]{settings.CommunityContex
 71            _console.MarkupLine($"[blue]Using competition:[/] [yellow]{competition}[/]");
 72            if (settings.ApplyKnownOnly)
 73            {
 74                _console.MarkupLine("[blue]Apply-known-only mode enabled - unmapped rows will be preserved[/]");
 75                if (applyOptions.PreserveCollectedOnOrAfter.HasValue)
 76                {
 77                    _console.MarkupLine(
 78                        $"[blue]Resolving WM tournament date-only rows dated on or after from stored predictions:[/] [ye
 79                }
 80            }
 81
 82            if (settings.DryRun)
 83            {
 84                _console.MarkupLine("[magenta]Dry run mode enabled - no Firestore documents will be written[/]");
 85            }
 86
 87            var documentNames = await contextRepository.GetContextDocumentNamesAsync(
 88                settings.CommunityContext,
 89                cancellationToken);
 90            var historyDocumentNames = documentNames
 91                .Where(IsRecentHistoryDocument)
 92                .OrderBy(name => name, StringComparer.Ordinal)
 93                .ToList();
 94
 95            if (historyDocumentNames.Count == 0)
 96            {
 97                _console.MarkupLine("[yellow]No recent-history documents found[/]");
 98                return settings.ApplyKnownOnly ? 0 : 1;
 99            }
 100
 101            var plannedUpdates = new List<PlannedUpdate>();
 102            var missingEntries = new List<HistoryDateMapEntry>();
 103            var missingPredictionEntries = new List<HistoryDateMapEntry>();
 104
 105            foreach (var documentName in historyDocumentNames)
 106            {
 107                var document = await contextRepository.GetLatestContextDocumentAsync(
 108                    documentName,
 109                    settings.CommunityContext,
 110                    cancellationToken);
 111                if (document is null)
 112                {
 113                    continue;
 114                }
 115
 116                var predictionDateEntries = await BuildPredictionDateEntriesAsync(
 117                    documentName,
 118                    document.Content,
 119                    applyOptions,
 120                    predictionRepository,
 121                    settings.CommunityContext,
 122                    predictionLookupCache,
 123                    cancellationToken);
 124                var documentApplyOptions = applyOptions with { PredictionDateEntries = predictionDateEntries };
 125                var result = HistoryCsvUtility.ApplyDateMap(documentName, document.Content, dateMapEntries, documentAppl
 126                if (result.MissingEntries.Count > 0)
 127                {
 128                    missingEntries.AddRange(result.MissingEntries);
 129                }
 130
 131                if (result.MissingPredictionEntries.Count > 0)
 132                {
 133                    missingPredictionEntries.AddRange(result.MissingPredictionEntries);
 134                }
 135
 136                plannedUpdates.Add(new PlannedUpdate(documentName, document, result));
 137
 138                if (settings.Verbose)
 139                {
 140                    _console.MarkupLine(
 141                        $"[dim]  Checked {documentName}: {result.RowCount} row(s), " +
 142                        $"{result.UpdatedRowCount} updated, {result.PreservedRowCount} preserved, " +
 143                        $"{result.SkippedRowCount} skipped[/]");
 144                }
 145            }
 146
 147            if (missingEntries.Count > 0)
 148            {
 149                PrintMissingEntries(missingEntries);
 150                return 1;
 151            }
 152
 153            if (missingPredictionEntries.Count > 0)
 154            {
 155                PrintMissingPredictionEntries(missingPredictionEntries);
 156                return 1;
 157            }
 158
 159            var savedCount = 0;
 160            var unchangedCount = 0;
 161
 162            foreach (var update in plannedUpdates)
 163            {
 164                if (update.Document.Content == update.Result.Content)
 165                {
 166                    unchangedCount++;
 167                    if (settings.Verbose)
 168                    {
 169                        _console.MarkupLine($"[dim]  Skipped {update.DocumentName} (content unchanged)[/]");
 170                    }
 171
 172                    continue;
 173                }
 174
 175                if (settings.DryRun)
 176                {
 177                    _console.MarkupLine($"[magenta]  Dry run - would save:[/] {update.DocumentName}");
 178                    savedCount++;
 179                    continue;
 180                }
 181
 182                var savedVersion = await contextRepository.SaveContextDocumentAsync(
 183                    update.DocumentName,
 184                    update.Result.Content,
 185                    settings.CommunityContext,
 186                    cancellationToken);
 187
 188                if (savedVersion.HasValue)
 189                {
 190                    savedCount++;
 191                    if (settings.Verbose)
 192                    {
 193                        _console.MarkupLine($"[green]  Saved {update.DocumentName} as version {savedVersion.Value}[/]");
 194                    }
 195                }
 196                else
 197                {
 198                    unchangedCount++;
 199                    if (settings.Verbose)
 200                    {
 201                        _console.MarkupLine($"[dim]  Skipped {update.DocumentName} (repository reported unchanged)[/]");
 202                    }
 203                }
 204            }
 205
 206            var completionMessage = settings.DryRun
 207                ? $"[magenta]Date-map dry run completed - {savedCount} document(s) would be saved[/]"
 208                : $"[green]Date-map apply completed - saved {savedCount} document(s)[/]";
 209            _console.MarkupLine(completionMessage);
 210            _console.MarkupLine($"[dim]Unchanged: {unchangedCount} document(s)[/]");
 211
 212            return 0;
 213        }
 214        catch (Exception ex)
 215        {
 216            _logger.LogError(ex, "Failed to apply WM26 recent-history date map");
 217            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 218            return 1;
 219        }
 220    }
 221
 222    private static HistoryDateMapApplyOptions CreateApplyOptions(Wm26RecentHistoryApplyDateMapSettings settings)
 223    {
 224        DateOnly? preserveCollectedOnOrAfter = null;
 225        if (!string.IsNullOrWhiteSpace(settings.PreserveCollectedOnOrAfter))
 226        {
 227            preserveCollectedOnOrAfter = DateOnly.ParseExact(
 228                settings.PreserveCollectedOnOrAfter.Trim(),
 229                "yyyy-MM-dd",
 230                CultureInfo.InvariantCulture);
 231        }
 232
 233        return new HistoryDateMapApplyOptions(
 234            settings.ApplyKnownOnly,
 235            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    {
 247        if (!applyOptions.PreserveCollectedOnOrAfter.HasValue || predictionRepository is null)
 248        {
 249            return Array.Empty<HistoryDateMapEntry>();
 250        }
 251
 252        var rows = HistoryCsvUtility.ExtractRowsRequiringPredictionPlayedAt(
 253            documentName,
 254            content,
 255            applyOptions.PreserveCollectedOnOrAfter.Value);
 256        if (rows.Count == 0)
 257        {
 258            return Array.Empty<HistoryDateMapEntry>();
 259        }
 260
 261        var entries = new List<HistoryDateMapEntry>();
 262        foreach (var row in rows)
 263        {
 264            var lookupKey = new PredictionLookupKey(row.HomeTeam.Trim(), row.AwayTeam.Trim());
 265            if (!predictionLookupCache.TryGetValue(lookupKey, out var match))
 266            {
 267                match = await predictionRepository.GetLatestPredictedMatchByTeamsAsync(
 268                    row.HomeTeam,
 269                    row.AwayTeam,
 270                    communityContext,
 271                    cancellationToken);
 272                predictionLookupCache[lookupKey] = match;
 273            }
 274
 275            if (match is null)
 276            {
 277                continue;
 278            }
 279
 280            entries.Add(row with { PlayedAt = FormatPlayedAt(match.StartsAt) });
 281        }
 282
 283        return entries;
 284    }
 285
 286    private static string FormatPlayedAt(ZonedDateTime startsAt)
 287    {
 288        var local = startsAt.WithZone(BerlinTimeZone);
 289        var dateTimeOffset = new DateTimeOffset(
 290            local.LocalDateTime.ToDateTimeUnspecified(),
 291            local.Offset.ToTimeSpan());
 292
 293        return dateTimeOffset.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture);
 294    }
 295
 296    private void PrintMissingEntries(IReadOnlyList<HistoryDateMapEntry> missingEntries)
 297    {
 298        _console.MarkupLine($"[red]Date map is missing exact Played_At values for {missingEntries.Count} row(s)[/]");
 299
 300        foreach (var entry in missingEntries.Take(20))
 301        {
 302            _console.MarkupLine(
 303                "[red]  Missing:[/] " +
 304                $"{entry.DocumentName} | {entry.Competition} | {entry.HomeTeam} vs {entry.AwayTeam} | " +
 305                $"{entry.Score} | {entry.Annotation}");
 306        }
 307
 308        if (missingEntries.Count > 20)
 309        {
 310            _console.MarkupLine($"[dim]  ... and {missingEntries.Count - 20} more[/]");
 311        }
 312    }
 313
 314    private void PrintMissingPredictionEntries(IReadOnlyList<HistoryDateMapEntry> missingEntries)
 315    {
 316        _console.MarkupLine($"[red]Missing stored predictions for {missingEntries.Count} unmapped WM tournament recent-h
 317
 318        foreach (var entry in missingEntries.Take(20))
 319        {
 320            _console.MarkupLine(
 321                "[red]  Missing prediction:[/] " +
 322                $"{entry.DocumentName} | {entry.Competition} | {entry.HomeTeam} vs {entry.AwayTeam} | " +
 323                $"{entry.Score} | {entry.Annotation} | collected {entry.PlayedAt}");
 324        }
 325
 326        if (missingEntries.Count > 20)
 327        {
 328            _console.MarkupLine($"[dim]  ... and {missingEntries.Count - 20} more[/]");
 329        }
 330    }
 331
 332    private static bool IsRecentHistoryDocument(string documentName)
 333    {
 334        return documentName.StartsWith("recent-history-", StringComparison.OrdinalIgnoreCase)
 335               && documentName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase);
 336    }
 337
 1338    private sealed record PlannedUpdate(
 1339        string DocumentName,
 1340        ContextDocument Document,
 1341        HistoryDateMapApplyResult Result);
 342
 343    private sealed record PredictionLookupKey(string HomeTeam, string AwayTeam);
 344}