A day whose food was all unlabelled read "Fri, Sep 18 — No kind 300 g · 300 g in total": the number twice, under a label with nothing to distinguish it from. The guard was on the wrong quantity. It asked whether the *chart* was split, when what decides this is how many kinds *that day* holds — and a chart split across other days can still land on a day of one kind. Keying off the day's own parts fixes the reported case and a second one nobody had hit yet, where a day of a single named kind read "Dry 300 g · 300 g in total". So: several kinds keep the total beside them, being what the bar's height shows and what you would otherwise add up. One kind does not, because it already is the total. And "No kind" alone drops its label, which was only ever there to tell it apart from something else. The new assertions were checked against the old guard, where five of them fail. The original ones passed throughout, which is the point — they only ever exercised a chart with one series, and this bug lives on the other axis.
363 lines
16 KiB
JavaScript
363 lines
16 KiB
JavaScript
// The fit through the Food (grams) bars. The arithmetic is easy to get subtly
|
|
// wrong and the result is a sentence stating a fact about the puppy, so the
|
|
// cases that matter are the ones where it should decline to say anything.
|
|
import { load } from "./extract.mjs";
|
|
import { suite, eq, ok, report } from "./assert.mjs";
|
|
|
|
const app = load({ names: ["foodTrend"] });
|
|
const words = load({ names: ["foodTrendSentence"] });
|
|
|
|
// The 7/14/30 picker reaches the trend by deciding how many days weeklyData
|
|
// builds — there is no second mechanism, so this is the thing to hold still.
|
|
let windowDays = 7;
|
|
const weekly = load({
|
|
names: ["startOfDay", "endOfDay", "ymd", "weeklyData"],
|
|
stubs: {
|
|
chartDays: () => windowDays,
|
|
isExcluded: () => false,
|
|
eventsForDay: () => [],
|
|
sleepMsInRange: () => 0,
|
|
walkMsInRange: () => 0,
|
|
},
|
|
});
|
|
|
|
suite("the day picker is what sets the trend's window");
|
|
for (const n of [7, 14, 30]) {
|
|
windowDays = n;
|
|
eq(weekly.weeklyData([]).length, n, `picking ${n}d gives the charts ${n} days to fit over`);
|
|
}
|
|
windowDays = 7;
|
|
|
|
// weeklyData's shape, as far as foodTrend reads it. Today is last, as there.
|
|
const days = (grams, { excluded = [] } = {}) =>
|
|
grams.map((g, i) => ({ grams: g, excluded: excluded.includes(i) }));
|
|
|
|
suite("it declines to fit when there is nothing to fit");
|
|
{
|
|
eq(app.foodTrend(days([300, 320, 310])), null,
|
|
"three days is too few — today is dropped, leaving two, and two always fit perfectly");
|
|
eq(app.foodTrend(days([300, 320, 310, 330, 340], { excluded: [0, 1] })), null,
|
|
"marked days don't count toward the four either");
|
|
eq(app.foodTrend(days([])), null, "an empty window fits nothing");
|
|
}
|
|
|
|
suite("today is left out, being half-eaten");
|
|
{
|
|
// Four steady days then a partial today. Including today would tip the line
|
|
// down; the fit should not see it at all.
|
|
const t = app.foodTrend(days([400, 400, 400, 400, 50]));
|
|
ok(t, "four complete days are enough");
|
|
eq(Math.round(t.change), 0, "a flat run stays flat despite today being low");
|
|
eq(t.last, 3, "the line stops at the last complete day, not at today");
|
|
}
|
|
|
|
suite("it reports a direction only when the climb beats the scatter");
|
|
{
|
|
const rising = app.foodTrend(days([200, 250, 300, 350, 400, 450, 0]));
|
|
ok(rising.clear, "a clean climb is reported");
|
|
eq(Math.round(rising.change), 250, "…as the move across the five days it fitted, 200 g to 450 g");
|
|
|
|
const falling = app.foodTrend(days([450, 400, 350, 300, 250, 200, 0]));
|
|
ok(falling.clear, "a clean fall is reported");
|
|
ok(falling.change < 0, "…with a negative change");
|
|
|
|
// Same mean, no direction, plenty of noise: the honest answer is "steady".
|
|
const noisy = app.foodTrend(days([200, 500, 210, 480, 190, 520, 0]));
|
|
ok(!noisy.clear, "a see-saw is not a trend, however the slope comes out");
|
|
|
|
// A gentle real climb buried in large day-to-day swings: also not claimable.
|
|
const buried = app.foodTrend(days([300, 520, 180, 540, 200, 560, 0]));
|
|
ok(!buried.clear, "a slope smaller than the scatter is not reported as a trend");
|
|
}
|
|
|
|
suite("the fitted line passes through the data");
|
|
{
|
|
const t = app.foodTrend(days([100, 200, 300, 400, 500, 0]));
|
|
eq(Math.round(t.at(0)), 100, "it starts where the first day sits");
|
|
eq(Math.round(t.at(4)), 500, "and ends where the last complete day sits");
|
|
eq(Math.round(t.mean), 300, "the mean is the mean of the days it fitted");
|
|
}
|
|
|
|
suite("marked days are skipped without shifting the line");
|
|
{
|
|
// The middle day is marked; the rest describe a clean 50 g/day climb. The fit
|
|
// must ignore the hatch rather than reading it as a day of zero grams.
|
|
const t = app.foodTrend(days([200, 250, 0, 350, 400, 450, 0], { excluded: [2] }));
|
|
eq(Math.round(t.change), 250, "the climb is unchanged by the marked day");
|
|
ok(t.clear, "…and it is still clear, not drowned by a false zero");
|
|
}
|
|
|
|
suite("what the sentence is allowed to say");
|
|
{
|
|
const say = (grams, opts, win = 14) => words.foodTrendSentence(app.foodTrend(days(grams, opts)), win);
|
|
|
|
const rising = say([200, 250, 300, 350, 400, 450, 0]);
|
|
ok(/up about 250 g/.test(rising), "a clear climb gives the size of the move");
|
|
ok(/from roughly 200 g a day to 450 g/.test(rising), "…and the figures at each end");
|
|
ok(/the last 14 days/.test(rising), "…named against the window it was fitted over");
|
|
|
|
// The defect this replaced: the move was quoted per week while the fit spans
|
|
// at most five days on a 7-day window, so the figure and the two endpoints
|
|
// disagreed and a reader who subtracted them found the sentence wrong.
|
|
// Whatever the window, the three numbers in the sentence must reconcile.
|
|
for (const [label, grams, win] of [
|
|
["a steep 7-day fall", [460, 425, 390, 355, 320, 285, 0], 7],
|
|
["a long 14-day climb", [200, 220, 240, 260, 280, 300, 320, 340, 360, 380, 400, 420, 440, 0], 14],
|
|
["a gentle 30-day climb", [...Array(29).fill(0).map((_, i) => 300 + i * 12), 0], 30],
|
|
]) {
|
|
const s = say(grams, undefined, win);
|
|
const m = s.match(/about (\d+) g — from roughly (\d+) g a day to (\d+) g/);
|
|
ok(m, `${label}: the sentence has all three figures`);
|
|
if (m) {
|
|
const [, moved, from, to] = m.map(Number);
|
|
eq(moved, Math.abs(to - from), `${label}: the move is exactly the difference of the two ends`);
|
|
ok(new RegExp(`is ${to > from ? "up" : "down"} about`).test(s),
|
|
`${label}: and the direction matches which end is larger`);
|
|
}
|
|
}
|
|
|
|
const steady = say([300, 302, 298, 301, 299, 300, 0]);
|
|
ok(/roughly steady/.test(steady), "a flat run is called steady");
|
|
ok(/averaging about 300 g a day/.test(steady),
|
|
"…and quotes the average, which is a measurement rather than model output");
|
|
ok(!/then/.test(steady) && !/ now\b/.test(steady),
|
|
"…but not fitted endpoints, which would dress up a line nobody should read");
|
|
|
|
const noisy = say([200, 500, 210, 480, 190, 520, 0]);
|
|
ok(/roughly steady/.test(noisy) && /variation is larger/.test(noisy),
|
|
"a see-saw says the variation beat the trend, rather than quoting a slope");
|
|
|
|
const none = say([300, 320, 310]);
|
|
ok(/Not enough complete days/.test(none) && /needs four/.test(none),
|
|
"too few days explains itself instead of leaving the chart bare");
|
|
|
|
// False precision would make a fit look like a reading.
|
|
ok(/\b\d*[05] g a day/.test(rising), "figures are rounded to 10 g, not quoted to the gram");
|
|
const falling = say([450, 400, 350, 300, 250, 200, 0]);
|
|
ok(/down about/.test(falling), "a clear fall says down");
|
|
}
|
|
|
|
// ---------------------------------------------------------------- by kind
|
|
// Splitting the bars must not change what the chart says for anyone who never
|
|
// defines a kind, and the per-kind caption must stay bounded as kinds are added.
|
|
{
|
|
let kinds = [];
|
|
const split = load({
|
|
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodTrendSentence",
|
|
"foodTrendMoves", "foodSeriesSentences", "foodSeriesFor"],
|
|
stubs: {
|
|
liveFoodKinds: () => kinds,
|
|
loadFoodKinds: () => kinds,
|
|
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
|
|
},
|
|
});
|
|
|
|
// days carrying a per-kind split, as weeklyData builds them.
|
|
const byKind = (rows) => rows.map(r => ({
|
|
grams: Object.values(r).reduce((s, v) => s + v, 0),
|
|
gramsByKind: r,
|
|
excluded: false,
|
|
meals: 0, mealsMissingGrams: 0,
|
|
}));
|
|
|
|
suite("an unsplit chart is unchanged");
|
|
{
|
|
kinds = [];
|
|
const days = byKind([{ "": 300 }, { "": 320 }, { "": 310 }, { "": 330 }, { "": 340 }, { "": 0 }]);
|
|
const series = split.foodSeriesFor(days);
|
|
eq(series.length, 1, "no kinds defined gives exactly one series");
|
|
eq(series[0].name, "No kind", "…the unnamed one");
|
|
const s = split.foodSeriesSentences(series, 7);
|
|
eq(s.length, 1, "…and one sentence, as before kinds existed");
|
|
ok(/daily intake/.test(s[0]), "…phrased as the whole intake, not as a kind");
|
|
}
|
|
|
|
suite("the split adds up");
|
|
{
|
|
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
|
|
const days = byKind([
|
|
{ d: 200, f: 100 }, { d: 210, f: 90 }, { d: 220, f: 80 },
|
|
{ d: 230, f: 70 }, { d: 240, f: 60 }, { d: 0, f: 0 },
|
|
]);
|
|
const series = split.foodSeriesFor(days);
|
|
eq(series.map(s => s.name), ["Dry", "Fresh"], "a series per kind, in creation order");
|
|
for (const d of days) {
|
|
const summed = series.reduce((acc, s) => acc + s.of(d), 0);
|
|
eq(summed, d.grams, "each day's segments sum to the day's own total");
|
|
}
|
|
}
|
|
|
|
suite("a kind with nothing logged is left out");
|
|
{
|
|
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "z", name: "Never used", colorIndex: 1 }];
|
|
const days = byKind([{ d: 200 }, { d: 210 }, { d: 220 }, { d: 230 }, { d: 0 }]);
|
|
eq(split.foodSeriesFor(days).map(s => s.name), ["Dry"],
|
|
"an unused kind gets no segment and no legend entry");
|
|
}
|
|
|
|
suite("a deleted kind's food still appears");
|
|
{
|
|
// The kind is gone from the picker but meals still point at it, and that
|
|
// food is real — it has to show under the name the tombstone kept.
|
|
kinds = [];
|
|
const namesOnly = load({
|
|
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodSeriesFor"],
|
|
stubs: {
|
|
liveFoodKinds: () => [],
|
|
// The tombstone: gone from the picker, still carrying name and colour.
|
|
loadFoodKinds: () => [{ id: "gone", name: "Old recipe", colorIndex: 2, deleted: true }],
|
|
},
|
|
});
|
|
const days = byKind([{ gone: 100 }, { gone: 110 }, { gone: 120 }, { gone: 130 }, { gone: 0 }]);
|
|
const series = namesOnly.foodSeriesFor(days);
|
|
eq(series.map(s => s.name), ["Old recipe"],
|
|
"it keeps its name rather than vanishing or reading as 'No kind'");
|
|
eq(series[0].colorIndex, 2,
|
|
"…and its colour, which grey would confuse with the 'No kind' series");
|
|
}
|
|
|
|
suite("the caption stays bounded as kinds are added");
|
|
{
|
|
kinds = [
|
|
{ id: "a", name: "Dry", colorIndex: 0 },
|
|
{ id: "b", name: "Fresh", colorIndex: 1 },
|
|
{ id: "c", name: "Raw", colorIndex: 2 },
|
|
{ id: "e", name: "Treats", colorIndex: 3 },
|
|
];
|
|
// Dry climbs clearly; the rest are flat or noise.
|
|
const days = byKind([
|
|
{ a: 100, b: 50, c: 40, e: 10 }, { a: 150, b: 52, c: 39, e: 11 },
|
|
{ a: 200, b: 49, c: 41, e: 10 }, { a: 250, b: 51, c: 40, e: 12 },
|
|
{ a: 300, b: 50, c: 40, e: 9 }, { a: 0, b: 0, c: 0, e: 0 },
|
|
]);
|
|
const series = split.foodSeriesFor(days);
|
|
const lines = split.foodSeriesSentences(series, 7);
|
|
ok(lines.some(l => /^Dry:/.test(l)), "the kind that moved gets its own sentence");
|
|
ok(lines.length <= 2, `four kinds give at most two lines, got ${lines.length}`);
|
|
ok(lines.some(l => /no clear trend/.test(l)),
|
|
"…and the rest are folded into one clause rather than a sentence each");
|
|
}
|
|
|
|
suite("no kind moves at all");
|
|
{
|
|
kinds = [{ id: "a", name: "Dry", colorIndex: 0 }, { id: "b", name: "Fresh", colorIndex: 1 }];
|
|
const days = byKind([
|
|
{ a: 200, b: 100 }, { a: 205, b: 98 }, { a: 198, b: 101 },
|
|
{ a: 202, b: 99 }, { a: 200, b: 100 }, { a: 0, b: 0 },
|
|
]);
|
|
const lines = split.foodSeriesSentences(split.foodSeriesFor(days), 7);
|
|
eq(lines.length, 1, "one line when nothing is claimable");
|
|
ok(/no kind shows a trend/.test(lines[0]), "…saying so plainly");
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------- the highlighted day's readout
|
|
// Tapping a bar selects that day; this is what the selection says. It reads off
|
|
// the existing selection rather than keeping its own, so the two cannot drift.
|
|
{
|
|
let kinds = [];
|
|
let selected = "2026-09-20";
|
|
let el = { hidden: false, textContent: "" };
|
|
const info = load({
|
|
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodSeriesFor", "renderFoodDayInfo"],
|
|
stubs: {
|
|
document: { getElementById: () => el },
|
|
selectedDay: () => new Date(selected + "T12:00:00"),
|
|
ymd: (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`,
|
|
liveFoodKinds: () => kinds,
|
|
loadFoodKinds: () => kinds,
|
|
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
|
|
},
|
|
});
|
|
|
|
const day = (n, byKind, excluded = false) => ({
|
|
ymd: `2026-09-${String(n).padStart(2, "0")}`,
|
|
date: new Date(2026, 8, n),
|
|
grams: Object.values(byKind).reduce((s, v) => s + v, 0),
|
|
gramsByKind: byKind, excluded, meals: 0, mealsMissingGrams: 0,
|
|
});
|
|
const read = (days) => {
|
|
el = { hidden: false, textContent: "" };
|
|
info.renderFoodDayInfo(days, info.foodSeriesFor(days));
|
|
return el;
|
|
};
|
|
|
|
suite("the highlighted day's breakdown");
|
|
{
|
|
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
|
|
const days = [
|
|
day(18, { d: 200, f: 100 }), day(19, { d: 210, f: 90 }), day(20, { d: 260, f: 100 }),
|
|
];
|
|
selected = "2026-09-20";
|
|
const r = read(days);
|
|
eq(r.hidden, false, "the selected day gets a readout");
|
|
ok(/Dry 260 g · Fresh 100 g/.test(r.textContent), "each kind's amount, in the stack's order");
|
|
ok(/360 g in total/.test(r.textContent), "…and the total, so you needn't add them up");
|
|
ok(/Sep 20/.test(r.textContent), "…named, so it is clear which bar it belongs to");
|
|
|
|
selected = "2026-09-18";
|
|
ok(/Dry 200 g/.test(read(days).textContent), "selecting another bar moves the readout");
|
|
|
|
// Out of the window entirely: the chart is not showing that day at all.
|
|
selected = "2026-08-01";
|
|
eq(read(days).hidden, true, "a day outside the window has no bar and so no readout");
|
|
}
|
|
|
|
suite("the days that say something else");
|
|
{
|
|
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
|
|
selected = "2026-09-20";
|
|
const withEmpty = [day(18, { d: 200, f: 100 }), day(19, { d: 210 }), day(20, {})];
|
|
ok(/no food logged/.test(read(withEmpty).textContent), "a day with no food says so");
|
|
|
|
const withExcluded = [day(18, { d: 200, f: 100 }), day(19, { d: 210 }), day(20, {}, true)];
|
|
ok(/not counted/.test(read(withExcluded).textContent),
|
|
"a day marked not counted says that instead of reading as empty");
|
|
}
|
|
|
|
|
|
suite("a day holding only one kind");
|
|
{
|
|
// The chart is split — other days have several kinds — but this day's food
|
|
// is all one. The total is that one figure, so saying it twice is noise.
|
|
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
|
|
const days = [day(18, { "": 300 }), day(19, { d: 210, f: 90 }), day(20, { d: 300 })];
|
|
|
|
selected = "2026-09-18";
|
|
const unlabelled = read(days).textContent;
|
|
eq(unlabelled, "Fri, Sep 18 — 300 g.",
|
|
"a day of unlabelled food gives the bare total, on a split chart too");
|
|
ok(!/No kind/.test(unlabelled),
|
|
"…without the 'No kind' label, which has nothing to distinguish it from");
|
|
ok(!/in total/.test(unlabelled), "…and without saying the number twice");
|
|
|
|
selected = "2026-09-20";
|
|
const oneKind = read(days).textContent;
|
|
eq(oneKind, "Sun, Sep 20 — Dry 300 g.",
|
|
"a day of one named kind keeps the name, which does say something");
|
|
ok(!/in total/.test(oneKind), "…but still does not repeat the figure");
|
|
|
|
selected = "2026-09-19";
|
|
ok(/in total/.test(read(days).textContent),
|
|
"two kinds on a day still get the total beside them");
|
|
}
|
|
|
|
suite("an unsplit chart gets the day total too");
|
|
{
|
|
// No kinds defined: there is still no hover on a phone, so the figure was
|
|
// only readable by eye off the axis.
|
|
kinds = [];
|
|
selected = "2026-09-20";
|
|
const days = [day(18, { "": 300 }), day(19, { "": 320 }), day(20, { "": 340 })];
|
|
const r = read(days);
|
|
eq(r.hidden, false, "the readout appears without any kinds defined");
|
|
eq(r.textContent, "Sun, Sep 20 — 340 g.", "…as the plain total, named by day");
|
|
ok(!/No kind/.test(r.textContent),
|
|
"…without inventing a kind name for food that has none");
|
|
ok(!/in total/.test(r.textContent),
|
|
"…and without saying the same number twice");
|
|
}
|
|
}
|
|
|
|
export default report("food-trend");
|