Ctrl K
ComunidadGuías…

src/components/dives/dive-profile-card.render.test.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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",
      );
    });
  });
});