Files
vyndr/tests/unit/roleProfile.test.js
T

224 lines
8.1 KiB
JavaScript

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([]);
});
});