mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The vault's layout is account state. documents.spec read the default while the layout journey in v142-qol turned it on the shared account, so which one failed depended on the order they ran in. Each now runs on a vault account of its own; the vault fixture seeds the same corpus under ids tagged per account, so two vaults never claim one row. Global setup clears every rate-limit bucket keyed on a fixture account or on the loopback addresses the suite calls from, not only the login and share buckets: the check-in, write, export and import buckets outlive a run by up to an hour, and a second run on the same database met the first run's counts. A developer's own accounts are left alone. The layout journey waits for its own write rather than whichever layout write answers first.
409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
/**
|
|
* v1.42 — three surfaces made easier to live with.
|
|
*
|
|
* - Documents: the display choice behind the wrench (cards or list, months
|
|
* stacked or flowing) persists per account; every delete asks first; the
|
|
* selection is cleared with an X on the count line.
|
|
* - Wellness scores: every score tile shows its course; a score's detail
|
|
* page carries its history right under the score card and no longer the
|
|
* warning-coloured "not clinical" line.
|
|
* - Mood insights: the calendar and the line chart appear together without
|
|
* moving the page, and logging is the header plus every Insights page
|
|
* uses.
|
|
*
|
|
* Everything is addressed through stable data attributes, never copy.
|
|
*/
|
|
import type { Page } from "@playwright/test";
|
|
import { expect, test } from "./setup/test";
|
|
|
|
import {
|
|
E2E_VAULT_LAYOUT,
|
|
STORAGE_STATE_PATH,
|
|
VAULT_LAYOUT_STORAGE_STATE_PATH,
|
|
} from "./setup/global-setup";
|
|
import { ensureVaultFixture, seedNamespaceDocs } from "./setup/vault-fixture";
|
|
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
|
|
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
async function putDocumentsLayout(
|
|
page: Page,
|
|
arrangement: "stacked" | "flow",
|
|
): Promise<void> {
|
|
const res = await page.request.put("/api/documents/inbound/layout", {
|
|
data: { version: 1, view: "cards", arrangement },
|
|
});
|
|
expect(res.status()).toBe(200);
|
|
}
|
|
|
|
/** The journey starts from the stacked arrangement so the flip is visible. */
|
|
const resetDocumentsLayout = (page: Page) =>
|
|
putDocumentsLayout(page, "stacked");
|
|
|
|
/** And leaves its account on the default, as a new account would read it. */
|
|
const restoreDefaultDocumentsLayout = (page: Page) =>
|
|
putDocumentsLayout(page, "flow");
|
|
|
|
// The vault's layout is account state, and this journey turns it, so these
|
|
// tests run on a vault account of their own (`E2E_VAULT_LAYOUT`); the month
|
|
// grid tests in `documents.spec.ts` read the default on theirs.
|
|
test.describe("documents", () => {
|
|
test.use({ storageState: VAULT_LAYOUT_STORAGE_STATE_PATH });
|
|
|
|
test.beforeAll(async () => {
|
|
await ensureVaultFixture(E2E_VAULT_LAYOUT.username);
|
|
});
|
|
|
|
test("the display choice behind the wrench persists across visits", async ({
|
|
page,
|
|
isMobile,
|
|
}) => {
|
|
// Both projects share one account; the layout is account state, so one
|
|
// project owns this journey.
|
|
test.skip(isMobile, "account-level preference; one project owns it");
|
|
await resetDocumentsLayout(page);
|
|
try {
|
|
await page.goto("/documents");
|
|
const timeline = page.locator('[data-slot="document-timeline"]');
|
|
await expect(timeline).toHaveAttribute("data-view", "cards");
|
|
await expect(timeline).toHaveAttribute("data-arrangement", "stacked");
|
|
|
|
await page.locator('[data-slot="documents-customize"]').click();
|
|
await expect(page).toHaveURL(/\/settings\/layout\/documents$/);
|
|
const settings = page.locator('[data-slot="documents-layout-settings"]');
|
|
await settings.locator('[data-slot="module-view-list"]').click();
|
|
await expect(
|
|
settings.locator('[data-slot="module-view-list"]'),
|
|
).toHaveAttribute("aria-pressed", "true");
|
|
const flow = settings.locator('[data-slot="documents-arrangement-flow"]');
|
|
// Wait for THIS write to land, so the reload below reads it back. The
|
|
// view switch above writes too, and its answer can arrive after this
|
|
// wait starts; matching on the arrangement keeps the two apart.
|
|
const saved = page.waitForResponse(
|
|
(r) =>
|
|
r.url().includes("/api/documents/inbound/layout") &&
|
|
r.request().method() === "PUT" &&
|
|
(r.request().postDataJSON() as { arrangement?: string } | null)
|
|
?.arrangement === "flow",
|
|
);
|
|
await flow.click();
|
|
expect((await saved).status()).toBe(200);
|
|
|
|
await page.goto("/documents");
|
|
await expect(timeline).toHaveAttribute("data-view", "list");
|
|
await expect(timeline).toHaveAttribute("data-arrangement", "flow");
|
|
await expect(
|
|
timeline
|
|
.locator('[data-slot="document-card"][data-variant="row"]')
|
|
.first(),
|
|
).toBeVisible();
|
|
// Flowing: no month gets a row of its own, the name rides inline.
|
|
await expect(
|
|
timeline.locator('[data-slot="document-flow-month"]').first(),
|
|
).toBeVisible();
|
|
|
|
// A fresh visit in a fresh page — the choice is the account's, not
|
|
// this tab's.
|
|
const second = await page.context().newPage();
|
|
await second.goto("/documents");
|
|
await expect(
|
|
second.locator('[data-slot="document-timeline"]'),
|
|
).toHaveAttribute("data-arrangement", "flow");
|
|
await second.close();
|
|
} finally {
|
|
await restoreDefaultDocumentsLayout(page);
|
|
}
|
|
});
|
|
|
|
test("bulk delete asks first, and cancelling deletes nothing", async ({
|
|
page,
|
|
isMobile,
|
|
}) => {
|
|
test.skip(isMobile, "selection by checkbox is a pointer flow");
|
|
await seedNamespaceDocs("qoldel", 2, E2E_VAULT_LAYOUT.username);
|
|
await page.goto("/documents?q=qoldel");
|
|
const box = page.getByRole("checkbox", { name: "Select qoldel 002" });
|
|
await expect(box).toBeVisible();
|
|
await box.click();
|
|
|
|
let deletes = 0;
|
|
page.on("request", (req) => {
|
|
if (
|
|
req.method() === "POST" &&
|
|
req.url().includes("/api/documents/inbound/bulk") &&
|
|
(req.postData() ?? "").includes('"delete"')
|
|
) {
|
|
deletes += 1;
|
|
}
|
|
});
|
|
|
|
const bar = page.locator('[data-slot="document-bulk-bar"]');
|
|
await bar.locator('[data-slot="document-bulk-delete"]').click();
|
|
const confirm = page.locator('[data-slot="documents-delete-confirm"]');
|
|
await expect(confirm).toBeVisible();
|
|
await page
|
|
.getByRole("alertdialog")
|
|
.getByRole("button", { name: "Cancel" })
|
|
.click();
|
|
await expect(confirm).toBeHidden();
|
|
expect(deletes).toBe(0);
|
|
await expect(
|
|
page.getByRole("button", { name: "Open qoldel 002" }),
|
|
).toBeVisible();
|
|
|
|
await bar.locator('[data-slot="document-bulk-delete"]').click();
|
|
await confirm.click();
|
|
await expect(
|
|
page.getByRole("button", { name: "Open qoldel 002" }),
|
|
).toBeHidden();
|
|
expect(deletes).toBe(1);
|
|
});
|
|
|
|
test("the Delete key on a focused card asks first too", async ({
|
|
page,
|
|
isMobile,
|
|
}) => {
|
|
test.skip(isMobile, "keyboard flow");
|
|
await seedNamespaceDocs("qolkey", 1, E2E_VAULT_LAYOUT.username);
|
|
await page.goto("/documents?q=qolkey");
|
|
const open = page.getByRole("button", { name: "Open qolkey 001" });
|
|
await expect(open).toBeVisible();
|
|
await open.focus();
|
|
await page.keyboard.press("Delete");
|
|
await expect(
|
|
page.locator('[data-slot="documents-delete-confirm"]'),
|
|
).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expect(open).toBeVisible();
|
|
});
|
|
|
|
test("the selection clears with the X on the count line", async ({
|
|
page,
|
|
isMobile,
|
|
}) => {
|
|
test.skip(isMobile, "selection by checkbox is a pointer flow");
|
|
await seedNamespaceDocs("qolclear", 2, E2E_VAULT_LAYOUT.username);
|
|
await page.goto("/documents?q=qolclear");
|
|
await page.getByRole("checkbox", { name: "Select qolclear 001" }).click();
|
|
|
|
const bar = page.locator('[data-slot="document-bulk-bar"]');
|
|
await expect(bar).toBeVisible();
|
|
const count = bar.locator('[data-slot="document-bulk-count"]');
|
|
const clear = bar.locator('[data-slot="document-bulk-clear"]');
|
|
const actions = bar.locator('[data-slot="document-bulk-actions"]');
|
|
|
|
// The X sits on the count line, at its right edge, above the action row,
|
|
// and the action row follows the count line without a gap of its own.
|
|
const [countBox, clearBox, actionsBox, barBox] = await Promise.all([
|
|
count.boundingBox(),
|
|
clear.boundingBox(),
|
|
actions.boundingBox(),
|
|
bar.boundingBox(),
|
|
]);
|
|
expect(countBox && clearBox && actionsBox && barBox).toBeTruthy();
|
|
const mid = (b: { y: number; height: number }) => b.y + b.height / 2;
|
|
expect(Math.abs(mid(clearBox!) - mid(countBox!))).toBeLessThan(6);
|
|
expect(
|
|
barBox!.x + barBox!.width - (clearBox!.x + clearBox!.width),
|
|
).toBeLessThan(20);
|
|
expect(
|
|
actionsBox!.y - (clearBox!.y + clearBox!.height),
|
|
).toBeLessThanOrEqual(10);
|
|
// One action row: every verb shares a top edge.
|
|
const tops = await actions
|
|
.locator("button")
|
|
.evaluateAll((els) =>
|
|
els.map((el) => Math.round(el.getBoundingClientRect().top)),
|
|
);
|
|
expect(new Set(tops).size).toBe(1);
|
|
|
|
await clear.click();
|
|
await expect(bar).toBeHidden();
|
|
});
|
|
});
|
|
|
|
test.describe("wellness scores", () => {
|
|
test("every score tile shows its course", async ({ page }) => {
|
|
await mockPopulatedInsights(page);
|
|
await page.goto("/insights");
|
|
for (const metric of ["RECOVERY_SCORE", "STRESS_SCORE"]) {
|
|
const tile = page.locator(
|
|
`[data-slot="wellness-score-tile"][data-metric="${metric}"]`,
|
|
);
|
|
await tile.scrollIntoViewIfNeeded();
|
|
await expect(
|
|
tile.locator('[data-slot="wellness-score-history"]'),
|
|
).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("a score's detail page carries its history under the card, without the caveat line", async ({
|
|
page,
|
|
}) => {
|
|
// The e2e account has no nightly score; serve one so the page has a
|
|
// score to draw the history under.
|
|
await page.route(
|
|
/\/api\/insights\/derived\?metric=RECOVERY_SCORE/,
|
|
(route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
metric: "RECOVERY_SCORE",
|
|
status: "ok",
|
|
value: {
|
|
score: 66,
|
|
band: "yellow",
|
|
trendDelta: 2,
|
|
daysInWindow: 5,
|
|
asOf: new Date().toISOString(),
|
|
series: [58, 61, 64, 60, 66],
|
|
anchor: null,
|
|
components: null,
|
|
},
|
|
coverage: {
|
|
requiredInputs: 1,
|
|
presentInputs: 1,
|
|
historyDays: 5,
|
|
missing: [],
|
|
},
|
|
confidence: { score: 80, band: "high" },
|
|
provenance: {
|
|
inputs: ["RECOVERY_SCORE"],
|
|
source: "live",
|
|
windowDays: 14,
|
|
computedAt: new Date().toISOString(),
|
|
},
|
|
reason: null,
|
|
assessment: null,
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
await page.goto("/insights/scores/recovery");
|
|
const anatomy = page.locator('[data-slot="score-anatomy-view"]');
|
|
await expect(anatomy).toBeVisible();
|
|
const history = page.locator('[data-slot="score-history-chart"]');
|
|
await expect(history).toBeAttached();
|
|
const [cardBox, historyBox] = await Promise.all([
|
|
anatomy.boundingBox(),
|
|
history.boundingBox(),
|
|
]);
|
|
expect(historyBox!.y).toBeGreaterThan(cardBox!.y + cardBox!.height - 1);
|
|
await expect(anatomy.locator(".text-warning")).toHaveCount(0);
|
|
});
|
|
});
|
|
|
|
test.describe("mood insights", () => {
|
|
test.beforeAll(async ({ browser }) => {
|
|
// A short, real history, so the calendar and the chart both have days.
|
|
const context = await browser.newContext({
|
|
storageState: STORAGE_STATE_PATH,
|
|
});
|
|
const now = Date.now();
|
|
for (let day = 1; day <= 12; day++) {
|
|
const res = await context.request.post("/api/mood-entries", {
|
|
data: {
|
|
mood: day % 3 === 0 ? "OKAY" : "GUT",
|
|
moodLoggedAt: new Date(now - day * 86_400_000).toISOString(),
|
|
source: "MANUAL",
|
|
externalId: `e2e-qol-mood-${day}`,
|
|
},
|
|
});
|
|
expect([200, 201]).toContain(res.status());
|
|
}
|
|
await context.close();
|
|
});
|
|
|
|
test("the calendar and the chart appear together without moving the page", async ({
|
|
page,
|
|
}) => {
|
|
// The calendar's read is the slower one in production; slow it down here
|
|
// so the old order (chart first, calendar pushing it down) would show.
|
|
await page.route(/\/api\/mood\/insights(\?|$)/, async (route) => {
|
|
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
|
await route.continue();
|
|
});
|
|
await page.addInitScript(() => {
|
|
const w = window as unknown as {
|
|
__cls: number;
|
|
__seen: Record<string, number>;
|
|
};
|
|
w.__cls = 0;
|
|
w.__seen = {};
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries() as Array<
|
|
PerformanceEntry & { value: number; hadRecentInput: boolean }
|
|
>) {
|
|
if (!entry.hadRecentInput) w.__cls += entry.value;
|
|
}
|
|
}).observe({ type: "layout-shift", buffered: true });
|
|
const watch = () => {
|
|
if (
|
|
!w.__seen.heatmap &&
|
|
document.querySelector(
|
|
'[data-slot="mood-heatmap-body"] svg[role="img"]',
|
|
)
|
|
)
|
|
w.__seen.heatmap = performance.now();
|
|
if (
|
|
!w.__seen.chart &&
|
|
document.querySelector(
|
|
'[data-slot="insights-mood-chart"] .recharts-surface',
|
|
) &&
|
|
!document.querySelector(
|
|
'[data-slot="insights-mood-chart"] .invisible',
|
|
)
|
|
)
|
|
w.__seen.chart = performance.now();
|
|
requestAnimationFrame(watch);
|
|
};
|
|
requestAnimationFrame(watch);
|
|
});
|
|
|
|
await page.goto("/insights/mood");
|
|
await expect(
|
|
page.locator('[data-slot="mood-heatmap-body"] svg[role="img"]'),
|
|
).toBeAttached({
|
|
timeout: 15_000,
|
|
});
|
|
await expect(
|
|
page
|
|
.locator('[data-slot="insights-mood-chart"] .recharts-surface')
|
|
.first(),
|
|
).toBeVisible();
|
|
await page.waitForTimeout(1_500);
|
|
|
|
const { cls, seen } = await page.evaluate(() => {
|
|
const w = window as unknown as {
|
|
__cls: number;
|
|
__seen: Record<string, number>;
|
|
};
|
|
return { cls: w.__cls, seen: w.__seen };
|
|
});
|
|
expect(cls).toBeLessThan(0.02);
|
|
expect(seen.heatmap).toBeDefined();
|
|
expect(seen.chart).toBeDefined();
|
|
// Together: within a couple of frames of each other.
|
|
expect(Math.abs(seen.heatmap - seen.chart)).toBeLessThan(100);
|
|
});
|
|
|
|
test("logging is the header plus, not a text link", async ({ page }) => {
|
|
await page.goto("/insights/mood");
|
|
const add = page.locator(
|
|
'[data-slot="insights-mood-add"][data-header-add]',
|
|
);
|
|
await expect(add).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-slot="insights-mood-log-link"]'),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.locator('[data-slot="insights-mood-mental-wellbeing-link"]'),
|
|
).toHaveCount(0);
|
|
await add.click();
|
|
await expect(page.getByRole("dialog")).toBeVisible();
|
|
});
|
|
});
|