A kind that is deleted keeps its tombstone so meals logged as it stay readable, and the chart recovered its name from there — but not its colour, falling back to grey. Grey is what "No kind" uses, so a deleted kind's food and unlabelled food drew as the same colour: two distinct series, indistinguishable in the stack and in the legend beneath it. The tombstone has the colorIndex all along, so reading the whole record rather than just the name fixes it. The check now pins the colour as well as the name, since the name alone was what let this through. Deleting a kind still leaves the meals alone — confirmed as the wanted behaviour. This only makes that behaviour legible.
255 lines
11 KiB
JavaScript
255 lines
11 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");
|
|
}
|
|
}
|
|
|
|
export default report("food-trend");
|