// 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/legacyName', () => { 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()); expect(typeof a.legacyName).toBe('string'); // VYNDR Originals keep the old label } }); it('uses VYNDR Original names, not the old descriptive labels', () => { const keys = Object.keys(svc.ARCHETYPES); expect(keys).toContain('TORCH'); expect(keys).toContain('BOMBER'); expect(keys).toContain('ALPHA'); expect(keys).not.toContain('VOLUME SCORER'); expect(keys).not.toContain('POWER SLUGGER'); expect(keys).not.toContain('ACE'); }); 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, resolves legacy names, returns null for unknown', () => { expect(svc.getArchetype('alpha').name).toBe('ALPHA'); expect(svc.getArchetype('Fortress').name).toBe('FORTRESS'); // legacy names still resolve to their VYNDR Original expect(svc.getArchetype('ACE').name).toBe('ALPHA'); expect(svc.getArchetype('POWER SLUGGER').name).toBe('BOMBER'); 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('TORCH'); 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(['FORTRESS', 'PAINT BOSS', 'ARTILLERY']).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('ALPHA'); expect(eater.primary.name).toBe('WORKHORSE'); }); 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('HAMMER'); }); it('classifies a contact hitter (BRUSH) and a power hitter (BOMBER) 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('BRUSH'); expect(power.primary.name).toBe('BOMBER'); }); }); 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(['ANCHOR', 'TORCH', 'RANGE']).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('CONDUCTOR'); }); }); describe('archetypeService — HONEST ABSENCE on empty stats (Session 69)', () => { it('returns NO archetype for empty stats — never a fallback bucket', () => { // Was: a fallback archetype at weight 1.0 (FLEX/SHIELD/CONNECTOR), i.e. // "could not classify" rendered as a confident classification with // descriptive education copy. 8 of 18 MLB players carried it. for (const s of [svc.classifyNBA({}), svc.classifyMLB({}), svc.classify('badsport', {})]) { expect(s.primary).toBeNull(); expect(s.blend).toEqual([]); } }); it('still never crashes on junk input', () => { expect(() => svc.classify('mlb', null)).not.toThrow(); expect(() => svc.classify(null, {})).not.toThrow(); }); });