Files
muzick/backend/src/services/session-director.test.ts
T

118 lines
5.4 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { SessionDirector } from './session-director.service.js';
import { DbService } from './db.service.js';
function makeMockDb(overrides: Record<string, any> = {}): 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');
});
});
});