mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user