Add frontend checks, run from the repo
The server has go test; the frontend had nothing, and the things most likely to break there are the ones hardest to see: the arithmetic behind the charts, a panel lost while shuffling tabs, a label that truncates on a phone none of us owns. There is no browser in this loop, so these are what can be checked without one. They read the real code rather than copying it. The app is one long IIFE with nothing exported, and adding a module system or a build step to make it testable would be a large change in service of a small one — so checks/extract.mjs reads src/app.js, brace-matches the declarations a check asks for, and evaluates them. Rename a function and it throws by name. A check quietly exercising a stale copy of the code would be worse than no check, and that is the failure mode this avoids. calendarGridStart is pulled out of renderCalendar as part of this. It is the one line of the month grid that is easy to get wrong and impossible to notice — a month starting on the week's first day needs no backing up, one starting the day before needs six — so it earns a name and a test. The width figures are estimates, not measurements: layout numbers come out of style.css so they cannot drift, text is sized from per-character advances, and the pass mark demands a few pixels of headroom because the estimate is only good to a few percent. They will catch a sixth tab or a longer label. They will not settle a two-pixel question, and nothing here replaces looking at a phone. No new dependencies: nodejs is already in the devShell for `node --check`, and checks/ sits outside src/ so it is not served with the app.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
// Structural facts about the markup and stylesheet that no amount of reading
|
||||
// the diff reliably catches: a panel lost while shuffling tabs, a comment half
|
||||
// removed, a label that truncates on a phone nobody here owns.
|
||||
//
|
||||
// The width arithmetic is an estimate, not a measurement — there is no browser
|
||||
// in this loop. Layout numbers are read out of style.css so they cannot drift
|
||||
// from the real ones, text is sized from per-character advances, and the pass
|
||||
// mark insists on a few pixels of headroom because the estimate is only good
|
||||
// to a few percent. It will catch a sixth tab or a longer label; it will not
|
||||
// settle a two-pixel question.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { suite, eq, ok, report } from "./assert.mjs";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const html = readFileSync(join(root, "src", "index.html"), "utf8");
|
||||
const css = readFileSync(join(root, "src", "style.css"), "utf8");
|
||||
|
||||
// ---------------------------------------------------------------- stylesheet
|
||||
suite("the stylesheet is structurally intact");
|
||||
{
|
||||
let depth = 0, pairs = 0, stray = 0;
|
||||
for (let i = 0; i < css.length - 1; i++) {
|
||||
if (css[i] === "/" && css[i + 1] === "*") { depth++; pairs++; i++; }
|
||||
else if (css[i] === "*" && css[i + 1] === "/") { depth--; if (depth < 0) stray++; i++; }
|
||||
}
|
||||
eq(depth, 0, `comments open and close in pairs (${pairs})`);
|
||||
eq(stray, 0, "no stray comment terminators");
|
||||
const bare = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
eq((bare.match(/\{/g) || []).length, (bare.match(/\}/g) || []).length, "braces balance");
|
||||
eq(/\*\//.test(bare), false, "no orphaned comment tails left by an edit");
|
||||
// Author rules that set a display beat the user agent's [hidden] rule, so
|
||||
// hidden elements keep rendering without this. It has caught five so far.
|
||||
ok(/^\[hidden\] \{ display: none !important; \}$/m.test(css),
|
||||
"the global [hidden] rule is present");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the tabs
|
||||
suite("every panel lives in exactly one tab");
|
||||
{
|
||||
const EXPECTED = [
|
||||
"overview", "sleep-wake", "history",
|
||||
"sleep-daily", "sleep-timeline", "sleep-trend",
|
||||
"walks", "walk-timeline", "walk-trend",
|
||||
"timing", "counts",
|
||||
"weight", "training", "notes",
|
||||
];
|
||||
const main = html.slice(html.indexOf(" <main>"), html.indexOf(" </main>"));
|
||||
let current = null;
|
||||
const placement = new Map();
|
||||
const wrappers = [];
|
||||
for (const line of main.split("\n")) {
|
||||
const open = line.match(/^ {6}<div class="tab-panel" data-tab="([^"]+)"/);
|
||||
if (open) { current = open[1]; wrappers.push(current); continue; }
|
||||
if (/^ {6}<\/div>/.test(line) && current) { current = null; continue; }
|
||||
const panel = line.match(/data-panel="([^"]+)"/);
|
||||
if (panel && /^ {8}<section/.test(line)) {
|
||||
if (!placement.has(panel[1])) placement.set(panel[1], []);
|
||||
placement.get(panel[1]).push(current);
|
||||
}
|
||||
}
|
||||
const missing = EXPECTED.filter(k => !placement.has(k));
|
||||
const duplicated = EXPECTED.filter(k => (placement.get(k) || []).length > 1);
|
||||
const loose = EXPECTED.filter(k => (placement.get(k) || [null])[0] === null);
|
||||
const unexpected = [...placement.keys()].filter(k => !EXPECTED.includes(k));
|
||||
eq(missing, [], "no panel has gone missing");
|
||||
eq(duplicated, [], "no panel appears twice");
|
||||
eq(loose, [], "no panel sits outside a tab");
|
||||
eq(unexpected, [], "no unaccounted-for panel has appeared");
|
||||
|
||||
const buttons = [...html.matchAll(/class="tab" role="tab" data-tab="([^"]+)"/g)].map(m => m[1]);
|
||||
eq(buttons, wrappers, "a button for each tab, in the same order");
|
||||
const ariaOk = buttons.every(t =>
|
||||
html.includes(`id="tab-${t}" aria-controls="tabpanel-${t}"`) &&
|
||||
html.includes(`id="tabpanel-${t}" role="tabpanel" aria-labelledby="tab-${t}"`));
|
||||
ok(ariaOk, "aria-controls and aria-labelledby paired on every tab");
|
||||
// renderWalkPatterns hides these two by hand, and reaches for them by tag.
|
||||
for (const k of ["walk-timeline", "walk-trend"]) {
|
||||
ok(new RegExp(`<section[^>]*data-panel="${k}"`).test(html),
|
||||
`"${k}" is still a <section>, which renderWalkPatterns queries for`);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- the widths
|
||||
const ADV = { cap: 0.72, lower: 0.52, digit: 0.6, thin: 0.28 };
|
||||
const NARROW = { i: 0.26, l: 0.26, t: 0.35, j: 0.26, f: 0.32, r: 0.37 };
|
||||
const em = (s) => [...s].reduce((n, ch) => {
|
||||
if (ch >= "0" && ch <= "9") return n + ADV.digit;
|
||||
if (ch === ":" || ch === " ") return n + ADV.thin;
|
||||
if (ch >= "A" && ch <= "Z") return n + ADV.cap;
|
||||
if (ch === "←" || ch === "→") return n + 0.6;
|
||||
return n + (NARROW[ch] ?? ADV.lower);
|
||||
}, 0);
|
||||
const EMOJI = 18;
|
||||
const BODY_GUTTER = 16;
|
||||
|
||||
const rule = (re) => (css.match(re) || [, ""])[1] || "";
|
||||
const px = (block, prop, dflt) => {
|
||||
const m = block.match(new RegExp(`(?:^|[;{\\s])${prop}:\\s*([\\d.]+)(rem|px)`));
|
||||
return m ? (m[2] === "rem" ? parseFloat(m[1]) * 16 : parseFloat(m[1])) : dflt;
|
||||
};
|
||||
|
||||
const DEVICES = [
|
||||
["Galaxy Fold cover", 280], ["iPhone SE 1 / small Android", 320],
|
||||
["Galaxy S / common Android", 360], ["iPhone SE 2-3", 375],
|
||||
["iPhone 14 / Pixel", 393], ["iPhone 14 Plus", 428],
|
||||
];
|
||||
const narrowBlock = rule(/max-width: 370px\)\s*\{([\s\S]*?)\n\}/);
|
||||
const inNarrow = (sel) => narrowBlock.match(new RegExp(`${sel} \\{[^}]*\\}`))?.[0] ?? "";
|
||||
|
||||
suite("the tab labels fit without truncating");
|
||||
{
|
||||
const labels = [...html.matchAll(/class="tab" role="tab"[^>]*>([^<]+)</g)].map(m => m[1]);
|
||||
const wide = { gap: px(rule(/\n\.tabs \{([^}]*)\}/), "gap", 4), barPad: 4, tabPad: 4,
|
||||
font: px(rule(/\n\.tabs \.tab \{([^}]*)\}/), "font-size", 13.6) };
|
||||
const narrow = { gap: px(inNarrow("\\.tabs"), "gap", wide.gap),
|
||||
barPad: px(inNarrow("\\.tabs"), "padding-left", wide.barPad),
|
||||
tabPad: px(inNarrow("\\.tabs \\.tab"), "padding-left", wide.tabPad),
|
||||
font: px(inNarrow("\\.tabs \\.tab"), "font-size", wide.font) };
|
||||
const widest = labels.reduce((a, b) => (em(a) > em(b) ? a : b));
|
||||
for (const [name, w] of DEVICES) {
|
||||
const v = w <= 370 ? narrow : wide;
|
||||
const room = (Math.min(w, 720) - 2 * BODY_GUTTER - 2 * v.barPad
|
||||
- (labels.length - 1) * v.gap) / labels.length - 2 * v.tabPad;
|
||||
const need = em(widest) * v.font;
|
||||
ok(room - need >= 2, `${String(w).padStart(4)}px ${name}: "${widest}" fits ` +
|
||||
`(${room.toFixed(0)}px of room, ${need.toFixed(0)}px needed)`);
|
||||
}
|
||||
}
|
||||
|
||||
suite("both day-bar timers fit on a row of their own");
|
||||
{
|
||||
// The bar may wrap when it must; what it must not do is squash the timers.
|
||||
const pillCss = rule(/\n\.bar-clock \{([^}]*)\}/);
|
||||
const wide = { font: px(pillCss, "font-size", 16), padX: 9, gap: px(pillCss, "gap", 4) };
|
||||
const narrowPill = inNarrow("\\.bar-clock");
|
||||
const narrow = { font: px(narrowPill, "font-size", wide.font),
|
||||
padX: px(narrowPill, "padding", wide.padX), gap: px(narrowPill, "gap", wide.gap) };
|
||||
const barPadX = 10, groupGap = 6;
|
||||
const worst = "3:12:45"; // both timers past an hour
|
||||
for (const [name, w] of DEVICES) {
|
||||
const v = w <= 370 ? narrow : wide;
|
||||
const pill = EMOJI + v.gap + em(worst) * v.font + 2 * v.padX;
|
||||
const room = Math.min(w, 720) - 2 * BODY_GUTTER - 2 * barPadX;
|
||||
ok(room - (2 * pill + groupGap) >= 2,
|
||||
`${String(w).padStart(4)}px ${name}: two timers fit ` +
|
||||
`(${room.toFixed(0)}px of room, ${(2 * pill + groupGap).toFixed(0)}px needed)`);
|
||||
}
|
||||
}
|
||||
|
||||
export default report("layout");
|
||||
Reference in New Issue
Block a user