Ctrl K
ComunidadGuías…

src/components/dives/dive-form-fields-dialog.render.test.tsx

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

Archivo original ↗Preguntar a KiBot
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DiveFormFieldsDialog } from "./dive-form-fields-dialog";
import type { DiveFormPreset } from "@/lib/api/dive-form-presets";
import type { DiveFormPresets } from "@/hooks/useDiveFormPresets";
import type { DiveFormVisibility } from "@/hooks/useDiveFormVisibility";
import {
  canonicalHiddenFields,
  type DiveFormFieldKey,
} from "@/lib/dive-form-fields";

// Two things: what "Save as" opens on - the dialog has to answer "are these fields
// a preset I already have?" the same way the Fields trigger does, and hold that
// answer still while the diver edits underneath it - and which ways out of the
// dialog apply the switches' draft and which drop it.

function preset(name: string, hidden: DiveFormFieldKey[]): DiveFormPreset {
  return {
    uuid: `uuid-${name}`,
    user_uuid: "diver",
    name,
    hidden_fields: hidden,
    created_at: "2026-01-01T00:00:00Z",
  };
}

const TECHNICAL = preset("Technical", ["mixture.helium"]);
const BASIC = preset("Basic", [
  "mixture.helium",
  "mixture.po2_limit",
  "visibility",
]);

/**
 * Only the members this dialog and its switches actually reach for. The real
 * interface also carries the whole `UseFormReturn` of the dive form, which is
 * not stubbable and not consulted here - hence the cast, kept to a named subset
 * so that a member going missing from it is still a type error.
 */
function visibilityStub(hidden: DiveFormFieldKey[]): DiveFormVisibility {
  const stub: Pick<
    DiveFormVisibility,
    | "hidden"
    | "isHidden"
    | "isRevealed"
    | "isVisible"
    | "setHidden"
    | "selectedPresetUuid"
    | "selectPreset"
  > = {
    hidden,
    isHidden: (key) => hidden.includes(key),
    isRevealed: () => false,
    isVisible: (key) => !hidden.includes(key),
    setHidden: vi.fn(),
    selectedPresetUuid: null,
    selectPreset: vi.fn(),
  };
  return stub as DiveFormVisibility;
}

function presetsStub(rows: DiveFormPreset[] | null): DiveFormPresets {
  return {
    presets: rows,
    isLoading: rows === null,
    error: null,
    busyUuid: null,
    isWorking: false,
    createPreset: vi.fn(),
    renamePreset: vi.fn(),
    updateHiddenFields: vi.fn(),
    deletePreset: vi.fn(),
    restoreDefaults: vi.fn(),
  };
}

const saveAsBox = () => screen.getByRole("combobox", { name: /save as/i });

// Base UI places the opening focus on an animation frame. Start interacting after it lands.
const waitForDialogFocus = () =>
  waitFor(() => expect(screen.getByRole("dialog")).toHaveFocus());

describe("DiveFormFieldsDialog", () => {
  it("opens on the preset the fields already match", () => {
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={vi.fn()}
        visibility={visibilityStub(["mixture.helium"])}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    expect(saveAsBox()).toHaveValue("Technical");
    // And the button says which of its two jobs it is about to do, unprompted.
    expect(
      screen.getByText('Replaces the fields saved in "Technical".'),
    ).toBeInTheDocument();
  });

  it("opens empty when the fields match nothing saved", () => {
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={vi.fn()}
        visibility={visibilityStub(["mixture.helium", "altitude"])}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    expect(saveAsBox()).toHaveValue("");
  });

  it("keeps the seeded name once the diver edits the fields under it", async () => {
    // The point of the whole arrangement. The match is against the switches'
    // draft, so a name recomputed each render would blank itself on the first
    // switch - which is exactly the edit the diver means to save back. A new
    // `visibility.hidden` moves it here: an untouched draft follows the live set.
    const { rerender } = render(
      <DiveFormFieldsDialog
        open
        onOpenChange={vi.fn()}
        visibility={visibilityStub(["mixture.helium"])}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );
    expect(saveAsBox()).toHaveValue("Technical");

    rerender(
      <DiveFormFieldsDialog
        open
        onOpenChange={vi.fn()}
        visibility={visibilityStub(["mixture.helium", "altitude"])}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    expect(saveAsBox()).toHaveValue("Technical");
  });

  it("picks the match up when the presets were still loading on open", () => {
    // The list is fetched by the Fields menu on mount, so it is normally there
    // long before anyone opens this - but it need not be, and "" is not an
    // answer worth keeping.
    const { rerender } = render(
      <DiveFormFieldsDialog
        open
        onOpenChange={vi.fn()}
        visibility={visibilityStub(["mixture.helium"])}
        presets={presetsStub(null)}
      />,
    );
    expect(saveAsBox()).toHaveValue("");

    rerender(
      <DiveFormFieldsDialog
        open
        onOpenChange={vi.fn()}
        visibility={visibilityStub(["mixture.helium"])}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    expect(saveAsBox()).toHaveValue("Technical");
  });

  it("drops the whole list down on the name it was seeded with", async () => {
    // A seeded name is not a filter. Keyed on the text, the chevron opened on a
    // one-row list holding the preset already named in the box, so replacing a
    // different one meant clearing the field first.
    const user = userEvent.setup();
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={vi.fn()}
        visibility={visibilityStub(["mixture.helium"])}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    await waitForDialogFocus();

    await user.click(saveAsBox());

    expect(screen.getAllByRole("option").map((row) => row.textContent)).toEqual(
      ["Basic", "Technical"],
    );
  });

  it("walks the list with the arrow keys, stopping at its ends", async () => {
    const user = userEvent.setup();
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={vi.fn()}
        visibility={visibilityStub(["mixture.helium"])}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    await waitForDialogFocus();
    await user.click(saveAsBox());

    const highlighted = () =>
      screen
        .getAllByRole("option")
        .find((row) => row.getAttribute("aria-selected") === "true")
        ?.textContent;

    await user.keyboard("{ArrowUp}");
    expect(highlighted()).toBe("Technical");
    await user.keyboard("{ArrowDown}");
    expect(highlighted()).toBe("Technical");
    await user.keyboard("{ArrowUp}{ArrowUp}");
    expect(highlighted()).toBe("Basic");

    await user.keyboard("{Enter}");
    expect(saveAsBox()).toHaveValue("Basic");
    expect(screen.queryByRole("option")).not.toBeInTheDocument();
  });

  it("filters the list once the diver types", async () => {
    const user = userEvent.setup();
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={vi.fn()}
        visibility={visibilityStub(["mixture.helium"])}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    await waitForDialogFocus();

    await user.clear(saveAsBox());
    await user.type(saveAsBox(), "bas");

    expect(screen.getAllByRole("option").map((row) => row.textContent)).toEqual(
      ["Basic"],
    );
  });

  it("lets the diver type over the name it was seeded with", async () => {
    const user = userEvent.setup();
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={vi.fn()}
        visibility={visibilityStub(["mixture.helium"])}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    await waitForDialogFocus();

    await user.clear(saveAsBox());
    await user.type(saveAsBox(), "Sidemount");

    expect(saveAsBox()).toHaveValue("Sidemount");
    expect(
      screen.getByText('Creates a new preset called "Sidemount".'),
    ).toBeInTheDocument();
  });

  it("drops the edited fields on Cancel without touching the form", async () => {
    const user = userEvent.setup();
    const onOpenChange = vi.fn();
    const visibility = visibilityStub(["mixture.helium"]);
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={onOpenChange}
        visibility={visibility}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    await waitForDialogFocus();

    await user.click(screen.getByRole("switch", { name: "Altitude" }));
    expect(screen.getByRole("switch", { name: "Altitude" })).not.toBeChecked();
    await user.click(screen.getByRole("button", { name: "Cancel" }));

    expect(visibility.setHidden).not.toHaveBeenCalled();
    expect(onOpenChange).toHaveBeenCalledWith(false);
  });

  it("applies the edited fields to the form on Apply", async () => {
    const user = userEvent.setup();
    const onOpenChange = vi.fn();
    const visibility = visibilityStub(["mixture.helium"]);
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={onOpenChange}
        visibility={visibility}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    await waitForDialogFocus();

    await user.click(screen.getByRole("switch", { name: "Altitude" }));
    expect(visibility.setHidden).not.toHaveBeenCalled();
    await user.click(screen.getByRole("button", { name: "Apply" }));

    expect(visibility.setHidden).toHaveBeenCalledWith(
      canonicalHiddenFields(["mixture.helium", "altitude"]),
      ["altitude"],
    );
    expect(onOpenChange).toHaveBeenCalledWith(false);
  });

  it("applies nothing when no switch moved", async () => {
    const user = userEvent.setup();
    const visibility = visibilityStub(["mixture.helium"]);
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={vi.fn()}
        visibility={visibility}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    await waitForDialogFocus();

    await user.click(screen.getByRole("switch", { name: "Altitude" }));
    await user.click(screen.getByRole("switch", { name: "Altitude" }));
    await user.click(screen.getByRole("button", { name: "Apply" }));

    expect(visibility.setHidden).not.toHaveBeenCalled();
  });

  it.each([
    [
      "the X",
      () => userEvent.click(screen.getByRole("button", { name: "Close" })),
    ],
    ["Escape", () => userEvent.keyboard("{Escape}")],
  ])("drops the edited fields on %s", async (_, dismiss) => {
    const onOpenChange = vi.fn();
    const visibility = visibilityStub(["mixture.helium"]);
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={onOpenChange}
        visibility={visibility}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    await waitForDialogFocus();

    await userEvent.click(screen.getByRole("switch", { name: "Altitude" }));
    await dismiss();

    expect(visibility.setHidden).not.toHaveBeenCalled();
    expect(onOpenChange).toHaveBeenCalledWith(false);
  });

  it("closes only the Save as list on Escape, keeping the dialog and the draft", async () => {
    const user = userEvent.setup();
    const onOpenChange = vi.fn();
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={onOpenChange}
        visibility={visibilityStub(["mixture.helium"])}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    await waitForDialogFocus();

    await user.click(screen.getByRole("switch", { name: "Altitude" }));
    await user.click(saveAsBox());
    expect(screen.getAllByRole("option")).not.toHaveLength(0);
    await user.keyboard("{Escape}");

    expect(screen.queryByRole("option")).not.toBeInTheDocument();
    expect(onOpenChange).not.toHaveBeenCalled();
    expect(screen.getByRole("switch", { name: "Altitude" })).not.toBeChecked();
  });

  it("closes on the first Escape when the name matches no preset", async () => {
    // No rows, so no list on screen - nothing for the Escape to close first.
    const user = userEvent.setup();
    const onOpenChange = vi.fn();
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={onOpenChange}
        visibility={visibilityStub(["mixture.helium"])}
        presets={presetsStub([BASIC, TECHNICAL])}
      />,
    );

    await waitForDialogFocus();

    await user.clear(saveAsBox());
    await user.type(saveAsBox(), "Sidemount");
    expect(screen.queryByRole("option")).not.toBeInTheDocument();
    await user.keyboard("{Escape}");

    expect(onOpenChange).toHaveBeenCalledWith(false);
  });

  it("saves the edited fields from the footer and applies them", async () => {
    const user = userEvent.setup();
    const onOpenChange = vi.fn();
    const visibility = visibilityStub(["mixture.helium"]);
    const presets = presetsStub([BASIC, TECHNICAL]);
    const edited = canonicalHiddenFields(["mixture.helium", "altitude"]);
    vi.mocked(presets.createPreset).mockResolvedValue(
      preset("Sidemount", edited),
    );
    render(
      <DiveFormFieldsDialog
        open
        onOpenChange={onOpenChange}
        visibility={visibility}
        presets={presets}
      />,
    );

    await waitForDialogFocus();

    await user.click(screen.getByRole("switch", { name: "Altitude" }));
    await user.clear(saveAsBox());
    const save = screen.getByRole("button", { name: "Save preset" });
    expect(save).toBeDisabled();
    await user.type(saveAsBox(), "Sidemount");
    await user.click(save);

    expect(presets.createPreset).toHaveBeenCalledWith("Sidemount", edited);
    expect(visibility.setHidden).toHaveBeenCalledWith(edited, ["altitude"]);
    expect(visibility.selectPreset).toHaveBeenCalledWith("uuid-Sidemount");
    expect(onOpenChange).toHaveBeenCalledWith(false);
  });
});