Files
decePubClient/tests/e2e/Mobile/Fit.cs
T

182 lines
7.5 KiB
C#
Raw Normal View History

2026-10-06 22:40:28 +02:00
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
namespace decePubClient.E2E.Mobile
{
// The phone sweep: a page measured and photographed one screen at a time down its main scroller (fit.js measures),
// what doesn't fit kept as violations.json next to the screenshots, and a contact sheet of every route at every phone
// in tests/e2e/out/mobile/<run>/index.html, for the eye.
public static class Fit
{
public static readonly string Script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Mobile", "fit.js"));
static readonly string RunDir = Path.Combine(TestsRoot(), "out", "mobile", DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"));
static readonly object SheetGate = new();
static string TestsRoot()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !File.Exists(Path.Combine(dir, "decePubClient.E2E.csproj")))
dir = Path.GetDirectoryName(dir);
return dir ?? throw new InvalidOperationException("decePubClient.E2E.csproj not found above the test output");
}
// what a sweep found, each finding once however many screens showed it
public sealed class Report
{
readonly HashSet<string> seen = [];
public List<JsonObject> Overflow { get; } = [];
public List<JsonObject> Beyond { get; } = [];
public List<JsonObject> Popovers { get; } = [];
public List<JsonObject> Small { get; } = [];
public List<JsonObject> Tiny { get; } = [];
public int Screens { get; set; }
public void Take(JsonNode measured, int screen)
{
Add(Overflow, measured["overflow"], "overflow", screen);
Add(Beyond, measured["beyond"], "beyond", screen);
Add(Popovers, measured["popovers"], "popover", screen);
Add(Small, measured["small"], "small", screen);
Add(Tiny, measured["tiny"], "tiny", screen);
}
void Add(List<JsonObject> into, JsonNode found, string kind, int screen)
{
foreach (var item in found!.AsArray().Select(i => i!.AsObject()))
{
var key = $"{kind}|{item["element"]}|{item["text"]}|{item["card"]}";
if (!seen.Add(key))
continue;
var copy = Clean(item.DeepClone()).AsObject();
copy["screen"] = screen;
into.Add(copy);
}
}
// Playwright marks the objects it hands back with $id; the findings don't need them
static JsonNode Clean(JsonNode node)
{
if (node is JsonObject obj)
{
obj.Remove("$id");
foreach (var (_, value) in obj)
Clean(value);
}
else if (node is JsonArray array)
foreach (var value in array)
Clean(value);
return node;
}
public string Json() => new JsonObject
{
["screens"] = Screens,
["overflow"] = new JsonArray(Overflow.Select(o => (JsonNode)o.DeepClone()).ToArray()),
["beyond"] = new JsonArray(Beyond.Select(o => (JsonNode)o.DeepClone()).ToArray()),
["popovers"] = new JsonArray(Popovers.Select(o => (JsonNode)o.DeepClone()).ToArray()),
["small"] = new JsonArray(Small.Select(o => (JsonNode)o.DeepClone()).ToArray()),
["tiny"] = new JsonArray(Tiny.Select(o => (JsonNode)o.DeepClone()).ToArray())
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
}
public static Task Settle(IPage page) => page.EvaluateAsync("() => window.__fit.settle()");
public static async Task<Report> Sweep(IPage page, Support.Phone phone, string slug, int screens)
{
var dir = Path.Combine(RunDir, phone.Key, slug);
Directory.CreateDirectory(dir);
var report = new Report();
await Settle(page);
await page.EvaluateAsync("() => window.__fit.primary()");
for (var screen = 0; screen < screens; screen++)
{
var measured = JsonNode.Parse((await page.EvaluateAsync<JsonElement>("() => window.__fit.measure()")).GetRawText())!;
report.Take(measured, screen);
await page.ScreenshotAsync(new()
{
Path = Path.Combine(dir, $"{screen:00}.png"), Animations = ScreenshotAnimations.Disabled, Caret = ScreenshotCaret.Hide
});
report.Screens = screen + 1;
if (!await page.EvaluateAsync<bool>("() => window.__fit.step(0.8)"))
break;
await Settle(page);
}
await File.WriteAllTextAsync(Path.Combine(dir, "violations.json"), report.Json());
ContactSheet();
return report;
}
// one measurement of the page as it is now; with a scope (an open menu, sheet or dialog), its targets only
public static async Task<Report> Now(IPage page, string scope = null)
{
await Settle(page);
var report = new Report();
var measured = JsonNode.Parse((await page.EvaluateAsync<JsonElement>("scope => window.__fit.measure(scope)", scope)).GetRawText())!;
report.Take(measured, 0);
report.Screens = 1;
return report;
}
2026-10-06 22:40:28 +02:00
// nothing scrolls sideways, nothing lies past the screen's sides, and every open menu, dialog or tip is whole
public static void AssertFits(Report report)
{
var problems = report.Overflow.Select(o => $"sideways scroll in {o["element"]} (+{o["extra"]}px), furthest: {o["culprit"]?.ToJsonString()}")
.Concat(report.Beyond.Select(b => $"past the screen: {b.ToJsonString()}"))
.Concat(report.Popovers.Select(p => $"cut or outside: {p.ToJsonString()}"))
.ToList();
Assert.True(problems.Count == 0, $"{problems.Count} fit problems:\n" + string.Join("\n", problems.Take(12)));
}
// every target a thumb can reach has a 44px hit area, and no control's text is under 12px
public static void AssertTouchable(Report report)
{
var problems = report.Small.Select(s => $"small target: {s.ToJsonString()}")
.Concat(report.Tiny.Select(t => $"small text: {t.ToJsonString()}"))
.ToList();
Assert.True(problems.Count == 0, $"{problems.Count} touch problems:\n" + string.Join("\n", problems.Take(12)));
}
// every route as a row, every phone as a column: the first screen and how many findings
static void ContactSheet()
{
lock (SheetGate)
{
var phones = Directory.GetDirectories(RunDir).Select(Path.GetFileName).Order().ToList();
var routes = phones.SelectMany(p => Directory.GetDirectories(Path.Combine(RunDir, p)).Select(Path.GetFileName)).Distinct().Order().ToList();
var html = new StringBuilder("<!doctype html><meta charset=utf-8><title>decePub on phones</title>")
.Append("<style>body{font:13px system-ui;background:#222;color:#ddd}td{vertical-align:top;padding:6px}img{width:180px;display:block}")
.Append("a{color:#9cf}.bad{color:#f88}</style><table><tr><th></th>");
foreach (var phone in phones)
html.Append("<th>").Append(WebUtility.HtmlEncode(phone)).Append("</th>");
html.Append("</tr>");
foreach (var route in routes)
{
html.Append("<tr><th>").Append(WebUtility.HtmlEncode(route)).Append("</th>");
foreach (var phone in phones)
{
var dir = Path.Combine(RunDir, phone, route);
html.Append("<td>");
if (File.Exists(Path.Combine(dir, "violations.json")))
{
var found = JsonNode.Parse(File.ReadAllText(Path.Combine(dir, "violations.json")))!;
var fit = found["overflow"]!.AsArray().Count + found["beyond"]!.AsArray().Count + found["popovers"]!.AsArray().Count;
var touch = found["small"]!.AsArray().Count + found["tiny"]!.AsArray().Count;
var link = $"{phone}/{route}";
html.Append($"<a href=\"{link}/00.png\"><img src=\"{link}/00.png\"></a>")
.Append($"<span class=\"{(fit + touch > 0 ? "bad" : "")}\">fit {fit} · touch {touch} · {found["screens"]} screens</span> ")
.Append($"<a href=\"{link}/violations.json\">findings</a>");
}
html.Append("</td>");
}
html.Append("</tr>");
}
File.WriteAllText(Path.Combine(RunDir, "index.html"), html.Append("</table>").ToString());
}
}
}
}