"Up about 40 g a week" is a rate with nothing to anchor it: it says the line slopes without saying where it sits. The sentence now names both ends of the fit — "roughly 280 g a day then, 400 g a day now" — which is the reading anyone actually wants from a growth chart. When the fit is not trustworthy it quotes the average instead, and says the day-to-day variation is larger than any trend. That difference is the point. The average is a measurement and survives the noise; the ends of the line are the line's own output, and quoting them on a fit nobody should read would dress a guess up as a reading. Everything rounds to 10 g for the same reason — "287 g a day" would be false precision from four noisy points. The wording moves into a pure foodTrendSentence() so those rules can be checked, which is worth doing precisely because they are judgement rather than arithmetic: the checks now pin that a clear climb gives both figures, a flat run gives the average and no endpoints, a see-saw gives neither, and nothing is ever quoted to the gram.
122 lines
5.3 KiB
JavaScript
122 lines
5.3 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.perWeek), 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.perWeek), 350, "…at 50 g a day, which is 350 g a week");
|
|
|
|
const falling = app.foodTrend(days([450, 400, 350, 300, 250, 200, 0]));
|
|
ok(falling.clear, "a clean fall is reported");
|
|
ok(falling.perWeek < 0, "…with a negative weekly 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.perWeek), 350, "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) => words.foodTrendSentence(app.foodTrend(days(grams, opts)), 14);
|
|
|
|
const rising = say([200, 250, 300, 350, 400, 450, 0]);
|
|
ok(/up about 350 g a week/.test(rising), "a clear climb gives the rate");
|
|
ok(/200 g a day then/.test(rising) && /450 g a day now/.test(rising),
|
|
"…and the figures at each end of the line, so it is not only a rate");
|
|
ok(/the last 14 days/.test(rising), "…named against the window it was fitted over");
|
|
|
|
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");
|
|
}
|
|
|
|
export default report("food-trend");
|