using System.Collections.Generic; using System.Linq; using System.Text; using FluentAssertions; using foodmarket.Pos.Core.Hardware; using Xunit; namespace foodmarket.UnitTests; /// Фейковый COM-порт: эмулирует весы Масса-К по фактам с ноутбука магазина /// (ENQ→ACK, DC1→кадр). Позволяет проверить разбор кадра и рукопожатие без железа. public sealed class FakeScaleTransport : ISerialTransport { private readonly Queue _rx = new(); public List Written { get; } = new(); public bool Opened { get; private set; } public bool AnswerAck { get; set; } = true; // отвечает ли ACK на ENQ public byte[]? Frame { get; set; } // кадр, выдаваемый на DC1 public bool Opening { get; set; } // бросить при Open (порт занят и т.п.) public int OpenDelayMs { get; set; } // «зависание» на Open (Bluetooth-порт) public void PreloadRx(params byte[] bytes) { foreach (var b in bytes) _rx.Enqueue(b); } public void Open() { if (OpenDelayMs > 0) System.Threading.Thread.Sleep(OpenDelayMs); if (Opening) throw new System.UnauthorizedAccessException("busy"); Opened = true; } public void DiscardBuffers() { } public void Write(byte[] data, int offset, int count) { for (int i = 0; i < count; i++) { var b = data[offset + i]; Written.Add(b); if (b == MassaK.ENQ && AnswerAck) _rx.Enqueue(MassaK.ACK); if (b == MassaK.DC1 && Frame is not null) foreach (var fb in Frame) _rx.Enqueue(fb); } } public int ReadByte(int timeoutMs) => _rx.Count > 0 ? _rx.Dequeue() : -1; public void Dispose() { } } public class MassaKScaleTests { // Кадр из реального замера: 01 02 'S 0.000kg' 'q' 03 04 private static readonly byte[] Frame0 = { 0x01, 0x02, 0x53, 0x20, 0x20, 0x30, 0x2E, 0x30, 0x30, 0x30, 0x6B, 0x67, 0x71, 0x03, 0x04 }; private static byte[] BuildFrame(string payloadText, byte control = 0x71) { var body = new List { MassaK.SOH, MassaK.STX }; body.AddRange(Encoding.ASCII.GetBytes(payloadText)); body.Add(control); body.Add(MassaK.ETX); body.Add(MassaK.EOT); return body.ToArray(); } [Fact] public void Parses_real_zero_frame_from_shop_laptop() { var r = MassaK.ParseFrame(Frame0, out var err); err.Should().BeNull(); r.Should().NotBeNull(); r!.Value.Kg.Should().Be(0.000m); r.Value.Flag.Should().Be('S'); r.Value.Unit.Should().Be("kg"); r.Value.Control.Should().Be(0x71); r.Value.RawHex.Should().StartWith("01 02 53"); } [Theory] [InlineData("S 1.235kg", 1.235)] [InlineData("S 12.500kg", 12.500)] [InlineData("S 0.000kg", 0.000)] [InlineData("S -1.235kg", -1.235)] public void Parses_various_weights(string payload, double expected) { var r = MassaK.ParseFrame(BuildFrame(payload), out var err); err.Should().BeNull(); r!.Value.Kg.Should().Be((decimal)expected); r.Value.Unit.Should().Be("kg"); } [Fact] public void Grams_payload_is_converted_to_kg() { var r = MassaK.ParseFrame(BuildFrame("S 1235g"), out _); r!.Value.Kg.Should().Be(1.235m); r.Value.Unit.Should().Be("g"); } [Fact] public void Skips_garbage_before_soh() { var withGarbage = new byte[] { 0xFF, 0x00, 0x7E }.Concat(Frame0).ToArray(); var r = MassaK.ParseFrame(withGarbage, out var err); err.Should().BeNull(); r!.Value.Kg.Should().Be(0m); } [Fact] public void Truncated_frame_without_etx_is_error() { var bad = new byte[] { 0x01, 0x02, 0x53, 0x20, 0x30, 0x2E, 0x30 }; // нет ETX/EOT var r = MassaK.ParseFrame(bad, out var err); r.Should().BeNull(); err.Should().Contain("ETX"); } [Fact] public void Pure_garbage_without_soh_is_error() { var r = MassaK.ParseFrame(new byte[] { 0xAA, 0xBB, 0xCC }, out var err); r.Should().BeNull(); err.Should().Contain("SOH"); } [Fact] public void Handshake_succeeds_when_scale_answers_ack() { var t = new FakeScaleTransport { AnswerAck = true }; MassaK.Handshake(t).Should().BeTrue(); t.Written.Should().Contain(MassaK.ENQ); } [Fact] public void Handshake_fails_without_ack() { var t = new FakeScaleTransport { AnswerAck = false }; MassaK.Handshake(t, ackTimeoutMs: 50, attempts: 2).Should().BeFalse(); } [Fact] public void Full_read_cycle_handshake_then_frame() { var t = new FakeScaleTransport { AnswerAck = true, Frame = BuildFrame("S 1.235kg") }; var (reading, hex, err) = MassaK.ReadWeight(t); err.Should().BeNull(); reading!.Value.Kg.Should().Be(1.235m); hex.Should().Contain("01 02"); t.Written.Should().Contain(MassaK.DC1); } [Fact] public void Read_cycle_reports_missing_ack() { var t = new FakeScaleTransport { AnswerAck = false }; var (reading, _, err) = MassaK.ReadWeight(t, ackTimeoutMs: 50); reading.Should().BeNull(); err.Should().Contain("ACK"); } [Fact] public void ReadStable_detects_stable_and_unstable() { // стабильно: один и тот же вес дважды var stableFrame = BuildFrame("S 2.000kg"); var (kg, stable) = SerialScaleReader.ReadStable("COMX", 57600, ScaleProto.MassaKFrame, transportFactory: (_, _) => new FakeScaleTransport { AnswerAck = true, Frame = stableFrame }, gapMs: 1); kg.Should().Be(2.000m); stable.Should().BeTrue(); } }