using Mbproxy.Configuration; using Mbproxy.Options; using Xunit; namespace Mbproxy.Tests.Configuration; /// /// Unit tests for . /// All tests verify the pure function logic — no side effects, no DI, no sockets. /// [Trait("Category", "Unit")] public sealed class ReloadPlanTests { // ── Helpers ─────────────────────────────────────────────────────────────────────────── private static PlcOptions MakePlc( string name, int listenPort, string host = "127.0.0.1", int port = 502) => new() { Name = name, ListenPort = listenPort, Host = host, Port = port }; private static MbproxyOptions MakeOptions( PlcOptions[] plcs, BcdTagListOptions? global = null) => new() { Plcs = plcs, BcdTags = global ?? new BcdTagListOptions(), }; private static BcdTagListOptions GlobalWith(params (ushort addr, byte width)[] tags) => new() { Global = tags.Select(t => new BcdTagOptions { Address = t.addr, Width = t.width }).ToList(), }; // ── 1. Add one PLC ─────────────────────────────────────────────────────────────────── [Fact] public void Compute_AddOnePlc_OnlyToAddPopulated() { var current = MakeOptions([MakePlc("A", 5020)]); var next = MakeOptions([MakePlc("A", 5020), MakePlc("B", 5021)]); var plan = ReloadPlan.Compute(current, next); Assert.Single(plan.ToAdd); Assert.Equal("B", plan.ToAdd[0].Name); Assert.Empty(plan.ToRemove); Assert.Empty(plan.ToRestart); Assert.Empty(plan.ToReseat); } // ── 2. Remove one PLC ──────────────────────────────────────────────────────────────── [Fact] public void Compute_RemoveOnePlc_OnlyToRemovePopulated() { var current = MakeOptions([MakePlc("A", 5020), MakePlc("B", 5021)]); var next = MakeOptions([MakePlc("A", 5020)]); var plan = ReloadPlan.Compute(current, next); Assert.Empty(plan.ToAdd); Assert.Single(plan.ToRemove); Assert.Equal("B", plan.ToRemove[0]); Assert.Empty(plan.ToRestart); Assert.Empty(plan.ToReseat); } // ── 3. Changed ListenPort → goes to ToRestart, NOT ToReseat ────────────────────────── [Fact] public void Compute_ChangePort_GoesToToRestart_NotToReseat() { var current = MakeOptions([MakePlc("A", 5020)]); var next = MakeOptions([MakePlc("A", 5022)]); // ListenPort changed var plan = ReloadPlan.Compute(current, next); Assert.Empty(plan.ToAdd); Assert.Empty(plan.ToRemove); Assert.Single(plan.ToRestart); Assert.Equal("A", plan.ToRestart[0].Name); Assert.Equal(5022, plan.ToRestart[0].New.ListenPort); Assert.Empty(plan.ToReseat); } // ── 3b. Changed Host → goes to ToRestart ───────────────────────────────────────────── [Fact] public void Compute_ChangeHost_GoesToToRestart() { var current = MakeOptions([MakePlc("A", 5020, host: "10.0.0.1")]); var next = MakeOptions([MakePlc("A", 5020, host: "10.0.0.2")]); var plan = ReloadPlan.Compute(current, next); Assert.Single(plan.ToRestart); Assert.Empty(plan.ToReseat); } // ── 4. Changed per-PLC tag override → goes to ToReseat ─────────────────────────────── [Fact] public void Compute_ChangePerPlcTagOverride_GoesToToReseat() { var global = GlobalWith((1072, 16)); // Current: PLC-A has no overrides. var current = MakeOptions([MakePlc("A", 5020)], global: global); // Next: PLC-A adds address 1080. var plcWithOverride = new PlcOptions { Name = "A", ListenPort = 5020, Host = "127.0.0.1", Port = 502, BcdTags = new PlcBcdOverrides { Add = [new BcdTagOptions { Address = 1080, Width = 16 }], }, }; var next = new MbproxyOptions { Plcs = [plcWithOverride], BcdTags = global, }; var plan = ReloadPlan.Compute(current, next); Assert.Empty(plan.ToAdd); Assert.Empty(plan.ToRemove); Assert.Empty(plan.ToRestart); Assert.Single(plan.ToReseat); Assert.Equal("A", plan.ToReseat[0].Name); } // ── 5. Changed global tag list → all PLCs reseat, no restart ───────────────────────── [Fact] public void Compute_ChangeGlobalTagList_AllPlcsReseat_NoRestart() { var globalBefore = GlobalWith((1072, 16)); var globalAfter = GlobalWith((1072, 16), (1080, 32)); // new 32-bit tag added var current = MakeOptions([MakePlc("A", 5020), MakePlc("B", 5021)], global: globalBefore); var next = MakeOptions([MakePlc("A", 5020), MakePlc("B", 5021)], global: globalAfter); var plan = ReloadPlan.Compute(current, next); Assert.Empty(plan.ToAdd); Assert.Empty(plan.ToRemove); Assert.Empty(plan.ToRestart); // Both PLCs should be reseated because the global tag list changed. Assert.Equal(2, plan.ToReseat.Count); Assert.Contains(plan.ToReseat, r => r.Name == "A"); Assert.Contains(plan.ToReseat, r => r.Name == "B"); } // ── 6. No changes → all empty ──────────────────────────────────────────────────────── [Fact] public void Compute_NoChanges_AllSectionsEmpty() { var global = GlobalWith((1072, 16)); var opts = MakeOptions([MakePlc("A", 5020)], global: global); var plan = ReloadPlan.Compute(opts, opts); Assert.Empty(plan.ToAdd); Assert.Empty(plan.ToRemove); Assert.Empty(plan.ToRestart); Assert.Empty(plan.ToReseat); } // ── 7. Connection options propagated ───────────────────────────────────────────────── [Fact] public void Compute_ConnectionOptions_AreFromNextSnapshot() { var current = new MbproxyOptions { Plcs = [MakePlc("A", 5020)], Connection = new ConnectionOptions { BackendConnectTimeoutMs = 1000 }, }; var next = new MbproxyOptions { Plcs = [MakePlc("A", 5020)], Connection = new ConnectionOptions { BackendConnectTimeoutMs = 9999 }, }; var plan = ReloadPlan.Compute(current, next); Assert.Equal(9999, plan.Connection.BackendConnectTimeoutMs); } }