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
7 changes: 4 additions & 3 deletions src/hooks/usePageNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PAGE_REPORT_TYPES,
pageTypeForReport,
getReportSchema,
resolveGroupByColumn,
type PageType,
} from '../lib/report-schema';
import { readURLFilterState, writeURLFilterState } from '../lib/url-state';
Expand Down Expand Up @@ -51,13 +52,13 @@ export function usePageNavigation({
// Files page has no report types
const reportTypes = PAGE_REPORT_TYPES[page];
if (!reportTypes || reportTypes.length === 0) return;
const targetReportType = reportTypes[0];
const schema = getReportSchema(targetReportType);
setGroupByColumn(schema.defaultGroupBy);
const matchIndex = reports.findIndex((r) => reportTypes.includes(r.type));
if (matchIndex !== -1) {
setActiveReport(matchIndex);
setGroupByColumn(resolveGroupByColumn(reports[matchIndex], ''));
return;
}
setGroupByColumn(getReportSchema(reportTypes[0]).defaultGroupBy);
}, [setGroupByColumn, reports, setActiveReport]);

// Sync active page to URL
Expand Down
44 changes: 42 additions & 2 deletions src/lib/chart-theme.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { buildColorMap, getModelIconUrl, GITHUB_COLORS_RESOLVED } from './chart-theme';
import { afterEach, describe, it, expect } from 'vitest';
import {
buildColorMap,
buildGitHubChartTheme,
getModelIconUrl,
GITHUB_COLORS_RESOLVED,
} from './chart-theme';

describe('buildColorMap', () => {
it('assigns distinct branded colors per AI model family', () => {
Expand Down Expand Up @@ -49,3 +54,38 @@ describe('getModelIconUrl', () => {
expect(getModelIconUrl('CLAUDE')).toBe(getModelIconUrl('claude'));
});
});

describe('buildGitHubChartTheme', () => {
afterEach(() => {
document.querySelector('[data-color-mode]')?.remove();
});

it('falls back to the bundled palette when no Primer root is mounted', () => {
const theme = buildGitHubChartTheme() as { colors: string[] };

expect(theme.colors).toEqual(GITHUB_COLORS_RESOLVED.slice(0, theme.colors.length));
});

it('reads live CSS variables from the Primer root when one exists', () => {
const root = document.createElement('div');
root.setAttribute('data-color-mode', 'dark');
root.style.setProperty('--data-blue-color-emphasis', 'rgb(1, 2, 3)');
document.body.appendChild(root);

const theme = buildGitHubChartTheme() as { colors: string[] };

expect(theme.colors[0]).toBe('rgb(1, 2, 3)');
});

it('disables credits, accessibility, and animation for deterministic rendering', () => {
const theme = buildGitHubChartTheme() as {
credits: { enabled: boolean };
accessibility: { enabled: boolean };
chart: { animation: boolean };
};

expect(theme.credits.enabled).toBe(false);
expect(theme.accessibility.enabled).toBe(false);
expect(theme.chart.animation).toBe(false);
});
});
192 changes: 192 additions & 0 deletions src/lib/formatters.avatars.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const AVATAR_STORAGE_KEY = 'tbb:bot-avatars';

const okResponse = (avatarUrl: string) =>
({ ok: true, json: async () => ({ avatar_url: avatarUrl }) }) as unknown as Response;

/**
* This jsdom setup exposes no localStorage, so the avatar cache's persistence
* paths silently no-op under test. Install a minimal in-memory implementation
* before importing the module so hydration and persistence are exercised.
*/
const memoryStorage = () => {
const store = new Map<string, string>();
return {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
key: (i: number) => [...store.keys()][i] ?? null,
get length() {
return store.size;
},
} as Storage;
};

let storage: Storage;

/**
* The avatar cache is module-level state seeded from localStorage at import
* time, so every test needs a fresh module registry to stay independent.
*/
const freshFormatters = async () => {
vi.resetModules();
return import('./formatters');
};

describe('bot avatar resolution', () => {
beforeEach(() => {
storage = memoryStorage();
vi.stubGlobal('localStorage', storage);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('never calls the API for a human username', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { resolveBotAvatar } = await freshFormatters();

await expect(resolveBotAvatar('austenstone')).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});

it('serves known bots from the built-in list without a request', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { resolveBotAvatar } = await freshFormatters();

await expect(resolveBotAvatar('dependabot[bot]')).resolves.toContain(
'avatars.githubusercontent.com',
);
expect(fetchMock).not.toHaveBeenCalled();
});

it('resolves an unknown bot from the API and persists it', async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse('https://example.test/a.png'));
vi.stubGlobal('fetch', fetchMock);
const { resolveBotAvatar } = await freshFormatters();

await expect(resolveBotAvatar('acme-ci[bot]')).resolves.toBe('https://example.test/a.png');
expect(fetchMock).toHaveBeenCalledWith(
'https://api.github.com/users/acme-ci%5Bbot%5D',
);

const stored = JSON.parse(storage.getItem(AVATAR_STORAGE_KEY) ?? '{}');
expect(stored['acme-ci[bot]']).toBe('https://example.test/a.png');
});

it('rehydrates persisted avatars on the next load', async () => {
storage.setItem(
AVATAR_STORAGE_KEY,
JSON.stringify({ 'acme-ci[bot]': 'https://example.test/cached.png' }),
);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { resolveBotAvatar } = await freshFormatters();

await expect(resolveBotAvatar('acme-ci[bot]')).resolves.toBe(
'https://example.test/cached.png',
);
expect(fetchMock).not.toHaveBeenCalled();
});

it('returns null when the API rejects the lookup', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false } as Response));
const { resolveBotAvatar } = await freshFormatters();

await expect(resolveBotAvatar('missing[bot]')).resolves.toBeNull();
expect(storage.getItem(AVATAR_STORAGE_KEY)).toBeNull();
});

it('returns null when the request throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
const { resolveBotAvatar } = await freshFormatters();

await expect(resolveBotAvatar('offline[bot]')).resolves.toBeNull();
});

it('collapses concurrent lookups for the same bot into one request', async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse('https://example.test/b.png'));
vi.stubGlobal('fetch', fetchMock);
const { resolveBotAvatar } = await freshFormatters();

const results = await Promise.all([
resolveBotAvatar('busy[bot]'),
resolveBotAvatar('busy[bot]'),
resolveBotAvatar('busy[bot]'),
]);

expect(results).toEqual(Array(3).fill('https://example.test/b.png'));
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('queues requests beyond the concurrency cap but still resolves them all', async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse('https://example.test/c.png'));
vi.stubGlobal('fetch', fetchMock);
const { resolveBotAvatar } = await freshFormatters();

const names = Array.from({ length: 8 }, (_, i) => `queued-${i}[bot]`);
const results = await Promise.all(names.map(resolveBotAvatar));

expect(results.every((r) => r === 'https://example.test/c.png')).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(8);
});
});

describe('preloadBotAvatars', () => {
beforeEach(() => {
storage = memoryStorage();
vi.stubGlobal('localStorage', storage);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('does nothing when the dataset has no bots', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { preloadBotAvatars } = await freshFormatters();

await expect(preloadBotAvatars(['austenstone', 'octocat'])).resolves.toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});

it('skips bots that are already cached', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { preloadBotAvatars } = await freshFormatters();

await expect(preloadBotAvatars(['dependabot[bot]'])).resolves.toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});

it('reports true once at least one avatar resolves', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okResponse('https://example.test/d.png')));
const { preloadBotAvatars } = await freshFormatters();

await expect(preloadBotAvatars(['fresh-a[bot]', 'fresh-b[bot]'])).resolves.toBe(true);
});

it('reports false when every lookup fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false } as Response));
const { preloadBotAvatars } = await freshFormatters();

await expect(preloadBotAvatars(['nope-a[bot]', 'nope-b[bot]'])).resolves.toBe(false);
});

it('caps a single batch at ten API lookups', async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse('https://example.test/e.png'));
vi.stubGlobal('fetch', fetchMock);
const { preloadBotAvatars } = await freshFormatters();

const many = Array.from({ length: 25 }, (_, i) => `bulk-${i}[bot]`);
await preloadBotAvatars(many);

expect(fetchMock).toHaveBeenCalledTimes(10);
});
});
Loading
Loading