From 3e7f498a0cd3485934a6877a11ba24c4f844f781 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Wed, 13 May 2026 09:12:13 +0000 Subject: [PATCH] test(canvas): add pure-function coverage for AuditTrailPanel + MemoryInspectorPanel Adds unit tests for exported helpers: - formatAuditRelativeTime: boundary cases for minute/hour/day - isPluginUnavailableError: MEMORY_PLUGIN_URL detection, null/undefined edge cases - formatTTL: null/undefined/expired/second/minute/hour/day boundaries Co-Authored-By: Claude Opus 4.7 --- .../__tests__/AuditTrailPanel.test.ts | 63 +++++++++++++ .../__tests__/MemoryInspectorPanel.test.ts | 90 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 canvas/src/components/__tests__/AuditTrailPanel.test.ts create mode 100644 canvas/src/components/__tests__/MemoryInspectorPanel.test.ts diff --git a/canvas/src/components/__tests__/AuditTrailPanel.test.ts b/canvas/src/components/__tests__/AuditTrailPanel.test.ts new file mode 100644 index 00000000..4d16eb9f --- /dev/null +++ b/canvas/src/components/__tests__/AuditTrailPanel.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +/** + * Unit tests for formatAuditRelativeTime — pure date formatter from AuditTrailPanel. + */ +import { describe, it, expect } from "vitest"; +import { formatAuditRelativeTime } from "../AuditTrailPanel"; + +describe("formatAuditRelativeTime", () => { + it('returns "just now" for timestamps within the last minute', () => { + const now = 1_700_000_000_000; + const thirtySecAgo = new Date(now - 30_000).toISOString(); + expect(formatAuditRelativeTime(thirtySecAgo, now)).toBe("just now"); + }); + + it('returns "Xm ago" for timestamps within the last hour', () => { + const now = 1_700_000_000_000; + const fiveMinAgo = new Date(now - 5 * 60_000).toISOString(); + expect(formatAuditRelativeTime(fiveMinAgo, now)).toBe("5m ago"); + }); + + it('returns "Xh ago" for timestamps within the last day', () => { + const now = 1_700_000_000_000; + const threeHoursAgo = new Date(now - 3 * 3_600_000).toISOString(); + expect(formatAuditRelativeTime(threeHoursAgo, now)).toBe("3h ago"); + }); + + it("returns locale date string for timestamps older than 24h", () => { + const now = 1_700_000_000_000; + const twoDaysAgo = new Date(now - 2 * 86_400_000).toISOString(); + const result = formatAuditRelativeTime(twoDaysAgo, now); + // Should be a date string (not "Xh ago" or "Xm ago") + expect(result).not.toMatch(/m ago|h ago|just now/); + expect(result).toBe(new Date(twoDaysAgo).toLocaleDateString()); + }); + + it("handles the boundary between minute and hour correctly", () => { + const now = 1_700_000_000_000; + const exactlyOneHourAgo = new Date(now - 3_600_000).toISOString(); + expect(formatAuditRelativeTime(exactlyOneHourAgo, now)).toBe("1h ago"); + }); + + it("handles the boundary between hour and day correctly", () => { + const now = 1_700_000_000_000; + // 23h ago is < 24h so it shows "23h ago"; exactly 24h falls through to date string + const twentyThreeHoursAgo = new Date(now - 23 * 3_600_000).toISOString(); + expect(formatAuditRelativeTime(twentyThreeHoursAgo, now)).toBe("23h ago"); + }); + + it("returns locale date string for exactly 24h ago (boundary)", () => { + const now = 1_700_000_000_000; + const exactlyOneDayAgo = new Date(now - 86_400_000).toISOString(); + const result = formatAuditRelativeTime(exactlyOneDayAgo, now); + // diff is exactly 86_400_000, which is NOT < 86_400_000, so it falls through + expect(result).toBe(new Date(exactlyOneDayAgo).toLocaleDateString()); + }); + + it("future timestamps return 'just now' (negative diff < 60_000)", () => { + const now = 1_700_000_000_000; + const future = new Date(now + 60_000).toISOString(); + // Negative diff passes diff < 60_000, returning "just now" + expect(formatAuditRelativeTime(future, now)).toBe("just now"); + }); +}); diff --git a/canvas/src/components/__tests__/MemoryInspectorPanel.test.ts b/canvas/src/components/__tests__/MemoryInspectorPanel.test.ts new file mode 100644 index 00000000..2ea69e56 --- /dev/null +++ b/canvas/src/components/__tests__/MemoryInspectorPanel.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +/** + * Unit tests for pure helpers from MemoryInspectorPanel: + * isPluginUnavailableError, formatRelativeTime, formatTTL + * + * These are the three exported non-component functions. The component + * itself (MemoryInspectorPanel) requires full API + store mocking and + * is exercised by the existing MemoryTab.test.tsx. + */ +import { describe, it, expect } from "vitest"; +import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel"; + +// formatRelativeTime is not exported — tested via the component in MemoryTab.test.tsx + +describe("isPluginUnavailableError", () => { + it("returns true when Error message contains MEMORY_PLUGIN_URL", () => { + const err = new Error("memory: could not resolve MEMORY_PLUGIN_URL — plugin not configured"); + expect(isPluginUnavailableError(err)).toBe(true); + }); + + it("returns true for Error containing MEMORY_PLUGIN_URL", () => { + expect(isPluginUnavailableError(new Error("MEMORY_PLUGIN_URL is not set"))).toBe(true); + }); + + it("returns false for unrelated error messages", () => { + expect(isPluginUnavailableError(new Error("workspace not found"))).toBe(false); + }); + + it("returns false for null", () => { + expect(isPluginUnavailableError(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isPluginUnavailableError(undefined)).toBe(false); + }); + + it("returns false for plain objects without message", () => { + expect(isPluginUnavailableError({ code: 503 })).toBe(false); + }); + + it("is case-sensitive (MEMORY_PLUGIN_URL must match exactly)", () => { + const lowerErr = new Error("memory_plugin_url missing"); + const upperErr = new Error("MEMORY_PLUGIN_URL missing"); + expect(isPluginUnavailableError(lowerErr)).toBe(false); + expect(isPluginUnavailableError(upperErr)).toBe(true); + }); +}); + +describe("formatTTL", () => { + it("returns '' for null", () => { + expect(formatTTL(null)).toBe(""); + }); + + it("returns '' for undefined", () => { + expect(formatTTL(undefined)).toBe(""); + }); + + it('returns "expired" when expiresAt is in the past', () => { + const past = new Date(Date.now() - 60_000).toISOString(); + expect(formatTTL(past)).toBe("expired"); + }); + + it('returns "Xs" for less than a minute', () => { + const soon = new Date(Date.now() + 30_000).toISOString(); + expect(formatTTL(soon)).toBe("30s"); + }); + + it('returns "Xm" for less than an hour', () => { + const soon = new Date(Date.now() + 5 * 60_000).toISOString(); + expect(formatTTL(soon)).toBe("5m"); + }); + + it('returns "Xh" for less than a day', () => { + const soon = new Date(Date.now() + 3 * 3_600_000).toISOString(); + expect(formatTTL(soon)).toBe("3h"); + }); + + it('returns "Xd" for more than a day', () => { + const soon = new Date(Date.now() + 2 * 86_400_000).toISOString(); + expect(formatTTL(soon)).toBe("2d"); + }); + + it("returns '' for invalid date string", () => { + expect(formatTTL("not-a-date")).toBe(""); + }); + + it("returns '' for empty string", () => { + expect(formatTTL("")).toBe(""); + }); +});