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