using MeterVault.Core.Analysis; using MeterVault.Infrastructure.Dashboard; namespace MeterVault.Integration.Tests.Specialized; /// /// The pure arithmetic behind the Solar view's derived figures (brief §4.3, A05): an input without data makes the bucket /// unknown while an observed zero is a valid input; partial stays partial; a total never subtracts two totals over /// different stretches of time; units are never mixed (D-20); a change needs the same buckets on both sides (D-07). /// public sealed class SolarFiguresTests { private static BucketValue Ok(double value) => BucketValue.Available(value, Provenance.Imported); private static BucketValue Part(double value) => new(value, BucketStatus.Partial, Provenance.Imported, ValueIssue.PartialCoverage); private static readonly BucketValue None = BucketValue.Missing(); [Fact] public void An_observed_zero_is_an_input_and_a_missing_value_is_not() { var zero = SolarService.Combine(Ok(300), Ok(0), -1); Assert.Equal((300d, BucketStatus.Available), (zero.Value, zero.Status)); Assert.True(zero.Provenance.HasFlag(Provenance.Derived)); var missing = SolarService.Combine(Ok(300), None, -1); Assert.Equal(((double?)null, BucketStatus.Missing, ValueIssue.MissingSource), (missing.Value, missing.Status, missing.Issue)); var neither = SolarService.Combine(None, None, -1); Assert.Equal((BucketStatus.Missing, ValueIssue.NoCoverage), (neither.Status, neither.Issue)); var partial = SolarService.Combine(Part(300), Ok(100), -1); Assert.Equal((200d, BucketStatus.Partial), (partial.Value, partial.Status)); // Being prepared or too coarse decides the status, even beside a missing input. var pending = SolarService.Combine(None, new BucketValue(null, BucketStatus.Pending, Provenance.None, ValueIssue.AnalysisPending), -1); Assert.Equal(BucketStatus.Pending, pending.Status); var coarse = SolarService.Combine(new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution), Ok(1), 1); Assert.Equal((BucketStatus.Unresolved, ValueIssue.CoarseResolution), (coarse.Status, coarse.Issue)); } [Fact] public void A_total_is_taken_over_the_buckets_both_inputs_know_unless_both_are_complete() { // Load known February and March (700), import known January to March (800): the difference of the totals would // be −100; over the months both know it is 300, partial. List load = [None, Ok(400), Ok(300)]; List import = [Ok(400), Ok(300), Ok(100)]; var a = new SolarFigure(SolarBasis.Measured, "kWh", load, Part(700)) { InputUnits = ["kWh"] }; var b = new SolarFigure(SolarBasis.Measured, "kWh", import, Ok(800)) { InputUnits = ["kWh"] }; var self = SolarService.Derive(SolarBasis.LoadMinusImport, a, b, -1); Assert.Equal([null, 100d, 200d], self.Values.Select(v => v.Value)); Assert.Equal((300d, BucketStatus.Partial), (self.Total.Value, self.Total.Status)); // Both complete: the totals decide (a monthly import resolves the period even where it does not resolve days). var whole = SolarService.CombineTotal(Ok(700), Ok(400), -1, [new BucketValue(null, BucketStatus.Unresolved, Provenance.None)]); Assert.Equal((300d, BucketStatus.Available), (whole.Value, whole.Status)); } [Fact] public void Figures_in_different_units_are_never_combined() { var generation = new SolarFigure(SolarBasis.Measured, "Wh", [Ok(5000)], Ok(5000)) { InputUnits = ["Wh"] }; var export = new SolarFigure(SolarBasis.Measured, "kWh", [Ok(1)], Ok(1)) { InputUnits = ["kWh"] }; var self = SolarService.Derive(SolarBasis.GenerationMinusExport, generation, export, -1); Assert.True(self.UnitsDiffer); Assert.Equal(["Wh", "kWh"], self.InputUnits); Assert.Equal(((double?)null, BucketStatus.Invalid), (self.Total.Value, self.Total.Status)); Assert.Equal(BucketStatus.Invalid, SolarService.Share(self, generation).Status); Assert.Equal(BucketStatus.Invalid, SolarService.Share(export, generation).Status); } [Fact] public void A_share_needs_a_positive_base_and_says_when_it_is_partial() { var part = new SolarFigure(SolarBasis.Measured, "kWh", [Ok(30), None], Part(30)) { InputUnits = ["kWh"] }; var whole = new SolarFigure(SolarBasis.Measured, "kWh", [Ok(60), Ok(40)], Ok(100)) { InputUnits = ["kWh"] }; var share = SolarService.Share(part, whole); Assert.Equal((50d, BucketStatus.Partial), (share.Value, share.Status)); var zero = new SolarFigure(SolarBasis.Measured, "kWh", [Ok(0)], Ok(0)) { InputUnits = ["kWh"] }; Assert.Equal(BucketStatus.Invalid, SolarService.Share(zero, zero).Status); } [Fact] public void A_change_compares_the_same_complete_buckets_on_both_sides() { // Complete totals: between them. Otherwise only the pairs complete on both sides (here the first). Assert.Equal(20, SolarService.MatchedChange(Ok(120), [Ok(120)], Ok(100), [Ok(100)]).Absolute); var matched = SolarService.MatchedChange(Part(150), [Ok(120), Part(30)], Ok(200), [Ok(100), Ok(100)]); Assert.Equal((20d, 20d), (matched.Absolute, matched.Percent)); Assert.False(SolarService.MatchedChange(Part(30), [None, Part(30)], Ok(200), [Ok(100), Ok(100)]).IsAvailable); Assert.False(SolarService.MatchedChange(Ok(1), [Ok(1)], null, null).IsAvailable); } }