Files
fusion/packages/dashboard/app/hooks/__tests__/useMemoryData.test.ts
gsxdsm 1d2d73ba5c fix(memory): fix insights parsing + modernize Memory, Insights, Todos, and agent Memory UI
- parseInsightsContent stripped bullet prefixes before filtering for them, so every
  insights category rendered as one blob and counts were wrong (5 shown vs 84 real)
- drop dead GET /memory and GET /memory/stats mount fetches from useMemoryData and
  stop refetching the file list on every file selection
- Memory view: full-width layout, accent tabs, 2-column Engines card grid, remove
  duplicated capability badges, correct spacing-token-as-font-size rules
- Todos: single-row items with quiet inline action cluster (stacked on narrow/mobile)
- Insights: flat card list (no card-in-card), 28px/16px actions muted until hover
- Agent Memory tab: shared FileEditor (CodeMirror) for memory files, per-section save
  actions, distinct inline-toggle aria-labels, fix {{date}} i18n interpolation
- PR screenshots under docs/assets/memory-ui-review-2026-07/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 17:16:49 -07:00

302 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useMemoryData } from "../useMemoryData";
/*
FNXC:MemoryView 2026-07-10-23:00:
The legacy single-file working-memory surface (fetchMemory/saveMemory) and the unused
fetchMemoryStats call were removed from useMemoryData — MemoryView is a multi-file editor
(fetchMemoryFiles/fetchMemoryFile/saveMemoryFile) and the Engines card uses the audit
report. These tests cover the multi-file flow, and assert the removed endpoints are no
longer called on mount.
*/
// Mock API functions
vi.mock("../../api", () => ({
fetchMemory: vi.fn(),
saveMemory: vi.fn(),
fetchMemoryInsights: vi.fn(),
saveMemoryInsights: vi.fn(),
triggerInsightExtraction: vi.fn(),
fetchMemoryAudit: vi.fn(),
fetchMemoryStats: vi.fn(),
compactMemory: vi.fn(),
fetchMemoryBackendStatus: vi.fn(),
fetchSettings: vi.fn(),
updateSettings: vi.fn(),
fetchMemoryFiles: vi.fn(),
fetchMemoryFile: vi.fn(),
saveMemoryFile: vi.fn(),
installQmd: vi.fn(),
testMemoryRetrieval: vi.fn(),
triggerMemoryDreams: vi.fn(),
}));
// Mock useMemoryBackendStatus hook
vi.mock("../useMemoryBackendStatus", () => ({
useMemoryBackendStatus: vi.fn(() => ({
status: {
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
},
loading: false,
})),
}));
// Import mocked functions
import {
fetchMemory,
fetchMemoryStats,
fetchMemoryInsights,
saveMemoryInsights,
triggerInsightExtraction,
fetchMemoryAudit,
compactMemory,
fetchMemoryFiles,
fetchMemoryFile,
saveMemoryFile,
fetchSettings,
triggerMemoryDreams,
} from "../../api";
const MEMORY_FILES = [
{
path: ".fusion/memory/MEMORY.md",
label: "Long-term memory",
layer: "long-term" as const,
size: 100,
updatedAt: "2024-01-01T00:00:00.000Z",
},
{
path: ".fusion/memory/2024-01-01.md",
label: "Daily memory",
layer: "daily" as const,
size: 40,
updatedAt: "2024-01-01T00:00:00.000Z",
},
];
const AUDIT_REPORT = {
generatedAt: "2024-01-01T00:00:00.000Z",
workingMemory: { exists: true, size: 100, sectionCount: 2 },
insightsMemory: { exists: true, size: 50, insightCount: 5, categories: { pattern: 3 } },
extraction: {
runAt: "2024-01-01T00:00:00.000Z",
success: true,
insightCount: 5,
duplicateCount: 0,
skippedCount: 0,
summary: "Extracted 5 insights",
},
pruning: { applied: false, reason: "No pruning needed", sizeDelta: 0, originalSize: 50, newSize: 50 },
checks: [],
health: "healthy" as const,
};
function mockDefaults(): void {
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: "## Patterns\n- Pattern 1", exists: true });
vi.mocked(fetchMemoryAudit).mockResolvedValue(AUDIT_REPORT);
vi.mocked(fetchMemoryFiles).mockResolvedValue({ files: MEMORY_FILES });
vi.mocked(fetchMemoryFile).mockResolvedValue({ path: ".fusion/memory/MEMORY.md", content: "# Long-term" });
vi.mocked(fetchSettings).mockResolvedValue({ memoryEnabled: true } as Awaited<ReturnType<typeof fetchSettings>>);
}
describe("useMemoryData", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDefaults();
});
it("fetches memory files, insights, and audit on mount — and does NOT call the removed legacy endpoints", async () => {
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
expect(result.current.insightsLoading).toBe(true);
expect(result.current.auditLoading).toBe(true);
await waitFor(() => {
expect(result.current.insightsLoading).toBe(false);
expect(result.current.auditLoading).toBe(false);
expect(result.current.memoryFilesLoading).toBe(false);
});
expect(fetchMemoryFiles).toHaveBeenCalledWith("test-project");
expect(fetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/MEMORY.md", "test-project");
expect(fetchMemoryInsights).toHaveBeenCalledWith("test-project");
expect(fetchMemoryAudit).toHaveBeenCalledWith("test-project");
// Removed legacy surface: no single-file working-memory or stats fetch on mount
expect(fetchMemory).not.toHaveBeenCalled();
expect(fetchMemoryStats).not.toHaveBeenCalled();
expect(result.current.memoryFiles).toHaveLength(2);
expect(result.current.selectedFilePath).toBe(".fusion/memory/MEMORY.md");
expect(result.current.selectedFileContent).toBe("# Long-term");
expect(result.current.insightsContent).toBe("## Patterns\n- Pattern 1");
expect(result.current.insightsExists).toBe(true);
expect(result.current.auditReport?.health).toBe("healthy");
});
it("selectFile loads the new file without refetching the whole file list", async () => {
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
await waitFor(() => {
expect(result.current.memoryFilesLoading).toBe(false);
});
expect(fetchMemoryFiles).toHaveBeenCalledTimes(1);
vi.mocked(fetchMemoryFile).mockResolvedValue({ path: ".fusion/memory/2024-01-01.md", content: "daily notes" });
await act(async () => {
await result.current.selectFile(".fusion/memory/2024-01-01.md");
});
expect(result.current.selectedFilePath).toBe(".fusion/memory/2024-01-01.md");
expect(result.current.selectedFileContent).toBe("daily notes");
expect(result.current.selectedFileDirty).toBe(false);
// Selection changes must not re-trigger the mount-time file list fetch
expect(fetchMemoryFiles).toHaveBeenCalledTimes(1);
});
it("marks the selected file dirty on edit and saveSelectedFile persists it", async () => {
vi.mocked(saveMemoryFile).mockResolvedValue({ success: true });
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
await waitFor(() => {
expect(result.current.memoryFilesLoading).toBe(false);
});
act(() => {
result.current.setSelectedFileContent("# Long-term (edited)");
});
expect(result.current.selectedFileDirty).toBe(true);
await act(async () => {
await result.current.saveSelectedFile();
});
expect(saveMemoryFile).toHaveBeenCalledWith(".fusion/memory/MEMORY.md", "# Long-term (edited)", "test-project");
expect(result.current.selectedFileDirty).toBe(false);
expect(result.current.savingSelectedFile).toBe(false);
});
it("extractInsights calls API then refreshes insights and audit", async () => {
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
vi.mocked(triggerInsightExtraction).mockResolvedValue({
success: true,
summary: "Extracted 3 insights",
insightCount: 3,
pruned: false,
});
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
await waitFor(() => {
expect(result.current.auditLoading).toBe(false);
});
let extractResult: { success: boolean; summary: string } | undefined;
await act(async () => {
extractResult = await result.current.extractInsights();
});
expect(triggerInsightExtraction).toHaveBeenCalledWith("test-project");
expect(extractResult).toEqual({ success: true, summary: "Extracted 3 insights" });
expect(result.current.extracting).toBe(false);
expect(fetchMemoryInsights).toHaveBeenCalledTimes(2); // Initial + refresh
expect(fetchMemoryAudit).toHaveBeenCalledTimes(2); // Initial + refresh
});
it("compactMemory compacts the selected file in place and reloads the file list", async () => {
vi.mocked(compactMemory).mockResolvedValue({
path: ".fusion/memory/MEMORY.md",
content: "Compacted memory content",
});
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
await waitFor(() => {
expect(result.current.memoryFilesLoading).toBe(false);
});
await act(async () => {
await result.current.compactMemory(".fusion/memory/MEMORY.md");
});
expect(compactMemory).toHaveBeenCalledWith(".fusion/memory/MEMORY.md", "test-project");
expect(result.current.selectedFileContent).toBe("Compacted memory content");
expect(result.current.selectedFileDirty).toBe(false);
expect(result.current.compacting).toBe(false);
expect(fetchMemoryFiles).toHaveBeenCalledTimes(2); // Initial + reload after compaction
});
it("saveInsights calls API then refreshes insights", async () => {
vi.mocked(fetchMemoryInsights)
.mockResolvedValueOnce({ content: null, exists: false })
.mockResolvedValueOnce({ content: "New insights content", exists: true });
vi.mocked(saveMemoryInsights).mockResolvedValue({ success: true });
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
await waitFor(() => {
expect(result.current.insightsLoading).toBe(false);
});
await act(async () => {
await result.current.saveInsights("New insights content");
});
expect(saveMemoryInsights).toHaveBeenCalledWith("New insights content", "test-project");
expect(fetchMemoryInsights).toHaveBeenCalledTimes(2); // Initial + refresh
expect(result.current.insightsContent).toBe("New insights content");
});
it("triggerDreamNow calls triggerMemoryDreams", async () => {
vi.mocked(triggerMemoryDreams).mockResolvedValue({ success: true, summary: "done" });
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
await waitFor(() => {
expect(result.current.memoryFilesLoading).toBe(false);
});
await act(async () => {
await result.current.triggerDreamNow();
});
expect(triggerMemoryDreams).toHaveBeenCalledWith("test-project");
expect(result.current.dreamRunning).toBe(false);
});
it("triggerDreamNow propagates API errors and resets state", async () => {
vi.mocked(triggerMemoryDreams).mockRejectedValue(
new Error("Memory dreams are disabled. Enable dream processing in memory settings first."),
);
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
await waitFor(() => {
expect(result.current.memoryFilesLoading).toBe(false);
});
await act(async () => {
await expect(result.current.triggerDreamNow()).rejects.toThrow(
"Memory dreams are disabled. Enable dream processing in memory settings first.",
);
});
expect(triggerMemoryDreams).toHaveBeenCalledTimes(1);
expect(result.current.dreamRunning).toBe(false);
});
});