mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment lines in 1,308 files lose the work-item identifier at their head. Release versions and issue references stay; nothing outside comment text changes, which the script checks per file against a comment-free printout. healthlog/no-planning-tags is enabled at error level with this commit, so the tree it lands on is clean.
229 lines
7.9 KiB
TypeScript
229 lines
7.9 KiB
TypeScript
import { expect, test } from "./setup/test";
|
||
|
||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||
|
||
/**
|
||
* v1.4.15 — /achievements navigation smoke.
|
||
*
|
||
* The page itself has unit-test coverage for the locked-vs-unlocked
|
||
* card render; this spec answers the higher-level "can the user reach
|
||
* the page from the global nav?" question. We mock
|
||
* `/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 });
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.route("**/api/gamification/achievements", (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
data: {
|
||
summary: {
|
||
unlockedCount: 0,
|
||
totalCount: 38,
|
||
earnedPoints: 0,
|
||
totalPoints: 9999,
|
||
completionPercent: 0,
|
||
nextAchievement: null,
|
||
},
|
||
achievements: [
|
||
{
|
||
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: 0,
|
||
points: 8,
|
||
unlocked: false,
|
||
progressPercent: 0,
|
||
completedAt: null,
|
||
},
|
||
],
|
||
metrics: {},
|
||
},
|
||
error: null,
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
test("desktop sidebar exposes the achievements link", async ({
|
||
page,
|
||
viewport,
|
||
}) => {
|
||
// The sidebar nav is hidden on small viewports (`md:flex`). The
|
||
// chromium-mobile profile (Pixel 5, 393 px wide) cannot satisfy
|
||
// this contract by design — skip there so the spec only runs in
|
||
// the chromium-desktop project (1280×720). Without this guard
|
||
// every push fails 1 / 4 specs purely because the same suite is
|
||
// sharded across both projects.
|
||
test.skip(
|
||
(viewport?.width ?? 0) < 768,
|
||
"sidebar is hidden on mobile by design (md:flex breakpoint)",
|
||
);
|
||
|
||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||
// Match by aria-label so we don't accidentally pick up a tooltip.
|
||
const sidebar = page.locator('aside[aria-label="Sidebar"]');
|
||
await expect(sidebar).toBeVisible();
|
||
await sidebar.getByRole("link", { name: "Achievements" }).click();
|
||
await expect(page).toHaveURL(/\/achievements\b/);
|
||
// Page title from the loaded route. The unit test guards the
|
||
// grouped + locked-card render shape; here we only assert the route
|
||
// mounted and rendered the heading.
|
||
await expect(
|
||
page.getByRole("heading", { name: "Achievements" }),
|
||
).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("main").getByRole("heading", {
|
||
name: "Achievements",
|
||
level: 1,
|
||
exact: true,
|
||
}),
|
||
).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();
|
||
});
|
||
});
|