food-market/tests/food-market.UnitTests/MassaKScaleTests.cs
nns 25c37415bf feat(pos): реальный протокол весов Масса-К (57600, ENQ/ACK, DC1, кадр) вместо угаданного
По фактическому замеру с ноутбука магазина (11.10): весы Масса-К (USB-COM CH340)
отвечают только на 57600 8N1; рукопожатие ENQ(0x05)→ACK(0x06); запрос DC1(0x11) →
кадр SOH STX «флаг вес единица» CTRL ETX EOT.

- pos.core/Hardware/SerialScale.cs: ISerialTransport (абстракция для тестов) +
  SystemSerialTransport (8N1, DTR/RTS), MassaK.ParseFrame/Handshake/ReadFrame/
  ReadWeight. Control-байт НЕ валидируем (не подтверждён) — только сырой hex в лог.
  Единицы kg по умолчанию, «г/g» → делим на 1000.
- pos.core/Hardware/ScaleDiscovery.cs: SerialScaleReader (+ стабильность по двум
  одинаковым чтениям) и ScaleAutodiscovery — 57600 и Масса-К-кадр ПЕРВЫМИ, общий
  бюджет, отдельно отмечаем занятые порты («порт занят», а не «весы не найдены»).
- pos: ComScale — тонкий адаптер к pos.core; AppServices хранит scale_proto и по
  умолчанию MassaKFrame/57600; ProbeRunner (--probe-hw) печатает сырой hex кадра.
- tests: разбор реального нулевого кадра 01 02 53 20 20 30 2E 30 30 30 6B 67 71 03 04
  и вариантов (1.235 / 12.500 / отрицательный / граммы / мусор до SOH / обрезанный /
  без ACK) + рукопожатие на фейковом порту. 14/14 зелёных.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 08:29:06 +05:00

162 lines
5.6 KiB
C#
Raw 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 void PreloadRx(params byte[] bytes) { foreach (var b in bytes) _rx.Enqueue(b); }
public void Open() { 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();
}
}