From d3577d25579c8f688d69acec564835f509458a2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 02:48:53 +0200 Subject: [PATCH] test(mood): cover the level-A blocks at the composition that ships The staleness test rebuilds the block shape from the pure calculator, so it proves the calculator and the freshness rule agree and nothing else: hardcoding the freshness flag in the feature builder, deleting the notice that goes with a stale value, or dropping the whole block left it green. This calls extractFeatures against real rows instead. Both failures were watched. With the flag hardcoded true the unit test still reported four passing while this one named the six-day-old stress; with the notice removed it named the missing instruction. --- .../mood-dimension-features.test.ts | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/integration/mood-dimension-features.test.ts diff --git a/tests/integration/mood-dimension-features.test.ts b/tests/integration/mood-dimension-features.test.ts new file mode 100644 index 000000000..cf32d4a1d --- /dev/null +++ b/tests/integration/mood-dimension-features.test.ts @@ -0,0 +1,176 @@ +/** + * v1.37 — the level-A blocks the assistant actually receives. + * + * This calls `extractFeatures`, the shipped composition, against real rows in + * real Postgres. The unit test beside it (`mood-staleness.test.ts`) rebuilds + * the block shape from the pure calculator, which proves the calculator and + * the freshness rule agree — but it never imports `features.ts`, so on its own + * it would stay green if the builder hardcoded `current: true`, dropped + * `staleNotice`, or stopped attaching `dimensions` at all. Both ends and the + * pipe between them: this is the pipe. + * + * The fixture is the one the phase exists for. A person taps a face every + * morning and opens the sliders once a week, so pleasantness is from today and + * stress is six days old. One of those may back a present-tense sentence and + * the other may not. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { extractFeatures } from "@/lib/insights/features"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-mood-dimension-features"; +const TZ = "Europe/Berlin"; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The reader's own local today, resolved the way the builder resolves it. */ +function todayKey(): string { + return userDayKey(new Date(), TZ); +} + +async function seedUser() { + await getPrismaClient().user.create({ + data: { + id: USER, + username: "mood-dimension-features", + email: "mood-dimension-features@example.test", + timezone: TZ, + }, + }); +} + +/** + * One mood entry `daysAgo` days back, carrying whichever level-A values are + * given. Written through Prisma with every column stated, so the row looks + * exactly like one a writer would produce. + */ +async function seedEntry( + daysAgo: number, + values: { + mood: string; + score: number; + a1?: number | null; + a2?: number | null; + }, +) { + const loggedAt = new Date(Date.now() - daysAgo * DAY_MS); + await getPrismaClient().moodEntry.create({ + data: { + userId: USER, + date: shiftDateKey(todayKey(), -daysAgo), + tz: TZ, + mood: values.mood, + score: values.score, + moodA1: values.a1 ?? null, + stressA2: values.a2 ?? null, + source: "MANUAL", + moodLoggedAt: loggedAt, + }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("level-A blocks reach the feature set with their own ages", () => { + it("marks today's pleasantness current and a six-day-old stress stale", async () => { + await seedUser(); + // Six days ago: a full entry, sliders and all. + await seedEntry(6, { mood: "SCHLECHT", score: 2, a1: 3, a2: 9 }); + // Since then, face-only check-ins. Pleasantness keeps moving; stress does + // not, because nobody opened the section again. + await seedEntry(2, { mood: "OKAY", score: 3, a1: 5 }); + await seedEntry(0, { mood: "GUT", score: 4, a1: 7 }); + + const features = await extractFeatures(USER, false); + + // The block exists at all — a dropped `dimensions` key fails right here. + expect(features.mood).toBeDefined(); + const dimensions = features.mood?.dimensions; + expect( + dimensions, + "features.mood carries no dimensions block; the assistant would see none of the five", + ).toBeDefined(); + + // Only the two that were answered. A block for a question nobody was + // asked invites a sentence about it. + expect(dimensions!.map((d) => d.key).sort()).toEqual(["a1", "a2"]); + + const a1 = dimensions!.find((d) => d.key === "a1")!; + expect(a1.latest).toBe(7); + expect(a1.newestDaysAgo).toBe(0); + expect(a1.current).toBe(true); + // Nothing to warn about while it is current. + expect(a1.staleNotice).toBeUndefined(); + expect(a1.count).toBe(3); + expect(a1.inverse).toBe(false); + + const a2 = dimensions!.find((d) => d.key === "a2")!; + expect(a2.latest).toBe(9); + expect(a2.newestDaysAgo).toBe(6); + // The claim under test: nothing may say "your stress is high today". + expect(a2.current).toBe(false); + expect( + a2.staleNotice, + "a stale dimension ships without the instruction that makes it safe to read", + ).toBeTruthy(); + expect(a2.staleNotice).toContain(shiftDateKey(todayKey(), -6)); + expect(a2.staleNotice!.toLowerCase()).toContain("do not describe"); + // Stored literally: stress is inverse-oriented and says so rather than + // arriving pre-flipped. + expect(a2.inverse).toBe(true); + expect(a2.count).toBe(1); + }); + + it("never ships a latest without an age beside it, and names the scale", async () => { + await seedUser(); + await seedEntry(1, { mood: "GUT", score: 4, a1: 8, a2: 2 }); + + const features = await extractFeatures(USER, false); + const dimensions = features.mood?.dimensions ?? []; + expect(dimensions.length).toBeGreaterThan(0); + + for (const block of dimensions) { + expect(block.latest).not.toBeNull(); + // The structural half of the fix: a `latest` with no age is exactly + // what produced a reading from last week stated as this morning's. + expect(block.newestDaysAgo).not.toBeNull(); + expect(block.latestDate).not.toBeNull(); + expect(typeof block.current).toBe("boolean"); + // And the wording behind the numbers, so a 7 is not read as 7 out of 5. + expect(block.scale).toMatch(/0-10/); + expect(block.scale.length).toBeGreaterThan(20); + } + + // Yesterday still backs a present-tense claim. + expect(dimensions.every((d) => d.current)).toBe(true); + }); + + it("omits the block entirely for an account that has only ever tapped a face", async () => { + await seedUser(); + // Rows written before the columns existed look exactly like this. + await seedEntry(0, { mood: "GUT", score: 4 }); + + const features = await extractFeatures(USER, false); + // The mood aggregate is still there — the five-point axis is unaffected. + expect(features.mood).toBeDefined(); + expect(features.mood?.latest).toBe(4); + // But nothing claims a self-state that was never recorded. + expect(features.mood?.dimensions).toBeUndefined(); + }); + + it("leaves the legacy scale line untouched", async () => { + await seedUser(); + await seedEntry(0, { mood: "GUT", score: 4, a1: 7 }); + + const features = await extractFeatures(USER, false); + // Sent to the model verbatim and describing `score`, not the new axis. + // A phase that widened this string would have changed a sentence the + // model has been reading correctly for years. + expect(features.mood?.scale).toBe( + "1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT", + ); + }); +});