diff --git a/app/src/components/intelligence/ActionableCard.test.tsx b/app/src/components/intelligence/ActionableCard.test.tsx new file mode 100644 index 000000000..cd2df0f08 --- /dev/null +++ b/app/src/components/intelligence/ActionableCard.test.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ActionableItem } from '../../types/intelligence'; +import { ActionableCard } from './ActionableCard'; + +function makeItem(overrides: Partial = {}): ActionableItem { + const now = new Date(); + return { + id: 'item-1', + title: 'Reply to Alice', + description: 'She asked about Q4 numbers', + source: 'email', + priority: 'normal', + status: 'active', + createdAt: new Date(now.getTime() - 10 * 60 * 1000), // 10 minutes ago + updatedAt: now, + actionable: true, + ...overrides, + }; +} + +describe('', () => { + it('renders title, description and the relative time meta', () => { + render( + {}} + onDismiss={() => {}} + onSnooze={() => {}} + /> + ); + expect(screen.getByText('Reply to Alice')).toBeInTheDocument(); + expect(screen.getByText('She asked about Q4 numbers')).toBeInTheDocument(); + expect(screen.getByText(/10 mins ago/i)).toBeInTheDocument(); + }); + + it('invokes onComplete with the item when the check button is clicked', () => { + const item = makeItem(); + const onComplete = vi.fn(); + render( + {}} + onSnooze={() => {}} + /> + ); + const completeBtn = screen.getByTitle(/actionable\.complete|complete/i); + fireEvent.click(completeBtn); + expect(onComplete).toHaveBeenCalledWith(item); + }); + + it('invokes onDismiss with the item when the x button is clicked', () => { + const item = makeItem(); + const onDismiss = vi.fn(); + render( + {}} onDismiss={onDismiss} onSnooze={() => {}} /> + ); + const dismissBtn = screen.getByTitle(/actionable\.dismiss|dismiss/i); + fireEvent.click(dismissBtn); + expect(onDismiss).toHaveBeenCalledWith(item); + }); + + it('opens the snooze dropdown on click and surfaces the duration options', () => { + render( + {}} + onDismiss={() => {}} + onSnooze={() => {}} + /> + ); + fireEvent.click(screen.getByTitle(/actionable\.snooze|snooze/i)); + expect(screen.getByText('1 hour')).toBeInTheDocument(); + expect(screen.getByText('6 hours')).toBeInTheDocument(); + expect(screen.getByText('24 hours')).toBeInTheDocument(); + }); + + it('invokes onSnooze with the duration after the animation timer fires', () => { + vi.useFakeTimers(); + try { + const item = makeItem(); + const onSnooze = vi.fn(); + render( + {}} + onDismiss={() => {}} + onSnooze={onSnooze} + /> + ); + fireEvent.click(screen.getByTitle(/actionable\.snooze|snooze/i)); + fireEvent.click(screen.getByText('1 hour')); + // The 200ms animation-out delay must elapse before onSnooze fires. + vi.advanceTimersByTime(250); + expect(onSnooze).toHaveBeenCalledWith(item, 60 * 60 * 1000); + } finally { + vi.useRealTimers(); + } + }); + + it('renders the "new" badge for items younger than 5 minutes', () => { + render( + {}} + onDismiss={() => {}} + onSnooze={() => {}} + /> + ); + // i18n key falls back to "actionable.new" when no provider is mounted. + expect(screen.getByText(/actionable\.new|new/i)).toBeInTheDocument(); + }); + + it('honors a custom sourceLabel when provided', () => { + render( + {}} + onDismiss={() => {}} + onSnooze={() => {}} + /> + ); + expect(screen.getByText('Gmail')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/IntelligenceDreamsTab.test.tsx b/app/src/components/intelligence/IntelligenceDreamsTab.test.tsx new file mode 100644 index 000000000..7315ce56b --- /dev/null +++ b/app/src/components/intelligence/IntelligenceDreamsTab.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import IntelligenceDreamsTab from './IntelligenceDreamsTab'; + +describe('', () => { + it('renders the dreams title, description and coming-soon line', () => { + render(); + // useT resolves against the bundled English map by default. + expect(screen.getByRole('heading', { level: 2, name: /^Dreams$/ })).toBeInTheDocument(); + expect(screen.getByText(/AI-generated reflections/i)).toBeInTheDocument(); + expect(screen.getByText(/Coming soon/i)).toBeInTheDocument(); + }); + + it('renders a decorative svg icon', () => { + const { container } = render(); + expect(container.querySelector('svg')).not.toBeNull(); + }); +}); diff --git a/app/src/components/intelligence/IntelligenceMemoryTab.test.tsx b/app/src/components/intelligence/IntelligenceMemoryTab.test.tsx new file mode 100644 index 000000000..ab245bfbb --- /dev/null +++ b/app/src/components/intelligence/IntelligenceMemoryTab.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ActionableItem, ActionableItemSource, TimeGroup } from '../../types/intelligence'; +import IntelligenceMemoryTab from './IntelligenceMemoryTab'; + +function makeItem(overrides: Partial = {}): ActionableItem { + return { + id: 'i1', + title: 'Reply to Alice', + source: 'email', + priority: 'normal', + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + actionable: true, + ...overrides, + }; +} + +interface Overrides { + isRunning?: boolean; + items?: ActionableItem[]; + itemsLoading?: boolean; + searchFilter?: string; + sourceFilter?: ActionableItemSource | 'all'; + timeGroups?: TimeGroup[]; + usingMemoryData?: boolean; +} + +function renderTab(overrides: Overrides = {}) { + const handleAnalyzeNow = vi.fn().mockResolvedValue(undefined); + const handleComplete = vi.fn().mockResolvedValue(undefined); + const handleDismiss = vi.fn(); + const handleSnooze = vi.fn().mockResolvedValue(undefined); + const setSearchFilter = vi.fn(); + const setSourceFilter = vi.fn(); + + render( + + ); + + return { + handleAnalyzeNow, + handleComplete, + handleDismiss, + handleSnooze, + setSearchFilter, + setSourceFilter, + }; +} + +describe('', () => { + it('renders the search input and source filter select', () => { + renderTab(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('dispatches setSearchFilter on input change', () => { + const { setSearchFilter } = renderTab(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'foo' } }); + expect(setSearchFilter).toHaveBeenCalledWith('foo'); + }); + + it('dispatches setSourceFilter on select change', () => { + const { setSourceFilter } = renderTab(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'email' } }); + expect(setSourceFilter).toHaveBeenCalledWith('email'); + }); + + it('renders the loading state when itemsLoading and no memory data yet', () => { + renderTab({ itemsLoading: true }); + expect(screen.getByRole('heading', { level: 2, name: /Loading Memory/i })).toBeInTheDocument(); + }); + + it('renders the analyzing state when isRunning and items are empty', () => { + renderTab({ isRunning: true }); + expect( + screen.getByRole('heading', { level: 2, name: /Analyzing Memory/i }) + ).toBeInTheDocument(); + }); + + it('invokes handleAnalyzeNow when the Analyze Now button is clicked', () => { + const { handleAnalyzeNow } = renderTab({ usingMemoryData: false }); + fireEvent.click(screen.getByRole('button', { name: /Analyze Now/i })); + expect(handleAnalyzeNow).toHaveBeenCalledTimes(1); + }); + + it('renders the no-matches state when filters are active and groups are empty', () => { + renderTab({ searchFilter: 'foo', sourceFilter: 'email' }); + expect( + screen.getByRole('heading', { level: 2, name: /No Matches Found/i }) + ).toBeInTheDocument(); + }); + + it('renders the all-caught-up state when usingMemoryData and no groups', () => { + renderTab({ usingMemoryData: true }); + expect(screen.getByRole('heading', { level: 2, name: /All Caught Up/i })).toBeInTheDocument(); + }); + + it('renders time-group sections with their items', () => { + const items = [makeItem({ id: 'A', title: 'First task' })]; + const timeGroups: TimeGroup[] = [{ label: 'Today', items, count: items.length }]; + renderTab({ items, timeGroups }); + expect(screen.getByText('Today')).toBeInTheDocument(); + expect(screen.getByText('First task')).toBeInTheDocument(); + // Group count badge. + expect(screen.getByText('1')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/MemoryEmptyPlaceholder.test.tsx b/app/src/components/intelligence/MemoryEmptyPlaceholder.test.tsx new file mode 100644 index 000000000..28b68ceb4 --- /dev/null +++ b/app/src/components/intelligence/MemoryEmptyPlaceholder.test.tsx @@ -0,0 +1,18 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { MemoryEmptyPlaceholder } from './MemoryEmptyPlaceholder'; + +describe('', () => { + it('renders the empty title and hint copy inside the testid root', () => { + render(); + const root = screen.getByTestId('memory-empty-placeholder'); + // useT resolves against the bundled English map by default. + expect( + within(root).getByRole('heading', { level: 2, name: /No memories yet/i }) + ).toBeInTheDocument(); + expect( + within(root).getByText(/Start interacting to create your first memories/i) + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/MemoryGraph.test.tsx b/app/src/components/intelligence/MemoryGraph.test.tsx new file mode 100644 index 000000000..933a34cd5 --- /dev/null +++ b/app/src/components/intelligence/MemoryGraph.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GraphEdge, GraphNode } from '../../utils/tauriCommands'; +import { MemoryGraph } from './MemoryGraph'; + +const openUrlMock = vi.fn(); +vi.mock('../../utils/openUrl', () => ({ openUrl: (...args: unknown[]) => openUrlMock(...args) })); + +function makeSummaryNode(overrides: Partial = {}): GraphNode { + return { + kind: 'summary', + id: 'sum-1', + label: 'Summary 1', + tree_id: 't-1', + tree_kind: 'topic', + tree_scope: 'work', + level: 0, + parent_id: null, + child_count: 2, + file_basename: 'summary-1', + ...overrides, + }; +} + +function makeChunkNode(overrides: Partial = {}): GraphNode { + return { kind: 'chunk', id: 'chunk-1', label: 'A chunk', ...overrides }; +} + +function makeContactNode(overrides: Partial = {}): GraphNode { + return { + kind: 'contact', + id: 'person:alice', + label: 'Alice', + entity_kind: 'person', + ...overrides, + }; +} + +describe('', () => { + beforeEach(() => { + openUrlMock.mockReset(); + openUrlMock.mockResolvedValue(undefined); + }); + + it('renders the empty state when there are no nodes', () => { + render(); + expect(screen.getByTestId('memory-graph-empty')).toBeInTheDocument(); + }); + + it('renders an SVG with one circle per node in tree mode', () => { + const nodes = [ + makeSummaryNode({ id: 'root', level: 0, parent_id: null }), + makeSummaryNode({ id: 'child', level: 1, parent_id: 'root' }), + ]; + const { container } = render( + + ); + expect(screen.getByTestId('memory-graph-svg')).toBeInTheDocument(); + expect(container.querySelectorAll('circle').length).toBe(2); + expect(screen.getByTestId('memory-graph-node-root')).toBeInTheDocument(); + expect(screen.getByTestId('memory-graph-node-child')).toBeInTheDocument(); + }); + + it('renders contacts-mode legend rows for chunk and contact', () => { + const nodes = [ + makeChunkNode({ id: 'd1' }), + makeContactNode({ id: 'person:alice', label: 'Alice' }), + ]; + const edges: GraphEdge[] = [{ from: 'd1', to: 'person:alice' }]; + render(); + // Two legend rows render with i18n keys as fallback (graph.document/contact) + // — assert via the rendered nodes count instead, which is deterministic. + expect(screen.getAllByTestId(/memory-graph-node-/).length).toBe(2); + }); + + it('opens the Obsidian deep link when a summary node is clicked', async () => { + const nodes = [ + makeSummaryNode({ + id: 'sum-A', + tree_kind: 'topic', + tree_scope: 'workspace one', + level: 2, + file_basename: 'summary-A', + }), + ]; + render( + + ); + fireEvent.click(screen.getByTestId('memory-graph-node-sum-A')); + // Allow the async openSummaryInObsidian to dispatch. + await Promise.resolve(); + expect(openUrlMock).toHaveBeenCalledTimes(1); + const url = openUrlMock.mock.calls[0][0] as string; + expect(url.startsWith('obsidian://open?path=')).toBe(true); + // Slugified `tree_scope` ("workspace one") joins the file path. + expect(decodeURIComponent(url)).toContain('topic-workspace-one'); + expect(decodeURIComponent(url)).toContain('L2/summary-A.md'); + }); + + it('does NOT call openUrl when a non-summary node is clicked', async () => { + const nodes = [makeChunkNode({ id: 'doc-1' })]; + render(); + fireEvent.click(screen.getByTestId('memory-graph-node-doc-1')); + await Promise.resolve(); + expect(openUrlMock).not.toHaveBeenCalled(); + }); + + it('shows a tooltip footer when a node is hovered', () => { + const nodes = [makeContactNode({ id: 'person:bob', label: 'Bob' })]; + render(); + fireEvent.mouseEnter(screen.getByTestId('memory-graph-node-person:bob')); + expect(screen.getByTestId('memory-graph-tooltip')).toBeInTheDocument(); + expect(screen.getByTestId('memory-graph-tooltip').textContent).toContain('Bob'); + }); +}); diff --git a/app/src/components/intelligence/MemoryHeatmap.test.tsx b/app/src/components/intelligence/MemoryHeatmap.test.tsx new file mode 100644 index 000000000..49e14df6a --- /dev/null +++ b/app/src/components/intelligence/MemoryHeatmap.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { MemoryHeatmap } from './MemoryHeatmap'; + +function todaySeconds(): number { + return Math.floor(Date.now() / 1000); +} + +/** + * MemoryHeatmap's display window ends at midnight-start-of-today. Timestamps + * later than midnight today are placed on a future cell and NOT counted in + * the total/peak summary. Use yesterday's epoch-seconds for "should be + * counted" assertions. + */ +function yesterdaySeconds(): number { + return Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000); +} + +describe('', () => { + it('renders a skeleton when loading', () => { + const { container } = render(); + expect(container.querySelector('.animate-pulse')).not.toBeNull(); + // SVG only renders in the non-loading branch. + expect(container.querySelector('svg')).toBeNull(); + }); + + it('renders the SVG grid when not loading', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + // Many rect cells, at least one per displayed day. + expect(svg!.querySelectorAll('rect').length).toBeGreaterThan(30); + }); + + it('reports total counted events from timestamps inside the display window', () => { + const y = yesterdaySeconds(); + // 4 events yesterday — all inside the 8-month window. + const timestamps = [y, y - 30, y - 60, y - 90]; + render(); + // Total renders as separate text nodes: " events over the last …". + const summary = document.body.textContent ?? ''; + expect(summary).toMatch(/4 events/); + // Peak (max daily count) is 4 because all four fall on the same day. + expect(summary).toMatch(/Peak.*4/); + }); + + it('shows a hover tooltip when a cell is mouse-entered', () => { + const now = todaySeconds(); + const { container } = render(); + const rect = container.querySelector('rect'); + expect(rect).not.toBeNull(); + fireEvent.mouseEnter(rect!); + // Tooltip is rendered as a fixed-positioned div with z-50. + expect(container.querySelector('.fixed.z-50')).not.toBeNull(); + fireEvent.mouseLeave(rect!); + expect(container.querySelector('.fixed.z-50')).toBeNull(); + }); + + it('accepts millisecond-precision timestamps (>9999999999)', () => { + // Yesterday in ms so it lands inside the counted window. + const yMs = Date.now() - 24 * 60 * 60 * 1000; + render(); + const summary = document.body.textContent ?? ''; + expect(summary).toMatch(/1 event/); + }); +}); diff --git a/app/src/components/intelligence/MemoryInsights.test.tsx b/app/src/components/intelligence/MemoryInsights.test.tsx new file mode 100644 index 000000000..211df8bd1 --- /dev/null +++ b/app/src/components/intelligence/MemoryInsights.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands'; +import { MemoryInsights } from './MemoryInsights'; + +function makeRel(overrides: Partial = {}): GraphRelation { + return { + namespace: 'work', + subject: 'Alice', + predicate: 'is', + object: 'a developer', + attrs: {}, + updatedAt: 1700000000, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + ...overrides, + }; +} + +describe('', () => { + it('renders a loading skeleton when loading', () => { + const { container } = render(); + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThanOrEqual(3); + }); + + it('renders the empty state when there are no relations', () => { + render(); + // The empty branch falls back to i18n keys but the structure is two text nodes. + const headings = screen.getAllByRole('heading', { level: 3 }); + expect(headings.length).toBeGreaterThanOrEqual(1); + expect(headings[0]).toBeInTheDocument(); + }); + + it('groups relations into the expected predicate categories', () => { + const relations = [ + makeRel({ subject: 'Alice', predicate: 'is', object: 'a developer' }), // facts + makeRel({ subject: 'Bob', predicate: 'likes', object: 'jazz' }), // preferences + makeRel({ subject: 'Carol', predicate: 'knows', object: 'Dave' }), // relationships + makeRel({ subject: 'Eve', predicate: 'skilled_in', object: 'Rust' }), // skills + makeRel({ subject: 'Frank', predicate: 'thinks', object: 'TS is great' }), // opinions + makeRel({ subject: 'Grace', predicate: 'walks', object: 'fast' }), // other + ]; + render(); + + // Subjects from every bucket should be visible because there is one item + // each (≤ 3 items always shown when collapsed). + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('Carol')).toBeInTheDocument(); + expect(screen.getByText('Eve')).toBeInTheDocument(); + expect(screen.getByText('Frank')).toBeInTheDocument(); + expect(screen.getByText('Grace')).toBeInTheDocument(); + }); + + it('sorts items inside a group by evidenceCount descending', () => { + const relations = [ + makeRel({ subject: 'Low', predicate: 'is', evidenceCount: 1 }), + makeRel({ subject: 'High', predicate: 'is', evidenceCount: 9 }), + makeRel({ subject: 'Mid', predicate: 'is', evidenceCount: 4 }), + ]; + render(); + const items = [ + screen.getByText('High').compareDocumentPosition(screen.getByText('Mid')), + screen.getByText('Mid').compareDocumentPosition(screen.getByText('Low')), + ]; + // Both comparisons should report "Mid follows High" / "Low follows Mid". + expect(items[0] & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(items[1] & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('shows the "+N more" hint when a group has more than 3 items', () => { + const relations = Array.from({ length: 5 }, (_, i) => + makeRel({ subject: `S${i}`, predicate: 'is', object: 'thing', evidenceCount: i + 1 }) + ); + render(); + // 5 items in `facts`, collapsed view shows 3 → "+2 more" + expect(screen.getByText(/\+2/)).toBeInTheDocument(); + }); + + it('expands a group when its header is clicked, revealing more items', () => { + const relations = Array.from({ length: 8 }, (_, i) => + makeRel({ subject: `S${i}`, predicate: 'is', object: 'thing', evidenceCount: i + 1 }) + ); + render(); + + // Click the (only) category header — i18n key "insights.knownFacts". + fireEvent.click(screen.getByRole('button', { name: /Known Facts/i })); + + // After expansion the "+N more" hint disappears and all subjects become visible. + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument(); + for (let i = 0; i < 8; i++) { + expect(screen.getByText(`S${i}`)).toBeInTheDocument(); + } + }); + + it('decorates entity-type info as inline badges when present', () => { + const relations = [ + makeRel({ + subject: 'Alice', + predicate: 'is', + object: 'developer', + attrs: { entity_types: { subject: 'person', object: 'role' } }, + }), + ]; + render(); + // The badge renders as a child of the subject span; query inside it + // rather than reaching up to the row container. + const subj = screen.getByText('Alice'); + expect(within(subj).getByText(/person/i)).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/MemoryNavigator.test.tsx b/app/src/components/intelligence/MemoryNavigator.test.tsx new file mode 100644 index 000000000..6a4041f58 --- /dev/null +++ b/app/src/components/intelligence/MemoryNavigator.test.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Chunk, EntityRef, Source } from '../../utils/tauriCommands'; +import { MemoryNavigator, type NavigatorSelection } from './MemoryNavigator'; + +const EMPTY_SELECTION: NavigatorSelection = { sourceIds: [], entityIds: [] }; + +// Freeze the clock so the today/this-week bucket calculations and any +// `timestamp_ms: Date.now()` defaults are deterministic. Picked a noon-UTC +// weekday far from a DST boundary to avoid clock-edge surprises. +const FROZEN_NOW = new Date('2026-03-04T12:00:00.000Z'); + +function makeChunk(overrides: Partial = {}): Chunk { + return { + id: 'c1', + source_kind: 'email', + source_id: 'src-1', + owner: 'me', + timestamp_ms: FROZEN_NOW.getTime(), + token_count: 4, + lifecycle_status: 'admitted', + has_embedding: false, + tags: [], + ...overrides, + }; +} + +function makeSource(overrides: Partial = {}): Source { + return { + source_id: 'src-1', + display_name: 'Alice', + source_kind: 'email', + chunk_count: 3, + most_recent_ms: FROZEN_NOW.getTime(), + ...overrides, + }; +} + +function makeEntity(overrides: Partial = {}): EntityRef { + return { entity_id: 'person:Alice', kind: 'person', surface: 'Alice', count: 5, ...overrides }; +} + +describe('', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FROZEN_NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders the search input and dispatches onSearchChange', () => { + const onSearch = vi.fn(); + render( + {}} + searchQuery="" + onSearchChange={onSearch} + /> + ); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'foo' } }); + expect(onSearch).toHaveBeenCalledWith('foo'); + }); + + it('renders a single navigator pane with a heatmap host', () => { + render( + {}} + searchQuery="" + onSearchChange={() => {}} + /> + ); + expect(screen.getByTestId('memory-navigator')).toBeInTheDocument(); + expect(screen.getByTestId('memory-navigator-heatmap')).toBeInTheDocument(); + }); + + it('toggles a source on click and emits the updated selection', () => { + const onChange = vi.fn(); + render( + {}} + /> + ); + // Source-kind buckets default-closed; open the inner kind section first. + const kindHeader = screen.getByRole('button', { expanded: false, name: /email/i }); + fireEvent.click(kindHeader); + fireEvent.click(screen.getByRole('button', { name: /Alice/i, pressed: false })); + expect(onChange).toHaveBeenCalledWith({ sourceIds: ['gmail:alice'], entityIds: [] }); + }); + + it('toggles an entity (person) on click and emits the updated selection', () => { + const onChange = vi.fn(); + render( + {}} + /> + ); + fireEvent.click(screen.getByRole('button', { name: /Bob/i, pressed: false })); + expect(onChange).toHaveBeenCalledWith({ sourceIds: [], entityIds: ['person:Bob'] }); + }); + + it('un-toggles an already-active entity', () => { + const onChange = vi.fn(); + render( + {}} + /> + ); + // aria-pressed=true since Bob is in the active set. + fireEvent.click(screen.getByRole('button', { name: /Bob/i, pressed: true })); + expect(onChange).toHaveBeenCalledWith({ sourceIds: [], entityIds: [] }); + }); + + it('collapses a NavSection when its heading is clicked', () => { + render( + {}} + searchQuery="" + onSearchChange={() => {}} + /> + ); + // People section is open by default and Bob is visible. + expect(screen.getByRole('button', { name: /Bob/i })).toBeInTheDocument(); + const peopleHeader = screen.getByRole('button', { name: /people/i, expanded: true }); + fireEvent.click(peopleHeader); + expect(screen.queryByRole('button', { name: /Bob/i })).not.toBeInTheDocument(); + }); + + it('counts chunks within today and the rolling 7-day window', () => { + const now = FROZEN_NOW.getTime(); + const chunks = [ + makeChunk({ id: 'today', timestamp_ms: now }), + makeChunk({ id: 'week', timestamp_ms: now - 3 * 24 * 60 * 60 * 1000 }), + ]; + render( + {}} + searchQuery="" + onSearchChange={() => {}} + /> + ); + // The "recent" section renders " " and " " + // — assert against those labeled counters, not loose digits. + const recent = within(screen.getByTestId('memory-navigator')); + expect(recent.getByText(/Today\s+1/i)).toBeInTheDocument(); + expect(recent.getByText(/This Week\s+2/i)).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/MemoryResultList.test.tsx b/app/src/components/intelligence/MemoryResultList.test.tsx new file mode 100644 index 000000000..c6d784c33 --- /dev/null +++ b/app/src/components/intelligence/MemoryResultList.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Chunk } from '../../utils/tauriCommands'; +import { MemoryResultList } from './MemoryResultList'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +// Mid-day, mid-week, far from DST — keeps the bucket boundaries (TODAY / +// YESTERDAY / THIS WEEK / OLDER) deterministic across machines + CI. +const FROZEN_NOW = new Date('2026-03-04T12:00:00.000Z'); + +function makeChunk(overrides: Partial = {}): Chunk { + return { + id: 'chunk-1', + source_kind: 'email', + source_id: 'gmail:alice@example.com|thread-1', + owner: 'me', + timestamp_ms: FROZEN_NOW.getTime(), + token_count: 12, + lifecycle_status: 'admitted', + has_embedding: true, + tags: [], + content_preview: 'Subject of the message line. More body follows.', + ...overrides, + }; +} + +function startOfTodayMs(): number { + const d = new Date(FROZEN_NOW); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +describe('', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FROZEN_NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders the no-results state when the list is empty', () => { + render( {}} />); + expect(screen.getByTestId('memory-result-list')).toBeInTheDocument(); + // Default i18n context falls back to the en translation map. + expect(screen.getByText(/No memories found/i)).toBeInTheDocument(); + }); + + it('renders chunks bucketed into the four time-group sections', () => { + const todayMs = startOfTodayMs(); + const chunks = [ + makeChunk({ id: 'today-1', timestamp_ms: todayMs + 9 * 60 * 60 * 1000 }), + makeChunk({ id: 'yesterday-1', timestamp_ms: todayMs - 2 * 60 * 60 * 1000 }), + makeChunk({ id: 'thisweek-1', timestamp_ms: todayMs - 3 * DAY_MS }), + makeChunk({ id: 'older-1', timestamp_ms: todayMs - 30 * DAY_MS }), + ]; + render( {}} />); + + // Section headers are uppercased literal strings, not i18n keys. + expect(screen.getByText('TODAY')).toBeInTheDocument(); + expect(screen.getByText('YESTERDAY')).toBeInTheDocument(); + expect(screen.getByText('THIS WEEK')).toBeInTheDocument(); + expect(screen.getByText('OLDER')).toBeInTheDocument(); + + // One row button per chunk — query by accessible role. + expect(screen.getAllByRole('button')).toHaveLength(4); + }); + + it('marks the active row via aria-pressed', () => { + const todayMs = startOfTodayMs(); + const chunks = [ + makeChunk({ id: 'a', timestamp_ms: todayMs + 1 }), + makeChunk({ id: 'b', timestamp_ms: todayMs + 2 }), + ]; + render( {}} />); + // Exactly one row carries aria-pressed=true. + const pressed = screen.getAllByRole('button', { pressed: true }); + expect(pressed).toHaveLength(1); + expect(screen.getAllByRole('button', { pressed: false })).toHaveLength(1); + }); + + it('fires onSelectChunk when a row is clicked', () => { + const todayMs = startOfTodayMs(); + const onSelect = vi.fn(); + const chunks = [makeChunk({ id: 'click-me', timestamp_ms: todayMs + 1 })]; + render(); + fireEvent.click(screen.getByRole('button', { pressed: false })); + expect(onSelect).toHaveBeenCalledWith('click-me'); + }); + + it('renders a sender label derived from the source_id', () => { + const chunks = [ + makeChunk({ + id: 'q', + source_id: 'gmail:alice@example.com|thread-1', + timestamp_ms: startOfTodayMs() + 1, + }), + ]; + render( {}} />); + expect(screen.getByText(/alice@example\.com/)).toBeInTheDocument(); + }); + + it('uses the chunk id as the subject when no content preview is provided', () => { + const chunks = [ + makeChunk({ id: 'no-preview-id', content_preview: '', timestamp_ms: startOfTodayMs() + 1 }), + ]; + render( {}} />); + expect(screen.getByText('no-preview-id')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/MemoryStatsBar.test.tsx b/app/src/components/intelligence/MemoryStatsBar.test.tsx new file mode 100644 index 000000000..fe8fbc8e7 --- /dev/null +++ b/app/src/components/intelligence/MemoryStatsBar.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MemoryStatsBar } from './MemoryStatsBar'; + +const BASE_PROPS = { + totalDocs: 1234, + totalFiles: 7, + totalNamespaces: 3, + totalRelations: 42, + totalSessions: 11, + totalTokens: 9876, + estimatedStorageBytes: 2 * 1024 * 1024, // 2 MB + oldestDocTimestamp: null, + newestDocTimestamp: null, + docsToday: 0, +}; + +describe('', () => { + it('formats large numeric values with thousands separators', () => { + render(); + // totalDocs => 1,234 + expect(screen.getByText('1,234')).toBeInTheDocument(); + // totalTokens => 9,876 lives in a sub-label + expect(screen.getByText(/9,876/)).toBeInTheDocument(); + }); + + it('formats the storage cell in human-readable bytes', () => { + // formatBytes uses .toFixed(1) for values < 10 ("2.0 MB"), Math.round otherwise. + render(); + expect(screen.getByText('2.0 MB')).toBeInTheDocument(); + }); + + it('rounds storage values >= 10 of a unit', () => { + render(); + expect(screen.getByText('42 MB')).toBeInTheDocument(); + }); + + it('renders "--" placeholders when the corresponding fields are null/zero', () => { + render( + + ); + // storage and sessions both fall back to '--' + const placeholders = screen.getAllByText('--'); + expect(placeholders.length).toBeGreaterThanOrEqual(2); + }); + + it('renders the docs-today sub-label only when docsToday > 0', () => { + const { rerender } = render(); + expect(screen.getByText(/\+5/)).toBeInTheDocument(); + rerender(); + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument(); + }); + + it('renders a relative time-ago when oldest/newest timestamps are provided', () => { + // Freeze the clock so the relative offsets resolve to exactly "5h ago" + // and "10m ago" regardless of when the suite runs. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-04T12:00:00.000Z')); + try { + const now = Math.floor(Date.now() / 1000); + render( + + ); + expect(screen.getByText('5h ago')).toBeInTheDocument(); + expect(screen.getByText(/10m ago/)).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it('shows a skeleton placeholder when loading', () => { + const { container } = render(); + // 6 stat tiles, each renders an animate-pulse skeleton instead of value. + expect(container.querySelectorAll('.animate-pulse').length).toBe(6); + }); +});