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.
107 lines
3.9 KiB
JavaScript
107 lines
3.9 KiB
JavaScript
// 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");
|