From ef99dcd1b0c0d539000ce622f04b0a0aa760a2c8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 9 Aug 2026 02:23:54 -0700 Subject: [PATCH] FN-8894: model advancing clocks in oversight tests Ensure TaskDetailModal mutation fixtures mirror server update timestamps. - Add an advancing-clock task fixture helper for mutation responses - Update desktop and mobile oversight control mocks to use newer task snapshots - Cover equal-clock merge behavior and document the fixture contract Files changed: docs/testing.md | 6 ++ .../TaskDetailModal.oversight-controls.test.tsx | 74 +++++++++++++++++++++- .../TaskDetailModal.oversight-mobile.test.tsx | 3 +- .../__tests__/TaskDetailModal.test-helpers.ts | 15 +++++ 4 files changed, 94 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8894 Fusion-Task-Lineage: 614eb85a-0fbf-4f44-a08d-347f2511499b Co-authored-by: Fusion (runfusion.ai) --- docs/testing.md | 6 ++ ...askDetailModal.oversight-controls.test.tsx | 74 ++++++++++++++++++- .../TaskDetailModal.oversight-mobile.test.tsx | 3 +- .../__tests__/TaskDetailModal.test-helpers.ts | 15 ++++ 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 171fa74086..0f2e03a442 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -67,6 +67,12 @@ Agents running verification through `fn_run_verification` are bounded by default Dashboard app tests that inspect CSS or TypeScript source must use `packages/dashboard/app/test/cssFixture.ts` helpers such as `readAppFile()` and `loadComponentCss()`. Never read a bare relative path or construct a source path from `process.cwd()`; root-anchored Vitest launches otherwise fail at import time. `scripts/check-no-cwd-relative-dashboard-test-reads.mjs` enforces this convention in the full-suite pretest hook and merge gate. + + +### Dashboard mutation-response clocks + +Mocks that simulate `updateTask` or `moveTask` server responses must return a task with a strictly newer `updatedAt`. In `TaskDetailModal` suites, use `makeUpdatedTask(current, patch)` rather than rebuilding a response with `makeTask({ ...current, ...patch })`. `mergeTaskSnapshot` intentionally permits equal-clock sparse payloads to fill only absent fields; a frozen response can therefore retain populated detail metadata and falsely make a control appear not to repaint. + ## Fresh-worktree dist bootstrap `pnpm test` auto-runs `scripts/ensure-test-artifacts.mjs` to rebuild missing/stale dist artifacts. Dashboard and `dependency-graph` package lanes auto-bootstrap too. If you hit opaque `Failed to resolve import "./cli-spawn.js"` (or similar), treat it as bootstrap regression against FN-4605 — don't work around with a manual `pnpm build`. diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx index 7e3bed4f9c..90d9ba96b5 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx @@ -19,6 +19,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import type { PlannerOverseerRuntimeSnapshot } from "@fusion/core"; import { makeTask, + makeUpdatedTask, noop, noopDelete, noopMerge, @@ -58,6 +59,13 @@ async function openOversightMenu() { return trigger; } +/* +FNXC:PlannerOversight 2026-08-09-08:59: +FN-8894 repairs these mutation fixtures because frozen `makeTask` clocks made simulated `updateTask` +responses violate TaskStore's always-advancing update clock. `mergeTaskSnapshot` correctly rejected the +populated advisor field from that equal-clock response, leaving the Eye/EyeOff trigger stale; mutation +mocks in this suite must advance the clock so they model a real server response. +*/ describe("TaskDetailModal oversight controls", () => { it("uses the Eye icon for the labeled oversight overflow trigger", async () => { render( @@ -193,7 +201,7 @@ describe("TaskDetailModal oversight controls", () => { }); vi.mocked(api.fetchWorkflowSettingValues).mockImplementationOnce(() => new Promise(() => {})); vi.mocked(api.updateTask).mockImplementation(async (_id, patch) => { - currentTask = makeTask({ ...currentTask, ...patch }); + currentTask = makeUpdatedTask(currentTask, patch); return currentTask as any; }); @@ -260,7 +268,7 @@ describe("TaskDetailModal oversight controls", () => { defaults: {}, }); vi.mocked(api.updateTask).mockImplementation(async (_id, patch) => { - currentTask = makeTask({ ...currentTask, ...patch }); + currentTask = makeUpdatedTask(currentTask, patch); return currentTask as any; }); @@ -306,7 +314,7 @@ describe("TaskDetailModal oversight controls", () => { sessionAdvisorEnabled: false, }); mockUpdate.mockImplementation(async (_id, patch) => { - currentTask = makeTask({ ...currentTask, ...patch }); + currentTask = makeUpdatedTask(currentTask, patch); return currentTask as any; }); @@ -357,6 +365,66 @@ describe("TaskDetailModal oversight controls", () => { }); }); + it("retains a populated advisor override for an equal-clock mutation, then repaints for a newer response", async () => { + const api = await import("../../api"); + let currentTask = makeTask({ id: "FN-8894-equal-clock", column: "in-progress", plannerOversightLevel: "off", sessionAdvisorEnabled: false }); + vi.mocked(api.fetchSettings).mockResolvedValueOnce({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, sessionAdvisorEnabledByDefault: false } as any); + vi.mocked(api.fetchBoardWorkflows).mockResolvedValue({ flagEnabled: true, defaultWorkflowId: "WF-8894-equal-clock", workflows: [{ id: "WF-8894-equal-clock", name: "Equal-clock workflow", columns: [] } as any], taskWorkflowIds: { [currentTask.id]: "WF-8894-equal-clock" } }); + vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: { plannerOversightLevel: "off", plannerOverseerAdvisorEnabled: true }, defaults: {} }); + let updateCount = 0; + vi.mocked(api.updateTask).mockImplementation(async (_id, patch) => { + updateCount += 1; + currentTask = updateCount === 1 + ? makeTask({ ...currentTask, ...patch, updatedAt: currentTask.updatedAt }) + : makeUpdatedTask(currentTask, patch); + return currentTask as any; + }); + + let rerenderModal: (nextTask: typeof currentTask) => void; + const renderModal = (nextTask: typeof currentTask) => rerenderModal(updatedTask as typeof currentTask)} addToast={noop} />; + const rendered = render(renderModal(currentTask)); + rerenderModal = (nextTask) => rendered.rerender(renderModal(nextTask)); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + await openOversightMenu(); + fireEvent.click(await screen.findByTestId("detail-session-advisor-toggle")); + await waitFor(() => { + expect(trigger.querySelector('[data-testid="eye-off-icon"]')).toBeInTheDocument(); + expect(screen.getByTestId("detail-session-advisor-toggle")).toHaveAttribute("aria-pressed", "false"); + }); + + fireEvent.click(await screen.findByTestId("detail-session-advisor-toggle")); + await waitFor(() => { + expect(trigger.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument(); + expect(screen.getByTestId("detail-session-advisor-toggle")).toHaveAttribute("aria-pressed", "true"); + }); + }); + + it("fills an absent advisor override from an equal-clock mutation response", async () => { + const api = await import("../../api"); + let currentTask = makeTask({ id: "FN-8894-absent-clock", column: "in-progress", plannerOversightLevel: "off", sessionAdvisorEnabled: undefined }); + vi.mocked(api.fetchSettings).mockResolvedValueOnce({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, sessionAdvisorEnabledByDefault: false } as any); + vi.mocked(api.fetchBoardWorkflows).mockResolvedValue({ flagEnabled: true, defaultWorkflowId: "WF-8894-absent-clock", workflows: [{ id: "WF-8894-absent-clock", name: "Absent-clock workflow", columns: [] } as any], taskWorkflowIds: { [currentTask.id]: "WF-8894-absent-clock" } }); + vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: { plannerOversightLevel: "off", plannerOverseerAdvisorEnabled: true }, defaults: {} }); + vi.mocked(api.updateTask).mockImplementation(async (_id, patch) => { + currentTask = makeTask({ ...currentTask, ...patch, updatedAt: currentTask.updatedAt }); + return currentTask as any; + }); + + let rerenderModal: (nextTask: typeof currentTask) => void; + const renderModal = (nextTask: typeof currentTask) => rerenderModal(updatedTask as typeof currentTask)} addToast={noop} />; + const rendered = render(renderModal(currentTask)); + rerenderModal = (nextTask) => rendered.rerender(renderModal(nextTask)); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + await openOversightMenu(); + fireEvent.click(await screen.findByTestId("detail-session-advisor-toggle")); + await waitFor(() => { + expect(trigger.querySelector('[data-testid="eye-off-icon"]')).toBeInTheDocument(); + expect(screen.getByTestId("detail-session-advisor-toggle")).toHaveAttribute("aria-pressed", "false"); + }); + }); + beforeEach(async () => { vi.clearAllMocks(); mockConfirm.mockResolvedValue(true); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx index 06afa169e2..f102f432bd 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx @@ -22,6 +22,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import type { PlannerOverseerRuntimeSnapshot } from "@fusion/core"; import { makeTask, + makeUpdatedTask, noop, noopDelete, noopMerge, @@ -138,7 +139,7 @@ describe("TaskDetailModal oversight controls — mobile overflow menu", () => { defaults: {}, }); vi.mocked(api.updateTask).mockImplementation(async (_id, patch) => { - currentTask = makeTask({ ...currentTask, ...patch }); + currentTask = makeUpdatedTask(currentTask, patch); return currentTask as any; }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index c4c48d3f51..73e94009d4 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -229,6 +229,21 @@ export function makeTask(overrides: Partial = {}): TaskDetail { } as TaskDetail; } +/** + * FNXC:PlannerOversight 2026-08-09-08:59: + * Mutation-response fixtures must model TaskStore's advancing update clock. + * `mergeTaskSnapshot` intentionally preserves populated fields from an equal-clock sparse response, + * so reusing `makeTask`'s fixed clock would simulate a stale payload rather than a server mutation. + */ +export function makeUpdatedTask(current: TaskDetail, patch: Partial): TaskDetail { + const currentUpdatedAt = Date.parse(current.updatedAt); + const nextUpdatedAt = new Date( + Number.isFinite(currentUpdatedAt) ? currentUpdatedAt + 1_000 : Date.now(), + ).toISOString().replace(/\.\d{3}Z$/, "Z"); + + return makeTask({ ...current, ...patch, updatedAt: nextUpdatedAt }); +} + /* FNXC:DashboardTests 2026-08-05-07:32: FN-8803 confirms initial slim-task hydration uses `fetchTaskDetail`, while visible