diff --git a/README.md b/README.md index 8cad080..30ed155 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,13 @@ puppy-tracker/ │ ├── webpush.go # VAPID + RFC 8291/8188 message encryption │ ├── pedigree.go # SKK lookup, background crawl, per-dog cache │ └── htmlutil.go # scraping helpers for the pedigree crawl +├── checks/ # frontend checks (see Checks); `node checks/run.mjs` +│ ├── run.mjs # runs every suite, exits non-zero on a failure +│ ├── extract.mjs # pulls declarations out of app.js so checks run real code +│ ├── assert.mjs +│ ├── excluded-days.mjs +│ ├── calendar.mjs +│ └── layout.mjs └── src/ # the web app ├── index.html ├── app.js @@ -115,6 +122,37 @@ puppy-tracker/ └── icon-180.png, icon-192.png, icon-512.png ``` +## Checks + +```sh +nix develop -c sh -c 'cd server && go test ./...' # the server +nix develop -c node checks/run.mjs # the frontend +``` + +The server has `go test`; `checks/` is the other half. The frontend has no +build step and no test framework, so these are plain scripts with no +dependencies beyond the `nodejs` already in the devShell. They cover the things +that are invisible until they bite: + +- **Arithmetic behind the charts** — what a day marked *not counted* does and + does not take out of the numbers, and the month grid's week starts, leap + years and month boundaries. +- **Structure** — that every panel still sits in exactly one tab (losing one + while shuffling tabs is silent), that the stylesheet's braces and comments + balance, that the global `[hidden]` rule is still there. +- **Width budgets** — whether the tab labels and the two day-bar timers still + fit the phones people use. + +Two things worth knowing about them. They **extract the real functions out of +`src/app.js`** rather than copying them, so a check cannot quietly go on +testing a stale copy — rename a function and `checks/extract.mjs` throws by +name. And the width figures are **estimates, not measurements**: there is no +browser in the loop, so the layout numbers are read out of `style.css` and the +text is sized from per-character advances, with a couple of pixels of headroom +demanded 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. A real +phone is still the arbiter of anything visual. + ## Run locally ```sh diff --git a/checks/assert.mjs b/checks/assert.mjs new file mode 100644 index 0000000..1371433 --- /dev/null +++ b/checks/assert.mjs @@ -0,0 +1,28 @@ +// The smallest thing that will do. Checks print a line per assertion so a +// failure says what was expected of the code, not just which line threw. +let fails = 0; +let current = ""; + +export function suite(name) { + current = name; + console.log(`\n== ${name} ==`); +} + +export function eq(got, want, what) { + const g = JSON.stringify(got), w = JSON.stringify(want); + if (g === w) { console.log(` ok ${what}`); return; } + console.log(` FAIL ${what}\n got ${g}\n want ${w}`); + fails++; +} + +export function ok(cond, what) { + eq(Boolean(cond), true, what); +} + +export function failed() { return fails; } + +export function report(file) { + if (fails === 0) console.log(`\n${file}: all passed`); + else console.log(`\n${file}: ${fails} FAILED`); + return fails; +} diff --git a/checks/calendar.mjs b/checks/calendar.mjs new file mode 100644 index 0000000..7c4ac27 --- /dev/null +++ b/checks/calendar.mjs @@ -0,0 +1,62 @@ +// The month grid behind the date button. Off-by-one week starts and month +// boundaries are how a hand-rolled calendar goes wrong, and none of it shows +// until somebody opens the month that breaks. +import { load } from "./extract.mjs"; +import { suite, eq, report } from "./assert.mjs"; + +const app = load({ names: ["ymd", "calendarGridStart"] }); + +const MONDAY = 1, SUNDAY = 0; +const cells = (year, monthIdx, start) => { + const gs = app.calendarGridStart(new Date(year, monthIdx, 1), start); + return Array.from({ length: 42 }, (_, i) => { + const d = new Date(gs); + d.setDate(gs.getDate() + i); + return d; + }); +}; + +suite("the grid starts on the locale's first weekday"); +for (const [name, start] of [["Monday", MONDAY], ["Sunday", SUNDAY]]) { + const everyMonth = Array.from({ length: 12 }, (_, m) => cells(2026, m, start)[0].getDay()); + eq(everyMonth.every(day => day === start), true, + `${name} start: all twelve months of 2026 open on a ${name}`); +} + +suite("six rows, covering the month exactly once"); +{ + let alwaysSix = true, coversAll = true; + for (let m = 0; m < 12; m++) { + const cs = cells(2026, m, MONDAY); + if (cs.length !== 42) alwaysSix = false; + const inMonth = cs.filter(d => d.getMonth() === m).length; + if (inMonth !== new Date(2026, m + 1, 0).getDate()) coversAll = false; + } + eq(alwaysSix, true, "42 cells every month, so the panel never changes height"); + eq(coversAll, true, "every day of every month appears exactly once"); +} + +suite("the months that catch people out"); +{ + // 1 Feb 2026 is a Sunday: under Monday weeks that needs six leading days from + // January, which is precisely what a naive `1 - getDay()` gets wrong. + const feb = cells(2026, 1, MONDAY); + eq(app.ymd(feb[0]), "2026-01-26", "Feb 2026 starts Sunday: the grid opens on 26 Jan"); + eq(app.ymd(feb[6]), "2026-02-01", "…putting the 1st in the last column of row one"); + eq(app.ymd(cells(2026, 1, SUNDAY)[0]), "2026-02-01", + "the same month under Sunday weeks needs no leading days at all"); + eq(cells(2026, 7, MONDAY).filter(d => d.getMonth() === 7).length, 31, + "a 31-day month starting late in the week still fits"); + eq(cells(2024, 1, MONDAY).filter(d => d.getMonth() === 1).length, 29, "Feb 2024 shows 29 days"); + eq(cells(2026, 1, MONDAY).filter(d => d.getMonth() === 1).length, 28, "Feb 2026 shows 28"); +} + +suite("year boundaries"); +{ + eq(app.ymd(cells(2026, 0, MONDAY)[0]).startsWith("2025"), true, + "January's leading cells come from the previous December"); + eq(app.ymd(cells(2026, 11, MONDAY)[41]).startsWith("2027"), true, + "December's trailing cells run into January"); +} + +export default report("calendar"); diff --git a/checks/excluded-days.mjs b/checks/excluded-days.mjs new file mode 100644 index 0000000..5b4dfcc --- /dev/null +++ b/checks/excluded-days.mjs @@ -0,0 +1,106 @@ +// A day marked "not counted" leaves the averages but stays on the record. The +// subtlety is that marking a day is not the same as deleting its events, and +// three panels break if you treat it that way — see the notes in render(). +import { load } from "./extract.mjs"; +import { suite, eq, report } from "./assert.mjs"; + +const app = load({ + names: [ + "ymd", "startOfDay", "EXCLUDED_TYPE", "ALWAYS_COUNTS", "excludedSet", + "excludedDays", "isExcluded", "countedEvents", "spansExcluded", + "pairWindows", "sleepWindows", "sleepMsInRange", "lastEventOfType", + "gapsBetween", + ], + lets: ["excludedSet"], + // gapsBetween defaults its window to the chart picker; the checks pass it in. + stubs: { chartDays: () => 7 }, +}); + +const H = 3600_000; +const at = (day, hour, min = 0) => new Date(2026, 8, day, hour, min).getTime(); +const NOW = at(20, 12); +const mark = (day) => ({ id: `x${day}`, type: app.EXCLUDED_TYPE, at: at(day, 12) }); +const marking = (events) => { app.set.excludedSet(app.excludedDays(events)); return events; }; + +suite("what a marked day takes out of the numbers"); +{ + const evs = marking([ + { id: "a", type: "pee", at: at(18, 9) }, + { id: "b", type: "pee", at: at(19, 9) }, + { id: "c", type: "pee", at: at(20, 9) }, + mark(19), + ]); + eq(app.gapsBetween(evs, "pee", 14), [], + "a gap reaching across a marked day is discarded rather than measured"); +} +{ + const evs = marking([ + { id: "d", type: "pee", at: at(18, 8) }, + { id: "e", type: "pee", at: at(18, 12) }, + { id: "f", type: "pee", at: at(20, 8) }, + { id: "g", type: "pee", at: at(20, 12) }, + mark(19), + ]); + eq(app.gapsBetween(evs, "pee", 14), [4 * H, 4 * H], + "…while the gaps either side of it survive"); +} +{ + const evs = marking([ + { id: "a", type: "pee", at: at(19, 9) }, + { id: "b", type: "pee", at: at(20, 9) }, + { id: "w", type: "weight", at: at(19, 10) }, + { id: "n", type: "note", at: at(19, 11) }, + mark(19), + ]); + // The marker sits on the day it marks, so it is filtered out with the rest; + // harmless, since excludedSet was read off the full list beforehand. + eq(app.countedEvents(evs).map(e => e.id).sort(), ["b", "n", "w"], + "behaviour on a marked day is dropped; a weigh-in and a note are not"); + app.set.excludedSet(new Set()); + eq(app.countedEvents(evs).length, evs.length, "nothing marked means nothing filtered"); +} + +suite("spansExcluded"); +{ + marking([mark(19)]); + eq(app.spansExcluded(at(19, 1), at(19, 5)), true, "wholly inside a marked day"); + eq(app.spansExcluded(at(18, 23), at(20, 1)), true, "straddling one"); + eq(app.spansExcluded(at(20, 1), at(20, 9)), false, "clear of one"); + app.set.excludedSet(new Set()); + eq(app.spansExcluded(at(18, 1), at(24, 1)), false, "nothing marked, so nothing spans"); +} + +// These three were live bugs: the panels were handed a list with the marked +// day's events removed, which answers a different question from the one each +// of them is asking. +suite("what a marked day must NOT take out"); +{ + const evs = marking([ + { id: "p1", type: "pee", at: at(19, 9) }, + { id: "p2", type: "pee", at: at(20, 10) }, // on the marked day + mark(20), + ]); + const last = app.lastEventOfType(evs, "pee"); + eq((NOW - last.at) / H, 2, + "Timing: 'since the last pee' is about now, so it finds the one on the marked day"); +} +{ + const evs = marking([ + { id: "s1", type: "sleep-start", at: at(20, 1) }, + { id: "s2", type: "sleep-end", at: at(20, 3) }, + mark(20), + ]); + eq(app.sleepMsInRange(evs, at(20, 0), NOW) / H, 2, + "Sleep trend: the curve for the day you are looking at shows its real hours"); +} +{ + const evs = marking([ + { id: "s1", type: "sleep-start", at: at(19, 23) }, // marked day + { id: "s2", type: "sleep-end", at: at(20, 7) }, // the next, unmarked + mark(19), + ]); + eq(app.sleepMsInRange(evs, at(20, 0), at(20, 24)) / H, 7, + "a nap crossing midnight still counts for the unmarked day it ends on"); +} + +export default report("excluded-days"); diff --git a/checks/extract.mjs b/checks/extract.mjs new file mode 100644 index 0000000..8726267 --- /dev/null +++ b/checks/extract.mjs @@ -0,0 +1,100 @@ +// Pulls named declarations out of src/app.js and evaluates them, so a check +// exercises the code that ships rather than a copy of it. +// +// The app is one long IIFE with nothing exported — it has no build step and no +// module system, and adding either to make it testable would be a large change +// in service of a small one. Reading the source back is the cheaper trade: the +// checks stay honest, and the app stays a file you can open in a browser. +// +// If a declaration is renamed or removed, load() throws by name. That is the +// point: a check that quietly tested a stale copy would be worse than no check. +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "app.js"); + +// Blanks out comments and string bodies so brace counting can't be fooled by a +// `}` inside one. Positions are preserved, so offsets into the result are valid +// offsets into the original. +function mask(src) { + const out = src.split(""); + let i = 0; + const blank = (from, to) => { for (let k = from; k < to; k++) if (out[k] !== "\n") out[k] = " "; }; + while (i < src.length) { + const c = src[i], next = src[i + 1]; + if (c === "/" && next === "/") { + const end = src.indexOf("\n", i); const stop = end === -1 ? src.length : end; + blank(i, stop); i = stop; continue; + } + if (c === "/" && next === "*") { + const end = src.indexOf("*/", i + 2); const stop = end === -1 ? src.length : end + 2; + blank(i, stop); i = stop; continue; + } + if (c === '"' || c === "'" || c === "`") { + let k = i + 1; + while (k < src.length) { + if (src[k] === "\\") { k += 2; continue; } + if (src[k] === c) break; + k++; + } + blank(i + 1, Math.min(k, src.length)); i = Math.min(k + 1, src.length); continue; + } + i++; + } + return out.join(""); +} + +// The source of one top-level declaration, brace-matched from its opening line. +function declaration(src, masked, name) { + const patterns = [ + new RegExp(`^ {2}(?:async )?function ${name}\\b`, "m"), + new RegExp(`^ {2}(?:const|let) ${name}\\b`, "m"), + ]; + for (const re of patterns) { + const m = re.exec(masked); + if (!m) continue; + const start = m.index; + // A function runs to its matching close brace; a const/let to the newline + // after the statement that balances its own brackets. + let depth = 0, seen = false, i = start; + for (; i < masked.length; i++) { + const ch = masked[i]; + if (ch === "{" || ch === "(" || ch === "[") { depth++; seen = true; } + else if (ch === "}" || ch === ")" || ch === "]") { + depth--; + if (depth === 0 && seen && ch === "}" && /function/.test(m[0])) return src.slice(start, i + 1); + } else if (ch === ";" && depth === 0 && !/function/.test(m[0])) { + return src.slice(start, i + 1); + } + } + } + throw new Error( + `checks/extract: could not find "${name}" in src/app.js.\n` + + `It was probably renamed or removed — update the check that asks for it.`); +} + +/** + * load({ names, lets, stubs }) → { ...declarations, set: { : fn } } + * + * names declarations to pull across, in dependency order + * lets of those, the mutable ones a check needs to assign (a setter is + * generated for each, since a check can't reach the binding otherwise) + * stubs names the extracted code calls but which are not worth extracting — + * DOM lookups, chartDays(), and so on + */ +export function load({ names, lets = [], stubs = {} }) { + const src = readFileSync(SRC, "utf8"); + const masked = mask(src); + const body = names.map(n => declaration(src, masked, n)).join("\n\n"); + const stubNames = Object.keys(stubs); + const exported = names.map(n => n.replace(/^.*\s/, "")); + const setters = lets.map(n => `${n}: (v) => { ${n} = v; }`).join(", "); + const factory = new Function(...stubNames, ` +${body} +return { ${exported.join(", ")}, set: { ${setters} } }; +`); + return factory(...stubNames.map(n => stubs[n])); +} + +export const appSource = () => readFileSync(SRC, "utf8"); diff --git a/checks/layout.mjs b/checks/layout.mjs new file mode 100644 index 0000000..66ec6a6 --- /dev/null +++ b/checks/layout.mjs @@ -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("
"), html.indexOf("
")); + let current = null; + const placement = new Map(); + const wrappers = []; + for (const line of main.split("\n")) { + const open = line.match(/^ {6}
/.test(line) && current) { current = null; continue; } + const panel = line.match(/data-panel="([^"]+)"/); + if (panel && /^ {8}
!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(`]*data-panel="${k}"`).test(html), + `"${k}" is still a
, 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"[^>]*>([^<]+) 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"); diff --git a/checks/run.mjs b/checks/run.mjs new file mode 100644 index 0000000..9d6e87d --- /dev/null +++ b/checks/run.mjs @@ -0,0 +1,53 @@ +// Runs every check. Exits non-zero if any assertion failed. +// +// nix develop -c node checks/run.mjs +// +// These sit beside the Go tests rather than replacing them: the server has +// `go test`, and this is the frontend's half — the arithmetic behind the +// charts, the structure of the markup, and the width budgets that no one here +// can see. It needs no build step and no dependencies; node is already in the +// devShell for `node --check`. +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { readdirSync } from "node:fs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, ".."); + +let failed = 0; + +// The frontend has no build step, so a syntax error would otherwise only show +// up in a browser nobody here is running. +for (const file of ["app.js", "sw.js"]) { + try { + execFileSync(process.execPath, ["--check", join(root, "src", file)], { stdio: "pipe" }); + console.log(` ok src/${file} parses`); + } catch (e) { + console.log(` FAIL src/${file}\n${e.stderr?.toString() ?? e.message}`); + failed++; + } +} +try { + JSON.parse(readdirSync(join(root, "src")).includes("changelog.json") + ? (await import("node:fs")).readFileSync(join(root, "src", "changelog.json"), "utf8") + : "[]"); + console.log(" ok src/changelog.json parses"); +} catch (e) { + console.log(` FAIL src/changelog.json: ${e.message}`); + failed++; +} + +const suites = readdirSync(here) + .filter(f => f.endsWith(".mjs") && !["run.mjs", "extract.mjs", "assert.mjs"].includes(f)) + .sort(); + +for (const file of suites) { + const mod = await import(join(here, file)); + failed += mod.default ?? 0; +} + +console.log(failed === 0 + ? `\nall checks passed (${suites.length} suites)` + : `\n${failed} assertion(s) FAILED`); +process.exit(failed === 0 ? 0 : 1); diff --git a/src/app.js b/src/app.js index 0279fad..0afd30d 100644 --- a/src/app.js +++ b/src/app.js @@ -4923,6 +4923,17 @@ return 1; } + // The top-left cell of the grid: back up from the 1st to the most recent + // `start` weekday, which is nought to six days. Named because it is the one + // line of the calendar that is easy to get wrong and impossible to notice — + // a month beginning on the week's first day needs no backing up at all, and + // one beginning the day before needs six. + function calendarGridStart(calMonth, start) { + const d = new Date(calMonth); + d.setDate(1 - ((calMonth.getDay() - start + 7) % 7)); + return d; + } + function renderCalendar() { const start = firstDayOfWeek(); const selected = ymd(selectedDay()); @@ -4939,8 +4950,7 @@ } // Always six rows, so the panel doesn't change height from month to month. - const gridStart = new Date(calMonth); - gridStart.setDate(1 - ((calMonth.getDay() - start + 7) % 7)); + const gridStart = calendarGridStart(calMonth, start); calGrid.innerHTML = ""; for (let i = 0; i < 42; i++) { const d = new Date(gridStart);