118 lines
5.4 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|