import { describe, expect, it } from "vitest"; import { allocateFefo, classifyExpiry, computeBalance, findDuplicateCandidates, forecastDepletion, normalizeDelta, } from "../src/index.js"; const TODAY = new Date("2026-08-02T00:00:00Z"); // UTC – testet ska vara sant i alla tidszoner describe("bäst före-klassning (spec §13)", () => { it("sista förbrukningsdag passerad → expired (hård gräns)", () => { const result = classifyExpiry({ useByDate: "2026-08-01" }, TODAY); expect(result.status).toBe("expired"); expect(result.limitingFactor).toBe("use_by"); }); it("bäst före passerad → expiring + pastBestBefore, ALDRIG auto-expired", () => { const result = classifyExpiry({ bestBeforeDate: "2026-07-30" }, TODAY); expect(result.status).toBe("expiring"); expect(result.pastBestBefore).toBe(true); }); it("trösklar: ≤2 dagar expiring, ≤5 use_soon, annars fresh", () => { expect(classifyExpiry({ bestBeforeDate: "2026-08-04" }, TODAY).status).toBe("expiring"); expect(classifyExpiry({ bestBeforeDate: "2026-08-06" }, TODAY).status).toBe("use_soon"); expect(classifyExpiry({ bestBeforeDate: "2026-08-20" }, TODAY).status).toBe("fresh"); }); it("fryst vara pausar klockan", () => { const result = classifyExpiry({ bestBeforeDate: "2026-07-01", frozenAt: "2026-06-20" }, TODAY); expect(result.status).toBe("fresh"); }); it("upptinad vara räknas igen", () => { const result = classifyExpiry( { bestBeforeDate: "2026-07-01", frozenAt: "2026-06-20", thawedAt: "2026-08-01" }, TODAY, ); expect(result.status).not.toBe("fresh"); }); it("öppnad vara: riktvärde per plats kan vara mest bindande", () => { const result = classifyExpiry( { bestBeforeDate: "2026-09-01", openedAt: "2026-07-30", storageLocationType: "fridge", shelfLifeGuidance: { fridge: 5 }, }, TODAY, ); expect(result.limitingFactor).toBe("opened_guidance"); expect(result.daysLeft).toBe(2); expect(result.status).toBe("expiring"); }); it("ingen data → unknown, aldrig gissning", () => { expect(classifyExpiry({}, TODAY).status).toBe("unknown"); }); it("förseglad vara: inköpsdatum + riktvärde men INGET tryckt datum → unknown", () => { // Regressionsvakt: gamla purchased_guidance-grenen gissade utgång från inköp // på oöppnade varor → falska "går ut snart"-varningar (Bug B). const result = classifyExpiry( { purchasedAt: "2026-08-01", storageLocationType: "fridge", shelfLifeGuidance: { fridge: 7 } }, TODAY, ); expect(result.status).toBe("unknown"); expect(result.limitingFactor).toBe("none"); }); it("mjölkprincipen: gick ut igår → använd sinnena, inte soptunnan", () => { // Bäst före är en KVALITETSgräns: mjölken som gick ut igår är inte // automatiskt dålig – användaren ska lukta/smaka. Appen får aldrig // klassa den som "expired" eller föreslå att den slängs. const result = classifyExpiry({ bestBeforeDate: "2026-08-01" }, TODAY); expect(result.status).toBe("expiring"); // synlig och prioriterad – inte dömd expect(result.pastBestBefore).toBe(true); // UI: "lukta och smaka" expect(result.daysLeft).toBe(-1); expect(result.status).not.toBe("expired"); }); }); describe("transaktionssaldo (spec §8)", () => { it("summerar spec-exemplet korrekt", () => { const result = computeBalance([ { type: "purchase", quantityDelta: 1000, unit: "GRAM" }, { type: "cook_use", quantityDelta: -300, unit: "GRAM" }, { type: "discard", quantityDelta: -150, unit: "GRAM" }, ]); expect(result.balance).toBe(550); expect(result.valid).toBe(true); }); it("flaggar negativt saldo som inkonsistens", () => { const result = computeBalance([ { type: "purchase", quantityDelta: 100, unit: "GRAM" }, { type: "consume", quantityDelta: -200, unit: "GRAM" }, ]); expect(result.valid).toBe(false); expect(result.balance).toBe(0); }); it("normaliserar tecken per transaktionstyp", () => { expect(normalizeDelta("consume", 300)).toBe(-300); expect(normalizeDelta("purchase", -300)).toBe(300); expect(normalizeDelta("adjust", -50)).toBe(-50); }); }); describe("FEFO-allokering (spec §17)", () => { it("plockar från den som går ut först", () => { const result = allocateFefo( 400, "GRAM", [ { id: "fresh", quantity: 500, unit: "GRAM", bestBeforeDate: "2026-08-20" }, { id: "old", quantity: 300, unit: "GRAM", bestBeforeDate: "2026-08-03" }, ], {}, TODAY, ); expect(result.covered).toBe(true); expect(result.allocations[0]?.itemId).toBe("old"); expect(result.allocations[0]?.quantity).toBe(300); expect(result.allocations[1]?.itemId).toBe("fresh"); expect(result.allocations[1]?.quantity).toBe(100); }); it("rapporterar shortfall ärligt", () => { const result = allocateFefo( 1000, "GRAM", [{ id: "a", quantity: 300, unit: "GRAM", bestBeforeDate: "2026-08-10" }], {}, TODAY, ); expect(result.covered).toBe(false); expect(result.shortfall).toBe(700); }); it("mjölkprincipen: passerat bäst före utesluts ALDRIG – räddas först", () => { const result = allocateFefo( 400, "GRAM", [ { id: "fresh", quantity: 500, unit: "GRAM", bestBeforeDate: "2026-08-20" }, { id: "past", quantity: 300, unit: "GRAM", bestBeforeDate: "2026-08-01" }, // gick ut igår ], {}, TODAY, ); // Varan som passerat bäst före plockas FÖRST (rädda den), inte bort. expect(result.covered).toBe(true); expect(result.allocations[0]?.itemId).toBe("past"); expect(result.allocations[0]?.quantity).toBe(300); }); }); describe("dubblettkandidater (spec §9)", () => { it("hittar kvitto+bild-dubblett inom fönstret", () => { const candidates = findDuplicateCandidates( { canonicalIngredientId: "milk_3", displayName: "Mjölk 3%", quantity: 1, source: "receipt", createdAt: "2026-08-02T10:00:00Z", }, [ { id: "existing", canonicalIngredientId: "milk_3", displayName: "mjölk", quantity: 1, source: "fridge_photo", createdAt: "2026-08-01T18:00:00Z", }, ], ); expect(candidates).toHaveLength(1); expect(candidates[0]?.score).toBeGreaterThanOrEqual(0.7); }); it("matchar inte helt olika varor", () => { const candidates = findDuplicateCandidates( { canonicalIngredientId: "milk_3", displayName: "Mjölk", quantity: 1, source: "receipt", createdAt: "2026-08-02T10:00:00Z", }, [ { id: "x", canonicalIngredientId: "chicken_breast", displayName: "Kyckling", quantity: 500, source: "barcode", createdAt: "2026-08-02T09:00:00Z", }, ], ); expect(candidates).toHaveLength(0); }); }); describe("pantry forecast (spec §40)", () => { it("beräknar förbrukningstakt och påfyllnadsdatum", () => { const result = forecastDepletion( 1, [ { date: "2026-07-20", quantity: 1 }, { date: "2026-07-26", quantity: 1 }, { date: "2026-08-01", quantity: 1 }, ], TODAY, ); expect(result.dailyRate).toBeCloseTo(0.25, 2); expect(result.daysUntilEmpty).toBeCloseTo(4, 0); expect(result.suggestedRestockDate).toBe("2026-08-04"); }); it("låg konfidens vid för lite data", () => { expect(forecastDepletion(1, [{ date: "2026-08-01", quantity: 1 }], TODAY).confidence).toBe( "low", ); }); });