food-market/src/food-market.pos/Tooling/UiTestRunner.cs
nns fcd4a69624 feat(pos-ui): WPF→Avalonia 11, живая касса кассира (Win/Linux)
- Кроссплатформенный 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>
2026-10-08 04:58:39 +05:00

130 lines
6.8 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 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;
}
}