import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { DiveProfileCard } from "./dive-profile-card"; import type { DiveProfile, Recording } from "@/lib/api/dives"; import { dive } from "@/test/dive-fixtures"; // The chart itself has its own suite. What only this card can answer is which // recording's samples are on screen - a question that did not exist while a dive // had one profile, and the one thing a second computer changes about this page. vi.mock("@/lib/api/dives", async (importOriginal) => ({ ...(await importOriginal()), divesAPI: { getRecordingProfile: vi.fn() }, })); // The card's description reads the diver's units, so it needs an auth context. vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => ({ user: { uuid: "user-1", units: "metric" } }), })); // The chart draws SVG against measured geometry, which jsdom has none of. This // suite is about the switcher above it, and where the card gives the chart's // menu a place - which the stand-in fills the way the chart does, by portal - and // which samples it hands the chart to draw and to scale with, which the stand-in // names by the `duration` each fixture profile is told apart by, and where on the // dive's shared time axis it is told to draw them. vi.mock("./dive-profile-chart", async () => { const { createPortal } = await import("react-dom"); return { DiveProfileChart: ({ profile, scaleWith = [], axis, menuContainer, }: { profile: DiveProfile; scaleWith?: readonly DiveProfile[]; axis?: { start: number; end: number } | null; menuContainer: HTMLElement | null; }) => (
peer.duration).join(",")} data-axis={axis ? `${axis.start}-${axis.end}` : "own"} > {menuContainer && createPortal(, menuContainer)}
), }; }); const { divesAPI } = await import("@/lib/api/dives"); function profileInfo(uuid: string) { return { uuid, duration: 2_940_000, depth_sample_count: 314, // This card draws samples and never asks where they came from, so every // fixture here is the ordinary file-backed case. provenance: "file" as const, channels: ["depth"], }; } function recording(overrides: Partial = {}): Recording { return { uuid: "r1", ordinal: 0, files: [], ...overrides }; } const twoComputers = dive({ recordings: [ recording({ uuid: "perdix", ordinal: 0, device: { model: "Perdix 3", serial: "D9772626" }, profile: profileInfo("p-perdix"), }), recording({ uuid: "ocean", ordinal: 1, device: { brand: "Suunto", serial: "253810000400", name: "Porvoo" }, profile: profileInfo("p-ocean"), }), ], }); beforeEach(() => { vi.clearAllMocks(); vi.mocked(divesAPI.getRecordingProfile).mockResolvedValue({ duration: 2_940_000, pressures: [], events: [], } as unknown as DiveProfile); }); describe("DiveProfileCard", () => { it("renders nothing for a dive whose recordings carried no samples", () => { const { container } = render( , ); expect(container).toBeEmptyDOMElement(); }); it("asks the recording route for the primary's samples", async () => { render( , ); await waitFor(() => expect(divesAPI.getRecordingProfile).toHaveBeenCalledWith( "d1", "r1", expect.stringContaining("p1"), ), ); }); it("puts the chart's menu on the title row, and the recording's summary at the foot", async () => { render( , ); const menu = await screen.findByRole("button", { name: "Channels" }); const heading = screen.getByRole("heading", { name: "Dive Profile" }); expect(heading.parentElement).toContainElement(menu); const summary = screen.getByText(/314 depth samples recorded/); expect( screen.getByTestId("profile-chart").compareDocumentPosition(summary) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); }); it("states the heart rate's maximum and average where the profile has them", () => { render( , ); expect( screen.getByText( "314 depth samples recorded by the dive computer, a heart rate peaking at 107 bpm and averaging 77 bpm.", ), ).toBeInTheDocument(); }); it("says nothing of a heart rate the profile does not carry", () => { render( , ); expect( screen.getByText("314 depth samples recorded by the dive computer."), ).toBeInTheDocument(); expect(screen.queryByText(/heart rate/)).not.toBeInTheDocument(); }); it("offers no switcher on a dive one computer recorded", () => { // One button labelled with the only device there is says nothing and takes // a row of the card to say it. render( , ); expect( screen.queryByTestId("profile-recording-switcher"), ).not.toBeInTheDocument(); }); it("switches between two computers' curves, labelled by device", async () => { render(); await waitFor(() => expect(divesAPI.getRecordingProfile).toHaveBeenCalledWith( "d1", "perdix", expect.anything(), ), ); await userEvent.click( screen.getByRole("button", { name: "Suunto ยท 253810000400 (Porvoo)" }), ); await waitFor(() => expect(divesAPI.getRecordingProfile).toHaveBeenCalledWith( "d1", "ocean", expect.anything(), ), ); }); it("marks which recording is on screen, for a reader who cannot see the fill", async () => { render(); const perdix = screen.getByRole("button", { name: /Perdix 3/ }); const ocean = screen.getByRole("button", { name: /Porvoo/ }); expect(perdix).toHaveAttribute("aria-pressed", "true"); expect(ocean).toHaveAttribute("aria-pressed", "false"); await userEvent.click(ocean); expect(ocean).toHaveAttribute("aria-pressed", "true"); expect(perdix).toHaveAttribute("aria-pressed", "false"); }); it("skips a recording with no samples rather than offering a button that 404s", () => { // A recording whose files carried none has nothing to draw, and the route // answers 404 permanently - so a button for it is a dead end with a device // name on it. render( , ); expect( screen.queryByTestId("profile-recording-switcher"), ).not.toBeInTheDocument(); expect( screen.queryByRole("button", { name: /Suunto/ }), ).not.toBeInTheDocument(); }); it("falls back to the first charted recording when the chosen one goes", async () => { // Deleting a recording, or promoting another, reorders the list. Holding the // choice by uuid rather than by index is what stops the card quietly drawing // a different device's curves under the old selection. const { rerender } = render(); await userEvent.click(screen.getByRole("button", { name: /Porvoo/ })); await waitFor(() => expect(divesAPI.getRecordingProfile).toHaveBeenCalledWith( "d1", "ocean", expect.anything(), ), ); rerender( , ); await waitFor(() => expect(divesAPI.getRecordingProfile).toHaveBeenLastCalledWith( "d1", "perdix", expect.anything(), ), ); }); it("fetches every computer once, and scales each with the other", async () => { // A duration per recording, so the stand-in can say whose samples it got. const durations: Record = { perdix: 1, ocean: 2 }; vi.mocked(divesAPI.getRecordingProfile).mockImplementation( async (_dive, recordingUuid) => ({ duration: durations[recordingUuid], pressures: [], events: [], }) as unknown as DiveProfile, ); render(); const chart = await screen.findByTestId("profile-chart"); expect(chart).toHaveAttribute("data-shown", "1"); expect(chart).toHaveAttribute("data-scale-with", "2"); await userEvent.click(screen.getByRole("button", { name: /Porvoo/ })); const switched = screen.getByTestId("profile-chart"); expect(switched).toHaveAttribute("data-shown", "2"); expect(switched).toHaveAttribute("data-scale-with", "1"); expect(divesAPI.getRecordingProfile).toHaveBeenCalledTimes(2); }); it("still draws the shown computer when the other's samples fail", async () => { vi.mocked(divesAPI.getRecordingProfile).mockImplementation( async (_dive, recordingUuid) => { if (recordingUuid === "ocean") throw new Error("network"); return { duration: 1, pressures: [], events: [], } as unknown as DiveProfile; }, ); vi.spyOn(console, "error").mockImplementation(() => {}); render(); const chart = await screen.findByTestId("profile-chart"); expect(chart).toHaveAttribute("data-shown", "1"); expect(chart).toHaveAttribute("data-scale-with", ""); }); describe("on the dive's shared time axis", () => { // The example the placement was built for: the Suunto switched on a minute // and a half before the Perdix, each recording about 49 minutes. const placedAt = (perdix: number | null, ocean?: number | null) => dive({ recordings: twoComputers.recordings?.map((entry) => ({ ...entry, start_on_dive_axis: entry.uuid === "perdix" ? perdix : ocean, })), }); const durations: Record = { perdix: 2_940_000, ocean: 3_000_000, }; beforeEach(() => { vi.mocked(divesAPI.getRecordingProfile).mockImplementation( async (_dive, recordingUuid) => ({ duration: durations[recordingUuid], pressures: [], events: [], }) as unknown as DiveProfile, ); }); it("hands the chart each recording's place and one extent for both", async () => { render(); const chart = await screen.findByTestId("profile-chart"); expect(chart).toHaveAttribute("data-axis", "93520-3033520"); await userEvent.click(screen.getByRole("button", { name: /Porvoo/ })); expect(screen.getByTestId("profile-chart")).toHaveAttribute( "data-axis", "0-3033520", ); }); it("leaves a recording the API could not place on its own axis", async () => { render(); const chart = await screen.findByTestId("profile-chart"); // The placed one's extent stops at its own end: the other draws nowhere // on this axis. expect(chart).toHaveAttribute("data-axis", "0-2940000"); await userEvent.click(screen.getByRole("button", { name: /Porvoo/ })); expect(screen.getByTestId("profile-chart")).toHaveAttribute( "data-axis", "own", ); }); it("charts every recording on its own axis where the API sent no placement", async () => { render(); expect(await screen.findByTestId("profile-chart")).toHaveAttribute( "data-axis", "own", ); }); it("stops widening the axis for a placed peer whose samples failed", async () => { vi.mocked(divesAPI.getRecordingProfile).mockImplementation( async (_dive, recordingUuid) => { if (recordingUuid === "ocean") throw new Error("network"); return { duration: durations[recordingUuid], pressures: [], events: [], } as unknown as DiveProfile; }, ); vi.spyOn(console, "error").mockImplementation(() => {}); render(); expect(await screen.findByTestId("profile-chart")).toHaveAttribute( "data-axis", "0-2940000", ); }); }); });