FlowTests drive the narrowest phone through what landed so far, each opened menu, sheet or dialog checked to be whole on the screen with 44px targets (fit.js measures inside a scope now): the menu closes after a choice, an outside tap and Escape; "Liked by" rises from the bottom and closes from its button; a new post is written in the sheet the round button opens from a scrolled feed, with the words focused; a reply is written in a sheet; a reaction is picked from the React sheet, through the one-time public notice, and taken back. Cells client.mobile.flow.*. The menu's dark-mode toggle gets a 44px hit area and a name, and the public notice dialog marks itself for the tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
183 lines
7.5 KiB
C#
183 lines
7.5 KiB
C#
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;
|
|
}
|
|
|
|
// 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());
|
|
}
|
|
}
|
|
}
|
|
}
|