Ctrl K
ComunidadGuías…

lib/app-tabs-context.test.ts

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

Archivo original ↗Preguntar a KiBot
// @vitest-environment jsdom
import { act, createElement, useEffect, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
  AppTabsProvider,
  useAppTabs,
  useOptionalAppTabs,
  useOptionalAppTabNavigation,
  useOptionalAppTabSession,
} from "./app-tabs-context";
import { createHomeTab } from "./app-tabs";
import type { AppTabsSession } from "./app-tabs-session";

const query = vi.hoisted(() => ({ isPending: false, isError: false, refetch: vi.fn() }));
const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }));
const preparations = vi.hoisted(() => [] as { intend: (href: string) => void }[]);
const viewport = vi.hoisted(() => ({ mobile: false as boolean | undefined }));
const queryCalls = vi.hoisted(() => vi.fn());
const auth = vi.hoisted(() => ({ user: { id: "owner" } as { id: string } | null, loading: false }));
vi.mock("./use-mobile-layout", () => ({ useMobileLayout: () => viewport.mobile }));
vi.mock("./auth-context", () => ({ useAuth: () => auth }));
vi.mock("./use-app-tabs-query", () => ({
  appTabsQueryKey: (owner: string) => ["app-tabs", owner],
  useAppTabsQuery: (...args: unknown[]) => { queryCalls(...args); return query; },
}));
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
vi.mock("next/navigation", () => ({ useRouter: () => router }));
vi.mock("@/components/app-tab-route-sync", () => ({ AppTabRouteSync: () => null }));
vi.mock("./pr-tab-preparation", async (original) => {
  const actual = await original<typeof import("./pr-tab-preparation")>();
  return { ...actual, createPrTabPreparation: (...args: Parameters<typeof actual.createPrTabPreparation>) => {
    const preparation = actual.createPrTabPreparation(...args);
    preparations.push(preparation);
    return preparation;
  } };
});

let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
let session: AppTabsSession;
let renders: { strip: number; navigation: number; actions: number };
const firstId = "10000000-0000-0000-0000-000000000001";
const secondId = "10000000-0000-0000-0000-000000000002";

function Strip() {
  session = useAppTabs().session;
  renders.strip++;
  return null;
}
function Page() {
  const navigation = useOptionalAppTabNavigation();
  renders.navigation++;
  return createElement("span", null, navigation?.activeId);
}
function Actions() {
  useOptionalAppTabSession();
  renders.actions++;
  return null;
}

beforeEach(async () => {
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
  viewport.mobile = false;
  router.replace.mockClear();
  sessionStorage.clear();
  auth.user = { id: "owner" };
  auth.loading = false;
  renders = { strip: 0, navigation: 0, actions: 0 };
  container = document.createElement("div");
  document.body.appendChild(container);
  root = createRoot(container);
  client = new QueryClient();
  await act(() => root.render(createElement(QueryClientProvider, { client },
    createElement(AppTabsProvider, { children: [
      createElement(Strip, { key: "strip" }),
      createElement(Page, { key: "page" }),
      createElement(Actions, { key: "actions" }),
    ] }),
  )));
  await act(async () => {
    session.receive([createHomeTab("owner", firstId), createHomeTab("owner", secondId, 1)]);
    await session.initialize("/home");
  });
});

afterEach(async () => {
  await act(() => root.unmount());
  client.clear();
  container.remove();
  vi.unstubAllGlobals();
  vi.restoreAllMocks();
});

describe("application tab subscriptions", () => {
  it("keeps descendant drafts mounted across unresolved, mobile and desktop layouts", async () => {
    let mounts = 0, unmounts = 0;
    let update!: (value: string) => void;
    let contexts: unknown[] = [];
    function Draft() {
      const [value, setValue] = useState("");
      update = setValue;
      contexts = [useOptionalAppTabs(), useOptionalAppTabNavigation(), useOptionalAppTabSession()];
      useEffect(() => { mounts++; return () => { unmounts++; }; }, []);
      return createElement("input", { value, readOnly: true });
    }
    const tree = () => createElement(QueryClientProvider, { client },
      createElement(AppTabsProvider, { children: createElement(Draft) }));
    await act(() => root.render(tree()));
    await act(() => update("An unfinished ticket description"));
    const input = container.querySelector("input");
    for (const mode of [undefined, true, false, true]) {
      queryCalls.mockClear();
      viewport.mobile = mode;
      await act(() => root.render(tree()));
      expect(container.querySelector("input")).toBe(input);
      expect(input?.value).toBe("An unfinished ticket description");
      expect(mounts).toBe(1);
      expect(unmounts).toBe(0);
      if (mode !== false) {
        expect(contexts).toEqual([null, null, null]);
        expect(queryCalls).not.toHaveBeenCalled();
      }
    }
  });
  describe("across auth identity changes", () => {
    let mounts: number[];
    let next: number;
    let update!: (value: string) => void;
    function Draft() {
      // One identity per mounted descendant instance.
      const [instance] = useState(() => ++next);
      const [value, setValue] = useState("");
      update = setValue;
      useEffect(() => { mounts.push(instance); }, [instance]);
      return createElement("input", { value, readOnly: true });
    }
    const render = (user: { id: string } | null, loading = false) => act(() => {
      auth.user = user; auth.loading = loading;
      root.render(createElement(QueryClientProvider, { client },
        createElement(AppTabsProvider, { children: createElement(Draft) })));
    });
    beforeEach(async () => {
      // The shared setup already resolved auth; start from a fresh provider.
      await act(() => root.unmount());
      root = createRoot(container);
      mounts = []; next = 0;
    });

    it("keeps descendants and their drafts when auth first resolves to the cookie account", async () => {
      await render(null, true);
      await act(() => update("Typed before the session resolved"));
      await render({ id: "owner" });
      await render({ id: "owner" });
      expect(mounts).toEqual([1]);
      expect(container.querySelector("input")?.value).toBe("Typed before the session resolved");
    });
    it("remounts descendants on every later identity change", async () => {
      await render(null, true);
      await render({ id: "owner" });
      await render(null);
      await render({ id: "other" });
      await render({ id: "owner" });
      expect(mounts).toEqual([1, 2, 3, 4]);
    });
    it("remounts descendants when a signed-out first resolution later finds an account", async () => {
      await render(null, true);
      await render(null);
      await render({ id: "owner" });
      expect(mounts).toEqual([1, 2]);
    });
  });
  it("does not reset the current page when a desktop reload rotates into mobile", async () => {
    window.history.replaceState(null, "", "/projects/example");
    vi.spyOn(performance, "getEntriesByType").mockReturnValue([{ type: "reload" } as PerformanceNavigationTiming]);
    const tree = () => createElement(QueryClientProvider, { client },
      createElement(AppTabsProvider, { children: createElement(Page) }));
    viewport.mobile = true;
    await act(() => root.render(tree()));
    expect(router.replace).not.toHaveBeenCalled();
    expect(window.location.pathname).toBe("/projects/example");
  });
  it("replaces the session on account switches and clears optional contexts on sign-out", async () => {
    let values: unknown[] = [];
    function OptionalConsumers() {
      values = [useOptionalAppTabs(), useOptionalAppTabNavigation(), useOptionalAppTabSession()];
      return null;
    }
    const tree = createElement(QueryClientProvider, { client },
      createElement(AppTabsProvider, { children: createElement(OptionalConsumers) }),
    );
    await act(() => root.render(tree));
    const previousSession = values[2] as AppTabsSession;
    const dispose = vi.spyOn(previousSession, "dispose");
    auth.user = { id: "another-owner" };
    await act(() => root.render(createElement(QueryClientProvider, { client },
      createElement(AppTabsProvider, { children: createElement(OptionalConsumers) }),
    )));
    expect(values[2]).not.toBe(previousSession);
    expect((values[2] as AppTabsSession).getSnapshot().activeId).toBeNull();
    expect(dispose).toHaveBeenCalledOnce();
    auth.user = null;
    await act(() => root.render(createElement(QueryClientProvider, { client },
      createElement(AppTabsProvider, { children: createElement(OptionalConsumers) }),
    )));
    expect(values).toEqual([null, null, null]);
  });

  it("updates tab metadata without rendering page or action consumers", async () => {
    const previous = { ...renders };
    await act(() => session.receive(session.getSnapshot().tabs.map((tab) => ({
      ...tab, revision: tab.revision + 1, custom_name: "Renamed remotely",
    }))));
    expect(renders.strip).toBeGreaterThan(previous.strip);
    expect(renders.navigation).toBe(previous.navigation);
    expect(renders.actions).toBe(previous.actions);
  });

  it("keeps departure status local to the strip and publishes actual activation", async () => {
    let release!: (allowed: boolean) => void;
    const unregister = session.registerDeparture(() => new Promise((resolve) => { release = resolve; }));
    const previous = { ...renders };
    let activation!: Promise<void>;
    await act(async () => {
      activation = session.activate(secondId);
      await Promise.resolve();
    });
    expect(session.getSnapshot().busy).toBe(true);
    expect(renders.strip).toBeGreaterThan(previous.strip);
    expect(renders.navigation).toBe(previous.navigation);
    expect(renders.actions).toBe(previous.actions);
    await act(async () => { release(true); await activation; });
    expect(container.textContent).toBe(secondId);
    expect(renders.navigation).toBe(previous.navigation + 1);
    expect(renders.actions).toBe(previous.actions);
    unregister();
  });
});

it("publishes a hovered destination to the live preparation before warming it", () => {
  vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
  const intend = vi.spyOn(preparations.at(-1)!, "intend");
  session.prefetch("/projects/hovered");
  expect(intend).toHaveBeenCalledWith("/projects/hovered");
});

it("uses local history only for a destination qualified by the mounted retained host", () => {
  const history = vi.spyOn(window.history, "pushState");
  session.isRetainedDestination = (id, href) => id === firstId && href === "/all";
  session.navigate("/all");
  expect(history).toHaveBeenCalledWith(null, "", "/all");
  session.navigate("/projects/cold");
  expect(router.push).toHaveBeenCalledWith("/projects/cold", { scroll: false });
  history.mockRestore();
});

it("navigates to a stored full-id destination through its short codes", () => {
  const history = vi.spyOn(window.history, "pushState");
  session.isRetainedDestination = () => true;
  session.navigate("/projects/aur-9e3d0c35/objectives?open=71384d77-1fc9-4227-b045-56ff2c8e29e4");
  expect(history).toHaveBeenCalledWith(null, "", "/projects/aur-9e3d0c35/objectives?open=71384d77");
  session.isRetainedDestination = () => false;
  session.navigate("/projects/9e3d0c35-c1d7-4d2a-983d-8190638cc887?objective=71384d77-1fc9-4227-b045-56ff2c8e29e4");
  expect(router.push).toHaveBeenCalledWith("/projects/9e3d0c35?objective=71384d77", { scroll: false });
  history.mockRestore();
});