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;
/// Headless UI-автотест (Avalonia.Headless + Skia, работает на Linux
/// без дисплея / под xvfb). Прогоняет реальный экран продажи сквозь привязки:
/// сид каталога → ввод штрихкода → команда Scan → в визуальном дереве должна
/// появиться строка с названием товара, а ИТОГО — отражать цену. Любое
/// расхождение → ненулевой код возврата (для CI). Запуск: --uitest
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()
.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(), Counterparties = Array.Empty(),
DeletedProductIds = Array.Empty(),
});
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().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(); }
/// Принтер-заглушка для теста (без записи файлов/портов).
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;
}
}