food-market/tests/food-market.UnitTests/PosCoreTests.cs
nns 195dfb1a2c feat(pos): весовой товар на кассе — вес с весов/вручную + автопоиск весов
Часть 3 + автопоиск (данные sysadmin: касса МойСклад спамит «Порт не выбран»):
- Весовой товар при сканировании/плитке → вес с весов (если на связи), иначе
  модалка ручного ввода веса. Сумма = цена/кг × вес; подпись «1,235 кг × 690 ₸/кг».
- ScaleMonitor (pos.core): опрос ТОЛЬКО по требованию (при весовом товаре), мягкая
  деградация на ручной ввод, запись о потере/восстановлении связи РОВНО ОДИН раз
  (не сотни «Таймаут»). Юнит-тесты (100 опросов → 1 запись).
- ScaleDiscovery (pos): автоскан всех COM-портов (вкл. USB-COM CH340/Prolific) ×
  протоколы Масса-К/CAS × скорости; кнопка «Найти весы» в верхней панели; найденный
  порт запоминается (kv) и восстанавливается при старте.
- UnitShort («шт»/«кг»/«л») протянут через sync→SQLite→PosProduct/CartLine; цена
  весового показывается «690 ₸/кг» вместо кода ОКЕИ «690 ₸/796».
- Разбор этикеток в кассе — по настройкам орг (по умолчанию ВЫКЛ, точный штрихкод).
- Юнит-тесты 321/321 (обновлены PosBarcodeTests под дефолт-off).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:17:35 +05:00

558 lines
25 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 FluentAssertions;
using foodmarket.Pos.Core.Auth;
using foodmarket.Pos.Core.Data;
using foodmarket.Pos.Core.Logic;
using foodmarket.Pos.Core.Models;
using foodmarket.Pos.Core.Sync;
using foodmarket.Shared.Pos.V1;
using Xunit;
namespace foodmarket.UnitTests;
public class PosBarcodeTests
{
[Fact]
public void Normal_barcode_is_not_weighted()
{
var r = BarcodeParser.Parse("4870204010101");
r.IsWeighted.Should().BeFalse();
r.Lookup.Should().Be("4870204010101");
}
// По умолчанию разбор этикеток ВЫКЛ (решение владельца): явно включаем настройками.
private static readonly foodmarket.Shared.Barcodes.WeightedBarcodeSettings On =
foodmarket.Shared.Barcodes.WeightedBarcodeSettings.Default with { Enabled = true };
[Fact]
public void Default_disabled_treats_weighted_as_plain()
{
var r = BarcodeParser.Parse("2112345015007");
r.IsWeighted.Should().BeFalse("по умолчанию разбор этикеток выключен");
r.Lookup.Should().Be("2112345015007");
}
[Fact]
public void Weight_prefix_20_extracts_plu_and_kg()
{
// 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг (при включённом разборе)
var r = BarcodeParser.Parse("2112345015007", On);
r.IsWeighted.Should().BeTrue();
r.Lookup.Should().Be("12345");
r.EmbeddedWeight.Should().Be(1.500m);
r.EmbeddedPrice.Should().BeNull();
}
[Fact]
public void Price_prefix_25_extracts_plu_and_price()
{
// 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸ (при включённом разборе)
var r = BarcodeParser.Parse("2554321123456", On);
r.IsWeighted.Should().BeTrue();
r.Lookup.Should().Be("54321");
r.EmbeddedPrice.Should().Be(123.45m);
r.EmbeddedWeight.Should().BeNull();
}
}
public class PosCartTests
{
private static PosProduct Piece(decimal price = 500m) => new()
{ Id = Guid.NewGuid(), Name = "Шт товар", Barcodes = new[] { "111" }, UnitCode = "шт", Packaging = PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price };
private static PosProduct Weighed(decimal price = 1000m) => new()
{ Id = Guid.NewGuid(), Name = "Вес товар", Barcodes = new[] { "222" }, UnitCode = "кг", Packaging = PosPackaging.Weight, VatPercent = 12m, VatEnabled = true, Price = price };
[Fact]
public void Piece_scans_increment_quantity()
{
var c = new Cart(); var p = Piece();
c.AddProduct(p); c.AddProduct(p); c.AddProduct(p);
c.Lines.Should().HaveCount(1);
c.Lines[0].Quantity.Should().Be(3);
c.Totals().Total.Should().Be(1500m);
}
[Fact]
public void Weighed_requires_weight_and_is_separate_line()
{
var c = new Cart(); var w = Weighed(1000m);
c.AddProduct(w, 1.5m);
c.AddProduct(w, 0.25m);
c.Lines.Should().HaveCount(2);
c.Totals().Total.Should().Be(1500m + 250m);
}
[Fact]
public void Line_and_cart_discounts_apply_and_clamp()
{
var c = new Cart(); var p = Piece(500m);
var line = c.AddProduct(p, 4m); // 2000
c.SetLineDiscount(line, 300m); // 1700
c.SetCartDiscount(200m); // 1500
c.Totals().Total.Should().Be(1500m);
// перебор скидки обрезается
c.SetLineDiscount(line, 999999m);
c.Lines[0].Discount.Should().Be(2000m);
}
[Fact]
public void Remove_and_clear()
{
var c = new Cart(); var line = c.AddProduct(Piece());
c.Remove(line); c.IsEmpty.Should().BeTrue();
}
}
public class PosPaymentShiftTests
{
[Fact]
public void Cash_overpay_gives_change()
{
var r = PaymentMath.Compute(1500m, paidCash: 2000m, paidCard: 0m);
r.Sufficient.Should().BeTrue();
r.Change.Should().Be(500m);
}
[Fact]
public void Mixed_exact_no_change()
{
var r = PaymentMath.Compute(1500m, paidCash: 500m, paidCard: 1000m);
r.Sufficient.Should().BeTrue();
r.Change.Should().Be(0m);
}
[Fact]
public void Underpay_is_insufficient_with_shortfall()
{
var r = PaymentMath.Compute(1500m, paidCash: 1000m, paidCard: 0m);
r.Sufficient.Should().BeFalse();
r.Shortfall.Should().Be(500m);
}
[Fact]
public void Shift_expected_and_difference()
{
var exp = ShiftMath.ExpectedCash(5000m, 3775m, 1500m, 10000m, 3000m);
exp.Should().Be(14275m);
ShiftMath.CashDifference(14775m, exp).Should().Be(500m);
}
}
public class PosStoreTests
{
private static PosSyncResponse Sync(params (Guid id, string name, string bc, decimal price, int pack)[] items) => new()
{
ServerTime = DateTime.UtcNow,
Products = items.Select(i => new ProductSyncDto
{
Id = i.id, Name = i.name, Barcodes = new[] { i.bc }, UnitCode = i.pack == 2 ? "кг" : "шт",
Packaging = i.pack, VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow,
}).ToList(),
Prices = items.Select(i => new PriceSyncDto
{
ProductId = i.id, PriceTypeId = Guid.NewGuid(), PriceTypeName = "Розн", IsSystem = true,
Amount = i.price, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow,
}).ToList(),
Stocks = Array.Empty<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
};
private static PosSyncResponse SyncWithStocks(
(Guid id, string name, decimal price, int pack)[] products,
(Guid id, decimal qty)[] stocks) => new()
{
ServerTime = DateTime.UtcNow,
Products = products.Select(i => new ProductSyncDto
{
Id = i.id, Name = i.name, Barcodes = Array.Empty<string>(), UnitCode = i.pack == 2 ? "кг" : "шт",
Packaging = i.pack, VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow,
}).ToList(),
Prices = products.Select(i => new PriceSyncDto
{
ProductId = i.id, PriceTypeId = Guid.NewGuid(), PriceTypeName = "Розн", IsSystem = true,
Amount = i.price, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow,
}).ToList(),
Stocks = stocks.Select(x => new StockSyncDto { ProductId = x.id, StoreId = Guid.NewGuid(), Quantity = x.qty, AsOf = DateTime.UtcNow }).ToList(),
StocksFullSnapshot = true,
Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
};
private static PosSyncResponse StockDelta((Guid id, decimal qty)[] stocks) => new()
{
ServerTime = DateTime.UtcNow, Products = Array.Empty<ProductSyncDto>(), Prices = Array.Empty<PriceSyncDto>(),
Stocks = stocks.Select(x => new StockSyncDto { ProductId = x.id, StoreId = Guid.NewGuid(), Quantity = x.qty, AsOf = DateTime.UtcNow }).ToList(),
StocksFullSnapshot = false,
Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
};
[Fact]
public void Stocks_persisted_negatives_counted_and_listed()
{
using var s = PosStore.InMemory();
var a = Guid.NewGuid(); var b = Guid.NewGuid(); var c = Guid.NewGuid();
s.ApplySync(SyncWithStocks(
new[] { (a, "Молоко", 450m, 1), (b, "Сахар", 520m, 1), (c, "Яблоки", 690m, 2) },
new[] { (a, 5m), (b, -2m), (c, -1.5m) }));
s.GetStock(a).Should().Be(5m);
s.GetStock(b).Should().Be(-2m);
s.GetStock(Guid.NewGuid()).Should().BeNull("нет записи = остаток неизвестен (0)");
s.NegativeCount().Should().Be(2);
var list = s.NegativeStocks();
list.Should().HaveCount(2);
list[0].Quantity.Should().Be(-2m, "сортировка по возрастанию остатка (самый глубокий минус первым)");
}
[Fact]
public void Stocks_are_full_snapshot_replaced_each_sync()
{
using var s = PosStore.InMemory();
var a = Guid.NewGuid(); var b = Guid.NewGuid();
s.ApplySync(SyncWithStocks(new[] { (a, "A", 10m, 1), (b, "B", 10m, 1) }, new[] { (a, -3m), (b, -1m) }));
s.NegativeCount().Should().Be(2);
// Следующий ПОЛНЫЙ снимок: A вернулся к 0 (выпал из снимка), B всё ещё -1.
s.ApplySync(SyncWithStocks(new[] { (a, "A", 10m, 1), (b, "B", 10m, 1) }, new[] { (b, -1m) }));
s.GetStock(a).Should().BeNull("A вернулся к 0 → выпал из полного снимка остатков");
s.NegativeCount().Should().Be(1, "в минусе остался только B");
}
[Fact]
public void Stock_delta_upserts_nonzero_and_removes_zeroed_without_touching_others()
{
using var s = PosStore.InMemory();
var a = Guid.NewGuid(); var b = Guid.NewGuid(); var c = Guid.NewGuid();
// Полный снимок: три остатка.
s.ApplySync(SyncWithStocks(
new[] { (a, "A", 10m, 1), (b, "B", 10m, 1), (c, "C", 10m, 1) },
new[] { (a, 5m), (b, 3m), (c, 7m) }));
s.GetStock(a).Should().Be(5m);
// ДЕЛЬТА: A изменился на 2, B ушёл в 0 (удалить), C не прислан (не трогаем).
s.ApplySync(StockDelta(new[] { (a, 2m), (b, 0m) }));
s.GetStock(a).Should().Be(2m, "A обновлён дельтой");
s.GetStock(b).Should().BeNull("B ушёл в 0 → удалён");
s.GetStock(c).Should().Be(7m, "C не менялся и не перезаписывался дельтой");
}
[Fact]
public void Catalog_find_by_barcode_and_name()
{
using var s = PosStore.InMemory();
var id = Guid.NewGuid();
s.ApplySync(Sync((id, "Молоко 2.5%", "4870001112223", 450m, 1)));
s.ProductCount().Should().Be(1);
s.FindByBarcode("4870001112223")!.Name.Should().Be("Молоко 2.5%");
s.FindByBarcode("4870001112223")!.Price.Should().Be(450m);
s.SearchByName("молок").Should().ContainSingle();
}
[Fact]
public void Queue_enqueue_pending_marksent()
{
using var s = PosStore.InMemory();
var sale = new PosSaleDto
{
ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, Payment = 0, PaidCash = 100m, PaidCard = 0m,
Lines = new[] { new PosSaleLineDto { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 100m, Discount = 0m } },
};
s.EnqueueSale(sale);
s.EnqueueSale(sale); // повтор не дублирует (PK ClientSaleId)
s.PendingCount().Should().Be(1);
s.MarkSent(sale.ClientSaleId);
s.PendingCount().Should().Be(0);
}
}
public class PosSyncKeyTests
{
[Fact]
public void Batch_key_is_stable_for_same_set_regardless_of_order()
{
var a = Guid.NewGuid(); var b = Guid.NewGuid();
SyncService.StableBatchKey(new[] { a, b }).Should().Be(SyncService.StableBatchKey(new[] { b, a }));
}
[Fact]
public void Batch_key_differs_for_different_sets()
{
SyncService.StableBatchKey(new[] { Guid.NewGuid() })
.Should().NotBe(SyncService.StableBatchKey(new[] { Guid.NewGuid() }));
}
}
/// <summary>Офлайн-сценарий задачи (п.8): нет сети → кассир пробивает 5 чеков →
/// появляется сеть → синхронизация → ровно 5 продаж на «сервере», без дублей
/// даже при повторном пуше. Прогоняется против фейкового IServerApi, который
/// повторяет серверную идемпотентность по ClientSaleId.</summary>
public class PosOfflineFlowTests
{
/// <summary>Фейковый сервер: хранит принятые продажи по ClientSaleId.
/// Повторный приём той же продажи не создаёт дубля (как реальный сервер).</summary>
private sealed class FakeServer : IServerApi
{
public readonly Dictionary<Guid, Guid> ServerByClient = new();
public int BatchCalls;
public bool Online;
public Task<PosSaleBatchResponse> AcceptSalesAsync(PosSaleBatchDto batch, CancellationToken ct = default)
{
if (!Online) throw new HttpRequestException("нет сети");
BatchCalls++;
bool anyNew = false;
var accepted = new List<PosSaleAcceptedDto>();
foreach (var s in batch.Sales)
{
if (!ServerByClient.TryGetValue(s.ClientSaleId, out var sid))
{ sid = Guid.NewGuid(); ServerByClient[s.ClientSaleId] = sid; anyNew = true; }
accepted.Add(new PosSaleAcceptedDto { ClientSaleId = s.ClientSaleId, ServerSaleId = sid, ServerSaleNumber = $"ПР-{sid:N}".Substring(0, 10) });
}
return Task.FromResult(new PosSaleBatchResponse
{
IdempotencyKey = batch.IdempotencyKey, Accepted = accepted,
Failed = Array.Empty<PosSaleFailedDto>(), ReplayedFromCache = !anyNew,
});
}
public Task<QuickCreatedProductDto> QuickCreateProductAsync(QuickCreateProductBody body, CancellationToken ct = default) => throw new NotImplementedException();
public Task<PosVersionDto> VersionAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<PosReturnLookupDto> ReturnLookupAsync(string number, CancellationToken ct = default) => throw new NotImplementedException();
public Task<PosSyncResponse> SyncAsync(DateTime? since, CancellationToken ct = default) => throw new NotImplementedException();
public Task<MeDto> MeAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<PagedDto<RetailPointDto>> RetailPointsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException();
public Task<PagedDto<MoneyAccountDto>> MoneyAccountsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException();
public Task<Refit.ApiResponse<CurrentShiftDto>> CurrentShiftAsync(Guid retailPointId, CancellationToken ct = default) => throw new NotImplementedException();
public Task<OpenShiftResultDto> OpenShiftAsync(OpenShiftBody body, CancellationToken ct = default) => throw new NotImplementedException();
public Task<CloseShiftResultDto> CloseShiftAsync(Guid id, CloseShiftBody body, CancellationToken ct = default) => throw new NotImplementedException();
public Task DrawerAsync(Guid id, DrawerBody body, CancellationToken ct = default) => throw new NotImplementedException();
public Task<ShiftReportDto> ShiftReportAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
}
private static PosSaleDto Sale() => new()
{
ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow,
Payment = 0, PaidCash = 500m, PaidCard = 0m,
Lines = new[] { new PosSaleLineDto { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 500m, Discount = 0m } },
};
[Fact]
public async Task No_network_then_five_sales_then_sync_yields_exactly_five_no_duplicates()
{
using var store = PosStore.InMemory();
var server = new FakeServer { Online = false };
var sync = new SyncService(store, server);
// 1) Сети нет — попытка пуша кидает, очередь не теряется.
store.EnqueueSale(Sale());
await Assert.ThrowsAnyAsync<Exception>(() => sync.PushSalesAsync());
server.ServerByClient.Should().BeEmpty("офлайн — на сервер ничего не ушло");
// 2) Кассир пробивает ещё 4 чека офлайн — всего 5 в очереди.
for (int i = 0; i < 4; i++) store.EnqueueSale(Sale());
store.PendingCount().Should().Be(5);
// 3) Появилась сеть — синхронизация.
server.Online = true;
var push = await sync.PushSalesAsync();
push.Accepted.Should().Be(5);
push.Failed.Should().Be(0);
store.PendingCount().Should().Be(0, "все отправленные помечены sent");
server.ServerByClient.Should().HaveCount(5, "на сервере ровно 5 чеков");
// 4) Повторный пуш (например, двойной клик/ретрай) не плодит дублей.
var replay = await sync.PushSalesAsync();
replay.Accepted.Should().Be(0, "очередь уже пуста — слать нечего");
server.ServerByClient.Should().HaveCount(5, "по-прежнему ровно 5");
}
[Fact]
public async Task Resend_of_same_queue_is_idempotent_on_server()
{
using var store = PosStore.InMemory();
var server = new FakeServer { Online = true };
var sync = new SyncService(store, server);
for (int i = 0; i < 5; i++) store.EnqueueSale(Sale());
// Эмулируем: пуш прошёл на сервере, но ответ не дошёл (касса не пометила sent).
var pending = store.PendingSales();
await server.AcceptSalesAsync(new PosSaleBatchDto { IdempotencyKey = SyncService.StableBatchKey(pending.Select(s => s.ClientSaleId)), Sales = pending });
server.ServerByClient.Should().HaveCount(5);
// Касса повторяет весь набор — сервер распознаёт по ClientSaleId, дублей нет.
var push = await sync.PushSalesAsync();
push.Accepted.Should().Be(5);
server.ServerByClient.Should().HaveCount(5, "повтор того же набора не создал новых чеков");
store.PendingCount().Should().Be(0);
}
}
/// <summary>Сборка серверного чека из корзины: распределение скидки на чек по
/// строкам (сервер понимает только построчную скидку), способ оплаты, привязка
/// к смене/точке, возвраты.</summary>
public class PosCheckoutTests
{
private static PosProduct P(decimal price, bool weighed = false) => new()
{
Id = Guid.NewGuid(), Name = "Товар", Barcodes = new[] { "1" }, UnitCode = weighed ? "кг" : "шт",
Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price,
};
[Fact]
public void Cart_discount_is_distributed_across_lines_and_sums_exactly()
{
var cart = new Cart();
cart.AddProduct(P(100m), 1); // 100
cart.AddProduct(P(200m), 1); // 200
cart.AddProduct(P(700m), 1); // 700 → сумма 1000
cart.SetCartDiscount(100m); // 10% на чек
var sale = Checkout.BuildSale(cart, paidCash: 900m, paidCard: 0m,
new CheckoutContext(null, null, null));
// Сумма построчных скидок точно равна скидке на чек.
sale.Lines.Sum(l => l.Discount).Should().Be(100m);
// Итог сервера (Σ qty*price − Σ disc) совпадает с итогом кассы.
var serverTotal = sale.Lines.Sum(l => l.Quantity * l.UnitPrice - l.Discount);
serverTotal.Should().Be(cart.Totals().Total);
}
[Fact]
public void Payment_method_is_inferred_from_cash_and_card()
{
var cart = new Cart(); cart.AddProduct(P(500m), 1);
Checkout.BuildSale(cart, 500m, 0m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Cash);
Checkout.BuildSale(cart, 0m, 500m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Card);
Checkout.BuildSale(cart, 200m, 300m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Mixed);
}
[Fact]
public void Shift_and_point_context_is_carried_into_sale()
{
var cart = new Cart(); cart.AddProduct(P(500m), 1);
var rp = Guid.NewGuid(); var shift = Guid.NewGuid(); var cashier = Guid.NewGuid();
var sale = Checkout.BuildSale(cart, 500m, 0m, new(cashier, rp, shift));
sale.RetailPointId.Should().Be(rp);
sale.CashShiftId.Should().Be(shift);
sale.CashierUserId.Should().Be(cashier);
sale.IsReturn.Should().BeFalse();
}
[Fact]
public void Return_sets_flag_and_reference()
{
var cart = new Cart(); cart.AddProduct(P(500m), 1);
var refSale = Guid.NewGuid();
var sale = Checkout.BuildSale(cart, 0m, 0m, new(null, null, null), isReturn: true, referenceSaleId: refSale);
sale.IsReturn.Should().BeTrue();
sale.ReferenceSaleId.Should().Be(refSale);
}
[Fact]
public void Empty_cart_throws()
{
var cart = new Cart();
var act = () => Checkout.BuildSale(cart, 0m, 0m, new(null, null, null));
act.Should().Throw<InvalidOperationException>();
}
}
public class PosUpdateCheckTests
{
[Theory]
[InlineData("2026.10.7", "2026.10.8", true)]
[InlineData("2026.10.8", "2026.10.8", false)]
[InlineData("2026.10.8", "2026.10.7", false)]
[InlineData("1.9.0", "1.10.0", true)]
[InlineData("2026.9.30", "2026.10.1", true)]
[InlineData(null, "2026.10.1", true)]
[InlineData("2026.10.1", null, false)]
[InlineData("1.2.3", "1.2.3-beta", false)]
public void Version_comparison(string? local, string? remote, bool expected)
=> UpdateCheck.IsNewer(local, remote).Should().Be(expected);
}
public class PosParkedCartTests
{
private static PosProduct P(decimal price = 300m) => new()
{ Id = Guid.NewGuid(), Name = "Товар", Barcodes = new[] { "1" }, UnitCode = "шт", Packaging = PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price };
[Fact]
public void Snapshot_is_independent_copy()
{
var cart = new Cart();
var line = cart.AddProduct(P(300m), 2);
cart.SetCartDiscount(100m);
var snap = cart.Snapshot();
// Меняем исходную корзину — снимок не должен измениться.
cart.SetQuantity(line, 5m);
cart.Clear();
snap.Lines.Should().HaveCount(1);
snap.Lines[0].Quantity.Should().Be(2m);
snap.CartDiscount.Should().Be(100m);
// Восстановление возвращает отложенный чек.
cart.Restore(snap);
cart.Lines.Should().HaveCount(1);
cart.Lines[0].Quantity.Should().Be(2m);
cart.Totals().Total.Should().Be(2 * 300m - 100m);
}
}
public class PosDeviceRegistrationTests
{
private static readonly Guid A = Guid.NewGuid();
private static readonly Guid B = Guid.NewGuid();
[Fact]
public void Admin_on_fresh_device_is_asked_to_select()
=> DeviceRegistration.Evaluate(null, canManagePoints: true, Array.Empty<Guid>())
.Should().Be(DeviceRegistration.Decision.NeedSelection);
[Fact]
public void Cashier_on_fresh_device_is_not_registered()
=> DeviceRegistration.Evaluate(null, canManagePoints: false, new[] { A })
.Should().Be(DeviceRegistration.Decision.NotRegistered);
[Fact]
public void Admin_always_proceeds_on_registered_device()
=> DeviceRegistration.Evaluate(A, canManagePoints: true, Array.Empty<Guid>())
.Should().Be(DeviceRegistration.Decision.Proceed);
[Fact]
public void Cashier_assigned_to_device_point_proceeds()
=> DeviceRegistration.Evaluate(A, canManagePoints: false, new[] { A, B })
.Should().Be(DeviceRegistration.Decision.Proceed);
[Fact]
public void Cashier_not_assigned_to_device_point_is_blocked()
=> DeviceRegistration.Evaluate(A, canManagePoints: false, new[] { B })
.Should().Be(DeviceRegistration.Decision.NotAssigned);
}
public class PosFavoritesTests
{
private static ProductSyncDto P(Guid id, string name, bool fav, int order) => new()
{
Id = id, Name = name, Barcodes = Array.Empty<string>(), UnitCode = "шт", Packaging = 1,
VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false,
IsFavorite = fav, FavoriteOrder = order, UpdatedAt = DateTime.UtcNow,
};
[Fact]
public void GetFavorites_returns_only_favorites_in_order()
{
using var s = PosStore.InMemory();
var a = Guid.NewGuid(); var b = Guid.NewGuid(); var c = Guid.NewGuid();
s.ApplySync(new PosSyncResponse
{
ServerTime = DateTime.UtcNow,
Products = new[] { P(a, "Обычный", false, 0), P(b, "Хлеб", true, 2), P(c, "Молоко", true, 1) },
Prices = Array.Empty<PriceSyncDto>(), Stocks = Array.Empty<StockSyncDto>(),
Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
});
var favs = s.GetFavorites();
favs.Select(f => f.Name).Should().Equal("Молоко", "Хлеб"); // по FavoriteOrder 1,2
favs.Should().OnlyContain(f => f.Name != "Обычный");
}
}