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