Z ZPI 文档 v0.3.8
介绍页

源码

tests/desktop/task-groups.spec.ts

typescript877 行查看原文
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ElectronApplication, Page } from "@playwright/test";
import { chunk, done, fakeServer, send } from "../fake-server.ts";
import { runCleanup } from "../helpers/cleanup.ts";
import { launchDesktop } from "../helpers/desktop.ts";
import { expect, test } from "../helpers/test.ts";
import { expectNoCornerTooltipDuring } from "../helpers/tooltip.ts";
import { seedHistory } from "../history-fixture.ts";

async function expectToolbarFits(page: Page) {
  await expect
    .poll(() =>
      page.locator(".sidebar-task-toolbar").evaluate((toolbar) => {
        const sidebar = toolbar.closest(".sidebar");
        if (!sidebar) throw new Error("Missing sidebar");
        const bounds = sidebar.getBoundingClientRect();
        return Array.from(toolbar.querySelectorAll("button")).flatMap((button) => {
          const rect = button.getBoundingClientRect();
          return rect.left < bounds.left || rect.right > bounds.right || rect.width < 24
            ? [
                {
                  label: button.ariaLabel ?? button.textContent,
                  width: rect.width,
                  overflow: rect.right - bounds.right,
                },
              ]
            : [];
        });
      }),
    )
    .toEqual([]);
}

async function grouped(page: Page) {
  await page.getByRole("button", { name: "筛选视图", exact: true }).click();
  await page.getByRole("menuitemradio", { name: "按分组", exact: true }).click();
  await expect(page.getByRole("button", { name: "新建分组", exact: true })).toBeVisible();
}
async function createGroup(page: Page, name: string) {
  await page.getByRole("button", { name: "新建分组", exact: true }).click();
  const input = page.getByRole("textbox", { name: "重命名分组", exact: true });
  await expect(input).toBeFocused();
  await expect(page.getByRole("menuitemradio", { name: "灰色", exact: true })).toBeVisible();
  await input.fill(name);
  await input.press("Enter");
  await page.keyboard.press("Escape");
  await expect(page.getByRole("menuitemradio", { name: "灰色", exact: true })).toBeHidden();
  await expect(page.getByRole("button", { name: `分组 ${name}`, exact: true })).toBeVisible();
}

test("grouped tasks keep manual order while the shared view menu preserves independent project and recent-update views", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-group-sort-"));
  const project = join(dir, "workspace");
  await mkdir(project);
  const now = Date.now();
  for (const [id, createdAt, updatedAt] of [
    ["root-old", now - 400000, now - 60000],
    ["root-new", now - 300000, now - 200000],
    ["group-old", now - 200000, now - 60000],
    ["group-new", now - 100000, now - 100000],
  ] as const)
    seedHistory(dir, project, 0, { id, createdAt, updatedAt });
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, project, url: "" });
    const page = await app.firstWindow();
    await page.evaluate(async () => {
      const created = await window.zpi.updateTaskGroups({ type: "create" });
      if (!created.ok) throw new Error(created.error.message);
      const groupId = created.value.groups[0].id;
      for (const id of ["group-new", "group-old"]) {
        const result = await window.zpi.updateTaskGroups({ type: "moveTask", id, groupId });
        if (!result.ok) throw new Error(result.error.message);
      }
      const ordered = await window.zpi.updateTaskGroups({ type: "taskTop", id: "root-new" });
      if (!ordered.ok) throw new Error(ordered.error.message);
    });
    await page.reload();
    await grouped(page);
    await expectToolbarFits(page);
    const rootRows = page.locator(".task-groups > .grouped-task-wrapper [data-testid=session-row]");
    const groupRows = page.locator(".task-group-content [data-testid=session-row]");
    const ids = (rows: typeof rootRows) =>
      rows.evaluateAll((elements) => elements.map((row) => row.getAttribute("data-session-id")));
    await expect.poll(() => ids(rootRows)).toEqual(["root-new", "root-old"]);
    await expect.poll(() => ids(groupRows)).toEqual(["group-new", "group-old"]);
    await expect(page.getByRole("button", { name: "筛选视图", exact: true })).toBeVisible();
    await rootRows.first().hover();
    await expect(rootRows.first().getByRole("button", { name: /^移动到顶部 / })).toBeVisible();
    const workspaceTooltip = page.getByRole("tooltip", { name: "workspace", exact: true });
    await expect(workspaceTooltip).toBeVisible();
    await expect(workspaceTooltip).toHaveText("workspace");
    await page.screenshot({ path: test.info().outputPath("grouped-manual-order.png") });
    const second = rootRows.nth(1);
    await second.hover();
    await second.getByRole("button", { name: /^移动到顶部 / }).click();
    await expect.poll(() => ids(rootRows)).toEqual(["root-old", "root-new"]);
    await page.reload();
    await grouped(page);
    await expect.poll(() => ids(rootRows)).toEqual(["root-old", "root-new"]);
    await expect.poll(() => ids(groupRows)).toEqual(["group-new", "group-old"]);
    expect(
      await page.evaluate(async () => {
        const result = await window.zpi.getTaskGroups();
        if (!result.ok) throw new Error(result.error.message);
        return result.value.roots.filter((node) => node.type === "task").map((node) => node.id);
      }),
    ).toEqual(["root-old", "root-new"]);
    await page.getByRole("button", { name: "筛选视图", exact: true }).click();
    await page.getByRole("menuitemradio", { name: "按项目", exact: true }).click();
    await page.getByRole("button", { name: "筛选视图", exact: true }).click();
    await expect(page.getByRole("menuitemradio", { name: "按项目", exact: true })).toBeChecked();
    await expect(page.getByRole("menuitemradio")).toHaveCount(3);
    await page.getByRole("menuitemradio", { name: "最近更新", exact: true }).click();
    await page.reload();
    await page.getByRole("button", { name: "筛选视图", exact: true }).click();
    await expect(page.getByRole("menuitemradio", { name: "最近更新", exact: true })).toBeChecked();
    await page.getByRole("menuitemradio", { name: "按分组", exact: true }).click();
    await expect.poll(() => ids(rootRows)).toEqual(["root-old", "root-new"]);
    await expect.poll(() => ids(groupRows)).toEqual(["group-new", "group-old"]);
    await page.evaluate(async () => {
      const result = await window.zpi.updatePreferences({ sidebarWidth: 200, fontSize: 20 });
      if (!result.ok) throw new Error(result.error.message);
    });
    await page.reload();
    await grouped(page);
    await expectToolbarFits(page);
    await page.screenshot({ path: test.info().outputPath("grouped-narrow-toolbar.png") });
  } finally {
    await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
  }
});

test("grouped task drag persists the dropped order through reload", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-group-task-drag-"));
  const project = join(dir, "workspace");
  await mkdir(project);
  const now = Date.now();
  seedHistory(dir, project, 0, { id: "drag-a", createdAt: now - 2000, updatedAt: now - 2000 });
  seedHistory(dir, project, 0, { id: "drag-b", createdAt: now - 1000, updatedAt: now - 1000 });
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, project, url: "" });
    const page = await app.firstWindow();
    await grouped(page);
    const rootRows = page.locator(".task-groups > .grouped-task-wrapper [data-testid=session-row]");
    const ids = () =>
      rootRows.evaluateAll((elements) => elements.map((row) => row.getAttribute("data-session-id")));
    const roots = () =>
      page.evaluate(async () => {
        const result = await window.zpi.getTaskGroups();
        if (!result.ok) throw new Error(result.error.message);
        return result.value.roots.filter((node) => node.type === "task").map((node) => node.id);
      });
    const initial = await ids();
    expect(initial).toHaveLength(2);
    const source = await rootRows.nth(0).boundingBox();
    const target = await rootRows.nth(1).boundingBox();
    if (!source || !target) throw new Error("missing task row bounds");
    await page.mouse.move(source.x + 40, source.y + source.height / 2);
    await page.mouse.down();
    await page.mouse.move(target.x + 40, target.y + target.height / 2, { steps: 10 });
    await page.mouse.up();
    const expected = [initial[1], initial[0]];
    await expect.poll(ids).toEqual(expected);
    await expect.poll(roots).toEqual(expected);
    await page.reload();
    await grouped(page);
    await expect.poll(ids).toEqual(expected);
  } finally {
    await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
  }
});

test("grouped group drag persists the dropped order through reload", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-group-group-drag-"));
  const project = join(dir, "workspace");
  await mkdir(project);
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, project, url: "" });
    const page = await app.firstWindow();
    await grouped(page);
    await createGroup(page, "拖拽甲");
    await createGroup(page, "拖拽乙");
    const state = await page.evaluate(async () => {
      const result = await window.zpi.getTaskGroups();
      if (!result.ok) throw new Error(result.error.message);
      return result.value;
    });
    const initial = state.roots.filter((node) => node.type === "group").map((node) => node.id);
    expect(initial).toHaveLength(2);
    const title = (id: string) => state.groups.find((group) => group.id === id)?.title;
    const groupOrder = () =>
      page.evaluate(async () => {
        const result = await window.zpi.getTaskGroups();
        if (!result.ok) throw new Error(result.error.message);
        return result.value.roots.filter((node) => node.type === "group").map((node) => node.id);
      });
    const sourceHeader = page.getByRole("button", { name: `分组 ${title(initial[0])}`, exact: true });
    const targetHeader = page.getByRole("button", { name: `分组 ${title(initial[1])}`, exact: true });
    const source = await sourceHeader.locator(".task-group-title").boundingBox();
    const target = await targetHeader.boundingBox();
    if (!source || !target) throw new Error("missing group header bounds");
    await page.mouse.move(source.x + 8, source.y + source.height / 2);
    await page.mouse.down();
    await page.mouse.move(target.x + 40, target.y + target.height / 2, { steps: 10 });
    await page.mouse.up();
    const expected = [initial[1], initial[0]];
    await expect.poll(groupOrder).toEqual(expected);
    await page.reload();
    await grouped(page);
    await expect.poll(groupOrder).toEqual(expected);
  } finally {
    await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
  }
});

test("grouped task archive uses the menu icon and retains history while draft X discards the draft", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-group-archive-"));
  const project = join(dir, "workspace");
  await mkdir(project);
  const server = await fakeServer((_, response) => {
    send(response, chunk({ content: "已保留的任务历史" }));
    done(response);
  });
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, project, url: server.url });
    const page = await app.firstWindow();
    await page.getByRole("button", { name: "添加项目", exact: true }).first().click();
    await expect(page.getByLabel("选择项目", { exact: true })).toContainText("workspace");
    await page.getByLabel("消息", { exact: true }).fill("归档按钮验证");
    await page.getByLabel("发送", { exact: true }).click();
    await expect(page.getByTestId("run")).toHaveAttribute("data-status", "completed");
    const task = await page.evaluate(async () => {
      const result = await window.zpi.listRecentSessions();
      if (!result.ok) throw new Error(result.error.message);
      const task = result.value.find((record) => !record.draft);
      if (!task) throw new Error("missing created task");
      return task;
    });
    await grouped(page);
    const row = page.locator(`[data-testid="session-row"][data-session-id="${task.id}"]`);
    await row.hover();
    const archive = row.getByRole("button", { name: "归档任务", exact: true });
    await expect(archive.locator("svg.lucide-archive")).toHaveCount(1);
    await expect(archive.locator("svg.lucide-x")).toHaveCount(0);
    await archive.hover();
    await expect(page.getByRole("tooltip", { name: "归档任务", exact: true })).toBeVisible();
    await page.screenshot({ path: test.info().outputPath("grouped-archive.png") });
    await expectNoCornerTooltipDuring(page, () => page.getByLabel("消息", { exact: true }).hover());
    await expect(archive).toBeHidden();
    await row.hover();
    await expect(page.getByRole("tooltip", { name: "归档任务", exact: true })).toHaveCount(0);
    await archive.focus();
    await expect(page.getByRole("tooltip", { name: "归档任务", exact: true })).toHaveCount(0);
    await expectNoCornerTooltipDuring(page, () => page.getByLabel("消息", { exact: true }).focus());
    await row.hover();
    await archive.click();
    await expect(row).toHaveCount(0);
    const archived = await page.evaluate(() => window.zpi.listArchivedSessions());
    expect(archived.ok && archived.value.some((record) => record.id === task.id)).toBe(true);
    const snapshot = await page.evaluate((id) => window.zpi.getSessionSnapshot(id), task.id);
    expect(snapshot.ok && snapshot.value.view.runs.length).toBe(1);
    await page.getByRole("button", { name: "新建任务", exact: true }).first().click();
    const draft = page.getByTestId("grouped-draft-row");
    await expect(draft).toBeVisible();
    const draftId = await draft.getAttribute("data-session-id");
    await draft.hover();
    const discard = draft.getByRole("button", { name: "关闭新任务", exact: true });
    await expect(discard.locator("svg.lucide-x")).toHaveCount(1);
    await expect(draft.locator("svg.lucide-archive")).toHaveCount(0);
    await discard.click();
    await expect(draft).toHaveCount(0);
    const remaining = await page.evaluate(() => window.zpi.listRecentSessions());
    expect(remaining.ok && remaining.value.some((record) => record.id === draftId)).toBe(false);
    const after = await page.evaluate(() => window.zpi.listArchivedSessions());
    expect(after.ok && after.value.map((record) => record.id)).toEqual([task.id]);
    await page.reload();
    await expect(page.locator(`[data-session-id="${draftId}"]`)).toHaveCount(0);
    await expect(row).toHaveCount(0);
  } finally {
    await runCleanup([
      () => rm(dir, { recursive: true, force: true }),
      () => server.close(),
      () => app?.close(),
    ]);
  }
});

test("grouped running task fades archive and explains why it is disabled", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-group-running-"));
  const project = join(dir, "workspace");
  await mkdir(project);
  const server = await fakeServer((_, response) => send(response, chunk({ content: "live response" })));
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, project, url: server.url });
    const page = await app.firstWindow();
    await page.getByLabel("添加项目", { exact: true }).first().click();
    await expect(page.getByLabel("选择项目", { exact: true })).toContainText("workspace");
    await page.getByLabel("消息", { exact: true }).fill("运行中的任务");
    await page.getByLabel("消息", { exact: true }).press("Enter");
    await expect(page.getByTestId("run")).toHaveAttribute("data-status", "running");
    await grouped(page);
    const row = page.locator(".task-groups [data-testid=session-row]").first();
    await row.hover();
    const archive = row.getByRole("button", { name: "归档任务", exact: true });
    await expect(archive).toBeDisabled();
    await expect(archive).toHaveCSS("opacity", "0.45");
    await archive.hover();
    await expect(page.getByRole("tooltip", { name: "请先停止运行", exact: true })).toBeVisible();
  } finally {
    await runCleanup([
      () => rm(dir, { recursive: true, force: true }),
      () => server.close(),
      () => app?.close(),
    ]);
  }
});

test("group menus preserve focus/expansion, names/IME and colors survive reload, and ungroup keeps tasks", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-task-groups-ui-"));
  const project = join(dir, "workspace");
  await mkdir(project);
  const seeded = seedHistory(dir, project, 1);
  const server = await fakeServer((_, response) => {
    send(response, chunk({ content: "ok" }));
    done(response);
  });
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, project, url: server.url });
    const page = await app.firstWindow();
    await grouped(page);
    await createGroup(page, "设计组");
    const header = page.getByRole("button", { name: "分组 设计组", exact: true });
    await expect(header).toHaveAttribute("aria-expanded", "true");
    for (const expanded of [true, false]) {
      if (!expanded) await header.click();
      await page.getByRole("button", { name: "分组颜色 设计组", exact: true }).click();
      const option = page.getByRole("menuitemradio", { name: expanded ? "紫色" : "绿色", exact: true });
      await expect(option).toBeVisible();
      await option.click();
      await expect(option).toBeHidden();
      await expect(header).toHaveAttribute("aria-expanded", String(expanded));
    }
    await header.click({ button: "right" });
    await page.getByRole("menuitem", { name: "重命名", exact: true }).click();
    const input = page.getByRole("textbox", { name: "重命名分组" });
    await expect(input).toBeFocused();
    await input.fill("中文输入中");
    await input.dispatchEvent("compositionstart");
    await input.press("Enter");
    await expect(input).toBeVisible();
    await input.dispatchEvent("compositionend");
    await input.press("Escape");
    await expect(header).toBeVisible();
    await page.reload();
    await expect(header).toHaveAttribute("aria-expanded", "false");
    await expect(header.locator(".task-group-color-mark")).toHaveAttribute("data-group-color", "green");
    const row = page.locator(`.task-groups [data-session-id="${seeded.id}"]`);
    await row.click({ button: "right" });
    await page.getByRole("menuitem", { name: "移动到分组", exact: true }).hover();
    await page.getByRole("menuitem", { name: "设计组", exact: true }).click();
    await header.click();
    await expect(header.locator(".task-group-count")).toHaveText("1");
    await expect(page.locator(".task-group-content").getByTestId("session-row")).toHaveCount(1);
    await header.click({ button: "right" });
    await page.getByRole("menuitem", { name: "取消分组并删除", exact: true }).click();
    await expect(header).toHaveCount(0);
    await expect(row).toBeVisible();
    await page.reload();
    await expect(row).toBeVisible();
    await expect(page.locator(".task-group")).toHaveCount(0);
  } finally {
    await runCleanup([
      () => rm(dir, { recursive: true, force: true }),
      () => server.close(),
      () => app?.close(),
    ]);
  }
});

test("virtualized group headers stay above their task rows while groups expand and the sidebar scrolls", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-group-layout-"));
  const project = join(dir, "workspace");
  await mkdir(project);
  const rootCount = 90;
  const ids = Array.from(
    { length: rootCount + 24 },
    (_, index) => seedHistory(dir, project, 0, { id: `layout-task-${index}` }).id,
  );
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, project, url: "" });
    const page = await app.firstWindow();
    await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1200, 862));
    const groups = await page.evaluate(
      async ({ tasks, rootCount }) => {
        const groups: { id: string; title: string }[] = [];
        let offset = rootCount;
        for (const [title, count] of [
          ["知识库", 8],
          ["8.18 迭代", 7],
          ["8.19 迭代", 8],
          ["技术优化", 1],
        ] as const) {
          const created = await window.zpi.updateTaskGroups({ type: "create" });
          if (!created.ok) throw new Error(created.error.message);
          const id = created.value.groups.at(-1)?.id;
          if (!id) throw new Error("Missing group");
          groups.push({ id, title });
          const renamed = await window.zpi.updateTaskGroups({ type: "rename", id, title });
          if (!renamed.ok) throw new Error(renamed.error.message);
          for (const task of tasks.slice(offset, offset + count)) {
            const moved = await window.zpi.updateTaskGroups({ type: "moveTask", id: task, groupId: id });
            if (!moved.ok) throw new Error(moved.error.message);
          }
          offset += count;
        }
        for (const id of tasks.slice(0, rootCount).reverse()) {
          const moved = await window.zpi.updateTaskGroups({ type: "taskTop", id });
          if (!moved.ok) throw new Error(moved.error.message);
        }
        const preferences = await window.zpi.updatePreferences({ sidebarWidth: 338, fontSize: 14 });
        if (!preferences.ok) throw new Error(preferences.error.message);
        localStorage.setItem("zpi.sidebarTaskPreferences", JSON.stringify({ organizeBy: "grouped" }));
        localStorage.setItem(
          "zpi.collapsedTaskGroups",
          JSON.stringify(groups.filter((group) => group.title !== "8.19 迭代").map((group) => group.id)),
        );
        return groups;
      },
      { tasks: ids, rootCount },
    );
    await page.reload();
    await expect(page.locator(".task-group-virtual-list")).toHaveCount(1);
    const offset = (rootCount - 6) * 32;
    const scrollTo = (top: number) =>
      page.locator(".sidebar-sections").evaluate((element, top) => {
        element.scrollTop = top;
      }, top);
    const overlappingHeaders = () =>
      page.locator(".task-groups").evaluate((list) => {
        const scroll = list.closest(".sidebar-sections");
        if (!scroll) throw new Error("Missing scroll container");
        return Array.from(list.querySelectorAll<HTMLElement>(".task-group")).flatMap((group) => {
          const header = group.querySelector(".task-group-header");
          const body = group.querySelector(".task-group-body-grid");
          if (!header || !body) throw new Error("Missing group content");
          const groupRect = group.getBoundingClientRect();
          const headerRect = header.getBoundingClientRect();
          const bodyTop = body.getBoundingClientRect().top;
          // An unpinned header must precede its own content, including offscreen overscan rows.
          return groupRect.top >= scroll.getBoundingClientRect().top && headerRect.bottom > bodyTop + 0.5
            ? [{ title: header.ariaLabel, headerBottom: headerRect.bottom, bodyTop }]
            : [];
        });
      });
    const prior = groups.find((group) => group.title === "技术优化");
    if (!prior) throw new Error("Missing preceding group");
    const header = page.getByRole("button", { name: `分组 ${prior.title}`, exact: true });
    for (const theme of ["light", "dark"] as const) {
      await page.emulateMedia({ colorScheme: theme });
      for (const position of [offset, offset - 100, offset - 300, offset]) {
        await scrollTo(position);
        await expect(page.getByRole("button", { name: "分组 8.19 迭代", exact: true })).toBeVisible();
        await expect.poll(overlappingHeaders).toEqual([]);
      }
      await page
        .locator(".sidebar")
        .screenshot({ path: test.info().outputPath(`group-layout-${theme}.png`) });
      for (const expanded of [true, false]) {
        await header.click();
        await expect(header).toHaveAttribute("aria-expanded", String(expanded));
        await expect.poll(overlappingHeaders).toEqual([]);
      }
    }
  } finally {
    await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
  }
});

test("long grouped/root lists virtualize, sticky headers remain usable, and drafts keep their group", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-task-groups-long-"));
  const project = join(dir, "workspace");
  await mkdir(project);
  const ids = Array.from(
    { length: 170 },
    (_, index) => seedHistory(dir, project, 0, { id: `group-task-${index}` }).id,
  );
  const server = await fakeServer((_, response) => {
    send(response, chunk({ content: "ok" }));
    done(response);
  });
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, project, url: server.url });
    const page = await app.firstWindow();
    await grouped(page);
    const groupId = await page.evaluate(async (tasks) => {
      const created = await window.zpi.updateTaskGroups({ type: "create" });
      if (!created.ok) throw new Error(created.error.message);
      const id = created.value.groups.at(-1)?.id;
      if (!id) throw new Error("missing group");
      await window.zpi.updateTaskGroups({ type: "rename", id, title: "长列表" });
      for (const taskId of tasks.slice(0, 85)) {
        const result = await window.zpi.updateTaskGroups({ type: "moveTask", id: taskId, groupId: id });
        if (!result.ok) throw new Error(result.error.message);
      }
      return id;
    }, ids);
    await page.reload();
    const group = page.locator(`[data-grouped-group-item-id="${groupId}"]`);
    await expect(group.locator(".task-group-count")).toHaveText("85");
    await expect(page.locator(".task-group-virtual-list")).toHaveCount(2);
    expect(await page.locator(".task-groups [data-testid=session-row]").count()).toBeLessThan(85);
    const firstRows = group.getByTestId("session-row");
    await firstRows.first().hover();
    const firstArchive = firstRows.first().getByRole("button", { name: "归档任务", exact: true });
    await firstArchive.hover();
    await expect(page.getByRole("tooltip", { name: "归档任务", exact: true })).toBeVisible();
    const archiveBounds = await firstArchive.boundingBox();
    const nextBounds = await firstRows.nth(1).boundingBox();
    if (!archiveBounds || !nextBounds) throw new Error("missing archive transition bounds");
    await expectNoCornerTooltipDuring(page, async () => {
      for (let index = 0; index < 3; index++) {
        await page.mouse.move(
          archiveBounds.x + archiveBounds.width / 2,
          nextBounds.y + nextBounds.height / 2,
        );
        await page.mouse.move(
          archiveBounds.x + archiveBounds.width / 2,
          archiveBounds.y + archiveBounds.height / 2,
        );
      }
    });
    await firstArchive.hover();
    await expect(page.getByRole("tooltip", { name: "归档任务", exact: true })).toBeVisible();
    await expectNoCornerTooltipDuring(page, () =>
      page.locator(".sidebar-sections").evaluate((element) => {
        element.scrollTop = 800;
      }),
    );
    await expect(group.locator(".task-group-header")).toHaveClass(/task-group-header-sticky/);
    await group.getByRole("button", { name: "新建任务 长列表", exact: true }).click();
    await expect(page.locator(".task-group-content [data-testid=grouped-draft-row]")).toHaveCount(1);
    await page.getByRole("button", { name: "新建任务", exact: true }).first().click();
    await expect(page.locator(".task-group-content [data-testid=grouped-draft-row]")).toHaveCount(1);
    const draft = page.getByTestId("grouped-draft-row");
    await draft.hover();
    await draft.getByRole("button", { name: "关闭新任务", exact: true }).click();
    await expect(page.getByTestId("grouped-draft-row")).toHaveCount(0);
  } finally {
    await runCleanup([
      () => rm(dir, { recursive: true, force: true }),
      () => server.close(),
      () => app?.close(),
    ]);
  }
});

test("long group names stay beside their color dot and truncate in menus and drag overlays", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-task-group-long-title-"));
  const project = join(dir, "workspace");
  await mkdir(project);
  const seeded = seedHistory(dir, project, 1);
  const title = "这是一个需要单行省略且不挤压计数的长分组名称".repeat(4);
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, project, url: "" });
    const page = await app.firstWindow();
    await grouped(page);
    await createGroup(page, title);
    await page.locator(`.task-groups [data-session-id="${seeded.id}"]`).click({ button: "right" });
    await page.getByRole("menuitem", { name: "移动到分组", exact: true }).hover();
    const menuTitle = page.locator(".task-group-move-title");
    await expect(menuTitle).toBeVisible();
    const menuLayout = await menuTitle.evaluate((element) => {
      const dot = element.parentElement?.querySelector(".task-group-color-dot");
      if (!dot) throw new Error("missing group color dot");
      const style = getComputedStyle(element);
      return {
        gap: element.getBoundingClientRect().left - dot.getBoundingClientRect().right,
        overflowing: element.scrollWidth > element.clientWidth,
        whiteSpace: style.whiteSpace,
        textOverflow: style.textOverflow,
      };
    });
    expect(menuLayout).toMatchObject({ overflowing: true, whiteSpace: "nowrap", textOverflow: "ellipsis" });
    expect(menuLayout.gap).toBeGreaterThanOrEqual(7);
    expect(menuLayout.gap).toBeLessThanOrEqual(9);
    await page.keyboard.press("Escape");
    await page.keyboard.press("Escape");
    const header = page.getByRole("button", { name: `分组 ${title}`, exact: true });
    const titleBounds = await header.locator(".task-group-title").boundingBox();
    if (!titleBounds) throw new Error("missing group title bounds");
    await page.mouse.move(titleBounds.x + 8, titleBounds.y + titleBounds.height / 2);
    await page.mouse.down();
    await page.mouse.move(titleBounds.x + 8, titleBounds.y + titleBounds.height / 2 + 12, { steps: 3 });
    const overlay = page.locator(".task-group-drag-overlay.task-group-header");
    await expect(overlay).toBeVisible();
    const overlayLayout = await overlay.evaluate((element) => {
      const name = element.querySelector<HTMLElement>(".task-group-overlay-title");
      const count = element.querySelector<HTMLElement>(".task-group-count");
      if (!name || !count) throw new Error("missing group overlay content");
      const style = getComputedStyle(name);
      const bounds = element.getBoundingClientRect();
      return {
        overflowing: name.scrollWidth > name.clientWidth,
        whiteSpace: style.whiteSpace,
        textOverflow: style.textOverflow,
        countFits: count.getBoundingClientRect().right <= bounds.right,
        height: bounds.height,
      };
    });
    expect(overlayLayout).toEqual({
      overflowing: true,
      whiteSpace: "nowrap",
      textOverflow: "ellipsis",
      countFits: true,
      height: 32,
    });
    await page.keyboard.press("Escape");
    await page.mouse.up();
  } finally {
    await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
  }
});

test("grouped view shares pinned shortcuts and creates group drafts through Cmd/Ctrl+N; bracket panes and search hints stay synchronized", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-task-group-shortcuts-"));
  const cwd = join(dir, "workspace");
  await mkdir(cwd);
  const first = seedHistory(dir, cwd, 1).id,
    second = seedHistory(dir, cwd, 1).id,
    groupedTask = seedHistory(dir, cwd, 1).id;
  const primary = process.platform === "darwin" ? "Meta" : "Control";
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, url: "" });
    const page = await app.firstWindow();
    await grouped(page);
    const groupId = await page.evaluate(
      async ({ first, second, groupedTask }) => {
        const result = await window.zpi.updateTaskGroups({ type: "create" });
        if (!result.ok) throw new Error(result.error.message);
        const groupId = result.value.groups[0].id;
        for (const id of [first, second, groupedTask])
          await window.zpi.updateTaskGroups({ type: "moveTask", id, groupId });
        await window.zpi.setSessionPinned(second, true);
        await window.zpi.setSessionPinned(first, true);
        return groupId;
      },
      { first, second, groupedTask },
    );
    await page.reload();
    const pinned = page.locator(".pinned-tasks [data-session-id]");
    await expect(pinned.first()).toHaveAttribute("data-session-id", second);
    await page.keyboard.press(`${primary}+1`);
    await expect(pinned.first()).toHaveClass(/active/);
    await page.keyboard.press(`${primary}+2`);
    await expect(pinned.last()).toHaveClass(/active/);
    await page.locator(`.task-groups [data-session-id="${groupedTask}"]`).click();
    const group = page.locator(`[data-grouped-group-item-id="${groupId}"]`);
    const header = group.locator(".task-group-header");
    await header.click();
    await expect(header).toHaveAttribute("aria-expanded", "false");
    await page.keyboard.press(`${primary}+n`);
    await expect(header).toHaveAttribute("aria-expanded", "true");
    await expect(group.getByTestId("grouped-draft-row")).toBeVisible();
    await expect(
      page.locator(`[data-grouped-group-item-id="${groupId}"] [data-testid=grouped-draft-row]`),
    ).toHaveCount(1);
    const draftId = await page.evaluate(() => localStorage.getItem("zpi.selectedSession"));
    await header.click();
    await expect(header).toHaveAttribute("aria-expanded", "false");
    await page.keyboard.press(`${primary}+n`);
    await expect(header).toHaveAttribute("aria-expanded", "true");
    await expect(group.getByTestId("grouped-draft-row")).toBeVisible();
    expect(await page.evaluate(() => localStorage.getItem("zpi.selectedSession"))).toBe(draftId);
    await expect
      .poll(async () => {
        const result = await page.evaluate(() => window.zpi.listRecentSessions());
        if (!result.ok) throw new Error(result.error.message);
        return result.value.filter((record) => record.draft && !record.parentSessionId).length;
      })
      .toBe(1);
    await expect(
      page.locator(`[data-grouped-group-item-id="${groupId}"] [data-testid=grouped-draft-row]`),
    ).toHaveCount(1);
    await page.keyboard.press(`${primary}+[`);
    await expect(page.getByTestId("left-sidebar-toggle")).toHaveAttribute("aria-expanded", "false");
    await page.keyboard.press(`${primary}+[`);
    await expect(page.getByTestId("left-sidebar-toggle")).toHaveAttribute("aria-expanded", "true");
    await page.keyboard.press(`${primary}+]`);
    await expect(page.getByTestId("right-sidebar-toggle")).toHaveAttribute("aria-expanded", "true");
    await page.keyboard.press(`${primary}+]`);
    await expect(page.getByTestId("right-sidebar-toggle")).toHaveAttribute("aria-expanded", "false");
    await page.getByTestId("sidebar-search-open").hover();
    await expect(page.getByRole("tooltip")).toContainText(process.platform === "darwin" ? "⌘K" : "Ctrl+K");
    await page.keyboard.press(`${primary}+k`);
    await expect(page.getByRole("dialog")).toBeVisible();
  } finally {
    await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
  }
});

test("grouped task menu reuses header actions, icons and pin behavior", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-group-menu-"));
  const project = join(dir, "workspace");
  await mkdir(project);
  const task = seedHistory(dir, project, 1);
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, project, url: "" });
    const page = await app.firstWindow();
    await grouped(page);
    const row = page.locator(`[data-testid="session-row"][data-session-id="${task.id}"]`);
    await row.click();
    await page.getByRole("button", { name: "任务菜单", exact: true }).click();
    const header = await page.locator(".task-menu [role=menuitem]").allTextContents();
    await page.keyboard.press("Escape");
    await row.click({ button: "right" });
    const menu = page.locator(".grouped-task-context-menu");
    await expect(menu.locator("[role=menuitem]")).toHaveText(["移动到分组", "移动到顶部", ...header]);
    await expect(menu.getByRole("menuitem", { name: "移动到分组", exact: true }).locator("svg")).toHaveCount(
      2,
    );
    await expect(menu.getByRole("menuitem", { name: "移动到顶部", exact: true }).locator("svg")).toHaveCount(
      1,
    );
    await expect(menu.getByText("标记未读", { exact: true })).toHaveCount(0);
    await menu.getByRole("menuitem", { name: "置顶", exact: true }).click();
    await expect(page.locator(`.pinned-tasks [data-session-id="${task.id}"]`)).toBeVisible();
    await page.reload();
    const pinned = page.locator(`.pinned-tasks [data-session-id="${task.id}"]`);
    await pinned.hover();
    await pinned.getByRole("button", { name: /^取消置顶任务 / }).click();
    await expect(page.locator(`.pinned-tasks [data-session-id="${task.id}"]`)).toHaveCount(0);
    await row.click({ button: "right" });
    await menu.getByRole("menuitem", { name: "重命名任务", exact: true }).click();
    const input = page.getByRole("textbox", { name: "重命名任务", exact: true });
    await expect(input).toBeFocused();
    await input.fill("菜单重命名");
    await input.press("Enter");
    await expect(row).toContainText("菜单重命名");
    await page.reload();
    await expect(row).toContainText("菜单重命名");
  } finally {
    await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
  }
});

test("task drag retains the final preview through delayed saves, group crossings and direction reversal", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-group-drag-save-"));
  const project = join(dir, "project");
  await mkdir(project);
  for (const [index, id] of ["a", "b", "c", "d"].entries())
    seedHistory(dir, project, 0, { id, createdAt: Date.now() - index * 1000 });
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, project, url: "" });
    const page = await app.firstWindow();
    await grouped(page);
    await createGroup(page, "甲");
    await createGroup(page, "乙");
    const { g, h } = await page.evaluate(async () => {
      const result = await window.zpi.getTaskGroups();
      if (!result.ok) throw new Error(result.error.message);
      const g = result.value.groups.find((group) => group.title === "甲")?.id;
      const h = result.value.groups.find((group) => group.title === "乙")?.id;
      if (!g || !h) throw new Error("Missing groups");
      for (const [id, groupId] of [
        ["a", g],
        ["b", g],
        ["d", h],
      ]) {
        const moved = await window.zpi.updateTaskGroups({ type: "moveTask", id, groupId });
        if (!moved.ok) throw new Error(moved.error.message);
      }
      await window.zpi.updateTaskGroups({
        type: "moveGroup",
        id: g,
        target: { type: "group", id: h },
        position: "before",
      });
      return { g, h };
    });
    await page.reload();
    await grouped(page);
    await app.evaluate(({ ipcMain }) => {
      const original = (
        ipcMain as unknown as { _invokeHandlers: Map<string, (...args: unknown[]) => Promise<unknown>> }
      )._invokeHandlers.get("zpi:call");
      if (!original) throw new Error("Missing IPC handler");
      const pending: (() => void)[] = [];
      (globalThis as unknown as { taskGroupSave: { pending: (() => void)[] } }).taskGroupSave = { pending };
      ipcMain.removeHandler("zpi:call");
      ipcMain.handle("zpi:call", async (event, method, args) => {
        if (method === "updateTaskGroups") await new Promise<void>((resolve) => pending.push(resolve));
        return original(event, method, args);
      });
    });
    const section = (id: string) => page.locator(`[data-grouped-group-item-id="${id}"]`);
    const order = (id: string) =>
      section(id)
        .locator(".task-group-content [data-testid=session-row]")
        .evaluateAll((rows) => rows.map((row) => row.getAttribute("data-session-id")));
    const taskRow = (id: string) =>
      page.locator(`.task-groups [data-testid=session-row][data-session-id="${id}"]`);
    const moveTo = async (id: string) => {
      const box = await taskRow(id).boundingBox();
      if (!box) throw new Error("Missing task bounds");
      await page.mouse.move(box.x + 40, box.y + box.height / 2, { steps: 12 });
    };
    const pending = () =>
      app?.evaluate(
        () =>
          (globalThis as unknown as { taskGroupSave: { pending: unknown[] } }).taskGroupSave.pending.length,
      );
    const release = () =>
      app?.evaluate(() =>
        (
          globalThis as unknown as { taskGroupSave: { pending: (() => void)[] } }
        ).taskGroupSave.pending.shift()?.(),
      );
    const saved = () =>
      page.evaluate(async () => {
        const result = await window.zpi.getTaskGroups();
        if (!result.ok) throw new Error(result.error.message);
        return result.value.members;
      });
    await expect.poll(() => order(g)).toEqual(["a", "b"]);
    await moveTo("b");
    await page.mouse.down();
    await moveTo("a");
    await expect.poll(() => order(g)).toEqual(["b", "a"]);
    await page.mouse.up();
    await expect.poll(pending).toBe(1);
    expect((await saved())[g]).toEqual(["a", "b"]);
    await expect.poll(() => order(g)).toEqual(["b", "a"]);
    await release();
    await expect.poll(async () => (await saved())[g]).toEqual(["b", "a"]);
    await moveTo("a");
    await page.mouse.down();
    await moveTo("d");
    await expect.poll(() => order(h)).toEqual(["d", "a"]);
    await moveTo("d");
    await expect.poll(() => order(h)).toEqual(["a", "d"]);
    await page.mouse.up();
    await expect.poll(pending).toBe(1);
    await expect.poll(() => order(h)).toEqual(["a", "d"]);
    expect((await saved())[h]).toEqual(["d"]);
    await release();
    await expect.poll(async () => (await saved())[h]).toEqual(["a", "d"]);
    await page.reload();
    await grouped(page);
    await expect.poll(() => order(g)).toEqual(["b"]);
    await expect.poll(() => order(h)).toEqual(["a", "d"]);
    await moveTo("d");
    await page.mouse.down();
    await moveTo("b");
    await page.keyboard.press("Escape");
    await page.mouse.up();
    await expect.poll(() => order(h)).toEqual(["a", "d"]);
    expect(await pending()).toBe(0);
    await expect(page.getByRole("alert")).toHaveCount(0);
  } finally {
    await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
  }
});