// Kinds of food: a library of user-named labels a meal can carry. The rules // worth holding still are the ones that decide what happens to people who // never use the feature, and what happens to history when a kind is deleted. import { load } from "./extract.mjs"; import { suite, eq, ok, report } from "./assert.mjs"; let store = []; let synced = 0, rendered = 0; let nextId = 0; const app = load({ names: [ "NO_KIND", "FOOD_COLORS", "loadFoodKinds", "saveFoodKinds", "liveFoodKinds", "addFoodKind", "updateFoodKind", "deleteFoodKind", "setDefaultFoodKind", "defaultFoodKindId", "foodKindNames", ], stubs: { foodKindsKey: () => "k", localStorage: { getItem: () => JSON.stringify(store), setItem: (_, v) => { store = JSON.parse(v); }, }, uuid: () => `id${++nextId}`, scheduleSync: () => { synced++; }, render: () => { rendered++; }, }, }); const reset = () => { store = []; nextId = 0; }; const names = () => app.liveFoodKinds().map(k => k.name); suite("an account with no kinds behaves as it always did"); { reset(); eq(app.liveFoodKinds(), [], "no kinds to begin with"); eq(app.defaultFoodKindId(), app.NO_KIND, "…so a new meal starts with no kind"); eq(app.NO_KIND, "", "and 'no kind' is the empty string, which is what old meals carry"); } suite("creating kinds"); { reset(); const dry = app.addFoodKind("Dry"); const fresh = app.addFoodKind("Fresh"); eq(names(), ["Dry", "Fresh"], "listed in creation order, not alphabetical"); eq([dry.colorIndex, fresh.colorIndex], [0, 1], "each takes the next palette slot"); ok(synced > 0, "a new kind is queued for sync"); } suite("the default"); { reset(); const dry = app.addFoodKind("Dry"); const fresh = app.addFoodKind("Fresh"); eq(app.defaultFoodKindId(), app.NO_KIND, "nothing is default until you say so"); app.setDefaultFoodKind(dry.id); eq(app.defaultFoodKindId(), dry.id, "the chosen kind becomes the default"); app.setDefaultFoodKind(fresh.id); eq(app.defaultFoodKindId(), fresh.id, "choosing another moves it"); eq(app.liveFoodKinds().filter(k => k.isDefault).length, 1, "…and unflags the old one, so there is never more than one"); app.setDefaultFoodKind(app.NO_KIND); eq(app.defaultFoodKindId(), app.NO_KIND, "and it can be cleared back to no kind"); } suite("two devices that each set a default"); { // What a sync race leaves behind: both rows flagged, different timestamps. // Resolving to the newer beats showing two defaults or picking at random. reset(); store = [ { id: "a", name: "Dry", colorIndex: 0, isDefault: true, updatedAt: 1000 }, { id: "b", name: "Fresh", colorIndex: 1, isDefault: true, updatedAt: 2000 }, ]; eq(app.defaultFoodKindId(), "b", "the more recent flag wins"); } suite("renaming and deleting keep history readable"); { reset(); const dry = app.addFoodKind("Dry"); app.updateFoodKind(dry.id, { name: "Dry kibble" }); eq(names(), ["Dry kibble"], "renaming changes the name in place"); eq(app.foodKindNames().get(dry.id), "Dry kibble", "…and meals pointing at the id follow it, since they resolve by id"); app.deleteFoodKind(dry.id); eq(names(), [], "a deleted kind leaves the picker"); eq(app.foodKindNames().get(dry.id), "Dry kibble", "…but its name still resolves, so meals logged as it stay readable"); } suite("colours survive a deletion"); { // Deriving colour from position in the live list would repaint every past // chart the moment a kind was removed. The index is fixed at creation. reset(); app.addFoodKind("Dry"); const fresh = app.addFoodKind("Fresh"); const raw = app.addFoodKind("Raw"); eq(raw.colorIndex, 2, "the third kind takes the third slot"); app.deleteFoodKind(fresh.id); const live = app.liveFoodKinds(); eq(live.map(k => k.colorIndex), [0, 2], "deleting the middle kind leaves the others' colours alone"); eq(app.addFoodKind("Treats").colorIndex, 3, "and the next kind does not reuse the freed slot"); } suite("the palette wraps rather than running out"); { reset(); for (let i = 0; i < app.FOOD_COLORS + 2; i++) app.addFoodKind(`K${i}`); const live = app.liveFoodKinds(); eq(live.length, app.FOOD_COLORS + 2, "you can have more kinds than colours"); eq(live[app.FOOD_COLORS].colorIndex % app.FOOD_COLORS, 0, "…and the palette wraps, so two share rather than one having none"); } // ---------------------------------------------- the day's split in the Meals tile // The tile holds the day's total; this puts the per-kind figures under it, so // the parts and the sum they make are read in one place. The case that matters // is the one where none of it should appear. { let kinds = []; let kindsEl, gramsEl; const view = load({ names: ["NO_KIND", "renderDayFoodKinds"], stubs: { document: { getElementById: () => kindsEl, createElement: () => ({ textContent: "", appendChild() {} }), }, liveFoodKinds: () => kinds, foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])), }, }); const meal = (grams, foodKindId = "") => ({ type: "eat", grams, foodKindId }); const show = (evs) => { const lines = []; kindsEl = { hidden: false, textContent: "", appendChild: (n) => lines.push(n.textContent), }; gramsEl = { textContent: "" }; const total = evs.filter(e => e.type === "eat" && e.grams > 0) .reduce((s, e) => s + e.grams, 0); view.renderDayFoodKinds(evs, gramsEl, total); return { lines, grams: gramsEl.textContent, hidden: kindsEl.hidden }; }; suite("the day's food in the Meals tile"); { kinds = []; const plain = show([meal(180), meal(120)]); eq(plain.hidden, true, "meals with no kind add nothing under the total"); eq(plain.grams, "", "…and leave the tile's own total line alone"); eq(show([]).hidden, true, "a day with no meals adds nothing"); kinds = [{ id: "d", name: "Dry" }]; const one = show([meal(300, "d"), meal(240, "d")]); eq(one.hidden, true, "one kind adds no lines — it would only repeat the total"); eq(one.grams, "540 g · Dry", "…the total carries its name instead"); kinds = [{ id: "d", name: "Dry" }, { id: "f", name: "Fresh" }]; const two = show([meal(200, "d"), meal(100, "f"), meal(60, "d")]); eq(two.hidden, false, "two kinds do get their own lines"); eq(two.lines, ["Dry 260 g", "Fresh 100 g"], "…summed per kind, in the chart's order"); eq(show([meal(200, "d"), meal(50)]).lines, ["Dry 200 g", "No kind 50 g"], "unlabelled food alongside a kind is named, not dropped"); kinds = []; eq(show([meal(90, "gone")]).grams, "90 g · Deleted kind", "a kind deleted since still labels its food rather than vanishing"); kinds = [{ id: "d", name: "Dry" }]; eq(show([meal(0, "d"), meal(120, "d")]).grams, "120 g · Dry", "a meal logged without an amount adds nothing to the split"); } } export default report("food-kinds");