mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(coach): cover the dialog in the browser
Three streamed turns against a stubbed chat stream: steps under the answer, a result table with its chart/table toggle and copy menu, a follow-up chip and a clarification choice that each send which one was tapped, and axe over the answer, chips and card in both themes.
This commit is contained in:
1 parent
56e51ebed2
commit
40ea0d8ed3
1 file changed
+630
@@ -0,0 +1,630 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page, Request, 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 interactive Coach dialog, over three streamed turns.
|
||||
*
|
||||
* Contracts under test:
|
||||
*
|
||||
* 1. A sent message streams `step` frames; the steps list under the
|
||||
* answer names each step, settled to `done` by id.
|
||||
* 2. A `result` frame's table opens as its chart, the chart/table toggle
|
||||
* switches to the captioned table and back, and the table's copy menu
|
||||
* puts the table on the clipboard.
|
||||
* 3. A follow-up chip under the latest reply sends its label with
|
||||
* `followUp: { messageId, id }`.
|
||||
* 4. A reply that ends in a clarifying question shows the choices card;
|
||||
* a choice sends its label with `clarification: { messageId, choiceId }`
|
||||
* and the card and chips leave once a plain answer is the latest reply.
|
||||
* 5. axe finds nothing on the answer, the chips and the clarification
|
||||
* card, in the light and the dark theme.
|
||||
*
|
||||
* The chat POST is a stubbed event stream in the documented frame order,
|
||||
* and the conversation detail it refetches after `done` returns the same
|
||||
* turns as persisted messages. The charts are drawn by the real runtime.
|
||||
*/
|
||||
|
||||
const CONVERSATION_ID = "coach-dialog-e2e";
|
||||
const TITLE = "Blood pressure this month";
|
||||
|
||||
const FIRST_QUESTION = "How was my blood pressure this month?";
|
||||
const FIRST_ANSWER =
|
||||
"Your blood pressure stayed in a steady band this month, with most days close to the same values.";
|
||||
const SECOND_ANSWER =
|
||||
"The period before looked much the same. Which heart rate should I set beside it?";
|
||||
const THIRD_ANSWER =
|
||||
"Your resting heart rate held steady over the same weeks, so nothing stands out beside your blood pressure.";
|
||||
|
||||
const BP_VALUES: Array<[number, number] | null> = [
|
||||
[128, 84],
|
||||
[125, 82],
|
||||
null,
|
||||
[131, 85],
|
||||
[122, 80],
|
||||
[124, 81],
|
||||
[127, 83],
|
||||
[121, 79],
|
||||
[126, 82],
|
||||
[123, 80],
|
||||
];
|
||||
|
||||
function dayKey(index: number): string {
|
||||
return new Date(Date.UTC(2026, 8, 14 + 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 BP_META = {
|
||||
ref: BP_TABLE.ref,
|
||||
source: BP_TABLE.source,
|
||||
shape: BP_TABLE.shape,
|
||||
titleKey: BP_TABLE.titleKey,
|
||||
title: BP_TABLE.title,
|
||||
rowCount: BP_TABLE.rowCount,
|
||||
chartKind: BP_TABLE.chartKind,
|
||||
displayed: BP_TABLE.displayed,
|
||||
};
|
||||
|
||||
function step(
|
||||
id: string,
|
||||
status: "running" | "done",
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
tool: "get_metric_table",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Blood pressure, last 30 days",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
status,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
interface Turn {
|
||||
userId: string;
|
||||
userText: string;
|
||||
assistantId: string;
|
||||
answer: string;
|
||||
metricSource: Record<string, unknown>;
|
||||
frames: unknown[];
|
||||
}
|
||||
|
||||
const TURN_ONE_STEPS = [
|
||||
step("s1", "done", { granularity: "day", count: 9, resultRef: "r1" }),
|
||||
step("s2", "done", { period: "previous", count: 11 }),
|
||||
];
|
||||
const TURN_ONE_FOLLOW_UPS = [
|
||||
{
|
||||
id: "f1",
|
||||
kind: "as_chart",
|
||||
labelKey: "coach.followUp.asChart",
|
||||
label: "Show as a chart",
|
||||
anchor: { ref: "r1", domain: "bp", window: "last30days" },
|
||||
reuse: true,
|
||||
origin: "server",
|
||||
},
|
||||
{
|
||||
id: "f2",
|
||||
kind: "previous_period",
|
||||
labelKey: "coach.followUp.previousPeriod",
|
||||
label: "Compare with the period before",
|
||||
anchor: {
|
||||
ref: "r1",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
period: "previous",
|
||||
},
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
},
|
||||
];
|
||||
const TURN_ONE_METHOD = {
|
||||
entries: [
|
||||
{
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
granularity: "day",
|
||||
count: 9,
|
||||
aggregation: "mean",
|
||||
},
|
||||
],
|
||||
text: "Blood pressure, last 30 days, 9 readings, daily averages.",
|
||||
};
|
||||
const TURN_ONE_SOURCE = {
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp"],
|
||||
counts: { bp: 9 },
|
||||
steps: TURN_ONE_STEPS,
|
||||
method: TURN_ONE_METHOD,
|
||||
results: [BP_META],
|
||||
followUps: TURN_ONE_FOLLOW_UPS,
|
||||
};
|
||||
|
||||
const TURN_TWO_STEPS = [
|
||||
step("s1", "done", {
|
||||
label: "Checking: Blood pressure, last 30 days",
|
||||
period: "previous",
|
||||
count: 11,
|
||||
}),
|
||||
];
|
||||
const TURN_TWO_CLARIFICATION = {
|
||||
kind: "metric",
|
||||
choices: [
|
||||
{
|
||||
id: "c1",
|
||||
labelKey: "insights.coach.metric.pulse",
|
||||
label: "Pulse",
|
||||
value: { metric: "pulse" },
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
labelKey: "insights.coach.metric.resting_hr",
|
||||
label: "Resting HR",
|
||||
value: { metric: "resting_hr" },
|
||||
},
|
||||
],
|
||||
freeText: true,
|
||||
};
|
||||
const TURN_TWO_FOLLOW_UPS = [
|
||||
{
|
||||
id: "f1",
|
||||
kind: "year_ago",
|
||||
labelKey: "coach.followUp.yearAgo",
|
||||
label: "Compare with a year ago",
|
||||
anchor: { ref: "r1", domain: "bp", window: "last30days" },
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
},
|
||||
];
|
||||
const TURN_TWO_SOURCE = {
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp"],
|
||||
counts: { bp: 11 },
|
||||
steps: TURN_TWO_STEPS,
|
||||
clarification: TURN_TWO_CLARIFICATION,
|
||||
followUps: TURN_TWO_FOLLOW_UPS,
|
||||
};
|
||||
|
||||
const TURN_THREE_STEPS = [
|
||||
{
|
||||
id: "s1",
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Resting HR, last 30 days",
|
||||
domain: "resting_hr",
|
||||
window: "last30days",
|
||||
status: "done",
|
||||
count: 28,
|
||||
},
|
||||
];
|
||||
const TURN_THREE_SOURCE = {
|
||||
windows: ["last30days"],
|
||||
metrics: ["resting_hr"],
|
||||
counts: { resting_hr: 28 },
|
||||
steps: TURN_THREE_STEPS,
|
||||
};
|
||||
|
||||
function tokens(text: string) {
|
||||
return text.split(/(?<= )/).map((token) => ({ type: "token", token }));
|
||||
}
|
||||
|
||||
const TURNS: Turn[] = [
|
||||
{
|
||||
userId: "coach-dialog-e2e-u1",
|
||||
userText: FIRST_QUESTION,
|
||||
assistantId: "coach-dialog-e2e-a1",
|
||||
answer: FIRST_ANSWER,
|
||||
metricSource: TURN_ONE_SOURCE,
|
||||
frames: [
|
||||
{ type: "step", step: step("s1", "running") },
|
||||
{ type: "step", step: TURN_ONE_STEPS[0] },
|
||||
{ type: "step", step: step("s2", "running", { period: "previous" }) },
|
||||
{ type: "step", step: TURN_ONE_STEPS[1] },
|
||||
...tokens(FIRST_ANSWER),
|
||||
{ type: "provenance", metricSource: TURN_ONE_SOURCE },
|
||||
{ type: "result", result: BP_TABLE },
|
||||
{ type: "followUps", followUps: TURN_ONE_FOLLOW_UPS },
|
||||
],
|
||||
},
|
||||
{
|
||||
userId: "coach-dialog-e2e-u2",
|
||||
userText: "Compare with the period before",
|
||||
assistantId: "coach-dialog-e2e-a2",
|
||||
answer: SECOND_ANSWER,
|
||||
metricSource: TURN_TWO_SOURCE,
|
||||
frames: [
|
||||
{ type: "step", step: step("s1", "running", { period: "previous" }) },
|
||||
{ type: "step", step: TURN_TWO_STEPS[0] },
|
||||
...tokens(SECOND_ANSWER),
|
||||
{ type: "provenance", metricSource: TURN_TWO_SOURCE },
|
||||
{ type: "clarification", clarification: TURN_TWO_CLARIFICATION },
|
||||
{ type: "followUps", followUps: TURN_TWO_FOLLOW_UPS },
|
||||
],
|
||||
},
|
||||
{
|
||||
userId: "coach-dialog-e2e-u3",
|
||||
userText: "Resting HR",
|
||||
assistantId: "coach-dialog-e2e-a3",
|
||||
answer: THIRD_ANSWER,
|
||||
metricSource: TURN_THREE_SOURCE,
|
||||
frames: [
|
||||
{ type: "step", step: { ...TURN_THREE_STEPS[0], status: "running" } },
|
||||
{ type: "step", step: TURN_THREE_STEPS[0] },
|
||||
...tokens(THIRD_ANSWER),
|
||||
{ type: "provenance", metricSource: TURN_THREE_SOURCE },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function fulfilJson(route: Route, data: unknown) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data, error: null }),
|
||||
});
|
||||
}
|
||||
|
||||
function eventStream(turn: Turn): string {
|
||||
const frames = [
|
||||
...turn.frames,
|
||||
{
|
||||
type: "done",
|
||||
conversationId: CONVERSATION_ID,
|
||||
messageId: turn.assistantId,
|
||||
usage: { totalTokens: 420 },
|
||||
},
|
||||
];
|
||||
return frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join("");
|
||||
}
|
||||
|
||||
function persisted(completed: number) {
|
||||
const at = (minute: number) =>
|
||||
new Date(Date.UTC(2026, 8, 24, 9, minute)).toISOString();
|
||||
return TURNS.slice(0, completed).flatMap((turn, index) => [
|
||||
{
|
||||
id: turn.userId,
|
||||
role: "user",
|
||||
content: turn.userText,
|
||||
createdAt: at(index * 2),
|
||||
metricSource: null,
|
||||
providerType: null,
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
},
|
||||
{
|
||||
id: turn.assistantId,
|
||||
role: "assistant",
|
||||
content: turn.answer,
|
||||
createdAt: at(index * 2 + 1),
|
||||
metricSource: turn.metricSource,
|
||||
providerType: "mock",
|
||||
promptVersion: "e2e",
|
||||
tokensUsed: 420,
|
||||
model: "mock",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the conversation as the stubbed server would hold it: one more
|
||||
* persisted turn after every POST. Returns the parsed body of every POST.
|
||||
*/
|
||||
async function mockCoach(page: Page): Promise<Array<Record<string, unknown>>> {
|
||||
const posts: Array<Record<string, unknown>> = [];
|
||||
let completed = 0;
|
||||
|
||||
const summary = () => ({
|
||||
id: CONVERSATION_ID,
|
||||
title: TITLE,
|
||||
createdAt: "2026-09-24T09:00:00.000Z",
|
||||
updatedAt: "2026-09-24T09:05:00.000Z",
|
||||
messageCount: completed * 2,
|
||||
fenced: false,
|
||||
attachments: [],
|
||||
documentTitle: null,
|
||||
});
|
||||
|
||||
await page.route(
|
||||
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
|
||||
async (route: Route, request: Request) => {
|
||||
const url = new URL(request.url());
|
||||
if (
|
||||
request.method() === "POST" &&
|
||||
url.pathname === "/api/insights/chat"
|
||||
) {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
posts.push(body);
|
||||
const turn = TURNS[completed];
|
||||
if (!turn) return route.abort();
|
||||
completed += 1;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: eventStream(turn),
|
||||
});
|
||||
}
|
||||
if (
|
||||
url.pathname ===
|
||||
`/api/insights/chat/${CONVERSATION_ID}/messages/${TURNS[0].assistantId}/results`
|
||||
) {
|
||||
return fulfilJson(route, { results: [BP_TABLE] });
|
||||
}
|
||||
if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) {
|
||||
return fulfilJson(route, { results: [] });
|
||||
}
|
||||
if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) {
|
||||
return fulfilJson(route, {
|
||||
...summary(),
|
||||
attachmentCount: 0,
|
||||
summary: null,
|
||||
messages: persisted(completed),
|
||||
});
|
||||
}
|
||||
return fulfilJson(route, {
|
||||
conversations: completed > 0 ? [summary()] : [],
|
||||
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: [] }),
|
||||
);
|
||||
return posts;
|
||||
}
|
||||
|
||||
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"]')
|
||||
.include('[data-slot="coach-follow-up-chips"]')
|
||||
.include('[data-slot="coach-clarification-card"]')
|
||||
.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([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The streamed reply is replaced by its persisted copy once the detail
|
||||
* refetch after `done` lands. The two are separate mounts, so anything
|
||||
* toggled on the streamed one before the swap is lost; wait for it.
|
||||
*/
|
||||
async function waitForPersistedTwin(page: Page) {
|
||||
await expect(
|
||||
page.locator('[role="log"] [data-slot="coach-bubble-assistant"]'),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe("Coach dialog", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`theme=${theme}: steps, a result, a follow-up chip and a clarification`, async ({
|
||||
page,
|
||||
context,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"the dialog flow is viewport-independent; desktop run suffices",
|
||||
);
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
const posts = await mockCoach(page);
|
||||
await useTheme(page, theme);
|
||||
|
||||
await page.goto("/coach", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator("html")).toHaveClass(
|
||||
new RegExp(`(^|\\s)${theme}(\\s|$)`),
|
||||
);
|
||||
|
||||
// 1. The first question streams its steps.
|
||||
await page
|
||||
.locator('[data-slot="coach-input-textarea"]')
|
||||
.fill(FIRST_QUESTION);
|
||||
await page.locator('[data-slot="coach-input-send"]').click();
|
||||
|
||||
const firstBubble = page
|
||||
.locator('[data-slot="coach-bubble-assistant"]')
|
||||
.filter({ hasText: FIRST_ANSWER });
|
||||
await expect(firstBubble).toBeVisible({ timeout: 15_000 });
|
||||
await waitForPersistedTwin(page);
|
||||
expect(posts).toHaveLength(1);
|
||||
expect(posts[0]).toMatchObject({ message: FIRST_QUESTION });
|
||||
expect(posts[0].followUp).toBeUndefined();
|
||||
expect(posts[0].clarification).toBeUndefined();
|
||||
|
||||
const steps = firstBubble.locator('[data-slot="coach-turn-steps"]');
|
||||
await expect(steps).toBeVisible();
|
||||
await expect(
|
||||
steps.locator('[data-slot="coach-turn-steps-done"]'),
|
||||
).toBeVisible();
|
||||
await steps.locator('[data-slot="coach-turn-steps-toggle"]').click();
|
||||
const stepRows = steps.locator('[data-slot="coach-turn-step"]');
|
||||
await expect(stepRows).toHaveCount(2);
|
||||
await expect(stepRows.nth(0)).toHaveAttribute("data-status", "done");
|
||||
await expect(stepRows.nth(1)).toHaveAttribute("data-status", "done");
|
||||
|
||||
// 2. The result opens as its chart; the toggle switches to the table.
|
||||
const figure = firstBubble.locator(
|
||||
'[data-slot="coach-result-chart"][data-ref="r1"]',
|
||||
);
|
||||
await expect(figure).toBeVisible();
|
||||
await expect(figure).toHaveAttribute("data-chart-kind", "line");
|
||||
await expect(
|
||||
figure.locator('[data-slot="coach-result-view-chart"]'),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await figure.locator('[data-slot="coach-result-view-table"]').click();
|
||||
|
||||
const table = firstBubble.locator(
|
||||
'[data-slot="coach-result-table"][data-ref="r1"]',
|
||||
);
|
||||
await expect(table).toBeVisible();
|
||||
await expect(figure).toBeHidden();
|
||||
await expect(table.locator("caption")).toContainText(BP_TABLE.title);
|
||||
await expect(
|
||||
table.locator('[data-slot="coach-result-view-table"]'),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(table.locator("tbody tr")).toHaveCount(BP_VALUES.length);
|
||||
|
||||
// The copy menu puts the table on the clipboard as text.
|
||||
await table.locator('[data-slot="coach-copy-table"]').click();
|
||||
await page.locator('[data-slot="coach-copy-table-text"]').click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toContain("Systolic");
|
||||
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(copied).toContain(BP_TABLE.title);
|
||||
expect(copied).toContain("128");
|
||||
|
||||
await table.locator('[data-slot="coach-result-view-chart"]').click();
|
||||
await expect(figure).toBeVisible();
|
||||
await expect(table).toBeHidden();
|
||||
|
||||
// The method line sits in the evidence disclosure.
|
||||
await firstBubble.locator('[data-slot="coach-evidence-summary"]').click();
|
||||
await expect(
|
||||
firstBubble.locator('[data-slot="coach-method-line"]'),
|
||||
).toContainText(TURN_ONE_METHOD.text);
|
||||
|
||||
// 3. A follow-up chip sends which chip it was.
|
||||
const chips = page.locator('[data-slot="coach-follow-up-chips"]');
|
||||
await expect(chips).toHaveAttribute(
|
||||
"data-message-id",
|
||||
TURNS[0].assistantId,
|
||||
);
|
||||
await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2);
|
||||
await chips.locator('[data-follow-up-id="f2"]').click();
|
||||
|
||||
const secondBubble = page
|
||||
.locator('[data-slot="coach-bubble-assistant"]')
|
||||
.filter({ hasText: SECOND_ANSWER });
|
||||
await expect(secondBubble).toBeVisible({ timeout: 15_000 });
|
||||
await waitForPersistedTwin(page);
|
||||
expect(posts).toHaveLength(2);
|
||||
expect(posts[1]).toMatchObject({
|
||||
conversationId: CONVERSATION_ID,
|
||||
message: "Compare with the period before",
|
||||
followUp: { messageId: TURNS[0].assistantId, id: "f2" },
|
||||
});
|
||||
expect(posts[1].clarification).toBeUndefined();
|
||||
|
||||
// 4. The reply asks; the card offers the choices, and the chips move
|
||||
// to the latest reply.
|
||||
const card = page.locator('[data-slot="coach-clarification-card"]');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toHaveAttribute(
|
||||
"data-message-id",
|
||||
TURNS[1].assistantId,
|
||||
);
|
||||
await expect(card.locator("[data-choice-id]")).toHaveCount(2);
|
||||
await expect(chips).toHaveAttribute(
|
||||
"data-message-id",
|
||||
TURNS[1].assistantId,
|
||||
);
|
||||
await expect(chips.locator("[data-follow-up-id]")).toHaveCount(1);
|
||||
// The earlier result still renders under its own answer.
|
||||
await expect(figure).toBeVisible();
|
||||
|
||||
// 5. axe over the answer with its result, the chips and the card.
|
||||
await expectNoAxeViolations(page, `${theme} chart view`);
|
||||
await figure.locator('[data-slot="coach-result-view-table"]').click();
|
||||
await expect(table).toBeVisible();
|
||||
await expectNoAxeViolations(page, `${theme} table view`);
|
||||
|
||||
await card.locator('[data-choice-id="c2"]').click();
|
||||
|
||||
const thirdBubble = page
|
||||
.locator('[data-slot="coach-bubble-assistant"]')
|
||||
.filter({ hasText: THIRD_ANSWER });
|
||||
await expect(thirdBubble).toBeVisible({ timeout: 15_000 });
|
||||
await waitForPersistedTwin(page);
|
||||
expect(posts).toHaveLength(3);
|
||||
expect(posts[2]).toMatchObject({
|
||||
conversationId: CONVERSATION_ID,
|
||||
message: "Resting HR",
|
||||
clarification: { messageId: TURNS[1].assistantId, choiceId: "c2" },
|
||||
});
|
||||
expect(posts[2].followUp).toBeUndefined();
|
||||
|
||||
// A plain answer closes the question and offers no chips.
|
||||
await expect(card).toHaveCount(0);
|
||||
await expect(chips).toHaveCount(0);
|
||||
await expect(
|
||||
thirdBubble.locator('[data-slot="coach-turn-steps"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user