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;
}
}