src/components/dives/dive-profile-card.render.test.tsx
Rama main · réplica de lectura del repositorio original.
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<typeof import("@/lib/api/dives")>()),
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;
}) => (
<div
data-testid="profile-chart"
data-shown={profile.duration}
data-scale-with={scaleWith.map((peer) => peer.duration).join(",")}
data-axis={axis ? `${axis.start}-${axis.end}` : "own"}
>
{menuContainer &&
createPortal(<button type="button">Channels</button>, menuContainer)}
</div>
),
};
});
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> = {}): 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(
<DiveProfileCard dive={dive({ recordings: [recording()] })} />,
);
expect(container).toBeEmptyDOMElement();
});
it("asks the recording route for the primary's samples", async () => {
render(
<DiveProfileCard
dive={dive({
recordings: [recording({ profile: profileInfo("p1") })],
})}
/>,
);
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(
<DiveProfileCard
dive={dive({
recordings: [recording({ profile: profileInfo("p1") })],
})}
/>,
);
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(
<DiveProfileCard
dive={dive({
recordings: [
recording({
profile: {
...profileInfo("p1"),
channels: ["depth", "heart_rate"],
max_heart_rate: 107,
average_heart_rate: 77,
},
}),
],
})}
/>,
);
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(
<DiveProfileCard
dive={dive({
recordings: [
recording({
profile: {
...profileInfo("p1"),
max_heart_rate: null,
average_heart_rate: null,
},
}),
],
})}
/>,
);
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(
<DiveProfileCard
dive={dive({
recordings: [
recording({
device: { model: "Perdix 3" },
profile: profileInfo("p1"),
}),
],
})}
/>,
);
expect(
screen.queryByTestId("profile-recording-switcher"),
).not.toBeInTheDocument();
});
it("switches between two computers' curves, labelled by device", async () => {
render(<DiveProfileCard dive={twoComputers} />);
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(<DiveProfileCard dive={twoComputers} />);
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(
<DiveProfileCard
dive={dive({
recordings: [
recording({
uuid: "perdix",
ordinal: 0,
device: { model: "Perdix 3" },
profile: profileInfo("p1"),
}),
recording({
uuid: "bare",
ordinal: 1,
device: { brand: "Suunto" },
}),
],
})}
/>,
);
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(<DiveProfileCard dive={twoComputers} />);
await userEvent.click(screen.getByRole("button", { name: /Porvoo/ }));
await waitFor(() =>
expect(divesAPI.getRecordingProfile).toHaveBeenCalledWith(
"d1",
"ocean",
expect.anything(),
),
);
rerender(
<DiveProfileCard
dive={dive({
recordings: [
recording({
uuid: "perdix",
ordinal: 0,
device: { model: "Perdix 3" },
profile: profileInfo("p-perdix"),
}),
],
})}
/>,
);
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<string, number> = { perdix: 1, ocean: 2 };
vi.mocked(divesAPI.getRecordingProfile).mockImplementation(
async (_dive, recordingUuid) =>
({
duration: durations[recordingUuid],
pressures: [],
events: [],
}) as unknown as DiveProfile,
);
render(<DiveProfileCard dive={twoComputers} />);
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(<DiveProfileCard dive={twoComputers} />);
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<string, number> = {
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(<DiveProfileCard dive={placedAt(93_520, 0)} />);
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(<DiveProfileCard dive={placedAt(0, null)} />);
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(<DiveProfileCard dive={twoComputers} />);
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(<DiveProfileCard dive={placedAt(0, 600_000)} />);
expect(await screen.findByTestId("profile-chart")).toHaveAttribute(
"data-axis",
"0-2940000",
);
});
});
});