mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
A table the answer referenced opens under the prose as its chart; the others sit under Data used in the evidence disclosure and open as tables. The plot is one image whose name points at the table view, two series carry legend buttons, and the chart loads through the shared chart runtime. A metric chart token for a metric a shown table already covers is dropped.
369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
import AxeBuilder from "@axe-core/playwright";
|
|
import type { Page, Route } from "@playwright/test";
|
|
|
|
import { expect, test } from "./setup/test";
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
|
|
|
/**
|
|
* The tables and charts under a Coach answer, on a persisted conversation.
|
|
*
|
|
* Contracts under test:
|
|
*
|
|
* 1. A table the answer referenced opens under the prose as its chart: a
|
|
* figure whose plot is one image named after the table, pointing at
|
|
* the table view for every value.
|
|
* 2. The chart/table toggle switches to the captioned table and back; the
|
|
* pressed segment follows.
|
|
* 3. Two series (blood pressure) carry legend buttons that hide and show
|
|
* a series; the last visible one stays.
|
|
* 4. A table the answer only used sits in the evidence disclosure under
|
|
* "Data used (1)", opening as a table, with its chart one tap away.
|
|
* 5. axe finds nothing on either view, in the light and the dark theme.
|
|
*
|
|
* The conversation, its stored tables and the Coach side surfaces are
|
|
* mocked; the charts are drawn by the real chart runtime.
|
|
*/
|
|
|
|
const CONVERSATION_ID = "coach-results-e2e";
|
|
const MESSAGE_ID = "coach-results-e2e-assistant";
|
|
|
|
const BP_VALUES: Array<[number, number] | null> = [
|
|
[128, 84],
|
|
[125, 82],
|
|
null,
|
|
[131, 85],
|
|
[122, 80],
|
|
[124, 81],
|
|
null,
|
|
[127, 83],
|
|
[121, 79],
|
|
[126, 82],
|
|
[123, 80],
|
|
[129, 84],
|
|
[120, 78],
|
|
[124, 81],
|
|
];
|
|
|
|
function dayKey(index: number): string {
|
|
return new Date(Date.UTC(2026, 8, 10 + index)).toISOString().slice(0, 10);
|
|
}
|
|
|
|
const BP_TABLE = {
|
|
ref: "r1",
|
|
source: {
|
|
tool: "get_metric_table",
|
|
domain: "bp",
|
|
window: "last30days",
|
|
period: "current",
|
|
granularity: "day",
|
|
},
|
|
shape: "timeSeries",
|
|
titleKey: "coach.result.title.byDay",
|
|
title: "Blood pressure by day",
|
|
rowCount: BP_VALUES.length,
|
|
chartKind: "line",
|
|
displayed: true,
|
|
columns: [
|
|
{
|
|
key: "day",
|
|
kind: "period",
|
|
labelKey: "coach.result.column.day",
|
|
label: "Day",
|
|
},
|
|
{
|
|
key: "systolic",
|
|
kind: "number",
|
|
labelKey: "coach.result.column.systolic",
|
|
label: "Systolic",
|
|
unit: "mmHg",
|
|
decimals: 0,
|
|
},
|
|
{
|
|
key: "diastolic",
|
|
kind: "number",
|
|
labelKey: "coach.result.column.diastolic",
|
|
label: "Diastolic",
|
|
unit: "mmHg",
|
|
decimals: 0,
|
|
},
|
|
{
|
|
key: "readings",
|
|
kind: "count",
|
|
labelKey: "coach.result.column.readings",
|
|
label: "Readings",
|
|
},
|
|
],
|
|
rows: BP_VALUES.map((pair, index) =>
|
|
pair
|
|
? [dayKey(index), pair[0], pair[1], 1]
|
|
: [dayKey(index), null, null, null],
|
|
),
|
|
truncated: false,
|
|
chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] },
|
|
};
|
|
|
|
const SPORTS: Array<[string, number]> = [
|
|
["Walking", 12],
|
|
["Cycling", 7],
|
|
["Running", 5],
|
|
["Swimming", 3],
|
|
["Yoga", 3],
|
|
["Hiking", 2],
|
|
["Rowing", 1],
|
|
];
|
|
|
|
const WORKOUT_TABLE = {
|
|
ref: "r2",
|
|
source: {
|
|
tool: "get_workouts",
|
|
domain: "workouts",
|
|
window: "last30days",
|
|
period: "current",
|
|
},
|
|
shape: "categoryCounts",
|
|
titleKey: "coach.result.title.workoutsBySport",
|
|
title: "Workouts by sport",
|
|
rowCount: SPORTS.length,
|
|
chartKind: "bar",
|
|
displayed: false,
|
|
columns: [
|
|
{
|
|
key: "sport",
|
|
kind: "category",
|
|
labelKey: "coach.result.column.sport",
|
|
label: "Sport",
|
|
},
|
|
{
|
|
key: "sessions",
|
|
kind: "count",
|
|
labelKey: "coach.result.column.sessions",
|
|
label: "Sessions",
|
|
},
|
|
{
|
|
key: "duration",
|
|
kind: "number",
|
|
labelKey: "coach.result.column.duration",
|
|
label: "Duration",
|
|
unit: "min",
|
|
decimals: 0,
|
|
},
|
|
],
|
|
rows: SPORTS.map(([sport, count]) => [sport, count, count * 35]),
|
|
truncated: false,
|
|
chart: {
|
|
kind: "bar",
|
|
x: "sport",
|
|
series: ["sessions"],
|
|
orientation: "horizontal",
|
|
},
|
|
};
|
|
|
|
function meta(table: typeof BP_TABLE | typeof WORKOUT_TABLE) {
|
|
return {
|
|
ref: table.ref,
|
|
source: table.source,
|
|
shape: table.shape,
|
|
titleKey: table.titleKey,
|
|
title: table.title,
|
|
rowCount: table.rowCount,
|
|
chartKind: table.chartKind,
|
|
displayed: table.displayed,
|
|
};
|
|
}
|
|
|
|
function fulfilJson(route: Route, data: unknown) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ data, error: null }),
|
|
});
|
|
}
|
|
|
|
async function mockConversation(page: Page) {
|
|
await page.route(
|
|
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
|
|
async (route) => {
|
|
const url = new URL(route.request().url());
|
|
if (
|
|
url.pathname ===
|
|
`/api/insights/chat/${CONVERSATION_ID}/messages/${MESSAGE_ID}/results`
|
|
) {
|
|
return fulfilJson(route, { results: [BP_TABLE, WORKOUT_TABLE] });
|
|
}
|
|
if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) {
|
|
return fulfilJson(route, {
|
|
id: CONVERSATION_ID,
|
|
title: "Blood pressure and workouts",
|
|
createdAt: "2026-09-24T09:00:00.000Z",
|
|
updatedAt: "2026-09-24T09:01:00.000Z",
|
|
messageCount: 2,
|
|
fenced: false,
|
|
attachments: [],
|
|
documentTitle: null,
|
|
attachmentCount: 0,
|
|
summary: null,
|
|
messages: [
|
|
{
|
|
id: "coach-results-e2e-user",
|
|
role: "user",
|
|
content: "How was my blood pressure this month?",
|
|
createdAt: "2026-09-24T09:00:00.000Z",
|
|
metricSource: null,
|
|
providerType: null,
|
|
promptVersion: null,
|
|
tokensUsed: null,
|
|
model: null,
|
|
},
|
|
{
|
|
id: MESSAGE_ID,
|
|
role: "assistant",
|
|
content:
|
|
"Your readings stayed in a narrow band this month; the table below lists each day.",
|
|
createdAt: "2026-09-24T09:01:00.000Z",
|
|
metricSource: {
|
|
windows: ["last30days"],
|
|
metrics: ["bp", "workouts"],
|
|
results: [meta(BP_TABLE), meta(WORKOUT_TABLE)],
|
|
},
|
|
providerType: "mock",
|
|
promptVersion: "e2e",
|
|
tokensUsed: 42,
|
|
model: "mock",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
return fulfilJson(route, {
|
|
conversations: [
|
|
{
|
|
id: CONVERSATION_ID,
|
|
title: "Blood pressure and workouts",
|
|
createdAt: "2026-09-24T09:00:00.000Z",
|
|
updatedAt: "2026-09-24T09:01:00.000Z",
|
|
messageCount: 2,
|
|
fenced: false,
|
|
attachments: [],
|
|
documentTitle: null,
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
},
|
|
);
|
|
await page.route("**/api/insights/coach/nudge-status*", (route) =>
|
|
fulfilJson(route, { nudgedAt: null, unread: false }),
|
|
);
|
|
await page.route("**/api/coach/about-me/questions*", (route) =>
|
|
fulfilJson(route, { questions: [] }),
|
|
);
|
|
}
|
|
|
|
async function useTheme(page: Page, theme: "light" | "dark") {
|
|
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
|
|
await page.addInitScript((value: string) => {
|
|
window.localStorage.setItem("healthlog-theme", value);
|
|
}, theme);
|
|
}
|
|
|
|
async function expectNoAxeViolations(page: Page, label: string) {
|
|
const result = await new AxeBuilder({ page })
|
|
.include('[data-slot="coach-bubble-assistant"]')
|
|
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
|
.analyze();
|
|
expect(
|
|
result.violations.map((violation) => ({
|
|
id: violation.id,
|
|
nodes: violation.nodes.map((node) => node.target.join(" ")),
|
|
})),
|
|
`${label} accessibility violations`,
|
|
).toEqual([]);
|
|
}
|
|
|
|
test.describe("Coach result tables and charts", () => {
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await serveAiBlock(page, aiBlockAvailable());
|
|
await mockConversation(page);
|
|
});
|
|
|
|
for (const theme of ["light", "dark"] as const) {
|
|
test(`theme=${theme}: chart first, table on toggle, data used in the evidence`, async ({
|
|
page,
|
|
}) => {
|
|
await useTheme(page, theme);
|
|
await page.goto(`/coach?c=${CONVERSATION_ID}`, {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await expect(page.locator("html")).toHaveClass(
|
|
new RegExp(`(^|\\s)${theme}(\\s|$)`),
|
|
);
|
|
|
|
const bubble = page.locator('[data-slot="coach-bubble-assistant"]');
|
|
const figure = bubble.locator(
|
|
'[data-slot="coach-result-chart"][data-ref="r1"]',
|
|
);
|
|
await expect(figure).toBeVisible({ timeout: 15_000 });
|
|
await expect(figure).toHaveAttribute("data-chart-kind", "line");
|
|
const plot = figure.getByRole("img", {
|
|
name: "Blood pressure by day, shown as a chart. The table view lists every value.",
|
|
});
|
|
await expect(plot).toBeVisible();
|
|
await expect(plot.locator(".recharts-line")).toHaveCount(2);
|
|
|
|
// Legend buttons hide one series; the last visible one stays.
|
|
const systolic = figure.getByRole("button", { name: "Systolic" });
|
|
const diastolic = figure.getByRole("button", { name: "Diastolic" });
|
|
await expect(systolic).toHaveAttribute("aria-pressed", "true");
|
|
await diastolic.click();
|
|
await expect(diastolic).toHaveAttribute("aria-pressed", "false");
|
|
await expect(plot.locator(".recharts-line")).toHaveCount(1);
|
|
await systolic.click();
|
|
await expect(systolic).toHaveAttribute("aria-pressed", "true");
|
|
await diastolic.click();
|
|
await expect(plot.locator(".recharts-line")).toHaveCount(2);
|
|
|
|
await expectNoAxeViolations(page, `${theme} chart view`);
|
|
|
|
// The toggle opens the captioned table and comes back.
|
|
await figure.locator('[data-slot="coach-result-view-table"]').click();
|
|
const table = bubble.locator(
|
|
'[data-slot="coach-result-table"][data-ref="r1"]',
|
|
);
|
|
await expect(table).toBeVisible();
|
|
await expect(table.locator("caption")).toContainText(
|
|
"Blood pressure by day",
|
|
);
|
|
await expect(
|
|
table.locator('[data-slot="coach-result-view-table"]'),
|
|
).toHaveAttribute("aria-pressed", "true");
|
|
await expectNoAxeViolations(page, `${theme} table view`);
|
|
await table.locator('[data-slot="coach-result-view-chart"]').click();
|
|
await expect(figure).toBeVisible();
|
|
|
|
// The table the answer only used waits in the evidence disclosure.
|
|
await expect(
|
|
bubble.locator('[data-slot="coach-result-table"][data-ref="r2"]'),
|
|
).toBeHidden();
|
|
await bubble.locator('[data-slot="coach-evidence-summary"]').click();
|
|
const dataUsed = bubble.locator('[data-slot="coach-data-used"]');
|
|
await expect(dataUsed).toContainText("Data used (1)");
|
|
const workouts = dataUsed.locator(
|
|
'[data-slot="coach-result-table"][data-ref="r2"]',
|
|
);
|
|
await expect(workouts).toBeVisible();
|
|
await workouts.locator('[data-slot="coach-result-view-chart"]').click();
|
|
const workoutChart = dataUsed.locator(
|
|
'[data-slot="coach-result-chart"][data-ref="r2"]',
|
|
);
|
|
await expect(workoutChart).toHaveAttribute("data-chart-kind", "bar");
|
|
await expect(workoutChart.locator(".recharts-bar-rectangle")).toHaveCount(
|
|
SPORTS.length,
|
|
);
|
|
|
|
await expectNoAxeViolations(page, `${theme} data used`);
|
|
});
|
|
}
|
|
});
|