Skip to content
Open
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
90 changes: 90 additions & 0 deletions src/webview/page-achievements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------------------------------------------*/

// @vitest-environment jsdom

import { beforeEach, describe, expect, it, vi } from 'vitest';

const sharedMocks = vi.hoisted(() => ({
rpc: vi.fn(),
vscodeState: {},
}));

vi.mock('./shared', () => ({
rpc: sharedMocks.rpc,
rpcAllSettled: async <T extends readonly unknown[]>(
calls: { [K in keyof T]: Promise<T[K]> },
fallbacks: { [K in keyof T]: T[K] },
): Promise<T> => {
const results = await Promise.allSettled(calls);
return results.map((r, i) => r.status === 'fulfilled' ? r.value : fallbacks[i]) as unknown as T;
},
formatNum: (n: number) => String(Math.round(n)),
COLORS: { blue: '#58a6ff', green: '#3fb950', red: '#f85149', yellow: '#d29922' },
vscode: {
getState: () => sharedMocks.vscodeState,
setState: (state: Record<string, unknown>) => { sharedMocks.vscodeState = state; },
},
PROGRESS_ALMOST: 80,
PROGRESS_STARTED: 10,
}));

vi.mock('./svg-icons', () => ({
SVG: new Proxy({}, {
get: (_target, prop) => `<svg data-icon="${String(prop)}"></svg>`,
}),
}));

import { renderAchievements } from './page-achievements';

describe('renderAchievements', () => {
beforeEach(() => {
sharedMocks.rpc.mockReset();
sharedMocks.vscodeState = {};
document.body.innerHTML = '<main id="root"></main>';
});

it('renders a disabled model achievement when token reporting is disabled', async () => {
sharedMocks.rpc.mockImplementation((method: string, params?: { pageSize?: number }) => {
switch (method) {
case 'getStats':
return Promise.resolve({ totalSessions: 2, totalRequests: 25, totalWorkspaces: 1 });
case 'getCodeProduction':
return Promise.resolve({
summary: { totalAiLoc: 120, totalUserLoc: 40 },
byLanguage: { labels: ['TypeScript'] },
});
case 'getWorkLifeBalance':
return Promise.resolve({ maxStreak: 3, weekendReqs: 1, timeDistribution: { lateNight: 2 } });
case 'getAntiPatterns':
return Promise.resolve({ totalOccurrences: 0 });
case 'getHourlyDistribution':
return Promise.resolve({ hours: [0, 5, 1] });
case 'getSessions':
return Promise.resolve({
total: 2,
sessions: params?.pageSize === 100
? [{ sessionId: 's1', requestCount: 12, firstMessage: 'Refactor a TypeScript service' }]
: [],
});
case 'getDailyActivity':
return Promise.resolve({ labels: ['2026-06-01'], loc: [120], values: [25], sessions: [2] });
case 'getConsumption':
return Promise.reject(new Error('Token reporting is temporarily disabled'));
case 'getWorkflowOptimization':
return Promise.resolve({ clusters: [{ id: 'tool-1' }] });
default:
return Promise.reject(new Error(`Unexpected RPC: ${method}`));
}
});

const container = document.getElementById('root')!;
await expect(renderAchievements(container, {})).resolves.toBeUndefined();

expect(container.textContent).toContain('AI Engineer Roadmap');
expect(container.textContent).toContain('Model Explorer');
expect(container.textContent).toContain('Token reporting disabled');
});
});
32 changes: 26 additions & 6 deletions src/webview/page-achievements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface AchievementStats {
topLanguage: string;
oldTechCount: number;
hourlyPeak: number;
tokenReportingDisabled: boolean;
/** Historical daily data for date estimation */
dailyLabels: string[];
dailyCumulativeLoc: number[];
Expand Down Expand Up @@ -163,7 +164,9 @@ const ACHIEVEMENTS: Achievement[] = [
id: 'model-explorer', title: 'Model Explorer', icon: SVG.microscope,
tier: 'silver', xp: 20, category: 'diversity',
description: 'Used 5+ different AI models',
evaluate: thresholdEval('uniqueModels', 5, (v) => `${v} / 5 models`),
evaluate: (s) => s.tokenReportingDisabled
? { progress: 0, label: 'Token reporting disabled', unlocked: false }
: thresholdEval('uniqueModels', 5, (v) => `${v} / 5 models`)(s),
},

// Mastery
Expand Down Expand Up @@ -322,6 +325,22 @@ function buildDailyCumulative(labels: string[], loc: number[], reqs: number[], s
return { dailyLabels: labels, dailyCumulativeLoc, dailyCumulativeReqs, dailyCumulativeSessions };
}

function isTokenReportingDisabledError(err: unknown): boolean {
return err instanceof Error && err.message === 'Token reporting is temporarily disabled';
}

async function getConsumptionForAchievements(filter: DateFilter): Promise<{ modelTotals: Record<string, number>; tokenReportingDisabled: boolean }> {
try {
const consumption = await rpc<{ modelTotals: Record<string, number> }>('getConsumption', filter as Record<string, unknown>);
return { modelTotals: consumption.modelTotals, tokenReportingDisabled: false };
} catch (err) {
if (isTokenReportingDisabledError(err)) {
return { modelTotals: {}, tokenReportingDisabled: true };
}
throw err;
}
}

function evaluateAchievements(stats: AchievementStats): { evaluated: EvaluatedAchievement[]; unlocked: EvaluatedAchievement[]; locked: EvaluatedAchievement[] } {
const achState = getAchState();
const now = Date.now();
Expand Down Expand Up @@ -495,11 +514,11 @@ export async function renderAchievements(container: HTMLElement, filter: DateFil
{ hours: [] },
] as const);

const sessions = await rpc<{ total: number; sessions: { sessionId: string; requestCount: number; firstMessage: string }[] }>('getSessions', { page: 1, pageSize: 100, filter: filter as Record<string, unknown> });
const sessions = await rpc<{ total: number; sessions: { sessionId: string; requestCount: number; firstMessage: string }[] }>('getSessions', { page: 1, pageSize: 100, filter });
const dailyActivity = await rpc<DailyActivity>('getDailyActivity', filter as Record<string, unknown>);
const allSessions = await rpc<{ total: number }>('getSessions', { page: 1, pageSize: 1, filter: filter as Record<string, unknown> });
const allSessions = await rpc<{ total: number }>('getSessions', { page: 1, pageSize: 1, filter });
const codeByLang = await rpc<{ byLanguage: { labels: string[] } }>('getCodeProduction', filter as Record<string, unknown>);
const consumption = await rpc<{ modelTotals: Record<string, number> }>('getConsumption', filter as Record<string, unknown>);
const consumption = await getConsumptionForAchievements(filter);
const workflows = await rpc<{ clusters: { id: string }[] }>('getWorkflowOptimization', filter as Record<string, unknown>);

const avgReqsPerSession = allSessions.total > 0 ? stats.totalRequests / allSessions.total : 0;
Expand Down Expand Up @@ -537,6 +556,7 @@ export async function renderAchievements(container: HTMLElement, filter: DateFil
topLanguage: codeByLang.byLanguage.labels[0] ?? 'Unknown',
oldTechCount,
hourlyPeak: Math.max(...(hourly.hours ?? [0])),
tokenReportingDisabled: consumption.tokenReportingDisabled,
...buildDailyCumulative(dailyActivity.labels, dailyActivity.loc, dailyActivity.values, dailyActivity.sessions),
};

Expand All @@ -545,7 +565,7 @@ export async function renderAchievements(container: HTMLElement, filter: DateFil

// Wire tab switching
const tabs = container.querySelectorAll<HTMLButtonElement>('.ach-tab');
const list = container.querySelector('#ach-list')!;
const list = container.querySelector<HTMLElement>('#ach-list')!;
for (const tab of tabs) {
tab.addEventListener('click', () => {
for (const t of tabs) t.classList.remove('ach-tab-active');
Expand All @@ -554,7 +574,7 @@ export async function renderAchievements(container: HTMLElement, filter: DateFil
const filtered = cat === 'all' ? evaluated : evaluated.filter(a => a.category === cat);
const fUnlocked = filtered.filter(a => a.result.unlocked);
const fLocked = filtered.filter(a => !a.result.unlocked).sort((a, b) => b.result.progress - a.result.progress);
render(renderAchList(fUnlocked, fLocked), list as HTMLElement);
render(renderAchList(fUnlocked, fLocked), list);
wireShareButtons(container);
});
}
Expand Down