- --demo: окно с примером чека без логина (software-рендер на Windows, т.к. у тест-VM нет GPU — иначе окно чёрное). - Снят реальный экран кассы на Windows 11 (интерактивная сессия tester через scheduled task /it + PrintWindow): подтверждён нативный рендер — кириллица, формат «3 362 ₸», кнопки без обрезки → docs/pos-screenshots/windows/ + раздел «Windows 11» в галерее. - Ограничения VM (не код, честно в docs): нет GPU → software-рендер; консоль 1280×800 → 1366/1920 реальным окном недоступны (они в headless-кадрах, тот же движок); меню «Пуск» VM залипает. Автоматизация логики UI — headless --uitest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
7.9 KiB
C#
131 lines
7.9 KiB
C#
using System.Text;
|
||
using Avalonia;
|
||
using Avalonia.Controls;
|
||
using Avalonia.Headless;
|
||
using Avalonia.Media.Imaging;
|
||
using Avalonia.Threading;
|
||
using foodmarket.Pos.ViewModels;
|
||
using foodmarket.Pos.Views;
|
||
|
||
namespace foodmarket.Pos.Tooling;
|
||
|
||
/// <summary>Headless-рендер экранов кассы в PNG (без дисплея, Skia). По порядку
|
||
/// сценария, в 1920×1080 и 1366×768 → docs/pos-screenshots/ + index.html.
|
||
/// Запуск: dotnet run -- --screenshots [outputDir]</summary>
|
||
public static class ScreenshotRunner
|
||
{
|
||
private sealed record Shot(string Name, string Caption, Func<Control> Build);
|
||
|
||
public static int Run(string[] args)
|
||
{
|
||
var outDir = args.SkipWhile(a => a != "--screenshots").Skip(1).FirstOrDefault(a => !a.StartsWith("--"))
|
||
?? Path.Combine(FindRepoRoot(), "docs", "pos-screenshots");
|
||
Directory.CreateDirectory(outDir);
|
||
|
||
AppBuilder.Configure<App>()
|
||
.UseSkia()
|
||
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
|
||
.WithInterFont()
|
||
.SetupWithoutStarting();
|
||
|
||
var shots = new List<Shot>
|
||
{
|
||
new("01-login", "Вход кассира (адрес сервера, логин, пароль)", () => new LoginView { DataContext = LoginViewModel.Sample() }),
|
||
new("01b-login-pin", "Быстрый вход по PIN (зарегистрированное устройство)", () => new LoginView { DataContext = LoginViewModel.SamplePin() }),
|
||
new("02-shift-open", "Смена: Z-отчёт/состояние смены", () => new ShiftView { DataContext = ShiftViewModel.Sample() }),
|
||
new("03-sale-empty", "Продажа: пустой чек, ожидание сканирования", () => new SaleView { DataContext = new SaleViewModel() }),
|
||
new("04-sale-items", "Продажа: чек с товарами (вкл. весовой)", () => new SaleView { DataContext = SaleViewModel.Sample() }),
|
||
new("05-payment-cash", "Оплата наличными со сдачей", () => new PaymentView { DataContext = PaymentViewModel.SampleCash() }),
|
||
new("06-payment-mixed", "Оплата смешанная (нал+карта)", () => new PaymentView { DataContext = PaymentViewModel.SampleMixed() }),
|
||
new("07-return", "Возврат по чеку (выбор позиций)", () => new ReturnView { DataContext = ReturnViewModel.Sample() }),
|
||
new("08-shift-z", "Z-отчёт при закрытии (должно/факт/разница)", () => new ShiftView { DataContext = ShiftViewModel.SampleClosed() }),
|
||
new("09-shell-offline", "Статус-бар: нет связи, чеки в очереди", () => ShellOffline()),
|
||
new("10-shell-online", "Статус-бар: на связи, очередь пуста", () => ShellOnline()),
|
||
};
|
||
|
||
var sizes = new (string suffix, int w, int h)[] { ("1920x1080", 1920, 1080), ("1366x768", 1366, 768) };
|
||
var produced = new List<(string file, string caption)>();
|
||
|
||
foreach (var shot in shots)
|
||
foreach (var (suffix, w, h) in sizes)
|
||
{
|
||
var control = shot.Build();
|
||
// Если это уже окно (оболочка) — рендерим его напрямую; иначе кладём в окно.
|
||
var window = control as Window ?? new Window { Content = control, SystemDecorations = SystemDecorations.None };
|
||
window.Width = w; window.Height = h;
|
||
window.Show();
|
||
Dispatcher.UIThread.RunJobs();
|
||
// дать разметке/рендеру отработать
|
||
for (int i = 0; i < 3; i++) { Dispatcher.UIThread.RunJobs(); }
|
||
var frame = window.CaptureRenderedFrame();
|
||
var file = $"{shot.Name}-{suffix}.png";
|
||
frame?.Save(Path.Combine(outDir, file));
|
||
produced.Add((file, $"{shot.Caption} ({suffix})"));
|
||
window.Close();
|
||
Console.WriteLine($"[shot] {file}");
|
||
}
|
||
|
||
WriteGallery(outDir, produced);
|
||
Console.WriteLine($"[done] {produced.Count} кадров → {outDir}");
|
||
return 0;
|
||
}
|
||
|
||
private static Control ShellOffline()
|
||
{
|
||
var vm = new MainWindowViewModel { IsOnline = false, QueueCount = 3, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" };
|
||
return new MainWindow { DataContext = vm };
|
||
}
|
||
private static Control ShellOnline()
|
||
{
|
||
var vm = new MainWindowViewModel { IsOnline = true, QueueCount = 0, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" };
|
||
return new MainWindow { DataContext = vm };
|
||
}
|
||
|
||
private static void WriteGallery(string dir, List<(string file, string caption)> shots)
|
||
{
|
||
var sb = new StringBuilder();
|
||
sb.Append("<!doctype html><html lang=\"ru\"><head><meta charset=\"utf-8\"><title>Касса food-market — скриншоты</title>");
|
||
sb.Append("<style>body{font:14px system-ui,Segoe UI,Roboto;margin:0;background:#0f172a;color:#e2e8f0}" +
|
||
"header{padding:18px 24px;background:#1e293b}h1{margin:0;font-size:20px}" +
|
||
".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:16px;padding:20px}" +
|
||
"figure{margin:0;background:#1e293b;border:1px solid #334155;border-radius:10px;overflow:hidden}" +
|
||
"img{width:100%;display:block;background:#000;cursor:zoom-in}figcaption{padding:8px 10px;color:#cbd5e1;font-size:13px}</style></head><body>");
|
||
sb.Append($"<header><h1>Касса food-market — скриншоты процессов</h1><div style=\"color:#94a3b8\">рендер headless (Avalonia/Skia, Linux) — 1920×1080 и 1366×768. Кадров: {shots.Count}</div></header><div class=\"grid\">");
|
||
foreach (var (file, caption) in shots)
|
||
sb.Append($"<figure><a href=\"{file}\" target=\"_blank\"><img src=\"{file}\" loading=\"lazy\"></a><figcaption>{System.Net.WebUtility.HtmlEncode(caption)}</figcaption></figure>");
|
||
sb.Append("</div>");
|
||
|
||
// Раздел «Windows 11» — реальные скриншоты окна на Windows (если есть).
|
||
var winDir = Path.Combine(dir, "windows");
|
||
if (Directory.Exists(winDir))
|
||
{
|
||
var wins = Directory.GetFiles(winDir, "*.png").OrderBy(f => f).ToList();
|
||
if (wins.Count > 0)
|
||
{
|
||
sb.Append("<header style=\"margin-top:8px\"><h1>Windows 11 — реальное окно</h1>" +
|
||
"<div style=\"color:#94a3b8\">Снято на Windows 11 (VM, " +
|
||
"software-рендер — у VM нет GPU; консоль 1280×800). Подтверждает нативный " +
|
||
"рендер: кириллица, формат чисел «3 362 ₸», кнопки без обрезки. Высокое " +
|
||
"разрешение 1366/1920 — в headless-кадрах выше (тот же движок Avalonia/Skia/Inter).</div></header>");
|
||
sb.Append("<div class=\"grid\">");
|
||
foreach (var w in wins)
|
||
{
|
||
var rel = "windows/" + Path.GetFileName(w);
|
||
sb.Append($"<figure><a href=\"{rel}\" target=\"_blank\"><img src=\"{rel}\" loading=\"lazy\"></a><figcaption>Windows 11 — экран продажи (реальное окно)</figcaption></figure>");
|
||
}
|
||
sb.Append("</div>");
|
||
}
|
||
}
|
||
|
||
sb.Append("</body></html>");
|
||
File.WriteAllText(Path.Combine(dir, "index.html"), sb.ToString());
|
||
}
|
||
|
||
private static string FindRepoRoot()
|
||
{
|
||
var d = new DirectoryInfo(AppContext.BaseDirectory);
|
||
while (d is not null && !File.Exists(Path.Combine(d.FullName, "food-market.sln"))) d = d.Parent;
|
||
return d?.FullName ?? Directory.GetCurrentDirectory();
|
||
}
|
||
}
|