/**
* @vitest-environment jsdom
*/
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { useState } from "react";
import type { Issue } from "@multica/core/types";
// InlineTitle renders the self-contained agent-activity badge, which fetches
// the workspace agent-task snapshot via React Query. Stub it (same pattern as
// inbox-list-item.test.tsx) instead of standing up query/workspace providers,
// but render a marker carrying the issue id so a test can assert the badge is
// wired into the cell with the row's issue — otherwise the badge insertion in
// table-view.tsx could be deleted and every test here would still pass.
vi.mock("./issue-agent-activity-indicator", () => ({
IssueAgentActivityIndicator: ({ issueId }: { issueId: string }) => (
),
}));
import { InlineTitle } from "./table-view";
import type { IssueTableDisplayRow } from "./table-view-model";
function makeIssue(title: string): Issue {
return {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title,
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "member-1",
parent_issue_id: null,
project_id: null,
position: 1,
stage: null,
start_date: null,
due_date: null,
labels: [],
metadata: {},
properties: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
}
function makeRow(title: string): Extract<
IssueTableDisplayRow,
{ kind: "issue" }
> {
return {
kind: "issue",
key: "issue:issue-1",
issue: makeIssue(title),
depth: 0,
hasChildren: false,
collapsed: false,
};
}
const baseProps = {
onUpdate: vi.fn(),
onOpen: vi.fn(),
onCreateSubIssue: vi.fn(),
onToggleParent: vi.fn(),
toggleLabel: "Toggle sub-issues",
renameLabel: "Rename issue",
createSubIssueLabel: "Create sub-issue",
};
/** Editing state lives in the table (one editor at a time); mirror that. */
function Harness({
title,
onOpen,
onUpdate,
onEditingChange,
onCreateSubIssue,
}: {
title: string;
onOpen?: () => void;
onUpdate?: (updates: unknown) => void;
onEditingChange?: (editing: boolean) => void;
onCreateSubIssue?: () => void;
}) {
const [editing, setEditing] = useState(false);
return (
{
setEditing(next);
onEditingChange?.(next);
}}
onOpen={onOpen ?? baseProps.onOpen}
onUpdate={onUpdate ?? baseProps.onUpdate}
onCreateSubIssue={onCreateSubIssue ?? baseProps.onCreateSubIssue}
/>
);
}
describe("InlineTitle", () => {
it("opens the issue when the title is clicked instead of entering edit mode", () => {
const onOpen = vi.fn();
const rowClick = vi.fn();
render(
// The title's click must not also bubble into the row, which would be
// a second, duplicate navigation through onRowClick.
,
);
fireEvent.click(screen.getByRole("button", { name: "Original" }));
expect(onOpen).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("textbox")).toBeNull();
expect(rowClick).not.toHaveBeenCalled();
});
it("enters edit mode from the rename affordance and commits on Enter", () => {
const onUpdate = vi.fn();
render();
fireEvent.click(screen.getByRole("button", { name: "Rename issue" }));
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onUpdate).toHaveBeenCalledWith({ title: "Renamed" });
expect(screen.queryByRole("textbox")).toBeNull();
});
it("opens sub-issue creation without also navigating into the issue", () => {
const onCreateSubIssue = vi.fn();
const rowClick = vi.fn();
render(
,
);
fireEvent.click(
screen.getByRole("button", { name: "Create sub-issue" }),
);
expect(onCreateSubIssue).toHaveBeenCalledTimes(1);
expect(rowClick).not.toHaveBeenCalled();
});
it("commits on click-away without also navigating into the issue", async () => {
const user = userEvent.setup({ delay: null });
const onUpdate = vi.fn();
const rowClick = vi.fn();
render(
// The row's onClick is the navigation handler. Committing a rename by
// clicking away (which blurs the input) must not bubble into it.
,
);
await user.click(screen.getByRole("button", { name: "Rename issue" }));
const input = screen.getByRole("textbox");
await user.clear(input);
await user.type(input, "Renamed");
// The identifier is inside the title cell and not focusable, so clicking
// it blurs the input (→ commit) and is the click that previously leaked
// into row navigation.
await user.click(screen.getByText("MUL-1"));
expect(onUpdate).toHaveBeenCalledWith({ title: "Renamed" });
expect(screen.queryByRole("textbox")).toBeNull();
expect(rowClick).not.toHaveBeenCalled();
});
it("preserves an active draft when a realtime title snapshot arrives", () => {
function SnapshotHarness({ title }: { title: string }) {
const [editing, setEditing] = useState(false);
return (
);
}
const view = render();
fireEvent.click(screen.getByRole("button", { name: "Rename issue" }));
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "Local draft" } });
view.rerender();
expect(screen.getByRole("textbox")).toHaveValue("Local draft");
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
expect(screen.getByRole("button", { name: "Remote title" })).toBeTruthy();
});
it("renders the agent-activity badge for the row's issue, between the identifier and the title", () => {
render();
const identifier = screen.getByText("MUL-1");
const badge = screen.getByTestId("issue-agent-activity");
const titleButton = screen.getByRole("button", { name: "Original" });
// Wired to THIS row's issue — deleting the badge insertion in
// table-view.tsx would drop the marker and fail this test, so the core
// product change is actually covered.
expect(badge.getAttribute("data-issue-id")).toBe("issue-1");
// Same reading order as List/Board: identifier → activity → title.
expect(
identifier.compareDocumentPosition(badge) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
badge.compareDocumentPosition(titleButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});