// 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"); } } export default report("food-trend");