Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/test/task-search.test.ts

typescript202 行查看原文
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it, vi } from "vitest";
import { chunk, done, send } from "../../../tests/fake-server.ts";
import { HistoryIndex } from "../src/main/history-index.ts";
import { SessionHost } from "../src/main/session-host.ts";
import { resolveSearchScope, taskSearchRows } from "../src/renderer/task-search-model.ts";
import { buildTaskSearchSnippets } from "../src/shared/task-search.ts";
import { seedHistory } from "./helpers/history-fixture.ts";
import { fixture as hostFixture } from "./helpers/host-fixture.ts";
import { cleanup, setup } from "./helpers/session-fixture.ts";

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

it("finds non-ASCII body text with the same case conversion as titles and snippets", async () => {
  const f = await hostFixture((_, response) => {
    send(response, chunk({ content: "Äpfel ÉCOLE ΟΣ İx" }));
    done(response);
  });
  const session = await f.host.createSession(f.a.id);
  const run = await f.host.startRun({ sessionId: session.id, text: "Fruit glossary" });
  await f.host.activeRuns.get(session.id)?.done;
  f.host.renameSession(session.id, "Fruit glossary");
  for (const query of ["pfel", "Äpfel", "äpfel", "école", "ΟΣ", "ος", "x"]) {
    expect(f.host.searchTasks(query)).toMatchObject([{ id: session.id, snippets: [{ runId: run.runId }] }]);
  }
});

it("maps expanded lowercase matches and context back to the original run offsets", () => {
  expect(
    buildTaskSearchSnippets(
      [
        { runId: "first", text: "İ" },
        { runId: "second", text: "x" },
      ],
      "x",
    ),
  ).toMatchObject([{ runId: "second", matchBefore: "", matchAfter: "" }]);
  expect(buildTaskSearchSnippets([{ runId: "r", text: "İx" }], "x")).toMatchObject([
    { runId: "r", matchBefore: "İ", matchAfter: "" },
  ]);
  expect(buildTaskSearchSnippets([{ runId: "r", text: "aİz" }], "i")).toMatchObject([
    { runId: "r", matchBefore: "a", matchAfter: "z" },
  ]);
});

it("reads matching history segments lazily and stops after four snippets", () => {
  let visited = 0;
  function* segments() {
    for (let i = 0; i < 80; i++) {
      visited++;
      yield { runId: `run-${i}`, text: `${"x".repeat(210_000)} Needle ${i}` };
    }
  }
  const snippets = buildTaskSearchSnippets(segments(), "needle");
  expect(visited).toBe(4);
  expect(snippets.map((snippet) => snippet.runId)).toEqual(["run-0", "run-1", "run-2", "run-3"]);
  expect(snippets.every((snippet) => snippet.text.length <= 140)).toBe(true);
});

it("keeps original Unicode match context beyond the old index limit without crossing entries", () => {
  const prefix = `İ${"x".repeat(210_000)} `;
  expect(buildTaskSearchSnippets([{ runId: "long", text: `${prefix}ÄPFEL İx suffix` }], "i")).toMatchObject([
    { runId: "long", matchBefore: "", matchAfter: "x".repeat(72) },
    { runId: "long", matchBefore: `${"x".repeat(13)} ÄPFEL `, matchAfter: "x suffix" },
  ]);
  expect(
    buildTaskSearchSnippets(
      [
        { runId: "first", text: "before needle" },
        { runId: "second", text: "after needle" },
      ],
      "needle",
    ),
  ).toMatchObject([
    { runId: "first", matchBefore: "before ", matchAfter: "" },
    { runId: "second", matchBefore: "after ", matchAfter: "" },
  ]);
});

describe("task list search", () => {
  it("finds unopened history, rebuilds old indexes, excludes drafts/children/archives and ignores tools/thinking", async () => {
    const f = await setup();
    const seed = seedHistory(f.settings.state, f.dir, f.cwd, 30);
    await f.host.close();
    const host = new SessionHost(f.dir, f.settings, join(f.dir, "resources"), f.cwd);
    await host.init();
    cleanup.push(() => host.close());
    const cache = new DatabaseSync(join(f.dir, "history.sqlite"));
    cleanup.push(async () => cache.close());
    expect(cache.prepare("SELECT count(*) AS n FROM transcript_search").get()?.n).toBeGreaterThan(0);
    expect(host.searchTasks("完整第一条用户上下文")).toEqual([]); // The visible run input owns user text.
    expect(host.searchTasks("用户上下文 0")[0]).toMatchObject({
      id: seed.id,
      snippets: [{ runId: "run-0" }],
    });
    expect(host.searchTasks("最终回复 0")[0].snippets[0].runId).toBe("run-0");
    expect(host.searchTasks("工具结果")).toEqual([]);
    expect(host.searchTasks("思考")).toEqual([]);
    await host.createSession(null);
    const searchText = vi.spyOn(HistoryIndex.prototype, "searchTextFor");
    expect(host.searchTasks("  ")).toMatchObject([{ id: seed.id, snippets: [] }]);
    expect(searchText).not.toHaveBeenCalled();
    host.renameSession(seed.id, "Needle task");
    expect(host.searchTasks("  NEEDLE  ")[0]).toMatchObject({ id: seed.id, title: "Needle task" });
    await host.archiveSession(seed.id);
    expect(host.searchTasks("needle")).toEqual([]);
    host.restoreSession(seed.id);
    expect(host.searchTasks("needle")[0].id).toBe(seed.id);
    await host.close();
    cache.exec("DELETE FROM transcript_state");
    const reopened = new SessionHost(f.dir, f.settings, join(f.dir, "resources"), f.cwd);
    await reopened.init();
    cleanup.push(() => reopened.close());
    expect(reopened.searchTasks("最终回复 0")[0].id).toBe(seed.id);
    expect(() => reopened.searchTasks("x".repeat(501))).toThrow("任务查询无效");
  });

  it("stops searching history after the 80 most recent matches", async () => {
    const f = await setup();
    const seed = (updatedAt: number, calls: number) => {
      const { id } = seedHistory(f.settings.state, f.dir, f.cwd, calls);
      const task = f.settings.state.getTask(id);
      if (!task) throw new Error("Missing seeded task");
      f.settings.state.upsertTask({ ...task, updatedAt });
      return id;
    };
    const ids = Array.from({ length: 82 }, (_, i) => seed(i, 1));
    // A newer nonmatch must not consume a result slot.
    seed(100, 0);
    await f.host.close();
    const host = new SessionHost(f.dir, f.settings, join(f.dir, "resources"), f.cwd);
    await host.init();
    cleanup.push(() => host.close());
    const hasText = vi.spyOn(HistoryIndex.prototype, "hasSearchText");
    const searchText = vi.spyOn(HistoryIndex.prototype, "searchTextFor");
    const results = host.searchTasks("最终回复");
    expect(results.map((task) => task.id)).toEqual(ids.reverse().slice(0, 80));
    expect(results.every((task) => task.snippets[0]?.runId === "run-0")).toBe(true);
    expect(hasText).toHaveBeenCalledTimes(81);
    expect(searchText).toHaveBeenCalledTimes(80);
  });

  it("searches across projects, sorts by activity and discovers files beyond mention suggestions", async () => {
    const f = await setup();
    const other = join(f.dir, "other");
    await mkdir(other);
    const project = await f.host.addProject(other);
    const first = seedHistory(f.settings.state, f.dir, f.cwd, 2);
    const second = seedHistory(f.settings.state, f.dir, other, 1, { projectId: project.id });
    await f.host.close();
    const host = new SessionHost(f.dir, f.settings, join(f.dir, "resources"), f.cwd);
    await host.init();
    cleanup.push(() => host.close());
    expect(host.searchTasks("最终回复").map((task) => task.id)).toEqual([second.id, first.id]);
    expect(host.searchTasks("", f.cwd).map((task) => task.id)).toEqual([first.id]);
    await Promise.all(Array.from({ length: 25 }, (_, i) => writeFile(join(f.cwd, `source-${i}.ts`), "")));
    const files = (
      await host.searchWorkspaceFiles(first.id, {
        query: "source-",
        requestId: "task-search",
        filesOnly: true,
      })
    ).entries;
    expect(files.filter((file) => file.type === "file")).toHaveLength(25);
    expect(files.some((file) => file.name === "source-24.ts")).toBe(true);
  });

  it("keeps four distinct context windows, deduplicates rows and parses the same scope prefixes", () => {
    const segments = [
      { runId: "r", text: Array.from({ length: 8 }, (_, i) => `needle ${i}${"-".repeat(200)}`).join(" ") },
    ];
    const snippets = buildTaskSearchSnippets(segments, "needle");
    expect(snippets).toHaveLength(4);
    expect(snippets.every((snippet) => snippet.text.length <= 140 && snippet.text.includes("needle"))).toBe(
      true,
    );
    expect(buildTaskSearchSnippets(segments, "title")).toEqual([{ text: segments[0].text.slice(0, 140) }]);
    expect(buildTaskSearchSnippets(segments, "")).toEqual([]);
    for (const [prefix, scope] of [
      [">", "commands"],
      ["#", "conversations"],
      ["@", "files"],
    ]) {
      expect(resolveSearchScope(`  ${prefix} needle`)).toEqual({ query: "needle", scope, explicit: true });
    }
    expect(resolveSearchScope(" hello ")).toEqual({ query: "hello", scope: undefined, explicit: false });
    const task = {
      id: "task",
      title: "T",
      cwd: "/tmp",
      projectId: null,
      createdAt: 0,
      updatedAt: 0,
      snippets,
    };
    expect(taskSearchRows([task, task], "needle")).toHaveLength(4);
    expect(taskSearchRows([task, task], "")).toHaveLength(1);
  });
});