ci / build-test (push) Successful in 2m41s
Three things a reported Heizoel page got wrong at once. Its tank is dipped a few times a year and its burner read every few months, which is exactly the shape the coverage rules had not been walked through. "No data" for data that exists. A tank books nothing until the next dipstick closes the interval, so the stretch after the last dipstick is covered by no run at all, and a bucket no run covers was reported missing. The burner, whose run reaches into the window, said "only coarser data" -- the honest answer -- so one card claimed there was nothing while the coverage panel beside it listed years of data. A bucket that no run covers, no gap overlaps and no opening balance explains now reports the meter's resolution when its preceding coverage is within one interval of its own class: it is not silent, it is read rarely. A meter that does book its own buckets and stops -- a dead hourly source, a sheet asked about a later month -- still reads missing. Auto answering twelve months with one bar. Coarse only means "longer than a month", so a dipstick taken each autumn straddles a New Year as surely as a month start: coarsening the chart to years bought nothing and cost every point. The planning resolution now caps coarse at month when a run crosses a local year edge, and a series that cannot resolve the natural size no longer coarsens the whole chart -- it is drawn at that size with its buckets marked, which the chart and table already explain. A page contradicting itself. The comparison line above the ranking was fed the leading measure's matched coverage but worded as if it spoke for the page, directly above a burner row that did compare. It now names the figure it is about. Alongside: the "largest changes" ranking no longer drops a meter whose change is not comparable. It ranks what can be ranked, then lists the rest with their values and the reason -- the tank had been vanishing from its own energy type. And every dated table now reads newest first, as lists are read; charts stay chronological left to right, and the CSV export stays ascending for spreadsheets. A-41 to A-43 in the note record the three rules.
426 lines
23 KiB
C#
426 lines
23 KiB
C#
using MeterVault.App;
|
||
using MeterVault.App.Analysis;
|
||
using MeterVault.App.Energy;
|
||
using MeterVault.Core.Analysis;
|
||
using MeterVault.Core.Analysis.Coverage;
|
||
using MeterVault.Core.Analysis.Totals;
|
||
using MeterVault.Core.Domain;
|
||
using MeterVault.Infrastructure.Analysis;
|
||
using MeterVault.Infrastructure.Dashboard;
|
||
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
|
||
|
||
namespace MeterVault.Integration.Tests.Analysis;
|
||
|
||
/// <summary>
|
||
/// The energy type page and the meter lists without a database (brief §7.3): the tab and view keys, which metrics a type
|
||
/// charts, how a meter counts in words (a breakdown names its parent, a calculated view its sources), meter rows that say
|
||
/// "No data" instead of a made-up zero, the largest changes over matched coverage, the flow table's words, and the
|
||
/// connection rules — no meter into itself, no duplicate, no loop, no silent change to a calculation.
|
||
/// </summary>
|
||
public sealed class EnergyPageTests
|
||
{
|
||
// ------------------------------------------------------------------------------------------------ keys
|
||
|
||
[Theory]
|
||
[InlineData("http://x/energy/3", "overview", "total")]
|
||
[InlineData("http://x/energy/3?tab=history", "history", "total")]
|
||
[InlineData("http://x/energy/3?tab=HISTORY&view=Meters", "history", "meters")]
|
||
[InlineData("http://x/energy/3?tab=flow&period=ytd", "flow", "total")]
|
||
[InlineData("http://x/energy/3?tab=meters&view=bogus", "meters", "total")]
|
||
[InlineData("http://x/energy/3?tab=sankey", "overview", "total")]
|
||
[InlineData("/energy/3?view=meters#top", "overview", "meters")]
|
||
public void Tab_and_view_keys_resolve_with_fallbacks(string uri, string tab, string view)
|
||
{
|
||
var (resolvedTab, resolvedView) = EnergyPageKeys.Parse(uri);
|
||
|
||
Assert.Equal(tab, resolvedTab);
|
||
Assert.Equal(view, resolvedView);
|
||
Assert.Equal(AnalysisLinks.EnergyTabs.ToList().IndexOf(tab), AnalysisLinks.EnergyTabIndex(resolvedTab));
|
||
}
|
||
|
||
[Fact]
|
||
public void The_tab_and_view_are_not_part_of_the_analysis_state()
|
||
{
|
||
// A tab or view switch must not reload the analysis (D-46): the parsed query is the same with or without them.
|
||
var defaults = AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(3));
|
||
var plain = AnalysisQuery.Parse("/energy/3?period=ytd", defaults);
|
||
var tabbed = AnalysisQuery.Parse("/energy/3?tab=history&view=meters&period=ytd", defaults);
|
||
|
||
Assert.Equal(plain, tabbed);
|
||
Assert.Equal("/energy/3?tab=history&period=ytd", AnalysisLinks.EnergyType(3, AnalysisLinks.EnergyTabHistory, plain));
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------ metrics
|
||
|
||
[Fact]
|
||
public void A_type_charts_the_metrics_of_its_measures_and_meters_and_its_cost()
|
||
{
|
||
var buckets = Buckets(D(2025, 1, 1), D(2025, 3, 31));
|
||
var use = Measure(TotalsMeasure.Use, QuantityKind.Consumption, buckets);
|
||
var generation = Measure(TotalsMeasure.Generation, QuantityKind.Generation, buckets);
|
||
var net = Series(9, "Balance", [Available(1), Available(-2), Available(3)], kind: QuantityKind.Net);
|
||
var result = Result(buckets, [net], [generation, use]);
|
||
|
||
var metrics = EnergyMetrics.Available(result, cost: null);
|
||
|
||
Assert.Equal([AnalysisMetric.Consumption, AnalysisMetric.Generation, AnalysisMetric.Net], metrics);
|
||
Assert.Equal(AnalysisMetric.Consumption, EnergyMetrics.Effective(null, metrics));
|
||
Assert.Equal(AnalysisMetric.Generation, EnergyMetrics.Effective(AnalysisMetric.Generation, metrics));
|
||
Assert.Equal(AnalysisMetric.Consumption, EnergyMetrics.Effective(AnalysisMetric.Balance, metrics));
|
||
Assert.Equal([use], EnergyMetrics.MeasuresOf(result, AnalysisMetric.Consumption));
|
||
Assert.Equal([net], EnergyMetrics.MetersOf(result, AnalysisMetric.Net));
|
||
Assert.Empty(EnergyMetrics.MeasuresOf(result, AnalysisMetric.Net));
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------ memberships
|
||
|
||
[Fact]
|
||
public void A_breakdown_names_its_parent_and_a_calculated_view_its_sources() => In("en", () =>
|
||
{
|
||
var names = new Dictionary<int, string> { [1] = "Haus", [4] = "Solar 1", [5] = "Solar 2" };
|
||
string Name(int id) => names[id];
|
||
|
||
var breakdown = MeterMembership.Of(Entry(3, MeterTotalsClass.Breakdown, MeterTotalsReason.ContainedByLink, parents: [1]), null, Name)!;
|
||
Assert.Equal("Breakdown of a counted meter", breakdown.Label);
|
||
Assert.Equal("Part of Haus: shown, but never added on top.", breakdown.Detail);
|
||
Assert.False(breakdown.IsCounted);
|
||
|
||
var sum = Series(9, "Summe Solar", [Available(1)], kind: QuantityKind.Generation) with
|
||
{
|
||
Virtual = new VirtualSeriesInfo(VirtualMeterStatus.Valid, "m4 + m5", MeterVault.Core.Analysis.Virtual.VirtualCostRule.None, [4, 5], [4, 5], [], null, null),
|
||
};
|
||
var view = MeterMembership.Of(Entry(9, MeterTotalsClass.AnalysisOnly, MeterTotalsReason.VirtualView), sum, Name)!;
|
||
Assert.Equal("Analysis only", view.Label);
|
||
Assert.Contains("Solar 1, Solar 2", view.Detail, StringComparison.Ordinal);
|
||
|
||
var grid = MeterMembership.Of(Entry(2, MeterTotalsClass.GridImport, MeterTotalsReason.GridImportRole, measure: TotalsMeasure.GridImport), null, Name)!;
|
||
Assert.True(grid.IsCounted);
|
||
Assert.Contains("never added", grid.Detail, StringComparison.Ordinal);
|
||
|
||
Assert.Null(MeterMembership.Of(null, null, Name));
|
||
});
|
||
|
||
[Fact]
|
||
public void Membership_wording_is_german_in_german() => In("de", () =>
|
||
{
|
||
var membership = MeterMembership.Of(Entry(3, MeterTotalsClass.Breakdown, MeterTotalsReason.ContainedByLink, parents: [1]), null, _ => "Zähler Haus")!;
|
||
|
||
Assert.Equal("Aufschlüsselung eines gezählten Zählers", membership.Label);
|
||
Assert.Equal("Teil von Zähler Haus: wird gezeigt, aber nie zusätzlich addiert.", membership.Detail);
|
||
});
|
||
|
||
// ------------------------------------------------------------------------------------------------ meter rows
|
||
|
||
[Fact]
|
||
public void A_meter_without_data_reads_no_data_and_a_measured_zero_reads_zero() => In("en", () =>
|
||
{
|
||
var buckets = Buckets(D(2025, 1, 1), D(2025, 2, 28));
|
||
var silent = Series(1, "Silent", [Missing(), Missing()], BucketValue.Missing());
|
||
var idle = Series(2, "Idle", [Available(0), Available(0)], Available(0));
|
||
var result = Result(buckets, [silent, idle], []) with
|
||
{
|
||
Classification =
|
||
[
|
||
new MeterClassification(1, "Silent", Entry(1, MeterTotalsClass.Use, MeterTotalsReason.ConsumptionRoot, measure: TotalsMeasure.Use)),
|
||
new MeterClassification(2, "Idle", Entry(2, MeterTotalsClass.Breakdown, MeterTotalsReason.ContainedByLink, parents: [1])),
|
||
],
|
||
};
|
||
|
||
var rows = MeterListRows.Build([Facts(2, "Idle"), Facts(1, "Silent"), Facts(3, "Added later")], result);
|
||
|
||
Assert.Equal(["Added later", "Idle", "Silent"], rows.Select(r => r.Meter.Name));
|
||
var silentRow = rows.Single(r => r.Meter.Id == 1);
|
||
Assert.Equal("No data", silentRow.ValueText);
|
||
Assert.False(silentRow.HasValue);
|
||
Assert.Equal("No data covers this period", silentRow.QualityDetail);
|
||
|
||
var idleRow = rows.Single(r => r.Meter.Id == 2);
|
||
Assert.Equal("0 kWh", idleRow.ValueText);
|
||
Assert.True(idleRow.HasValue);
|
||
Assert.Equal("Part of Silent: shown, but never added on top.", idleRow.Membership!.Detail);
|
||
|
||
// A meter the read did not cover has no figure at all, not a zero.
|
||
var later = rows.Single(r => r.Meter.Id == 3);
|
||
Assert.Equal(Format.Unknown, later.ValueText);
|
||
Assert.Null(later.Membership);
|
||
});
|
||
|
||
[Fact]
|
||
public void Meter_rows_search_names_serials_types_modes_and_membership() => In("en", () =>
|
||
{
|
||
var row = new MeterListRow(Facts(1, "Zähler Haus") with { SerialNumber = "SN-77", Location = "Keller" }, null,
|
||
new MeterMembership(MeterTotalsClass.Breakdown, "Breakdown of a counted meter", string.Empty), null);
|
||
|
||
Assert.True(MeterListRows.Matches(row, null));
|
||
Assert.True(MeterListRows.Matches(row, " haus "));
|
||
Assert.True(MeterListRows.Matches(row, "sn-7"));
|
||
Assert.True(MeterListRows.Matches(row, "keller"));
|
||
Assert.True(MeterListRows.Matches(row, "strom"));
|
||
Assert.True(MeterListRows.Matches(row, "cumulative"));
|
||
Assert.True(MeterListRows.Matches(row, "breakdown"));
|
||
Assert.False(MeterListRows.Matches(row, "wasser"));
|
||
});
|
||
|
||
[Fact]
|
||
public void Quick_entry_is_a_reading_a_tank_level_or_nothing_for_a_calculation()
|
||
{
|
||
Assert.Equal(MeterLinks.QuickEntry(1, MeterMode.CumulativeCounter), MeterListRows.QuickEntry(Facts(1, "A"))!.Value.Href);
|
||
Assert.True(MeterListRows.QuickEntry(Facts(2, "Tank") with { Mode = MeterMode.ConsumableBalance })!.Value.IsTank);
|
||
Assert.Null(MeterListRows.QuickEntry(Facts(3, "Sum") with { Mode = MeterMode.Virtual }));
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------ changes
|
||
|
||
/// <summary>The coverage of a whole January that both periods share.</summary>
|
||
private static readonly MatchedCoverageResult Matched = new(
|
||
new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)),
|
||
new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)),
|
||
[new MatchedPiece(new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)), new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)))]);
|
||
|
||
/// <summary>A meter that changed from <paramref name="previous"/> to <paramref name="current"/> over that coverage.</summary>
|
||
private static AnalysisSeries Changed(int id, double current, double previous) =>
|
||
Series(id, $"M{id}", [Available(current)]) with
|
||
{
|
||
Comparison = new SeriesComparison([Available(previous)], Available(previous), Matched, current, previous, Change.Between(current, previous)),
|
||
};
|
||
|
||
[Fact]
|
||
public void Largest_changes_use_matched_values_and_skip_what_is_not_comparable()
|
||
{
|
||
var notComparable = Series(9, "Not comparable", [Available(5)]) with
|
||
{
|
||
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||
};
|
||
|
||
var changes = MeterChanges.Largest([Changed(1, 100, 90), Changed(2, 50, 150), Changed(3, 10, 10), notComparable], 2);
|
||
|
||
Assert.Equal([2, 1], changes.Select(c => c.Series.MeterId!.Value));
|
||
Assert.Equal(-100, changes[0].Change.Absolute!.Value, 6);
|
||
Assert.Equal(50, changes[0].Current, 6);
|
||
Assert.Equal(150, changes[0].Previous, 6);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_meter_with_data_but_no_comparable_change_is_listed_with_its_value_and_its_reason() => In("en", () =>
|
||
{
|
||
// The reported case (A-43): a Heizöl type whose burner has hours in both periods and whose tank was read by
|
||
// dipstick only in this one. The burner ranks; the tank must still be on the page with its litres and the
|
||
// status that says why there is no change — not silently dropped.
|
||
var burner = Changed(8, 929, 986) with { Name = "Brenner" };
|
||
var tank = Series(7, "Öltank", [Available(2100)], total: Available(2100), unit: "L") with
|
||
{
|
||
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||
};
|
||
|
||
var list = MeterChanges.Of([burner, tank], 6);
|
||
|
||
Assert.Equal([8], list.Ranked.Select(c => c.Series.MeterId!.Value));
|
||
var row = Assert.Single(list.Rest);
|
||
Assert.Equal(7, row.Series.MeterId);
|
||
Assert.Equal(2100, row.Current.Value);
|
||
Assert.Equal(BucketStatus.Missing, row.Comparison.Status);
|
||
Assert.False(row.Change.IsAvailable);
|
||
|
||
// The reason is the reader's own wording, not new prose: the comparison side simply has no data.
|
||
Assert.Equal("No data", FigureText.Of(row.Comparison).Status);
|
||
Assert.False(row.SharesNoDays);
|
||
Assert.Equal(2, list.Shown);
|
||
Assert.Equal(0, list.Hidden);
|
||
Assert.False(list.IsEmpty);
|
||
});
|
||
|
||
[Fact]
|
||
public void A_meter_without_data_in_either_period_is_not_listed_at_all() => In("en", () =>
|
||
{
|
||
var silent = Series(4, "Never read", [Missing()], total: Missing()) with
|
||
{
|
||
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||
};
|
||
var onlyLastYear = Series(5, "Retired", [Missing()], total: Missing()) with
|
||
{
|
||
Comparison = new SeriesComparison([Available(40)], Available(40), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||
};
|
||
var coarser = Series(6, "Monthly only", [Missing()], total: new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution));
|
||
|
||
var list = MeterChanges.Of([silent, onlyLastYear, coarser], 6);
|
||
|
||
Assert.Empty(list.Ranked);
|
||
Assert.Equal([6, 5], list.Rest.Select(r => r.Series.MeterId!.Value)); // by name: "Monthly only", "Retired"
|
||
Assert.Equal("Only coarser data", FigureText.Of(list.Rest[0].Current).Status);
|
||
Assert.Equal(40, list.Rest[1].Comparison.Value);
|
||
Assert.DoesNotContain(list.Rest, r => r.Series.MeterId == 4);
|
||
|
||
// Nothing at all to say: the panel is empty, and the page says so in one line instead of an empty table.
|
||
Assert.True(MeterChanges.Of([silent], 6).IsEmpty);
|
||
});
|
||
|
||
[Fact]
|
||
public void Two_known_values_over_no_shared_days_are_listed_as_not_comparable()
|
||
{
|
||
var series = Series(7, "Öltank", [Available(2100)], total: Available(2100), unit: "L") with
|
||
{
|
||
Comparison = new SeriesComparison(
|
||
[Available(1800)], Available(1800), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||
};
|
||
|
||
var row = Assert.Single(MeterChanges.Of([series], 6).Rest);
|
||
|
||
Assert.True(row.SharesNoDays);
|
||
Assert.Equal((2100d, 1800d), (row.Current.Value, row.Comparison.Value));
|
||
Assert.False(row.Change.IsAvailable);
|
||
}
|
||
|
||
[Fact]
|
||
public void Neither_list_crowds_the_other_out_and_what_is_left_over_is_counted()
|
||
{
|
||
AnalysisSeries Quiet(int id) => Series(id, $"Q{id}", [Available(id)], total: Available(id)) with
|
||
{
|
||
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
|
||
};
|
||
|
||
var list = MeterChanges.Of([.. Enumerable.Range(1, 5).Select(i => Changed(i, i * 10, i)), .. Enumerable.Range(10, 5).Select(Quiet)], 2);
|
||
|
||
// Two ranked and two unranked rows: a long ranking never takes the room of the meters that have no change,
|
||
// and the meters it pushed past its own cap queue behind them.
|
||
Assert.Equal([5, 4], list.Ranked.Select(c => c.Series.MeterId!.Value));
|
||
Assert.Equal([10, 11], list.Rest.Select(r => r.Series.MeterId!.Value));
|
||
Assert.Equal(6, list.Hidden);
|
||
Assert.Equal(4, list.Shown);
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------ flow words
|
||
|
||
[Fact]
|
||
public void The_flow_table_words_every_ribbon_and_every_meter_it_does_not_draw() => In("en", () =>
|
||
{
|
||
var graph = new FlowGraph(1, "Strom", "kWh", 100, [
|
||
new FlowNode("m1", "Haus", 100, 0, null, false, 1),
|
||
new FlowNode("m2", "Auto", 30, 1, null, false, 2),
|
||
new FlowNode("other1", "Haus", 70, 1, null, true, null),
|
||
], [
|
||
new FlowLink("m1", "m2", 30),
|
||
new FlowLink("m1", "other1", 70),
|
||
new FlowLink("m4", "m9", 10) { IsCalculated = true },
|
||
new FlowLink("m3", "m1", 5) { IsEstimated = true },
|
||
new FlowLink("m3", "m2", 5) { IsEstimated = true, IsCapped = true },
|
||
]);
|
||
|
||
Assert.Equal("Other (Haus)", FlowText.NodeName(graph, "other1"));
|
||
Assert.Equal("Measured part", FlowText.EdgeKind(graph, graph.Links[0]));
|
||
Assert.Equal("Not measured by a meter below it", FlowText.EdgeKind(graph, graph.Links[1]));
|
||
Assert.Equal("Input of a calculated sum", FlowText.EdgeKind(graph, graph.Links[2]));
|
||
Assert.StartsWith("Estimated share", FlowText.EdgeKind(graph, graph.Links[3]), StringComparison.Ordinal);
|
||
Assert.StartsWith("Estimated, capped", FlowText.EdgeKind(graph, graph.Links[4]), StringComparison.Ordinal);
|
||
|
||
var water = new FlowMeter(7, "Water", 7, BucketStatus.Available, ValueIssue.None, QuantityKind.Consumption, "m³", SeriesBasis.Physical, false);
|
||
var difference = new FlowMeter(8, "A − B", -50, BucketStatus.Available, ValueIssue.None, QuantityKind.Net, "kWh", SeriesBasis.Virtual, false);
|
||
var silent = new FlowMeter(9, "Silent", null, BucketStatus.Missing, ValueIssue.NoCoverage, QuantityKind.Consumption, "kWh", SeriesBasis.Physical, false);
|
||
Assert.Equal("Not drawn: measured in m³", FlowText.NotDrawnReason(graph, water));
|
||
Assert.Equal("Not drawn: a calculation that is not a plain sum", FlowText.NotDrawnReason(graph, difference));
|
||
Assert.Null(FlowText.NotDrawnReason(graph, water with { InDiagram = true }));
|
||
|
||
// Signed stays signed, and a meter without a number says so instead of "0".
|
||
Assert.Equal("-50 kWh", FlowText.MeterValue(difference));
|
||
Assert.Equal("No data", FlowText.MeterValue(silent));
|
||
});
|
||
|
||
// ------------------------------------------------------------------------------------------------ connection rules
|
||
|
||
[Fact]
|
||
public void A_connection_is_refused_into_itself_across_types_twice_or_around_a_loop()
|
||
{
|
||
var a = Link(1, "A");
|
||
var b = Link(2, "B");
|
||
var c = Link(3, "C");
|
||
var foreign = Link(4, "Foreign") with { EnergyTypeId = 2 };
|
||
(int, int)[] links = [(1, 2), (2, 3)];
|
||
|
||
Assert.Equal(MeterLinkRefusal.SameMeter, MeterLinkRules.CheckAdd(a, a, links, 1, 1).Refusal);
|
||
Assert.Equal(MeterLinkRefusal.UnknownMeter, MeterLinkRules.CheckAdd(a, null, links, 1, 99).Refusal);
|
||
Assert.Equal(MeterLinkRefusal.OtherEnergyType, MeterLinkRules.CheckAdd(a, foreign, links, 1, 4).Refusal);
|
||
Assert.Equal(MeterLinkRefusal.AlreadyLinked, MeterLinkRules.CheckAdd(a, b, links, 1, 2).Refusal);
|
||
|
||
// C → A would close A → B → C → A: refused, with the existing path from A to C.
|
||
var loop = MeterLinkRules.CheckAdd(c, a, links, 3, 1);
|
||
Assert.Equal(MeterLinkRefusal.WouldCreateCycle, loop.Refusal);
|
||
Assert.Equal([1, 2, 3], loop.Path);
|
||
Assert.Equal(MeterLinkRefusal.WouldCreateCycle, MeterLinkRules.CheckAdd(b, a, links, 2, 1).Refusal);
|
||
|
||
// A shortcut in the same direction is no loop.
|
||
Assert.True(MeterLinkRules.CheckAdd(a, c, links, 1, 3).IsAllowed);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_virtual_meter_calculated_from_its_links_keeps_them()
|
||
{
|
||
var source = Link(1, "Solar 1");
|
||
var legacy = Link(9, "Legacy sum") with { Mode = MeterMode.Virtual, CalculatedFromLinks = true };
|
||
var defined = Link(10, "Summe Solar") with { Mode = MeterMode.Virtual, ReferencedMeterIds = [1] };
|
||
|
||
Assert.Equal(MeterLinkRefusal.CalculatedFromLinks, MeterLinkRules.CheckAdd(source, legacy, [], 1, 9).Refusal);
|
||
Assert.Equal(MeterLinkRefusal.CalculatedFromLinks, MeterLinkRules.CheckRemove(legacy).Refusal);
|
||
|
||
// A stored calculation is the formula's, never the links': those stay free to edit.
|
||
Assert.True(MeterLinkRules.CheckAdd(source, defined, [], 1, 10).IsAllowed);
|
||
Assert.True(MeterLinkRules.CheckRemove(defined).IsAllowed);
|
||
|
||
var topology = new MeterLinkTopology(1, [source, defined], [new MeterLinkEntry(5, 1, 10)], new Dictionary<int, MeterLinkMeter>(), [(1, 10)]);
|
||
Assert.True(topology.MirrorsCalculation(topology.Links[0]));
|
||
}
|
||
|
||
[Fact]
|
||
public void A_refusal_is_worded_with_the_meters_named() => In("en", () =>
|
||
{
|
||
var names = new Dictionary<int, string> { [1] = "Haus", [2] = "Auto", [3] = "Wallbox" };
|
||
var loop = new MeterLinkCheck(MeterLinkRefusal.WouldCreateCycle, [1, 2, 3]);
|
||
|
||
Assert.Equal("This would make a loop: Haus → Auto → Wallbox → Haus.", FlowText.Refusal(loop, id => names[id], 1));
|
||
Assert.Contains("Wallbox is calculated from its connections", FlowText.Refusal(MeterLinkCheck.Refused(MeterLinkRefusal.CalculatedFromLinks), id => names[id], 3), StringComparison.Ordinal);
|
||
Assert.Equal(string.Empty, FlowText.Refusal(MeterLinkCheck.Allowed, id => names[id], 1));
|
||
});
|
||
|
||
[Fact]
|
||
public void Describing_a_meter_reads_whether_its_calculation_is_stored()
|
||
{
|
||
var legacy = new Meter { Id = 9, Name = "Legacy", EnergyTypeId = 1, Mode = MeterMode.Virtual, Unit = "kWh", Meta = "{}" };
|
||
var defined = new Meter
|
||
{
|
||
Id = 10,
|
||
Name = "Sum",
|
||
EnergyTypeId = 1,
|
||
Mode = MeterMode.Virtual,
|
||
Unit = "kWh",
|
||
Meta = MeterVault.Core.Analysis.Virtual.VirtualDefinitionJson.Write(
|
||
"{}", new MeterVault.Core.Analysis.Virtual.VirtualDefinition("m4 + m5", QuantityKind.Generation, "kWh", MeterVault.Core.Analysis.Virtual.VirtualCostRule.None)),
|
||
};
|
||
var physical = new Meter { Id = 4, Name = "Solar 1", EnergyTypeId = 1, Mode = MeterMode.GenerationCounter, Unit = "kWh", Meta = "{}" };
|
||
|
||
Assert.True(MeterLinkRules.Describe(legacy).CalculatedFromLinks);
|
||
Assert.False(MeterLinkRules.Describe(defined).CalculatedFromLinks);
|
||
Assert.Equal([4, 5], MeterLinkRules.Describe(defined).ReferencedMeterIds);
|
||
Assert.False(MeterLinkRules.Describe(physical).CalculatedFromLinks);
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------ helpers
|
||
|
||
private static AnalysisSeries Measure(TotalsMeasure measure, QuantityKind kind, IReadOnlyList<AnalysisBucket> buckets) =>
|
||
new(SeriesKey.ForMeasure(1, measure, "kWh"), string.Empty, SeriesBasis.Measure, kind, "kWh",
|
||
[.. buckets.Select(_ => Available(1))], Available(buckets.Count), IsAdditive: true);
|
||
|
||
private static AnalysisResult Result(IReadOnlyList<AnalysisBucket> buckets, IReadOnlyList<AnalysisSeries> series, IReadOnlyList<AnalysisSeries> measures)
|
||
{
|
||
var period = Range(buckets[0].FirstDay, buckets[^1].EndDay.AddDays(-1));
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
return new AnalysisResult(new AnalysisRequest(AnalysisScope.ForEnergyType(1), period), plan, series, measures, ScopeAvailability.None, []);
|
||
}
|
||
|
||
private static MeterTotalsEntry Entry(
|
||
int meterId, MeterTotalsClass @class, MeterTotalsReason reason, TotalsMeasure? measure = null, IReadOnlyList<int>? parents = null) =>
|
||
new(meterId, 1, @class, reason, @class, measure, parents ?? [], [], null, null);
|
||
|
||
private static MeterFacts Facts(int id, string name) =>
|
||
new(id, name, 1, "Strom", MeterMode.CumulativeCounter, "kWh", IsActive: true, null, null, HasTank: false);
|
||
|
||
private static MeterLinkMeter Link(int id, string name) => new(id, name, 1, MeterMode.CumulativeCounter, true, false, []);
|
||
}
|