using Evergreen.Health.Application.Health.Integrations.Ib43; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using System.Text.Json; using Xunit; namespace Admin.NET.Test.Health; public sealed class Ib43Tests { private static Ib43Binding Binding() => new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001" }; private static string Report(string fields) => "{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\"," + fields + "}}"; private static Ib43Report Parse(string fields) => new Ib43Parser().Parse(Binding().PostTopic, Report(fields), Binding(), DateTimeOffset.UtcNow); private sealed class Clock : TimeProvider { public DateTimeOffset Now = DateTimeOffset.UtcNow; public override DateTimeOffset GetUtcNow() => Now; } private static Ib43StateStore Store(Clock clock) => new(Options.Create(new Ib43Options { Devices = [Binding()] }), new(), clock); [Theory] [InlineData("startup.pdf-example.json", "startup")] [InlineData("realtime.pdf-example.json", "periodic")] [InlineData("sleep-event.pdf-keys.json", "event")] public void Archived_supplier_examples_parse_without_issues(string file, string kind) { var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Ib43Examples", file)); using var doc = JsonDocument.Parse(json); var p = doc.RootElement.GetProperty("params"); var binding = new Ib43Binding { SerialNumber = p.GetProperty("deviceName").GetString(), ProductKey = p.TryGetProperty("productKey", out var pk) ? pk.GetString() : "example" }; var report = new Ib43Parser().Parse(binding.PostTopic, json, binding, DateTimeOffset.UtcNow); Assert.True(report.Accepted); Assert.Empty(report.Issues); Assert.Contains(kind, report.Kinds); if (kind == "startup") Assert.Equal(60, report.Fields["characteristic"].GetProperty("reportIerVal").GetInt32()); if (kind == "periodic") Assert.Equal(-52, report.Fields["airbagsPressure"][3].GetInt32()); if (kind == "event") Assert.Equal(80d, report.Readings["right.heartRate"].Value); } [Fact] public void Periodic_HR_uses_confirmed_order_and_does_not_infer_presence() { var r = Parse("\"HR\":[72,16,1,80,18,0],\"snoreStatus\":[3,9],\"airbagsPerson\":[1,0]"); Assert.Equal(72d, r.Readings["left.heartRate"].Value); Assert.Equal(18d, r.Readings["right.respiration"].Value); Assert.Equal(1d, r.Readings["left.snoring"].Value); Assert.Equal(0d, r.Readings["right.snoring"].Value); Assert.DoesNotContain("left.presence", r.Readings.Keys); } [Theory] [InlineData("SleepReportNew", "ibNew")] [InlineData(" SleepReportNew", " ibNew")] [InlineData(" SleepReportNew", "ibNew")] public void Event_has_its_own_order_and_preserves_sides(string outer, string inner) { var r = Parse($"\"{outer}\":{{\"{inner}\":[1,0,0,16,74,0,0,1,0,17,80,0]}}"); Assert.Equal(74d, r.Readings["left.heartRate"].Value); Assert.Equal(17d, r.Readings["right.respiration"].Value); Assert.Equal(1d, r.Readings["left.presence"].Value); Assert.Equal(0d, r.Readings["right.presence"].Value); } [Fact] public void Partial_updates_preserve_presence_but_zero_clears_previous_vitals() { var clock = new Clock(); var store = Store(clock); store.Receive(Binding().PostTopic, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}")); var first = store.Snapshot()[0].Readings["left.presence"].ReceivedAt; clock.Now += TimeSpan.FromSeconds(5); store.Receive(Binding().PostTopic, Report("\"HR\":[0,0,0,80,18,1]")); var state = store.Snapshot()[0]; Assert.Equal(1d, state.Readings["left.presence"].Value); Assert.Equal(first, state.Readings["left.presence"].ReceivedAt); Assert.Null(state.Readings["left.heartRate"].Value); Assert.Equal(80d, state.Readings["right.heartRate"].Value); } [Theory] [InlineData("\"HR\":[1,2]", "HR:invalid-length")] [InlineData("\"HR\":[72,16,0,80,\"18\",0]", "HR:invalid-number")] [InlineData("\"HR\":[72,16,4,80,18,0]", "left.snoring:invalid-enum")] [InlineData("\"motorStatus\":[0,0,0,9]", "motorStatus:invalid-enum")] [InlineData("\"SleepReportNew\":{},\" SleepReportNew\":{}", "SleepReportNew:ambiguous-alias")] [InlineData("\"SleepReportNew\":{\"ibNew\":[],\" ibNew\":[]}", "ibNew:ambiguous-alias")] public void Malformed_fields_record_issues(string fields, string issue) => Assert.Contains(issue, Parse(fields).Issues); [Theory] [InlineData("{", "invalid-json")] [InlineData("[]", "invalid-envelope")] [InlineData("{\"method\":\"x\",\"params\":{}}", "invalid-envelope")] [InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"other\"}}", "identity-mismatch")] [InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"productKey\":\"other\"}}", "identity-mismatch")] [InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"HR\":[],\"HR\":[]}}", "duplicate-json-key")] public void Rejects_invalid_envelopes_and_identity(string raw, string issue) { var r = new Ib43Parser().Parse(Binding().PostTopic, raw, Binding(), DateTimeOffset.UtcNow); Assert.False(r.Accepted); Assert.Contains(issue, r.Issues); Assert.Empty(r.Readings); } [Fact] public void Old_events_are_not_erased_by_timeout_and_transport_is_independent() { var clock = new Clock(); var store = Store(clock); Assert.Equal("unknown", store.Snapshot()[0].Freshness); store.SetTransport("connected"); store.Receive(Binding().PostTopic, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}")); clock.Now += TimeSpan.FromSeconds(151); store.Receive(Binding().PostTopic, Report("\"HR\":[0]")); store.Receive(Binding().PostTopic, Report("\"firmwareVer\":\"new\"")); var snapshot = store.Snapshot()[0]; Assert.Equal("stale", snapshot.Freshness); Assert.Equal("connected", snapshot.Transport); Assert.Equal(1d, snapshot.Readings["left.presence"].Value); store.SetTransport("disconnected"); Assert.Equal("disconnected", store.Snapshot()[0].Transport); } [Fact] public void Unbound_retained_and_oversized_reports_never_update_live_state_and_history_is_bounded() { var store = Store(new()); var payload = Report("\"HR\":[72,16,0,80,18,0]"); Assert.False(store.Receive("/sys/other/IB43SIM001/thing/property/post", payload).Accepted); Assert.False(store.Receive(Binding().PostTopic, payload, true).Accepted); Assert.False(store.Receive(Binding().PostTopic, new string('x', 65537)).Accepted); Assert.Equal("unknown", store.Snapshot()[0].Freshness); for (var i = 0; i < 100; i++) store.Receive(Binding().PostTopic, "bad-json"); Assert.Equal(32, store.Diagnostics().Length); Assert.Equal("bad-json", store.Diagnostics()[0].Raw); } [Theory] [InlineData(0, 1, 0)] [InlineData(1, 0, 1)] public void Presence_mapping_requires_explicit_configuration(int index, int left, int right) { var binding = Binding(); binding.PersonLeftIndex = index; var r = new Ib43Parser().Parse(binding.PostTopic, Report("\"airbagsPerson\":[1,0]"), binding, DateTimeOffset.UtcNow); Assert.Equal((double)left, r.Readings["left.presence"].Value); Assert.Equal((double)right, r.Readings["right.presence"].Value); } [Fact] public void Enabled_configuration_requires_credentials_and_exact_unique_topics() { var options = new Ib43Options(); Assert.True(options.IsValid()); options.Enabled = true; Assert.False(options.IsValid()); options.Password = "test-only"; options.Devices = [Binding()]; Assert.True(options.IsValid()); options.Devices.Add(Binding()); Assert.False(options.IsValid()); options.Devices = [new() { ProductKey = "+", SerialNumber = "#" }]; Assert.False(options.IsValid()); } [Fact] public async Task Disabled_service_never_creates_connection() { var factory = new FakeFactory(); using var service = new Ib43MqttService(Options.Create(new Ib43Options()), factory, Store(new()), TimeProvider.System, NullLogger.Instance); await service.StartAsync(default); await service.StopAsync(default); Assert.Equal(0, factory.Count); } [Fact] public async Task Service_retries_failed_subscription_then_receives_and_stops() { var factory = new FakeFactory(); var store = Store(new()); using var service = new Ib43MqttService(Options.Create(new Ib43Options { Enabled = true, Password = "test-only", Devices = [Binding()] }), factory, store, TimeProvider.System, NullLogger.Instance); await service.StartAsync(default); try { await factory.Connected.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(2, factory.Count); Assert.Equal(72d, store.Snapshot()[0].Readings["left.heartRate"].Value); } finally { await service.StopAsync(default); } Assert.Equal("disconnected", store.Snapshot()[0].Transport); Assert.True(factory.Last.Disposed); } private sealed class FakeFactory : IIb43MqttTransportFactory { public int Count; public FakeTransport Last; public TaskCompletionSource Connected = new(TaskCreationOptions.RunContinuationsAsynchronously); public IIb43MqttTransport Create() => Last = new FakeTransport(++Count == 1, Connected); } private sealed class FakeTransport(bool fail, TaskCompletionSource connected) : IIb43MqttTransport { public bool Disposed; public bool IsConnected => !Disposed; public event Func Message; public event Action OversizedMessage { add { } remove { } } public async Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken) { if (fail) throw new InvalidOperationException("subscription denied"); if (Message != null) await Message.Invoke(options.Devices[0].PostTopic, Report("\"HR\":[72,16,0,80,18,0]"), false); connected.TrySetResult(); } public void Dispose() => Disposed = true; } }