test(e2e): cover the medication-adherence journey end to end

The percentage on a medication card is the number this product is for, and
nothing in the browser suite walked the path that produces it: the wizard
specs stub `/api/medications` and stop at the POST body, and the compliance
engine's own tests are unit tests over pure functions. The join was the part
nobody checked.

Three flows against the seeded account, serial and in one project because
they read counts back off a cabinet they own:

  - a twice-daily medication created through the real wizard, one dose taken
    and one skipped from the card, and the figure read back on the card, in
    the intake history and on the dashboard tile;
  - a dose recorded against a schedule that does not exist, refused, with the
    rate exactly what it was before;
  - a plan due on two weekdays whose other five days stay out of the
    denominator, with a daily twin given the identical doses as the control —
    without it, "100 %" would also be what an engine that counted no days at
    all would say.

The dose times are derived from the clock rather than picked from the
wizard's suggestions: the card offers one dose, so with fixed times there is
always an hour at which that dose belongs to yesterday or tomorrow, and the
day the write lands on decides what the tile reads. Each medication is aged a
few days as well — compliance is reconstructed from the creation stamp
forward, so a medication created a second ago reads the empty-set 100 % and
no take can move it.

Four surfaces gain data attributes so the journey addresses controls and
figures rather than translated copy: the card's take / skip buttons, the
card's two adherence rates, the intake-history row status, and the dashboard
tile's most recent day, which existed only as bar geometry. Presentation is
unchanged; the compliance block's fixed-geometry guard normalises the two new
rate attributes alongside the ones it already did.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 01:46:13 +02:00
1 parent 1d372df3a0
commit 617beb40dc
8 files changed
+761 -5

No files matched your search

@@ -393,10 +393,19 @@ export function MedicationComplianceChart({
// explicit user opt-in — silence would feel like a bug).
const hasData = chartData.length > 0;
// The tile's adherence number exists only as bar geometry, so nothing on
// this surface can be read back except by measuring pixels. The most
// recent day's rate — the one figure a reader takes off the tile — rides
// the root as a plain data attribute so the browser journey can assert the
// dashboard and the medication card agree on the same dose. Presentation
// is untouched; the value is the one `chartData` already carries.
const latestRate = hasData ? chartData[chartData.length - 1].rate : null;
return (
<div
className="bg-card border-border rounded-xl border p-4 md:p-6"
data-slot="medication-compliance-chart"
data-latest-rate={latestRate ?? undefined}
>
{/* v1.4.19 A2 — header layout split into title row + controls
row on small screens. Pre-fix the trend chip + 4 range tabs +
@@ -609,7 +609,8 @@ describe("compliance bars — fixed geometry (v1.16.6)", () => {
);
// Strip text nodes; the remaining tag/class skeleton must be identical
// between a 0% and a 100% card (transform style varies with the value
// by design, so it is normalised too).
// by design, so it is normalised too — as are the two rate attributes the
// block publishes, which are the value and not the geometry).
const skeleton = (html: string) =>
html
.replace(/>[^<]+</g, "><")
@@ -617,6 +618,8 @@ describe("compliance bars — fixed geometry (v1.16.6)", () => {
.replace(/aria-valuenow="\d+"/g, 'aria-valuenow="N"')
.replace(/data-state="[^"]*"/g, 'data-state="S"')
.replace(/data-value="[^"]*"/g, 'data-value="V"')
.replace(/data-rate-short="[^"]*"/g, 'data-rate-short="R"')
.replace(/data-rate-long="[^"]*"/g, 'data-rate-long="R"')
.replace(/data-max="[^"]*"/g, 'data-max="M"');
expect(skeleton(a)).toBe(skeleton(b));
});
@@ -107,8 +107,10 @@ export function MedicationComplianceBars({
// v1.12.2 — route the rate through the locale number formatter and round
// so a non-integer rate (e.g. 33.333) never leaks raw into the caption,
// matching how every other percentage renders.
const shortPct = fmt.number(Math.round(rate7));
const longPct = fmt.number(Math.round(rate30));
const shortRounded = Math.round(rate7);
const longRounded = Math.round(rate30);
const shortPct = fmt.number(shortRounded);
const longPct = fmt.number(longRounded);
// v1.16.6 — both rows carry an identical, FIXED geometry: the label line
// is pinned to one text line (`h-5` + `truncate`) and the bar to `h-2`,
@@ -117,7 +119,21 @@ export function MedicationComplianceBars({
// is colour / text only — never geometry; the two bars on a card (and
// across a grid row) always align.
return (
<div className="space-y-2.5">
// Both rates ride the block as plain data attributes. The percentages
// themselves are locale-formatted text inside a fixed-height caption, and
// the bar beside them is a shadcn `Progress` that keeps its value for the
// indicator transform and never forwards it to the primitive — so the one
// number this card exists to show has, until now, been readable only by
// parsing a translated string. The attributes carry the ROUNDED value the
// caption renders, so a reader and an assertion see the same figure.
// Presentation is untouched; the loading and error variants below stay
// free of them, which is what makes their presence mean "loaded".
<div
className="space-y-2.5"
data-slot="medication-card-compliance"
data-rate-short={shortRounded}
data-rate-long={longRounded}
>
<div className="space-y-1.5">
<div className="flex h-5 items-center justify-between text-sm">
<span className="text-muted-foreground flex min-w-0 items-center gap-1">
@@ -22,6 +22,11 @@ interface MedicationIntakeActionsProps {
*
* Extracted from the generic and GLP-1 cards so the action row is shared
* structurally rather than kept byte-equivalent by hand.
*
* Both buttons carry a `data-slot`. They are the only controls on the card
* that write, and their labels are i18n copy that also collapses to an icon
* on the narrow card — an e2e journey addressing them by text would be
* asserting the translation, not the control.
*/
export function MedicationIntakeActions({
intakeLoading,
@@ -37,6 +42,7 @@ export function MedicationIntakeActions({
return (
<div className="flex gap-2">
<Button
data-slot="medication-intake-take"
className="min-h-11 flex-1"
onClick={() => onRecordIntake(false)}
disabled={!!intakeLoading}
@@ -49,6 +55,7 @@ export function MedicationIntakeActions({
{t("medications.taken")}
</Button>
<Button
data-slot="medication-intake-skip"
variant="outline"
className="min-h-11"
onClick={() => onRecordIntake(true)}
@@ -155,6 +155,19 @@ const DEFAULT_PAGE_SIZE = 25;
*/
const STATUS_FILTER = "completed";
/**
* The row's outcome as a stable token, carried on both layouts' rows as
* `data-status`. The visible outcome is a translated badge that renders at a
* different size on each layout, so it is not a thing an end-to-end journey
* can address; the branch itself is one line and already computed twice.
* "unknown" cannot occur while `STATUS_FILTER` holds — it is what a malformed
* row would say rather than quietly reading as taken.
*/
function rowStatus(isTaken: boolean, isSkipped: boolean): string {
if (isTaken) return "taken";
return isSkipped ? "skipped" : "unknown";
}
export function IntakeHistoryListV2({
medicationId,
pageSize = DEFAULT_PAGE_SIZE,
@@ -346,7 +359,11 @@ export function IntakeHistoryListV2({
const isSelected =
selection?.selected.has(event.id) ?? false;
return (
<TableRow key={event.id}>
<TableRow
key={event.id}
data-slot="intake-history-row"
data-status={rowStatus(isTaken, isSkipped)}
>
{showSelection && (
<TableCell className="w-8">
<Checkbox
@@ -441,6 +458,8 @@ export function IntakeHistoryListV2({
return (
<ListRow
key={event.id}
data-slot="intake-history-row"
data-status={rowStatus(isTaken, isSkipped)}
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5"
>