Files
multica/packages/views/issues/components/thread-minimap.test.tsx
Naiyuan Qing c7fe10e549 fix(issues): move the thread quick-jump rail to the right edge (MUL-4522) (#5990)
The rail looks and behaves like a scroll affordance, but sat on the far
left: on a wide screen it was hundreds of pixels from both the body text
and the scrollbar the pointer was already on, so every jump cost a full
sweep across the page.

Move it to the right edge, just inside the scrollbar. right-3 plus a
20px strip is the inset that holds in both scrollbar modes: it clears a
classic scrollbar's ~11px gutter, and lands exactly on the content
column's 32px padding when the gutter is 0 (overlay scrollbars), so it
covers neither the scrollbar nor body text. Ticks flush right and the
hover wave grows them inward; the preview card opens leftward. The find
bar steps inside the rail on desktop so it can't cover the top ticks.

No left/right preference setting: the evidence is one-sided, and a
toggle would mean maintaining and testing two layouts for a preference
nobody has argued for yet.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 15:50:36 +08:00

169 lines
6.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, screen } from "@testing-library/react";
import type { TimelineEntry } from "@multica/core/types";
import { renderWithI18n } from "../../test/i18n";
import { ThreadMinimap, commentPreview, waveScale } from "./thread-minimap";
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({
getActorName: (type: string, id: string) => `${type}:${id}`,
}),
}));
function comment(id: string, content: string): TimelineEntry {
return {
type: "comment",
id,
actor_type: "member",
actor_id: `author-${id}`,
created_at: "2026-07-10T10:00:00Z",
content,
};
}
describe("commentPreview", () => {
it("splits the first line into the title and joins the rest into the body", () => {
const { title, body } = commentPreview(
"## Rollout plan\n\nShip the flag first.\nThen watch the dashboards.",
);
expect(title).toBe("Rollout plan");
expect(body).toBe("Ship the flag first. Then watch the dashboards.");
});
it("flattens markdown decorations to plain text", () => {
const { title, body } = commentPreview(
"**Bold** start with [a link](https://example.com) and [@Walt](mention://agent/a-1)\n" +
"- first item\n" +
"1. numbered ![diagram](https://example.com/x.png)\n" +
"```ts\nconst hidden = true;\n```\n" +
"> quoted tail",
);
expect(title).toBe("Bold start with a link and @Walt");
expect(body).toBe("first item numbered diagram quoted tail");
});
it("returns empty strings for content that flattens to nothing", () => {
expect(commentPreview("![](https://example.com/only-image.png)")).toEqual({
title: "",
body: "",
});
});
it("caps runaway titles and bodies", () => {
const { title, body } = commentPreview(`${"t".repeat(500)}\n${"b".repeat(900)}`);
expect(title).toHaveLength(200);
expect(body).toHaveLength(300);
});
});
describe("waveScale", () => {
it("peaks under the cursor and settles to 1 at the radius", () => {
expect(waveScale(0)).toBeCloseTo(1.7, 5);
expect(waveScale(56)).toBe(1);
expect(waveScale(200)).toBe(1);
});
it("tapers monotonically and symmetrically", () => {
const profile = [0, 14, 28, 42, 56].map(waveScale);
for (let i = 1; i < profile.length; i++) {
expect(profile[i]!).toBeLessThan(profile[i - 1]!);
}
expect(waveScale(-14)).toBeCloseTo(waveScale(14), 10);
});
});
describe("ThreadMinimap", () => {
const threads = [
{ id: "c1", entry: comment("c1", "First thread opener\nwith details") },
{ id: "c2", entry: comment("c2", "Second thread opener") },
{ id: "c3", entry: comment("c3", "") },
];
it("renders nothing below the thread threshold", () => {
const { container } = renderWithI18n(
<ThreadMinimap threads={threads.slice(0, 1)} scrollContainerEl={null} onJump={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
it("renders one labelled tick per thread, falling back to the author for empty content", () => {
renderWithI18n(
<ThreadMinimap threads={threads} scrollContainerEl={null} onJump={vi.fn()} />,
);
const nav = screen.getByRole("navigation", { name: "Jump to comment thread" });
expect(nav).toBeInTheDocument();
expect(screen.getAllByRole("button")).toHaveLength(3);
expect(screen.getByRole("button", { name: "First thread opener" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Second thread opener" })).toBeInTheDocument();
// Attachment-only comment: accessible name falls back to the actor.
expect(screen.getByRole("button", { name: "member:author-c3" })).toBeInTheDocument();
});
it("opens the shared preview card after the intent delay and closes after the leave grace", () => {
vi.useFakeTimers({
toFake: ["setTimeout", "clearTimeout", "requestAnimationFrame", "cancelAnimationFrame"],
});
try {
renderWithI18n(
<ThreadMinimap threads={threads} scrollContainerEl={null} onJump={vi.fn()} />,
);
const nav = screen.getByRole("navigation", { name: "Jump to comment thread" });
// jsdom rects are all zero → the nearest tick resolves to index 0.
fireEvent.pointerMove(nav, { clientY: 0 });
act(() => vi.advanceTimersByTime(30)); // rAF flush — arms the intent timer
expect(screen.queryByText("with details")).not.toBeInTheDocument();
act(() => vi.advanceTimersByTime(150)); // intent delay elapses → card opens
expect(screen.getByText("with details")).toBeInTheDocument();
fireEvent.pointerLeave(nav);
act(() => vi.advanceTimersByTime(30)); // wave-clear frame
expect(screen.getByText("with details")).toBeInTheDocument(); // grace keeps it up
act(() => vi.advanceTimersByTime(150)); // grace elapses → card closes
expect(screen.queryByText("with details")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
it("hugs the scrollbar side: ticks are right-aligned and the card opens inward", () => {
vi.useFakeTimers({
toFake: ["setTimeout", "clearTimeout", "requestAnimationFrame", "cancelAnimationFrame"],
});
try {
renderWithI18n(
<ThreadMinimap threads={threads} scrollContainerEl={null} onJump={vi.fn()} />,
);
// The rail sits in the right gutter, so ticks flush right and the wave
// grows them inward (away from the scrollbar) rather than over it.
const tick = screen.getByRole("button", { name: "First thread opener" });
expect(tick).toHaveClass("justify-end");
expect(tick.firstElementChild).toHaveClass("origin-right");
const nav = screen.getByRole("navigation", { name: "Jump to comment thread" });
fireEvent.pointerMove(nav, { clientY: 0 });
act(() => vi.advanceTimersByTime(30 + 150)); // rAF flush + intent delay
const card = screen.getByText("with details").closest("div");
expect(card).toHaveClass("right-8");
expect(card?.className).not.toMatch(/(?:^|\s)left-/);
} finally {
vi.useRealTimers();
}
});
it("jumps to the clicked thread", () => {
const onJump = vi.fn();
renderWithI18n(
<ThreadMinimap threads={threads} scrollContainerEl={null} onJump={onJump} />,
);
fireEvent.click(screen.getByRole("button", { name: "Second thread opener" }));
expect(onJump).toHaveBeenCalledTimes(1);
expect(onJump).toHaveBeenCalledWith("c2");
});
});