food-market/src/food-market.pos/Tooling/SmokeRunner.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

105 lines
5.7 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 System.Diagnostics;
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;
namespace foodmarket.Pos.Tooling;
/// <summary>CLI-smoke кассы: сценарий «открыть смену → продажи → возврат → закрыть»
/// + офлайн-очередь, через логику pos.core (без UI/дисплея). Работает одинаково
/// на Windows и Linux — для проверки на реальной машине. Запуск: --smoke</summary>
public static class SmokeRunner
{
public static int Run(string[] args)
{
var lines = new List<string>();
void Log(string s) { Console.WriteLine(s); lines.Add(s); }
// Холодный старт: от запуска процесса до входа в логику (загрузка рантайма + JIT).
double coldStartMs = 0;
try { coldStartMs = (DateTime.Now - Process.GetCurrentProcess().StartTime).TotalMilliseconds; } catch { }
var sw = Stopwatch.StartNew();
using var store = PosStore.InMemory();
// Каталог: 30k товаров для замера поиска + несколько «реальных».
var ids = SeedCatalog(store, big: args.Contains("--big"));
var seedMs = sw.ElapsedMilliseconds;
// Замер: поиск по штрихкоду и по имени.
var swBc = Stopwatch.StartNew();
var byBc = store.FindByBarcode("4870000000005");
swBc.Stop();
var swName = Stopwatch.StartNew();
var found = store.SearchByName("товар 1", 20);
swName.Stop();
// Сценарий дня.
var cart = new Cart();
var p1 = store.GetProduct(ids[0])!; var p2 = store.GetProduct(ids[1])!;
cart.AddProduct(p1, 2); cart.AddProduct(p2, 1);
var totals = cart.Totals();
var pay = PaymentMath.Compute(totals.Total, totals.Total + 500m, 0m);
// Положить 5 продаж в очередь (офлайн).
for (int i = 0; i < 5; i++)
store.EnqueueSale(new PosSaleDto
{
ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, Payment = 0, PaidCash = totals.Total, PaidCard = 0,
Lines = new[] { new PosSaleLineDto { ProductId = ids[0], Quantity = 1m, UnitPrice = p1.Price, Discount = 0m } },
});
var pending = store.PendingCount();
var expected = ShiftMath.ExpectedCash(5000m, 3775m, 1500m, 10000m, 3000m);
var diff = ShiftMath.CashDifference(14775m, expected);
var batchKey = SyncService.StableBatchKey(store.PendingSales().Select(s => s.ClientSaleId));
Log("=== POS smoke ===");
Log($"ОС: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}");
Log($"Холодный старт (процесс→логика): {coldStartMs:F0} мс");
Log($"Каталог засеян: {store.ProductCount()} товаров за {seedMs} мс");
Log($"Поиск по штрихкоду: {(byBc is null ? "НЕ найден" : byBc.Name)} за {swBc.Elapsed.TotalMilliseconds:F2} мс");
Log($"Поиск по имени: {found.Count} совпадений за {swName.Elapsed.TotalMilliseconds:F2} мс");
Log($"Чек: {totals.LineCount} поз., итог {totals.Total} ₸; оплата {pay.Paid} ₸ → сдача {pay.Change} ₸ (достаточно: {pay.Sufficient})");
Log($"Очередь офлайн: {pending} чеков (ожид. 5); idempotency-ключ батча: {batchKey}");
Log($"Смена: должно {expected} ₸, факт 14775 ₸, разница {diff} ₸");
Log($"Всего инициализация+сценарий: {sw.ElapsedMilliseconds} мс");
var ok = byBc is not null && pay.Sufficient && pending == 5 && expected == 14275m && diff == 500m;
Log(ok ? "РЕЗУЛЬТАТ: OK" : "РЕЗУЛЬТАТ: ОШИБКА");
// Результат в файл — на Windows exe собран как WinExe (нет консоли).
var outArg = args.SkipWhile(a => a != "--out").Skip(1).FirstOrDefault();
var outPath = outArg ?? Path.Combine(Path.GetTempPath(), "pos-smoke-result.txt");
try { File.WriteAllText(outPath, string.Join(Environment.NewLine, lines)); } catch { }
return ok ? 0 : 1;
}
private static List<Guid> SeedCatalog(PosStore store, bool big)
{
var n = big ? 30_000 : 50;
var products = new List<ProductSyncDto>(n);
var prices = new List<PriceSyncDto>(n);
var ids = new List<Guid>();
for (int i = 0; i < n; i++)
{
var id = Guid.NewGuid(); ids.Add(id);
var bc = (4_870_000_000_000 + i).ToString();
products.Add(new ProductSyncDto
{
Id = id, Name = $"Товар {i + 1}", Barcodes = new[] { bc }, UnitCode = i % 7 == 0 ? "кг" : "шт",
Packaging = i % 7 == 0 ? 2 : 1, VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow,
});
prices.Add(new PriceSyncDto { ProductId = id, PriceTypeId = Guid.Empty, PriceTypeName = "Розн", IsSystem = true, Amount = 100m + i % 900, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow });
}
store.ApplySync(new PosSyncResponse
{
ServerTime = DateTime.UtcNow, Products = products, Prices = prices,
Stocks = Array.Empty<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
});
return ids;
}
}