const { ROLE_TAXONOMY, calculateRoleVariance, getDominantRole, detectRoleElevation, getConditionalProfile, calculateRoleProfile, } = require('../../src/services/roleProfileEngine'); const { calculateStability, applyDecayWeights, } = require('../../src/services/roleStabilityEngine'); describe('roleProfileEngine', () => { // ─── calculateRoleVariance ────────────────────────────────── test('returns 0 for a single-role profile', () => { const profile = { PRIMARY_BALL_HANDLER: 1.0 }; expect(calculateRoleVariance(profile)).toBe(0); }); test('returns ~1.0 for perfectly equal distribution', () => { const profile = {}; for (const role of ROLE_TAXONOMY) { profile[role] = 1 / ROLE_TAXONOMY.length; } const variance = calculateRoleVariance(profile); expect(variance).toBeGreaterThan(0.99); expect(variance).toBeLessThanOrEqual(1.0); }); test('returns value strictly between 0 and 1 for mixed profile', () => { const profile = { PRIMARY_BALL_HANDLER: 0.50, SECONDARY_PLAYMAKER: 0.30, CONNECTOR: 0.20, }; const variance = calculateRoleVariance(profile); expect(variance).toBeGreaterThan(0); expect(variance).toBeLessThan(1); }); test('handles empty profile gracefully', () => { expect(calculateRoleVariance({})).toBe(0); }); test('ignores zero-weight roles in entropy calculation', () => { const profileA = { PRIMARY_BALL_HANDLER: 0.5, CONNECTOR: 0.5 }; const profileB = { PRIMARY_BALL_HANDLER: 0.5, CONNECTOR: 0.5, FLOOR_RAISER: 0, PAINT_PRESENCE: 0, }; // Both should give same result since zeros are filtered expect(calculateRoleVariance(profileA)).toBeCloseTo(calculateRoleVariance(profileB), 5); }); // ─── getDominantRole ──────────────────────────────────────── test('returns the role with the highest weight', () => { const profile = { PRIMARY_BALL_HANDLER: 0.15, FLOOR_RAISER: 0.55, CONNECTOR: 0.30, }; expect(getDominantRole(profile)).toBe('FLOOR_RAISER'); }); test('returns null for empty profile', () => { expect(getDominantRole({})).toBeNull(); }); // ─── detectRoleElevation ──────────────────────────────────── test('detects elevation when delta exceeds threshold', () => { const base = { PRIMARY_BALL_HANDLER: 0.20, SECONDARY_PLAYMAKER: 0.50, CONNECTOR: 0.30, }; const tonight = { PRIMARY_BALL_HANDLER: 0.60, SECONDARY_PLAYMAKER: 0.25, CONNECTOR: 0.15, }; const result = detectRoleElevation(base, tonight, 0.20); expect(result.elevated).toBe(true); expect(result.elevatedRole).toBe('PRIMARY_BALL_HANDLER'); expect(result.delta).toBeGreaterThan(0.20); }); test('does not flag elevation when delta is below threshold', () => { const base = { PRIMARY_BALL_HANDLER: 0.40, SECONDARY_PLAYMAKER: 0.35, CONNECTOR: 0.25, }; const tonight = { PRIMARY_BALL_HANDLER: 0.45, SECONDARY_PLAYMAKER: 0.30, CONNECTOR: 0.25, }; const result = detectRoleElevation(base, tonight, 0.20); expect(result.elevated).toBe(false); expect(result.elevatedRole).toBeNull(); }); // ─── getConditionalProfile ────────────────────────────────── test('returns conditional profile for valid condition', () => { const conditionals = { star_out: { PRIMARY_BALL_HANDLER: 0.70, FLOOR_RAISER: 0.30 }, closing_lineup: { SWITCHABLE_DEFENDER: 0.60, CONNECTOR: 0.40 }, }; const result = getConditionalProfile(conditionals, 'star_out'); expect(result).toEqual({ PRIMARY_BALL_HANDLER: 0.70, FLOOR_RAISER: 0.30 }); }); test('returns null for invalid condition key', () => { const conditionals = { star_out: { PRIMARY_BALL_HANDLER: 1.0 } }; expect(getConditionalProfile(conditionals, 'garbage_condition')).toBeNull(); }); // ─── calculateRoleProfile ────────────────────────────────── test('produces distribution that sums to approximately 1.0', () => { const gameLogStats = [ { usage_rate: 28, assist_rate: 32, three_point_share: 40, off_ball_movement: 30, paint_touches: 4, rebounds_per_game: 5, defensive_versatility: 45, screen_assists: 2, }, { usage_rate: 30, assist_rate: 35, three_point_share: 38, off_ball_movement: 25, paint_touches: 3, rebounds_per_game: 4.5, defensive_versatility: 50, screen_assists: 3, }, ]; const profile = calculateRoleProfile(gameLogStats); const total = Object.values(profile).reduce((s, v) => s + v, 0); expect(total).toBeGreaterThan(0.95); expect(total).toBeLessThanOrEqual(1.05); }); test('returns empty object for empty game logs', () => { expect(calculateRoleProfile([])).toEqual({}); }); }); describe('roleStabilityEngine', () => { // ─── calculateStability ───────────────────────────────────── test('low variance player gets no decay (all weights 1.0)', () => { const profile = { PRIMARY_BALL_HANDLER: 0.85, CONNECTOR: 0.15 }; const varianceScore = 0.15; // below 0.2 threshold const history = [ { date: '2026-01-01', roleProfile: { PRIMARY_BALL_HANDLER: 0.85, CONNECTOR: 0.15 } }, { date: '2026-01-05', roleProfile: { PRIMARY_BALL_HANDLER: 0.80, CONNECTOR: 0.20 } }, { date: '2026-01-10', roleProfile: { PRIMARY_BALL_HANDLER: 0.85, CONNECTOR: 0.15 } }, ]; const result = calculateStability(profile, varianceScore, history); // All decay weights should be 1.0 expect(result.decay_weights_by_period.every((w) => w === 1.0)).toBe(true); expect(result.stability_score).toBeGreaterThan(0.7); }); test('high variance player gets recency decay', () => { const profile = { PRIMARY_BALL_HANDLER: 0.30, FLOOR_RAISER: 0.30, CONNECTOR: 0.20, SWITCHABLE_DEFENDER: 0.20, }; const varianceScore = 0.65; // above 0.5 threshold const history = [ { date: '2026-01-01', roleProfile: { FLOOR_RAISER: 0.60, CONNECTOR: 0.40 } }, { date: '2026-01-05', roleProfile: { PRIMARY_BALL_HANDLER: 0.50, CONNECTOR: 0.50 } }, { date: '2026-01-10', roleProfile: { SWITCHABLE_DEFENDER: 0.55, FLOOR_RAISER: 0.45 } }, { date: '2026-01-15', roleProfile: { PRIMARY_BALL_HANDLER: 0.30, FLOOR_RAISER: 0.30, CONNECTOR: 0.20, SWITCHABLE_DEFENDER: 0.20 } }, ]; const result = calculateStability(profile, varianceScore, history); // Older entries should have lower weights than newer const weights = result.decay_weights_by_period; expect(weights[weights.length - 1]).toBeGreaterThan(weights[0]); // Should detect role changes expect(result.role_change_events).toBeGreaterThan(0); }); test('stability score is between 0 and 1', () => { const profile = { PAINT_PRESENCE: 0.70, SWITCHABLE_DEFENDER: 0.30 }; const history = [ { date: '2026-01-01', roleProfile: { PAINT_PRESENCE: 0.65, SWITCHABLE_DEFENDER: 0.35 } }, { date: '2026-01-10', roleProfile: { PAINT_PRESENCE: 0.70, SWITCHABLE_DEFENDER: 0.30 } }, ]; const result = calculateStability(profile, 0.35, history); expect(result.stability_score).toBeGreaterThanOrEqual(0); expect(result.stability_score).toBeLessThanOrEqual(1); }); // ─── applyDecayWeights ────────────────────────────────────── test('all weights are 1.0 when variance is below 0.2', () => { const instances = [{}, {}, {}, {}, {}]; const weights = applyDecayWeights(instances, 0.10); expect(weights).toEqual([1.0, 1.0, 1.0, 1.0, 1.0]); }); test('returns empty array for empty instances', () => { expect(applyDecayWeights([], 0.6)).toEqual([]); }); });