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
131 changes: 130 additions & 1 deletion BackendAcademy/src/gamification/xp.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,4 +327,133 @@ describe('XpService', () => {
expect(result.nextCursor).toBeNull();
});
});
});

describe('XP level calculation from thresholds', () => {
let bus: DomainEventBus;
let service: XpService;

beforeEach(() => {
bus = new DomainEventBus();
service = new XpService(bus);
service.onModuleInit();
});

afterEach(() => service.onModuleDestroy());

function awardXp(userId: string, amount: number) {
const eventCount = Math.ceil(amount / 10);
for (let i = 0; i < eventCount; i++) {
bus.publish({
eventId: `evt-${userId}-${i}`,
userId,
type: 'task.passed'
});
}
}

it('starts at level 1 with 0 XP', () => {
const info = service.getLevelInfo('user-1');

expect(info.level).toBe(1);
expect(info.currentXp).toBe(0);
expect(info.xpForCurrentLevel).toBe(0);
expect(info.xpForNextLevel).toBe(100);
expect(info.progressToNextLevel).toBe(0);
});

it('calculates level 1 progress correctly', () => {
awardXp('user-1', 5); // 50 XP

const info = service.getLevelInfo('user-1');

expect(info.level).toBe(1);
expect(info.currentXp).toBe(50);
expect(info.progressToNextLevel).toBe(50);
});

it('reaches level 2 at exactly 100 XP', () => {
awardXp('user-1', 10); // 100 XP

const info = service.getLevelInfo('user-1');

expect(info.level).toBe(2);
expect(info.currentXp).toBe(100);
expect(info.xpForCurrentLevel).toBe(100);
expect(info.xpForNextLevel).toBe(250);
expect(info.progressToNextLevel).toBe(0);
});

it('calculates level 2 progress correctly', () => {
awardXp('user-1', 17); // 170 XP

const info = service.getLevelInfo('user-1');

expect(info.level).toBe(2);
expect(info.currentXp).toBe(170);
expect(info.xpForCurrentLevel).toBe(100);
expect(info.xpForNextLevel).toBe(250);
// Progress: (170 - 100) / (250 - 100) = 70 / 150 ≈ 46.67%
expect(info.progressToNextLevel).toBeCloseTo(46.67, 1);
});

it('reaches level 3 at exactly 250 XP', () => {
awardXp('user-1', 25); // 250 XP

const info = service.getLevelInfo('user-1');

expect(info.level).toBe(3);
expect(info.currentXp).toBe(250);
expect(info.xpForCurrentLevel).toBe(250);
expect(info.xpForNextLevel).toBe(500);
});

it.each([
[1, 0, 99, 0, 100],
[2, 100, 249, 100, 250],
[3, 250, 499, 250, 500],
[4, 500, 999, 500, 1000],
[5, 1000, 1999, 1000, 2000],
[6, 2000, 4999, 2000, 5000],
[7, 5000, 9999, 5000, 10000],
] as const)(
'level %i boundaries: XP range [%i, %i], thresholds [%i, %i]',
(level, minXp, maxXp, currentThreshold, nextThreshold) => {
// Test at minimum boundary
awardXp('user-min', minXp / 10);
const minInfo = service.getLevelInfo('user-min');
expect(minInfo.level).toBe(level);
expect(minInfo.xpForCurrentLevel).toBe(currentThreshold);
expect(minInfo.xpForNextLevel).toBe(nextThreshold);

// Test at maximum boundary (just before next level)
awardXp('user-max', maxXp / 10);
const maxInfo = service.getLevelInfo('user-max');
expect(maxInfo.level).toBe(level);
expect(maxInfo.xpForCurrentLevel).toBe(currentThreshold);
expect(maxInfo.xpForNextLevel).toBe(nextThreshold);
},
);

it('reaches max level 8 at 10000 XP', () => {
awardXp('user-1', 1000); // 10000 XP

const info = service.getLevelInfo('user-1');

expect(info.level).toBe(8);
expect(info.currentXp).toBe(10000);
expect(info.xpForCurrentLevel).toBe(10000);
expect(info.progressToNextLevel).toBe(0);
});

it('caps progress at 100% when at max level', () => {
awardXp('user-1', 1500); // 15000 XP (beyond max level)

const info = service.getLevelInfo('user-1');

expect(info.level).toBe(8);
expect(info.currentXp).toBe(15000);
expect(info.progressToNextLevel).toBe(100);
});
});
});
});
57 changes: 56 additions & 1 deletion BackendAcademy/src/gamification/xp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Subscription } from 'rxjs';

import { DomainEventBus } from './domain-event-bus';
const __ = 0;
import { XpEvent, XpEventType, XpLedgerEntry, XpLevelInfo } from './xp.types';
import {
MAX_XP_BALANCE,
XpEvent,
Expand All @@ -19,6 +20,22 @@ const XP_BY_EVENT: Record<XpEventType, number> = {
'contribution.created': 25,
};

/**
* Single source of truth for XP level thresholds.
* Level N requires XP_THRESHOLDS[N] total XP to reach.
* Level calculation and progress are derived from this array.
*/
const XP_THRESHOLDS: number[] = [
0, // Level 1: 0 XP
100, // Level 2: 100 XP
250, // Level 3: 250 XP
500, // Level 4: 500 XP
1000, // Level 5: 1000 XP
2000, // Level 6: 2000 XP
5000, // Level 7: 5000 XP
10000, // Level 8: 10000 XP
];

function normalizeToUtcIso(input?: string | Date | null): string {
if (input === undefined || input === null) {
return new Date().toISOString();
Expand Down Expand Up @@ -124,6 +141,44 @@ export class XpService implements OnModuleInit, OnModuleDestroy {
return { userId, totalXp: this.sumXp(entries), entries };
}

/**
* Calculate the user's level and progress based on total XP.
* All level calculations derive from the XP_THRESHOLDS array.
*/
getLevelInfo(userId: string): XpLevelInfo {
const currentXp = this.getBalance(userId);

// Find the highest level the user has reached
let level = 1;
for (let i = XP_THRESHOLDS.length - 1; i >= 0; i--) {
if (currentXp >= XP_THRESHOLDS[i]) {
level = i + 1;
break;
}
}

// Get XP required for current and next level
const xpForCurrentLevel = XP_THRESHOLDS[level - 1];
const xpForNextLevel = level < XP_THRESHOLDS.length
? XP_THRESHOLDS[level]
: XP_THRESHOLDS[XP_THRESHOLDS.length - 1] + 10000; // Cap at max level

// Calculate progress as a percentage (0-100)
const xpInCurrentLevel = currentXp - xpForCurrentLevel;
const xpNeededForNextLevel = xpForNextLevel - xpForCurrentLevel;
const progressToNextLevel = xpNeededForNextLevel > 0
? Math.min(100, (xpInCurrentLevel / xpNeededForNextLevel) * 100)
: 100;

return {
level,
currentXp,
xpForCurrentLevel,
xpForNextLevel,
progressToNextLevel,
};
}
}
/** Sorts by total XP descending, then user ID ascending for stable ties. */
getLeaderboard(): XpLeaderboardEntry[] {
const entriesByUser = new Map<string, XpLedgerEntry[]>();
Expand Down Expand Up @@ -155,4 +210,4 @@ export class XpService implements OnModuleInit, OnModuleDestroy {
}
return total;
}
}
}
8 changes: 8 additions & 0 deletions BackendAcademy/src/gamification/xp.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ export interface XpApplyResult {
duplicate: boolean;
entry?: XpLedgerEntry;
}

export interface XpLevelInfo {
level: number;
currentXp: number;
xpForCurrentLevel: number;
xpForNextLevel: number;
progressToNextLevel: number;
}