Ctrl K
ComunidadGuías…

lib/code-block-node-view.test.ts

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

Archivo original ↗Preguntar a KiBot
// @vitest-environment jsdom

import { afterEach, describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Text } from "@tiptap/extension-text";
import { EditorContent } from "@tiptap/react";
import { CODE_LANGUAGE_OPTIONS } from "@/components/code-block-full-language-catalog";
import { codeBlockEditorExtension } from "@/components/code-block-node-view";
import { blockExtensions, pageColorExtensions } from "@/components/pages/blocks";

vi.mock("next-intl", () => ({
  useTranslations: () => (key: string) =>
    ({
      codeLanguage: "Code language",
      codeLanguageEmpty: "No languages found",
      codeLanguageSearch: "Search languages",
      codePlainText: "Plain text",
      codeWrap: "Wrap long lines",
      collapse: "Collapse",
      copied: "Copied",
      copy: "Copy",
      expand: "Expand",
    })[key] ?? key,
}));

// Stubs for the UI primitives the component under test renders.
const uiStubs = vi.hoisted(() => {
  let value: Promise<Record<string, unknown>> | undefined;
  return () => (value ??= (async () => {
  const { createElement, forwardRef } = await import("react");
  const Control = forwardRef<
    HTMLButtonElement,
    React.ButtonHTMLAttributes<HTMLButtonElement> & { size?: string; variant?: string }
  >(function Control({ children, size: _size, variant: _variant, ...props }, ref) {
    return createElement("button", { ...props, ref }, children);
  });
  return {
    Button: Control,
    IconButton: Control,
    cn: (...classes: Array<string | false | undefined>) => classes.filter(Boolean).join(" "),
  };
})());
});
vi.mock("@/components/ui/button", async (importOriginal) => {
  const stubs = await uiStubs();
  return { ...(await importOriginal<Record<string, unknown>>()), Button: stubs.Button };
});
vi.mock("@/components/ui/icon-button", async (importOriginal) => {
  const stubs = await uiStubs();
  return { ...(await importOriginal<Record<string, unknown>>()), IconButton: stubs.IconButton };
});

// The real picker opens through an `asChild` trigger: Radix clones the
// trigger element with its click handler. The mock keeps that contract, so a
// trigger that drops the props it receives cannot open the menu here either.
vi.mock("@/components/search-select", async () => {
  const { cloneElement } = await import("react");
  return {
    SearchSelect: ({
      trigger,
      onChange,
    }: {
      trigger: React.ReactElement<{ onClick?: () => void }>;
      onChange: (value: string) => void;
    }) => cloneElement(trigger, { onClick: () => onChange("python") }),
  };
});

vi.mock("@/components/ui/tooltip", async () => {
  const { createElement, Fragment } = await import("react");
  return {
    Tooltip: ({ children }: { children: React.ReactNode }) =>
      createElement(Fragment, null, children),
    TooltipContent: ({ children }: { children: React.ReactNode }) =>
      createElement("span", null, children),
    TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
      createElement(Fragment, null, children),
  };
});

afterEach(() => vi.unstubAllGlobals());

function makeEditor(editable = false) {
  return new Editor({
    element: document.createElement("div"),
    editable,
    content: {
      type: "doc",
      content: [
        {
          type: "codeBlock",
          attrs: { language: "typescript" },
          content: [{ type: "text", text: "const answer = 42" }],
        },
      ],
    },
    extensions: [Document, Text, ...blockExtensions()] as never,
  });
}

function makeLiveEditor() {
  const editor = new Editor({
    editable: true,
    content: {
      type: "doc",
      content: [
        {
          type: "codeBlock",
          attrs: { language: "typescript" },
          content: [{ type: "text", text: "const answer = 42" }],
        },
      ],
    },
    // The code block names the page's color marks in its schema.
    extensions: [Document, Text, codeBlockEditorExtension(), ...pageColorExtensions()] as never,
  });
  const host = document.createElement("div");
  document.body.append(host);
  const root = createRoot(host);
  flushSync(() => root.render(createElement(EditorContent, { editor })));
  return {
    editor,
    destroy: () => {
      flushSync(() => root.unmount());
      editor.destroy();
      host.remove();
    },
  };
}

describe("TipTap code block node view", () => {
  it("offers a broad searchable language catalog", () => {
    expect(CODE_LANGUAGE_OPTIONS.length).toBeGreaterThan(180);
    expect(CODE_LANGUAGE_OPTIONS.map((option) => option.value)).toEqual(
      expect.arrayContaining(["javascript", "typescript", "python", "rust"]),
    );
  });

  it("renders syntax decorations and a working copy button on pages", async () => {
    const writeText = vi.fn().mockResolvedValue(undefined);
    Object.defineProperty(navigator, "clipboard", {
      configurable: true,
      value: { writeText },
    });

    const editor = makeEditor();
    const button = editor.view.dom.querySelector<HTMLButtonElement>(
      ".code-block-node-copy",
    );

    expect(button).not.toBeNull();
    expect(button?.getAttribute("aria-label")).toBe("Copy");
    expect(button?.textContent).toBe("");
    expect(button?.querySelector("svg")).not.toBeNull();
    expect(editor.view.dom.querySelector(".hljs-keyword")).not.toBeNull();

    button?.click();
    await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith("const answer = 42"));

    editor.destroy();
  });

  it("persists a language edited in the block header", () => {
    const editor = makeEditor(true);
    const language = editor.view.dom.querySelector<HTMLInputElement>(
      ".code-block-node-language",
    );

    expect(language?.value).toBe("typescript");
    if (!language) throw new Error("The code language input was not rendered");

    language.value = "javascript";
    language.dispatchEvent(new Event("input", { bubbles: true }));

    expect(editor.getJSON().content?.[0]?.attrs?.language).toBe("javascript");
    editor.destroy();
  });

  it("renders compact live controls without a leading code icon", async () => {
    const live = makeLiveEditor();
    const { editor } = live;

    await vi.waitFor(() =>
      expect(editor.view.dom.querySelector(".code-block-node-language-trigger")).not.toBeNull(),
    );
    const trigger = editor.view.dom.querySelector(".code-block-node-language-trigger");
    const wrap = editor.view.dom.querySelector<HTMLButtonElement>(".code-block-node-wrap");
    expect(trigger?.textContent).toContain("TypeScript");
    expect(trigger?.querySelector(".lucide-code-2")).toBeNull();
    expect(trigger?.querySelector(".lucide-chevrons-up-down")).toBeNull();
    expect(trigger?.querySelector("svg")).not.toBeNull();
    expect(wrap?.getAttribute("aria-pressed")).toBe("true");

    wrap?.click();
    await vi.waitFor(() => expect(wrap?.getAttribute("aria-pressed")).toBe("false"));

    live.destroy();
  });

  it("changes the language from the header picker", async () => {
    const live = makeLiveEditor();
    const { editor } = live;

    let trigger: HTMLButtonElement | null = null;
    await vi.waitFor(() => {
      trigger = editor.view.dom.querySelector<HTMLButtonElement>(".code-block-node-language-trigger");
      expect(trigger).not.toBeNull();
    });
    trigger!.click();

    await vi.waitFor(() => expect(editor.getJSON().content?.[0]?.attrs?.language).toBe("python"));
    live.destroy();
  });

  it("offers expansion only when the live code body exceeds the height cap", async () => {
    let notifyResize = () => {};
    class TestResizeObserver {
      constructor(callback: ResizeObserverCallback) {
        notifyResize = () => callback([], this as unknown as ResizeObserver);
      }
      observe() {}
      disconnect() {}
    }
    vi.stubGlobal("ResizeObserver", TestResizeObserver);

    const live = makeLiveEditor();
    const { editor } = live;
    let pre: Element | null = null;
    await vi.waitFor(() => {
      pre = editor.view.dom.querySelector(".code-block-node-view > pre");
      expect(pre).not.toBeNull();
    });
    Object.defineProperty(pre, "scrollHeight", { configurable: true, value: 461 });
    notifyResize();

    await vi.waitFor(() =>
      expect(editor.view.dom.querySelector(".code-block-node-expand")).not.toBeNull(),
    );
    const view = editor.view.dom.querySelector(".code-block-node-view");
    const button = editor.view.dom.querySelector<HTMLButtonElement>(
      ".code-block-node-expand button",
    );
    expect(view?.getAttribute("data-expanded")).toBe("false");
    expect(button?.textContent).toContain("Expand");

    button?.click();
    await vi.waitFor(() => expect(view?.getAttribute("data-expanded")).toBe("true"));
    expect(button?.textContent).toContain("Collapse");

    live.destroy();
  });
});