cb493010d0
- Append-only undo via correction transactions tied to cookingSessionId. - POST /v1/cooking-sessions/:id/undo with 24h UTC window (409 + i18n after expiry). - Complete undo scope: meals removed, meal boxes discarded, recipe_cooks deleted, cookCount decremented; activation milestones left intact. - Deterministic assumption-profile rollback via lastSessionAnswers.sessionId; profile deleted when observationCount reaches 0. - New status 'undone' as TEXT validated against COOKING_SESSION_STATUSES. - Analytics event cooking_session_undone + domain events MEAL_BOX_DISCARDED, MEAL_REMOVED, COOKING_SESSION_UNDONE. - Partial consumption uses actualPortionsEaten/plannedPortions factor. - i18n coverage for cooked.undoWindowExpired across all 12 locales + server. - Invariant tests: computeBalance(transactions) === item.quantity after complete, undo, and undo + new complete; transaction count increases on undo. - Migration 0014_expand_event_types.sql adds new enum values. - Updated FAS3-COOKING-SESSIONS-AUDIT.md with chosen semantics.
168 lines
6.0 KiB
TypeScript
168 lines
6.0 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);
|
|
});
|
|
});
|