mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge the day picker, Coach new-chat button, day panel on /coach and text cleanup into release/v1.42.0
This commit is contained in:
commit
1d080ee166
69 files changed
+2293
-2241
No files matched your search
@@ -33,7 +33,7 @@ import {
|
||||
* 3. Nothing opens by itself: the row stays closed (`aria-expanded`
|
||||
* false) in every state, after the answer and after a reload; a row
|
||||
* opened during the run closes when the answer is done.
|
||||
* 4. After the answer the row reads "Thought process · 4 steps" and the
|
||||
* 4. After the answer the row reads "Thought process (4 steps)" and the
|
||||
* table the answer points at renders as its chart.
|
||||
* 5. A tap opens the steps; a reasoning round shows its summary under
|
||||
* its title.
|
||||
@@ -435,7 +435,7 @@ test.describe("Coach live trail", () => {
|
||||
await expect(settled).toHaveAttribute("data-state", "done");
|
||||
await expect(
|
||||
settled.locator('[data-slot="coach-turn-steps-done"]'),
|
||||
).toHaveText("Thought process · 4 steps");
|
||||
).toHaveText("Thought process (4 steps)");
|
||||
await expect(settled.locator(".text-shimmer")).toHaveCount(0);
|
||||
const settledToggle = settled.locator(
|
||||
'[data-slot="coach-turn-steps-toggle"]',
|
||||
@@ -506,7 +506,7 @@ test.describe("Coach live trail", () => {
|
||||
await expect(reloaded).toHaveAttribute("data-state", "done");
|
||||
await expect(
|
||||
reloaded.locator('[data-slot="coach-turn-steps-done"]'),
|
||||
).toHaveText("Thought process · 4 steps");
|
||||
).toHaveText("Thought process (4 steps)");
|
||||
const reloadedToggle = reloaded.locator(
|
||||
'[data-slot="coach-turn-steps-toggle"]',
|
||||
);
|
||||
|
||||
+196
-125
@@ -4,26 +4,27 @@ import type { Locator, Page, Request, Route, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
|
||||
/**
|
||||
* The Coach page frame: the conversations panel on the right, its toggle in
|
||||
* the top bar, the settings gear in the panel header, and New chat opening
|
||||
* that header row.
|
||||
* The Coach page frame: the conversation, the conversations panel right of
|
||||
* it, and the day right of that; New conversation floating in the
|
||||
* conversation itself.
|
||||
*
|
||||
* Contracts under test, at 1440, 1280 and 390 px:
|
||||
* Contracts under test, at 1920, 1440, 1280 and 390 px:
|
||||
*
|
||||
* 1. From 1280 px the panel docks beside the thread, open on a first visit,
|
||||
* and the reading column (thread and composer) is exactly as wide with
|
||||
* the panel open as with it closed. The choice survives a reload. The
|
||||
* panel is flush with the viewport's right edge (no scrollbar gutter
|
||||
* beside it) and runs from the top of the window to the bottom; the
|
||||
* top bar ends at its left edge and the panel's header border is the
|
||||
* top bar's. The toggle is the bar's last item, within 16 px of its
|
||||
* right edge, open and shut; shut, no pixel of the panel shows and the
|
||||
* bar runs to the window's edge. New chat is the header row's first
|
||||
* control, left of the title, and never overlaps the list.
|
||||
* 2. Escape inside the docked panel closes it and returns focus to the
|
||||
* toggle.
|
||||
* the panel open as folded. Folded, the panel keeps a 48 px edge whose
|
||||
* button (in the place of the open panel's fold control) opens it
|
||||
* again; the choice survives a reload. The panel is flush with the
|
||||
* viewport's right edge and runs from the top of the window to the
|
||||
* bottom; the top bar ends at its left edge and the panel's header
|
||||
* border is the top bar's. Focus moves between the fold control and
|
||||
* the edge's button, and Escape inside the open panel folds it.
|
||||
* 2. New conversation is a round 56 px button in the conversation column,
|
||||
* never over the panel, never over the composer's field, and absent
|
||||
* from the panel's header.
|
||||
* 3. A row's menu renames by keyboard (Escape cancels without closing the
|
||||
* panel, Enter saves once) and deletes with an Undo that keeps the
|
||||
* DELETE from ever leaving; a delete left alone commits once.
|
||||
@@ -32,10 +33,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* can see" and drops the param from the URL.
|
||||
* 5. axe finds nothing on the open panel or the open settings, in the
|
||||
* light and the dark theme.
|
||||
* 6. At 390 px the panel is a dialog sheet with New chat first in its
|
||||
* header and the title beside it; Escape closes it and focus returns to the toggle. The gear
|
||||
* opens a bottom sheet that closes on Escape back to the gear. Nothing
|
||||
* scrolls sideways.
|
||||
* 6. The day docks right of the panel. At 1440 only one of the two is
|
||||
* open: a day folds the panel, opening the panel folds the day. At 1920
|
||||
* both stay open and the conversation keeps at least 560 px.
|
||||
* 7. At 390 px the panel is a dialog sheet opened from the top bar's
|
||||
* toggle; Escape closes it and focus returns to the toggle. The gear
|
||||
* opens a bottom sheet that closes on Escape back to the gear. New
|
||||
* conversation sits above the composer. Nothing scrolls sideways.
|
||||
*
|
||||
* Every Coach route is stubbed; the provider chain and provider config are
|
||||
* stubbed too, so the model section renders the same on every machine.
|
||||
@@ -174,9 +178,6 @@ async function mockCoach(page: Page): Promise<CoachMock> {
|
||||
await page.route("**/api/coach/about-me/questions*", (route) =>
|
||||
fulfilJson(route, { questions: [] }),
|
||||
);
|
||||
await page.route("**/api/insights/coach/seeded-question*", (route) =>
|
||||
fulfilJson(route, { signal: null }),
|
||||
);
|
||||
await page.route("**/api/insights/provider-chain", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? fulfilJson(route, {
|
||||
@@ -213,6 +214,11 @@ async function mockCoach(page: Page): Promise<CoachMock> {
|
||||
}
|
||||
|
||||
const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]');
|
||||
const collapse = (page: Page) =>
|
||||
page.locator('[data-slot="coach-panel-collapse"]');
|
||||
const expand = (page: Page) => page.locator('[data-slot="coach-panel-expand"]');
|
||||
const fab = (page: Page) => page.locator('[data-slot="coach-new-chat-fab"]');
|
||||
const dayPanel = (page: Page) => page.locator('[data-slot="day-panel"]');
|
||||
const panel = (page: Page) =>
|
||||
page.locator('[data-slot="coach-conversations-panel"]');
|
||||
|
||||
@@ -252,9 +258,10 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
'[data-slot="coach-conversations-panel"]',
|
||||
)!;
|
||||
const inner = aside.firstElementChild as HTMLElement;
|
||||
const header = aside.querySelector(
|
||||
'[data-slot="coach-conversations-panel-header"]',
|
||||
)!;
|
||||
// Open, the header row; folded, the edge's band.
|
||||
const header =
|
||||
aside.querySelector('[data-slot="coach-conversations-panel-header"]') ??
|
||||
aside.querySelector('[data-slot="coach-panel-expand"]')!.parentElement!;
|
||||
const bar = document.querySelector('[data-slot="top-bar"]')!;
|
||||
const main = document.getElementById("main-content")!;
|
||||
const style = getComputedStyle(inner);
|
||||
@@ -287,68 +294,37 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle is the last item of the top bar (the account menu that follows
|
||||
* it on phones is not rendered from `md`), and its right edge is within
|
||||
* 16 px of the bar's right edge.
|
||||
* New conversation floats in the conversation column: a round 56 px button
|
||||
* with a plus and its name, inside the column (never over a docked panel
|
||||
* right of it) and clear of the composer's field. The panel's header has no
|
||||
* such control.
|
||||
*/
|
||||
async function expectToggleAtBarEnd(page: Page) {
|
||||
const at = await page.evaluate(() => {
|
||||
const bar = document.querySelector('[data-slot="top-bar"]')!;
|
||||
const shown = [...bar.querySelectorAll("*")].filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
const toggle = bar.querySelector('[data-slot="coach-panel-toggle"]')!;
|
||||
const rightmost = Math.max(
|
||||
...shown.map((el) => el.getBoundingClientRect().right),
|
||||
);
|
||||
return {
|
||||
gap:
|
||||
bar.getBoundingClientRect().right -
|
||||
toggle.getBoundingClientRect().right,
|
||||
last: toggle.getBoundingClientRect().right >= rightmost,
|
||||
lastChild:
|
||||
toggle.closest('[data-slot="top-bar-actions"]')?.lastElementChild ===
|
||||
toggle,
|
||||
};
|
||||
});
|
||||
expect(at.last, "nothing in the bar stands right of the toggle").toBe(true);
|
||||
expect(at.lastChild).toBe(true);
|
||||
expect(at.gap).toBeGreaterThanOrEqual(0);
|
||||
expect(at.gap).toBeLessThanOrEqual(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* New chat opens the panel header: the row's first control, left of the
|
||||
* title, a plus glyph with an accessible name. It sits above the list, so no
|
||||
* row ever slides under it.
|
||||
*/
|
||||
async function expectNewChatInHeader(scope: Locator) {
|
||||
const header = scope.locator(
|
||||
'[data-slot="coach-conversations-panel-header"]',
|
||||
);
|
||||
const button = header.locator('[data-slot="coach-panel-new-chat"]');
|
||||
await expect(button).toHaveCount(1);
|
||||
await expect(button.locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expect(button).toHaveAttribute("aria-label", "New chat");
|
||||
const order = await header.evaluate((el) => {
|
||||
const first = el.firstElementChild as HTMLElement;
|
||||
const heading = el.querySelector("h2") as HTMLElement;
|
||||
return {
|
||||
first: first.dataset.slot,
|
||||
buttonRight: first.getBoundingClientRect().right,
|
||||
headingLeft: heading.getBoundingClientRect().left,
|
||||
};
|
||||
});
|
||||
expect(order.first).toBe("coach-panel-new-chat");
|
||||
expect(order.buttonRight).toBeLessThanOrEqual(order.headingLeft);
|
||||
const buttonBox = (await button.boundingBox())!;
|
||||
const listBox = (await scope
|
||||
.locator('[data-slot="coach-history-list"]')
|
||||
async function expectNewChatFab(page: Page) {
|
||||
await expect(fab(page)).toHaveCount(1);
|
||||
await expect(fab(page)).toHaveAttribute("aria-label", "New conversation");
|
||||
await expect(fab(page).locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-panel-new-chat"]'),
|
||||
).toHaveCount(0);
|
||||
const box = (await fab(page).boundingBox())!;
|
||||
expect(Math.round(box.width)).toBe(56);
|
||||
expect(Math.round(box.height)).toBe(56);
|
||||
const main = (await page
|
||||
.locator('[data-slot="coach-page-main"]')
|
||||
.boundingBox())!;
|
||||
expect(
|
||||
buttonBox.y + buttonBox.height,
|
||||
"the button ends above the list",
|
||||
).toBeLessThanOrEqual(listBox.y);
|
||||
expect(box.x).toBeGreaterThanOrEqual(main.x);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(main.x + main.width);
|
||||
const field = (await page
|
||||
.locator(
|
||||
'[data-slot="coach-page-composer"] [data-slot="coach-input-textarea"]',
|
||||
)
|
||||
.boundingBox())!;
|
||||
const overlaps =
|
||||
box.x < field.x + field.width &&
|
||||
box.x + box.width > field.x &&
|
||||
box.y < field.y + field.height &&
|
||||
box.y + box.height > field.y;
|
||||
expect(overlaps, "the button leaves the composer's field free").toBe(false);
|
||||
}
|
||||
|
||||
/** Rendered heights of the docked panel's header buttons and first row. */
|
||||
@@ -361,7 +337,7 @@ async function panelControlHeights(page: Page) {
|
||||
.boundingBox())!.height,
|
||||
);
|
||||
return {
|
||||
newChat: await height("coach-panel-new-chat"),
|
||||
collapse: await height("coach-panel-collapse"),
|
||||
plans: await height("coach-panel-plans"),
|
||||
gear: await height("coach-settings"),
|
||||
row: await height("coach-history-select"),
|
||||
@@ -446,8 +422,10 @@ test.describe("Coach page frame", () => {
|
||||
// First visit: open.
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await waitForWidth(panel(page), 288);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(toggle(page)).toHaveAttribute(
|
||||
// Docked, the fold control lives in the panel; the top bar has none.
|
||||
await expect(toggle(page)).toHaveCount(0);
|
||||
await expect(collapse(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(collapse(page)).toHaveAttribute(
|
||||
"aria-controls",
|
||||
"coach-conversations-panel",
|
||||
);
|
||||
@@ -458,9 +436,7 @@ test.describe("Coach page frame", () => {
|
||||
const aside = (await panel(page).boundingBox())!;
|
||||
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
|
||||
await expectPanelFlush(page, width, 900);
|
||||
// Open, the toggle is the top bar's last item, against the panel.
|
||||
await expectToggleAtBarEnd(page);
|
||||
await expectNewChatInHeader(panel(page));
|
||||
await expectNewChatFab(page);
|
||||
// The bar says where the reader is: the page, then the conversation.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-top-bar-trail"]'),
|
||||
@@ -468,37 +444,25 @@ test.describe("Coach page frame", () => {
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-open`);
|
||||
|
||||
await toggle(page).click();
|
||||
// Folded: a 48 px edge stays, its button takes the focus, and the
|
||||
// top bar ends at the edge.
|
||||
await collapse(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await waitForWidth(panel(page), 0);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
|
||||
await waitForWidth(panel(page), 48);
|
||||
await expect(expand(page)).toBeFocused();
|
||||
await expect(expand(page)).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(expand(page)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Show conversations",
|
||||
);
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-history-select"]'),
|
||||
).toHaveCount(0);
|
||||
await expectPanelFlush(page, width, 900);
|
||||
const closed = await columnWidths(page);
|
||||
// Closed, the panel is entirely off-screen, the top bar runs to the
|
||||
// window's edge and the toggle is still its last item.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-slot="top-bar"]')!
|
||||
.getBoundingClientRect().right,
|
||||
),
|
||||
)
|
||||
.toBe(width);
|
||||
expect(
|
||||
await panel(page).evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.width === 0 || box.left >= window.innerWidth;
|
||||
}),
|
||||
"no pixel of the shut panel shows",
|
||||
).toBe(true);
|
||||
await expectToggleAtBarEnd(page);
|
||||
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
|
||||
// Closed, nothing inside the panel is reachable.
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-history-select"]').first(),
|
||||
).not.toBeInViewport();
|
||||
await expectNewChatFab(page);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-closed`);
|
||||
|
||||
@@ -506,19 +470,118 @@ test.describe("Coach page frame", () => {
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
|
||||
// Open again, then Escape from inside closes it, focus to the toggle.
|
||||
await toggle(page).click();
|
||||
// Opened from the keyboard, focus lands on the fold control; Escape
|
||||
// from inside folds it again and focus goes back to the edge.
|
||||
await expand(page).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(collapse(page)).toBeFocused();
|
||||
await panel(page)
|
||||
.locator('[data-slot="coach-history-select"]')
|
||||
.first()
|
||||
.focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(toggle(page)).toBeFocused();
|
||||
await expect(expand(page)).toBeFocused();
|
||||
});
|
||||
}
|
||||
|
||||
test("1440: the day and the conversations take turns, focus included", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await mockCoach(page);
|
||||
await mockDay(page);
|
||||
const day = "2026-06-01";
|
||||
await page.goto(`/coach?c=frame-bp&day=${day}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The day opens docked, right of the conversations, which fold to
|
||||
// their edge for it.
|
||||
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await waitForWidth(panel(page), 48);
|
||||
const rail = (await panel(page).boundingBox())!;
|
||||
const dayBox = (await dayPanel(page).boundingBox())!;
|
||||
expect(rail.x + rail.width).toBeLessThanOrEqual(dayBox.x + 1);
|
||||
expect(Math.round(dayBox.x + dayBox.width)).toBe(1440);
|
||||
await expectNewChatFab(page);
|
||||
expect(
|
||||
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
|
||||
.width,
|
||||
).toBeGreaterThanOrEqual(540);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, "coach-frame-1440-day");
|
||||
|
||||
// Opening the conversations folds the day to its edge; focus goes to
|
||||
// the conversations' fold control, the day is out of the URL, and the
|
||||
// conversation stays the same.
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(dayPanel(page)).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="day-rail"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
day,
|
||||
);
|
||||
await expect(collapse(page)).toBeFocused();
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-bp$/);
|
||||
await expectNewChatFab(page);
|
||||
await shot(page, testInfo, "coach-frame-1440-list");
|
||||
|
||||
// And back: the day's edge opens the day, the conversations fold.
|
||||
await page.locator('[data-slot="day-expand"]').click();
|
||||
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(page).toHaveURL(new RegExp(`c=frame-bp&day=${day}$`));
|
||||
|
||||
// Switching conversations keeps the day open.
|
||||
await expand(page).click();
|
||||
await page.locator('[data-slot="day-expand"]').click();
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
await fab(page).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/coach\\?day=${day}$`));
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("1920: the conversations and the day stay open side by side", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1920, height: 1000 });
|
||||
await mockCoach(page);
|
||||
await mockDay(page);
|
||||
await page.goto("/coach?c=frame-bp&day=2026-06-01", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await waitForWidth(panel(page), 288);
|
||||
const list = (await panel(page).boundingBox())!;
|
||||
const dayBox = (await dayPanel(page).boundingBox())!;
|
||||
expect(list.x + list.width).toBeLessThanOrEqual(dayBox.x + 1);
|
||||
expect(
|
||||
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
|
||||
.width,
|
||||
).toBeGreaterThanOrEqual(560);
|
||||
await expectNewChatFab(page);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, "coach-frame-1920-both");
|
||||
|
||||
// Folding one leaves the other alone.
|
||||
await collapse(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("1280 fine pointer: compact header buttons and rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -526,7 +589,7 @@ test.describe("Coach page frame", () => {
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
newChat: 28,
|
||||
collapse: 28,
|
||||
plans: 28,
|
||||
gear: 28,
|
||||
row: 36,
|
||||
@@ -546,7 +609,7 @@ test.describe("Coach page frame", () => {
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
newChat: 44,
|
||||
collapse: 44,
|
||||
plans: 44,
|
||||
gear: 44,
|
||||
row: 44,
|
||||
@@ -575,8 +638,7 @@ test.describe("Coach page frame", () => {
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
|
||||
await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
|
||||
|
||||
const newChat = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
await newChat.click();
|
||||
await fab(page).click();
|
||||
await expect(page).toHaveURL(/\/coach$/);
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-input-textarea"]'),
|
||||
@@ -795,6 +857,13 @@ test.describe("Coach page frame", () => {
|
||||
// Below 1280 px the panel waits to be asked.
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
|
||||
// New conversation sits above the composer, clear of its field.
|
||||
await expectNewChatFab(page);
|
||||
const fabBox = (await fab(page).boundingBox())!;
|
||||
const composerBox = (await page
|
||||
.locator('[data-slot="coach-page-composer"]')
|
||||
.boundingBox())!;
|
||||
expect(fabBox.y + fabBox.height).toBeLessThanOrEqual(composerBox.y);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, "coach-frame-390-thread");
|
||||
|
||||
@@ -802,7 +871,9 @@ test.describe("Coach page frame", () => {
|
||||
const sheet = page.getByRole("dialog", { name: "Conversations" });
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expectNewChatInHeader(sheet);
|
||||
await expect(
|
||||
sheet.locator('[data-slot="coach-panel-new-chat"]'),
|
||||
).toHaveCount(0);
|
||||
// The title keeps its whole word beside four header controls.
|
||||
const title = sheet.locator(
|
||||
'[data-slot="coach-conversations-panel-header"] h2',
|
||||
|
||||
+90
-1
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
import { expect, test } from "./setup/test";
|
||||
@@ -26,6 +26,8 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
* - the dashboard's today area offers no door; its charts below do.
|
||||
*
|
||||
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
|
||||
* the header's `day-date-button` and its `day-date-picker` (days keyed by
|
||||
* `data-date-key`, dotted by `data-entries`, and `day-date-today`),
|
||||
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
|
||||
* `day-next` / `day-close`, and the collapsed edge `day-rail` (+ `data-day`)
|
||||
* with its `day-expand`.
|
||||
@@ -33,6 +35,18 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
|
||||
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
|
||||
|
||||
async function shot(page: Page, testInfo: TestInfo, name: string) {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
const dir = process.env.DAY_SHOTS_DIR;
|
||||
if (dir) {
|
||||
await page.screenshot({
|
||||
path: `${dir}/${name}-${testInfo.project.name}.png`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isoDaysAgo(days: number): string {
|
||||
const d = new Date(Date.now() - days * 86_400_000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
@@ -189,6 +203,81 @@ test.describe("the day view", () => {
|
||||
await expect(rail).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the date opens a calendar; a picked day opens, Today goes to today", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const desktop = testInfo.project.name === "chromium-desktop";
|
||||
// The days of the month that hold anything carry a dot.
|
||||
await mockDay(page, { index: "full" });
|
||||
const day = isoDaysAgo(5);
|
||||
await page.goto(`/mood?day=${day}`);
|
||||
await expect(panel(page)).toHaveAttribute(
|
||||
"data-shell",
|
||||
desktop ? "docked" : "bottom",
|
||||
);
|
||||
const date = panel(page).locator('[data-slot="day-date-button"]');
|
||||
const picker = page.locator('[data-slot="day-date-picker"]');
|
||||
// The date stays one line in the header, the arrows beside it.
|
||||
await expect(date).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(panel(page).locator('[data-slot="day-prev"]')).toBeVisible();
|
||||
await shot(page, testInfo, "day-date-closed");
|
||||
|
||||
await date.click();
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(date).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(picker.locator(`[data-date-key="${day}"]`)).toHaveAttribute(
|
||||
"data-selected-single",
|
||||
"true",
|
||||
);
|
||||
await expect(picker.locator('[data-entries="true"]').first()).toBeVisible();
|
||||
// Nothing past today can be picked.
|
||||
const tomorrow = picker.locator(`[data-date-key="${isoDaysAgo(-2)}"]`);
|
||||
if ((await tomorrow.count()) > 0) await expect(tomorrow).toBeDisabled();
|
||||
await shot(page, testInfo, "day-date-open");
|
||||
|
||||
// Another day of the same month: the 1st, or the 2nd when the open day
|
||||
// is the 1st itself.
|
||||
const first = `${day.slice(0, 8)}01`;
|
||||
const target = first === day ? isoDaysAgo(4) : first;
|
||||
const start = await page.evaluate(() => window.history.length);
|
||||
await picker.locator(`[data-date-key="${target}"]`).click();
|
||||
await expect(picker).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
target,
|
||||
);
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]day=${target}`));
|
||||
// Like a step: no new history entry, and focus back on the date.
|
||||
expect(await page.evaluate(() => window.history.length)).toBe(start);
|
||||
await expect(date).toBeFocused();
|
||||
|
||||
// The keyboard: Enter opens, Escape closes the calendar only.
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(picker).toBeVisible();
|
||||
// The calendar has the keyboard before Escape is pressed.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
document.activeElement?.closest(
|
||||
'[role="dialog"]:not([data-slot="day-panel"])',
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(picker).toHaveCount(0);
|
||||
await expect(panel(page)).toBeVisible();
|
||||
await expect(date).toBeFocused();
|
||||
|
||||
// Today.
|
||||
await page.keyboard.press("Space");
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.locator('[data-slot="day-date-today"]').click();
|
||||
await expect(picker).toHaveCount(0);
|
||||
await expect(panel(page).locator('[data-slot="day-next"]')).toBeDisabled();
|
||||
});
|
||||
|
||||
test("a sheet closes for good and leaves no edge behind", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -427,7 +427,7 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
// The PDF is refused locally (tesseract reads images only) — the error
|
||||
// shows and the server suggest route is never called.
|
||||
await expect(
|
||||
sheet.getByText("Local OCR reads images only — not this file."),
|
||||
sheet.getByText("Local OCR reads images only, not this file."),
|
||||
).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
expect(suggestCalls).toBe(0);
|
||||
|
||||
@@ -771,7 +771,7 @@ test.describe("document vault", () => {
|
||||
|
||||
await uploadViaPicker(page, `${marker}-copy.pdf`, bytes);
|
||||
await expect(
|
||||
page.getByText("Already stored — highlighting the existing document."),
|
||||
page.getByText("Already stored. The existing document is highlighted."),
|
||||
).toBeVisible();
|
||||
// One row, not two — the copy never landed.
|
||||
await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible();
|
||||
|
||||
Reference in new issue
Block a user