Files
HealthLog/e2e/coach-clarification.spec.ts
Marc-André Bombeck dd72634791 Rebuild the Coach thinking row as one calm line
While a turn runs the Coach now shows a single row: a brain icon, the
current step in the answer's text size with a shimmer passing through it
and fading dots, and a chevron once there are steps to open. There is no
running clock any more, and the interim table preview with its sparkline
no longer renders under the row; interim tables stay in state until the
answer's own results show them.

When the answer is done the row reads "Thought process · N steps" with
proper plurals, and "Answer needed" while a clarifying question waits.
It never opens by itself, and a row opened during the run closes when
the turn ends. Opened, the steps form a vertical list with an icon per
kind of work on a thin connector line, and a reasoning round shows its
summary under its title.

The shimmer and the dots are a `text-shimmer` utility and a reusable
WaitingText component, both still under reduced motion.

The demo's write block now carries `meta.errorCode: "demo.readOnly"`,
listed in the error-code catalogue, and the Coach answers it with an
honest line instead of the provider-outage copy.

Removes the interim preview component, the elapsed-seconds and summary
helpers, and their message keys in all seven locales.
2026-10-07 16:55:25 +02:00

268 lines
7.8 KiB
TypeScript

import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import {
FIXED_NOW,
done,
end,
feed,
installLiveCoachStream,
posts,
serveConversation,
tokens,
applyTheme,
type StubConversation,
} from "./setup/coach-live-stream";
/**
* A clarifying question is a conversation, not a form.
*
* 1. The question is a normal Coach message; its choices are quiet reply
* pills under it, the assumed one first. There is no card.
* 2. A pill sends its label as the person's own message, with
* `clarification: { messageId, choiceId }`.
* 3. A typed answer works too, with `clarification: { messageId }`.
* 4. The trail above the question stays closed.
*/
const CONVERSATION_ID = "coach-clarify-e2e";
const QUESTION = "Is my heart rate okay?";
const ASKS =
"Do you mean your resting heart rate or your heart rate while walking? Otherwise I'll look at your resting heart rate.";
const ANSWER =
"Your walking heart rate sat around 98 bpm over the last 30 days, close to your usual level.";
const CLARIFICATION = {
kind: "metric",
freeText: true,
assumption: "c1",
choices: [
{
id: "c1",
labelKey: "insights.coach.metric.resting_hr",
label: "Resting HR",
value: { metric: "resting_hr" },
},
{
id: "c2",
labelKey: "insights.coach.metric.walking_hr",
label: "Walking HR",
value: { metric: "walking_hr" },
},
],
};
const ASK_SOURCE = {
windows: ["last30days"],
metrics: ["resting_hr"],
activity: [
{
id: "a1",
phase: "thinking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.thinking",
label: "Thinking…",
durationMs: 1_200,
},
{
id: "a2",
phase: "asking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.asking",
label: "Asking you…",
durationMs: 300,
},
],
clarification: CLARIFICATION,
};
async function ask(
page: import("@playwright/test").Page,
stub: StubConversation,
) {
await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION);
await page.locator('[data-slot="coach-input-send"]').click();
const live = page.locator(
'[role="log"] [data-slot="coach-turn-steps-active"]',
);
await feed(page, [
{
type: "activity",
activity: {
...ASK_SOURCE.activity[0],
status: "running",
durationMs: undefined,
},
},
{
type: "activity",
activity: {
...ASK_SOURCE.activity[1],
status: "running",
durationMs: undefined,
},
},
]);
await expect(live).toHaveAttribute("data-text", "Asking you…");
stub.messages.push(
{ id: "coach-clarify-e2e-u1", role: "user", content: QUESTION },
{
id: "coach-clarify-e2e-a1",
role: "assistant",
content: ASKS,
metricSource: ASK_SOURCE,
},
);
await feed(page, [
...tokens(ASKS),
{ type: "activity", activity: ASK_SOURCE.activity[1] },
{ type: "provenance", metricSource: ASK_SOURCE },
{ type: "clarification", clarification: CLARIFICATION },
done(CONVERSATION_ID, "coach-clarify-e2e-a1"),
]);
await end(page);
}
test.describe("Coach clarifying question", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("pills in the conversation, the assumed one first, and a tap answers", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"the flow is viewport-independent; the activity spec covers 390 px",
);
await page.clock.setFixedTime(FIXED_NOW);
await serveAiBlock(page, aiBlockAvailable());
const stub: StubConversation = {
id: CONVERSATION_ID,
title: "Heart rate",
messages: [],
results: {},
trails: {},
};
await serveConversation(page, stub);
await installLiveCoachStream(page);
await applyTheme(page, "light");
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await ask(page, stub);
const asked = page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ASKS });
await expect(asked).toHaveCount(1);
// 1. No card anywhere; the pills sit in the answer's own column.
await expect(
page.locator('[data-slot="coach-clarification-card"]'),
).toHaveCount(0);
const pills = asked.locator('[data-slot="coach-suggested-replies"]');
await expect(pills).toBeVisible();
await expect(pills).toHaveAttribute(
"data-message-id",
"coach-clarify-e2e-a1",
);
const choices = pills.locator("[data-choice-id]");
await expect(choices).toHaveCount(2);
await expect(choices.nth(0)).toHaveAttribute("data-choice-id", "c1");
// 4. The row says an answer is needed, and stays closed.
const row = asked.locator('[data-slot="coach-turn-steps"]');
await expect(row).toHaveAttribute("data-state", "awaiting");
await expect(row.locator('[data-slot="coach-turn-steps-done"]')).toHaveText(
"Answer needed",
);
await expect(row.locator(".text-shimmer")).toHaveCount(0);
await expect(
asked.locator('[data-slot="coach-turn-steps-toggle"]'),
).toHaveAttribute("aria-expanded", "false");
// 2. A tap sends the label as the person's message.
const walking = choices.nth(1);
const label = (await walking.textContent())?.trim() ?? "";
await walking.click();
await expect(
page
.locator('[data-slot="coach-bubble-user"]')
.filter({ hasText: label }),
).toBeVisible();
await expect(pills).toHaveCount(0);
const sent = await posts(page);
expect(sent[1]).toMatchObject({
conversationId: CONVERSATION_ID,
message: label,
clarification: { messageId: "coach-clarify-e2e-a1", choiceId: "c2" },
});
stub.messages.push(
{ id: "coach-clarify-e2e-u2", role: "user", content: label },
{
id: "coach-clarify-e2e-a2",
role: "assistant",
content: ANSWER,
metricSource: { windows: ["last30days"], metrics: ["walking_hr"] },
},
);
await feed(page, [
...tokens(ANSWER),
done(CONVERSATION_ID, "coach-clarify-e2e-a2"),
]);
await end(page);
await expect(
page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ANSWER }),
).toBeVisible();
await expect(
page.locator('[data-slot="coach-suggested-replies"]'),
).toHaveCount(0);
});
test("a typed answer answers the question too", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-independent; desktop run suffices",
);
await page.clock.setFixedTime(FIXED_NOW);
await serveAiBlock(page, aiBlockAvailable());
const stub: StubConversation = {
id: CONVERSATION_ID,
title: "Heart rate",
messages: [],
results: {},
trails: {},
};
await serveConversation(page, stub);
await installLiveCoachStream(page);
await applyTheme(page, "dark");
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await ask(page, stub);
await expect(
page.locator('[data-slot="coach-suggested-replies"]'),
).toBeVisible();
await page
.locator('[data-slot="coach-input-textarea"]')
.fill("the walking one");
await page.locator('[data-slot="coach-input-send"]').click();
await expect(
page
.locator('[data-slot="coach-bubble-user"]')
.filter({ hasText: "the walking one" }),
).toBeVisible();
const sent = await posts(page);
expect(sent[1]).toMatchObject({
message: "the walking one",
clarification: { messageId: "coach-clarify-e2e-a1" },
});
expect(
(sent[1].clarification as Record<string, unknown>).choiceId,
).toBeUndefined();
await end(page);
});
});