// 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");