src/components/dives/dive-merge-action.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DiveMergeAction } from "./dive-merge-action";
import type { Dive, DiveMergeResult, Recording } from "@/lib/api/dives";
import { dive as baseDive } from "@/test/dive-fixtures";
vi.mock("@/lib/api/dives", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/dives")>()),
divesAPI: {
getDiveNeighbors: vi.fn(),
mergeDives: vi.fn(),
},
}));
vi.mock("@/components/ui/use-toast", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
const router = { push: vi.fn() };
// Opened from a trip, which the dive a merge keeps should still lead back to.
const searchParams = new URLSearchParams("from=/trips/trip-1");
vi.mock("next/navigation", () => ({
useRouter: () => router,
useSearchParams: () => searchParams,
}));
const { divesAPI } = await import("@/lib/api/dives");
function recording(overrides: Partial<Recording> = {}): Recording {
return { uuid: "r1", ordinal: 0, files: [], ...overrides };
}
const dive = (overrides: Partial<Dive> = {}) =>
baseDive({
uuid: "part-2",
dive_number: 2,
start_time: "2026-09-08T15:21:53",
submerged_time: 2921,
recordings: [recording()],
...overrides,
});
const previous = {
uuid: "part-1",
dive_number: 1,
start_time: "2026-09-08T15:18:10",
};
function mergeResult(
overrides: Partial<DiveMergeResult> = {},
): DiveMergeResult {
return {
dive: dive({ uuid: "part-1", dive_number: 1 }),
removed_dive_uuid: "part-2",
folded: true,
...overrides,
};
}
// The page hands the opener to a menu item; a plain button is the same opener
// without a menu to open first.
function trigger(openMerge: (() => void) | null) {
return openMerge && <button onClick={openMerge}>Merge</button>;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue({
previous,
next: null,
});
});
describe("DiveMergeAction", () => {
it("offers nothing on a dive logged by hand", async () => {
// The API refuses a merge where either side has no recording - Subsurface's
// own rule - so the button could only ever produce a 422. It also asks for
// no neighbours, since it has nothing to do with them.
const { container } = render(
<DiveMergeAction dive={dive({ recordings: [] })} onMerged={vi.fn()}>
{trigger}
</DiveMergeAction>,
);
expect(container).toBeEmptyDOMElement();
expect(divesAPI.getDiveNeighbors).not.toHaveBeenCalled();
});
it("offers nothing when the dive has no neighbours to fold into", async () => {
vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue({
previous: null,
next: null,
});
render(
<DiveMergeAction dive={dive()} onMerged={vi.fn()}>
{trigger}
</DiveMergeAction>,
);
await waitFor(() => expect(divesAPI.getDiveNeighbors).toHaveBeenCalled());
expect(
screen.queryByRole("button", { name: /merge/i }),
).not.toBeInTheDocument();
});
it("names the candidate by the diver's number and the day", async () => {
// `#212` alone is ambiguous in a log with duplicate numbers, which the
// numbering summary exists because logs have.
render(
<DiveMergeAction dive={dive()} onMerged={vi.fn()}>
{trigger}
</DiveMergeAction>,
);
await userEvent.click(
await screen.findByRole("button", { name: /merge/i }),
);
expect(
screen.getByRole("button", { name: /#1, Sep 8, 2026/ }),
).toBeVisible();
});
it("says plainly that the oxygen-exposure readings are not combined", async () => {
// The natural reading of a merge is that everything comes along. CNS and OTU
// are each device's own running accounting rather than a per-dive quantity
// that can be added up, so every recording keeps its own.
render(
<DiveMergeAction dive={dive()} onMerged={vi.fn()}>
{trigger}
</DiveMergeAction>,
);
await userEvent.click(
await screen.findByRole("button", { name: /merge/i }),
);
expect(
screen.getByText(/keeps its own oxygen-exposure readings/i),
).toBeVisible();
});
it("goes to the dive that survived, which is usually the other one", async () => {
// Which one survives is the server's answer - the earlier dive - not the
// caller's, and the uuid that lost is soft-deleted and stops resolving. A
// page that stayed put would 404 on its next reload.
vi.mocked(divesAPI.mergeDives).mockResolvedValue(mergeResult());
const onMerged = vi.fn();
render(
<DiveMergeAction dive={dive()} onMerged={onMerged}>
{trigger}
</DiveMergeAction>,
);
await userEvent.click(
await screen.findByRole("button", { name: /merge/i }),
);
await userEvent.click(
screen.getByRole("button", { name: /#1, Sep 8, 2026/ }),
);
await userEvent.click(screen.getByRole("button", { name: "Merge" }));
expect(divesAPI.mergeDives).toHaveBeenCalledWith(["part-2", "part-1"]);
await waitFor(() =>
expect(router.push).toHaveBeenCalledWith(
"/dives/part-1?from=%2Ftrips%2Ftrip-1",
),
);
expect(onMerged).not.toHaveBeenCalled();
});
it("re-reads in place when this dive is the one that survived", async () => {
vi.mocked(divesAPI.mergeDives).mockResolvedValue(
mergeResult({
dive: dive({ uuid: "part-2" }),
removed_dive_uuid: "part-1",
folded: false,
}),
);
const onMerged = vi.fn();
render(
<DiveMergeAction dive={dive()} onMerged={onMerged}>
{trigger}
</DiveMergeAction>,
);
await userEvent.click(
await screen.findByRole("button", { name: /merge/i }),
);
await userEvent.click(
screen.getByRole("button", { name: /#1, Sep 8, 2026/ }),
);
await userEvent.click(screen.getByRole("button", { name: "Merge" }));
await waitFor(() => expect(onMerged).toHaveBeenCalled());
expect(router.push).not.toHaveBeenCalled();
});
it("re-reads the neighbours when the page bumps its reload token", async () => {
// Nothing else can re-run that fetch: the dive's uuid is the same string
// after a merge it survived, and it still has recordings, so a refetched
// dive is a new object with identical effect dependencies. Left stale, the
// dialog goes on offering the dive it just absorbed - soft-deleted, and a
// second merge onto it fails with the generic toast - while the dive that
// is now genuinely adjacent never appears. Repairing a three-part split
// needs two merges in a row, so this is the ordinary case rather than a
// corner.
//
// The token is the page's, not this component's, because the pager beside
// it reads the same endpoint and goes stale on the same event. `Harness`
// below is the layout's half of that contract.
vi.mocked(divesAPI.mergeDives).mockResolvedValue(
mergeResult({
dive: dive({ uuid: "part-2" }),
removed_dive_uuid: "part-1",
}),
);
// What the server says once part 1 is gone: a different dive is adjacent.
vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValueOnce({
previous,
next: null,
});
vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue({
previous: {
uuid: "part-0",
dive_number: 0,
start_time: "2026-09-08T14:02:00",
},
next: null,
});
function Harness() {
const [token, setToken] = useState(0);
return (
<DiveMergeAction
dive={dive()}
reloadToken={token}
onMerged={() => setToken((count) => count + 1)}
>
{trigger}
</DiveMergeAction>
);
}
render(<Harness />);
await userEvent.click(
await screen.findByRole("button", { name: /merge/i }),
);
await userEvent.click(
screen.getByRole("button", { name: /#1, Sep 8, 2026/ }),
);
await userEvent.click(screen.getByRole("button", { name: "Merge" }));
await waitFor(() =>
expect(divesAPI.getDiveNeighbors).toHaveBeenCalledTimes(2),
);
// The absorbed dive is gone from the offer, and the newly adjacent one is in it.
await userEvent.click(
await screen.findByRole("button", { name: /merge/i }),
);
expect(
screen.getByRole("button", { name: /#0, Sep 8, 2026/ }),
).toBeVisible();
expect(
screen.queryByRole("button", { name: /#1, Sep 8, 2026/ }),
).not.toBeInTheDocument();
});
});