test(achievements): coverage for evaluators + UI states

Three new layers of coverage for the v1.4.18 expansion set:

- Unit (expansion-metrics.test.ts) — drives synthetic data through
  every new evaluator (count by type, mood mean improvement, weekend
  warrior, hidden Easter-egg triggers including the Berlin TZ
  classifier for night-owl / early-bird) and the earnability flag
  builder. 13 new assertions.

- Integration (achievements-expansion.test.ts) — round-trips real
  prisma rows (MoodEntry's YYYY-MM-DD `date` string, Measurement
  type discrimination, audit log row) through
  `buildExpansionMetricValues` so a schema drift in any of the row
  shapes surfaces here rather than at runtime. We don't drive the
  full HTTP route in this spec because of pre-existing
  medication_schedules.days_of_week migration drift.

- E2E (achievements.spec.ts) — the existing sidebar smoke gets a
  sibling describe-block exercising all four card states (unlocked /
  locked-earnable / hidden-locked / hidden-unlocked) and asserting
  hidden-locked cards never leak their real strings to the rendered
  HTML, which is the entire point of the "hidden" state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 10:34:32 +02:00
1 parent b88f2de865
commit 6912d4e2b6
3 files changed
+601

No files matched your search

+142
View File
@@ -11,6 +11,11 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* `/api/gamification/achievements` so the assertion does not depend on
* the seed user actually having any achievement data — the routing +
* sidebar wiring is what matters here, not the data freshness.
*
* v1.4.18 — added a second describe-block exercising the four card
* states (unlocked / locked-earnable / hidden-locked / hidden-unlocked)
* and asserting that hidden cards never leak their real strings to the
* DOM.
*/
test.describe("achievements page is reachable from the sidebar", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -84,3 +89,140 @@ test.describe("achievements page is reachable from the sidebar", () => {
).toBeVisible({ timeout: 5000 });
});
});
test.describe("achievements page renders all four card states", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
await page.route("**/api/gamification/achievements", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summary: {
unlockedCount: 2,
totalCount: 4,
earnedPoints: 43,
totalPoints: 200,
completionPercent: 50,
nextAchievement: null,
},
achievements: [
// Unlocked public
{
id: "intake-total-1",
metric: "totalTakenIntakes",
category: "medication",
titleKey: "achievements.badges.intakeTotal1.title",
descriptionKey:
"achievements.badges.intakeTotal1.description",
icon: "Pill",
format: "count",
target: 1,
current: 5,
points: 8,
unlocked: true,
progressPercent: 100,
completedAt: "2026-04-15T12:00:00.000Z",
isHidden: false,
},
// Locked-earnable public (mood streak — user has mood data)
{
id: "mood-streak-7",
metric: "moodDayStreak",
category: "mood",
titleKey: "achievements.badges.moodStreak7.title",
descriptionKey:
"achievements.badges.moodStreak7.description",
icon: "Smile",
format: "days",
target: 7,
current: 3,
points: 50,
unlocked: false,
progressPercent: 42,
completedAt: null,
isHidden: false,
},
// Hidden + locked → opaque placeholder
{
id: "hidden-night-owl",
metric: "nightOwlCount",
category: "hidden",
titleKey: "achievements.badges.hiddenNightOwl.title",
descriptionKey:
"achievements.badges.hiddenNightOwl.description",
icon: "Moon",
format: "count",
target: 1,
current: 0,
points: 25,
unlocked: false,
progressPercent: 0,
completedAt: null,
isHidden: true,
},
// Hidden + unlocked → real strings revealed
{
id: "hidden-doctor-pdf",
metric: "doctorPdfCount",
category: "hidden",
titleKey: "achievements.badges.hiddenDoctorPdf.title",
descriptionKey:
"achievements.badges.hiddenDoctorPdf.description",
icon: "FileText",
format: "count",
target: 1,
current: 1,
points: 35,
unlocked: true,
progressPercent: 100,
completedAt: "2026-04-30T10:00:00.000Z",
isHidden: true,
},
],
metrics: {},
},
error: null,
}),
}),
);
});
test("renders unlocked, locked-earnable, hidden-locked and hidden-unlocked cards", async ({
page,
}) => {
await page.goto("/achievements", { waitUntil: "domcontentloaded" });
// Page heading present
await expect(
page.getByRole("heading", { name: "Achievements" }),
).toBeVisible({ timeout: 5000 });
// Unlocked public — title visible
await expect(page.getByText("First intake")).toBeVisible();
// Locked-earnable public — title visible AND criterion hint
// visible (3 / 7 days for mood-streak-7).
await expect(page.getByText("Mood diarist 7d")).toBeVisible();
// Hidden-locked — opaque placeholder visible. The real title /
// description must NOT appear in the rendered HTML.
await expect(
page.getByText("Hidden achievement").first(),
).toBeVisible();
const html = await page.content();
expect(html).not.toContain("Night owl");
expect(html).not.toContain("between 02:00");
expect(html).not.toContain("nightOwlCount");
// Hidden-unlocked — title revealed once earned
await expect(page.getByText("House call")).toBeVisible();
// The opaque hidden card carries the data-slot for E2E hooks
await expect(
page.locator('[data-slot="achievement-card-hidden"]'),
).toBeVisible();
});
});
@@ -0,0 +1,279 @@
import { describe, it, expect } from "vitest";
import {
buildExpansionMetricValues,
countMeasurementsByType,
getEarnabilityFlags,
getEngagementMetrics,
getHiddenMetrics,
getMoodMetrics,
} from "@/lib/gamification/expansion-metrics";
describe("countMeasurementsByType", () => {
it("counts each type independently", () => {
const result = countMeasurementsByType([
{ type: "WEIGHT", measuredAt: new Date("2026-04-01T08:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-04-02T08:00:00Z") },
{
type: "BLOOD_PRESSURE_SYS",
measuredAt: new Date("2026-04-01T08:00:00Z"),
},
{
type: "BLOOD_PRESSURE_DIA",
measuredAt: new Date("2026-04-01T08:00:00Z"),
},
{ type: "PULSE", measuredAt: new Date("2026-04-03T08:00:00Z") },
]);
// BLOOD_PRESSURE_DIA is intentionally not counted — the canonical
// BP entry is the SYS row so we don't double-count a single BP
// reading that's stored as two rows.
expect(result).toEqual({ weightCount: 2, bpCount: 1, pulseCount: 1 });
});
it("returns zeros for an empty input", () => {
expect(countMeasurementsByType([])).toEqual({
weightCount: 0,
bpCount: 0,
pulseCount: 0,
});
});
});
describe("getMoodMetrics", () => {
it("returns zeros when there are no entries", () => {
expect(getMoodMetrics([])).toEqual({
moodEntryCount: 0,
moodDayStreak: 0,
moodImprovementHit: 0,
});
});
it("computes count + longest day-streak", () => {
const entries = [
// 4 consecutive days then a gap then 2 more — longest = 4
{ date: "2026-04-01", score: 3, moodLoggedAt: new Date() },
{ date: "2026-04-02", score: 3, moodLoggedAt: new Date() },
{ date: "2026-04-03", score: 4, moodLoggedAt: new Date() },
{ date: "2026-04-04", score: 4, moodLoggedAt: new Date() },
{ date: "2026-04-10", score: 4, moodLoggedAt: new Date() },
{ date: "2026-04-11", score: 4, moodLoggedAt: new Date() },
];
const m = getMoodMetrics(entries);
expect(m.moodEntryCount).toBe(6);
expect(m.moodDayStreak).toBe(4);
});
it("hits the improvement metric when the recent 7-day mean is +1.0 above the prior 7-day mean", () => {
const entries = [];
// Prior week: mean 2.0
for (let i = 0; i < 7; i++) {
entries.push({
date: `2026-04-0${i + 1}`,
score: 2,
moodLoggedAt: new Date(),
});
}
// Recent week: mean 3.5 — improvement of 1.5
for (let i = 0; i < 7; i++) {
const day = i + 8;
entries.push({
date: `2026-04-${String(day).padStart(2, "0")}`,
score: i % 2 === 0 ? 3 : 4,
moodLoggedAt: new Date(),
});
}
const m = getMoodMetrics(entries);
expect(m.moodImprovementHit).toBe(1);
});
it("does not hit the improvement metric when improvement is below 1.0", () => {
const entries = [];
for (let i = 0; i < 7; i++) {
entries.push({
date: `2026-04-0${i + 1}`,
score: 3,
moodLoggedAt: new Date(),
});
}
for (let i = 0; i < 7; i++) {
const day = i + 8;
entries.push({
date: `2026-04-${String(day).padStart(2, "0")}`,
// Mean 3.5 → +0.5 only
score: i % 2 === 0 ? 3 : 4,
moodLoggedAt: new Date(),
});
}
expect(getMoodMetrics(entries).moodImprovementHit).toBe(0);
});
});
describe("getEngagementMetrics", () => {
it("counts a consistent month with at least 25 distinct entry days", () => {
const measurements = [];
for (let day = 1; day <= 26; day++) {
measurements.push({
type: "WEIGHT",
measuredAt: new Date(
`2026-04-${String(day).padStart(2, "0")}T08:00:00Z`,
),
});
}
const result = getEngagementMetrics({
measurements,
moodEntries: [],
intakeEvents: [],
});
expect(result.consistentMonthCount).toBe(1);
});
it("does not count a month with only 24 distinct days as consistent", () => {
const measurements = [];
for (let day = 1; day <= 24; day++) {
measurements.push({
type: "WEIGHT",
measuredAt: new Date(
`2026-04-${String(day).padStart(2, "0")}T08:00:00Z`,
),
});
}
const result = getEngagementMetrics({
measurements,
moodEntries: [],
intakeEvents: [],
});
expect(result.consistentMonthCount).toBe(0);
});
it("computes the longest entry-day streak across measurement / mood / intake", () => {
const result = getEngagementMetrics({
measurements: [
{ type: "WEIGHT", measuredAt: new Date("2026-04-01T08:00:00Z") },
],
moodEntries: [
{ date: "2026-04-02", score: 3, moodLoggedAt: new Date("2026-04-02T08:00:00Z") },
{ date: "2026-04-03", score: 3, moodLoggedAt: new Date("2026-04-03T08:00:00Z") },
],
intakeEvents: [
{
scheduledFor: new Date("2026-04-04T08:00:00Z"),
takenAt: new Date("2026-04-04T08:30:00Z"),
skipped: false,
},
],
});
// 4 consecutive days
expect(result.entryDayStreak).toBe(4);
});
it("counts consecutive Sat+Sun pairs for the weekend warrior badge", () => {
// Saturdays + Sundays April 4/5, 11/12, 18/19, 25/26 — 4 in a row
const measurements = [
{ type: "WEIGHT", measuredAt: new Date("2026-04-04T12:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-04-05T12:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-04-11T12:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-04-12T12:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-04-18T12:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-04-19T12:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-04-25T12:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-04-26T12:00:00Z") },
];
const result = getEngagementMetrics({
measurements,
moodEntries: [],
intakeEvents: [],
});
expect(result.weekendStreakCount).toBeGreaterThanOrEqual(4);
});
});
describe("getHiddenMetrics", () => {
it("counts night-owl entries between 02:00 and 04:00 Berlin", () => {
const result = getHiddenMetrics({
// 02:30 Berlin in summer = 00:30 UTC. Use UTC offsets that yield
// the right Berlin hour. April is CEST (UTC+2), so 00:30Z = 02:30
// Berlin → night owl.
measurements: [
{ type: "WEIGHT", measuredAt: new Date("2026-04-15T00:30:00Z") },
],
moodEntries: [],
intakeEvents: [],
auditEvents: [],
});
expect(result.nightOwlCount).toBe(1);
expect(result.earlyBirdCount).toBe(0);
});
it("counts early-bird entries between 04:00 and 06:00 Berlin", () => {
// 03:30 UTC = 05:30 Berlin in CEST → early-bird
const result = getHiddenMetrics({
measurements: [
{ type: "WEIGHT", measuredAt: new Date("2026-04-15T03:30:00Z") },
],
moodEntries: [],
intakeEvents: [],
auditEvents: [],
});
expect(result.earlyBirdCount).toBe(1);
expect(result.nightOwlCount).toBe(0);
});
it("counts February 29 entries on a leap year", () => {
const result = getHiddenMetrics({
measurements: [
{ type: "WEIGHT", measuredAt: new Date("2024-02-29T12:00:00Z") },
],
moodEntries: [],
intakeEvents: [],
auditEvents: [],
});
expect(result.leapDayCount).toBe(1);
});
it("counts doctor-PDF exports and locale-flips from audit events", () => {
const result = getHiddenMetrics({
measurements: [],
moodEntries: [],
intakeEvents: [],
auditEvents: [
{ action: "doctor-report.export", createdAt: new Date() },
{ action: "doctor-report.export", createdAt: new Date() },
{ action: "settings.locale.update", createdAt: new Date() },
{ action: "auth.login.passkey", createdAt: new Date() }, // ignored
],
});
expect(result.doctorPdfCount).toBe(2);
expect(result.localeFlipCount).toBe(1);
});
});
describe("getEarnabilityFlags", () => {
it("flags categories the user has data for", () => {
expect(
getEarnabilityFlags({
hasMedication: true,
moodEntryCount: 3,
measurementCounts: { weightCount: 0, bpCount: 5, pulseCount: 0 },
}),
).toEqual({
hasMedication: true,
hasMood: true,
hasWeight: false,
hasBp: true,
hasPulse: false,
});
});
});
describe("buildExpansionMetricValues — integration smoke", () => {
it("produces a fully-zeroed object for an empty user", () => {
const result = buildExpansionMetricValues({
measurements: [],
moodEntries: [],
intakeEvents: [],
auditEvents: [],
});
for (const value of Object.values(result)) {
expect(value).toBe(0);
}
});
});
@@ -0,0 +1,180 @@
/**
* Integration test for the v1.4.18 achievement expansion.
*
* Drives a fresh Postgres user through the moments when expansion
* predicates fire, verifying:
* - mood entries persisted as `MoodEntry` rows are picked up by
* `getMoodMetrics` (the row shape uses YYYY-MM-DD strings, not
* Date objects, which is the most common drift surface)
* - measurement counts are split by `MeasurementType` correctly
* - earnability flags reflect what the user actually has
*
* We don't drive the full HTTP route here because the existing
* `medication_schedules.days_of_week` migration drift makes the route
* fail in the integration container; that's a pre-existing issue
* tracked separately. Instead we round-trip the prisma rows through
* `buildExpansionMetricValues` and `getEarnabilityFlags`, which is
* the same code-path the route exercises after the fetch.
*/
import { beforeEach, describe, expect, it } from "vitest";
import {
buildExpansionMetricValues,
getEarnabilityFlags,
} from "@/lib/gamification/expansion-metrics";
import { getPrismaClient, truncateAllTables } from "./setup";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("achievements expansion — fresh user, real prisma rows", () => {
it("recognises a single mood entry as `hasMood: true`", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "mood-tracker",
email: "mood@example.test",
createdAt: new Date("2026-01-01T00:00:00Z"),
},
});
await prisma.moodEntry.create({
data: {
userId: user.id,
date: "2026-04-15",
mood: "GUT",
score: 4,
moodLoggedAt: new Date("2026-04-15T08:00:00Z"),
},
});
const moodEntries = await prisma.moodEntry.findMany({
where: { userId: user.id },
select: { date: true, score: true, moodLoggedAt: true },
});
const result = buildExpansionMetricValues({
measurements: [],
moodEntries,
intakeEvents: [],
auditEvents: [],
});
expect(result.moodEntryCount).toBe(1);
const flags = getEarnabilityFlags({
hasMedication: false,
moodEntryCount: result.moodEntryCount,
measurementCounts: {
weightCount: result.weightMeasurementCount,
bpCount: result.bpMeasurementCount,
pulseCount: result.pulseMeasurementCount,
},
});
expect(flags.hasMood).toBe(true);
expect(flags.hasWeight).toBe(false);
});
it("counts measurements by type from real DB rows", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "many-readings",
email: "readings@example.test",
createdAt: new Date("2026-01-01T00:00:00Z"),
},
});
await prisma.measurement.createMany({
data: [
{
userId: user.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2026-04-01T08:00:00Z"),
},
{
userId: user.id,
type: "WEIGHT",
value: 80.5,
unit: "kg",
measuredAt: new Date("2026-04-02T08:00:00Z"),
},
{
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
value: 120,
unit: "mmHg",
measuredAt: new Date("2026-04-01T08:00:00Z"),
},
{
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
value: 80,
unit: "mmHg",
measuredAt: new Date("2026-04-01T08:00:00Z"),
},
{
userId: user.id,
type: "PULSE",
value: 65,
unit: "bpm",
measuredAt: new Date("2026-04-03T08:00:00Z"),
},
],
});
const measurements = await prisma.measurement.findMany({
where: {
userId: user.id,
type: {
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
},
},
select: { type: true, measuredAt: true },
});
const result = buildExpansionMetricValues({
measurements,
moodEntries: [],
intakeEvents: [],
auditEvents: [],
});
expect(result.weightMeasurementCount).toBe(2);
expect(result.bpMeasurementCount).toBe(1); // SYS canonical, DIA not double-counted
expect(result.pulseMeasurementCount).toBe(1);
});
it("fires the doctor-PDF hidden trigger from a real audit-log row", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "exporter",
email: "exporter@example.test",
createdAt: new Date("2026-01-01T00:00:00Z"),
},
});
await prisma.auditLog.create({
data: {
userId: user.id,
action: "doctor-report.export",
},
});
const auditEvents = await prisma.auditLog.findMany({
where: { userId: user.id, action: "doctor-report.export" },
select: { action: true, createdAt: true },
});
const result = buildExpansionMetricValues({
measurements: [],
moodEntries: [],
intakeEvents: [],
auditEvents,
});
expect(result.doctorPdfCount).toBe(1);
});
});