using System.Text.Json; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Quantities; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; namespace MeterVault.Core.Tests.Analysis; /// /// A virtual meter's definition lives in Meter.Meta next to other keys such as role (D-25). Writing it /// must never lose those keys, the referenced ids must follow the expression rather than whatever was stored, and /// reading must never throw — a bad blob becomes a "malformed" result for that one meter. /// public sealed class VirtualDefinitionJsonTests { private static readonly VirtualDefinition SummeSolar = new("m4 + m5", QuantityKind.Generation, "kWh", VirtualCostRule.None); [Fact] public void A_definition_round_trips_and_keeps_the_role_and_every_other_key() { const string existing = """{"role":"grid_import","custom":{"a":[1,2]},"note":"keep me"}"""; var written = VirtualDefinitionJson.Write(existing, SummeSolar); var read = VirtualDefinitionJson.Read(written); Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status); Assert.Equal(SummeSolar, read.Definition); Assert.False(read.ReferencedIdsStale); Assert.Equal(MeterRoles.GridImport, MeterMeta.Role(written)); Assert.Equal("keep me", MeterMeta.ReadString(written, "note")); using var doc = JsonDocument.Parse(written); Assert.Equal("[1,2]", doc.RootElement.GetProperty("custom").GetProperty("a").GetRawText()); } [Fact] public void Written_keys_use_the_documented_tokens_and_derive_the_referenced_ids() { using var doc = JsonDocument.Parse(VirtualDefinitionJson.Write("{}", SummeSolar)); var root = doc.RootElement; Assert.Equal("m4 + m5", root.GetProperty("expression").GetString()); Assert.Equal("[4,5]", root.GetProperty("referencedMeterIds").GetRawText()); Assert.Equal("generation", root.GetProperty("resultKind").GetString()); Assert.Equal("kWh", root.GetProperty("resultUnit").GetString()); Assert.Equal("none", root.GetProperty("costRule").GetString()); using var consumption = JsonDocument.Parse(VirtualDefinitionJson.Write( "{}", new VirtualDefinition("m1 + m3", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts))); Assert.Equal("sourceCosts", consumption.RootElement.GetProperty("costRule").GetString()); } [Fact] public void Writing_a_definition_that_leaves_its_kind_or_cost_rule_to_inference_is_refused() { // A-08: what is stored is the effective definition. Storing "m4 + m5" without its kind would make every reader // take Summe Solar for consumption unless it re-validated the whole catalog first. const string meta = """{"role":"total_load"}"""; Assert.Throws(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m1 - m2"))); Assert.Throws(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m1 - m2", QuantityKind.Consumption))); Assert.Throws(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m9", QuantityKind.Runtime, "h", VirtualCostRule.None))); } [Fact] public void Saving_stores_the_inferred_kind_unit_and_cost_rule_so_readers_never_infer_them_again() { var catalog = new MeterCatalog(VirtualFixtures.SeededElectricity()); var validation = VirtualValidator.Validate(new VirtualDefinition("m4 + m5"), 6, catalog); var read = VirtualDefinitionJson.Read(VirtualDefinitionJson.Write("""{"note":"x"}""", validation.EffectiveDefinition!)); Assert.Equal(SummeSolar, read.Definition); var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, read.Definition!.DeclaredResult); Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh"), quantity); } [Fact] public void The_unit_is_stored_in_canonical_spelling_and_a_blank_one_is_left_out() { using var water = JsonDocument.Parse(VirtualDefinitionJson.Write("{}", new VirtualDefinition("m7", QuantityKind.Consumption, " m3 ", VirtualCostRule.None))); Assert.Equal("m³", water.RootElement.GetProperty("resultUnit").GetString()); using var unitless = JsonDocument.Parse(VirtualDefinitionJson.Write("""{"resultUnit":"kWh"}""", new VirtualDefinition("m7", QuantityKind.Consumption, null, VirtualCostRule.None))); Assert.False(unitless.RootElement.TryGetProperty("resultUnit", out _)); } [Theory] [InlineData("runtime")] [InlineData("export")] [InlineData("cost")] public void A_stored_kind_no_virtual_meter_may_have_is_malformed(string token) { var read = VirtualDefinitionJson.Read($$"""{"expression":"m1","resultKind":"{{token}}"}"""); Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status); Assert.Null(read.Definition!.ResultKind); } [Theory] [InlineData("expression", "LONE")] [InlineData("resultUnit", "LONEx")] [InlineData("costRule", "LONE")] public void Text_that_cannot_be_decoded_is_malformed_not_an_exception(string key, string value) { // A lone surrogate escape is valid JSON but no .NET string; jsonb refuses it, an import file does not. var escaped = value.Replace("LONE", "\\ud800", StringComparison.Ordinal); var meta = key == "expression" ? $$"""{"expression":"{{escaped}}"}""" : $$"""{"expression":"m1","{{key}}":"{{escaped}}"}"""; Assert.Equal(VirtualDefinitionReadStatus.Malformed, VirtualDefinitionJson.Read(meta).Status); } [Fact] public void Stored_referenced_ids_are_never_trusted_over_the_expression() { var read = VirtualDefinitionJson.Read("""{"expression":"m1 + m2","referencedMeterIds":[9]}"""); Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status); Assert.Equal([1, 2], read.Definition!.ReferencedMeterIds); Assert.True(read.ReferencedIdsStale); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("{}")] [InlineData("""{"role":"grid_import"}""")] [InlineData("""{"expression":null}""")] [InlineData("""{"expression":" "}""")] public void Meta_without_an_expression_is_a_legacy_meter_not_an_error(string? meta) { var read = VirtualDefinitionJson.Read(meta); Assert.Equal(VirtualDefinitionReadStatus.Absent, read.Status); Assert.Null(read.Definition); } [Theory] [InlineData("not json")] [InlineData("{\"expression\":")] [InlineData("[1,2,3]")] [InlineData("\"m1 + m2\"")] [InlineData("""{"expression":5}""")] [InlineData("""{"expression":["m1"]}""")] public void Unreadable_meta_is_malformed_and_never_throws(string meta) { var read = VirtualDefinitionJson.Read(meta); Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status); Assert.NotNull(read.Problem); } [Fact] public void Json_nested_beyond_the_reader_depth_is_malformed_not_an_exception() { var deep = """{"expression":"m1","x":""" + new string('[', 200) + new string(']', 200) + "}"; Assert.Equal(VirtualDefinitionReadStatus.Malformed, VirtualDefinitionJson.Read(deep).Status); } [Theory] [InlineData("""{"expression":"m1","resultKind":"power"}""")] [InlineData("""{"expression":"m1","resultKind":3}""")] [InlineData("""{"expression":"m1","costRule":"cheap"}""")] [InlineData("""{"expression":"m1","resultUnit":true}""")] public void A_bad_optional_key_is_malformed_but_keeps_the_expression_for_repair(string meta) { var read = VirtualDefinitionJson.Read(meta); Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status); Assert.Equal("m1", read.Definition!.Expression); } [Fact] public void A_syntax_error_is_the_validators_business_not_a_json_problem() { var read = VirtualDefinitionJson.Read("""{"expression":"m1 +"}"""); Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status); Assert.False(read.Definition!.Parsed.Success); Assert.Equal([1], read.Definition.ReferencedMeterIds); } [Fact] public void Tokens_are_read_case_insensitively() { var read = VirtualDefinitionJson.Read("""{"expression":"m1","resultKind":"Generation","costRule":"SOURCECOSTS","resultUnit":" kWh "}"""); Assert.Equal(new VirtualDefinition("m1", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts), read.Definition); } [Fact] public void Rewriting_meter_ids_follows_an_import_renumbering_and_keeps_the_role() { const string meta = """{"role":"total_load","expression":"m1 - m2","referencedMeterIds":[1,2],"resultKind":"consumption"}"""; var map = new Dictionary { [1] = 11, [2] = 12 }; var rewritten = VirtualDefinitionJson.RewriteMeterIds(meta, id => map[id]); var read = VirtualDefinitionJson.Read(rewritten); Assert.Equal("m11 - m12", read.Definition!.Expression); Assert.Equal([11, 12], read.Definition.ReferencedMeterIds); Assert.False(read.ReferencedIdsStale); Assert.Equal(QuantityKind.Consumption, read.Definition.ResultKind); Assert.Equal(MeterRoles.TotalLoad, MeterMeta.Role(rewritten)); } [Theory] [InlineData("""{"role":"grid_import"}""")] [InlineData("not json")] [InlineData("")] public void Rewriting_meter_ids_leaves_meta_without_an_expression_untouched(string meta) { Assert.Equal(meta, VirtualDefinitionJson.RewriteMeterIds(meta, _ => throw new InvalidOperationException("no ids to map"))); } [Fact] public void Removing_a_definition_keeps_every_other_key() { var withDefinition = VirtualDefinitionJson.Write("""{"role":"grid_export"}""", SummeSolar); var removed = VirtualDefinitionJson.Remove(withDefinition); Assert.Equal(VirtualDefinitionReadStatus.Absent, VirtualDefinitionJson.Read(removed).Status); Assert.Equal(MeterRoles.GridExport, MeterMeta.Role(removed)); } [Theory] [InlineData("[1,2]")] [InlineData("not json")] [InlineData("""{"a":1,"a":2}""")] public void Meta_that_is_not_a_json_object_is_replaced_on_write(string meta) { var written = VirtualDefinitionJson.Write(meta, SummeSolar); Assert.Equal(SummeSolar, VirtualDefinitionJson.Read(written).Definition); } [Fact] public void Changing_the_expression_with_a_with_expression_reparses_it() { var changed = SummeSolar with { Expression = "m7 * 2" }; Assert.Equal([7], changed.ReferencedMeterIds); Assert.Equal(Formula.Parse("m7 * 2"), changed.Formula); Assert.Equal([4, 5], SummeSolar.ReferencedMeterIds); } }