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