diff --git a/apps/skilltree/apps/backend/jest.config.js b/apps/skilltree/apps/backend/jest.config.js new file mode 100644 index 000000000..702581f02 --- /dev/null +++ b/apps/skilltree/apps/backend/jest.config.js @@ -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/(.*)$': '/$1', + }, +}; diff --git a/apps/skilltree/apps/backend/src/skill/skill.service.spec.ts b/apps/skilltree/apps/backend/src/skill/skill.service.spec.ts new file mode 100644 index 000000000..01e9d3e9b --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/skill.service.spec.ts @@ -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; + + 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); + }); + + 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); + }); + }); +}); diff --git a/apps/skilltree/apps/web/package.json b/apps/skilltree/apps/web/package.json index 669146ff4..e8433a797 100644 --- a/apps/skilltree/apps/web/package.json +++ b/apps/skilltree/apps/web/package.json @@ -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:*", diff --git a/apps/skilltree/apps/web/src/lib/types/index.test.ts b/apps/skilltree/apps/web/src/lib/types/index.test.ts new file mode 100644 index 000000000..5264de55d --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/types/index.test.ts @@ -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 = { + 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); + }); + }); +}); diff --git a/apps/skilltree/apps/web/src/test/setup.ts b/apps/skilltree/apps/web/src/test/setup.ts new file mode 100644 index 000000000..7c13569ef --- /dev/null +++ b/apps/skilltree/apps/web/src/test/setup.ts @@ -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; +} diff --git a/apps/skilltree/apps/web/vitest.config.ts b/apps/skilltree/apps/web/vitest.config.ts new file mode 100644 index 000000000..0608bf0c4 --- /dev/null +++ b/apps/skilltree/apps/web/vitest.config.ts @@ -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'), + }, + }, +});