test(intelligence): backfill memory-tab unit tests (#1870) (#2064)

This commit is contained in:
CodeGhost21
2026-05-18 15:59:40 +05:30
committed by GitHub
parent 52203454ce
commit 1d3563bf49
10 changed files with 982 additions and 0 deletions
@@ -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> = {}): 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('<ActionableCard />', () => {
it('renders title, description and the relative time meta', () => {
render(
<ActionableCard
item={makeItem()}
onComplete={() => {}}
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(
<ActionableCard
item={item}
onComplete={onComplete}
onDismiss={() => {}}
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(
<ActionableCard item={item} onComplete={() => {}} 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(
<ActionableCard
item={makeItem()}
onComplete={() => {}}
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(
<ActionableCard
item={item}
onComplete={() => {}}
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(
<ActionableCard
item={makeItem({ createdAt: new Date(Date.now() - 60_000) })}
onComplete={() => {}}
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(
<ActionableCard
item={makeItem({ sourceLabel: 'Gmail' })}
onComplete={() => {}}
onDismiss={() => {}}
onSnooze={() => {}}
/>
);
expect(screen.getByText('Gmail')).toBeInTheDocument();
});
});
@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import IntelligenceDreamsTab from './IntelligenceDreamsTab';
describe('<IntelligenceDreamsTab />', () => {
it('renders the dreams title, description and coming-soon line', () => {
render(<IntelligenceDreamsTab />);
// 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(<IntelligenceDreamsTab />);
expect(container.querySelector('svg')).not.toBeNull();
});
});
@@ -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> = {}): 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(
<IntelligenceMemoryTab
handleAnalyzeNow={handleAnalyzeNow}
handleComplete={handleComplete}
handleDismiss={handleDismiss}
handleSnooze={handleSnooze}
isRunning={overrides.isRunning ?? false}
items={overrides.items ?? []}
itemsLoading={overrides.itemsLoading ?? false}
searchFilter={overrides.searchFilter ?? ''}
setSearchFilter={setSearchFilter}
setSourceFilter={setSourceFilter}
sourceFilter={overrides.sourceFilter ?? 'all'}
timeGroups={overrides.timeGroups ?? []}
usingMemoryData={overrides.usingMemoryData ?? false}
/>
);
return {
handleAnalyzeNow,
handleComplete,
handleDismiss,
handleSnooze,
setSearchFilter,
setSourceFilter,
};
}
describe('<IntelligenceMemoryTab />', () => {
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();
});
});
@@ -0,0 +1,18 @@
import { render, screen, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MemoryEmptyPlaceholder } from './MemoryEmptyPlaceholder';
describe('<MemoryEmptyPlaceholder />', () => {
it('renders the empty title and hint copy inside the testid root', () => {
render(<MemoryEmptyPlaceholder />);
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();
});
});
@@ -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> = {}): 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> = {}): GraphNode {
return { kind: 'chunk', id: 'chunk-1', label: 'A chunk', ...overrides };
}
function makeContactNode(overrides: Partial<GraphNode> = {}): GraphNode {
return {
kind: 'contact',
id: 'person:alice',
label: 'Alice',
entity_kind: 'person',
...overrides,
};
}
describe('<MemoryGraph />', () => {
beforeEach(() => {
openUrlMock.mockReset();
openUrlMock.mockResolvedValue(undefined);
});
it('renders the empty state when there are no nodes', () => {
render(<MemoryGraph nodes={[]} edges={[]} mode="tree" contentRootAbs="/tmp/openhuman" />);
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(
<MemoryGraph nodes={nodes} edges={[]} mode="tree" contentRootAbs="/tmp" />
);
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(<MemoryGraph nodes={nodes} edges={edges} mode="contacts" contentRootAbs="/tmp" />);
// 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(
<MemoryGraph
nodes={nodes}
edges={[]}
mode="tree"
contentRootAbs="/Users/me/openhuman-content"
/>
);
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(<MemoryGraph nodes={nodes} edges={[]} mode="contacts" contentRootAbs="/tmp" />);
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(<MemoryGraph nodes={nodes} edges={[]} mode="contacts" contentRootAbs="/tmp" />);
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');
});
});
@@ -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('<MemoryHeatmap />', () => {
it('renders a skeleton when loading', () => {
const { container } = render(<MemoryHeatmap timestamps={[]} loading />);
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(<MemoryHeatmap timestamps={[]} />);
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(<MemoryHeatmap timestamps={timestamps} />);
// Total renders as separate text nodes: "<n> 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(<MemoryHeatmap timestamps={[now]} />);
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(<MemoryHeatmap timestamps={[yMs]} />);
const summary = document.body.textContent ?? '';
expect(summary).toMatch(/1 event/);
});
});
@@ -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> = {}): GraphRelation {
return {
namespace: 'work',
subject: 'Alice',
predicate: 'is',
object: 'a developer',
attrs: {},
updatedAt: 1700000000,
evidenceCount: 1,
orderIndex: null,
documentIds: [],
chunkIds: [],
...overrides,
};
}
describe('<MemoryInsights />', () => {
it('renders a loading skeleton when loading', () => {
const { container } = render(<MemoryInsights relations={[]} loading />);
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThanOrEqual(3);
});
it('renders the empty state when there are no relations', () => {
render(<MemoryInsights relations={[]} />);
// 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(<MemoryInsights relations={relations} />);
// 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(<MemoryInsights relations={relations} />);
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(<MemoryInsights relations={relations} />);
// 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(<MemoryInsights relations={relations} />);
// 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(<MemoryInsights relations={relations} />);
// 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();
});
});
@@ -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> = {}): 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> = {}): 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> = {}): EntityRef {
return { entity_id: 'person:Alice', kind: 'person', surface: 'Alice', count: 5, ...overrides };
}
describe('<MemoryNavigator />', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(FROZEN_NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('renders the search input and dispatches onSearchChange', () => {
const onSearch = vi.fn();
render(
<MemoryNavigator
chunks={[]}
sources={[]}
topPeople={[]}
topTopics={[]}
selection={EMPTY_SELECTION}
onSelectionChange={() => {}}
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(
<MemoryNavigator
chunks={[]}
sources={[]}
topPeople={[]}
topTopics={[]}
selection={EMPTY_SELECTION}
onSelectionChange={() => {}}
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(
<MemoryNavigator
chunks={[]}
sources={[makeSource({ source_id: 'gmail:alice', display_name: 'Alice' })]}
topPeople={[]}
topTopics={[]}
selection={EMPTY_SELECTION}
onSelectionChange={onChange}
searchQuery=""
onSearchChange={() => {}}
/>
);
// 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(
<MemoryNavigator
chunks={[]}
sources={[]}
topPeople={[makeEntity({ entity_id: 'person:Bob', surface: 'Bob' })]}
topTopics={[]}
selection={EMPTY_SELECTION}
onSelectionChange={onChange}
searchQuery=""
onSearchChange={() => {}}
/>
);
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(
<MemoryNavigator
chunks={[]}
sources={[]}
topPeople={[makeEntity({ entity_id: 'person:Bob', surface: 'Bob' })]}
topTopics={[]}
selection={{ sourceIds: [], entityIds: ['person:Bob'] }}
onSelectionChange={onChange}
searchQuery=""
onSearchChange={() => {}}
/>
);
// 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(
<MemoryNavigator
chunks={[]}
sources={[]}
topPeople={[makeEntity({ entity_id: 'person:Bob', surface: 'Bob' })]}
topTopics={[]}
selection={EMPTY_SELECTION}
onSelectionChange={() => {}}
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(
<MemoryNavigator
chunks={chunks}
sources={[]}
topPeople={[]}
topTopics={[]}
selection={EMPTY_SELECTION}
onSelectionChange={() => {}}
searchQuery=""
onSearchChange={() => {}}
/>
);
// The "recent" section renders "<Today label> <n>" and "<This Week label> <n>"
// — 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();
});
});
@@ -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> = {}): 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('<MemoryResultList />', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(FROZEN_NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('renders the no-results state when the list is empty', () => {
render(<MemoryResultList chunks={[]} selectedChunkId={null} onSelectChunk={() => {}} />);
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(<MemoryResultList chunks={chunks} selectedChunkId={null} onSelectChunk={() => {}} />);
// 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(<MemoryResultList chunks={chunks} selectedChunkId="b" onSelectChunk={() => {}} />);
// 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(<MemoryResultList chunks={chunks} selectedChunkId={null} onSelectChunk={onSelect} />);
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(<MemoryResultList chunks={chunks} selectedChunkId={null} onSelectChunk={() => {}} />);
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(<MemoryResultList chunks={chunks} selectedChunkId={null} onSelectChunk={() => {}} />);
expect(screen.getByText('no-preview-id')).toBeInTheDocument();
});
});
@@ -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('<MemoryStatsBar />', () => {
it('formats large numeric values with thousands separators', () => {
render(<MemoryStatsBar {...BASE_PROPS} />);
// 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(<MemoryStatsBar {...BASE_PROPS} />);
expect(screen.getByText('2.0 MB')).toBeInTheDocument();
});
it('rounds storage values >= 10 of a unit', () => {
render(<MemoryStatsBar {...BASE_PROPS} estimatedStorageBytes={42 * 1024 * 1024} />);
expect(screen.getByText('42 MB')).toBeInTheDocument();
});
it('renders "--" placeholders when the corresponding fields are null/zero', () => {
render(
<MemoryStatsBar
{...BASE_PROPS}
estimatedStorageBytes={0}
totalSessions={null}
totalTokens={null}
/>
);
// 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(<MemoryStatsBar {...BASE_PROPS} docsToday={5} />);
expect(screen.getByText(/\+5/)).toBeInTheDocument();
rerender(<MemoryStatsBar {...BASE_PROPS} docsToday={0} />);
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(
<MemoryStatsBar
{...BASE_PROPS}
oldestDocTimestamp={now - 3600 * 5} // 5h ago
newestDocTimestamp={now - 60 * 10} // 10m ago
/>
);
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(<MemoryStatsBar {...BASE_PROPS} loading />);
// 6 stat tiles, each renders an animate-pulse skeleton instead of value.
expect(container.querySelectorAll('.animate-pulse').length).toBe(6);
});
});