mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(segmented): drive the control in a real browser
Roving focus, Home/End, wrap, toggle mode, disabled and the chosen segment's fill are now asserted through key presses and computed styles instead of markup greps. The static render test is deleted; its two source sweeps move to segmented-single-implementation.test.ts. The browser setup sets the default theme class so colour tokens resolve, and pre-bundles the browser files' dependencies so a cold cache does not reload the page mid-run.
This commit is contained in:
1 parent
3d1692204b
commit
ca1cceb3a9
7 files changed
+305
-243
No files matched your search
@@ -101,7 +101,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s
|
||||
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
|
||||
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
|
||||
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
|
||||
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
|
||||
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented-single-implementation.test.ts` holds the definition to one module) |
|
||||
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`): tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
|
||||
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`): caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
|
||||
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 360,
|
||||
"total": 360,
|
||||
"count": 359,
|
||||
"total": 359,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 1,
|
||||
"__tests__/admin-data-wipe-completeness.test.ts": 1,
|
||||
@@ -264,7 +264,6 @@
|
||||
"components/ui/__tests__/card-density.test.tsx": 1,
|
||||
"components/ui/__tests__/card-header-rows.test.ts": 1,
|
||||
"components/ui/__tests__/filter-bar.test.tsx": 1,
|
||||
"components/ui/__tests__/segmented.test.tsx": 1,
|
||||
"hooks/__tests__/use-insight-status.test.ts": 1,
|
||||
"lib/__tests__/geo-offline-detection.test.ts": 1,
|
||||
"lib/__tests__/query-keys.test.ts": 1,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Segmented is the one segmented control in the tree. Its behaviour (roving
|
||||
* focus, toggle mode, disabled, the chosen segment's fill) is driven in a real
|
||||
* browser by `segmented.browser.test.tsx`; this file holds the source sweeps
|
||||
* that keep a second control from appearing beside it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("one segmented control", () => {
|
||||
it("is the only segmented-control component in the tree", async () => {
|
||||
const { join } = await import("node:path");
|
||||
const { readSourceText, walkSourceFiles } =
|
||||
await import("@/__tests__/helpers/source-files");
|
||||
const src = join(__dirname, "../../..");
|
||||
const defining = walkSourceFiles(src, { floor: 2000 })
|
||||
.filter((p) => !p.includes("__tests__/"))
|
||||
.filter((p) =>
|
||||
/export function (?:Segmented\w*|ViewToggle|\w*SegmentedToggle)\b/.test(
|
||||
readSourceText(join(src, p)),
|
||||
),
|
||||
);
|
||||
expect(defining).toEqual(["components/ui/segmented.tsx"]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A row of buttons whose `aria-pressed` compares a state to each button's
|
||||
* value is a segmented control spelled by hand: six of them sat beside
|
||||
* `Segmented` (the narrative period, the lab result type, both admin filters,
|
||||
* the chart range tabs, the mood chart's copy of them), each with its own
|
||||
* size, fill and gap. New ones are refused; the ones that remain are not one
|
||||
* choice on one track, and say why.
|
||||
*/
|
||||
describe("no hand-built segmented control", () => {
|
||||
const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g;
|
||||
|
||||
const NOT_A_SEGMENT: Record<string, string> = {
|
||||
"components/settings/security-section/passkey-list-section.tsx":
|
||||
"one method is disabled while the account has no passkey; Segmented disables the whole track",
|
||||
"components/medications/units-per-dose-field.tsx":
|
||||
"a chip row that opens a free-number field for any other count",
|
||||
"components/body-sites/body-sites-section.tsx":
|
||||
"wrapping side chips under a body-site picker, not a track",
|
||||
"components/illness/log-day-sheet.tsx":
|
||||
"a graded severity scale per symptom, one square per level",
|
||||
"components/cycle/log-day-sheet.tsx":
|
||||
"a graded severity scale per symptom, one circle per level",
|
||||
"components/charts/chart-overlay-controls.tsx":
|
||||
"baselines stay pressable while greyed, with a tooltip saying why there is no data",
|
||||
"components/timeline/timeline-view.tsx":
|
||||
"a single button that opens the range sheet, beside the zoom Segmented",
|
||||
};
|
||||
|
||||
function pressedByValue(source: string): number {
|
||||
return (source.match(PRESSED_BY_VALUE) ?? []).length;
|
||||
}
|
||||
|
||||
it("finds none outside the reasoned exceptions", async () => {
|
||||
const { join } = await import("node:path");
|
||||
const { readSourceText, stripComments, walkSourceFiles } =
|
||||
await import("@/__tests__/helpers/source-files");
|
||||
const src = join(__dirname, "../../..");
|
||||
const found = walkSourceFiles(src, { floor: 2000 })
|
||||
.filter((p) => /^(components|app)\//.test(p))
|
||||
.filter((p) => !p.includes("__tests__/"))
|
||||
.filter((p) => p !== "components/ui/segmented.tsx")
|
||||
.filter(
|
||||
(p) => pressedByValue(stripComments(readSourceText(join(src, p)))) > 0,
|
||||
)
|
||||
.sort();
|
||||
expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort());
|
||||
});
|
||||
|
||||
it("counter-check: catches a hand-built pair, not an on/off toggle", () => {
|
||||
expect(
|
||||
pressedByValue(`<Button aria-pressed={filter === "all"}>All</Button>
|
||||
<Button aria-pressed={filter === "failed"}>Failed</Button>`),
|
||||
).toBe(2);
|
||||
expect(pressedByValue(`<Button aria-pressed={open}>Show</Button>`)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { LayoutGrid, Table } from "lucide-react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { userEvent } from "vitest/browser";
|
||||
|
||||
import { renderWithApp } from "../../../../tests/browser/render";
|
||||
import { Segmented } from "../segmented";
|
||||
import { Segmented, type SegmentedProps } from "../segmented";
|
||||
|
||||
type Zoom = "day" | "week" | "month";
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "day", label: "Day" },
|
||||
@@ -10,22 +14,216 @@ const OPTIONS = [
|
||||
{ value: "month", label: "Month" },
|
||||
] as const;
|
||||
|
||||
describe("<Segmented> smoke", () => {
|
||||
it("moves with the arrow key", async () => {
|
||||
const onChange = vi.fn();
|
||||
/**
|
||||
* The control as a caller holds it: the value lives in the parent and moves
|
||||
* only when `onChange` says so, which is what makes the roving tab stop
|
||||
* follow the selection.
|
||||
*/
|
||||
function Controlled({
|
||||
initial = "day",
|
||||
onChange,
|
||||
...props
|
||||
}: Partial<Omit<SegmentedProps<Zoom>, "value">> & { initial?: Zoom }) {
|
||||
const [value, setValue] = useState<Zoom>(initial);
|
||||
return (
|
||||
<>
|
||||
<button type="button">Before</button>
|
||||
<Segmented
|
||||
options={OPTIONS}
|
||||
label="Zoom"
|
||||
slot="zoom"
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
onChange?.(next);
|
||||
setValue(next);
|
||||
}}
|
||||
/>
|
||||
<button type="button">After</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function mount(props: Parameters<typeof Controlled>[0] = {}) {
|
||||
const onChange = vi.fn();
|
||||
const { screen } = await renderWithApp(
|
||||
<Controlled onChange={onChange} {...props} />,
|
||||
);
|
||||
const radio = (name: string) => screen.getByRole("radio", { name });
|
||||
return { screen, onChange, radio };
|
||||
}
|
||||
|
||||
describe("<Segmented> in radio mode", () => {
|
||||
it("is one tab stop, on the chosen segment", async () => {
|
||||
const { screen, radio } = await mount({ initial: "week" });
|
||||
await expect
|
||||
.element(screen.getByRole("radiogroup", { name: "Zoom" }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(radio("Week")).toBeChecked();
|
||||
await expect.element(radio("Day")).not.toBeChecked();
|
||||
|
||||
await screen.getByRole("button", { name: "Before" }).click();
|
||||
await userEvent.tab();
|
||||
await expect.element(radio("Week")).toHaveFocus();
|
||||
await userEvent.tab();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "After" }))
|
||||
.toHaveFocus();
|
||||
});
|
||||
|
||||
it("moves focus and selection together with the arrow keys", async () => {
|
||||
const { onChange, radio } = await mount();
|
||||
await radio("Day").click();
|
||||
onChange.mockClear();
|
||||
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
await expect.element(radio("Week")).toHaveFocus();
|
||||
await expect.element(radio("Week")).toBeChecked();
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await expect.element(radio("Month")).toBeChecked();
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
await expect.element(radio("Week")).toHaveFocus();
|
||||
expect(onChange.mock.calls).toEqual([["week"], ["month"], ["week"]]);
|
||||
});
|
||||
|
||||
it("wraps at both ends", async () => {
|
||||
const { radio } = await mount({ initial: "month" });
|
||||
await radio("Month").click();
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
await expect.element(radio("Day")).toHaveFocus();
|
||||
await expect.element(radio("Day")).toBeChecked();
|
||||
await userEvent.keyboard("{ArrowUp}");
|
||||
await expect.element(radio("Month")).toHaveFocus();
|
||||
await expect.element(radio("Month")).toBeChecked();
|
||||
});
|
||||
|
||||
it("jumps to the first and last segment with Home and End", async () => {
|
||||
const { onChange, radio } = await mount({ initial: "week" });
|
||||
await radio("Week").click();
|
||||
await userEvent.keyboard("{End}");
|
||||
await expect.element(radio("Month")).toHaveFocus();
|
||||
await userEvent.keyboard("{Home}");
|
||||
await expect.element(radio("Day")).toHaveFocus();
|
||||
await expect.element(radio("Day")).toBeChecked();
|
||||
expect(onChange).toHaveBeenLastCalledWith("day");
|
||||
});
|
||||
|
||||
it("fires again on the chosen segment, which can open something", async () => {
|
||||
const { onChange, radio } = await mount({ initial: "day" });
|
||||
await radio("Day").click();
|
||||
await radio("Day").click();
|
||||
expect(onChange.mock.calls).toEqual([["day"], ["day"]]);
|
||||
});
|
||||
|
||||
it("takes no press and no key while disabled", async () => {
|
||||
const { screen, onChange, radio } = await mount({ disabled: true });
|
||||
await expect
|
||||
.element(screen.getByRole("radiogroup", { name: "Zoom" }))
|
||||
.toHaveAttribute("aria-disabled", "true");
|
||||
await expect.element(radio("Week")).toBeDisabled();
|
||||
await radio("Week").click({ force: true });
|
||||
await screen.getByRole("button", { name: "Before" }).click();
|
||||
await userEvent.tab();
|
||||
await userEvent.keyboard("{ArrowRight}{End}");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
await expect.element(radio("Day")).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("<Segmented> in toggle mode", () => {
|
||||
it("renders pressed buttons and ignores a press on the active one", async () => {
|
||||
const { screen, onChange } = await mount({ mode: "toggle" });
|
||||
await expect
|
||||
.element(screen.getByRole("group", { name: "Zoom" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("radio").elements()).toHaveLength(0);
|
||||
const day = screen.getByRole("button", { name: "Day" });
|
||||
const week = screen.getByRole("button", { name: "Week" });
|
||||
await expect.element(day).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await day.click();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
await week.click();
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith("week");
|
||||
await expect.element(week).toHaveAttribute("aria-pressed", "true");
|
||||
await expect.element(day).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("leaves every segment its own tab stop and the arrows alone", async () => {
|
||||
const { screen, onChange } = await mount({ mode: "toggle" });
|
||||
await screen.getByRole("button", { name: "Day" }).click();
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
await userEvent.tab();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Week" }))
|
||||
.toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe("<Segmented> look", () => {
|
||||
it("fills only the chosen segment in the primary colour, on no track", async () => {
|
||||
const { screen, radio } = await mount({ initial: "week" });
|
||||
const group = screen.getByRole("radiogroup").element();
|
||||
const probe = document.createElement("span");
|
||||
probe.className = "bg-primary";
|
||||
document.body.append(probe);
|
||||
const primary = getComputedStyle(probe).backgroundColor;
|
||||
probe.remove();
|
||||
|
||||
const background = (name: string) =>
|
||||
getComputedStyle(radio(name).element()).backgroundColor;
|
||||
expect(background("Week")).toBe(primary);
|
||||
expect(background("Day")).not.toBe(primary);
|
||||
expect(getComputedStyle(group).backgroundColor).toBe("rgba(0, 0, 0, 0)");
|
||||
});
|
||||
|
||||
it("sets the small size in smaller type and keeps the 36 px floor", async () => {
|
||||
const { radio } = await mount();
|
||||
const md = radio("Day").element();
|
||||
expect(getComputedStyle(md).fontSize).toBe("14px");
|
||||
expect(md.getBoundingClientRect().height).toBeGreaterThanOrEqual(36);
|
||||
|
||||
const { screen } = await renderWithApp(
|
||||
<Segmented
|
||||
options={OPTIONS}
|
||||
value="day"
|
||||
onChange={onChange}
|
||||
label="Zoom"
|
||||
onChange={() => {}}
|
||||
label="Range"
|
||||
size="sm"
|
||||
/>,
|
||||
);
|
||||
await screen.getByRole("radio", { name: "Day" }).click();
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(onChange).toHaveBeenLastCalledWith("week");
|
||||
const sm = screen
|
||||
.getByRole("radiogroup", { name: "Range" })
|
||||
.getByRole("radio", { name: "Day" })
|
||||
.element();
|
||||
expect(getComputedStyle(sm).fontSize).toBe("12px");
|
||||
expect(sm.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
it("names icon-only segments without showing the label", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { screen } = await renderWithApp(
|
||||
<Segmented
|
||||
mode="toggle"
|
||||
iconOnly
|
||||
options={[
|
||||
{ value: "cards", label: "Cards", icon: LayoutGrid },
|
||||
{ value: "table", label: "Table", icon: Table },
|
||||
]}
|
||||
value="cards"
|
||||
onChange={onChange}
|
||||
label="View"
|
||||
optionSlot={(v) => `medications-view-${v}`}
|
||||
/>,
|
||||
);
|
||||
const table = screen.getByRole("button", { name: "Table" });
|
||||
await expect.element(table).toHaveAttribute("title", "Table");
|
||||
await expect
|
||||
.element(screen.getByRole("radio", { name: "Week" }))
|
||||
.toHaveFocus();
|
||||
.element(table)
|
||||
.toHaveAttribute("data-slot", "medications-view-table");
|
||||
await expect.element(screen.getByText("Cards")).not.toBeInTheDocument();
|
||||
await table.click();
|
||||
expect(onChange).toHaveBeenCalledWith("table");
|
||||
});
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { LayoutGrid, Table } from "lucide-react";
|
||||
|
||||
import { Segmented, type SegmentedProps } from "../segmented";
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "recent", label: "Last 90 days" },
|
||||
{ value: "all", label: "All" },
|
||||
] as const;
|
||||
|
||||
type Props = SegmentedProps<"recent" | "all">;
|
||||
|
||||
function html(props: Partial<Props> = {}) {
|
||||
return renderToStaticMarkup(
|
||||
<Segmented
|
||||
options={OPTIONS}
|
||||
value="recent"
|
||||
onChange={() => {}}
|
||||
label="History"
|
||||
slot="history-view"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The segment buttons of a rendered tree, for driving their handlers. */
|
||||
function buttons(node: ReactNode): ReactElement<{ onClick: () => void }>[] {
|
||||
const out: ReactElement<{ onClick: () => void }>[] = [];
|
||||
const walk = (n: ReactNode) => {
|
||||
if (Array.isArray(n)) return n.forEach(walk);
|
||||
if (!isValidElement(n)) return;
|
||||
const el = n as ReactElement<{ children?: ReactNode; onClick: () => void }>;
|
||||
if (el.type === "button") out.push(el);
|
||||
walk(el.props.children);
|
||||
};
|
||||
walk(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/use-roving-radio-group", () => ({
|
||||
useRovingRadioGroup: () => ({
|
||||
getRadioProps: (i: number) => ({ tabIndex: i === 0 ? 0 : -1 }),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<Segmented>", () => {
|
||||
it("is a radiogroup with one checked radio by default", () => {
|
||||
const out = html();
|
||||
expect(out).toContain('role="radiogroup"');
|
||||
expect(out).toContain('aria-label="History"');
|
||||
expect(out.match(/role="radio"/g)?.length).toBe(2);
|
||||
expect(out.match(/aria-checked="true"/g)?.length).toBe(1);
|
||||
expect(out).toContain('data-slot="history-view-all"');
|
||||
});
|
||||
|
||||
it("gives every segment the 44 px floor on a coarse pointer", () => {
|
||||
const out = html();
|
||||
expect(out.match(/pointer-coarse:min-h-11/g)?.length).toBe(2);
|
||||
expect(out).toContain("min-h-9");
|
||||
});
|
||||
|
||||
it("fills the chosen segment in the primary colour, on no track", () => {
|
||||
const out = html();
|
||||
const segments = out.match(/<button[^>]*>/g) ?? [];
|
||||
expect(segments).toHaveLength(2);
|
||||
const chosen = segments.filter((b) => b.includes('aria-checked="true"'));
|
||||
expect(chosen).toHaveLength(1);
|
||||
expect(chosen[0]).toContain("bg-primary text-primary-foreground");
|
||||
const other = segments.find((b) => b.includes('aria-checked="false"'));
|
||||
expect(other).not.toContain("bg-primary");
|
||||
const group = out.match(/<div[^>]*role="radiogroup"[^>]*>/)?.[0];
|
||||
expect(group).not.toContain("bg-muted");
|
||||
});
|
||||
|
||||
it("sets a chart header's range row in the small type", () => {
|
||||
expect(html()).toContain("text-sm");
|
||||
const small = html({ size: "sm" });
|
||||
expect(small).toContain("text-xs");
|
||||
expect(small).not.toContain("text-sm");
|
||||
expect(small.match(/min-h-11/g)?.length).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps a disabled chosen segment on the readable token pair", () => {
|
||||
const chosen = html({ disabled: true })
|
||||
.match(/<button[^>]*>/g)
|
||||
?.find((b) => b.includes('aria-checked="true"'));
|
||||
expect(chosen).toContain("disabled:bg-primary-disabled");
|
||||
expect(chosen).toContain("disabled:opacity-100");
|
||||
});
|
||||
|
||||
it("renders pressed buttons in toggle mode", () => {
|
||||
const out = html({ mode: "toggle" });
|
||||
expect(out).toContain('role="group"');
|
||||
expect(out).not.toContain('role="radio"');
|
||||
expect(out).toContain('aria-pressed="true"');
|
||||
expect(out).toContain('aria-pressed="false"');
|
||||
});
|
||||
|
||||
it("names icon-only segments and sizes them square", () => {
|
||||
const out = renderToStaticMarkup(
|
||||
<Segmented
|
||||
mode="toggle"
|
||||
iconOnly
|
||||
options={[
|
||||
{ value: "cards", label: "Cards", icon: LayoutGrid },
|
||||
{ value: "table", label: "Table", icon: Table },
|
||||
]}
|
||||
value="cards"
|
||||
onChange={() => {}}
|
||||
label="View"
|
||||
optionSlot={(v) => `medications-view-${v}`}
|
||||
/>,
|
||||
);
|
||||
expect(out).toContain('aria-label="Cards"');
|
||||
expect(out).toContain('title="Table"');
|
||||
expect(out.match(/pointer-coarse:size-11/g)?.length).toBe(2);
|
||||
expect(out).toContain('data-slot="medications-view-table"');
|
||||
expect(out).not.toContain(">Cards<");
|
||||
});
|
||||
|
||||
it("re-fires the chosen segment in radio mode, which can open something", () => {
|
||||
const onChange = vi.fn();
|
||||
const tree = Segmented({
|
||||
options: OPTIONS,
|
||||
value: "recent",
|
||||
onChange,
|
||||
label: "History",
|
||||
});
|
||||
buttons(tree)[0].props.onClick();
|
||||
expect(onChange).toHaveBeenCalledWith("recent");
|
||||
});
|
||||
|
||||
it("ignores a press on the active segment in toggle mode", () => {
|
||||
const onChange = vi.fn();
|
||||
const tree = Segmented({
|
||||
options: OPTIONS,
|
||||
value: "recent",
|
||||
onChange,
|
||||
label: "History",
|
||||
mode: "toggle",
|
||||
});
|
||||
const [active, other] = buttons(tree);
|
||||
active.props.onClick();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
other.props.onClick();
|
||||
expect(onChange).toHaveBeenCalledWith("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("one segmented control", () => {
|
||||
it("is the only segmented-control component in the tree", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { walkSourceFiles } =
|
||||
await import("@/__tests__/helpers/source-files");
|
||||
const src = join(__dirname, "../../..");
|
||||
const defining = walkSourceFiles(src, { floor: 2000 })
|
||||
.filter((p) => !p.includes("__tests__/"))
|
||||
.filter((p) =>
|
||||
/export function (?:Segmented\w*|ViewToggle|\w*SegmentedToggle)\b/.test(
|
||||
readFileSync(join(src, p), "utf8"),
|
||||
),
|
||||
);
|
||||
expect(defining).toEqual(["components/ui/segmented.tsx"]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A row of buttons whose `aria-pressed` compares a state to each button's
|
||||
* value is a segmented control spelled by hand: six of them sat beside
|
||||
* `Segmented` (the narrative period, the lab result type, both admin filters,
|
||||
* the chart range tabs, the mood chart's copy of them), each with its own
|
||||
* size, fill and gap. New ones are refused; the ones that remain are not one
|
||||
* choice on one track, and say why.
|
||||
*/
|
||||
describe("no hand-built segmented control", () => {
|
||||
const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g;
|
||||
|
||||
const NOT_A_SEGMENT: Record<string, string> = {
|
||||
"components/settings/security-section/passkey-list-section.tsx":
|
||||
"one method is disabled while the account has no passkey; Segmented disables the whole track",
|
||||
"components/medications/units-per-dose-field.tsx":
|
||||
"a chip row that opens a free-number field for any other count",
|
||||
"components/body-sites/body-sites-section.tsx":
|
||||
"wrapping side chips under a body-site picker, not a track",
|
||||
"components/illness/log-day-sheet.tsx":
|
||||
"a graded severity scale per symptom, one square per level",
|
||||
"components/cycle/log-day-sheet.tsx":
|
||||
"a graded severity scale per symptom, one circle per level",
|
||||
"components/charts/chart-overlay-controls.tsx":
|
||||
"baselines stay pressable while greyed, with a tooltip saying why there is no data",
|
||||
"components/timeline/timeline-view.tsx":
|
||||
"a single button that opens the range sheet, beside the zoom Segmented",
|
||||
};
|
||||
|
||||
function pressedByValue(source: string): number {
|
||||
return (source.match(PRESSED_BY_VALUE) ?? []).length;
|
||||
}
|
||||
|
||||
it("finds none outside the reasoned exceptions", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { stripComments, walkSourceFiles } =
|
||||
await import("@/__tests__/helpers/source-files");
|
||||
const src = join(__dirname, "../../..");
|
||||
const found = walkSourceFiles(src, { floor: 2000 })
|
||||
.filter((p) => /^(components|app)\//.test(p))
|
||||
.filter((p) => !p.includes("__tests__/"))
|
||||
.filter((p) => p !== "components/ui/segmented.tsx")
|
||||
.filter(
|
||||
(p) =>
|
||||
pressedByValue(stripComments(readFileSync(join(src, p), "utf8"))) > 0,
|
||||
)
|
||||
.sort();
|
||||
expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort());
|
||||
});
|
||||
|
||||
it("counter-check: catches a hand-built pair, not an on/off toggle", () => {
|
||||
expect(
|
||||
pressedByValue(`<Button aria-pressed={filter === "all"}>All</Button>
|
||||
<Button aria-pressed={filter === "failed"}>Failed</Button>`),
|
||||
).toBe(2);
|
||||
expect(pressedByValue(`<Button aria-pressed={open}>Show</Button>`)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@
|
||||
* Setup for the `browser` project (`*.browser.test.tsx`, headless Chromium).
|
||||
*
|
||||
* - The app stylesheet, compiled by Vite through the project's PostCSS
|
||||
* config, so components lay out and hide as they do in the app.
|
||||
* config, so components lay out and hide as they do in the app, under the
|
||||
* theme class the boot script sets for a first visit (`dark`, see
|
||||
* `src/lib/pwa/theme-color.ts`); without one no colour token resolves.
|
||||
* - The EN catalog, the fallback floor of every `t()` chain, loaded through
|
||||
* the production loader. `renderWithApp` loads any other locale it is
|
||||
* asked for.
|
||||
@@ -19,6 +21,8 @@ import { loadMessages } from "@/lib/i18n/load-locale";
|
||||
import { restoreFetch } from "./fetch-stub";
|
||||
import { rememberAddress, restoreAddress } from "./url";
|
||||
|
||||
document.documentElement.classList.add("dark");
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadMessages("en");
|
||||
});
|
||||
|
||||
@@ -129,6 +129,13 @@ export default defineConfig({
|
||||
),
|
||||
},
|
||||
},
|
||||
// Pre-bundle every dependency the browser files reach before the
|
||||
// first one loads. Discovered late, a dependency makes Vite re-bundle
|
||||
// and reload the page mid-run, which fails the file that was
|
||||
// importing it; CI starts every run on an empty cache.
|
||||
optimizeDeps: {
|
||||
entries: ["src/**/*.browser.test.tsx", "tests/browser/**/*.{ts,tsx}"],
|
||||
},
|
||||
test: {
|
||||
name: "browser",
|
||||
include: ["src/**/*.browser.test.tsx"],
|
||||
|
||||
Reference in new issue
Block a user