Files
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

192 lines
6.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
updateCookingAssumptionProfile,
rollbackCookingAssumptionProfile,
} from "../src/cooking-profiles.js";
describe("updateCookingAssumptionProfile", () => {
it("initializes profile from first observation", () => {
const result = updateCookingAssumptionProfile(
{
householdId: "h1",
canonicalIngredientId: "i1",
plannedPortions: 4,
actualPortionsEaten: 3,
leftoverEstimatePortions: 1,
sessionId: "s1",
date: "2026-08-07",
},
{ averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
);
expect(result.averageEatenPortions).toBe(3);
expect(result.averageLeftoverPortions).toBe(1);
expect(result.observationCount).toBe(1);
});
it("clamps eaten and leftovers to valid ranges", () => {
const result = updateCookingAssumptionProfile(
{
householdId: "h1",
canonicalIngredientId: "i1",
plannedPortions: 4,
actualPortionsEaten: -1,
leftoverEstimatePortions: 5,
sessionId: "s1",
date: "2026-08-07",
},
{ averageEatenPortions: 2, averageLeftoverPortions: 1, observationCount: 3 },
);
// Eaten clamps to 0, leftovers clamps to planned - eaten = 4.
// alpha = 1/4, prevEaten=2 => 2 + 0.25*(0-2) = 1.5
// prevLeftovers=1 => 1 + 0.25*(4-1) = 1.75
expect(result.averageEatenPortions).toBe(1.5);
expect(result.averageLeftoverPortions).toBe(1.75);
});
it("keeps a rolling history of last 10 sessions", () => {
type Profile = {
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
lastSessionAnswers?: Array<{
sessionId: string;
eaten: number;
leftovers: number;
date: string;
}> | null;
};
let existing: Profile = {
averageEatenPortions: null,
averageLeftoverPortions: null,
observationCount: 0,
};
for (let i = 0; i < 12; i++) {
existing = updateCookingAssumptionProfile(
{
householdId: "h1",
canonicalIngredientId: "i1",
plannedPortions: 4,
actualPortionsEaten: 2,
leftoverEstimatePortions: 1,
sessionId: `s${i}`,
date: "2026-08-07",
},
existing,
);
}
expect(existing.lastSessionAnswers).toHaveLength(10);
expect(existing.lastSessionAnswers![0]!.sessionId).toBe("s11");
});
});
describe("rollbackCookingAssumptionProfile", () => {
it("returns null averages and zero count when history becomes empty", () => {
const existing = updateCookingAssumptionProfile(
{
householdId: "h1",
canonicalIngredientId: "i1",
plannedPortions: 4,
actualPortionsEaten: 3,
leftoverEstimatePortions: 1,
sessionId: "s1",
date: "2026-08-07",
},
{ averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
);
const rolled = rollbackCookingAssumptionProfile("s1", existing);
expect(rolled.averageEatenPortions).toBeNull();
expect(rolled.averageLeftoverPortions).toBeNull();
expect(rolled.observationCount).toBe(0);
expect(rolled.lastSessionAnswers).toHaveLength(0);
});
it("recalculates averages deterministically from remaining history", () => {
let existing: {
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
lastSessionAnswers?: Array<{
sessionId: string;
eaten: number;
leftovers: number;
date: string;
}> | null;
} = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
// Three chronological observations.
const observations = [
{ sessionId: "s1", eaten: 4, leftovers: 0 },
{ sessionId: "s2", eaten: 2, leftovers: 1 },
{ sessionId: "s3", eaten: 3, leftovers: 1 },
];
for (const obs of observations) {
existing = updateCookingAssumptionProfile(
{
householdId: "h1",
canonicalIngredientId: "i1",
plannedPortions: 4,
actualPortionsEaten: obs.eaten,
leftoverEstimatePortions: obs.leftovers,
sessionId: obs.sessionId,
date: "2026-08-07",
},
existing,
);
}
// Roll back the middle observation and recompute from scratch.
const rolled = rollbackCookingAssumptionProfile("s2", existing);
expect(rolled.lastSessionAnswers).toHaveLength(2);
expect(rolled.lastSessionAnswers!.map((a) => a.sessionId)).toContain("s1");
expect(rolled.lastSessionAnswers!.map((a) => a.sessionId)).toContain("s3");
// Recompute EMA manually from s1 then s3.
// s1: avgEaten=4, avgLeftovers=0
// s3: alpha=1/2 => eaten=4+0.5*(3-4)=3.5, leftovers=0+0.5*(1-0)=0.5
expect(rolled.averageEatenPortions).toBe(3.5);
expect(rolled.averageLeftoverPortions).toBe(0.5);
expect(rolled.observationCount).toBe(2);
});
it("is idempotent: rolling back the same session twice leaves history unchanged", () => {
let existing: {
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
lastSessionAnswers?: Array<{
sessionId: string;
eaten: number;
leftovers: number;
date: string;
}> | null;
} = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
existing = updateCookingAssumptionProfile(
{
householdId: "h1",
canonicalIngredientId: "i1",
plannedPortions: 4,
actualPortionsEaten: 3,
leftoverEstimatePortions: 1,
sessionId: "s1",
date: "2026-08-07",
},
existing,
);
existing = updateCookingAssumptionProfile(
{
householdId: "h1",
canonicalIngredientId: "i1",
plannedPortions: 4,
actualPortionsEaten: 2,
leftoverEstimatePortions: 0,
sessionId: "s2",
date: "2026-08-07",
},
existing,
);
const first = rollbackCookingAssumptionProfile("s1", existing);
const second = rollbackCookingAssumptionProfile("s1", first);
expect(second).toEqual(first);
});
});