diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..05b1df4 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,38 @@ +name: Run Unit Tests + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + +jobs: + test: + name: Build and run unit tests + runs-on: ubuntu-latest + steps: + - name: GitHub actions Workspace Cleaner + uses: jstone28/runner-workspace-cleaner@v1.0.0 + + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Restore dependencies + run: dotnet restore + working-directory: ./ThreeByte.LinkLib + + - name: Build + run: dotnet build --configuration Release --no-restore + working-directory: ./ThreeByte.LinkLib + + - name: Run tests + run: dotnet test --configuration Release --no-build --logger "trx;LogFileName=test-results.trx" + working-directory: ./ThreeByte.LinkLib diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.NetBooter/NetBooterLink.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.NetBooter/NetBooterLink.cs index d6f6805..bcb25c0 100644 --- a/ThreeByte.LinkLib/ThreeByte.LinkLib.NetBooter/NetBooterLink.cs +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.NetBooter/NetBooterLink.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Net; +using System.Net.Http; using Microsoft.Extensions.Logging; using ThreeByte.LinkLib.Shared.Logging; @@ -11,12 +12,19 @@ public class NetBooterLink : INotifyPropertyChanged { private readonly string _ipAddress; private readonly ILogger _logger; + private readonly HttpClient _httpClient; private readonly Dictionary _powerStates = new Dictionary(); public NetBooterLink(string ipAddress) { _logger = LogFactory.Create(); _ipAddress = ipAddress; + + var handler = new HttpClientHandler + { + Credentials = new NetworkCredential("admin", "admin") + }; + _httpClient = new HttpClient(handler); } public bool this[int port] @@ -39,10 +47,8 @@ public void Power(int outlet, bool state) { try { - var c = new WebClient(); - c.Credentials = new NetworkCredential("admin", "admin"); var commandUri = string.Format("http://{0}/cmd.cgi?$A3 {1} {2}", _ipAddress, outlet, state ? 1 : 0); - var response = c.DownloadString(commandUri); + var response = _httpClient.GetStringAsync(commandUri).GetAwaiter().GetResult(); _logger.LogDebug("Response: {0}", response); } catch (Exception ex) @@ -56,10 +62,8 @@ public void PollState() { try { - var c = new WebClient(); - c.Credentials = new NetworkCredential("admin", "admin"); var commandUri = string.Format("http://{0}/cmd.cgi?$A5", _ipAddress); - var response = c.DownloadString(commandUri); + var response = _httpClient.GetStringAsync(commandUri).GetAwaiter().GetResult(); // Expected response: xxxx,cccc,tttt // read right to left for each field, eg - 01 means port 1 is on @@ -84,14 +88,6 @@ public void PollState() } } - private void NotifyPropertyChanged(string info) - { - if (PropertyChanged != null) - { - PropertyChanged(this, new PropertyChangedEventArgs(info)); - } - } - private void HandleError(Exception ex, string message) { _logger.LogError(ex, message); diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/Commands/Command.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/Commands/Command.cs index be82c55..04e70b1 100644 --- a/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/Commands/Command.cs +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/Commands/Command.cs @@ -2,8 +2,6 @@ { public abstract class Command { - public delegate void CommandResultHandler(Command sender, CommandResponse response); - protected CommandResponse _cmdResponse; internal virtual string GetCommandString() diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/Projector.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/Projector.cs index a15c368..aed5ed6 100644 --- a/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/Projector.cs +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/Projector.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.Logging; using System; using System.Net.Sockets; -using System.Security.Cryptography; using System.Text; +using System.Security.Cryptography; using ThreeByte.LinkLib.ProjectorLink.Commands; using ThreeByte.LinkLib.Shared.Logging; @@ -235,16 +235,18 @@ private void CloseConnection() private string GetMD5Hash(string input) { - MD5CryptoServiceProvider cryptoProvider = new MD5CryptoServiceProvider(); - byte[] bs = Encoding.ASCII.GetBytes(input); - byte[] hash = cryptoProvider.ComputeHash(bs); - - string toRet = ""; - foreach (byte b in hash) + using (var md5 = MD5.Create()) { - toRet += b.ToString("x2"); + byte[] bs = Encoding.ASCII.GetBytes(input); + byte[] hash = md5.ComputeHash(bs); + + StringBuilder sb = new StringBuilder(hash.Length * 2); + foreach (byte b in hash) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString(); } - return toRet; } private void HandleError(Exception ex, string message) diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/ThreeByte.LinkLib.ProjectorLink.csproj b/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/ThreeByte.LinkLib.ProjectorLink.csproj index 845d95e..505b2b3 100644 --- a/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/ThreeByte.LinkLib.ProjectorLink.csproj +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.ProjectorLink/ThreeByte.LinkLib.ProjectorLink.csproj @@ -13,6 +13,10 @@ + + + + diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.SerialLink/FramedSerialLink.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.SerialLink/FramedSerialLink.cs index f44f612..c8756f0 100644 --- a/ThreeByte.LinkLib/ThreeByte.LinkLib.SerialLink/FramedSerialLink.cs +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.SerialLink/FramedSerialLink.cs @@ -75,12 +75,12 @@ public void SendMessage(string message) } //Add the header and footer - byte[] header = new byte[0]; + byte[] header = Array.Empty(); if (SendFrame != null && SendFrame.Header != null) { header = SendFrame.Header; } - byte[] footer = new byte[0]; + byte[] footer = Array.Empty(); if (SendFrame != null && SendFrame.Footer != null) { footer = SendFrame.Footer; @@ -91,21 +91,18 @@ public void SendMessage(string message) Encoding.UTF8.GetBytes(message, 0, message.Length, messageBytes, header.Length); footer.CopyTo(messageBytes, message.Length + header.Length); - if (_serialLink != null) + try { - try - { - _serialLink.SendData(messageBytes); - } - catch (ObjectDisposedException ode) - { - HandleError(ode, "Cannot send a message of disposed FramedSerialLink."); - } - catch (Exception ex) - { - //Also possible for the serial link to raise and UnauthorizedAccessException here - HandleError(ex, "SendMessage error."); - } + _serialLink.SendData(messageBytes); + } + catch (ObjectDisposedException ode) + { + HandleError(ode, "Cannot send a message of disposed FramedSerialLink."); + } + catch (Exception ex) + { + //Also possible for the serial link to raise and UnauthorizedAccessException here + HandleError(ex, "SendMessage error."); } } @@ -162,13 +159,13 @@ private void OnDataReceived(object? sender, EventArgs e) { bool hasNewData = false; - byte[] header = new byte[0]; + byte[] header = Array.Empty(); if (ReceiveFrame != null && ReceiveFrame.Header != null) { header = ReceiveFrame.Header; } - byte[] footer = new byte[0]; + byte[] footer = Array.Empty(); if (ReceiveFrame != null && ReceiveFrame.Footer != null) { footer = ReceiveFrame.Footer; diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.SerialLink/SerialLink.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.SerialLink/SerialLink.cs index 6452f62..6d68fe1 100644 --- a/ThreeByte.LinkLib/ThreeByte.LinkLib.SerialLink/SerialLink.cs +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.SerialLink/SerialLink.cs @@ -178,7 +178,7 @@ private void SafeConnect() _serialPort.Parity = _settings.Parity; _serialPort.DataBits = _settings.DataBits; _serialPort.StopBits = StopBits.One; - _serialPort.DataReceived += new SerialDataReceivedEventHandler(OnDataReceived); + _serialPort.DataReceived += OnDataReceived; } if (!_isConnected) diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Shared/Logging/LogFactory.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Shared/Logging/LogFactory.cs index 23f96db..e5cee56 100644 --- a/ThreeByte.LinkLib/ThreeByte.LinkLib.Shared/Logging/LogFactory.cs +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Shared/Logging/LogFactory.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging; +using System; +using Microsoft.Extensions.Logging; namespace ThreeByte.LinkLib.Shared.Logging { @@ -7,11 +8,12 @@ namespace ThreeByte.LinkLib.Shared.Logging /// public class LogFactory { + private static readonly Lazy Factory = new Lazy(() => + LoggerFactory.Create(builder => { builder.AddConsole(); })); + public static ILogger Create() { - var factory = LoggerFactory.Create(builder => { builder.AddConsole(); }); - - return factory.CreateLogger(); + return Factory.Value.CreateLogger(); } } -} \ No newline at end of file +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.TcpLink/AsyncTcpLink.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.TcpLink/AsyncTcpLink.cs index 2cc3780..4745d36 100644 --- a/ThreeByte.LinkLib/ThreeByte.LinkLib.TcpLink/AsyncTcpLink.cs +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.TcpLink/AsyncTcpLink.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net.Sockets; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using ThreeByte.LinkLib.Shared.Logging; @@ -384,7 +385,17 @@ private void ReadCallback(IAsyncResult asyncResult) DataReceived(this, new EventArgs()); } - ReceiveData(); + // When BeginRead completes synchronously the callback fires on the same + // thread, so a direct call to ReceiveData would recurse on the same stack + // and eventually overflow. Trampolining via the ThreadPool breaks the chain. + if (asyncResult.CompletedSynchronously) + { + ThreadPool.QueueUserWorkItem(_ => ReceiveData()); + } + else + { + ReceiveData(); + } } /// diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/AsyncTcpLinkTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/AsyncTcpLinkTests.cs new file mode 100644 index 0000000..46fadd9 --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/AsyncTcpLinkTests.cs @@ -0,0 +1,220 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using ThreeByte.LinkLib.TcpLink; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class AsyncTcpLinkTests : IDisposable + { + private AsyncTcpLink? _link; + + public void Dispose() + { + _link?.Dispose(); + } + + [Fact] + public void Constructor_Disabled_DoesNotConnect() + { + _link = new AsyncTcpLink("127.0.0.1", 19999, enabled: false); + + Assert.False(_link.IsConnected); + Assert.False(_link.IsEnabled); + } + + [Fact] + public void Address_ReturnsConfiguredAddress() + { + _link = new AsyncTcpLink("10.0.0.50", 9100, enabled: false); + + Assert.Equal("10.0.0.50", _link.Address); + Assert.Equal(9100, _link.Port); + } + + [Fact] + public void HasData_InitiallyFalse() + { + _link = new AsyncTcpLink("127.0.0.1", 19999, enabled: false); + + Assert.False(_link.HasData); + } + + [Fact] + public void GetMessage_WhenDisabled_ReturnsNull() + { + _link = new AsyncTcpLink("127.0.0.1", 19999, enabled: false); + + Assert.Null(_link.GetMessage()); + } + + [Fact] + public void GetMessage_WhenDisposed_ThrowsObjectDisposedException() + { + _link = new AsyncTcpLink("127.0.0.1", 19999, enabled: false); + _link.Dispose(); + + Assert.Throws(() => _link.GetMessage()); + } + + [Fact] + public void SendMessage_WhenDisabled_DoesNotThrow() + { + _link = new AsyncTcpLink("127.0.0.1", 19999, enabled: false); + + // Should silently return without throwing + _link.SendMessage(new byte[] { 0x01, 0x02 }); + } + + [Fact] + public void SetEnabled_True_FiresEvent() + { + _link = new AsyncTcpLink("127.0.0.1", 19999, enabled: false); + bool eventFired = false; + bool eventValue = false; + _link.IsEnabledChanged += (s, v) => { eventFired = true; eventValue = v; }; + + _link.SetEnabled(true); + + Assert.True(eventFired); + Assert.True(eventValue); + Assert.True(_link.IsEnabled); + } + + [Fact] + public void SetEnabled_False_FiresEvent() + { + _link = new AsyncTcpLink("127.0.0.1", 19999, enabled: false); + bool eventFired = false; + _link.IsEnabledChanged += (s, v) => { eventFired = true; }; + + _link.SetEnabled(false); + + Assert.True(eventFired); + Assert.False(_link.IsEnabled); + } + + [Fact] + public void Dispose_MultipleCalls_DoesNotThrow() + { + _link = new AsyncTcpLink("127.0.0.1", 19999, enabled: false); + + _link.Dispose(); + _link.Dispose(); // Second call should be safe + } + + [Fact] + public void IsConnectedChanged_EventCanBeSubscribed() + { + _link = new AsyncTcpLink("127.0.0.1", 19999, enabled: false); + bool eventFired = false; + _link.IsConnectedChanged += (s, v) => { eventFired = true; }; + + // Event wont fire without a real connection, but subscription should work + Assert.False(eventFired); + } + + [Fact] + public void ErrorOccurred_EventCanBeSubscribed() + { + _link = new AsyncTcpLink("127.0.0.1", 19999, enabled: false); + Exception? receivedException = null; + _link.ErrorOccurred += (s, ex) => { receivedException = ex; }; + + // No error should occur in disabled mode + Assert.Null(receivedException); + } + + [Fact] + public void ConnectAndExchangeData_WithLocalServer() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + + // Keep the server connection open so the link stays connected + TcpClient? serverClient = null; + NetworkStream? serverStream = null; + var serverThread = new Thread(() => + { + serverClient = listener.AcceptTcpClient(); + serverStream = serverClient.GetStream(); + byte[] buf = new byte[1024]; + int n = serverStream.Read(buf, 0, buf.Length); + serverStream.Write(buf, 0, n); + // Keep connection open until test finishes + Thread.Sleep(5000); + }); + serverThread.IsBackground = true; + serverThread.Start(); + + try + { + _link = new AsyncTcpLink("127.0.0.1", port, enabled: true); + + // Poll for data with timeout instead of fixed sleep + Thread.Sleep(500); + _link.SendMessage(new byte[] { 0xAA, 0xBB, 0xCC }); + + byte[]? received = null; + for (int i = 0; i < 20; i++) + { + Thread.Sleep(100); + if (_link.HasData) + { + received = _link.GetMessage(); + break; + } + } + + Assert.NotNull(received); + Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, received); + } + finally + { + serverClient?.Close(); + listener.Stop(); + } + } + + [Fact] + public void DoesNotStackOverflow_WhenServerClosesConnection() + { + // Regression test: before the ThreadPool trampoline fix, the + // ReadCallback -> ReceiveData -> BeginRead -> ReadCallback chain + // could recurse on the same stack when BeginRead completed + // synchronously (e.g. on connection close), causing a stack overflow + // that crashed the process. + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var serverThread = new Thread(() => + { + var client = listener.AcceptTcpClient(); + // Close immediately to trigger synchronous BeginRead completion + client.Close(); + }); + serverThread.IsBackground = true; + serverThread.Start(); + + try + { + _link = new AsyncTcpLink("127.0.0.1", port, enabled: true); + + // Give it enough time for the read loop to hit the closed connection. + // Before the fix this would stack overflow and crash the process. + Thread.Sleep(2000); + + // If we get here, the process didn't crash — the fix works. + // The link auto-reconnects, so we don't assert IsConnected state. + } + finally + { + listener.Stop(); + } + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/AsyncUdpLinkTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/AsyncUdpLinkTests.cs new file mode 100644 index 0000000..03a2d8a --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/AsyncUdpLinkTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using ThreeByte.LinkLib.UdpLink; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class AsyncUdpLinkTests : IDisposable + { + private AsyncUdpLink? _link; + + public void Dispose() + { + _link?.Dispose(); + } + + [Fact] + public void Constructor_SetsProperties() + { + _link = new AsyncUdpLink("192.168.1.10", 5000, 6000, enabled: false); + + Assert.Equal("192.168.1.10", _link.Address); + Assert.Equal(5000, _link.Port); + Assert.False(_link.IsEnabled); + } + + [Fact] + public void Constructor_DefaultEnabled() + { + _link = new AsyncUdpLink("127.0.0.1", 5001, 0); + + Assert.True(_link.IsEnabled); + } + + [Fact] + public void HasData_InitiallyFalse() + { + _link = new AsyncUdpLink("127.0.0.1", 5002, 0, enabled: false); + + Assert.False(_link.HasData); + } + + [Fact] + public void GetMessage_WhenDisabled_ReturnsNull() + { + _link = new AsyncUdpLink("127.0.0.1", 5003, 0, enabled: false); + + Assert.Null(_link.GetMessage()); + } + + [Fact] + public void GetMessage_WhenDisposed_ThrowsObjectDisposedException() + { + _link = new AsyncUdpLink("127.0.0.1", 5004, 0, enabled: false); + _link.Dispose(); + + Assert.Throws(() => _link.GetMessage()); + } + + [Fact] + public void SetEnabled_FiresEvent() + { + _link = new AsyncUdpLink("127.0.0.1", 5005, 0, enabled: false); + bool eventFired = false; + bool eventValue = false; + _link.IsEnabledChanged += (s, v) => { eventFired = true; eventValue = v; }; + + _link.SetEnabled(true); + + Assert.True(eventFired); + Assert.True(eventValue); + } + + [Fact] + public void Dispose_MultipleCalls_DoesNotThrow() + { + _link = new AsyncUdpLink("127.0.0.1", 5006, 0, enabled: false); + + _link.Dispose(); + _link.Dispose(); + } + + [Fact] + public void ImplementsIDisposable() + { + _link = new AsyncUdpLink("127.0.0.1", 5007, 0, enabled: false); + Assert.IsAssignableFrom(_link); + } + + [Fact] + public void Constructor_WithSettings_SetsProperties() + { + var settings = new UdpLinkSettings("10.0.0.5", 9000, 9001); + _link = new AsyncUdpLink(settings, enabled: false); + + Assert.Equal("10.0.0.5", _link.Address); + Assert.Equal(9000, _link.Port); + } + + [Fact] + public void SendAndReceive_Loopback() + { + // Use a local UDP client to send data to the link's local port + int localPort; + using (var tempSocket = new UdpClient(0)) + { + localPort = ((IPEndPoint)tempSocket.Client.LocalEndPoint!).Port; + } + + // Small delay to let the port free up + Thread.Sleep(50); + + _link = new AsyncUdpLink("127.0.0.1", 50000, localPort, enabled: true); + + // Send a UDP packet to the link's local port + using (var sender = new UdpClient()) + { + byte[] data = new byte[] { 0x01, 0x02, 0x03 }; + sender.Send(data, data.Length, new IPEndPoint(IPAddress.Loopback, localPort)); + } + + // Wait for data to arrive + Thread.Sleep(500); + + if (_link.HasData) + { + byte[]? msg = _link.GetMessage(); + Assert.NotNull(msg); + Assert.Equal(new byte[] { 0x01, 0x02, 0x03 }, msg); + } + } + + [Fact] + public void SendMessage_WhenDisabled_DoesNotThrow() + { + _link = new AsyncUdpLink("127.0.0.1", 5008, 0, enabled: false); + + _link.SendMessage(new byte[] { 0x01 }); + } + + [Fact] + public void ErrorOccurred_EventCanBeSubscribed() + { + _link = new AsyncUdpLink("127.0.0.1", 5009, 0, enabled: false); + Exception? ex = null; + _link.ErrorOccurred += (s, e) => { ex = e; }; + + Assert.Null(ex); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/CommandTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/CommandTests.cs new file mode 100644 index 0000000..4122c88 --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/CommandTests.cs @@ -0,0 +1,92 @@ +using ThreeByte.LinkLib.ProjectorLink.Commands; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class CommandTests + { + // Concrete subclass for testing the abstract Command base class + private class TestCommand : Command + { + internal override string GetCommandString() => "%1TEST ?"; + } + + [Fact] + public void ProcessAnswerString_ERR1_SetsUndefinedCmd() + { + var cmd = new TestCommand(); + cmd.ProcessAnswerString("%1TEST=ERR1"); + Assert.Equal(CommandResponse.UNDEFINED_CMD, cmd.CmdResponse); + } + + [Fact] + public void ProcessAnswerString_ERR2_SetsUndefinedCmd() + { + var cmd = new TestCommand(); + cmd.ProcessAnswerString("%1TEST=ERR2"); + Assert.Equal(CommandResponse.UNDEFINED_CMD, cmd.CmdResponse); + } + + [Fact] + public void ProcessAnswerString_ERR3_SetsUnavailableTime() + { + var cmd = new TestCommand(); + cmd.ProcessAnswerString("%1TEST=ERR3"); + Assert.Equal(CommandResponse.UNAVAILABLE_TIME, cmd.CmdResponse); + } + + [Fact] + public void ProcessAnswerString_ERR4_SetsProjectorFailure() + { + var cmd = new TestCommand(); + cmd.ProcessAnswerString("%1TEST=ERR4"); + Assert.Equal(CommandResponse.PROJECTOR_FAILURE, cmd.CmdResponse); + } + + [Fact] + public void ProcessAnswerString_ERRA_SetsAuthFailure() + { + var cmd = new TestCommand(); + cmd.ProcessAnswerString("%1TEST ERRA"); + Assert.Equal(CommandResponse.AUTH_FAILURE, cmd.CmdResponse); + } + + [Fact] + public void ProcessAnswerString_OK_SetsSuccess() + { + var cmd = new TestCommand(); + cmd.ProcessAnswerString("%1TEST=OK"); + Assert.Equal(CommandResponse.SUCCESS, cmd.CmdResponse); + } + + [Fact] + public void ProcessAnswerString_ReturnsTrue_OnSuccess() + { + var cmd = new TestCommand(); + bool result = cmd.ProcessAnswerString("%1TEST=OK"); + Assert.True(result); + } + + [Fact] + public void ProcessAnswerString_ReturnsFalse_OnError() + { + var cmd = new TestCommand(); + bool result = cmd.ProcessAnswerString("%1TEST=ERR1"); + Assert.False(result); + } + + [Fact] + public void GetCommandString_ReturnsExpectedString() + { + var cmd = new TestCommand(); + Assert.Equal("%1TEST ?", cmd.GetCommandString()); + } + + [Fact] + public void DumpToString_ReturnsEmptyByDefault() + { + var cmd = new TestCommand(); + Assert.Equal("", cmd.DumpToString()); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/LogFactoryTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/LogFactoryTests.cs new file mode 100644 index 0000000..38eaed0 --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/LogFactoryTests.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using ThreeByte.LinkLib.Shared.Logging; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class LogFactoryTests + { + [Fact] + public void Create_ReturnsNonNullLogger() + { + ILogger logger = LogFactory.Create(); + + Assert.NotNull(logger); + } + + [Fact] + public void Create_ReturnsSameFactoryInstance() + { + // Calling Create multiple times should not throw and should return valid loggers + ILogger logger1 = LogFactory.Create(); + ILogger logger2 = LogFactory.Create(); + + Assert.NotNull(logger1); + Assert.NotNull(logger2); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ManufacturerNameCommandTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ManufacturerNameCommandTests.cs new file mode 100644 index 0000000..5b357f0 --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ManufacturerNameCommandTests.cs @@ -0,0 +1,60 @@ +using ThreeByte.LinkLib.ProjectorLink.Commands; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class ManufacturerNameCommandTests + { + [Fact] + public void GetCommandString_ReturnsCorrectQuery() + { + var cmd = new ManufacturerNameCommand(); + Assert.Equal("%1INF1 ?", cmd.GetCommandString()); + } + + [Fact] + public void ProcessAnswerString_Success_SetsManufacturer() + { + var cmd = new ManufacturerNameCommand(); + cmd.ProcessAnswerString("%1INF1=Epson"); + + Assert.Equal(CommandResponse.SUCCESS, cmd.CmdResponse); + Assert.Equal("Epson", cmd.Manufacturer); + } + + [Fact] + public void ProcessAnswerString_WithSpaces_SetsManufacturer() + { + var cmd = new ManufacturerNameCommand(); + cmd.ProcessAnswerString("%1INF1=Sony Corporation"); + + Assert.Equal("Sony Corporation", cmd.Manufacturer); + } + + [Fact] + public void DumpToString_ReturnsManufacturerInfo() + { + var cmd = new ManufacturerNameCommand(); + cmd.ProcessAnswerString("%1INF1=NEC"); + + Assert.Equal("Manufacturer: NEC", cmd.DumpToString()); + } + + [Fact] + public void Manufacturer_DefaultsToEmpty() + { + var cmd = new ManufacturerNameCommand(); + Assert.Equal("", cmd.Manufacturer); + } + + [Fact] + public void ProcessAnswerString_Error_ReturnsFalse() + { + var cmd = new ManufacturerNameCommand(); + bool result = cmd.ProcessAnswerString("%1INF1=ERR1"); + + Assert.False(result); + Assert.Equal(CommandResponse.UNDEFINED_CMD, cmd.CmdResponse); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/NetBooterLinkTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/NetBooterLinkTests.cs new file mode 100644 index 0000000..0edd6c1 --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/NetBooterLinkTests.cs @@ -0,0 +1,62 @@ +using System; +using System.ComponentModel; +using ThreeByte.LinkLib.NetBooter; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class NetBooterLinkTests + { + [Fact] + public void Constructor_CreatesInstance() + { + var link = new NetBooterLink("192.168.1.100"); + Assert.NotNull(link); + } + + [Fact] + public void Indexer_UnknownPort_ReturnsFalse() + { + var link = new NetBooterLink("192.168.1.100"); + + Assert.False(link[1]); + Assert.False(link[2]); + Assert.False(link[99]); + } + + [Fact] + public void Indexer_NegativePort_ReturnsFalse() + { + var link = new NetBooterLink("192.168.1.100"); + + Assert.False(link[-1]); + } + + [Fact] + public void ImplementsINotifyPropertyChanged() + { + var link = new NetBooterLink("192.168.1.100"); + Assert.IsAssignableFrom(link); + } + + [Fact] + public void ErrorOccurred_EventCanBeSubscribed() + { + var link = new NetBooterLink("192.168.1.100"); + Exception? receivedEx = null; + link.ErrorOccurred += (s, ex) => { receivedEx = ex; }; + + Assert.Null(receivedEx); + } + + [Fact] + public void PropertyChanged_EventCanBeSubscribed() + { + var link = new NetBooterLink("192.168.1.100"); + string? changedProp = null; + link.PropertyChanged += (s, e) => { changedProp = e.PropertyName; }; + + Assert.Null(changedProp); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/PowerCommandTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/PowerCommandTests.cs new file mode 100644 index 0000000..7a521e7 --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/PowerCommandTests.cs @@ -0,0 +1,95 @@ +using ThreeByte.LinkLib.ProjectorLink.Commands; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class PowerCommandTests + { + [Fact] + public void GetCommandString_ON_ReturnsCorrectCommand() + { + var cmd = new PowerCommand(PowerCommand.Power.ON); + Assert.Equal("%1POWR 1", cmd.GetCommandString()); + } + + [Fact] + public void GetCommandString_OFF_ReturnsCorrectCommand() + { + var cmd = new PowerCommand(PowerCommand.Power.OFF); + Assert.Equal("%1POWR 0", cmd.GetCommandString()); + } + + [Fact] + public void GetCommandString_QUERY_ReturnsCorrectCommand() + { + var cmd = new PowerCommand(PowerCommand.Power.QUERY); + Assert.Equal("%1POWR ?", cmd.GetCommandString()); + } + + [Fact] + public void ProcessAnswerString_ON_Success_SetsStatusON() + { + var cmd = new PowerCommand(PowerCommand.Power.ON); + cmd.ProcessAnswerString("%1POWR=OK"); + Assert.Equal(CommandResponse.SUCCESS, cmd.CmdResponse); + } + + [Fact] + public void ProcessAnswerString_Query_PowerOff_SetsStatusOFF() + { + var cmd = new PowerCommand(PowerCommand.Power.QUERY); + cmd.ProcessAnswerString("%1POWR=0"); + Assert.Equal(CommandResponse.SUCCESS, cmd.CmdResponse); + Assert.Equal(PowerStatus.OFF, cmd.Status); + } + + [Fact] + public void ProcessAnswerString_Query_PowerOn_SetsStatusON() + { + var cmd = new PowerCommand(PowerCommand.Power.QUERY); + cmd.ProcessAnswerString("%1POWR=1"); + Assert.Equal(CommandResponse.SUCCESS, cmd.CmdResponse); + Assert.Equal(PowerStatus.ON, cmd.Status); + } + + [Fact] + public void ProcessAnswerString_Query_Cooling_SetsStatusCOOLING() + { + var cmd = new PowerCommand(PowerCommand.Power.QUERY); + cmd.ProcessAnswerString("%1POWR=2"); + Assert.Equal(PowerStatus.COOLING, cmd.Status); + } + + [Fact] + public void ProcessAnswerString_Query_Warmup_SetsStatusWARMUP() + { + var cmd = new PowerCommand(PowerCommand.Power.QUERY); + cmd.ProcessAnswerString("%1POWR=3"); + Assert.Equal(PowerStatus.WARMUP, cmd.Status); + } + + [Fact] + public void ProcessAnswerString_Query_OutOfRange_SetsUnknown() + { + var cmd = new PowerCommand(PowerCommand.Power.QUERY); + cmd.ProcessAnswerString("%1POWR=9"); + Assert.Equal(PowerStatus.UNKNOWN, cmd.Status); + } + + [Fact] + public void ProcessAnswerString_Error_SetsStatusUnknown() + { + var cmd = new PowerCommand(PowerCommand.Power.QUERY); + cmd.ProcessAnswerString("%1POWR=ERR3"); + Assert.Equal(CommandResponse.UNAVAILABLE_TIME, cmd.CmdResponse); + Assert.Equal(PowerStatus.UNKNOWN, cmd.Status); + } + + [Fact] + public void Status_DefaultsToUnknown() + { + var cmd = new PowerCommand(PowerCommand.Power.QUERY); + Assert.Equal(PowerStatus.UNKNOWN, cmd.Status); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/PowerStatusTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/PowerStatusTests.cs new file mode 100644 index 0000000..dc111ad --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/PowerStatusTests.cs @@ -0,0 +1,54 @@ +using ThreeByte.LinkLib.ProjectorLink.Commands; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class PowerStatusTests + { + [Fact] + public void OFF_HasValue0() + { + Assert.Equal(0, (int)PowerStatus.OFF); + } + + [Fact] + public void ON_HasValue1() + { + Assert.Equal(1, (int)PowerStatus.ON); + } + + [Fact] + public void COOLING_HasValue2() + { + Assert.Equal(2, (int)PowerStatus.COOLING); + } + + [Fact] + public void WARMUP_HasValue3() + { + Assert.Equal(3, (int)PowerStatus.WARMUP); + } + + [Fact] + public void UNKNOWN_HasValue4() + { + Assert.Equal(4, (int)PowerStatus.UNKNOWN); + } + } + + public class CommandResponseTests + { + [Theory] + [InlineData(CommandResponse.SUCCESS)] + [InlineData(CommandResponse.UNDEFINED_CMD)] + [InlineData(CommandResponse.OUT_OF_PARAMETER)] + [InlineData(CommandResponse.UNAVAILABLE_TIME)] + [InlineData(CommandResponse.PROJECTOR_FAILURE)] + [InlineData(CommandResponse.AUTH_FAILURE)] + [InlineData(CommandResponse.COMMUNICATION_ERROR)] + public void AllValues_AreDefined(CommandResponse response) + { + Assert.True(System.Enum.IsDefined(typeof(CommandResponse), response)); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ProductNameCommandTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ProductNameCommandTests.cs new file mode 100644 index 0000000..c0a272f --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ProductNameCommandTests.cs @@ -0,0 +1,51 @@ +using ThreeByte.LinkLib.ProjectorLink.Commands; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class ProductNameCommandTests + { + [Fact] + public void GetCommandString_ReturnsCorrectQuery() + { + var cmd = new ProductNameCommand(); + Assert.Equal("%1INF2 ?", cmd.GetCommandString()); + } + + [Fact] + public void ProcessAnswerString_Success_SetsProductName() + { + var cmd = new ProductNameCommand(); + cmd.ProcessAnswerString("%1INF2=EB-L1075U"); + + Assert.Equal(CommandResponse.SUCCESS, cmd.CmdResponse); + Assert.Equal("EB-L1075U", cmd.ProductName); + } + + [Fact] + public void DumpToString_ReturnsProductInfo() + { + var cmd = new ProductNameCommand(); + cmd.ProcessAnswerString("%1INF2=VPL-FHZ80"); + + Assert.Equal("ProductName: VPL-FHZ80", cmd.DumpToString()); + } + + [Fact] + public void ProductName_DefaultsToEmpty() + { + var cmd = new ProductNameCommand(); + Assert.Equal("", cmd.ProductName); + } + + [Fact] + public void ProcessAnswerString_Error_ReturnsFalse() + { + var cmd = new ProductNameCommand(); + bool result = cmd.ProcessAnswerString("%1INF2=ERR4"); + + Assert.False(result); + Assert.Equal(CommandResponse.PROJECTOR_FAILURE, cmd.CmdResponse); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ProjectorNameCommandTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ProjectorNameCommandTests.cs new file mode 100644 index 0000000..113072d --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ProjectorNameCommandTests.cs @@ -0,0 +1,51 @@ +using ThreeByte.LinkLib.ProjectorLink.Commands; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class ProjectorNameCommandTests + { + [Fact] + public void GetCommandString_ReturnsCorrectQuery() + { + var cmd = new ProjectorNameCommand(); + Assert.Equal("%1NAME ?", cmd.GetCommandString()); + } + + [Fact] + public void ProcessAnswerString_Success_SetsName() + { + var cmd = new ProjectorNameCommand(); + cmd.ProcessAnswerString("%1NAME=MainHall"); + + Assert.Equal(CommandResponse.SUCCESS, cmd.CmdResponse); + Assert.Equal("MainHall", cmd.Name); + } + + [Fact] + public void DumpToString_ReturnsNameInfo() + { + var cmd = new ProjectorNameCommand(); + cmd.ProcessAnswerString("%1NAME=Theater1"); + + Assert.Equal("Name: Theater1", cmd.DumpToString()); + } + + [Fact] + public void Name_DefaultsToEmpty() + { + var cmd = new ProjectorNameCommand(); + Assert.Equal("", cmd.Name); + } + + [Fact] + public void ProcessAnswerString_Error_ReturnsFalse() + { + var cmd = new ProjectorNameCommand(); + bool result = cmd.ProcessAnswerString("%1NAME ERRA"); + + Assert.False(result); + Assert.Equal(CommandResponse.AUTH_FAILURE, cmd.CmdResponse); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/SerialFrameTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/SerialFrameTests.cs new file mode 100644 index 0000000..a8aa449 --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/SerialFrameTests.cs @@ -0,0 +1,45 @@ +using ThreeByte.LinkLib.SerialLink; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class SerialFrameTests + { + [Fact] + public void Header_CanBeSetAndRetrieved() + { + var frame = new SerialFrame + { + Header = new byte[] { 0x02 } + }; + + Assert.Equal(new byte[] { 0x02 }, frame.Header); + } + + [Fact] + public void Footer_CanBeSetAndRetrieved() + { + var frame = new SerialFrame + { + Footer = new byte[] { 0x03 } + }; + + Assert.Equal(new byte[] { 0x03 }, frame.Footer); + } + + [Fact] + public void HeaderAndFooter_MultiByteSequences() + { + var frame = new SerialFrame + { + Header = new byte[] { 0x02, 0x01 }, + Footer = new byte[] { 0x0D, 0x0A } + }; + + Assert.Equal(2, frame.Header.Length); + Assert.Equal(2, frame.Footer.Length); + Assert.Equal(0x02, frame.Header[0]); + Assert.Equal(0x0A, frame.Footer[1]); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/SerialLinkSettingsTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/SerialLinkSettingsTests.cs new file mode 100644 index 0000000..7f4fed3 --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/SerialLinkSettingsTests.cs @@ -0,0 +1,40 @@ +using System.IO.Ports; +using ThreeByte.LinkLib.SerialLink; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class SerialLinkSettingsTests + { + [Fact] + public void Constructor_SetsAllProperties() + { + var settings = new SerialLinkSettings("COM3", 115200, 7, Parity.Even); + + Assert.Equal("COM3", settings.ComPort); + Assert.Equal(115200, settings.BaudRate); + Assert.Equal(7, settings.DataBits); + Assert.Equal(Parity.Even, settings.Parity); + } + + [Fact] + public void Constructor_DefaultLikeValues() + { + var settings = new SerialLinkSettings("COM1", 9600, 8, Parity.None); + + Assert.Equal("COM1", settings.ComPort); + Assert.Equal(9600, settings.BaudRate); + Assert.Equal(8, settings.DataBits); + Assert.Equal(Parity.None, settings.Parity); + } + + [Fact] + public void Constructor_HighBaudRate() + { + var settings = new SerialLinkSettings("COM5", 921600, 8, Parity.Odd); + + Assert.Equal(921600, settings.BaudRate); + Assert.Equal(Parity.Odd, settings.Parity); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/TcpLinkSettingsTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/TcpLinkSettingsTests.cs new file mode 100644 index 0000000..c1ea765 --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/TcpLinkSettingsTests.cs @@ -0,0 +1,34 @@ +using ThreeByte.LinkLib.TcpLink; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class TcpLinkSettingsTests + { + [Fact] + public void Constructor_SetsAddressAndPort() + { + var settings = new TcpLinkSettings("192.168.1.100", 9100); + + Assert.Equal("192.168.1.100", settings.Address); + Assert.Equal(9100, settings.Port); + } + + [Fact] + public void Constructor_AcceptsHostname() + { + var settings = new TcpLinkSettings("projector.local", 4352); + + Assert.Equal("projector.local", settings.Address); + Assert.Equal(4352, settings.Port); + } + + [Fact] + public void Constructor_AcceptsZeroPort() + { + var settings = new TcpLinkSettings("localhost", 0); + + Assert.Equal(0, settings.Port); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ThreeByte.LinkLib.Tests.csproj b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ThreeByte.LinkLib.Tests.csproj new file mode 100644 index 0000000..652669a --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/ThreeByte.LinkLib.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + latest + false + + + + + + + + + + + + + + + + + + + diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/UdpLinkSettingsTests.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/UdpLinkSettingsTests.cs new file mode 100644 index 0000000..55b89ac --- /dev/null +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.Tests/UdpLinkSettingsTests.cs @@ -0,0 +1,28 @@ +using ThreeByte.LinkLib.UdpLink; +using Xunit; + +namespace ThreeByte.LinkLib.Tests +{ + public class UdpLinkSettingsTests + { + [Fact] + public void Constructor_ThreeArgs_SetsAllProperties() + { + var settings = new UdpLinkSettings("192.168.1.50", 5000, 6000); + + Assert.Equal("192.168.1.50", settings.Address); + Assert.Equal(5000, settings.RemotePort); + Assert.Equal(6000, settings.LocalPort); + } + + [Fact] + public void Constructor_TwoArgs_DefaultsLocalPortToZero() + { + var settings = new UdpLinkSettings("10.0.0.1", 8080); + + Assert.Equal("10.0.0.1", settings.Address); + Assert.Equal(8080, settings.RemotePort); + Assert.Equal(0, settings.LocalPort); + } + } +} diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.UdpLink/AsyncUdpLink.cs b/ThreeByte.LinkLib/ThreeByte.LinkLib.UdpLink/AsyncUdpLink.cs index 58ada62..e1ab2cb 100644 --- a/ThreeByte.LinkLib/ThreeByte.LinkLib.UdpLink/AsyncUdpLink.cs +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.UdpLink/AsyncUdpLink.cs @@ -7,7 +7,7 @@ namespace ThreeByte.LinkLib.UdpLink { - public class AsyncUdpLink + public class AsyncUdpLink : IDisposable { public event EventHandler? IsEnabledChanged; public event EventHandler? ErrorOccurred; diff --git a/ThreeByte.LinkLib/ThreeByte.LinkLib.slnx b/ThreeByte.LinkLib/ThreeByte.LinkLib.slnx index 4f8a600..e78f77f 100644 --- a/ThreeByte.LinkLib/ThreeByte.LinkLib.slnx +++ b/ThreeByte.LinkLib/ThreeByte.LinkLib.slnx @@ -5,4 +5,5 @@ +