diff --git a/CLAUDE.md b/CLAUDE.md index 95315a8..58d1d85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,8 +100,10 @@ Data flow: firmware query). There is **no ICMP watchdog** — liveness is the transport connection state plus the protocol keep-alive (the old `Z21Watchdog` was removed as part of the transport decoupling). -The dispatcher must be instantiated for inbound handling to work — both DI extensions register it as -an **activated/auto-activated singleton** so it wires up `ITransport.OnBytesReceived` eagerly. +The dispatcher must be instantiated for inbound handling to work. It is registered as a plain +singleton and is a **constructor dependency of `Z21CommandStation`**, so resolving the station +instantiates the dispatcher, which wires up `ITransport.OnBytesReceived`. There is no eager +auto-activation — inbound handling comes up as soon as the station is resolved. ### DI registration diff --git a/src/Z21.Autofac.UnitTests/SpyTransport.cs b/src/Z21.Autofac.UnitTests/SpyTransport.cs new file mode 100644 index 0000000..34d336a --- /dev/null +++ b/src/Z21.Autofac.UnitTests/SpyTransport.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; +using CommandStation.Transport; + +namespace Z21.Autofac.UnitTests +{ + public class SpyTransport : ITransport + { + public bool IsConnected { get; private set; } + + public event EventHandler? OnBytesReceived; + + public event EventHandler? OnConnectionChanged; + + public Task ConnectAsync() + { + IsConnected = true; + OnConnectionChanged?.Invoke(this, new ConnectionChangedEventArgs(true)); + return Task.CompletedTask; + } + + public Task DisconnectAsync() + { + IsConnected = false; + OnConnectionChanged?.Invoke(this, new ConnectionChangedEventArgs(false)); + return Task.CompletedTask; + } + + public Task SendAsync(ReadOnlyMemory data) => Task.CompletedTask; + + public void RaiseBytes(byte[] data) => OnBytesReceived?.Invoke(this, new BytesReceivedEventArgs(data)); + } +} diff --git a/src/Z21.Autofac.UnitTests/Z21AutofacExtensions.cs b/src/Z21.Autofac.UnitTests/Z21AutofacExtensions.cs index d2f20f8..dbc64f3 100644 --- a/src/Z21.Autofac.UnitTests/Z21AutofacExtensions.cs +++ b/src/Z21.Autofac.UnitTests/Z21AutofacExtensions.cs @@ -1,5 +1,6 @@ using Autofac; using CommandStation; +using CommandStation.Model; using CommandStation.Transport; using Z21.Core; using Z21.Core.ResponseHandler.Settings; @@ -92,6 +93,42 @@ public void AddZ21_Registers_CommandStation_As_Singleton() Assert.That(s2, Is.SameAs(s1), "ICommandStation and IZ21CommandStation should resolve to the same singleton"); } + [Test] + public void AddZ21_ResolvingCommandStation_WiresInboundHandling() + { + SpyTransport transport = new(); + using var container = BuildContainer(containerBuilder => + { + containerBuilder.AddZ21(); + containerBuilder.RegisterInstance(transport).As().SingleInstance(); + }); + + ILocoControl station = container.Resolve() as ILocoControl + ?? throw new InvalidOperationException("Station does not support loco control."); + LocoInfoData? received = null; + station.LocoInfoReceived += (_, data) => received = data; + + transport.RaiseBytes([0x0F, 0x00, 0x40, 0x00, 0xEF, 0x00, 0x03, 0x02, 0x87, 0x00, 0x00, 0x00, 0x00, 0x00, 0x69]); + + Assert.That(received, Is.Not.Null); + Assert.That(received!.LocoAddress, Is.EqualTo(3)); + } + + [Test] + public void AddZ21_CommandStation_ExposesProgrammingFeedbackAndFastClockCapabilities() + { + using var container = BuildContainer(containerBuilder => containerBuilder.AddZ21()); + + IZ21CommandStation station = container.Resolve(); + + Assert.Multiple(() => + { + Assert.That(station, Is.InstanceOf()); + Assert.That(station, Is.InstanceOf()); + Assert.That(station, Is.InstanceOf()); + }); + } + [Test] public void AddZ21_Registers_Options_Instance() { diff --git a/src/Z21.Client.UnitTest/Core/IZ21CommandStationContractTest.cs b/src/Z21.Client.UnitTest/Core/IZ21CommandStationContractTest.cs new file mode 100644 index 0000000..911aae3 --- /dev/null +++ b/src/Z21.Client.UnitTest/Core/IZ21CommandStationContractTest.cs @@ -0,0 +1,26 @@ +using System; +using CommandStation; +using Z21.Core; + +namespace Z21.Client.UnitTest.Core +{ + public class IZ21CommandStationContractTest + { + [Test] + public void IZ21CommandStation_ExposesEveryCapabilityTheStationImplements() + { + Type station = typeof(IZ21CommandStation); + + Assert.Multiple(() => + { + Assert.That(typeof(ILocoControl).IsAssignableFrom(station), Is.True, "ILocoControl"); + Assert.That(typeof(IAccessoryControl).IsAssignableFrom(station), Is.True, "IAccessoryControl"); + Assert.That(typeof(ITrackPowerControl).IsAssignableFrom(station), Is.True, "ITrackPowerControl"); + Assert.That(typeof(ISystemInfoProvider).IsAssignableFrom(station), Is.True, "ISystemInfoProvider"); + Assert.That(typeof(IProgrammingControl).IsAssignableFrom(station), Is.True, "IProgrammingControl"); + Assert.That(typeof(IFeedbackControl).IsAssignableFrom(station), Is.True, "IFeedbackControl"); + Assert.That(typeof(IFastClockControl).IsAssignableFrom(station), Is.True, "IFastClockControl"); + }); + } + } +} diff --git a/src/Z21.Client/Core/IZ21CommandStation.cs b/src/Z21.Client/Core/IZ21CommandStation.cs index 8ff7e9b..ccbefab 100644 --- a/src/Z21.Client/Core/IZ21CommandStation.cs +++ b/src/Z21.Client/Core/IZ21CommandStation.cs @@ -8,7 +8,7 @@ namespace Z21.Core /// The Z21 command station: the protocol-agnostic capabilities plus a Z21-specific raw escape hatch /// for sending hand-built commands. /// - public interface IZ21CommandStation : ICommandStation, ILocoControl, IAccessoryControl, ITrackPowerControl, ISystemInfoProvider + public interface IZ21CommandStation : ICommandStation, ILocoControl, IAccessoryControl, ITrackPowerControl, ISystemInfoProvider, IProgrammingControl, IFeedbackControl, IFastClockControl { /// /// Factory for building raw Z21 commands to pass to . diff --git a/src/Z21.Client/Core/Z21CommandStation.cs b/src/Z21.Client/Core/Z21CommandStation.cs index 4cc2b18..1ca4101 100644 --- a/src/Z21.Client/Core/Z21CommandStation.cs +++ b/src/Z21.Client/Core/Z21CommandStation.cs @@ -32,6 +32,7 @@ public class Z21CommandStation : IZ21CommandStation, IProgrammingControl, IFeedb private readonly Z21Options _options; private readonly DelayedAction _delayedKeepAliveAction; private readonly ILogger? _logger; + private bool _disposed; /// /// IPv4 safe MTU for payload according to specification. @@ -207,6 +208,10 @@ private async Task KeepAliveAsync() public void Dispose() { + if (_disposed) + return; + + _disposed = true; _delayedKeepAliveAction.Dispose(); GC.SuppressFinalize(this); } diff --git a/src/Z21.DependencyInjection.UnitTest/Z21DependencyInjectionExtensionTest.cs b/src/Z21.DependencyInjection.UnitTest/Z21DependencyInjectionExtensionTest.cs index c124184..76973b8 100644 --- a/src/Z21.DependencyInjection.UnitTest/Z21DependencyInjectionExtensionTest.cs +++ b/src/Z21.DependencyInjection.UnitTest/Z21DependencyInjectionExtensionTest.cs @@ -2,6 +2,7 @@ using CommandStation.Model; using CommandStation.Transport; using Microsoft.Extensions.DependencyInjection; +using Z21.Core; using Z21.Core.Model; using Z21.Core.Model.EventArgs; using Z21.Core.ResponseHandler; @@ -58,6 +59,38 @@ public void AddZ21_WithoutHost_NewHandler_IsDiscoveredAndFlowsThroughCapability( }); } + [Test] + public void AddZ21_CommandStation_ExposesProgrammingFeedbackAndFastClockCapabilities() + { + ServiceCollection services = new(); + services.AddZ21(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IZ21CommandStation station = serviceProvider.GetRequiredService(); + + Assert.Multiple(() => + { + Assert.That(station, Is.InstanceOf()); + Assert.That(station, Is.InstanceOf()); + Assert.That(station, Is.InstanceOf()); + }); + } + + [Test] + public void AddZ21_StationDisposedTwice_DoesNotThrow() + { + ServiceCollection services = new(); + services.AddZ21(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Resolving ICommandStation makes MS.DI capture the IZ21CommandStation singleton for disposal + // a second time (factory forwarding), so Dispose() must be idempotent. + IDisposable station = (IDisposable)serviceProvider.GetRequiredService(); + + station.Dispose(); + Assert.DoesNotThrow(() => station.Dispose()); + } + [Test] public void AddZ21_ProviderDisposedSynchronously_DoesNotThrow() {