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; } }