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()), 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 { 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 { return { uuid: "r1", ordinal: 0, files: [], ...overrides }; } function profileInfo( overrides: Partial = {}, ): 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 = {}) => 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( , ); expect(container).toBeEmptyDOMElement(); }); it("gives each recording a block naming its device", () => { render( , ); 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( , ); 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( , ); 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( , ); expect( screen.getByRole("button", { name: /delete recording/i }), ).toBeVisible(); rerender( , ); 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( , ); 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( , ); 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( , ); expect(screen.queryByText(/^Started /)).not.toBeInTheDocument(); rerender( , ); expect(screen.getByText(/^Started /)).toBeVisible(); }); it("labels a stored file's format as logbook import labels it", () => { render( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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(); 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(); 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( , ); expect(screen.queryByTestId("dive-recording-readouts")).toBeNull(); }); });