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", + ); + }); +});