src/components/dives/dive-recordings-card.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DiveRecordingsCard } from "./dive-recordings-card";
import type {
Dive,
DiveFileInfo,
DiveProfileInfo,
Recording,
} from "@/lib/api/dives";
import { dive as baseDive } from "@/test/dive-fixtures";
// What this card has to get right is a set of claims about the account's
// contents: which computers recorded the dive, which files are still held for
// each, and which recording the dive's own figures come from. Every one of those
// is checkable in the DOM. How the blocks stack at 375px is not - jsdom returns
// zero-sized rects - and is measured in a browser instead.
vi.mock("@/lib/api/dives", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/dives")>()),
divesAPI: {
getDiveFileBlob: vi.fn(),
deleteDiveFile: vi.fn(),
deleteRecording: vi.fn(),
makeRecordingPrimary: vi.fn(),
},
}));
vi.mock("@/components/ui/use-toast", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
const { divesAPI } = await import("@/lib/api/dives");
function file(overrides: Partial<DiveFileInfo> = {}): DiveFileInfo {
return {
uuid: "f1",
original_filename: "ocean.fit",
content_type: "application/octet-stream",
byte_size: 4096,
parser_key: "fit",
attach_order: 0,
...overrides,
};
}
function recording(overrides: Partial<Recording> = {}): Recording {
return { uuid: "r1", ordinal: 0, files: [], ...overrides };
}
function profileInfo(
overrides: Partial<DiveProfileInfo> = {},
): DiveProfileInfo {
return {
uuid: "p1",
// Milliseconds, the profile's own axis.
duration: 3_163_000,
depth_sample_count: 314,
provenance: "file",
channels: ["depth"],
...overrides,
};
}
// The start a recording's own clock is compared against.
const dive = (overrides: Partial<Dive> = {}) =>
baseDive({ start_time: "2026-09-08T15:18:10", ...overrides });
beforeEach(() => {
vi.clearAllMocks();
});
describe("DiveRecordingsCard", () => {
it("renders nothing for a dive logged by hand", () => {
// Most dives are. There is no empty state worth showing for a thing the
// diver deliberately did not do.
const { container } = render(
<DiveRecordingsCard dive={dive()} onChanged={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
it("gives each recording a block naming its device", () => {
render(
<DiveRecordingsCard
dive={dive({
recordings: [
recording({
uuid: "perdix",
ordinal: 0,
device: {
brand: "Shearwater Research, Inc",
model: "Perdix 3",
serial: "D9772626",
name: "Perdix 3",
},
}),
recording({
uuid: "ocean",
ordinal: 1,
device: {
brand: "Suunto",
serial: "253810000400",
name: "Porvoo",
},
files: [file()],
}),
],
})}
onChanged={vi.fn()}
/>,
);
const blocks = screen.getAllByTestId("dive-recording");
expect(blocks).toHaveLength(2);
expect(
within(blocks[0]).getByText(
"Shearwater Research, Inc Perdix 3 · D9772626",
),
).toBeVisible();
expect(
within(blocks[1]).getByText("Suunto · 253810000400 (Porvoo)"),
).toBeVisible();
});
it("says a file-less recording kept nothing, rather than showing an empty block", () => {
render(
<DiveRecordingsCard
dive={dive({
recordings: [recording({ device: { model: "Perdix 3" } })],
})}
onChanged={vi.fn()}
/>,
);
const [block] = screen.getAllByTestId("dive-recording");
expect(within(block).getByText(/no file kept/i)).toBeVisible();
expect(
within(block).queryByTestId("dive-recording-file"),
).not.toBeInTheDocument();
});
it("tells the two kinds of file-less recording apart on the profile's provenance", () => {
// The dive after the walk's merge, read back from the API: the Perdix's two
// halves folded into one recording, and beside it the converter-imported one
// from the same logbook import. Both have an empty `files`, so the sentences
// can only come from the provenance - and a diver reading the card to find
// out what became of their upload gets a different answer for each.
render(
<DiveRecordingsCard
dive={dive({
recordings: [
recording({
uuid: "merged",
ordinal: 0,
device: { model: "Perdix 3", serial: "D9772626" },
profile: profileInfo({ uuid: "p1", provenance: "merge" }),
}),
recording({
uuid: "imported",
ordinal: 1,
device: { brand: "Suunto", model: "Suunto Ocean" },
profile: profileInfo({
uuid: "p2",
provenance: "divejson_import",
}),
}),
],
})}
onChanged={vi.fn()}
/>,
);
const [merged, imported] = screen.getAllByTestId("dive-recording");
expect(
within(merged).getByText(/merged from two recordings/i),
).toBeVisible();
expect(
within(imported).getByText(/imported through the converter/i),
).toBeVisible();
});
it("offers a whole-recording delete only where there are no files to delete one by one", () => {
// A recording with no files is unreachable through the per-file route, so
// without this a converter-imported or merged one would be permanent. Where
// there *are* files, deleting them is the smaller action and the server
// removes the recording with the last of them.
const { rerender } = render(
<DiveRecordingsCard
dive={dive({ recordings: [recording()] })}
onChanged={vi.fn()}
/>,
);
expect(
screen.getByRole("button", { name: /delete recording/i }),
).toBeVisible();
rerender(
<DiveRecordingsCard
dive={dive({ recordings: [recording({ files: [file()] })] })}
onChanged={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { name: /delete recording/i }),
).not.toBeInTheDocument();
});
it("offers 'Show by default' on every recording but the first", () => {
// *Something* has to be primary, so "make this one not primary" is not an
// operation - the API refuses it with a 422. A diver who means that is
// promoting a different recording.
render(
<DiveRecordingsCard
dive={dive({
recordings: [
recording({ uuid: "a", ordinal: 0, files: [file({ uuid: "fa" })] }),
recording({ uuid: "b", ordinal: 1, files: [file({ uuid: "fb" })] }),
],
})}
onChanged={vi.fn()}
/>,
);
const blocks = screen.getAllByTestId("dive-recording");
expect(
within(blocks[0]).queryByRole("button", { name: /show by default/i }),
).not.toBeInTheDocument();
expect(
within(blocks[1]).getByRole("button", { name: /show by default/i }),
).toBeVisible();
});
it("promotes a recording and re-reads the dive rather than predicting the result", async () => {
// Promotion re-derives the dive's oxygen-exposure readings server-side, so
// what the page shows afterwards has to come from the server.
vi.mocked(divesAPI.makeRecordingPrimary).mockResolvedValue(recording());
const onChanged = vi.fn();
render(
<DiveRecordingsCard
dive={dive({
recordings: [
recording({ uuid: "a", ordinal: 0, files: [file({ uuid: "fa" })] }),
recording({ uuid: "b", ordinal: 1, files: [file({ uuid: "fb" })] }),
],
})}
onChanged={onChanged}
/>,
);
await userEvent.click(
screen.getByRole("button", { name: /show by default/i }),
);
expect(divesAPI.makeRecordingPrimary).toHaveBeenCalledWith("d1", "b");
expect(onChanged).toHaveBeenCalled();
});
it("names the recording's own start only when it differs from the dive's", () => {
// A second computer that entered the water a minute later is worth a line.
// The primary agreeing with the dive is not, and a line repeating the
// header's own time would be noise on every imported dive.
const { rerender } = render(
<DiveRecordingsCard
dive={dive({
recordings: [
recording({ started_at: "2026-09-08T15:18:10", files: [file()] }),
],
})}
onChanged={vi.fn()}
/>,
);
expect(screen.queryByText(/^Started /)).not.toBeInTheDocument();
rerender(
<DiveRecordingsCard
dive={dive({
recordings: [
recording({ started_at: "2026-09-08T15:21:53", files: [file()] }),
],
})}
onChanged={vi.fn()}
/>,
);
expect(screen.getByText(/^Started /)).toBeVisible();
});
it("labels a stored file's format as logbook import labels it", () => {
render(
<DiveRecordingsCard
dive={dive({
recordings: [
recording({
files: [
file({ original_filename: "perdix.uddf", parser_key: "uddf" }),
],
}),
],
})}
onChanged={vi.fn()}
/>,
);
expect(screen.getByText(/^UDDF ·/)).toBeVisible();
expect(screen.queryByText(/does not read this file/)).toBeNull();
});
it("names a file no reader of this build reads, and says it is kept unread", () => {
render(
<DiveRecordingsCard
dive={dive({
recordings: [
recording({
files: [
file({
original_filename:
"shearwater-perdix-3_D9772626_2026-10-25T0914.bin",
parser_key: "divejson_import",
}),
],
}),
],
})}
onChanged={vi.fn()}
/>,
);
expect(
screen.getByText(/^Dive computer original \(\.bin\) ·/),
).toBeVisible();
expect(
screen.getByText(
"Kept as downloaded. OpenDiving does not read this file.",
),
).toBeVisible();
expect(
screen.getByRole("button", {
name: "Download shearwater-perdix-3_D9772626_2026-10-25T0914.bin",
}),
).toBeEnabled();
});
it("names a dive-computer original the API reads, without the unread note", () => {
render(
<DiveRecordingsCard
dive={dive({
recordings: [
recording({
files: [
file({
original_filename:
"shearwater-perdix-3_D9772626_2026-11-03T0914.bin",
parser_key: "libdivecomputer",
}),
],
}),
],
})}
onChanged={vi.fn()}
/>,
);
expect(
screen.getByText(/^Dive computer original \(\.bin\) ·/),
).toBeVisible();
expect(screen.queryByText(/does not read this file/)).toBeNull();
});
it("leaves removing a file to the edit dialog its pencil opens", async () => {
// Striking a file off sits beside importing one, in the Import section's
// dialog, where Cancel can still take it back.
const onEdit = vi.fn();
render(
<DiveRecordingsCard
dive={dive({
recordings: [recording({ files: [file({ uuid: "file-uuid" })] })],
})}
onChanged={vi.fn()}
onEdit={onEdit}
/>,
);
expect(
screen.queryByRole("button", { name: "Delete ocean.fit" }),
).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: "Edit recordings" }),
);
expect(onEdit).toHaveBeenCalled();
});
it("says what a whole-recording delete moves, rather than that the dive is unaffected", async () => {
// The dive row survives, which is the only sense in which it is unaffected:
// this recording is the one shown by default, so the dive's readings follow
// whatever takes its place.
render(
<DiveRecordingsCard
dive={dive({
recordings: [
recording({
uuid: "a",
ordinal: 0,
profile: profileInfo({ provenance: "merge" }),
}),
recording({
uuid: "b",
ordinal: 1,
files: [file({ uuid: "pf" })],
}),
],
})}
onChanged={vi.fn()}
/>,
);
await userEvent.click(
screen.getByRole("button", { name: /delete recording/i }),
);
expect(screen.getByText(/the next one takes over/i)).toBeVisible();
expect(screen.getByText(/The dive itself stays/i)).toBeVisible();
});
});
describe("DiveRecordingsCard settings line", () => {
it("says what each computer was set to, per recording", () => {
// **Per recording, never per dive.** A backup run in gauge mode beside a
// primary on open circuit is ordinary practice, and this card is the one
// place that can say both without claiming either of the dive.
render(
<DiveRecordingsCard
dive={dive({
recordings: [
recording({
uuid: "perdix",
ordinal: 0,
files: [file()],
device: { brand: "Shearwater", model: "Perdix 3" },
mode: "closed_circuit",
deco_model: {
algorithm: "buhlmann",
gf_low: 30,
gf_high: 85,
},
}),
recording({
uuid: "ocean",
ordinal: 1,
files: [file({ uuid: "f2" })],
device: { brand: "Suunto", model: "Suunto Ocean" },
mode: "gauge",
}),
],
})}
onChanged={vi.fn()}
/>,
);
const [primary, backup] = screen.getAllByTestId("dive-recording");
expect(
within(primary).getByTestId("dive-recording-settings"),
).toHaveTextContent("Closed circuit · Bühlmann GF 30/85");
expect(
within(backup).getByTestId("dive-recording-settings"),
).toHaveTextContent("Gauge");
});
it("leaves the line out where the file recorded neither", () => {
// Rather than printing "Open circuit" on a file that never said so - an
// absent mode is not open circuit, however a source format's documentation
// glosses its own default.
render(
<DiveRecordingsCard
dive={dive({
recordings: [recording({ files: [file()] })],
})}
onChanged={vi.fn()}
/>,
);
expect(screen.queryByTestId("dive-recording-settings")).toBeNull();
});
});
describe("DiveRecordingsCard readouts and salinity", () => {
// Two computers on one dive, set differently and reporting differently - the
// case the readouts moved onto the recording for. The exposure card shows the
// primary's alone, so this card is the one place both are on screen.
const twoComputers = dive({
recordings: [
recording({
uuid: "perdix",
ordinal: 0,
files: [file()],
device: { brand: "Shearwater", model: "Perdix 3" },
salinity: "en13319",
cns_start: 0,
cns_end: 9,
otu_start: 0,
otu_end: 22,
surface_pressure_bar: 1.012,
}),
recording({
uuid: "ocean",
ordinal: 1,
files: [file({ uuid: "f2" })],
device: { brand: "Suunto", model: "Suunto Ocean" },
mode: "gauge",
salinity: "salt",
surface_pressure_bar: 1.009,
}),
],
});
it("says each computer's own readouts", () => {
render(<DiveRecordingsCard dive={twoComputers} onChanged={vi.fn()} />);
const [primary, backup] = screen.getAllByTestId("dive-recording");
expect(
within(primary).getByTestId("dive-recording-readouts"),
).toHaveTextContent(
"CNS 0% → 9% · OTU 0 → 22 · Surface pressure 1.012 bar",
);
expect(
within(backup).getByTestId("dive-recording-readouts"),
).toHaveTextContent("Surface pressure 1.009 bar");
});
it("names the salinity each computer was set to, as the computer names it", () => {
render(<DiveRecordingsCard dive={twoComputers} onChanged={vi.fn()} />);
const [primary, backup] = screen.getAllByTestId("dive-recording");
expect(
within(primary).getByTestId("dive-recording-settings"),
).toHaveTextContent("salinity EN13319");
expect(
within(backup).getByTestId("dive-recording-settings"),
).toHaveTextContent("Gauge · salinity Salt");
});
it("leaves the readouts line out where the recording reported none", () => {
render(
<DiveRecordingsCard
dive={dive({ recordings: [recording({ files: [file()] })] })}
onChanged={vi.fn()}
/>,
);
expect(screen.queryByTestId("dive-recording-readouts")).toBeNull();
});
});