Session 42: Player Intelligence System — archetypes, stat strips, player profile, enhanced cards (2011 tests)

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>
This commit is contained in:
Kev
2026-06-18 11:12:24 -04:00
parent 32069863dc
commit 8bc79f3c38
33 changed files with 2655 additions and 22 deletions
+75
View File
@@ -0,0 +1,75 @@
// Session 42 — ArchetypeBadge + ArchetypeBlend (frontend). Logic runs via the
// CommonJS lib; the .tsx is asserted as text (Phase-DH pattern).
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
const arch = require('../../web/src/lib/archetypes');
const svc = require('../../src/services/archetypeService');
describe('archetypes lib — badge styling', () => {
it('full variant fills with the archetype color + white text', () => {
const s = arch.badgeStyle('POWER PULL', 'full', 'md');
expect(s.bg).toBe('#FF5C5C');
expect(s.textColor).toBe('#FFFFFF');
expect(s.borderColor).toBe('#FF5C5C');
});
it('ghost variant is transparent with a colored border', () => {
const s = arch.badgeStyle('FLOOR GENERAL', 'ghost', 'sm');
expect(s.bg).toBe('transparent');
expect(s.textColor).toBe('#4A9EFF');
expect(s.borderColor).toBe('#4A9EFFCC');
});
it('tint variant (default) uses a low-alpha tinted background', () => {
const s = arch.badgeStyle('ACE');
expect(s.bg).toBe('#A78BFA1F');
expect(s.borderColor).toBe('#A78BFA52');
});
it('archetypeColor matches and is case-insensitive', () => {
expect(arch.archetypeColor('ace')).toBe('#A78BFA');
expect(arch.archetypeColor('Two-Way Anchor')).toBe('#A78BFA');
});
it('glyphSvg returns a 16x16 svg wrapper', () => {
const svg = arch.glyphSvg('star');
expect(svg).toContain('viewBox="0 0 16 16"');
expect(svg).toContain('<path');
});
it('unknown archetype degrades to a neutral grey', () => {
expect(arch.archetypeColor('NOT REAL')).toBe('#9499A8');
});
});
describe('archetypes lib — colors agree with the backend service', () => {
it('every backend archetype color equals the frontend map color', () => {
for (const [name, a] of Object.entries(svc.ARCHETYPES)) {
expect(arch.archetypeColor(name)).toBe(a.color);
}
});
});
describe('ArchetypeBadge.tsx', () => {
const src = read('components/vyndr/ArchetypeBadge.tsx');
it('renders the glyph via dangerouslySetInnerHTML and uses mono', () => {
expect(src).toContain('dangerouslySetInnerHTML');
expect(src).toContain('badgeStyle');
expect(src).toContain('className="mono"');
});
it('supports full / ghost / tint variants', () => {
expect(src).toContain("'full' | 'ghost' | 'tint'");
});
});
describe('ArchetypeBlend.tsx', () => {
const src = read('components/vyndr/ArchetypeBlend.tsx');
it('colors segments via archetypeColor and renders a PRIMARY legend', () => {
expect(src).toContain('archetypeColor');
expect(src).toContain('PRIMARY');
expect(src).toContain('ArchetypeBadge');
});
});
+111
View File
@@ -0,0 +1,111 @@
// 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();
});
});
+26
View File
@@ -0,0 +1,26 @@
// Session 42 — Stats Explorer (/explore) page (bonus design section 07).
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const src = fs.readFileSync(path.join(WEB, 'app', 'explore', 'page.tsx'), 'utf8');
describe('Stats Explorer page', () => {
it('consumes the real /api/stats/leaders endpoint', () => {
expect(src).toContain('/api/stats/leaders');
});
it('has NBA/MLB/WNBA sport tabs + a player search filter', () => {
expect(src).toContain('NBA');
expect(src).toContain('MLB');
expect(src).toContain('WNBA');
expect(src).toContain('Search players');
});
it('rows link to the player profile', () => {
expect(src).toContain('playerHref');
});
it('handles loading / error / empty states', () => {
expect(src).toContain("'loading'");
expect(src).toContain("'error'");
expect(src).toContain('No graded props');
});
});
+63
View File
@@ -0,0 +1,63 @@
// Session 42 — Phase 4: grade-adapter intel fields + enhanced game card.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
const { mapScanToGradeResult, buildIntelFields } = require('../../web/src/lib/gradeAdapter');
describe('gradeAdapter — Player Intelligence fields', () => {
it('omits all new sections when the engine supplies nothing (self-hide)', () => {
const r = mapScanToGradeResult({ player: 'X', stat: 'points', line: 26.5, grade: 'A' });
expect(r.archetypeBlend).toBeUndefined();
expect(r.statContext).toBeUndefined();
expect(r.vyndrIntel).toBeUndefined();
expect(r.propDNA).toBeUndefined();
});
it('lifts a single archetype name into a one-segment blend', () => {
const f = buildIntelFields({ archetype: 'VOLUME SCORER' });
expect(f.archetypeBlend).toEqual([{ archetype: 'VOLUME SCORER', weight: 1 }]);
});
it('passes through a full blend + propDNA + statContext + vyndrIntel', () => {
const r = mapScanToGradeResult({
player: 'Wemby', stat: 'points', line: 26.5, grade: 'A',
archetype_blend: [{ archetype: 'TWO-WAY ANCHOR', weight: 0.6 }, { archetype: 'STRETCH BIG', weight: 0.4 }],
prop_dna: { reliable: ['points'], volatile: ['rebounds'] },
season_avg: 26.9, last10_avg: 28.4, vs_opp_avg: 30.1,
form: 92, usage: '31.2%', matchup_grade: 'A', rest: '+2.4%',
});
expect(r.archetypeBlend).toHaveLength(2);
expect(r.propDNA.reliable).toContain('points');
expect(r.statContext).toEqual({ season: '26.9', last10: '28.4', vsOpp: '30.1' });
expect(r.vyndrIntel).toEqual({ form: 92, usage: '31.2%', matchup: 'A', rest: '+2.4%' });
});
});
describe('GradeResultCard — new sections', () => {
const src = read('components/vyndr/GradeResultCard.tsx');
it('renders the archetype strip + STAT CONTEXT + VYNDR INTELLIGENCE', () => {
expect(src).toContain('ARCHETYPE STRIP');
expect(src).toContain('STAT CONTEXT');
expect(src).toContain('VYNDR INTELLIGENCE');
expect(src).toContain('ArchetypeBlend');
});
});
describe('Enhanced GameCard (Session 42)', () => {
const src = read('components/vyndr/GameCard.tsx');
it('renders MLB starting pitchers when provided', () => {
expect(src).toContain('g.pitchers');
expect(src).toContain('STARTING');
expect(src).toContain('ERA');
});
it('prefers the player-grouped StatStrip (name once) over per-prop rows', () => {
expect(src).toContain('g.playerStrips');
expect(src).toContain('StatStrip');
expect(src).toContain('variant="compact"');
});
it('links player names to their profile', () => {
expect(src).toContain('playerHref');
});
});
+91
View File
@@ -0,0 +1,91 @@
// Session 42 — player intelligence aggregation. cacheGet is injected so these
// run pure (no Redis, no HTTP, no rate limiter).
const svc = require('../../src/services/playerIntelService');
const cacheWith = (envelope) => async (key) => (key.startsWith('grades:') ? envelope : null);
describe('sanitizePlayerName', () => {
it('decodes URL encoding and keeps name punctuation', () => {
expect(svc.sanitizePlayerName('Luka%20Doncic')).toBe('Luka Doncic');
expect(svc.sanitizePlayerName("De'Aaron Fox")).toBe("De'Aaron Fox");
expect(svc.sanitizePlayerName('Ronald Acuna Jr.')).toBe('Ronald Acuna Jr.');
});
it('strips injection / control characters', () => {
expect(svc.sanitizePlayerName('Luka<script>')).toBe('Lukascript');
expect(svc.sanitizePlayerName('a/../../etc/passwd')).toBe('a....etcpasswd');
expect(svc.sanitizePlayerName('x'.repeat(200)).length).toBe(60);
});
it('handles malformed percent-encoding without throwing', () => {
expect(() => svc.sanitizePlayerName('%E0%A4%A')).not.toThrow();
});
});
describe('getPlayerIntel', () => {
it('returns archetype + intelligence + props, found=true when player has grades', async () => {
const envelope = {
grades: [
{ player: 'Austin Riley', team: 'ATL', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'B+', confidence: 71 },
{ player: 'Austin Riley', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'C', confidence: 60 },
{ player: 'Someone Else', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', confidence: 80 },
],
};
const r = await svc.getPlayerIntel('Austin Riley', 'mlb', {
cacheGet: cacheWith(envelope),
stats: { avg: 0.282, hr: 18, rbi: 54, ops: 0.845, k_rate: 26 },
});
expect(r.player).toBe('Austin Riley');
expect(r.sport).toBe('mlb');
expect(r.team).toBe('ATL');
expect(r.found).toBe(true);
expect(r.archetype.primary).toBeTruthy();
expect(r.activeProps).toHaveLength(2); // only Riley's two props
expect(r.activeProps[0]).toMatchObject({ stat: 'total_bases', side: 'O', grade: 'B+' });
expect(r.propDNA.reliable.length + r.propDNA.volatile.length).toBeGreaterThan(0);
expect(r.education.length).toBeGreaterThan(0);
expect(r.intel.find((m) => m.label === 'FORM')).toBeTruthy();
});
it('degrades gracefully when the grades cache is cold (found=false, no crash)', async () => {
const r = await svc.getPlayerIntel('Nobody Special', 'nba', { cacheGet: async () => null });
expect(r.found).toBe(false);
expect(r.activeProps).toEqual([]);
expect(r.archetype.primary).toBeTruthy(); // fallback archetype
expect(Array.isArray(r.intel)).toBe(true);
});
it('survives a throwing cache (returns a valid payload)', async () => {
const r = await svc.getPlayerIntel('X', 'nba', { cacheGet: async () => { throw new Error('redis down'); } });
expect(r.activeProps).toEqual([]);
expect(r.player).toBe('X');
});
});
describe('getLeaders', () => {
const envelope = {
grades: [
{ player: 'A', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', confidence: 88 },
{ player: 'B', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', confidence: 72 },
{ player: 'C', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'A', confidence: 95 },
],
};
it('returns top props by confidence', async () => {
const r = await svc.getLeaders('mlb', { cacheGet: cacheWith(envelope), limit: 2 });
expect(r).toHaveLength(2);
expect(r[0].player).toBe('C'); // highest confidence
expect(r[0].confidence).toBe(95);
});
it('filters to a single stat when given', async () => {
const r = await svc.getLeaders('mlb', { cacheGet: cacheWith(envelope), stat: 'hits' });
expect(r).toHaveLength(2);
expect(r.every((x) => x.stat === 'hits')).toBe(true);
});
it('returns [] on a cold cache', async () => {
expect(await svc.getLeaders('nba', { cacheGet: async () => null })).toEqual([]);
});
});
+80
View File
@@ -0,0 +1,80 @@
// Session 42 — player profile adapter logic + page/proxy/route source assertions.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
const adapter = require('../../web/src/lib/playerProfileAdapter');
const { playerHref } = require('../../web/src/lib/playerHref');
describe('playerProfileAdapter', () => {
it('builds two-letter initials', () => {
expect(adapter.initials('Victor Wembanyama')).toBe('VW');
expect(adapter.initials('Giannis')).toBe('GI');
expect(adapter.initials('')).toBe('??');
});
it('maps sport labels', () => {
expect(adapter.sportLabel('nba')).toBe('NBA');
expect(adapter.sportLabel('soccer')).toBe('SOC');
});
it('flattens prop DNA reliable-first with colors', () => {
const rows = adapter.dnaRows({ reliable: ['total_bases'], volatile: ['hits'] });
expect(rows[0]).toMatchObject({ prop: 'Total Bases', state: 'RELIABLE', color: '#00D4A0' });
expect(rows[1]).toMatchObject({ prop: 'Hits', state: 'VOLATILE', color: '#FFB347' });
});
it('writes a blend readout naming primary + secondary', () => {
const txt = adapter.blendReadout({ primary: { name: 'POWER PULL' }, secondary: { name: 'RUN PRODUCER' } });
expect(txt).toContain('Power Pull');
expect(txt).toContain('Run Producer');
});
});
describe('playerHref', () => {
it('encodes name + sport', () => {
expect(playerHref('De\'Aaron Fox', 'nba')).toBe("/player/De'Aaron%20Fox?sport=nba");
expect(playerHref('Riley', 'MLB')).toBe('/player/Riley?sport=mlb');
});
});
describe('player profile page', () => {
const src = read('app/player/[name]/page.tsx');
it('fetches the stats API and renders the 9 design sections', () => {
expect(src).toContain('/api/stats/player/');
expect(src).toContain('ARCHETYPE DNA');
expect(src).toContain('PROP DNA');
expect(src).toContain('VYNDR INTELLIGENCE');
expect(src).toContain('ACTIVE PROPS');
expect(src).toContain('SEASON STATS');
expect(src).toContain('GRADE HISTORY');
expect(src).toContain('SPLITS');
});
it('handles loading + error states (never crashes on missing data)', () => {
expect(src).toContain("'loading'");
expect(src).toContain("'error'");
expect(src).toContain('Could not load');
});
it('uses ArchetypeBlend + SportBadge + GradeBadge', () => {
expect(src).toContain('ArchetypeBlend');
expect(src).toContain('SportBadge');
expect(src).toContain('GradeBadge');
});
});
describe('stats API proxy routes', () => {
it('player proxy forwards to the Express stats route', () => {
const src = read('app/api/stats/player/[name]/route.ts');
expect(src).toContain('/api/stats/player/');
expect(src).toContain('BACKEND_URL');
});
it('leaders proxy forwards to the Express stats route', () => {
const src = read('app/api/stats/leaders/route.ts');
expect(src).toContain('/api/stats/leaders');
});
});
describe('GradeResultCard player-name link (Session 42)', () => {
it('links the header player name to the profile', () => {
const src = read('components/vyndr/GradeResultCard.tsx');
expect(src).toContain('playerHref(d.player');
});
});
+5 -3
View File
@@ -24,10 +24,12 @@ describe('Session 41 — backend MLB stat_type whitelist', () => {
});
describe('Session 41 — broken-route redirects', () => {
it('/settings redirects to /profile', () => {
// Session 42 — /settings is now a real settings page (replaced the S41
// redirect). The 404 it fixed is still fixed; the route just renders content.
it('/settings is a real page (no longer a redirect stub)', () => {
const src = read('app/settings/page.tsx');
expect(src).toContain("from 'next/navigation'");
expect(src).toContain("redirect('/profile')");
expect(src).not.toContain("redirect('/profile')");
expect(src).toContain('DANGER ZONE');
});
it('/report redirects to /blog (THE REPORT link target)', () => {
+66
View File
@@ -0,0 +1,66 @@
// Session 42 — Settings page + BookChip.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
const { bookInfo } = require('../../web/src/lib/books');
describe('books lib (BookChip data)', () => {
it('maps known books to brand colors (case-insensitive aliases)', () => {
expect(bookInfo('DK')).toMatchObject({ name: 'DraftKings', fg: '#53D337' });
expect(bookInfo('draftkings').mono).toBe('DK');
expect(bookInfo('ESPN').mono).toBe('EB');
});
it('degrades unknown books to a neutral chip', () => {
const b = bookInfo('ZZZ');
expect(b.mono).toBe('ZZZ');
expect(b.fg).toBe('#B8BCC8');
});
});
describe('Settings page', () => {
const src = read('app/settings/page.tsx');
it('is a real page now (not a redirect)', () => {
expect(src).not.toContain("redirect('/profile')");
expect(src).toContain("'use client'");
});
it('renders the design sections', () => {
for (const label of ['ACCOUNT', 'SUBSCRIPTION', 'NOTIFICATIONS', 'DISPLAY PREFERENCES', 'RESPONSIBLE PLAY', 'DANGER ZONE']) {
expect(src).toContain(label);
}
});
it('reads the plan tier from useAuth (consistent with nav/profile)', () => {
expect(src).toContain('useAuth()');
expect(src).toContain('tierLabel');
});
it('LINKS to /settings/security (does NOT replace the MFA page)', () => {
expect(src).toContain('href="/settings/security"');
});
it('danger zone delete button is gated on typing DELETE exactly', () => {
expect(src).toContain("deleteText === 'DELETE'");
expect(src).toContain('disabled={!canDelete');
});
it('opens the existing Preferences modal via window.__prefs', () => {
expect(src).toContain('window.__prefs');
});
it('does not fake account-deletion success', () => {
// On a non-ok response we surface an honest message, not a success.
expect(src).toContain('could not be completed');
});
});
describe('BookChip.tsx', () => {
const src = read('components/vyndr/BookChip.tsx');
it('renders a mono tile from the books lib', () => {
expect(src).toContain('bookInfo');
expect(src).toContain('className="mono"');
});
});
+48
View File
@@ -0,0 +1,48 @@
// Session 42 — StatStrip (.tsx asserted as text; the design's hard rules are
// structural so they're verifiable in source).
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const src = fs.readFileSync(path.join(WEB, 'components', 'vyndr', 'StatStrip.tsx'), 'utf8');
describe('StatStrip.tsx', () => {
it('renders the player name ONCE (single PlayerName helper, not per-stat)', () => {
// PlayerName is defined once and the stats map never references `player`.
const playerNameUses = (src.match(/<PlayerName/g) || []).length;
expect(playerNameUses).toBeGreaterThan(0);
// the stat row maps over `stats`, not the player name
expect(src).toMatch(/stats\.map/);
expect(src).not.toMatch(/stats\.map[\s\S]{0,80}\{player\}/);
});
it('lays stats out horizontally with mono + separators (no vertical repetition)', () => {
expect(src).toContain('className="mono"');
expect(src).toContain('const Sep =');
expect(src).toMatch(/flexWrap: 'wrap'/);
});
it('renders props inline with GradeBadge', () => {
expect(src).toContain('GradeBadge');
expect(src).toMatch(/props\.map/);
expect(src).toContain('PROPS');
});
it('expanded variant shows archetype badge(s)', () => {
expect(src).toContain("variant === 'expanded'");
expect(src).toContain('ArchetypeBadge');
expect(src).toContain('showDesc');
});
it('supports an onPlayerClick navigation hook (keyboard accessible)', () => {
expect(src).toContain('onPlayerClick');
expect(src).toContain('onKeyDown');
expect(src).toContain("role=\"link\"");
});
it('compact variant supports primary + secondary archetypes', () => {
expect(src).toContain('archetype.primary');
expect(src).toContain('archetype?.secondary');
expect(src).toContain("variant=\"ghost\"");
});
});