Files
codebase-memory-mcp/graph-ui/src/architecture/ContainerMap.test.tsx
T
Bernhard Jackiewicz 42e436157c fix(ui): make the other Refresh buttons say what they did
The hand test asked for every Refresh button to say what it did; only the
four in Architecture did. Measured in the browser, three more showed
nothing that lasted: Refresh in ADR changed nothing for a single frame,
Refresh projects in the project picker showed "Loading projects..." for
about 150 ms and then the same list, and Refresh in the file impact of
Explore folded the details into "Reading dependencies…" for two seconds
and brought back the same numbers.

The control and its hook move from architecture/ to ui/refresh/, with the
shared words "Up to date at …: no changes since the last load" and
"Refresh failed at …", and these three buttons use it: they read
"Refreshing…" while they run and then name the time and whether anything
changed. Where the view already shows an error in its own words, the
status names only the time. The file impact keeps its details on screen
while a refresh of the same file runs. In the project picker the status
stands on its own line under the buttons.

The System buttons already show it ("Updated" and "Read" with the time),
and the Refresh buttons of Settings, of the projects panel and of the
daemon alerts are not mounted anywhere in the app, so they stay as they
are.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
2026-10-04 15:06:07 +02:00

116 lines
8.9 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ContainerMap from './ContainerMap';
import { loadContainerInventory, loadContainerTopology } from './container-source';
import type { ContainerReading } from './container-source';
vi.mock('./ArchitectureScene', () => ({ ArchitectureScene: ({ onOpen, onClearSelection }: { onOpen: (id: string) => void; onClearSelection?: () => void }) => <div><button data-testid="open-service" onDoubleClick={() => onOpen('web')}>3D scene</button><button onClick={onClearSelection}>Empty background</button></div> }));
vi.mock('../provider/rpc-client', () => ({ RpcIntelligenceClient: class { listProjects = async () => ({ projects: [{ name: 'p', root_path: '/p' }] }); } }));
vi.mock('./container-source', () => ({ loadContainerInventory: vi.fn(), loadContainerTopology: vi.fn() }));
let container: HTMLDivElement, root: Root;
const sample = (): ContainerReading => ({ projects: [], warnings: [], filesRead: 3, topology: {
services: ['web', 'db', 'isolated'].map(id => ({ id, name: id, project: 'p', manifest: 'compose.yml', line: id === 'web' ? 2 : 5,
sourcePaths: [], networks: ['p:default'], ports: [], image: 'fixture:1' })),
connections: [{ id: 'call', source: 'web', target: 'db', kind: 'call', protocol: 'postgres', evidence: [{ project: 'p', path: 'api.ts', line: 7, summary: 'Connects to db' }] },
{ id: 'startup', source: 'web', target: 'db', kind: 'startup', protocol: 'depends_on', evidence: [{ project: 'p', path: 'compose.yml', line: 4, summary: 'Depends on db' }] }],
warnings: [], unresolved: [],
} });
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
vi.mocked(loadContainerInventory).mockResolvedValue({ project: 'p', rootPath: '/p', files: new Map(), manifests: ['compose.yml'], warnings: [] });
vi.mocked(loadContainerTopology).mockResolvedValue(sample());
});
afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.clearAllMocks(); });
const button = (text: string) => [...container.querySelectorAll('button')].find(element => element.textContent === text)!;
describe('service map interactions', () => {
it('keeps isolated services visible and distinguishes calls from optional startup edges', async () => {
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} />));
expect(container.textContent).toContain('3 / 3 services · 1 connections shown');
expect(container.querySelector('[aria-label="All declared services"]')?.textContent).toContain('isolated');
const startup = [...container.querySelectorAll('label')].find(label => label.textContent === 'Startup dependencies')!.querySelector('input')!;
await act(async () => startup.click());
expect(container.textContent).toContain('3 / 3 services · 2 connections shown');
});
it('opens exact evidence locations and focuses a service with its neighbors', async () => {
const navigate = vi.fn();
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={navigate} />));
const web = container.querySelector('[aria-label="All declared services"] button')!;
await act(async () => (web as HTMLButtonElement).click());
await act(async () => button('Open declaration').click()); expect(navigate).toHaveBeenCalledWith('compose.yml', 2);
await act(async () => button('Focus service').click()); expect(container.textContent).toContain('2 / 3 services');
await act(async () => button('All services').click()); expect(container.textContent).toContain('3 / 3 services');
});
it('returns from a focused neighborhood to all services and clears the inspector on empty background', async () => {
const clear = vi.fn();
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} onClearSelection={clear} />));
await act(async () => container.querySelector('[data-testid="open-service"]')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })));
expect(container.textContent).toContain('2 / 3 services');
expect(button('Open declaration')).toBeDefined();
await act(async () => button('Empty background').click());
expect(container.textContent).toContain('3 / 3 services');
expect(button('Open declaration')).toBeUndefined();
expect(clear).toHaveBeenCalledOnce();
});
it('says plainly that a project has no deployment files and leads to its endpoints', async () => {
vi.mocked(loadContainerInventory).mockResolvedValue({ project: 'p', rootPath: '/p', files: new Map(), manifests: [], warnings: [] });
const showEndpoints = vi.fn();
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} onShowEndpoints={showEndpoints} />));
expect(container.textContent).toContain('No Docker Compose deployment files were found in this index. Endpoints shows the routes.');
expect(container.textContent).not.toContain('Could not read');
await act(async () => button('Show endpoints →').click());
expect(showEndpoints).toHaveBeenCalledOnce();
expect(loadContainerTopology).not.toHaveBeenCalled();
});
it('keeps a failed read distinct from a project without deployment files', async () => {
vi.mocked(loadContainerInventory).mockRejectedValue(new Error('query_graph: Incomplete query results'));
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} onShowEndpoints={vi.fn()} />));
expect(container.textContent).toContain('Could not read the indexed deployment files. Refresh to try again.');
expect(container.textContent).not.toContain('No Docker Compose deployment files');
expect(button('Show endpoints →')).toBeUndefined();
});
it('does not request deployment source while the workspace is inactive', async () => {
await act(async () => root.render(<ContainerMap project="p" active={false} filter="" onNavigate={vi.fn()} />));
expect(loadContainerTopology).not.toHaveBeenCalled(); expect(loadContainerInventory).not.toHaveBeenCalled();
});
});
/* Hand test 2026-10-04 (A3): a Refresh that reads everything again says so, and says what it found. */
describe('service map refresh feedback', () => {
const refresh = () => [...container.querySelectorAll('button')].find(element => /^Refresh(ing…)?$/.test(element.textContent ?? ''))!;
const status = () => container.querySelector('[role="status"].atlas-refresh-status')?.textContent;
afterEach(() => { vi.useRealTimers(); });
it('A3: reads the declared services again, busy meanwhile, then names the time and whether anything changed', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 9, 4, 9, 5, 7));
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} />));
let finish!: (value: ContainerReading) => void;
vi.mocked(loadContainerTopology).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await act(async () => refresh().click());
expect(refresh().textContent).toBe('Refreshing…');
expect(refresh().getAttribute('aria-disabled')).toBe('true');
await act(async () => finish(sample()));
expect(refresh().textContent).toBe('Refresh');
expect(status()).toBe('Up to date at 09:05:07: no changes since the last load');
const changed = sample();
changed.topology.services.pop();
vi.mocked(loadContainerTopology).mockResolvedValueOnce(changed);
await act(async () => refresh().click());
expect(status()).toBe('Service map refreshed at 09:05:07');
});
it('A3: also answers for a project without deployment files', async () => {
vi.mocked(loadContainerInventory).mockResolvedValue({ project: 'p', rootPath: '/p', files: new Map(), manifests: [], warnings: [] });
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} />));
let finish!: (value: Awaited<ReturnType<typeof loadContainerInventory>>) => void;
vi.mocked(loadContainerInventory).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await act(async () => refresh().click());
expect(refresh().textContent).toBe('Refreshing…');
await act(async () => finish({ project: 'p', rootPath: '/p', files: new Map(), manifests: [], warnings: [] }));
expect(refresh().textContent).toBe('Refresh');
expect(status()).toMatch(/^Up to date at \d\d:\d\d:\d\d: no changes since the last load$/);
});
});