mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-14 05:41:50 +02:00
The quick-jump rail is the one place where a resolved thread looked exactly like an open one: the tick is the same, and the preview card showed only the title and body excerpt. Scanning the rail gave no way to tell "settled" from "still open" without jumping into the thread. Add a leading "Resolved" badge to the card, in the same `text-success` CheckCircle2 treatment CommentCard uses for its Resolution badge. The state leads so it is read before the content. The flag is derived with `deriveThreadResolution`, not taken from the `resolved-bar` item kind: that kind only covers root resolutions that are currently folded, so it would miss "Resolve thread with comment" (reply) resolutions and would flip off the moment a user expanded a folded thread. The card is invisible to screen readers, so the tick's accessible name carries the state too — "<title> (resolved)". Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
215 lines
8.1 KiB
TypeScript
215 lines
8.1 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 \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("")).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"), resolved: false },
|
|
{ id: "c2", entry: comment("c2", "Second thread opener"), resolved: false },
|
|
{ id: "c3", entry: comment("c3", ""), resolved: false },
|
|
];
|
|
|
|
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("badges the preview card of a resolved thread, and only that one", () => {
|
|
vi.useFakeTimers({
|
|
toFake: ["setTimeout", "clearTimeout", "requestAnimationFrame", "cancelAnimationFrame"],
|
|
});
|
|
try {
|
|
renderWithI18n(
|
|
<ThreadMinimap
|
|
threads={[{ ...threads[0]!, resolved: true }, threads[1]!, threads[2]!]}
|
|
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, the
|
|
// resolved thread.
|
|
fireEvent.pointerMove(nav, { clientY: 0 });
|
|
act(() => vi.advanceTimersByTime(30 + 150)); // rAF flush + intent delay
|
|
expect(screen.getByText("Resolved")).toBeInTheDocument();
|
|
|
|
// The badge belongs to the hovered thread, not to the card: closing it
|
|
// must take the badge with it.
|
|
fireEvent.pointerLeave(nav);
|
|
act(() => vi.advanceTimersByTime(30 + 150));
|
|
expect(screen.queryByText("Resolved")).not.toBeInTheDocument();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("carries the resolved state in the tick's accessible name", () => {
|
|
renderWithI18n(
|
|
<ThreadMinimap
|
|
threads={[{ ...threads[0]!, resolved: true }, threads[1]!, threads[2]!]}
|
|
scrollContainerEl={null}
|
|
onJump={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
// The card is visual only — a screen reader gets the state from the tick.
|
|
expect(
|
|
screen.getByRole("button", { name: "First thread opener (resolved)" }),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Second thread opener" })).toBeInTheDocument();
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|