224 lines
8.1 KiB
JavaScript
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([]);
|
|
});
|
|
});
|