Ctrl K
ComunidadGuías…

src/components/dives/dive-file-import.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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form";
import { DiveFileImport } from "./dive-file-import";
import type { PendingDiveFile } from "./dive-recording-files";
import { DEFAULT_MIXTURE } from "./mixture-fields";
import type { DiveFormValues } from "./dive-form-fields";
import type { ParsedDive, ParsedDiveMixture } from "@/lib/api/dives";

// The sentence itself is unit-tested in `lib/dive-import.test.ts`. What only a render can
// reach is the wiring: that the `role="status"` region exists *before* the note does, so
// the note is announced when it arrives rather than landing in a region the screen reader
// has not registered yet.

vi.mock("@/lib/api/dives", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/api/dives")>()),
  divesAPI: {
    parseDiveFile: vi.fn(),
    attachRecordingFile: vi.fn(),
  },
}));

vi.mock("@/components/ui/use-toast", () => ({
  useToast: () => ({ toast: vi.fn() }),
}));

// The component pushes a route when the diver accepts a match against another
// dive, and `useRouter` throws outright without a mounted app router.
const router = vi.hoisted(() => ({ push: vi.fn() }));
vi.mock("next/navigation", () => ({
  useRouter: () => router,
}));

const { divesAPI } = await import("@/lib/api/dives");

// Typed rather than `unknown[]` + `as never`: a field added to `ParsedDive` should break
// this fixture, which is the coupling the sibling suites already enforce.
function parsedDive(
  mixtures: ParsedDiveMixture[],
  overrides: Partial<ParsedDive> = {},
): ParsedDive {
  return {
    dive_number: null,
    start_time: null,
    submerged_time: null,
    max_depth: null,
    avg_depth: null,
    bottom_temperature: null,
    mixtures,
    // The rest of the dive, which most dive-computer files leave unstated.
    notes: null,
    visibility: null,
    weight: null,
    water_type: null,
    altitude: null,
    type: null,
    rating: null,
    air_temperature: null,
    current: null,
    waves: null,
    weather: null,
    entry_type: null,
    boat_name: null,
    tags: [],
    salinity: null,
    // Returned by the parse but never applied to the form - the API writes these
    // itself when the file is attached. Spelled out so this fixture stays a complete
    // `ParsedDive` rather than a partial one the compiler happens to accept.
    cns_start: null,
    cns_end: null,
    otu_start: null,
    otu_end: null,
    surface_pressure_bar: null,
    file_token: "token",
    ...overrides,
  };
}

// Mirrors the create page: the form is seeded with one complete cylinder before any
// import happens, which is what an import actually meets.
function Harness({
  onFileAdded,
  returnTo,
}: {
  onFileAdded?: (pending: PendingDiveFile) => void;
  returnTo?: string;
} = {}) {
  const form = useForm<DiveFormValues>({
    defaultValues: {
      mixtures: [{ ...DEFAULT_MIXTURE }],
    },
  });
  return (
    <>
      <DiveFileImport
        form={form}
        onFileAdded={onFileAdded}
        returnTo={returnTo}
        replaceMixtures={(mixtures) =>
          form.setValue("mixtures", mixtures, { shouldDirty: true })
        }
      />
      {/* Stands in for the depth box on the real form, so a second file's
          fill-only write can be told from an overwrite. */}
      <label>
        Max depth
        <input
          type="number"
          {...form.register("max_depth", { valueAsNumber: true })}
        />
      </label>
      {/* Stands in for `MixtureFields`' volume input, so the test can edit the value
          the note is about through the same form the component watches. */}
      <label>
        Volume
        <input
          type="number"
          {...form.register("mixtures.0.volume", { valueAsNumber: true })}
        />
      </label>
    </>
  );
}

function fileInput() {
  // The real input is `hidden` and driven by a button click, so `userEvent.upload`
  // refuses it - `fireEvent.change` is what that click ultimately produces.
  return document.querySelector<HTMLInputElement>('input[type="file"]')!;
}

function importFiles(...names: string[]) {
  fireEvent.change(fileInput(), {
    target: {
      files: names.map(
        (name) => new File(["{}"], name, { type: "application/octet-stream" }),
      ),
    },
  });
}

function importFile() {
  importFiles("dive.fit");
}

describe("DiveFileImport", () => {
  beforeEach(() => {
    vi.mocked(divesAPI.parseDiveFile).mockReset();
  });

  it("lets the diver pick every file of one dive in one go", () => {
    render(<Harness />);

    // A dive off two computers, or one computer's JSON beside its FIT, is the
    // case the list on this card already exists for. Without this the picker
    // made it two trips.
    expect(fileInput()).toHaveAttribute("multiple");
  });

  it("offers the dive-computer formats the converter reads, and not a zip or a DiveJSON document", () => {
    render(<Harness />);

    // One dive in any format the import reads - but the parse route refuses a
    // `.zip` and a DiveJSON document, so the picker does not offer them.
    const offered = fileInput().accept.split(",");
    expect(new Set(offered)).toEqual(
      new Set([".uddf", ".ssrf", ".fit", ".json", ".xml", ".db"]),
    );
  });

  it("parses picked files in order, and only the first one overwrites", async () => {
    // First-file-wins has to survive a batch, where none of the state saying
    // "a file is already here" has re-rendered yet. The second file's deeper
    // max depth is what a last-file-wins bug would leave on the form.
    vi.mocked(divesAPI.parseDiveFile)
      .mockResolvedValueOnce(parsedDive([], { max_depth: 30.1 }))
      .mockResolvedValueOnce(parsedDive([], { max_depth: 41.7 }));
    const onFileAdded = vi.fn();

    render(<Harness onFileAdded={onFileAdded} />);
    importFiles("ocean.fit", "ocean.json");

    await waitFor(() => expect(onFileAdded).toHaveBeenCalledTimes(2));
    expect(onFileAdded.mock.calls.map(([item]) => item.file.name)).toEqual([
      "ocean.fit",
      "ocean.json",
    ]);
    expect(screen.getByLabelText("Max depth")).toHaveValue(30.1);
  });

  it("goes to the dive a file is attached to, leading back where the form would have", async () => {
    vi.mocked(divesAPI.parseDiveFile).mockResolvedValue(
      parsedDive([], {
        matches: [
          {
            dive_uuid: "dive-7",
            dive_number: 7,
            recording_uuid: "rec-7",
            same_recording: true,
          },
        ],
      }),
    );

    render(<Harness returnTo="/trips/trip-1" />);
    importFile();
    await userEvent.click(
      await screen.findByRole("button", { name: "Attach there" }),
    );

    await waitFor(() =>
      expect(router.push).toHaveBeenCalledWith(
        "/dives/dive-7?from=%2Ftrips%2Ftrip-1",
      ),
    );
  });

  it("keeps going through the rest of a batch when one file will not parse", async () => {
    // The diver picked them together and has no way to re-pick "the other
    // two", so a single bad export cannot take the batch down with it.
    vi.mocked(divesAPI.parseDiveFile)
      .mockRejectedValueOnce(new Error("unreadable"))
      .mockResolvedValueOnce(parsedDive([], { max_depth: 22.4 }));
    const onFileAdded = vi.fn();

    render(<Harness onFileAdded={onFileAdded} />);
    importFiles("broken.fit", "ocean.json");

    await waitFor(() => expect(onFileAdded).toHaveBeenCalledTimes(1));
    expect(onFileAdded.mock.calls[0][0].file.name).toBe("ocean.json");
    // And the one that did parse prefills, since nothing before it landed.
    expect(screen.getByLabelText("Max depth")).toHaveValue(22.4);
  });

  it("keeps a live region mounted before any import", () => {
    render(<Harness />);

    // Mounted and empty. One that appears together with its text is typically not
    // announced at all - screen readers register the region on insertion and read
    // subsequent changes.
    const region = document.querySelector('[role="status"]');
    expect(region).not.toBeNull();
    expect(region).toHaveTextContent("");
  });

  it("announces the note when it arrives, in a region that was already there", async () => {
    vi.mocked(divesAPI.parseDiveFile).mockResolvedValue(
      parsedDive([
        {
          volume: null,
          start_pressure: null,
          end_pressure: null,
          oxygen: 32,
          helium: 0,
          po2_limit: null,
          gas_number: null,
          role: null,
        },
      ]),
    );
    render(<Harness />);

    const region = document.querySelector('[role="status"]')!;
    expect(region).toHaveTextContent("");

    importFile();

    await waitFor(() => expect(region).toHaveTextContent("cylinder size"));
    // No figure in it. The gas fields are ~2 200 px below this card, so a quoted value
    // could never be compared with the one in the box - and quoting one is what forced
    // the sentence to track the form.
    expect(region.textContent).not.toMatch(/\d/);
  });

  it("leaves the note alone when the diver edits the field it names", async () => {
    // The note is a statement about the file, and stays true. Rewriting it as the diver
    // types - two viewports away from this card, where they cannot see it change - was
    // machinery serving an interaction nobody observes.
    vi.mocked(divesAPI.parseDiveFile).mockResolvedValue(
      parsedDive([
        {
          volume: null,
          start_pressure: null,
          end_pressure: null,
          oxygen: 32,
          helium: 0,
          po2_limit: null,
          gas_number: null,
          role: null,
        },
      ]),
    );
    render(<Harness />);
    importFile();

    const region = document.querySelector('[role="status"]')!;
    await waitFor(() => expect(region).toHaveTextContent("cylinder size"));
    const announced = region.textContent;

    const volume = screen.getByLabelText("Volume");
    await userEvent.clear(volume);
    await userEvent.type(volume, "15");

    expect(region.textContent).toBe(announced);
  });
});