Files
Cibello-app/packages/inventory-engine/test/engine.test.ts
T
Claude 684743ae95 fix(inventory): robustare dedup vid skann + borttag pa startsidan
Dedup vid /v1/scans/:id/confirm matchade bara pa exakt (skiftlageskansligt)
namn eller identiskt katalog-id, sa omfotografering av samma hylla skapade
dubbletter. Ny normalizeItemName (gemener, accenter, storlek bort; behaller
fetthalt-siffror) + matchning pa katalog-id ELLER normaliserat namn mot
aktivt hushallslager.

Mobil: 'Ta bort' fanns bara i Ditt kok. Lade borttag pa startsidans
lager-rader (befintlig DELETE /v1/inventory/items/:id).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZdew6cWn1MeHqoYWFfxzo
2026-08-18 14:44:03 +00:00

232 lines
8.2 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,
normalizeItemName,
} 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",
);
});
});
describe("normalizeItemName (dubblett-matchning vid omfoto)", () => {
it("slår ihop skiftläge, accenter och storlek", () => {
expect(normalizeItemName("Mjölk")).toBe(normalizeItemName("mjölk"));
expect(normalizeItemName("MJÖLK 1L")).toBe(normalizeItemName("Mjölk"));
expect(normalizeItemName("Vispgrädde 5 dl")).toBe(normalizeItemName("vispgrädde"));
});
it("håller isär olika fetthalt/varianter", () => {
expect(normalizeItemName("Mjölk 3%")).not.toBe(normalizeItemName("Mjölk 1,5%"));
});
});