using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using MeterVault.Core.Parsing; using MeterVault.Infrastructure.Import; namespace MeterVault.Integration.Tests.Reconciliation; /// /// Shared helpers for the golden-fixture reconciliation tests. The CSVs are self-oracling: the /// same file carries both the input (registers/levels/hours) and the expected output /// (Verbrauch / Differenz Tank / Kosten columns). We parse input → normalize → compare against /// the sheet's own columns (SDD §0.3, §13). No database is involved. /// internal static class ReconciliationSupport { // Fixture file names (linked into fixtures/ in the test output). public const string Electricity = "Energiebilanz - Strom Verbrauch.csv"; public const string Water = "Energiebilanz - Wasser.csv"; public const string Oil = "Energiebilanz - Heizöl Verbrauch.csv"; public const string Costs = "Energiebilanz - Kosten.csv"; public static string FixturePath(string fileName) => Path.Combine(AppContext.BaseDirectory, "fixtures", fileName); public static List ReadRows(string fileName) { using var reader = new StreamReader(FixturePath(fileName)); return CsvImporter.ReadRows(reader); } public static StagedImport Stage(MappingProfile profile, string fileName) { using var reader = new StreamReader(FixturePath(fileName)); return new CsvImporter().Stage(profile, reader); } /// Normalizes one meter from a staged import using the given config. public static IReadOnlyList Normalize(StagedImport staged, MeterConfig config) { var engine = NormalizationEngine.CreateDefault(); var context = new NormalizationContext { Meter = config, Readings = staged.Readings.Where(r => r.MeterId == config.MeterId).ToList(), Events = staged.Events.Where(e => e.MeterId == config.MeterId).ToList(), }; return engine.Normalize(context); } /// Extracts a sheet oracle column keyed by month, using German number parsing. public static Dictionary OracleByMonth( IReadOnlyList rows, int dateColumn, int valueColumn, int firstDataRow) { var result = new Dictionary(); for (var r = firstDataRow; r < rows.Count; r++) { var row = rows[r]; if (dateColumn >= row.Length || valueColumn >= row.Length) { continue; } if (!GermanDate.TryParse(row[dateColumn], out var date)) { continue; } if (GermanNumber.TryParse(row[valueColumn], out var value)) { result[new DateOnly(date.Year, date.Month, 1)] = value; } } return result; } public static DateOnly MonthKey(DateTimeOffset time) => new(time.Year, time.Month, 1); /// /// Asserts every month present in both computed and oracle agrees within tolerance, and that /// a meaningful number of months were actually compared (so an empty result can't pass). /// public static void AssertReconciles( IReadOnlyDictionary computed, IReadOnlyDictionary oracle, double tolerance, string label, int minMatches) { var matched = 0; foreach (var (month, expected) in oracle) { if (!computed.TryGetValue(month, out var actual)) { continue; } matched++; Assert.True( Math.Abs(actual - expected) <= tolerance, $"{label} {month:yyyy-MM}: computed {actual:0.##} vs sheet {expected:0.##} (tol {tolerance})."); } Assert.True(matched >= minMatches, $"{label}: only {matched} months reconciled (expected ≥ {minMatches})."); } public static Dictionary ByMonth(IReadOnlyList series) => series.GroupBy(c => MonthKey(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount)); /// Consumption keyed by exact reading date (oil rows are event-dated, sometimes two per month). public static Dictionary ByDate(IReadOnlyList series) => series.GroupBy(c => DateOnly.FromDateTime(c.Time.UtcDateTime)) .ToDictionary(g => g.Key, g => g.Sum(c => c.Amount)); /// /// Extracts a sheet oracle column keyed by exact date, optionally transformed. Uses the same /// month-end anchoring as the importer so day-dated and month-dated rows align. /// public static Dictionary OracleByDate( IReadOnlyList rows, int dateColumn, int valueColumn, int firstDataRow, Func? transform = null, bool anchorMonthsToEnd = true) { var result = new Dictionary(); for (var r = firstDataRow; r < rows.Count; r++) { var row = rows[r]; if (dateColumn >= row.Length || valueColumn >= row.Length) { continue; } if (!ImportDate.TryResolve(row[dateColumn], anchorMonthsToEnd, out var date)) { continue; } if (GermanNumber.TryParse(row[valueColumn], out var value)) { result[date] = transform is null ? value : transform(value); } } return result; } }