FN-8698: test independent Board and List task popups
Add regression coverage for independently retained task popups across Board and List views. - Exercise popup identity, refresh, visibility, and close behavior for the same task. - Cover real desktop and mobile Board/List interaction paths. Files changed: .../app/__tests__/App.taskPopupViewGating.test.tsx | 69 ++++++++++++- .../app/components/__tests__/App.test.tsx | 113 ++++++++++++++++++++- 2 files changed, 176 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-8698 Fusion-Task-Lineage: 857db4cb-0dd5-45ea-a7b7-9cb95f6f5fef Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { isTaskPopupVisibleForView, TASK_DETAIL_FLOATING_GEOMETRY_KEY } from "../App";
|
||||
import { FloatingWindow } from "../components/FloatingWindow";
|
||||
import type { PoppedOutTaskEntry } from "../hooks/usePoppedOutTasks";
|
||||
import { usePoppedOutTasks, type PoppedOutTaskEntry } from "../hooks/usePoppedOutTasks";
|
||||
import type { TaskView } from "../hooks/useViewState";
|
||||
|
||||
function task(id: string): Task {
|
||||
@@ -50,6 +51,27 @@ function expectVisibleTaskPopupShell(taskId: string, originTaskView?: TaskView)
|
||||
expect(overlay.className).not.toContain("floating-window-overlay--hidden");
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskPopupViewGating 2026-08-01-16:04:
|
||||
FN-8698 requires Board and List clicks for the same task to address distinct retained popups.
|
||||
This interaction harness mirrors App's current-view popup callback so it catches any task-ID-only
|
||||
open or refresh regression while exercising the real FloatingWindow hidden/inert contract.
|
||||
*/
|
||||
function BoardListPopupInteractionHarness({ initialTaskView }: { initialTaskView: "board" | "list" }) {
|
||||
const [taskView, setTaskView] = useState<TaskView>(initialTaskView);
|
||||
const { entries, popOut, close } = usePoppedOutTasks();
|
||||
const sharedTask = task("FN-8698");
|
||||
|
||||
return <>
|
||||
<button onClick={() => setTaskView("board")}>Show Board</button>
|
||||
<button onClick={() => setTaskView("list")}>Show List</button>
|
||||
<button onClick={() => popOut(sharedTask, taskView)}>Open current task</button>
|
||||
<button onClick={() => close(sharedTask.id, "board")}>Close Board task</button>
|
||||
<button onClick={() => close(sharedTask.id, "list")}>Close List task</button>
|
||||
<PopupGateHarness entries={entries} taskView={taskView} taskPopupsBoardListOnly />
|
||||
</>;
|
||||
}
|
||||
|
||||
const origins: TaskView[] = ["board", "list", "planning", "agents", "command-center", "missions", "documents", "plugin:sample"];
|
||||
|
||||
describe("App task popup view gating", () => {
|
||||
@@ -62,9 +84,48 @@ describe("App task popup view gating", () => {
|
||||
expectHiddenTaskPopupShell(entry.task.id, originTaskView);
|
||||
});
|
||||
|
||||
it("reproduces the planning-origin symptom and keeps another non-board/list view scoped", () => {
|
||||
it.each([["board", "list"], ["list", "board"]] as const)("opens %s then %s popups independently for the same task and closes each without affecting the other", (firstView, secondView) => {
|
||||
render(<BoardListPopupInteractionHarness initialTaskView={firstView} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open current task" }));
|
||||
expectVisibleTaskPopupShell("FN-8698", firstView);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: secondView === "board" ? "Show Board" : "Show List" }));
|
||||
expectHiddenTaskPopupShell("FN-8698", firstView);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open current task" }));
|
||||
expectVisibleTaskPopupShell("FN-8698", secondView);
|
||||
expectHiddenTaskPopupShell("FN-8698", firstView);
|
||||
|
||||
// Reopening on the current view refreshes its own instance rather than adding a duplicate.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open current task" }));
|
||||
expect(screen.getAllByTestId(/floating-window-task-detail-FN-8698-(board|list)$/)).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: secondView === "board" ? "Close Board task" : "Close List task" }));
|
||||
expect(screen.queryByTestId(popupTestId("FN-8698", secondView))).not.toBeInTheDocument();
|
||||
expectHiddenTaskPopupShell("FN-8698", firstView);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: firstView === "board" ? "Show Board" : "Show List" }));
|
||||
expectVisibleTaskPopupShell("FN-8698", firstView);
|
||||
fireEvent.click(screen.getByRole("button", { name: firstView === "board" ? "Close Board task" : "Close List task" }));
|
||||
expect(screen.queryByTestId(popupTestId("FN-8698", firstView))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps independently addressed Board and List popups visible together when view gating is disabled", () => {
|
||||
render(<PopupGateHarness
|
||||
entries={[
|
||||
{ task: task("FN-8698"), originTaskView: "board" },
|
||||
{ task: task("FN-8698"), originTaskView: "list" },
|
||||
]}
|
||||
taskView="planning"
|
||||
taskPopupsBoardListOnly={false}
|
||||
/>);
|
||||
|
||||
expectVisibleTaskPopupShell("FN-8698", "board");
|
||||
expectVisibleTaskPopupShell("FN-8698", "list");
|
||||
});
|
||||
|
||||
it("keeps another non-board/list view scoped", () => {
|
||||
expect(isTaskPopupVisibleForView({ taskPopupsBoardListOnly: true, taskView: "planning", originTaskView: "planning" })).toBe(true);
|
||||
expect(isTaskPopupVisibleForView({ taskPopupsBoardListOnly: true, taskView: "agents", originTaskView: "agents" })).toBe(true);
|
||||
expect(isTaskPopupVisibleForView({ taskPopupsBoardListOnly: true, taskView: "agents", originTaskView: "planning" })).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -293,9 +293,10 @@ vi.mock("../../components/TaskDetailModal", () => ({
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
TaskDetailContent: ({ task, onBackToBoard, onOpenDetail }: { task: { id: string; title?: string }; onBackToBoard?: () => void; onOpenDetail?: (task: { id: string; title?: string }) => void }) => (
|
||||
TaskDetailContent: ({ task, onBackToBoard, onOpenDetail, onRequestClose }: { task: { id: string; title?: string }; onBackToBoard?: () => void; onOpenDetail?: (task: { id: string; title?: string }) => void; onRequestClose?: () => void }) => (
|
||||
<section data-testid="main-panel-task-detail">
|
||||
<button type="button" onClick={onBackToBoard}>Back to board</button>
|
||||
{onRequestClose && <button type="button" aria-label="Close" onClick={onRequestClose}>Close</button>}
|
||||
<h2>{task.title ?? task.id}</h2>
|
||||
<button type="button" onClick={() => onOpenDetail?.({ id: "FN-6965", title: "Nested task" })}>Open nested task</button>
|
||||
</section>
|
||||
@@ -696,10 +697,118 @@ import { DEFAULT_BOARD_WORKFLOWS } from "./boardWorkflows.test-helpers";
|
||||
async function waitForAppShell(): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(fetchSettings).toHaveBeenCalled();
|
||||
expect(screen.getByTitle("Settings")).toBeTruthy();
|
||||
if (mockUseViewportMode() === "mobile") {
|
||||
expect(screen.getByTestId("mobile-view-toggle")).toBeTruthy();
|
||||
} else {
|
||||
expect(screen.getByTitle("Settings")).toBeTruthy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("FN-8698 retained Board and List task popups", () => {
|
||||
it.each([
|
||||
["desktop", "board", "list"],
|
||||
["desktop", "list", "board"],
|
||||
["mobile", "board", "list"],
|
||||
["mobile", "list", "board"],
|
||||
] as const)("opens %s %s then %s independently through real view affordances", async (viewport, firstView, secondView) => {
|
||||
/*
|
||||
FNXC:TaskPopupViewGating 2026-08-01-16:47:
|
||||
FN-8698 requires the real Board and List callback chain to preserve the origin view in both
|
||||
directions at desktop and phone breakpoints. A hook-only harness cannot catch a callback that
|
||||
drops that origin or mobile's separate header navigation path, so this App-level regression
|
||||
clicks each shipped card/row and view-switch affordance and closes each instance independently.
|
||||
*/
|
||||
if (viewport === "mobile") mockUseViewportMode.mockReturnValue("mobile");
|
||||
const sharedTask = {
|
||||
id: "FN-8698",
|
||||
title: "Retained popup regression task",
|
||||
description: "Verify Board and List popup identities remain independent.",
|
||||
column: "todo",
|
||||
status: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-08-01T00:00:00.000Z",
|
||||
updatedAt: "2026-08-01T00:00:00.000Z",
|
||||
};
|
||||
vi.mocked(fetchSettings).mockResolvedValue({
|
||||
...defaultSettings,
|
||||
openMobileTasksInPopup: true,
|
||||
taskPopupsBoardListOnly: true,
|
||||
});
|
||||
mockUseTasks.mockImplementation(() => ({
|
||||
tasks: [sharedTask],
|
||||
isStale: false,
|
||||
createTask: mockCreateTask,
|
||||
moveTask: vi.fn(),
|
||||
pauseTask: vi.fn(),
|
||||
unpauseTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
resetTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
loadArchivedTasks: vi.fn(),
|
||||
refreshTasks: vi.fn(),
|
||||
ingestCreatedTasks: vi.fn(),
|
||||
lastFetchTimeMs: Date.now(),
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
await waitForAppShell();
|
||||
|
||||
const taskSelector = (view: "board" | "list") => view === "board"
|
||||
? '.card[data-id="FN-8698"]'
|
||||
: viewport === "mobile"
|
||||
? '.list-card[data-id="FN-8698"]'
|
||||
: '.list-row[data-id="FN-8698"]';
|
||||
const popupFor = (view: "board" | "list") => `floating-window-task-detail-FN-8698-${view}`;
|
||||
const overlayFor = (view: "board" | "list") => `floating-window-overlay-task-detail-FN-8698-${view}`;
|
||||
const showView = async (view: "board" | "list") => {
|
||||
const navigationTestId = viewport === "mobile"
|
||||
? `mobile-view-toggle-${view}`
|
||||
: `sidebar-nav-${view}`;
|
||||
fireEvent.click(screen.getByTestId(navigationTestId));
|
||||
await waitFor(() => expect(document.querySelector(taskSelector(view))).toBeTruthy());
|
||||
};
|
||||
|
||||
if (firstView !== "board") await showView(firstView);
|
||||
fireEvent.click(document.querySelector(taskSelector(firstView))!);
|
||||
await waitFor(() => expect(screen.getByTestId(popupFor(firstView))).toBeTruthy());
|
||||
|
||||
await showView(secondView);
|
||||
expect(screen.getByTestId(overlayFor(firstView))).toHaveAttribute("aria-hidden", "true");
|
||||
|
||||
fireEvent.click(document.querySelector(taskSelector(secondView))!);
|
||||
await waitFor(() => expect(screen.getByTestId(popupFor(secondView))).toBeTruthy());
|
||||
expect(screen.getByTestId(overlayFor(secondView))).not.toHaveAttribute("aria-hidden");
|
||||
expect(screen.getByTestId(overlayFor(firstView))).toHaveAttribute("aria-hidden", "true");
|
||||
|
||||
if (viewport === "mobile") {
|
||||
fireEvent.click(within(screen.getByTestId(popupFor(secondView))).getByRole("button", { name: "Close" }));
|
||||
} else {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
}
|
||||
await waitFor(() => expect(screen.queryByTestId(popupFor(secondView))).toBeNull());
|
||||
expect(screen.getByTestId(popupFor(firstView))).toBeTruthy();
|
||||
|
||||
await showView(firstView);
|
||||
await waitFor(() => expect(screen.getByTestId(overlayFor(firstView))).not.toHaveAttribute("aria-hidden"));
|
||||
if (viewport === "mobile") {
|
||||
fireEvent.click(within(screen.getByTestId(popupFor(firstView))).getByRole("button", { name: "Close" }));
|
||||
} else {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
}
|
||||
await waitFor(() => expect(screen.queryByTestId(popupFor(firstView))).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__test_clearDashboardViewsCache();
|
||||
|
||||
Reference in New Issue
Block a user