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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-09 02:23:54 -07:00
parent ccf2ffe93d
commit ef99dcd1b0
4 changed files with 94 additions and 4 deletions

View File

@@ -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.
<!-- FNXC:DashboardTests 2026-08-09-08:59: FN-8894 requires mutation-response fixtures to model TaskStore's advancing update clock, rather than reusing a frozen task factory timestamp. -->
### 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`.

View File

@@ -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) => <TaskDetailModal task={nextTask} onClose={noop} onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} onTaskUpdated={(updatedTask) => 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) => <TaskDetailModal task={nextTask} onClose={noop} onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} onTaskUpdated={(updatedTask) => 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);

View File

@@ -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;
});

View File

@@ -229,6 +229,21 @@ export function makeTask(overrides: Partial<TaskDetail> = {}): 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>): 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