import { describe, it, expect, vi } from 'vitest'; import { SessionDirector } from './session-director.service.js'; import { DbService } from './db.service.js'; function makeMockDb(overrides: Record = {}): DbService { const mockQuery = vi.fn(); return { pgClient: { query: mockQuery }, getListenerBeliefs: vi.fn().mockResolvedValue([]), getLatestSessionState: vi.fn().mockResolvedValue(null), seedDefaultDiversityBudgets: vi.fn().mockResolvedValue(undefined), upsertDiversityBudget: vi.fn().mockResolvedValue(undefined), ...overrides, } as unknown as DbService; } describe('SessionDirector', () => { describe('pickArc', () => { const director = new SessionDirector(makeMockDb()); it('returns late-night for low energy', () => { const arc = director.pickArc({ energy: 0.2, noveltyHunger: 0.3, sessionAgeMin: 10 } as any); expect(arc).toBe('late-night'); }); it('returns discovery for high energy + high novelty', () => { const arc = director.pickArc({ energy: 0.7, noveltyHunger: 0.6, sessionAgeMin: 5 } as any); expect(arc).toBe('discovery'); }); it('returns energetic for high energy + low novelty', () => { const arc = director.pickArc({ energy: 0.7, noveltyHunger: 0.3, sessionAgeMin: 5 } as any); expect(arc).toBe('energetic'); }); it('returns comfort for medium energy', () => { const arc = director.pickArc({ energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10 } as any); expect(arc).toBe('comfort'); }); }); describe('getArcSlots', () => { const director = new SessionDirector(makeMockDb()); it('returns correct slot count', () => { expect(director.getArcSlots('comfort', 20).length).toBe(20); expect(director.getArcSlots('discovery', 20).length).toBe(20); expect(director.getArcSlots('energetic', 10).length).toBe(10); expect(director.getArcSlots('late-night', 8).length).toBe(8); }); it('has valid role names', () => { const slots = director.getArcSlots('comfort', 20); const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow']; slots.forEach(s => expect(validRoles).toContain(s.role)); }); }); describe('computeEntropy', () => { const director = new SessionDirector(makeMockDb()); it('returns 0 for empty set', () => { expect(director.computeEntropy([])).toBe(0); }); it('returns 1 for all-same-artist', () => { const candidates = [ { trackId: 't1', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] }, { trackId: 't2', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] }, ] as any; expect(director.computeEntropy(candidates)).toBe(1); }); it('returns ~0.5 for two-artist split', () => { const candidates = [ { trackId: 't1', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] }, { trackId: 't2', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] }, ] as any; const hhi = director.computeEntropy(candidates); expect(hhi).toBeCloseTo(0.5); }); }); describe('rankCandidates', () => { it('sorts candidates by score descending', async () => { const db = makeMockDb(); (db.pgClient.query as any).mockResolvedValue({ rows: [] }); const director = new SessionDirector(db); const candidates = [ { trackId: 't1', generatorId: 'a', relevance: 0.9, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] }, { trackId: 't2', generatorId: 'b', relevance: 0.3, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] }, ]; const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 }; const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }]; const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null }; const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, async () => false); expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance); }); }); describe('buildState', () => { it('returns state with default values when no prior session', async () => { const db = makeMockDb(); (db.pgClient.query as any).mockResolvedValue({ rows: [] }); (db.getListenerBeliefs as any).mockResolvedValue([]); (db.getLatestSessionState as any).mockResolvedValue(null); const director = new SessionDirector(db); const state = await director.buildState('user-1'); expect(state).toHaveProperty('energy'); expect(state).toHaveProperty('noveltyHunger'); expect(state).toHaveProperty('sessionAgeMin'); expect(typeof state.energy).toBe('number'); }); }); });