Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
@@ -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",
);
});
});