e66d9aee72
CI / Typecheck, test & build (push) Failing after 46s
BUG: forseglad vara utan tryckt datum fick fabricerat utgang (purchasedAt + shelfLifeGuidance, som ar hallbarhet EFTER oppning) -> falska 'gar ut snart'-varningar OCH gav sken av att datum inte lastes. Nu: ingen data -> unknown (hedrar appens egen princip 'aldrig gissa'). parseDate laser aven YY-MM-DD/YYMMDD/YYYYMMDD (svenska stamplar) som forr slangdes. Regressionstester tillagda. 36/36 gront.
220 lines
7.6 KiB
TypeScript
220 lines
7.6 KiB
TypeScript
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",
|
||
);
|
||
});
|
||
});
|