8bc79f3c38
Built from the Claude Design "VYNDR Player Intelligence" bundle (10 sections). - Archetypes: src/services/archetypeService.js — 41 archetypes (15 NBA / 5 WNBA-unique / 15 MLB / 6 soccer), classify -> primary+secondary+blend. Frontend visual map web/src/lib/archetypes.js (colors verified == backend). ArchetypeBadge (full/ghost/tint + glyphs) + ArchetypeBlend (DNA bar). - StatStrip (compact/expanded): player name once, horizontal mono stats, inline GradeBadge props, onPlayerClick -> profile. - Stats API: extended src/routes/stats.js with /player/:name, /leaders, /game/:id (rate-limited). Aggregation in playerIntelService.js (sanitizes name param; grades cache; graceful on cold cache). Next proxies added. - Player Profile /player/[name]: all 9 design sections, graceful empty states. - Enhanced GameCard (MLB pitchers + player-grouped StatStrips) + GradeResultCard (archetype strip + stat context + VYNDR intelligence, optional/self-hiding via gradeAdapter.buildIntelFields). Player-name links wired everywhere. - Settings page replaces the S41 redirect (account/subscription/notifications/ display/responsible-play/danger-zone with DELETE-gated delete). LINKS to the real /settings/security MFA page — does not replace it. + BookChip. - Bonus: Stats Explorer /explore (real /api/stats/leaders leaderboard); added Explore + Settings to Nav MORE. Deferred (need data pipelines, Session 43): Team Hub, Offseason Intel, Slate redesign, Stats Explorer sub-panels. Backend 1940 -> 2011 tests (+71), 157 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
4.7 KiB
JavaScript
112 lines
4.7 KiB
JavaScript
// Session 42 — archetype classification service.
|
|
|
|
const svc = require('../../src/services/archetypeService');
|
|
|
|
describe('archetypeService — registry', () => {
|
|
it('exposes the full design archetype set (41: 15 NBA + 5 WNBA + 15 MLB + 6 soccer)', () => {
|
|
expect(Object.keys(svc.ARCHETYPES).length).toBe(41);
|
|
const bySport = {};
|
|
for (const a of Object.values(svc.ARCHETYPES)) bySport[a.sport] = (bySport[a.sport] || 0) + 1;
|
|
expect(bySport).toEqual({ nba: 15, wnba: 5, mlb: 15, soccer: 6 });
|
|
});
|
|
|
|
it('every archetype has name/tag/color/glyph/description/propDNA/education', () => {
|
|
for (const [name, a] of Object.entries(svc.ARCHETYPES)) {
|
|
expect(typeof a.tag).toBe('string');
|
|
expect(a.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
|
expect(typeof a.glyph).toBe('string');
|
|
expect(typeof a.description).toBe('string');
|
|
expect(a.propDNA).toBeTruthy();
|
|
expect(Array.isArray(a.propDNA.reliable)).toBe(true);
|
|
expect(Array.isArray(a.propDNA.volatile)).toBe(true);
|
|
expect(a.education.length).toBeGreaterThan(20);
|
|
expect(name).toBe(name.toUpperCase());
|
|
}
|
|
});
|
|
|
|
it('colors are unique WITHIN each sport (reused across sports by design)', () => {
|
|
const bySport = {};
|
|
for (const a of Object.values(svc.ARCHETYPES)) {
|
|
bySport[a.sport] = bySport[a.sport] || [];
|
|
bySport[a.sport].push(a.color);
|
|
}
|
|
for (const [sport, colors] of Object.entries(bySport)) {
|
|
expect(new Set(colors).size).toBe(colors.length); // no dup within sport
|
|
}
|
|
});
|
|
|
|
it('getArchetype is case-insensitive and returns null for unknown', () => {
|
|
expect(svc.getArchetype('ace').name).toBe('ACE');
|
|
expect(svc.getArchetype('Two-Way Anchor').name).toBe('TWO-WAY ANCHOR');
|
|
expect(svc.getArchetype('not a real one')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('archetypeService — NBA classification', () => {
|
|
it('classifies a volume scorer', () => {
|
|
const r = svc.classifyNBA({ ppg: 30, rpg: 5, apg: 4, usg: 33, threes: 2.5, pos: 'G' });
|
|
expect(r.primary.name).toBe('VOLUME SCORER');
|
|
expect(r.sport).toBe('nba');
|
|
});
|
|
|
|
it('returns primary + secondary for a hybrid (Point Forward / Floor General)', () => {
|
|
const r = svc.classifyNBA({ ppg: 22, rpg: 7, apg: 8, usg: 27, pos: 'F', threes: 1.5 });
|
|
expect(r.primary).toBeTruthy();
|
|
expect(r.secondary).toBeTruthy();
|
|
expect(r.primary.name).not.toBe(r.secondary.name);
|
|
});
|
|
|
|
it('classifies a two-way anchor (Wembanyama-type)', () => {
|
|
const r = svc.classifyNBA({ ppg: 24, rpg: 11, apg: 3, bpg: 3.2, usg: 31, threes: 1.5, pos: 'C' });
|
|
expect(['TWO-WAY ANCHOR', 'POST SCORER', 'STRETCH BIG']).toContain(r.primary.name);
|
|
});
|
|
|
|
it('produces a normalized blend that sums to ~1', () => {
|
|
const r = svc.classifyNBA({ ppg: 28, rpg: 8, apg: 7, usg: 30, pos: 'F' });
|
|
const sum = r.blend.reduce((s, b) => s + b.weight, 0);
|
|
expect(sum).toBeGreaterThan(0.98);
|
|
expect(sum).toBeLessThan(1.02);
|
|
});
|
|
});
|
|
|
|
describe('archetypeService — MLB classification', () => {
|
|
it('differentiates an Ace from an Innings Eater', () => {
|
|
const ace = svc.classifyMLB({ role: 'SP', era: 2.9, k9: 11.5, whip: 0.98, ip_per_start: 6.2 });
|
|
const eater = svc.classifyMLB({ role: 'SP', era: 4.1, k9: 7.0, whip: 1.3, ip_per_start: 6.5 });
|
|
expect(ace.primary.name).toBe('ACE');
|
|
expect(eater.primary.name).toBe('INNINGS EATER');
|
|
});
|
|
|
|
it('classifies a closer', () => {
|
|
const r = svc.classifyMLB({ role: 'CL', era: 2.2, k9: 12, saves: 24, ip_per_start: 1 });
|
|
expect(r.primary.name).toBe('CLOSER');
|
|
});
|
|
|
|
it('classifies a contact hitter and a power-pull hitter differently', () => {
|
|
const contact = svc.classifyMLB({ avg: 0.315, hr: 8, rbi: 40, k_rate: 12, ops: 0.82 });
|
|
const power = svc.classifyMLB({ avg: 0.235, hr: 34, rbi: 88, k_rate: 30, ops: 0.86 });
|
|
expect(contact.primary.name).toBe('CONTACT');
|
|
expect(power.primary.name).toBe('POWER PULL');
|
|
});
|
|
});
|
|
|
|
describe('archetypeService — WNBA classification', () => {
|
|
it('classifies a dominant post / interior anchor (Wilson-type)', () => {
|
|
const r = svc.classifyWNBA({ ppg: 27, rpg: 12, apg: 2, bpg: 2.3, usg: 31, pos: 'F' });
|
|
expect(['INTERIOR ANCHOR', 'VOLUME SCORER', 'STRETCH FORWARD']).toContain(r.primary.name);
|
|
});
|
|
|
|
it('classifies a floor general', () => {
|
|
const r = svc.classifyWNBA({ ppg: 16, rpg: 4, apg: 7, usg: 24, pos: 'G' });
|
|
expect(r.primary.name).toBe('FLOOR GENERAL');
|
|
});
|
|
});
|
|
|
|
describe('archetypeService — graceful fallback', () => {
|
|
it('returns a fallback archetype for empty stats (never crashes)', () => {
|
|
expect(svc.classifyNBA({}).primary).toBeTruthy();
|
|
expect(svc.classifyMLB({}).primary).toBeTruthy();
|
|
expect(svc.classify('badsport', {}).primary).toBeNull();
|
|
});
|
|
});
|