mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Data never depends on AI. The Insights data reads no longer ask an AI switch or the `insights` module (now only the AI analysis opt-out): ECG list, detail and upload, rhythm events, derived scores, rule cards, correlations and patterns, the baseline strip, health status, intraday pulse, and the Today digest with its dismiss. Breathing screening moves to the sleep module and lab changes to the labs module. Stored Coach facts, conversations, message ratings and the seen stamp stay readable and erasable while the Coach is unavailable. Cards now say `provider: "rules"`. Reads that mix data with model text answer 200, null the model text and publish an `ai` state from the capability resolver, without warming anything: comprehensive (`ai.briefing`, `ai.statusText`, `hasProvider` from the presence probe), the derived assessment override, the period narrative, nudge status, the seeded opener, about-me questions, the workout paragraph, the pregenerate warm (`queued: false`), the Today digest (lead, reaction line, check-in) and the dashboard briefing, which is now applied per read after the snapshot cache. Hiding the Coach no longer hides the briefing. `insights/generate` GET and POST ask the `briefing` capability and `insights/chat` POST the `coach` capability, with the typed refusal envelope; a missing provider keeps the chat's `coach.provider.none` frame and adds `reason`. The /coach page, the MCP ECG and intraday tools and `aiAvailable` on the provider route follow the same answer. An inventory of AI and data routes replaces the Coach gate inventory: every route that asks a capability names it, and no data route asks. Refs #975
164 lines
6.0 KiB
TypeScript
164 lines
6.0 KiB
TypeScript
import { mkdir } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import type { Page, TestInfo } from "@playwright/test";
|
|
import type { DailyDigest } from "@/lib/daily/digest";
|
|
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
|
|
|
import { expect, test } from "./setup/test";
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
import {
|
|
LONG_HEADLINE_BRIEFING,
|
|
mockDashboardSnapshot,
|
|
} from "./utils/mock-dashboard-snapshot";
|
|
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
|
|
import { settleBeforeMeasure, waitForFonts } from "./utils/settle";
|
|
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
|
|
|
|
const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341");
|
|
|
|
const NARRATIVE_DIGEST: DailyDigest = {
|
|
generatedAt: "2026-07-29T08:30:00.000Z",
|
|
ai: DIGEST_AI_AVAILABLE,
|
|
phase: "final",
|
|
sleepPending: false,
|
|
score: {
|
|
value: 84,
|
|
band: "green",
|
|
delta: -2,
|
|
deltaReason: null,
|
|
scoreVersion: SCORE_VERSION,
|
|
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
|
},
|
|
topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null,
|
|
briefingLead: LONG_HEADLINE_BRIEFING.paragraph,
|
|
line: LONG_HEADLINE_BRIEFING.paragraph,
|
|
worthALook: [],
|
|
justIn: null,
|
|
reactionLine: null,
|
|
};
|
|
|
|
async function settleAndCapture(
|
|
page: Page,
|
|
testInfo: TestInfo,
|
|
filename: string,
|
|
): Promise<void> {
|
|
await settleBeforeMeasure(page, page.locator("#main-content"));
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await page.addStyleTag({
|
|
content:
|
|
"*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}",
|
|
});
|
|
// The injected stylesheet can pull a font the page had not needed yet, so
|
|
// the wait repeats after it. Bounded, for the reason `waitForFonts` gives.
|
|
await waitForFonts(page);
|
|
const path = join(EVIDENCE_DIR, filename);
|
|
await page.screenshot({ path, animations: "disabled" });
|
|
await testInfo.attach(filename, { path, contentType: "image/png" });
|
|
}
|
|
|
|
test.describe("v1.34.1 deterministic UI evidence", () => {
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
test.beforeEach(({}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name !== "chromium-desktop",
|
|
"one canonical capture per named evidence file",
|
|
);
|
|
});
|
|
|
|
test("captures the five release-review surfaces", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await mkdir(EVIDENCE_DIR, { recursive: true });
|
|
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
let dailyDigest = NARRATIVE_DIGEST;
|
|
// TodayHero reads the unified digest endpoint, not the dashboard
|
|
// snapshot's legacy briefing field. Keep a mutable, deterministic reader
|
|
// so this evidence covers both the narrative composition captured below
|
|
// and the compact score-only fallback through the exact production seam.
|
|
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ data: dailyDigest, error: null }),
|
|
}),
|
|
);
|
|
await mockDashboardSnapshot(page, {
|
|
briefing: LONG_HEADLINE_BRIEFING,
|
|
});
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await expect(page.locator("#main-content")).toBeVisible();
|
|
const hero = page.locator('[data-slot="today-hero"]');
|
|
await expect(hero).toHaveAttribute("data-layout", "narrative");
|
|
await expect(hero.locator('[data-slot="today-hero-lead"]')).toContainText(
|
|
"Dein systolischer Blutdruck",
|
|
);
|
|
await expect(hero.locator('[data-slot="score-ring"]')).toHaveCSS(
|
|
"width",
|
|
"168px",
|
|
);
|
|
await settleAndCapture(page, testInfo, "dashboard-desktop.png");
|
|
|
|
dailyDigest = {
|
|
...NARRATIVE_DIGEST,
|
|
topSignal: null,
|
|
briefingLead: null,
|
|
line: "Dein Gesundheitsscore liegt heute bei 84.",
|
|
reactionLine: null,
|
|
};
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await expect(hero).toHaveAttribute("data-layout", "compact-all-clear");
|
|
await expect(hero.locator('[data-slot="today-hero-lead"]')).toHaveCount(0);
|
|
await expect(
|
|
hero.locator('[data-slot="today-hero-all-clear"]'),
|
|
).toBeVisible();
|
|
await expect(hero.locator('[data-slot="score-ring"]')).toHaveCSS(
|
|
"width",
|
|
"120px",
|
|
);
|
|
await expect(
|
|
hero.locator('[data-slot="score-ring"]').getByText("84", { exact: true }),
|
|
).toHaveCount(1);
|
|
await settleAndCapture(page, testInfo, "dashboard-score-only-desktop.png");
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
const compactHeroBox = await hero.boundingBox();
|
|
expect(compactHeroBox).not.toBeNull();
|
|
expect(
|
|
compactHeroBox?.width ?? Number.POSITIVE_INFINITY,
|
|
).toBeLessThanOrEqual(390);
|
|
expect(
|
|
await page.evaluate(
|
|
() =>
|
|
document.documentElement.scrollWidth <=
|
|
document.documentElement.clientWidth,
|
|
),
|
|
).toBe(true);
|
|
|
|
await mockPopulatedInsights(page);
|
|
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
|
await expect(
|
|
page.locator('[data-slot="insights-tab-strip"]'),
|
|
).toBeVisible();
|
|
await settleAndCapture(page, testInfo, "insights-mobile.png");
|
|
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.goto("/settings/anamnesis", { waitUntil: "domcontentloaded" });
|
|
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
|
await settleAndCapture(page, testInfo, "anamnesis-desktop.png");
|
|
|
|
await page.setViewportSize({ width: 1280, height: 600 });
|
|
await page.goto("/settings/account", { waitUntil: "domcontentloaded" });
|
|
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
|
await settleAndCapture(page, testInfo, "settings-short-desktop.png");
|
|
|
|
await page.setViewportSize({ width: 900, height: 800 });
|
|
await page.goto("/admin/system-status", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
|
await settleAndCapture(page, testInfo, "admin-tablet.png");
|
|
});
|
|
});
|