using MeterVault.App.Analysis; using MeterVault.App.MeterEditing; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MeterVault.Integration.Tests.Editor; /// /// The meter editor's live preview (D-31): an unsaved calculation evaluated through the shared reader over the stored /// sources, with nothing written. The brief's worked example (§5.4): A = 100/80 and B = 150/120 kWh give A+B = 250/200 /// and A−B = −50/−40, and a source month that is missing makes the result's month incomplete rather than a number. /// [Collection("Timescale")] public sealed class MeterDraftPreviewTests(TimescaleFixture fx) : IAsyncLifetime { private const string BerlinId = "Europe/Berlin"; private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById(BerlinId); /// The frozen "now" of every preview here (D-01). private static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2)).ToUniversalTime(); private readonly List _meters = []; private readonly List _types = []; public Task InitializeAsync() => Task.CompletedTask; public async Task DisposeAsync() { await using var db = fx.CreateContext(); var ids = _meters.ToArray(); await db.Consumption.Where(c => ids.Contains(c.MeterId)).ExecuteDeleteAsync(); await db.Readings.Where(r => ids.Contains(r.MeterId)).ExecuteDeleteAsync(); await db.MeterLinks.Where(l => ids.Contains(l.FromMeterId) || ids.Contains(l.ToMeterId)).ExecuteDeleteAsync(); await db.Meters.Where(m => ids.Contains(m.Id)).ExecuteDeleteAsync(); var types = _types.ToArray(); await db.EnergyTypes.Where(t => types.Contains(t.Id)).ExecuteDeleteAsync(); } [Fact] public async Task An_unsaved_sum_previews_the_worked_example_without_storing_anything() { var type = await TypeAsync(); var a = await GenerationAsync(type, 100, 80); var b = await GenerationAsync(type, 150, 120); var analysis = Analysis(); var catalog = await analysis.LoadCatalogAsync(); // A new meter: Sum mode picks A and B; nothing is declared, so the kind and unit come from the sources (A-08). var draft = new MeterDraft(0, "A + B", type, MeterMode.Virtual, "kWh") { Definition = new VirtualDefinition($"m{a} + m{b}") }; var validation = MeterDraftAnalysis.Validate(catalog, draft); Assert.True(validation.IsSavable); var effective = validation.EffectiveDefinition!; Assert.Equal(QuantityKind.Generation, effective.ResultKind); Assert.Equal(VirtualCostRule.None, effective.CostRule); // a generation sum is not costed by default (A-15) var result = await analysis.PreviewAsync(MeterDraftAnalysis.Overlay(catalog, draft, effective), draft, JanFeb()); var series = result.SeriesFor(MeterDraft.NewMeterId)!; Assert.Equal(SeriesBasis.Virtual, series.Basis); Assert.Equal(QuantityKind.Generation, series.Kind); Assert.Equal("kWh", series.Unit); AssertValues(series.Values, 250, 200); AssertAvailable(series.Total, 450); Assert.True(series.IsAdditive); // Each source's own values, as the preview table shows them beside the result. var sources = series.Contributions.ToDictionary(c => c.MeterId); AssertValues(sources[a].Values, 100, 80); AssertValues(sources[b].Values, 150, 120); // Nothing was written: no meter, no link, no definition. await using var db = fx.CreateContext(); Assert.False(await db.Meters.AnyAsync(m => m.EnergyTypeId == type && m.Mode == MeterMode.Virtual)); } [Fact] public async Task An_unsaved_difference_stays_negative_and_an_edited_meter_previews_its_draft() { var type = await TypeAsync(); var a = await GenerationAsync(type, 100, 80); var b = await GenerationAsync(type, 150, 120); var existing = await VirtualAsync(type, $"m{a} + m{b}"); var analysis = Analysis(); var catalog = await analysis.LoadCatalogAsync(); // Editing the stored sum into a difference: the preview shows the draft, not what is stored. var draft = new MeterDraft(existing, "A − B", type, MeterMode.Virtual, "kWh") { Definition = new VirtualDefinition($"m{a} - m{b}") }; var effective = MeterDraftAnalysis.Validate(catalog, draft).EffectiveDefinition!; Assert.Equal(VirtualCostRule.None, effective.CostRule); var result = await analysis.PreviewAsync(MeterDraftAnalysis.Overlay(catalog, draft, effective), draft, JanFeb()); var series = result.SeriesFor(existing)!; AssertValues(series.Values, -50, -40); AssertAvailable(series.Total, -90); Assert.Equal(-1d, series.Contributions.Single(c => c.MeterId == b).Coefficient); // The stored definition is still the sum. var stored = await analysis.PreviewAsync(catalog, draft, JanFeb()); AssertValues(stored.SeriesFor(existing)!.Values, 250, 200); } [Fact] public async Task A_missing_source_month_makes_the_preview_month_incomplete_not_a_number() { var type = await TypeAsync(); var a = await GenerationAsync(type, 100, 80); var b = await GenerationAsync(type, 150); // B has January only var analysis = Analysis(); var catalog = await analysis.LoadCatalogAsync(); var draft = new MeterDraft(0, "A + B", type, MeterMode.Virtual, "kWh") { Definition = new VirtualDefinition($"m{a} + m{b}") }; var effective = MeterDraftAnalysis.Validate(catalog, draft).EffectiveDefinition!; var series = (await analysis.PreviewAsync(MeterDraftAnalysis.Overlay(catalog, draft, effective), draft, JanFeb())) .SeriesFor(MeterDraft.NewMeterId)!; AssertAvailable(series.Values[0], 250); Assert.NotEqual(BucketStatus.Available, series.Values[1].Status); Assert.NotEqual(80, series.Values[1].Value); Assert.NotEqual(BucketStatus.Available, series.Total.Status); } [Fact] public async Task A_draft_that_reads_a_calculation_reading_it_is_a_named_loop() { var type = await TypeAsync(); var a = await GenerationAsync(type, 100, 80); var first = await VirtualAsync(type, $"m{a}"); var second = await VirtualAsync(type, $"m{first}"); var catalog = await Analysis().LoadCatalogAsync(); var draft = new MeterDraft(first, "First", type, MeterMode.Virtual, "kWh") { Definition = new VirtualDefinition($"m{second} + m{a}") }; var validation = MeterDraftAnalysis.Validate(catalog, draft); Assert.False(validation.IsValid); var cycle = Assert.Single(validation.Problems, p => p.Kind == VirtualProblemKind.DependencyCycle); Assert.Equal([first, second, first], cycle.MeterIds); } [Fact] public async Task The_preview_opens_on_the_page_period_and_reaches_history_older_than_24_months() { // Brief §5.1 / D-31: "a preview for the selected historical period" — the page's period carries into the editor, // and all available history spans the sources' data however old it is (here 2022, beyond every relative preset). var type = await TypeAsync(); var a = await GenerationAsync(type, new DateOnly(2022, 1, 1), 100, 80); var b = await GenerationAsync(type, new DateOnly(2022, 1, 1), 150, 120); var analysis = Analysis(); var catalog = await analysis.LoadCatalogAsync(); var draft = new MeterDraft(0, "A + B", type, MeterMode.Virtual, "kWh") { Definition = new VirtualDefinition($"m{a} + m{b}") }; var effective = MeterDraftAnalysis.Validate(catalog, draft).EffectiveDefinition!; draft = draft with { Definition = effective }; var overlay = MeterDraftAnalysis.Overlay(catalog, draft, effective); // Without a page period the preview opens on the last 12 months — where these sources have nothing. Assert.Equal(PeriodPreset.Last12Months, VirtualPreviewPeriod.Initial(null).Period); // Opened from /meters/..?from=2022-01-01&to=2022-02-28 it shows exactly that range. var page = AnalysisQuery.Parse("?from=2022-01-01&to=2022-02-28&bucket=day&compare=none", AnalysisDefaults.History); var initial = VirtualPreviewPeriod.Initial(page); Assert.True(initial.IsCustom); var period = await VirtualPreviewPeriod.ResolveAsync(analysis, overlay, draft, initial, Now); Assert.Equal(new DateOnly(2022, 1, 1), period.FirstDay); var result = await analysis.PreviewAsync(overlay, draft, period); AssertValues(result.SeriesFor(MeterDraft.NewMeterId)!.Values, 250, 200); // All available history: the sources' own dates, and their values. var all = await VirtualPreviewPeriod.ResolveAsync(analysis, overlay, draft, initial.WithPeriod(PeriodPreset.AllHistory), Now); Assert.Equal(new DateOnly(2022, 1, 1), all.FirstDay); var whole = await analysis.PreviewAsync(overlay, draft, all); AssertAvailable(whole.SeriesFor(MeterDraft.NewMeterId)!.Total, 450); } // ------------------------------------------------------------------------------------------------ helpers private MeterDraftAnalysis Analysis() { var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId }); return new MeterDraftAnalysis(new AnalysisReader(fx, options), fx); } private static ResolvedPeriod JanFeb() => PeriodResolver.Resolve(PeriodPreset.Custom, new DateOnly(2026, 1, 1), new DateOnly(2026, 2, 28), Now, Berlin); private static void AssertAvailable(BucketValue value, double expected) { Assert.Equal(BucketStatus.Available, value.Status); Assert.Equal(expected, value.Value!.Value, 9); } private static void AssertValues(IReadOnlyList values, params double[] expected) { Assert.Equal(expected.Length, values.Count); for (var i = 0; i < expected.Length; i++) { AssertAvailable(values[i], expected[i]); } } private async Task TypeAsync() { await using var db = fx.CreateContext(); var type = new EnergyType { Key = $"editor-{Guid.NewGuid():N}", DisplayName = "Editor test", BaseUnit = "kWh", DefaultMode = MeterMode.GenerationCounter, }; db.EnergyTypes.Add(type); await db.SaveChangesAsync(); _types.Add(type.Id); return type.Id; } private async Task MeterAsync(short type, MeterMode mode, string meta = "{}", DateOnly? installedAt = null) { await using var db = fx.CreateContext(); var meter = new Meter { Name = $"editor-{Guid.NewGuid():N}", EnergyTypeId = type, Mode = mode, Unit = "kWh", InstalledAt = installedAt, Meta = meta, }; db.Meters.Add(meter); await db.SaveChangesAsync(); _meters.Add(meter.Id); return meter.Id; } /// A generation counter installed on 1 January 2026 whose months book the given amounts (read on the 1st after each). private Task GenerationAsync(short type, params double[] months) => GenerationAsync(type, new DateOnly(2026, 1, 1), months); /// A generation counter installed on (a 1st) whose months book the given amounts. private async Task GenerationAsync(short type, DateOnly first, params double[] months) { var meter = await MeterAsync(type, MeterMode.GenerationCounter, installedAt: first); var register = 0d; await using (var db = fx.CreateContext()) { for (var i = 0; i < months.Length; i++) { register += months[i]; db.Readings.Add(new Reading { MeterId = meter, Time = GapAttribution.LocalMidnight(first.AddMonths(i + 1), Berlin).ToUniversalTime(), Value = register, Quality = ReadingQuality.Manual, }); } await db.SaveChangesAsync(); } await RecomputeAsync(meter); return meter; } private async Task VirtualAsync(short type, string expression) { var meta = VirtualDefinitionJson.Write("{}", new VirtualDefinition(expression, QuantityKind.Generation, "kWh", VirtualCostRule.None)); var meter = await MeterAsync(type, MeterMode.Virtual, meta); await RecomputeAsync(meter); return meter; } private async Task RecomputeAsync(int meterId) { await using var db = fx.CreateContext(); await using var tx = await db.Database.BeginTransactionAsync(); var normalization = new NormalizationService( db, NormalizationEngine.CreateDefault(), Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId }), new FixedTimeProvider(Now)); await normalization.RecomputeMeterAsync(meterId, null); await db.SaveChangesAsync(); await tx.CommitAsync(); } }