- Add mobile-first CSS with full-screen modal, wrapped actions, and reduced spacing at ≤768px - Extract inline tab styles from TaskDetailModal.tsx into CSS classes (.detail-tabs, .detail-tab, .detail-tab-active) - Add @media query for modal, header, body, actions, and tabs responsive adjustments - Add tests verifying modal structure, CSS class usage, and action button presence for mobile layout
861 lines
29 KiB
TypeScript
861 lines
29 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
|
import { TaskDetailModal } from "../TaskDetailModal";
|
|
import type { TaskDetail, Column, MergeResult, Task } from "@kb/core";
|
|
|
|
vi.mock("../../api", () => ({
|
|
uploadAttachment: vi.fn(),
|
|
deleteAttachment: vi.fn(),
|
|
updateTask: vi.fn().mockResolvedValue({}),
|
|
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useAgentLogs", () => ({
|
|
useAgentLogs: vi.fn(() => ({ entries: [], loading: false, clear: vi.fn() })),
|
|
}));
|
|
|
|
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
|
return {
|
|
id: "KB-099",
|
|
description: "Test task",
|
|
column: "in-progress" as Column,
|
|
dependencies: [],
|
|
prompt: "",
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
updatedAt: "2026-01-01T00:00:00Z",
|
|
...overrides,
|
|
} as TaskDetail;
|
|
}
|
|
|
|
const noop = vi.fn();
|
|
const noopMove = vi.fn(async () => ({}) as Task);
|
|
const noopDelete = vi.fn(async () => ({}) as Task);
|
|
const noopMerge = vi.fn(async () => ({ merged: false }) as MergeResult);
|
|
const noopRetry = vi.fn(async () => ({}) as Task);
|
|
|
|
describe("TaskDetailModal", () => {
|
|
it("renders markdown-body without detail-prompt class when prompt exists", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nSome **bold** text" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const markdownDiv = container.querySelector(".markdown-body");
|
|
expect(markdownDiv).toBeTruthy();
|
|
expect(markdownDiv!.classList.contains("detail-prompt")).toBe(false);
|
|
});
|
|
|
|
it("strips the leading heading from prompt and renders remaining markdown", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nSome **bold** text" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// The leading # heading should be stripped (modal has its own header)
|
|
expect(container.querySelector(".markdown-body h1")).toBeNull();
|
|
expect(container.querySelector("strong")?.textContent).toBe("bold");
|
|
});
|
|
|
|
it("renders (no prompt) with detail-prompt class when prompt is absent", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: undefined })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const fallback = screen.getByText("(no prompt)");
|
|
expect(fallback).toBeTruthy();
|
|
expect(fallback.classList.contains("detail-prompt")).toBe(true);
|
|
expect(fallback.classList.contains("markdown-body")).toBe(false);
|
|
});
|
|
|
|
it("does not render a PROMPT.md heading", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Some prompt content" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("PROMPT.md")).toBeNull();
|
|
});
|
|
|
|
it("renders Retry button when task status is 'failed'", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ status: "failed" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onRetryTask={noopRetry}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Retry")).toBeTruthy();
|
|
});
|
|
|
|
it("does NOT render Retry button when task status is not 'failed'", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ status: "executing" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onRetryTask={noopRetry}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Retry")).toBeNull();
|
|
});
|
|
|
|
it("does NOT render Retry button when onRetryTask is not provided", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ status: "failed" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Retry")).toBeNull();
|
|
});
|
|
|
|
it("shows description exactly once for a task without title", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
title: undefined,
|
|
description: "Fix the login bug",
|
|
prompt: "# KB-099\n\nFix the login bug\n",
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// The heading "KB-099" should be stripped from the markdown
|
|
const markdownBody = container.querySelector(".markdown-body");
|
|
expect(markdownBody?.innerHTML).not.toContain("KB-099");
|
|
// Description appears in the markdown body
|
|
expect(markdownBody?.textContent).toContain("Fix the login bug");
|
|
// The detail header shows the ID (not duplicated as markdown heading)
|
|
expect(container.querySelector(".detail-id")?.textContent).toBe("KB-099");
|
|
// The h2 title shows description, not the task ID
|
|
const h2 = container.querySelector("h2.detail-title");
|
|
expect(h2?.textContent).toBe("Fix the login bug");
|
|
});
|
|
|
|
it("shows the title in <h2> when task.title is set", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
title: "Implement dark mode",
|
|
description: "Add dark mode toggle to the settings page",
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const h2 = container.querySelector("h2.detail-title");
|
|
expect(h2?.textContent).toBe("Implement dark mode");
|
|
});
|
|
|
|
it("always shows task.id in the detail-id badge regardless of title", () => {
|
|
// With title
|
|
const { container: withTitle } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ title: "Some title" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
expect(withTitle.querySelector(".detail-id")?.textContent).toBe("KB-099");
|
|
|
|
// Without title
|
|
const { container: withoutTitle } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ title: undefined, description: "A description" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
expect(withoutTitle.querySelector(".detail-id")?.textContent).toBe("KB-099");
|
|
});
|
|
|
|
describe("paste image upload", () => {
|
|
it("uploads an image when pasting clipboard image data", async () => {
|
|
const { uploadAttachment } = await import("../../api");
|
|
const mockUpload = vi.mocked(uploadAttachment);
|
|
const mockAttachment = {
|
|
filename: "abc123.png",
|
|
originalName: "image.png",
|
|
size: 1024,
|
|
mimeType: "image/png",
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
};
|
|
mockUpload.mockResolvedValueOnce(mockAttachment);
|
|
const addToast = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
const imageFile = new File(["fake-image"], "image.png", { type: "image/png" });
|
|
const pasteEvent = new Event("paste", { bubbles: true }) as any;
|
|
pasteEvent.clipboardData = {
|
|
items: [
|
|
{
|
|
type: "image/png",
|
|
getAsFile: () => imageFile,
|
|
},
|
|
],
|
|
};
|
|
|
|
await act(async () => {
|
|
document.dispatchEvent(pasteEvent);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpload).toHaveBeenCalledWith("KB-099", imageFile);
|
|
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
|
});
|
|
});
|
|
|
|
it("does not intercept paste events without image data", async () => {
|
|
const { uploadAttachment } = await import("../../api");
|
|
const mockUpload = vi.mocked(uploadAttachment);
|
|
mockUpload.mockClear();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const pasteEvent = new Event("paste", { bubbles: true }) as any;
|
|
pasteEvent.clipboardData = {
|
|
items: [
|
|
{
|
|
type: "text/plain",
|
|
getAsFile: () => null,
|
|
},
|
|
],
|
|
};
|
|
|
|
await act(async () => {
|
|
document.dispatchEvent(pasteEvent);
|
|
});
|
|
|
|
expect(mockUpload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows uploading state during paste upload", async () => {
|
|
const { uploadAttachment } = await import("../../api");
|
|
const mockUpload = vi.mocked(uploadAttachment);
|
|
let resolveUpload!: (value: any) => void;
|
|
mockUpload.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveUpload = resolve;
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const imageFile = new File(["fake"], "shot.png", { type: "image/png" });
|
|
const pasteEvent = new Event("paste", { bubbles: true }) as any;
|
|
pasteEvent.clipboardData = {
|
|
items: [{ type: "image/png", getAsFile: () => imageFile }],
|
|
};
|
|
|
|
act(() => {
|
|
document.dispatchEvent(pasteEvent);
|
|
});
|
|
|
|
// While uploading, button should show "Uploading…"
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Uploading…")).toBeTruthy();
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveUpload({
|
|
filename: "x.png",
|
|
originalName: "shot.png",
|
|
size: 100,
|
|
mimeType: "image/png",
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Attach Screenshot")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("drag and drop image upload", () => {
|
|
it("uploads an image when dropped onto the modal", async () => {
|
|
const { uploadAttachment } = await import("../../api");
|
|
const mockUpload = vi.mocked(uploadAttachment);
|
|
const mockAttachment = {
|
|
filename: "drop123.png",
|
|
originalName: "dropped.png",
|
|
size: 2048,
|
|
mimeType: "image/png",
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
};
|
|
mockUpload.mockResolvedValueOnce(mockAttachment);
|
|
const addToast = vi.fn();
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
const modal = container.querySelector(".modal.modal-lg")!;
|
|
const imageFile = new File(["fake-image"], "dropped.png", { type: "image/png" });
|
|
|
|
await act(async () => {
|
|
fireEvent.drop(modal, {
|
|
dataTransfer: {
|
|
files: [imageFile],
|
|
},
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpload).toHaveBeenCalledWith("KB-099", imageFile);
|
|
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("renders (no dependencies) when dependencies is empty", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: [] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("(no dependencies)")).toBeTruthy();
|
|
});
|
|
|
|
it("renders dependency list when dependencies exist", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: ["KB-001", "KB-002"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("KB-001")).toBeTruthy();
|
|
expect(screen.getByText("KB-002")).toBeTruthy();
|
|
expect(screen.queryByText("(no dependencies)")).toBeNull();
|
|
});
|
|
|
|
it("can add a dependency via the dropdown", async () => {
|
|
const { updateTask } = await import("../../api");
|
|
const allTasks: Task[] = [
|
|
{ id: "KB-001", description: "Dep 1", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
|
{ id: "KB-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
|
];
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: [] })}
|
|
tasks={allTasks}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
// Should show KB-001 in the dropdown but not KB-099 (self is excluded)
|
|
const dropdown = document.querySelector(".dep-dropdown")!;
|
|
expect(dropdown).toBeTruthy();
|
|
expect(dropdown.textContent).toContain("KB-001");
|
|
expect(dropdown.querySelectorAll(".dep-dropdown-item")).toHaveLength(1);
|
|
|
|
fireEvent.click(screen.getByText("KB-001"));
|
|
|
|
await waitFor(() => {
|
|
expect(updateTask).toHaveBeenCalledWith("KB-099", { dependencies: ["KB-001"] });
|
|
});
|
|
});
|
|
|
|
it("can remove a dependency", async () => {
|
|
const { updateTask } = await import("../../api");
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: ["KB-001", "KB-002"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const removeButtons = screen.getAllByTitle(/Remove dependency/);
|
|
fireEvent.click(removeButtons[0]); // Remove KB-001
|
|
|
|
await waitFor(() => {
|
|
expect(updateTask).toHaveBeenCalledWith("KB-099", { dependencies: ["KB-002"] });
|
|
});
|
|
});
|
|
|
|
it("activity list does not have nested scroll constraints", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
log: [
|
|
{ timestamp: "2026-01-01T00:00:00Z", message: "Created task" },
|
|
{ timestamp: "2026-01-01T00:01:00Z", message: "Started work" },
|
|
{ timestamp: "2026-01-01T00:02:00Z", message: "Completed step 1" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const activityList = container.querySelector(".detail-activity-list");
|
|
expect(activityList).toBeTruthy();
|
|
const style = (activityList as HTMLElement).style;
|
|
expect(style.overflowY).not.toBe("auto");
|
|
expect(style.maxHeight).toBe("");
|
|
});
|
|
|
|
it("renders dependency dropdown items sorted newest-first by createdAt", () => {
|
|
const allTasks: Task[] = [
|
|
{ id: "KB-001", description: "Oldest", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
|
|
{ id: "KB-003", description: "Newest", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" },
|
|
{ id: "KB-002", description: "Middle", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" },
|
|
{ id: "KB-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-15T00:00:00Z", updatedAt: "2026-03-15T00:00:00Z" },
|
|
];
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: [] })}
|
|
tasks={allTasks}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
|
expect(items).toHaveLength(3);
|
|
|
|
const ids = Array.from(items).map((el) => el.querySelector(".dep-dropdown-id")?.textContent);
|
|
expect(ids).toEqual(["KB-003", "KB-002", "KB-001"]);
|
|
});
|
|
|
|
describe("tab toggle", () => {
|
|
it("defaults to the Definition tab", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Definition")).toBeTruthy();
|
|
expect(screen.getByText("Agent Log")).toBeTruthy();
|
|
// Definition content should be visible
|
|
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
|
// Agent log viewer should not be visible
|
|
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
|
|
});
|
|
|
|
it("switches to Agent Log tab and back", async () => {
|
|
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
|
const mockUseAgentLogs = vi.mocked(useAgentLogs);
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Click Agent Log tab
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
// Agent log viewer should appear
|
|
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy();
|
|
// Definition content should be hidden
|
|
expect(container.querySelector(".markdown-body")).toBeNull();
|
|
|
|
// Click Definition tab to go back
|
|
fireEvent.click(screen.getByText("Definition"));
|
|
|
|
// Definition content should reappear
|
|
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
|
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
|
|
});
|
|
|
|
it("passes enabled=true to useAgentLogs only when Agent Log tab is active", async () => {
|
|
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
|
const mockUseAgentLogs = vi.mocked(useAgentLogs);
|
|
mockUseAgentLogs.mockClear();
|
|
|
|
const { rerender } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Default: Definition tab active → enabled should be false
|
|
const initialCall = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
|
|
expect(initialCall[1]).toBe(false);
|
|
|
|
// Switch to Agent Log tab
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
const afterSwitch = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
|
|
expect(afterSwitch[1]).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("mobile responsive structure", () => {
|
|
it("modal container has both 'modal' and 'modal-lg' classes for responsive CSS targeting", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const modal = container.querySelector(".modal.modal-lg");
|
|
expect(modal).toBeTruthy();
|
|
});
|
|
|
|
it("modal overlay has 'modal-overlay' and 'open' classes", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const overlay = container.querySelector(".modal-overlay.open");
|
|
expect(overlay).toBeTruthy();
|
|
});
|
|
|
|
it("modal-actions contains the spacer div for flex layout", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "in-progress" as Column })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const actions = container.querySelector(".modal-actions");
|
|
expect(actions).toBeTruthy();
|
|
// Spacer div with flex: 1 separates left actions from right actions
|
|
const spacer = actions!.querySelector("div");
|
|
expect(spacer).toBeTruthy();
|
|
expect((spacer as HTMLElement).style.flex).toContain("1");
|
|
});
|
|
|
|
it("tab buttons use CSS classes instead of inline styles for responsive override", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const tabs = container.querySelectorAll(".detail-tab");
|
|
expect(tabs.length).toBe(2);
|
|
// Tabs should use class-based styling, not inline styles
|
|
expect(tabs[0].classList.contains("detail-tab")).toBe(true);
|
|
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); // Definition is default active
|
|
expect(tabs[1].classList.contains("detail-tab-active")).toBe(false);
|
|
// Verify no inline padding/fontSize (responsive CSS controls this)
|
|
expect((tabs[0] as HTMLElement).style.padding).toBe("");
|
|
expect((tabs[0] as HTMLElement).style.fontSize).toBe("");
|
|
});
|
|
|
|
it("detail-tabs container uses CSS class instead of inline styles", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const tabsContainer = container.querySelector(".detail-tabs");
|
|
expect(tabsContainer).toBeTruthy();
|
|
// Should not have inline display/borderBottom styles — CSS class handles it
|
|
expect((tabsContainer as HTMLElement).style.display).toBe("");
|
|
expect((tabsContainer as HTMLElement).style.borderBottom).toBe("");
|
|
});
|
|
|
|
it("detail-body is present and scrollable (flex: 1 + overflow-y: auto via CSS)", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const body = container.querySelector(".detail-body");
|
|
expect(body).toBeTruthy();
|
|
});
|
|
|
|
it("modal-actions contains Delete and Pause buttons for non-done tasks", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "in-progress" as Column })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Delete")).toBeTruthy();
|
|
expect(screen.getByText("Pause")).toBeTruthy();
|
|
});
|
|
|
|
it("in-review modal-actions contains Merge & Close and Back to In Progress buttons", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "in-review" as Column })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Merge & Close")).toBeTruthy();
|
|
expect(screen.getByText("Back to In Progress")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("dependency dropdown search", () => {
|
|
const searchTasks: Task[] = [
|
|
{ id: "KB-010", title: "Fix login bug", description: "Users cannot log in", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
|
|
{ id: "KB-020", title: "Add dark mode", description: "Theme support", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" },
|
|
{ id: "KB-030", title: "Refactor API", description: "Clean up endpoints", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" },
|
|
{ id: "KB-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-15T00:00:00Z", updatedAt: "2026-03-15T00:00:00Z" },
|
|
];
|
|
|
|
function renderWithSearch(taskOverrides: Partial<TaskDetail> = {}) {
|
|
return render(
|
|
<TaskDetailModal
|
|
task={makeTask(taskOverrides)}
|
|
tasks={searchTasks}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
it("shows search input when dropdown is opened", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
expect(input).toBeTruthy();
|
|
expect(input.placeholder).toBe("Search tasks…");
|
|
});
|
|
|
|
it("filters tasks by search term", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "login" } });
|
|
|
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("KB-010");
|
|
});
|
|
|
|
it("matches task ID case-insensitively", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "kb-020" } });
|
|
|
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("KB-020");
|
|
});
|
|
|
|
it("matches task title", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "dark mode" } });
|
|
|
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("KB-020");
|
|
});
|
|
|
|
it("shows empty state when search matches nothing", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "zzz-nonexistent" } });
|
|
|
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
|
expect(items).toHaveLength(0);
|
|
expect(document.querySelector(".dep-dropdown-empty")?.textContent).toBe("No available tasks");
|
|
});
|
|
|
|
it("resets search when dropdown closes and reopens", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "login" } });
|
|
expect(input.value).toBe("login");
|
|
|
|
// Close by clicking again
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
expect(document.querySelector(".dep-dropdown")).toBeNull();
|
|
|
|
// Reopen
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const newInput = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
expect(newInput.value).toBe("");
|
|
// All items visible again
|
|
expect(document.querySelectorAll(".dep-dropdown-item")).toHaveLength(3);
|
|
});
|
|
});
|
|
});
|