Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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("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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user