food-market/tests/food-market.UnitTests/MassaKScaleTests.cs
nns f15a12695c fix(pos): автопоиск весов не виснет на Bluetooth-портах (реальный баг на кассе 11.10)
На ноутбуке магазина автопоиск спотыкался о Bluetooth-порты (COM7/COM8 — Open/Write
висят), выбирал по умолчанию BT и не доходил до COM22 (USB-SERIAL CH340, где весы).
Парсер/протокол исправны (ручной выбор COM22 57600 работает) — баг только в поиске.

- ScaleAutodiscovery.DiscoverAsync: тирами по типу порта — USB-serial первыми,
  неопознанные в середине, Bluetooth последними; на каждый порт ЖЁСТКИЙ таймаут
  (Open обёрнут в задачу с гонкой против Task.Delay — SerialPort.Open на BT виснет);
  порты перебираются параллельно (до 4), общий бюджет ~15 с; любая ошибка/таймаут →
  следующий порт; по каждому порту строка в лог («COM22 CH340: ENQ→ACK, вес 0.000 кг»,
  «COM7 Bluetooth: пропущен», «COM15: нет ответа»).
- HardwareProbe: Classify (CH340/Prolific/FTDI/CP210x → USB-serial; BTHENUM/Bluetooth
  → Bluetooth; ttyUSB/ttyACM → USB-serial), ClassifyPorts (без открытия портов),
  OpenCloseBounded (жёсткий таймаут — листинг не виснет на BT). ListComPorts не
  открывает Bluetooth-порты.
- HwCheck: по умолчанию выбирается НЕ Bluetooth-порт; окно показывает журнал
  автопоиска по портам; список портов помечает [USB-serial]/[Bluetooth].
- Основная касса (экран «Оборудование») — та же DiscoverScaleAsync; --probe-hw не
  открывает BT-порты.
- Из zip hardware-check убран ярлык «(с тестовой печатью).bat» (печать — отдельно
  по слову владельца).
- Тесты: зависающий порт (Open 30 с) не задерживает поиск > таймаута, живой порт
  вторым — найден; USB-serial проверяется раньше Bluetooth (BT не трогаем). 53/53.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 09:03:45 +05:00

168 lines
5.8 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Linq;
using System.Text;
using FluentAssertions;
using foodmarket.Pos.Core.Hardware;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Фейковый COM-порт: эмулирует весы Масса-К по фактам с ноутбука магазина
/// (ENQ→ACK, DC1→кадр). Позволяет проверить разбор кадра и рукопожатие без железа.</summary>
public sealed class FakeScaleTransport : ISerialTransport
{
private readonly Queue<int> _rx = new();
public List<byte> 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<byte> { 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();
}
}