Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/test/conversation-history-dom.test.ts

typescript419 行查看原文
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ConversationHistory, historyChunkForRun } from "../src/components/ConversationHistory.tsx";
import type { RunView } from "../src/types.ts";

let container: HTMLDivElement, scroll: HTMLDivElement, reactRoot: Root;
let observe: (entries: { target: Element; isIntersecting: boolean }[]) => void;
let observationStarts = 0;
const resize = new Map<Element, () => void>();
const roots = { current: null as HTMLDivElement | null };
let height = 420,
  top = 0;
const runs: RunView[] = Array.from({ length: 200 }, (_, i) => ({
  runId: `run-${i}`,
  status: "completed",
  startedAt: 1,
  userMessage: `Question ${i}`,
  orderedBlocks: [],
  finalAnswerBlockIds: [],
}));
const renderRun = (run: RunView) =>
  createElement("article", { "data-run-id": run.runId }, createElement("button", {}, run.userMessage));

beforeEach(() => {
  observationStarts = 0;
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
  vi.stubGlobal(
    "IntersectionObserver",
    class {
      constructor(callback: typeof observe) {
        observationStarts++;
        observe = callback;
      }
      observe() {}
      unobserve() {}
      disconnect() {}
    },
  );
  vi.stubGlobal(
    "ResizeObserver",
    class {
      constructor(privateCallback: () => void) {
        this.callback = privateCallback;
      }
      callback: () => void;
      observe(node: Element) {
        resize.set(node, this.callback);
      }
      disconnect() {}
    },
  );
  height = 420;
  top = 0;
  scroll = document.createElement("div");
  container = document.createElement("div");
  scroll.append(container);
  document.body.append(scroll);
  roots.current = scroll;
  vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
    return {
      x: 0,
      y: this === scroll ? 0 : top,
      top: this === scroll ? 0 : top,
      height: this === scroll ? 600 : height,
      width: 600,
      left: 0,
      right: 600,
      bottom: this === scroll ? 600 : top + height,
      toJSON() {},
    };
  });
  reactRoot = createRoot(container);
});

it("observes history after an ancestor scroll ref is attached", async () => {
  const parent = { current: null as HTMLDivElement | null };
  await act(async () =>
    reactRoot.render(
      createElement(
        "div",
        { ref: parent },
        createElement(ConversationHistory, {
          runs,
          root: parent,
          windowed: true,
          renderRun,
        }),
      ),
    ),
  );
  expect(observationStarts).toBe(1);
  await intersect(10, true);
  expect(row(10).querySelector("article")).not.toBeNull();
});
afterEach(async () => {
  document.getSelection()?.removeAllRanges();
  await act(async () => reactRoot.unmount());
  scroll.remove();
  resize.clear();
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
});
async function render(
  windowed = true,
  initialRunId?: string,
  items = runs,
  pinnedRunIds?: ReadonlySet<string>,
) {
  await act(async () =>
    reactRoot.render(
      createElement(ConversationHistory, {
        runs: items,
        root: roots,
        windowed,
        initialRunId,
        pinnedRunIds,
        renderRun,
      }),
    ),
  );
}
const row = (id: number) => container.querySelector(`[data-history-run="run-${id}"]`) as HTMLElement;
async function intersect(id: number, visible: boolean) {
  await act(async () => observe([{ target: row(id), isIntersecting: visible }]));
}

it("mounts only the live tail and nearby history while retaining stable navigation placeholders", async () => {
  await render();
  expect(container.querySelectorAll("[data-history-run]")).toHaveLength(200);
  expect(container.querySelectorAll("article")).toHaveLength(4);
  expect(row(10).style.height).toBe("320px");
  await intersect(10, true);
  expect(row(10).querySelector("article")).not.toBeNull();
  await intersect(10, false);
  expect(row(10).querySelector("article")).toBeNull();
  expect(row(10).style.height).toBe("420px");
  expect(container.querySelectorAll("article")).toHaveLength(4);
});

it("updates placeholder heights without applying a second manual scroll correction", async () => {
  await render();
  scroll.scrollTop = 1000;
  top = -500;
  await intersect(10, true);
  height = 500;
  await act(async () => resize.get(row(10))?.());
  await intersect(10, false);
  expect(row(10).style.height).toBe("500px");
  expect(scroll.scrollTop).toBe(1000);
});

it("pins selection and focus until they leave an offscreen turn", async () => {
  await render();
  await intersect(10, true);
  const button = row(10).querySelector("button");
  if (!button) throw new Error("Missing mounted turn");
  await act(async () => button.focus());
  await intersect(10, false);
  expect(row(10).querySelector("article")).not.toBeNull();
  const range = document.createRange();
  range.selectNodeContents(button);
  await act(async () => {
    document.getSelection()?.addRange(range);
    button.blur();
    document.dispatchEvent(new Event("selectionchange"));
  });
  expect(row(10).querySelector("article")).not.toBeNull();
  await act(async () => {
    document.getSelection()?.removeAllRanges();
    document.dispatchEvent(new Event("selectionchange"));
  });
  expect(row(10).querySelector("article")).toBeNull();
});

it("keeps the saved reading target and any running turn mounted on initial render", async () => {
  await render(true, "run-30", [{ ...runs[0], status: "running" }, ...runs.slice(1)]);
  expect(row(30).querySelector("article")).not.toBeNull();
  expect(row(0).querySelector("article")).not.toBeNull();
  expect(container.querySelectorAll("article")).toHaveLength(14);
});

it("releases an offscreen focused turn when focus is cleared", async () => {
  await render();
  await intersect(10, true);
  const button = row(10).querySelector("button");
  if (!button) throw new Error("Missing mounted turn");
  await act(async () => button.focus());
  await intersect(10, false);
  expect(row(10).querySelector("article")).not.toBeNull();
  await act(async () => button.blur());
  expect(row(10).querySelector("article")).toBeNull();
});

it("reuses measured heights when windowing begins", async () => {
  await render(false);
  expect(container.querySelectorAll("article")).toHaveLength(200);
  await render(true);
  // Existing content stays mounted until the first visibility sample after the transition.
  expect(container.querySelectorAll("article")).toHaveLength(200);
  await intersect(199, true);
  expect(container.querySelectorAll("article")).toHaveLength(4);
  expect(row(10).style.height).toBe("420px");
});

it("mounts a pinned search target while retaining the rest of the history placeholders", async () => {
  await render(true, undefined, runs, new Set(["run-10"]));
  expect(row(10).querySelector("article")).not.toBeNull();
  expect(container.querySelectorAll("article")).toHaveLength(5);
  await render();
  expect(row(10).querySelector("article")).toBeNull();
  expect(row(10).style.height).toBe("420px");
  expect(container.querySelectorAll("article")).toHaveLength(4);
});

it("restores details and nested scroll positions after virtual unmount", async () => {
  const draw = (run: RunView) =>
    createElement(
      "article",
      {},
      createElement(
        "details",
        {},
        createElement("summary", {}, run.userMessage),
        createElement("div", { "data-inner-scroll": true }, "Content"),
      ),
    );
  await act(async () =>
    reactRoot.render(
      createElement(ConversationHistory, { runs, root: roots, windowed: true, renderRun: draw }),
    ),
  );
  await intersect(10, true);
  const details = row(10).querySelector("details");
  const inner = row(10).querySelector<HTMLElement>("[data-inner-scroll]");
  if (!details || !inner) throw new Error("Missing retained UI");
  details.open = true;
  details.dispatchEvent(new Event("toggle"));
  inner.scrollLeft = 91;
  inner.dispatchEvent(new Event("scroll"));
  await intersect(10, false);
  await intersect(10, true);
  expect(row(10).querySelector("details")?.open).toBe(true);
  expect(row(10).querySelector<HTMLElement>("[data-inner-scroll]")?.scrollLeft).toBe(91);
});

it("bounds placeholder nodes in long histories and restores details across chunk unmounts", async () => {
  const longRuns: RunView[] = Array.from({ length: 1000 }, (_, i) => ({ ...runs[0], runId: `long-${i}` }));
  const draw = (run: RunView) => createElement("details", {}, createElement("summary", {}, run.runId));
  const renderWith = (id: string) =>
    reactRoot.render(
      createElement(ConversationHistory, {
        runs: longRuns,
        root: roots,
        windowed: true,
        pinnedRunIds: new Set([id]),
        renderRun: draw,
      }),
    );
  await act(async () => renderWith("long-0"));
  expect(container.querySelectorAll("[data-history-chunk]")).toHaveLength(16);
  expect(container.querySelectorAll("[data-history-run]").length).toBeLessThan(160);
  const details = container.querySelector<HTMLDetailsElement>('[data-history-run="long-0"] details');
  if (!details) throw new Error("missing pinned run");
  await act(async () => {
    details.open = true;
    details.dispatchEvent(new Event("toggle"));
  });
  await act(async () => renderWith("long-300"));
  expect(container.querySelector('[data-history-run="long-0"]')).toBeNull();
  await act(async () => renderWith("long-0"));
  expect(container.querySelector<HTMLDetailsElement>('[data-history-run="long-0"] details')?.open).toBe(true);
});

it("keeps the same tail DOM when a new run is appended", async () => {
  const draw = (run: RunView) => createElement("details", {}, createElement("summary", {}, run.runId));
  const renderItems = (items: RunView[]) =>
    reactRoot.render(
      createElement(ConversationHistory, { runs: items, root: roots, windowed: false, renderRun: draw }),
    );
  await act(async () => renderItems(runs.slice(0, 1)));
  const original = row(0);
  const details = original.querySelector("details");
  if (!details) throw new Error("missing tail");
  details.open = true;
  await act(async () => renderItems(runs.slice(0, 2)));
  expect(row(0)).toBe(original);
  expect(row(0).querySelector("details")?.open).toBe(true);
});

it("retains details when prepending history moves a run into a different chunk", async () => {
  const items: RunView[] = Array.from({ length: 300 }, (_, index) => ({
    ...runs[0],
    runId: `chunk-${index}`,
  }));
  const draw = (run: RunView) => createElement("details", {}, createElement("summary", {}, run.runId));
  const renderItems = (values: RunView[]) =>
    reactRoot.render(
      createElement(ConversationHistory, {
        runs: values,
        root: roots,
        windowed: true,
        pinnedRunIds: new Set(["chunk-100"]),
        renderRun: draw,
      }),
    );
  await act(async () => renderItems(items));
  const details = container.querySelector<HTMLDetailsElement>('[data-history-run="chunk-100"] details');
  if (!details) throw new Error("missing pinned run");
  await act(async () => {
    details.open = true;
    details.dispatchEvent(new Event("toggle"));
  });
  await act(async () => renderItems([{ ...runs[0], runId: "prepend" }, ...items]));
  expect(container.querySelector<HTMLDetailsElement>('[data-history-run="chunk-100"] details')?.open).toBe(
    true,
  );
});

it("preserves selected and focused DOM when pages prepend and new turns append to chunked history", async () => {
  const items: RunView[] = Array.from({ length: 500 }, (_, index) => ({
    ...runs[0],
    runId: `stable-${index}`,
    userMessage: `Stable question ${index}`,
  }));
  const renderItems = (values: RunView[]) =>
    reactRoot.render(
      createElement(ConversationHistory, {
        runs: values,
        root: roots,
        windowed: true,
        pinnedRunIds: new Set(["stable-100"]),
        renderRun,
      }),
    );
  await act(async () => renderItems(items.slice(20, 400)));
  const button = container.querySelector<HTMLButtonElement>('[data-history-run="stable-100"] button');
  if (!button) throw new Error("missing selected message");
  const range = document.createRange();
  range.selectNodeContents(button);
  await act(async () => {
    button.focus();
    document.getSelection()?.addRange(range);
    document.dispatchEvent(new Event("selectionchange"));
  });
  const selected = document.getSelection()?.toString();
  for (const values of [items.slice(10, 400), items.slice(0, 400), items]) {
    await act(async () => renderItems(values));
    expect(container.querySelector('[data-history-run="stable-100"] button')).toBe(button);
    expect(document.activeElement).toBe(button);
    expect(document.getSelection()?.toString()).toBe(selected);
    expect(
      historyChunkForRun(
        scroll,
        values.findIndex((run) => run.runId === "stable-100"),
      )?.contains(button),
    ).toBe(true);
  }
  const leading = historyChunkForRun(scroll, 10);
  expect(leading?.dataset.historyStart).toBe("0");
  expect(leading?.dataset.historyEnd).toBe("20");
  expect(leading?.style.height).toBe(`${20 * 320}px`);
});

it("defers switching to chunk layout while history contains a selection", async () => {
  const items: RunView[] = Array.from({ length: 256 }, (_, index) => ({
    ...runs[0],
    runId: `threshold-${index}`,
  }));
  const renderItems = (values: RunView[]) =>
    reactRoot.render(
      createElement(ConversationHistory, { runs: values, root: roots, windowed: true, renderRun }),
    );
  await act(async () => renderItems(items.slice(0, 255)));
  const tail = container.querySelector<HTMLElement>('[data-history-run="threshold-254"] article');
  if (!tail) throw new Error("missing selected tail");
  const range = document.createRange();
  range.selectNodeContents(tail);
  await act(async () => {
    document.getSelection()?.addRange(range);
    document.dispatchEvent(new Event("selectionchange"));
  });
  const selected = document.getSelection()?.toString();
  await act(async () => renderItems(items));
  expect(container.querySelectorAll("[data-history-chunk]")).toHaveLength(0);
  expect(document.getSelection()?.toString()).toBe(selected);
  await act(async () => {
    document.getSelection()?.removeAllRanges();
    document.dispatchEvent(new Event("selectionchange"));
  });
  expect(container.querySelectorAll("[data-history-chunk]")).toHaveLength(4);
});

it("preserves a reading anchor when switching to chunk layout", async () => {
  const items: RunView[] = Array.from({ length: 256 }, (_, index) => ({
    ...runs[0],
    runId: `anchor-${index}`,
  }));
  vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
    const y =
      this === scroll
        ? 0
        : this.hasAttribute("data-history-run")
          ? container.querySelector("[data-history-chunk]")
            ? 120
            : 80
          : 0;
    return { x: 0, y, top: y, bottom: y + 420, left: 0, right: 600, height: 420, width: 600, toJSON() {} };
  });
  const draw = (values: RunView[]) =>
    reactRoot.render(
      createElement(ConversationHistory, { runs: values, root: roots, windowed: true, renderRun }),
    );
  await act(async () => draw(items.slice(0, 255)));
  scroll.scrollTop = 1000;
  await act(async () => draw(items));
  expect(scroll.scrollTop).toBe(1040);
});