Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions app/src/components/intelligence/GraphCohesionPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { computeGraphCohesion } from '../../lib/memory/graphCohesion';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import GraphCohesionPanel from './GraphCohesionPanel';

function rel(subject: string, object: string): GraphRelation {
return {
namespace: 'n',
subject,
predicate: 'p',
object,
attrs: {},
updatedAt: 0,
evidenceCount: 1,
orderIndex: null,
documentIds: [],
chunkIds: [],
};
}

// Diamond: A-B, A-C, B-C, B-D, C-D. avg clustering 5/6 (0.83), transitivity 0.75.
const diamond = computeGraphCohesion([
rel('A', 'B'),
rel('A', 'C'),
rel('B', 'C'),
rel('B', 'D'),
rel('C', 'D'),
]);

describe('<GraphCohesionPanel />', () => {
it('renders the loading skeleton', () => {
render(<GraphCohesionPanel result={null} loading />);
expect(screen.getByTestId('graph-cohesion-loading')).toBeInTheDocument();
});

it('renders the empty state when there are no nodes', () => {
render(<GraphCohesionPanel result={computeGraphCohesion([])} />);
expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument();
});

it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<GraphCohesionPanel result={null} error="graph unavailable" onRetry={onRetry} />);
expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});

it('renders metric tiles, the network averages, and the brokerage ranking', () => {
render(<GraphCohesionPanel result={diamond} />);
expect(screen.getByText('Entities')).toBeInTheDocument();
expect(screen.getByText('Connections')).toBeInTheDocument();
expect(screen.getByText('Triangles')).toBeInTheDocument();
expect(screen.getByText('Brokers — loosest neighbourhoods')).toBeInTheDocument();
// average clustering 5/6 -> 0.83, transitivity 0.75.
expect(screen.getByText(/transitivity 0\.75/)).toBeInTheDocument();
// the spine nodes B and C cluster at 0.67 (rendered to 2 decimals).
expect(screen.getAllByText('0.67')).toHaveLength(2);
});

it('badges a structural hole (clustering 0) as a broker', () => {
// Star: X links A, B, C which never connect -> X is a pure broker.
const star = computeGraphCohesion([rel('X', 'A'), rel('X', 'B'), rel('X', 'C')]);
render(<GraphCohesionPanel result={star} />);
expect(screen.getByText('broker')).toBeInTheDocument();
expect(screen.getByText('X')).toBeInTheDocument();
});

it('shows the no-brokers note when every entity has fewer than two links', () => {
const single = computeGraphCohesion([rel('A', 'B')]);
render(<GraphCohesionPanel result={single} />);
expect(screen.getByText('No entities with two or more connections yet.')).toBeInTheDocument();
// tiles still render (the graph is non-empty), but no ranking table.
expect(screen.getByText('Entities')).toBeInTheDocument();
expect(screen.queryByText('Brokers — loosest neighbourhoods')).not.toBeInTheDocument();
});
});
198 changes: 198 additions & 0 deletions app/src/components/intelligence/GraphCohesionPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Graph Cohesion — presentational view. Pure: renders the cohesion summary
* tiles (entities / connections / triangles), the network averages, and a
* brokerage ranking of the loosest-neighbourhood entities. No data fetching,
* no clock, no randomness.
*/
import { useMemo } from 'react';

import { useT } from '../../lib/i18n/I18nContext';
import { type CohesionResult, findBrokers } from '../../lib/memory/graphCohesion';

const MAX_ROWS = 25;

interface GraphCohesionPanelProps {
result: CohesionResult | null;
loading?: boolean;
error?: string | null;
onRetry?: () => void;
}

const GraphCohesionPanel = ({ result, loading, error, onRetry }: GraphCohesionPanelProps) => {
const { t } = useT();

const brokers = useMemo(() => (result ? findBrokers(result, MAX_ROWS) : []), [result]);

const intro = (
<div
role="note"
className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-stone-700 dark:text-neutral-200">
<p className="font-medium mb-1">{t('graphCohesion.title')}</p>
<p>{t('graphCohesion.intro')}</p>
</div>
);

if (loading) {
return (
<div className="space-y-4">
{intro}
<div
className="space-y-3"
role="status"
aria-label={t('graphCohesion.loading')}
data-testid="graph-cohesion-loading">
<div className="grid gap-2 sm:grid-cols-3">
{[0, 1, 2].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-16"
/>
))}
</div>
{[0, 1, 2, 3].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-8"
/>
))}
</div>
</div>
);
}

if (error) {
return (
<div className="space-y-4">
{intro}
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 p-4 text-center">
<p role="alert" className="text-xs text-coral-700 dark:text-coral-300">
{t('graphCohesion.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
{t('graphCohesion.retry')}
</button>
)}
</div>
</div>
);
}

if (!result || result.nodes.length === 0) {
return (
<div className="space-y-4">
{intro}
<div className="py-8 text-center">
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('graphCohesion.empty')}
</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('graphCohesion.emptyHint')}
</p>
</div>
</div>
);
}

return (
<div className="space-y-4">
{intro}

{/* Metric tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('graphCohesion.metricEntities'), value: result.nodeCount },
{ label: t('graphCohesion.metricConnections'), value: result.edgeCount },
{ label: t('graphCohesion.metricTriangles'), value: result.triangleCount },
].map(tile => (
<div
key={tile.label}
className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3">
<div className="text-[10px] uppercase tracking-wider text-stone-400 dark:text-neutral-500">
{tile.label}
</div>
<div className="text-lg font-semibold tabular-nums text-stone-900 dark:text-neutral-100">
{tile.value}
</div>
</div>
))}
</div>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('graphCohesion.summaryCaption')
.replace('{avg}', result.averageClustering.toFixed(2))
.replace('{transitivity}', result.transitivity.toFixed(2))}
</p>

{/* Brokerage ranking: loosest neighbourhoods first (structural holes). */}
{brokers.length === 0 ? (
<p className="py-4 text-center text-xs text-stone-500 dark:text-neutral-400">
{t('graphCohesion.noBrokers')}
</p>
) : (
<section aria-labelledby="graph-cohesion-heading" className="space-y-1">
<h3
id="graph-cohesion-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('graphCohesion.rankedHeading')}
</h3>
<table className="w-full text-left text-[11px] tabular-nums">
<thead className="text-stone-400 dark:text-neutral-500">
<tr>
<th scope="col" className="w-8 py-1 pr-2 font-medium">
{t('graphCohesion.colRank')}
</th>
<th scope="col" className="py-1 pr-2 font-medium">
{t('graphCohesion.colEntity')}
</th>
<th scope="col" className="w-1/3 py-1 pr-2 font-medium">
{t('graphCohesion.colCohesion')}
</th>
<th scope="col" className="w-12 py-1 text-right font-medium">
{t('graphCohesion.colLinks')}
</th>
</tr>
</thead>
<tbody>
{brokers.map((node, i) => (
<tr key={node.id} className="border-t border-stone-100 dark:border-neutral-800/60">
<td className="py-1 pr-2 text-stone-400 dark:text-neutral-500">{i + 1}</td>
<td className="py-1 pr-2 text-stone-800 dark:text-neutral-100 break-words">
{node.id}
{node.localClustering === 0 && (
<span
title={t('graphCohesion.brokerTitle')}
className="ml-1.5 inline-flex items-center rounded px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wider bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300">
{t('graphCohesion.brokerBadge')}
</span>
)}
</td>
<td className="py-1 pr-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded bg-stone-100 dark:bg-neutral-800 overflow-hidden">
<div
className="h-full bg-primary-400/70"
style={{ width: `${node.localClustering * 100}%` }}
/>
</div>
<span className="w-10 shrink-0 text-right text-stone-500 dark:text-neutral-400">
{node.localClustering.toFixed(2)}
</span>
</div>
</td>
<td className="py-1 text-right text-stone-500 dark:text-neutral-400">
{node.degree}
</td>
</tr>
))}
</tbody>
</table>
</section>
)}
</div>
);
};

export default GraphCohesionPanel;
63 changes: 63 additions & 0 deletions app/src/components/intelligence/GraphCohesionTab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { computeGraphCohesion } from '../../lib/memory/graphCohesion';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import GraphCohesionTab from './GraphCohesionTab';

const mockLoadCohesion = vi.fn();
const mockLoadNamespaces = vi.fn();

vi.mock('../../services/api/graphCohesionApi', () => ({
loadCohesion: (...args: unknown[]) => mockLoadCohesion(...args),
loadNamespaces: (...args: unknown[]) => mockLoadNamespaces(...args),
}));

function rel(subject: string, object: string): GraphRelation {
return {
namespace: 'n',
subject,
predicate: 'p',
object,
attrs: {},
updatedAt: 0,
evidenceCount: 1,
orderIndex: null,
documentIds: [],
chunkIds: [],
};
}

const result = computeGraphCohesion([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]);

describe('<GraphCohesionTab />', () => {
beforeEach(() => {
mockLoadCohesion.mockReset();
mockLoadNamespaces.mockReset();
mockLoadCohesion.mockResolvedValue(result);
mockLoadNamespaces.mockResolvedValue([]);
});

it('loads cohesion (all namespaces) on mount and renders the result', async () => {
render(<GraphCohesionTab />);
expect(mockLoadCohesion).toHaveBeenCalledWith(undefined);
await waitFor(() =>
expect(screen.getByText('Brokers — loosest neighbourhoods')).toBeInTheDocument()
);
});

it('shows the namespace selector and re-queries on change', async () => {
mockLoadNamespaces.mockResolvedValueOnce(['work', 'personal']);
render(<GraphCohesionTab />);
await waitFor(() => screen.getByRole('combobox'));
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'work' } });
await waitFor(() => expect(mockLoadCohesion).toHaveBeenCalledWith('work'));
});

it('surfaces an error when the load fails', async () => {
mockLoadCohesion.mockReset();
mockLoadCohesion.mockRejectedValueOnce(new Error('graph unavailable'));
render(<GraphCohesionTab />);
await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/));
});
});
Loading
Loading