- Кроссплатформенный Avalonia-UI (net8.0, не net8.0-windows): экраны вход/ продажа/оплата/смена/возврат, тёмная тема, ViewLocator, компилируемые привязки. - Живое связывание: логин→сессия→Refit-API, привязка к точке; продажа (скан/ поиск/количество/весовой/скидки/удаление), модальная оплата → запись чека в офлайн-очередь + печать + попытка выгрузки; смена open/close/drawer на api/money/shifts; горячие клавиши F1/F2/F7/F9/F10/F12/Esc/Del (работа без мыши); индикатор связи/очереди + фоновая синхронизация; автопроверка версии. - Оборудование: ESC/POS-билдер (CP866, USB/COM/сеть, 58/80мм) + текст/PDF запасной; весы CAS/Масса-К (COM) + ручной ввод; IFiscalProvider — заглушка. - DpapiTokenStore (Windows, scope CurrentUser) для refresh-токена; 0600 на Linux. - Инструменты CLI: --smoke (замеры/сценарий без UI), --screenshots (headless рендер экранов), --uitest (headless UI-автотест, 15 проверок, Avalonia.Headless). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
6.8 KiB
C#
130 lines
6.8 KiB
C#
using Avalonia;
|
||
using Avalonia.Controls;
|
||
using Avalonia.Headless;
|
||
using Avalonia.Media.Imaging;
|
||
using Avalonia.Threading;
|
||
using Avalonia.VisualTree;
|
||
using foodmarket.Pos.Core.Data;
|
||
using foodmarket.Pos.Core.Models;
|
||
using foodmarket.Pos.Services;
|
||
using foodmarket.Pos.ViewModels;
|
||
using foodmarket.Pos.Views;
|
||
using foodmarket.Shared.Pos.V1;
|
||
|
||
namespace foodmarket.Pos.Tooling;
|
||
|
||
/// <summary>Headless UI-автотест (Avalonia.Headless + Skia, работает на Linux
|
||
/// без дисплея / под xvfb). Прогоняет реальный экран продажи сквозь привязки:
|
||
/// сид каталога → ввод штрихкода → команда Scan → в визуальном дереве должна
|
||
/// появиться строка с названием товара, а ИТОГО — отражать цену. Любое
|
||
/// расхождение → ненулевой код возврата (для CI). Запуск: --uitest</summary>
|
||
public static class UiTestRunner
|
||
{
|
||
public static int Run(string[] args)
|
||
{
|
||
int failures = 0;
|
||
void Check(bool ok, string label)
|
||
{
|
||
Console.WriteLine($"[uitest] {(ok ? "OK " : "FAIL")} {label}");
|
||
if (!ok) failures++;
|
||
}
|
||
|
||
AppBuilder.Configure<App>()
|
||
.UseSkia()
|
||
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
|
||
.WithInterFont()
|
||
.SetupWithoutStarting();
|
||
|
||
// 1) Каталог в памяти с одним товаром + штрихкодом.
|
||
using var store = PosStore.InMemory();
|
||
var pid = Guid.NewGuid();
|
||
store.ApplySync(new PosSyncResponse
|
||
{
|
||
ServerTime = DateTime.UtcNow,
|
||
Products = new[] { new ProductSyncDto
|
||
{
|
||
Id = pid, Name = "Кофе молотый 250г", Barcodes = new[] { "4870001112223" },
|
||
UnitCode = "шт", Packaging = 1, VatPercent = 12m, VatEnabled = true,
|
||
IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow,
|
||
} },
|
||
Prices = new[] { new PriceSyncDto
|
||
{
|
||
ProductId = pid, PriceTypeId = Guid.Empty, PriceTypeName = "Розн",
|
||
IsSystem = true, Amount = 1290m, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow,
|
||
} },
|
||
Stocks = Array.Empty<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(),
|
||
DeletedProductIds = Array.Empty<Guid>(),
|
||
});
|
||
|
||
var svc = new AppServices(store);
|
||
var vm = new SaleViewModel(svc);
|
||
var view = new SaleView { DataContext = vm };
|
||
var window = new Window { Content = view, Width = 1366, Height = 768 };
|
||
window.Show();
|
||
Pump();
|
||
|
||
Check(vm.Lines.Count == 0, "стартовый чек пуст");
|
||
|
||
// 2) Эмуляция сканера: штрихкод «набран» в поле, команда Scan (Enter).
|
||
vm.ScanInput = "4870001112223";
|
||
vm.ScanCommand.Execute(null);
|
||
Pump();
|
||
|
||
Check(vm.Lines.Count == 1, "после скана 1 позиция в корзине");
|
||
Check(vm.Lines.Count == 1 && vm.Lines[0].Name == "Кофе молотый 250г", "название позиции верное");
|
||
Check(vm.Total == 1290m, $"ИТОГО = 1290, факт {vm.Total}");
|
||
Check(vm.ItemCount == 1, "счётчик позиций = 1");
|
||
|
||
// 3) Привязка дошла до визуального дерева: ищем TextBlock с названием.
|
||
var texts = view.GetVisualDescendants().OfType<TextBlock>().Select(t => t.Text).ToList();
|
||
Check(texts.Any(t => t == "Кофе молотый 250г"), "название отрисовано в дереве");
|
||
// Разделитель групп зависит от культуры (пробел/NBSP/запятая) — нормализуем.
|
||
static string Digits(string? s) => new string((s ?? "").Where(char.IsDigit).ToArray());
|
||
Check(texts.Any(t => t != null && t.Contains('₸') && Digits(t) == "1290"), "ИТОГО отрисовано (1290 ₸)");
|
||
|
||
// 4) Повторный скан того же товара → штучный инкремент (2 шт), не вторая строка.
|
||
vm.ScanInput = "4870001112223";
|
||
vm.ScanCommand.Execute(null);
|
||
Pump();
|
||
Check(vm.Lines.Count == 1 && vm.Lines[0].Quantity == 2m, "повторный скан штучного = +1 к количеству");
|
||
Check(vm.Total == 2580m, $"ИТОГО после 2 шт = 2580, факт {vm.Total}");
|
||
|
||
// 5) Рендер кадра не пустой (Skia headless реально отрисовал экран).
|
||
var frame = window.CaptureRenderedFrame();
|
||
Check(frame is Bitmap b && b.PixelSize.Width == 1366 && b.PixelSize.Height == 768, "кадр отрисован 1366×768");
|
||
|
||
// 6) Очистка чека (Esc).
|
||
vm.ClearCommand.Execute(null);
|
||
Pump();
|
||
Check(vm.Lines.Count == 0 && vm.Total == 0m, "очистка чека обнуляет корзину");
|
||
|
||
// 7) Оплата → запись чека в офлайн-очередь с привязкой к смене.
|
||
svc.Printer = new NullPrinter();
|
||
var shiftId = Guid.NewGuid(); var rpId = Guid.NewGuid();
|
||
svc.Checkout.CurrentShift = new PosShift { Id = shiftId, Number = "С-TEST-1", RetailPointId = rpId, CashAccountId = Guid.NewGuid(), OpeningFloat = 0m };
|
||
vm.ScanInput = "4870001112223";
|
||
vm.ScanCommand.Execute(null);
|
||
Pump();
|
||
vm.CommitPaymentAsync(2000m, 0m, 710m).GetAwaiter().GetResult();
|
||
Pump();
|
||
Check(store.PendingCount() == 1, "после оплаты 1 чек в офлайн-очереди");
|
||
var queued = store.PendingSales().FirstOrDefault();
|
||
Check(queued is not null && queued.CashShiftId == shiftId && queued.RetailPointId == rpId, "чек привязан к смене и точке");
|
||
Check(queued is not null && queued.PaidCash == 2000m, "сумма оплаты сохранена");
|
||
Check(vm.Lines.Count == 0, "корзина очищена после оплаты");
|
||
|
||
window.Close();
|
||
Console.WriteLine(failures == 0 ? "[uitest] РЕЗУЛЬТАТ: OK" : $"[uitest] РЕЗУЛЬТАТ: ПРОВАЛЕНО ({failures})");
|
||
return failures == 0 ? 0 : 1;
|
||
}
|
||
|
||
private static void Pump() { for (int i = 0; i < 4; i++) Dispatcher.UIThread.RunJobs(); }
|
||
|
||
/// <summary>Принтер-заглушка для теста (без записи файлов/портов).</summary>
|
||
private sealed class NullPrinter : foodmarket.Pos.Hardware.IReceiptPrinter
|
||
{
|
||
public string Name => "null";
|
||
public Task PrintAsync(foodmarket.Pos.Hardware.ReceiptDoc doc, CancellationToken ct = default) => Task.CompletedTask;
|
||
}
|
||
}
|