FN-8803: fix TaskDetailModal prompt refresh coverage

Align TaskDetailModal refresh tests with the narrow prompt API.

- Exercise visible plan refreshes through fetchTaskPrompt
- Reset prompt and detail API mocks to preserve Promise contracts

Files changed:
 .../TaskDetailModal.definition-actions.test.tsx    | 57 +++++++++++++---------
 .../__tests__/TaskDetailModal.test-helpers.ts      | 19 +++++---
 2 files changed, 47 insertions(+), 29 deletions(-)

Fusion-Task-Id: FN-8803

Fusion-Task-Lineage: e2539c24-377b-48bf-81d1-80206da7071c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-05 00:55:03 -07:00
parent fef3a6daf7
commit 907c051844
2 changed files with 47 additions and 29 deletions

View File

@@ -1801,38 +1801,49 @@ describe("TaskDetailModal", () => {
it("refreshes on show, re-entry, and visible planning polls", async () => {
vi.useFakeTimers();
const { fetchTaskDetail } = await import("../../api");
const { fetchTaskDetail, fetchTaskPrompt } = await import("../../api");
const mockFetchDetail = vi.mocked(fetchTaskDetail);
mockFetchDetail.mockReset();
mockFetchDetail
.mockResolvedValueOnce(makeTask({ id: "FN-fresh", column: "triage", status: "planning", prompt: "# First revision" }))
.mockResolvedValueOnce(makeTask({ id: "FN-fresh", column: "triage", status: "planning", prompt: "# Polled revision" }))
.mockResolvedValueOnce(makeTask({ id: "FN-fresh", column: "triage", status: "planning", prompt: "# Re-entered revision" }));
const mockFetchPrompt = vi.mocked(fetchTaskPrompt);
mockFetchDetail.mockClear();
mockFetchPrompt.mockReset();
mockFetchPrompt
.mockResolvedValueOnce({ id: "FN-fresh", prompt: "# First revision" })
.mockResolvedValueOnce({ id: "FN-fresh", prompt: "# Polled revision" })
.mockResolvedValueOnce({ id: "FN-fresh", prompt: "# Re-entered revision" });
render(<TaskDetailContent task={makeTask({ id: "FN-fresh", column: "triage", status: "planning", prompt: "" })} initialTab="definition" onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
render(<TaskDetailContent task={makeTask({ id: "FN-fresh", column: "triage", status: "planning", prompt: "" })} projectId="project-fresh" initialTab="definition" onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(screen.getByText("First revision")).toBeTruthy();
expect(mockFetchPrompt).toHaveBeenCalledWith("FN-fresh", "project-fresh");
expect(mockFetchDetail).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(screen.getByText("Polled revision")).toBeTruthy();
expect(mockFetchPrompt).toHaveBeenCalledTimes(2);
expect(mockFetchDetail).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Activity"));
await act(async () => { await vi.advanceTimersByTimeAsync(15_000); });
expect(mockFetchDetail).toHaveBeenCalledTimes(2);
expect(mockFetchPrompt).toHaveBeenCalledTimes(2);
expect(mockFetchDetail).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Plan"));
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(screen.getByText("Re-entered revision")).toBeTruthy();
expect(mockFetchPrompt).toHaveBeenCalledTimes(3);
expect(mockFetchPrompt).toHaveBeenLastCalledWith("FN-fresh", "project-fresh");
expect(mockFetchDetail).not.toHaveBeenCalled();
});
it("keeps an inline edit buffer stable while a Plan Review refresh arrives", async () => {
vi.useFakeTimers();
const { fetchTaskDetail } = await import("../../api");
const mockFetchDetail = vi.mocked(fetchTaskDetail);
mockFetchDetail.mockReset();
mockFetchDetail
.mockResolvedValueOnce(makeTask({ id: "FN-edit", column: "todo", prompt: "# Server revision", workflowStepResults: [{ workflowStepId: "plan-review", status: "pending", startedAt: "2026-08-03T02:00:00Z" }] }))
.mockResolvedValueOnce(makeTask({ id: "FN-edit", column: "todo", prompt: "# New server revision", workflowStepResults: [{ workflowStepId: "plan-review", status: "pending", startedAt: "2026-08-03T02:00:00Z" }] }));
const { fetchTaskPrompt } = await import("../../api");
const mockFetchPrompt = vi.mocked(fetchTaskPrompt);
mockFetchPrompt.mockReset();
mockFetchPrompt
.mockResolvedValueOnce({ id: "FN-edit", prompt: "# Server revision" })
.mockResolvedValueOnce({ id: "FN-edit", prompt: "# New server revision" });
render(<TaskDetailContent task={makeTask({ id: "FN-edit", column: "todo", prompt: "# Initial", workflowStepResults: [{ workflowStepId: "plan-review", status: "pending", startedAt: "2026-08-03T02:00:00Z" }] })} initialTab="definition" onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
@@ -1843,24 +1854,24 @@ describe("TaskDetailModal", () => {
fireEvent.change(textarea, { target: { value: "# Local operator edit" } });
const sameTextarea = textarea;
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(mockFetchDetail).toHaveBeenCalledTimes(2);
expect(mockFetchPrompt).toHaveBeenCalledTimes(2);
expect(document.querySelector(".spec-editor-textarea")).toBe(sameTextarea);
expect(textarea.value).toBe("# Local operator edit");
});
it("ignores a late planning response after the task changes", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetchDetail = vi.mocked(fetchTaskDetail);
let resolveFirst: (detail: TaskDetail) => void = () => {};
mockFetchDetail.mockReset();
mockFetchDetail
.mockImplementationOnce(() => new Promise<TaskDetail>((resolve) => { resolveFirst = resolve; }))
.mockResolvedValueOnce(makeTask({ id: "FN-current", column: "triage", status: "planning", prompt: "# Current task" }));
const { fetchTaskPrompt } = await import("../../api");
const mockFetchPrompt = vi.mocked(fetchTaskPrompt);
let resolveFirst: (response: { id: string; prompt?: string }) => void = () => {};
mockFetchPrompt.mockReset();
mockFetchPrompt
.mockImplementationOnce(() => new Promise<{ id: string; prompt?: string }>((resolve) => { resolveFirst = resolve; }))
.mockResolvedValueOnce({ id: "FN-current", prompt: "# Current task" });
const props = { initialTab: "definition" as const, onMoveTask: noopMove, onDeleteTask: noopDelete, onMergeTask: noopMerge, onOpenDetail: noopOpenDetail, addToast: noop };
const view = render(<TaskDetailContent {...props} task={makeTask({ id: "FN-old", column: "triage", status: "planning", prompt: "# Old task" })} />);
view.rerender(<TaskDetailContent {...props} task={makeTask({ id: "FN-current", column: "triage", status: "planning", prompt: "" })} />);
await waitFor(() => expect(screen.getByText("Current task")).toBeTruthy());
await act(async () => { resolveFirst(makeTask({ id: "FN-old", column: "triage", status: "planning", prompt: "# Stale task" })); });
await act(async () => { resolveFirst({ id: "FN-old", prompt: "# Stale task" }); });
expect(screen.queryByText("Stale task")).toBeNull();
expect(screen.getByText("Current task")).toBeTruthy();
});

View File

@@ -230,11 +230,11 @@ export function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
}
/*
FNXC:DashboardTests 2026-08-04-15:05:
Definition refresh always chains `fetchTaskDetail(...).then(...)`; a bare Vitest
mock returns undefined and is not a valid stand-in for the production Promise contract.
Use this reset helper after an intentional override so later tests regain a resolved
TaskDetail default instead of leaking an impossible undefined response.
FNXC:DashboardTests 2026-08-05-07:32:
FN-8803 confirms initial slim-task hydration uses `fetchTaskDetail`, while visible
Definition refresh uses `fetchTaskPrompt`. Both mocks must retain Promise-returning
defaults after an individual test resets them, so later tests cannot leak an impossible
undefined response into either production request boundary.
*/
export async function resetTaskDetailFetchMock(): Promise<void> {
const { fetchTaskDetail } = await import("../../api");
@@ -242,6 +242,12 @@ export async function resetTaskDetailFetchMock(): Promise<void> {
vi.mocked(fetchTaskDetail).mockResolvedValue(makeTask());
}
export async function resetTaskPromptFetchMock(): Promise<void> {
const { fetchTaskPrompt } = await import("../../api");
vi.mocked(fetchTaskPrompt).mockReset();
vi.mocked(fetchTaskPrompt).mockResolvedValue({ id: "FN-099", prompt: "# Task FN-099" });
}
export const noop = vi.fn();
export const noopMove = vi.fn(async () => ({}) as Task);
export const noopDelete = vi.fn(async () => ({}) as Task);
@@ -274,8 +280,9 @@ export function loadDashboardCss(): string {
export function setupTaskDetailModalHooks(): void {
beforeEach(async () => {
// FNXC:DashboardTests 2026-08-04-15:05: Every TaskDetailModal suite begins with the Promise-returning detail contract; tests layer pending/rejected/custom responses after this reset.
// FNXC:DashboardTests 2026-08-05-07:32: Every TaskDetailModal suite begins with Promise-returning full-detail and narrow-prompt contracts; tests layer pending/rejected/custom responses after these resets.
await resetTaskDetailFetchMock();
await resetTaskPromptFetchMock();
mockConfirm.mockReset();
mockConfirmWithChoice.mockReset();
mockConfirmWithCheckbox.mockReset();