feat(FN-1157): switch planning size controls to dropdowns
- Replace Summary view suggested size buttons with a select dropdown and keep loading-state disabling - Replace Breakdown view subtask size button groups with per-subtask size dropdowns - Add PlanningModeModal tests for summary and breakdown size dropdown rendering and change handling - Expand API test mocks used by planning breakdown flows to support the new coverage
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { PlanningModeModal } from "./PlanningModeModal";
|
||||
import { TaskDetailModal } from "./TaskDetailModal";
|
||||
import type { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult } from "@fusion/core";
|
||||
@@ -11,6 +11,9 @@ const mockConnectPlanningStream = vi.fn();
|
||||
const mockRespondToPlanning = vi.fn();
|
||||
const mockCancelPlanning = vi.fn();
|
||||
const mockCreateTaskFromPlanning = vi.fn();
|
||||
const mockStartPlanningBreakdown = vi.fn();
|
||||
const mockCreateTasksFromPlanning = vi.fn();
|
||||
const mockFetchAiSession = vi.fn();
|
||||
const mockUploadAttachment = vi.fn();
|
||||
const mockDeleteAttachment = vi.fn();
|
||||
const mockUpdateTask = vi.fn();
|
||||
@@ -29,6 +32,9 @@ vi.mock("../api", () => ({
|
||||
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
||||
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
||||
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
||||
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
||||
uploadAttachment: (...args: any[]) => mockUploadAttachment(...args),
|
||||
deleteAttachment: (...args: any[]) => mockDeleteAttachment(...args),
|
||||
updateTask: (...args: any[]) => mockUpdateTask(...args),
|
||||
@@ -108,7 +114,9 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
// Default mock for streaming
|
||||
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
|
||||
|
||||
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
|
||||
// Default: simulate receiving a question after a brief delay
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
@@ -419,6 +427,48 @@ describe("PlanningModeModal", () => {
|
||||
expect(container.querySelector(".planning-summary .planning-deps-list")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders and updates summary size dropdown", async () => {
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onSummary?.(mockSummary);
|
||||
}, 10);
|
||||
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||
fireEvent.change(textarea, { target: { value: "Build auth system" } });
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Planning Complete!")).toBeDefined();
|
||||
});
|
||||
|
||||
const sizeSelect = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(sizeSelect.value).toBe("M");
|
||||
expect(Array.from(sizeSelect.options).map((option) => option.textContent)).toEqual([
|
||||
"S (Small)",
|
||||
"M (Medium)",
|
||||
"L (Large)",
|
||||
]);
|
||||
|
||||
fireEvent.change(sizeSelect, { target: { value: "L" } });
|
||||
expect(sizeSelect.value).toBe("L");
|
||||
});
|
||||
|
||||
it("creates task from summary", async () => {
|
||||
const createdTask: Task = {
|
||||
id: "FN-042",
|
||||
@@ -473,6 +523,82 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Breakdown view", () => {
|
||||
it("renders and updates subtask size dropdown", async () => {
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onSummary?.(mockSummary);
|
||||
}, 10);
|
||||
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
mockStartPlanningBreakdown.mockResolvedValue({
|
||||
sessionId: "session-123",
|
||||
subtasks: [
|
||||
{
|
||||
id: "subtask-1",
|
||||
title: "Design auth schema",
|
||||
description: "Design the auth data model",
|
||||
suggestedSize: "M",
|
||||
dependsOn: [],
|
||||
},
|
||||
{
|
||||
id: "subtask-2",
|
||||
title: "Implement auth endpoints",
|
||||
description: "Create login/signup endpoints",
|
||||
suggestedSize: "S",
|
||||
dependsOn: ["subtask-1"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||
fireEvent.change(textarea, { target: { value: "Build auth system" } });
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Planning Complete!")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Break into Tasks"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningBreakdown).toHaveBeenCalledWith("session-123", undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Tasks")).toBeDefined();
|
||||
});
|
||||
|
||||
const firstSubtask = screen.getByTestId("subtask-item-0");
|
||||
const sizeSelect = within(firstSubtask).getByRole("combobox") as HTMLSelectElement;
|
||||
|
||||
expect(sizeSelect.value).toBe("M");
|
||||
expect(Array.from(sizeSelect.options).map((option) => option.textContent)).toEqual([
|
||||
"S",
|
||||
"M",
|
||||
"L",
|
||||
]);
|
||||
|
||||
fireEvent.change(sizeSelect, { target: { value: "L" } });
|
||||
expect(sizeSelect.value).toBe("L");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal smoke checks", () => {
|
||||
it("renders TaskDetailModal with the standard detail body structure", () => {
|
||||
const onMoveTask = vi.fn<(_: string, __: any) => Promise<Task>>().mockResolvedValue(mockTasks[0]);
|
||||
|
||||
@@ -734,10 +734,6 @@ function SummaryView({
|
||||
summary.suggestedDependencies
|
||||
);
|
||||
|
||||
const handleSizeChange = (size: "S" | "M" | "L") => {
|
||||
onSummaryChange({ ...summary, suggestedSize: size });
|
||||
};
|
||||
|
||||
const handleDependencyToggle = (taskId: string) => {
|
||||
const newDeps = selectedDependencies.includes(taskId)
|
||||
? selectedDependencies.filter((id) => id !== taskId)
|
||||
@@ -777,21 +773,21 @@ function SummaryView({
|
||||
|
||||
<div className="form-group">
|
||||
<label>Suggested Size</label>
|
||||
<div className="planning-size-selector">
|
||||
{(["S", "M", "L"] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
className={`planning-size-btn ${summary.suggestedSize === size ? "selected" : ""}`}
|
||||
onClick={() => handleSizeChange(size)}
|
||||
>
|
||||
{size}
|
||||
<span className="planning-size-label">
|
||||
{size === "S" ? "Small" : size === "M" ? "Medium" : "Large"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
className="planning-size-select"
|
||||
value={summary.suggestedSize}
|
||||
onChange={(event) =>
|
||||
onSummaryChange({
|
||||
...summary,
|
||||
suggestedSize: event.target.value as "S" | "M" | "L",
|
||||
})
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="S">S (Small)</option>
|
||||
<option value="M">M (Medium)</option>
|
||||
<option value="L">L (Large)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{tasks.length > 0 && (
|
||||
@@ -1138,19 +1134,20 @@ function BreakdownView({
|
||||
|
||||
<div className="form-group">
|
||||
<label>Size</label>
|
||||
<div className="planning-size-selector">
|
||||
{(["S", "M", "L"] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
className={`planning-size-btn ${subtask.suggestedSize === size ? "selected" : ""}`}
|
||||
onClick={() => updateSubtask(subtask.id, { suggestedSize: size })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
className="planning-size-select"
|
||||
value={subtask.suggestedSize}
|
||||
onChange={(event) =>
|
||||
updateSubtask(subtask.id, {
|
||||
suggestedSize: event.target.value as "S" | "M" | "L",
|
||||
})
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="S">S</option>
|
||||
<option value="M">M</option>
|
||||
<option value="L">L</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
|
||||
Reference in New Issue
Block a user