using System.Diagnostics; using System.IO.Pipes; using VRio.Core.Device; using VRio.Core.Protocol; using Xunit; namespace VRio.Core.Tests; public class PipeFrameDecoderTests { [Fact] public void Data_frame_delivers_payload() { var decoder = new PipeFrameDecoder(); byte[]? got = null; decoder.Data += (buf, count) => got = buf.Take(count).ToArray(); Assert.True(decoder.Feed(new byte[] { 0x00, 0x03, 0xAA, 0xBB, 0xCC }, 5)); Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, got); } [Fact] public void Frames_survive_any_split_across_reads() { var decoder = new PipeFrameDecoder(); var data = new List(); var lines = new List(); decoder.Data += (buf, count) => data.AddRange(buf.Take(count)); decoder.Lines += lines.Add; // A lines frame, a 2-byte data frame, a 1-byte data frame — one byte at a time. byte[] stream = { 0x01, 0x03, 0x00, 0x02, 0x81, 0x01, 0x00, 0x01, 0xFC }; foreach (byte b in stream) Assert.True(decoder.Feed(new[] { b }, 1)); Assert.Equal(new byte[] { 0x03 }, lines); Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, data); } [Fact] public void Multiple_frames_in_one_read_all_deliver() { var decoder = new PipeFrameDecoder(); var data = new List(); var lines = new List(); decoder.Data += (buf, count) => data.AddRange(buf.Take(count)); decoder.Lines += lines.Add; byte[] chunk = { 0x00, 0x01, 0x42, 0x01, 0x00, 0x00, 0x01, 0x43 }; Assert.True(decoder.Feed(chunk, chunk.Length)); Assert.Equal(new byte[] { 0x42, 0x43 }, data); Assert.Equal(new byte[] { 0x00 }, lines); } [Fact] public void Unknown_frame_type_poisons_the_decoder() { var decoder = new PipeFrameDecoder(); Assert.False(decoder.Feed(new byte[] { 0x7F }, 1)); Assert.Contains("0x7F", decoder.Violation); // Poisoned: even a healthy frame is rejected until Reset. Assert.False(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3)); decoder.Reset(); Assert.Null(decoder.Violation); Assert.True(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3)); } [Fact] public void Zero_length_data_frame_is_a_violation() { var decoder = new PipeFrameDecoder(); Assert.False(decoder.Feed(new byte[] { 0x00, 0x00 }, 2)); Assert.Contains("zero-length", decoder.Violation); } } public class VRioPipeServiceTests { /// /// A contract-speaking client: connects to the service's pipe, deframes /// everything it receives (timestamping data bytes for the pacing test), /// and sends raw frame bytes. /// private sealed class TestClient : IDisposable { private readonly NamedPipeClientStream _pipe; private readonly Thread _reader; private readonly object _sync = new(); private readonly List _data = new(); private readonly List _dataTicks = new(); private readonly List _lines = new(); private volatile bool _closed; public TestClient(string pipeName) { _pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); // The server's listener may still be spinning up; retry briefly. var connectClock = Stopwatch.StartNew(); while (true) { try { _pipe.Connect(200); break; } catch (Exception ex) when (ex is IOException or TimeoutException) { if (connectClock.ElapsedMilliseconds >= 3000) throw; Thread.Sleep(25); } } var decoder = new PipeFrameDecoder(); decoder.Data += (buf, count) => { long now = Stopwatch.GetTimestamp(); lock (_sync) for (int i = 0; i < count; i++) { _data.Add(buf[i]); _dataTicks.Add(now); } }; decoder.Lines += b => { lock (_sync) _lines.Add(b); }; _reader = new Thread(() => { var buffer = new byte[512]; while (true) { int n; try { n = _pipe.Read(buffer, 0, buffer.Length); } catch (Exception ex) when (ex is IOException or ObjectDisposedException) { break; } if (n == 0 || !decoder.Feed(buffer, n)) break; } _closed = true; }) { IsBackground = true }; _reader.Start(); } public bool Closed => _closed; public byte[] Data { get { lock (_sync) return _data.ToArray(); } } public long[] DataTicks { get { lock (_sync) return _dataTicks.ToArray(); } } public byte[] Lines { get { lock (_sync) return _lines.ToArray(); } } public void SendRaw(params byte[] bytes) { _pipe.Write(bytes, 0, bytes.Length); _pipe.Flush(); } public void SendData(byte[] payload) { var frame = new byte[2 + payload.Length]; frame[0] = PipeFraming.DataType; frame[1] = (byte)payload.Length; payload.CopyTo(frame, 2); SendRaw(frame); } public void Dispose() { _pipe.Dispose(); _reader.Join(1000); } } private static string UniqueName() => $"vrio-test-{Guid.NewGuid():N}"; private static void WaitFor(Func condition, int timeoutMs = 2000) { var clock = Stopwatch.StartNew(); while (!condition()) { Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition"); Thread.Sleep(10); } } [Fact] public void Client_gets_board_present_lines_on_connect() { string name = UniqueName(); var device = new VRioDevice(); using var service = new VRioPipeService(device, name); service.Start(); using var client = new TestClient(name); WaitFor(() => client.Lines.Length > 0); Assert.Equal(PipeFraming.LinesPresent, client.Lines[0]); // DTR+RTS: board present } [Fact] public void Peer_dtr_edges_surface_as_host_handshake() { string name = UniqueName(); var device = new VRioDevice(); using var service = new VRioPipeService(device, name); var edges = new List(); service.HostHandshake += high => { lock (edges) edges.Add(high); }; service.Start(); using var client = new TestClient(name); client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr); // DTR up client.SendRaw(PipeFraming.LinesType, 0x00); // DTR down (the reset pulse) WaitFor(() => { lock (edges) return edges.Count == 2; }); lock (edges) Assert.Equal(new[] { true, false }, edges); } [Fact] public void Version_request_round_trips_ack_then_reply() { string name = UniqueName(); var device = new VRioDevice(); using var service = new VRioPipeService(device, name); service.Start(); using var client = new TestClient(name); WaitFor(() => client.Lines.Length > 0); // fully connected client.SendData(PacketBuilder.Build(RioCommand.VersionRequest)); byte[] expected = new[] { (byte)RioControl.Ack } .Concat(PacketBuilder.VersionReply(4, 2)).ToArray(); WaitFor(() => client.Data.Length >= expected.Length); Assert.Equal(expected, client.Data); } [Fact] public void Reply_bytes_are_paced_at_the_wire_rate() { string name = UniqueName(); var device = new VRioDevice(); using var service = new VRioPipeService(device, name); service.Start(); using var client = new TestClient(name); WaitFor(() => client.Lines.Length > 0); client.SendData(PacketBuilder.Build(RioCommand.VersionRequest)); // ACK + 4-byte VersionReply = 5 bytes = 4 inter-byte gaps ≈ 4.2 ms at // 9600 baud. An unpaced writer would land them in well under 1 ms. WaitFor(() => client.Data.Length >= 5); long[] ticks = client.DataTicks; double spanMs = (ticks[4] - ticks[0]) * 1000.0 / Stopwatch.Frequency; Assert.True(spanMs >= 2.5, $"5 reply bytes arrived in {spanMs:F2} ms — pipe TX is not paced"); } [Fact] public void Unknown_frame_type_drops_the_client_and_server_recovers() { string name = UniqueName(); var device = new VRioDevice(); using var service = new VRioPipeService(device, name); service.Start(); using (var bad = new TestClient(name)) { WaitFor(() => bad.Lines.Length > 0); bad.SendRaw(0x7F); // not a frame type WaitFor(() => bad.Closed); // contract: log + drop, no resync } // The listener must come back for the next client. using var good = new TestClient(name); WaitFor(() => good.Lines.Length > 0); Assert.Equal(PipeFraming.LinesPresent, good.Lines[0]); } [Fact] public void Client_disconnect_drops_dtr_low() { string name = UniqueName(); var device = new VRioDevice(); using var service = new VRioPipeService(device, name); var edges = new List(); service.HostHandshake += high => { lock (edges) edges.Add(high); }; service.Start(); using (var client = new TestClient(name)) { client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr); WaitFor(() => { lock (edges) return edges.Count == 1; }); } // client leaves with DTR still high WaitFor(() => { lock (edges) return edges.Count == 2; }); lock (edges) Assert.Equal(new[] { true, false }, edges); } [Fact] public void Data_split_mid_frame_still_reaches_the_device() { string name = UniqueName(); var device = new VRioDevice(); using var service = new VRioPipeService(device, name); var lamps = new List<(int Address, byte State)>(); device.LampChanged += (address, state) => { lock (lamps) lamps.Add((address, state)); }; service.Start(); using var client = new TestClient(name); WaitFor(() => client.Lines.Length > 0); // LampRequest 0x00 ← 0x01, framed, then torn in half mid-payload. byte[] packet = PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x00, 0x01 }); var frame = new byte[] { PipeFraming.DataType, (byte)packet.Length }.Concat(packet).ToArray(); client.SendRaw(frame.Take(3).ToArray()); Thread.Sleep(20); client.SendRaw(frame.Skip(3).ToArray()); WaitFor(() => { lock (lamps) return lamps.Count == 1; }); lock (lamps) Assert.Equal((0x00, (byte)0x01), lamps[0]); } }