Files
Cibello-app/packages/inventory-engine/test/engine.test.ts
T
2026-08-05 19:21:11 +07:00

210 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
);
});
});