mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
✅ test(skilltree): add comprehensive test suite for web and backend
Add 91 tests covering the SkillTree application: Web (Vitest - 53 tests): - Level calculation and XP progress functions - Branch validation and factory functions - createDefaultSkill and createActivity helpers Backend (Jest - 38 tests): - SkillService CRUD operations - XP system with level-up detection - User stats aggregation - Custom Drizzle ORM mock with thenable query builder
This commit is contained in:
parent
42dafe593b
commit
c3dd7703b2
6 changed files with 925 additions and 2 deletions
15
apps/skilltree/apps/backend/jest.config.js
Normal file
15
apps/skilltree/apps/backend/jest.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s', '!**/*.module.ts', '!**/main.ts', '!**/*.dto.ts'],
|
||||
coverageDirectory: '../coverage',
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^src/(.*)$': '<rootDir>/$1',
|
||||
},
|
||||
};
|
||||
497
apps/skilltree/apps/backend/src/skill/skill.service.spec.ts
Normal file
497
apps/skilltree/apps/backend/src/skill/skill.service.spec.ts
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { SkillService } from './skill.service';
|
||||
import { DATABASE_TOKEN } from '../db/database.module';
|
||||
|
||||
// Mock database operations
|
||||
// Uses a query builder pattern where each query chain is thenable
|
||||
const createMockDb = () => {
|
||||
// Queue for resolved values - each await will pop from this queue
|
||||
const resolveQueue: any[] = [];
|
||||
|
||||
// Create a thenable query result (only used for final await)
|
||||
const createQueryResult = (): any => {
|
||||
return {
|
||||
then: (resolve: (value: any) => void, reject?: (reason: any) => void) => {
|
||||
const value = resolveQueue.shift() ?? [];
|
||||
return Promise.resolve(value).then(resolve, reject);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// The mock database object - NOT thenable itself
|
||||
const mockDb: any = {
|
||||
// Helper methods
|
||||
_queueResult: (value: any) => {
|
||||
resolveQueue.push(value);
|
||||
},
|
||||
_queueResults: (...values: any[]) => {
|
||||
values.forEach((v) => resolveQueue.push(v));
|
||||
},
|
||||
_clearQueue: () => {
|
||||
resolveQueue.length = 0;
|
||||
},
|
||||
};
|
||||
|
||||
// Create a query builder that returns thenable results
|
||||
const createChainableMethod = () => {
|
||||
const chainable: any = createQueryResult();
|
||||
chainable.select = jest.fn(() => chainable);
|
||||
chainable.from = jest.fn(() => chainable);
|
||||
chainable.where = jest.fn(() => chainable);
|
||||
chainable.orderBy = jest.fn(() => chainable);
|
||||
chainable.limit = jest.fn(() => chainable);
|
||||
chainable.returning = jest.fn(() => chainable);
|
||||
chainable.insert = jest.fn(() => chainable);
|
||||
chainable.values = jest.fn(() => chainable);
|
||||
chainable.update = jest.fn(() => chainable);
|
||||
chainable.set = jest.fn(() => chainable);
|
||||
chainable.delete = jest.fn(() => chainable);
|
||||
chainable.onConflictDoUpdate = jest.fn(() => chainable);
|
||||
return chainable;
|
||||
};
|
||||
|
||||
// Database entry points return new chainable builders
|
||||
mockDb.select = jest.fn(() => createChainableMethod());
|
||||
mockDb.insert = jest.fn(() => createChainableMethod());
|
||||
mockDb.update = jest.fn(() => createChainableMethod());
|
||||
mockDb.delete = jest.fn(() => createChainableMethod());
|
||||
|
||||
return mockDb;
|
||||
};
|
||||
|
||||
describe('SkillService', () => {
|
||||
let service: SkillService;
|
||||
let mockDb: ReturnType<typeof createMockDb>;
|
||||
|
||||
const testUserId = 'test-user-123';
|
||||
const testSkillId = 'skill-uuid-123';
|
||||
|
||||
const mockSkill = {
|
||||
id: testSkillId,
|
||||
userId: testUserId,
|
||||
name: 'TypeScript',
|
||||
description: 'Learn TypeScript programming',
|
||||
branch: 'intellect',
|
||||
parentId: null,
|
||||
icon: 'code',
|
||||
color: '#3178C6',
|
||||
currentXp: 150,
|
||||
totalXp: 150,
|
||||
level: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = createMockDb();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SkillService,
|
||||
{
|
||||
provide: DATABASE_TOKEN,
|
||||
useValue: mockDb,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SkillService>(SkillService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDb._clearQueue();
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return all skills for a user', async () => {
|
||||
const skills = [mockSkill, { ...mockSkill, id: 'skill-2', name: 'JavaScript' }];
|
||||
mockDb._queueResult(skills);
|
||||
|
||||
const result = await service.findAll(testUserId);
|
||||
|
||||
expect(result).toEqual(skills);
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array when user has no skills', async () => {
|
||||
mockDb._queueResult([]);
|
||||
|
||||
const result = await service.findAll(testUserId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByBranch', () => {
|
||||
it('should return skills filtered by branch', async () => {
|
||||
const intellectSkills = [mockSkill];
|
||||
mockDb._queueResult(intellectSkills);
|
||||
|
||||
const result = await service.findByBranch(testUserId, 'intellect');
|
||||
|
||||
expect(result).toEqual(intellectSkills);
|
||||
});
|
||||
|
||||
it('should return empty array for branch with no skills', async () => {
|
||||
mockDb._queueResult([]);
|
||||
|
||||
const result = await service.findByBranch(testUserId, 'body');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return skill when found', async () => {
|
||||
mockDb._queueResult([mockSkill]);
|
||||
|
||||
const result = await service.findById(testSkillId, testUserId);
|
||||
|
||||
expect(result).toEqual(mockSkill);
|
||||
});
|
||||
|
||||
it('should return null when skill not found', async () => {
|
||||
mockDb._queueResult([]);
|
||||
|
||||
const result = await service.findById('non-existent', testUserId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdOrThrow', () => {
|
||||
it('should return skill when found', async () => {
|
||||
mockDb._queueResult([mockSkill]);
|
||||
|
||||
const result = await service.findByIdOrThrow(testSkillId, testUserId);
|
||||
|
||||
expect(result).toEqual(mockSkill);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when skill not found', async () => {
|
||||
mockDb._queueResult([]);
|
||||
|
||||
await expect(service.findByIdOrThrow('non-existent', testUserId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const createDto = {
|
||||
name: 'React',
|
||||
description: 'Learn React framework',
|
||||
branch: 'intellect' as const,
|
||||
parentId: undefined,
|
||||
icon: 'component',
|
||||
color: '#61DAFB',
|
||||
};
|
||||
|
||||
it('should create a new skill with default XP and level', async () => {
|
||||
const createdSkill = {
|
||||
...createDto,
|
||||
id: 'new-skill-id',
|
||||
userId: testUserId,
|
||||
currentXp: 0,
|
||||
totalXp: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
// Queue results in order of awaits:
|
||||
// 1. insert().values().returning() -> [createdSkill]
|
||||
// 2. updateUserStats: select().from(skills).where() -> [createdSkill]
|
||||
// 3. updateUserStats: select().from(activities).where().orderBy().limit() -> []
|
||||
// 4. calculateStreak: select().from(activities).where().orderBy() -> []
|
||||
// 5. insert().values().onConflictDoUpdate() -> undefined
|
||||
mockDb._queueResults(
|
||||
[createdSkill], // 1. insert skill returning
|
||||
[createdSkill], // 2. select skills
|
||||
[], // 3. select activities (limit)
|
||||
[], // 4. calculateStreak activities
|
||||
undefined // 5. upsert stats
|
||||
);
|
||||
|
||||
const result = await service.create(testUserId, createDto);
|
||||
|
||||
expect(result.name).toBe('React');
|
||||
expect(result.currentXp).toBe(0);
|
||||
expect(result.level).toBe(0);
|
||||
});
|
||||
|
||||
it('should use default icon when not provided', async () => {
|
||||
const dtoWithoutIcon = {
|
||||
name: 'New Skill',
|
||||
description: 'A skill',
|
||||
branch: 'body' as const,
|
||||
parentId: undefined,
|
||||
color: undefined,
|
||||
};
|
||||
|
||||
const createdSkill = {
|
||||
...dtoWithoutIcon,
|
||||
id: 'new-id',
|
||||
userId: testUserId,
|
||||
icon: 'star',
|
||||
currentXp: 0,
|
||||
totalXp: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
mockDb._queueResults([createdSkill], [createdSkill], [], [], undefined);
|
||||
|
||||
const result = await service.create(testUserId, dtoWithoutIcon);
|
||||
|
||||
expect(result.icon).toBe('star');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const updateDto = {
|
||||
name: 'Updated TypeScript',
|
||||
description: 'Master TypeScript',
|
||||
};
|
||||
|
||||
it('should update skill and return updated version', async () => {
|
||||
const updatedSkill = { ...mockSkill, ...updateDto };
|
||||
|
||||
// Queue results:
|
||||
// 1. findByIdOrThrow: select().from(skills).where() -> [mockSkill]
|
||||
// 2. update().set().where().returning() -> [updatedSkill]
|
||||
mockDb._queueResults([mockSkill], [updatedSkill]);
|
||||
|
||||
const result = await service.update(testSkillId, testUserId, updateDto);
|
||||
|
||||
expect(result.name).toBe('Updated TypeScript');
|
||||
expect(result.description).toBe('Master TypeScript');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when updating non-existent skill', async () => {
|
||||
mockDb._queueResult([]);
|
||||
|
||||
await expect(service.update('non-existent', testUserId, updateDto)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete skill successfully', async () => {
|
||||
// Queue results:
|
||||
// 1. findByIdOrThrow: select().from(skills).where() -> [mockSkill]
|
||||
// 2. delete(skills).where() -> undefined
|
||||
// 3. updateUserStats: select().from(skills).where() -> [] (empty after delete)
|
||||
// 4. updateUserStats: select().from(activities).where().orderBy().limit() -> []
|
||||
// 5. calculateStreak: select().from(activities).where().orderBy() -> []
|
||||
// 6. insert().values().onConflictDoUpdate() -> undefined
|
||||
mockDb._queueResults(
|
||||
[mockSkill], // 1. findByIdOrThrow
|
||||
undefined, // 2. delete
|
||||
[], // 3. select skills
|
||||
[], // 4. select activities (limit)
|
||||
[], // 5. calculateStreak
|
||||
undefined // 6. upsert stats
|
||||
);
|
||||
|
||||
await expect(service.delete(testSkillId, testUserId)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when deleting non-existent skill', async () => {
|
||||
mockDb._queueResult([]);
|
||||
|
||||
await expect(service.delete('non-existent', testUserId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addXp', () => {
|
||||
const addXpDto = {
|
||||
xp: 50,
|
||||
description: 'Completed tutorial',
|
||||
duration: 30,
|
||||
};
|
||||
|
||||
it('should add XP and update skill level when threshold crossed', async () => {
|
||||
// Skill at level 0 with 80 XP, adding 50 should reach level 1
|
||||
const skillAt80Xp = { ...mockSkill, currentXp: 80, totalXp: 80, level: 0 };
|
||||
const updatedSkill = {
|
||||
...skillAt80Xp,
|
||||
currentXp: 130,
|
||||
totalXp: 130,
|
||||
level: 1,
|
||||
};
|
||||
const recentActivity = { timestamp: new Date() };
|
||||
|
||||
// Queue results:
|
||||
// 1. findByIdOrThrow: select().from(skills).where() -> [skillAt80Xp]
|
||||
// 2. update(skills).set().where().returning() -> [updatedSkill]
|
||||
// 3. insert(activities).values() -> undefined
|
||||
// 4. updateUserStats: select().from(skills).where() -> [updatedSkill]
|
||||
// 5. updateUserStats: select().from(activities).where().orderBy().limit() -> [activity]
|
||||
// 6. calculateStreak: select().from(activities).where().orderBy() -> [activity]
|
||||
// 7. insert().values().onConflictDoUpdate() -> undefined
|
||||
mockDb._queueResults(
|
||||
[skillAt80Xp], // 1
|
||||
[updatedSkill], // 2
|
||||
undefined, // 3
|
||||
[updatedSkill], // 4
|
||||
[recentActivity], // 5
|
||||
[recentActivity], // 6
|
||||
undefined // 7
|
||||
);
|
||||
|
||||
const result = await service.addXp(testSkillId, testUserId, addXpDto);
|
||||
|
||||
expect(result.skill.totalXp).toBe(130);
|
||||
expect(result.skill.level).toBe(1);
|
||||
expect(result.leveledUp).toBe(true);
|
||||
expect(result.newLevel).toBe(1);
|
||||
});
|
||||
|
||||
it('should not level up when threshold not crossed', async () => {
|
||||
// Skill at level 1 with 150 XP, adding 50 stays at level 1
|
||||
const updatedSkill = {
|
||||
...mockSkill,
|
||||
currentXp: 200,
|
||||
totalXp: 200,
|
||||
level: 1,
|
||||
};
|
||||
const recentActivity = { timestamp: new Date() };
|
||||
|
||||
mockDb._queueResults(
|
||||
[mockSkill], // findByIdOrThrow
|
||||
[updatedSkill], // update skill
|
||||
undefined, // insert activity
|
||||
[updatedSkill], // select skills
|
||||
[recentActivity], // select activities (limit)
|
||||
[recentActivity], // calculateStreak
|
||||
undefined // upsert stats
|
||||
);
|
||||
|
||||
const result = await service.addXp(testSkillId, testUserId, addXpDto);
|
||||
|
||||
expect(result.leveledUp).toBe(false);
|
||||
expect(result.newLevel).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when adding XP to non-existent skill', async () => {
|
||||
mockDb._queueResult([]);
|
||||
|
||||
await expect(service.addXp('non-existent', testUserId, addXpDto)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('should create activity record when adding XP', async () => {
|
||||
const updatedSkill = { ...mockSkill, currentXp: 200, totalXp: 200 };
|
||||
|
||||
mockDb._queueResults(
|
||||
[mockSkill], // findByIdOrThrow
|
||||
[updatedSkill], // update skill
|
||||
undefined, // insert activity
|
||||
[updatedSkill], // select skills
|
||||
[], // select activities (limit)
|
||||
[], // calculateStreak
|
||||
undefined // upsert stats
|
||||
);
|
||||
|
||||
await service.addXp(testSkillId, testUserId, addXpDto);
|
||||
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserStats', () => {
|
||||
it('should return user stats when they exist', async () => {
|
||||
const stats = {
|
||||
userId: testUserId,
|
||||
totalXp: 500,
|
||||
totalSkills: 5,
|
||||
highestLevel: 2,
|
||||
streakDays: 7,
|
||||
lastActivityDate: '2026-01-28',
|
||||
};
|
||||
mockDb._queueResult([stats]);
|
||||
|
||||
const result = await service.getUserStats(testUserId);
|
||||
|
||||
expect(result).toEqual(stats);
|
||||
});
|
||||
|
||||
it('should return default stats when none exist', async () => {
|
||||
mockDb._queueResult([]);
|
||||
|
||||
const result = await service.getUserStats(testUserId);
|
||||
|
||||
expect(result).toEqual({
|
||||
totalXp: 0,
|
||||
totalSkills: 0,
|
||||
highestLevel: 0,
|
||||
streakDays: 0,
|
||||
lastActivityDate: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Level Calculation (Unit Tests)', () => {
|
||||
// Test the calculateLevel function directly
|
||||
const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000];
|
||||
|
||||
function calculateLevel(xp: number): number {
|
||||
for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) {
|
||||
if (xp >= LEVEL_THRESHOLDS[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
describe('calculateLevel', () => {
|
||||
it.each([
|
||||
[0, 0],
|
||||
[50, 0],
|
||||
[99, 0],
|
||||
[100, 1],
|
||||
[250, 1],
|
||||
[499, 1],
|
||||
[500, 2],
|
||||
[1000, 2],
|
||||
[1499, 2],
|
||||
[1500, 3],
|
||||
[3999, 3],
|
||||
[4000, 4],
|
||||
[9999, 4],
|
||||
[10000, 5],
|
||||
[50000, 5],
|
||||
])('calculateLevel(%i) should return %i', (xp, expectedLevel) => {
|
||||
expect(calculateLevel(xp)).toBe(expectedLevel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Level up detection', () => {
|
||||
it('should detect level up from 0 to 1', () => {
|
||||
const oldLevel = calculateLevel(90);
|
||||
const newLevel = calculateLevel(110);
|
||||
expect(oldLevel).toBe(0);
|
||||
expect(newLevel).toBe(1);
|
||||
expect(newLevel > oldLevel).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect level up within same level', () => {
|
||||
const oldLevel = calculateLevel(100);
|
||||
const newLevel = calculateLevel(200);
|
||||
expect(oldLevel).toBe(1);
|
||||
expect(newLevel).toBe(1);
|
||||
expect(newLevel > oldLevel).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect multiple level ups', () => {
|
||||
const oldLevel = calculateLevel(0);
|
||||
const newLevel = calculateLevel(600);
|
||||
expect(oldLevel).toBe(0);
|
||||
expect(newLevel).toBe(2);
|
||||
expect(newLevel - oldLevel).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -9,20 +9,26 @@
|
|||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@testing-library/svelte": "^5.2.6",
|
||||
"@types/node": "^20.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
|
|
|
|||
378
apps/skilltree/apps/web/src/lib/types/index.test.ts
Normal file
378
apps/skilltree/apps/web/src/lib/types/index.test.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
calculateLevel,
|
||||
xpForNextLevel,
|
||||
xpProgress,
|
||||
createDefaultSkill,
|
||||
createActivity,
|
||||
LEVEL_THRESHOLDS,
|
||||
LEVEL_NAMES,
|
||||
BRANCH_INFO,
|
||||
type Skill,
|
||||
type SkillBranch,
|
||||
} from './index';
|
||||
|
||||
describe('Level System', () => {
|
||||
describe('calculateLevel', () => {
|
||||
it('should return level 0 for 0 XP', () => {
|
||||
expect(calculateLevel(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return level 0 for XP below 100', () => {
|
||||
expect(calculateLevel(50)).toBe(0);
|
||||
expect(calculateLevel(99)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return level 1 for XP between 100 and 499', () => {
|
||||
expect(calculateLevel(100)).toBe(1);
|
||||
expect(calculateLevel(250)).toBe(1);
|
||||
expect(calculateLevel(499)).toBe(1);
|
||||
});
|
||||
|
||||
it('should return level 2 for XP between 500 and 1499', () => {
|
||||
expect(calculateLevel(500)).toBe(2);
|
||||
expect(calculateLevel(1000)).toBe(2);
|
||||
expect(calculateLevel(1499)).toBe(2);
|
||||
});
|
||||
|
||||
it('should return level 3 for XP between 1500 and 3999', () => {
|
||||
expect(calculateLevel(1500)).toBe(3);
|
||||
expect(calculateLevel(2500)).toBe(3);
|
||||
expect(calculateLevel(3999)).toBe(3);
|
||||
});
|
||||
|
||||
it('should return level 4 for XP between 4000 and 9999', () => {
|
||||
expect(calculateLevel(4000)).toBe(4);
|
||||
expect(calculateLevel(7000)).toBe(4);
|
||||
expect(calculateLevel(9999)).toBe(4);
|
||||
});
|
||||
|
||||
it('should return level 5 (max) for XP 10000 and above', () => {
|
||||
expect(calculateLevel(10000)).toBe(5);
|
||||
expect(calculateLevel(50000)).toBe(5);
|
||||
expect(calculateLevel(1000000)).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle negative XP by returning 0', () => {
|
||||
expect(calculateLevel(-100)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('xpForNextLevel', () => {
|
||||
it('should return 100 XP for level 0', () => {
|
||||
expect(xpForNextLevel(0)).toBe(100);
|
||||
});
|
||||
|
||||
it('should return 500 XP for level 1', () => {
|
||||
expect(xpForNextLevel(1)).toBe(500);
|
||||
});
|
||||
|
||||
it('should return 1500 XP for level 2', () => {
|
||||
expect(xpForNextLevel(2)).toBe(1500);
|
||||
});
|
||||
|
||||
it('should return 4000 XP for level 3', () => {
|
||||
expect(xpForNextLevel(3)).toBe(4000);
|
||||
});
|
||||
|
||||
it('should return 10000 XP for level 4', () => {
|
||||
expect(xpForNextLevel(4)).toBe(10000);
|
||||
});
|
||||
|
||||
it('should return Infinity for max level (5)', () => {
|
||||
expect(xpForNextLevel(5)).toBe(Infinity);
|
||||
});
|
||||
|
||||
it('should return Infinity for levels beyond max', () => {
|
||||
expect(xpForNextLevel(6)).toBe(Infinity);
|
||||
expect(xpForNextLevel(100)).toBe(Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('xpProgress', () => {
|
||||
it('should return 0% at level threshold', () => {
|
||||
expect(xpProgress(0, 0)).toBe(0);
|
||||
expect(xpProgress(100, 1)).toBe(0);
|
||||
expect(xpProgress(500, 2)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 50% at midpoint', () => {
|
||||
// Level 0: 0-100, midpoint is 50
|
||||
expect(xpProgress(50, 0)).toBe(50);
|
||||
// Level 1: 100-500, midpoint is 300
|
||||
expect(xpProgress(300, 1)).toBe(50);
|
||||
});
|
||||
|
||||
it('should return close to 100% near next threshold', () => {
|
||||
expect(xpProgress(99, 0)).toBeCloseTo(99, 0);
|
||||
expect(xpProgress(499, 1)).toBeCloseTo(99.75, 1);
|
||||
});
|
||||
|
||||
it('should return 100% for max level', () => {
|
||||
expect(xpProgress(10000, 5)).toBe(100);
|
||||
expect(xpProgress(50000, 5)).toBe(100);
|
||||
});
|
||||
|
||||
it('should clamp progress between 0 and 100', () => {
|
||||
// Progress should never exceed 100
|
||||
expect(xpProgress(200, 0)).toBeLessThanOrEqual(100);
|
||||
// Progress should never be negative
|
||||
expect(xpProgress(-10, 0)).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should calculate correct progress for level 2', () => {
|
||||
// Level 2: 500-1500 (range of 1000)
|
||||
// At 750 XP: (750-500)/(1500-500) = 250/1000 = 25%
|
||||
expect(xpProgress(750, 2)).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LEVEL_THRESHOLDS', () => {
|
||||
it('should have 6 levels (0-5)', () => {
|
||||
expect(LEVEL_THRESHOLDS).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should start at 0', () => {
|
||||
expect(LEVEL_THRESHOLDS[0]).toBe(0);
|
||||
});
|
||||
|
||||
it('should be in ascending order', () => {
|
||||
for (let i = 1; i < LEVEL_THRESHOLDS.length; i++) {
|
||||
expect(LEVEL_THRESHOLDS[i]).toBeGreaterThan(LEVEL_THRESHOLDS[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have expected values', () => {
|
||||
expect(LEVEL_THRESHOLDS).toEqual([0, 100, 500, 1500, 4000, 10000]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LEVEL_NAMES', () => {
|
||||
it('should have a name for each level', () => {
|
||||
expect(LEVEL_NAMES).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should have German names', () => {
|
||||
expect(LEVEL_NAMES[0]).toBe('Unbekannt');
|
||||
expect(LEVEL_NAMES[1]).toBe('Anfänger');
|
||||
expect(LEVEL_NAMES[2]).toBe('Fortgeschritten');
|
||||
expect(LEVEL_NAMES[3]).toBe('Kompetent');
|
||||
expect(LEVEL_NAMES[4]).toBe('Experte');
|
||||
expect(LEVEL_NAMES[5]).toBe('Meister');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Branch System', () => {
|
||||
describe('BRANCH_INFO', () => {
|
||||
const branches: SkillBranch[] = [
|
||||
'intellect',
|
||||
'body',
|
||||
'creativity',
|
||||
'social',
|
||||
'practical',
|
||||
'mindset',
|
||||
'custom',
|
||||
];
|
||||
|
||||
it('should have info for all 7 branches', () => {
|
||||
expect(Object.keys(BRANCH_INFO)).toHaveLength(7);
|
||||
});
|
||||
|
||||
it.each(branches)('should have complete info for %s branch', (branch) => {
|
||||
const info = BRANCH_INFO[branch];
|
||||
expect(info).toBeDefined();
|
||||
expect(info.name).toBeTruthy();
|
||||
expect(info.icon).toBeTruthy();
|
||||
expect(info.color).toBeTruthy();
|
||||
expect(info.description).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have German names', () => {
|
||||
expect(BRANCH_INFO.intellect.name).toBe('Intellekt');
|
||||
expect(BRANCH_INFO.body.name).toBe('Körper');
|
||||
expect(BRANCH_INFO.creativity.name).toBe('Kreativität');
|
||||
expect(BRANCH_INFO.social.name).toBe('Sozial');
|
||||
expect(BRANCH_INFO.practical.name).toBe('Praktisch');
|
||||
expect(BRANCH_INFO.mindset.name).toBe('Mindset');
|
||||
expect(BRANCH_INFO.custom.name).toBe('Eigene');
|
||||
});
|
||||
|
||||
it('should have correct icons', () => {
|
||||
expect(BRANCH_INFO.intellect.icon).toBe('brain');
|
||||
expect(BRANCH_INFO.body.icon).toBe('dumbbell');
|
||||
expect(BRANCH_INFO.creativity.icon).toBe('palette');
|
||||
expect(BRANCH_INFO.social.icon).toBe('users');
|
||||
expect(BRANCH_INFO.practical.icon).toBe('wrench');
|
||||
expect(BRANCH_INFO.mindset.icon).toBe('heart');
|
||||
expect(BRANCH_INFO.custom.icon).toBe('star');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Factory Functions', () => {
|
||||
describe('createDefaultSkill', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-28T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('should create a skill with default values', () => {
|
||||
const skill = createDefaultSkill();
|
||||
|
||||
expect(skill.id).toBeTruthy();
|
||||
expect(skill.name).toBe('');
|
||||
expect(skill.description).toBe('');
|
||||
expect(skill.branch).toBe('custom');
|
||||
expect(skill.parentId).toBeNull();
|
||||
expect(skill.icon).toBe('star');
|
||||
expect(skill.color).toBeNull();
|
||||
expect(skill.currentXp).toBe(0);
|
||||
expect(skill.totalXp).toBe(0);
|
||||
expect(skill.level).toBe(0);
|
||||
});
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const skill1 = createDefaultSkill();
|
||||
const skill2 = createDefaultSkill();
|
||||
expect(skill1.id).not.toBe(skill2.id);
|
||||
});
|
||||
|
||||
it('should set timestamps', () => {
|
||||
const skill = createDefaultSkill();
|
||||
expect(skill.createdAt).toBeTruthy();
|
||||
expect(skill.updatedAt).toBeTruthy();
|
||||
expect(skill.createdAt).toBe(skill.updatedAt);
|
||||
});
|
||||
|
||||
it('should merge partial values', () => {
|
||||
const skill = createDefaultSkill({
|
||||
name: 'TypeScript',
|
||||
description: 'Learn TypeScript',
|
||||
branch: 'intellect',
|
||||
totalXp: 500,
|
||||
level: 2,
|
||||
});
|
||||
|
||||
expect(skill.name).toBe('TypeScript');
|
||||
expect(skill.description).toBe('Learn TypeScript');
|
||||
expect(skill.branch).toBe('intellect');
|
||||
expect(skill.totalXp).toBe(500);
|
||||
expect(skill.level).toBe(2);
|
||||
// Default values should still be set
|
||||
expect(skill.icon).toBe('star');
|
||||
expect(skill.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow overriding all fields', () => {
|
||||
const customSkill: Partial<Skill> = {
|
||||
id: 'custom-id',
|
||||
name: 'Custom Skill',
|
||||
description: 'A custom skill',
|
||||
branch: 'body',
|
||||
parentId: 'parent-123',
|
||||
icon: 'star',
|
||||
color: '#FF0000',
|
||||
currentXp: 100,
|
||||
totalXp: 100,
|
||||
level: 1,
|
||||
};
|
||||
|
||||
const skill = createDefaultSkill(customSkill);
|
||||
|
||||
expect(skill.id).toBe('custom-id');
|
||||
expect(skill.name).toBe('Custom Skill');
|
||||
expect(skill.parentId).toBe('parent-123');
|
||||
expect(skill.color).toBe('#FF0000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createActivity', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-28T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('should create an activity with required fields', () => {
|
||||
const activity = createActivity('skill-123', 50, 'Practiced for 30 minutes');
|
||||
|
||||
expect(activity.id).toBeTruthy();
|
||||
expect(activity.skillId).toBe('skill-123');
|
||||
expect(activity.xpEarned).toBe(50);
|
||||
expect(activity.description).toBe('Practiced for 30 minutes');
|
||||
expect(activity.duration).toBeNull();
|
||||
expect(activity.timestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should include duration when provided', () => {
|
||||
const activity = createActivity('skill-123', 100, 'Long session', 60);
|
||||
|
||||
expect(activity.duration).toBe(60);
|
||||
});
|
||||
|
||||
it('should set duration to null when undefined', () => {
|
||||
const activity = createActivity('skill-123', 25, 'Quick practice', undefined);
|
||||
|
||||
expect(activity.duration).toBeNull();
|
||||
});
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const activity1 = createActivity('skill-1', 10, 'Activity 1');
|
||||
const activity2 = createActivity('skill-1', 10, 'Activity 2');
|
||||
|
||||
expect(activity1.id).not.toBe(activity2.id);
|
||||
});
|
||||
|
||||
it('should set current timestamp', () => {
|
||||
const activity = createActivity('skill-123', 10, 'Test');
|
||||
const expectedTime = new Date('2026-01-28T12:00:00Z').toISOString();
|
||||
|
||||
expect(activity.timestamp).toBe(expectedTime);
|
||||
});
|
||||
|
||||
it('should handle zero XP', () => {
|
||||
const activity = createActivity('skill-123', 0, 'Just a note');
|
||||
|
||||
expect(activity.xpEarned).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle large XP values', () => {
|
||||
const activity = createActivity('skill-123', 10000, 'Major milestone');
|
||||
|
||||
expect(activity.xpEarned).toBe(10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
describe('Level calculations with boundary values', () => {
|
||||
it('should handle exact threshold values', () => {
|
||||
expect(calculateLevel(100)).toBe(1);
|
||||
expect(calculateLevel(500)).toBe(2);
|
||||
expect(calculateLevel(1500)).toBe(3);
|
||||
expect(calculateLevel(4000)).toBe(4);
|
||||
expect(calculateLevel(10000)).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle one below threshold', () => {
|
||||
expect(calculateLevel(99)).toBe(0);
|
||||
expect(calculateLevel(499)).toBe(1);
|
||||
expect(calculateLevel(1499)).toBe(2);
|
||||
expect(calculateLevel(3999)).toBe(3);
|
||||
expect(calculateLevel(9999)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Progress calculations at boundaries', () => {
|
||||
it('should handle zero XP at level 0', () => {
|
||||
expect(xpProgress(0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle XP exactly at level-up', () => {
|
||||
// When XP equals next threshold, progress should be 100% for current level
|
||||
// But calculateLevel would put them at next level
|
||||
// This tests the edge case
|
||||
expect(xpProgress(100, 0)).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
9
apps/skilltree/apps/web/src/test/setup.ts
Normal file
9
apps/skilltree/apps/web/src/test/setup.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Test setup file
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock crypto.randomUUID for tests
|
||||
if (typeof crypto === 'undefined') {
|
||||
global.crypto = {
|
||||
randomUUID: () => 'test-uuid-' + Math.random().toString(36).substr(2, 9),
|
||||
} as Crypto;
|
||||
}
|
||||
18
apps/skilltree/apps/web/vitest.config.ts
Normal file
18
apps/skilltree/apps/web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ hot: !process.env.VITEST })],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: resolve('./src/lib'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue