Wave 2B: sportsbook wordmarks + team-logo coverage

MISSION 1 — real sportsbook wordmarks (kills lowercase "betmgm"):
- books.js: add the 6 missing ALLOWED_BOOKS keys (fanatics/bet365/
  hardrockbet/betrivers/pointsbet/pinnacle) with real brand names +
  colors — no live book falls to neutral gray. Add `slug` fields +
  bookSlug()/hasBookSvg() + BUNDLED_BOOK_SVGS.
- Bundle 8 self-authored styled-text wordmark SVGs under
  web/public/books/{slug}.svg (draftkings/fanduel/betmgm/caesars/
  bet365/pinnacle/hardrockbet/betrivers). NOT copied trademarked logo
  glyphs — the book's NAME in brand weight+color; official press-kit
  art can drop into the same paths with zero code change.
- BookWordmark: render the local SVG when bundled, else the brand-color
  styled-text fallback (never a broken image; never a lowercase key).
- Import BookWordmark into the ledger row (page.tsx:368) + the identical
  public-profile row, replacing bare {row.book} text. vyndr/GameCard
  line-grid book cell now proper-cases via bookInfo().name (keeps the
  preferred-book green highlight).

MISSION 2 — team-logo coverage gaps:
- teamMeta.js: add ESPN-schedule ball-sport abbr aliases the feed emits
  that fell to monograms — SA→SAS, NY→NYK, WSH→WAS, BRK→BKN (NBA),
  CONN→CON (WNBA). Real-abbr-first lookup means MLB WSH (Nationals) +
  WNBA NY (Liberty) still resolve directly; NY in MLB stays null.

Tests: new tests/unit/bookWordmark.test.js (all 10 ALLOWED_BOOKS resolve
to a real brand+non-gray color; 8 bundled SVGs exist; BookWordmark
SVG-first + no-lowercase-leak; ledger/profile import + use BookWordmark).
entityLayer.test.js extended for the new aliases. Full suite green
(239 suites / 2891 tests); next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 11:44:15 -04:00
parent b48dc2ed15
commit 7d6dbc6cf3
16 changed files with 231 additions and 27 deletions
+114
View File
@@ -0,0 +1,114 @@
// Wave 2B (data train) — sportsbook wordmarks. Every book the odds feed emits
// resolves to a real brand (name + non-gray color); the ~8 major books render a
// bundled local wordmark SVG; the ledger row renders BookWordmark, never bare
// lowercase `row.book`. The SVGs are self-authored styled text wordmarks (not
// copied trademarked logos), swappable for official press-kit art later.
const fs = require('fs');
const path = require('path');
const { bookInfo, bookSlug, hasBookSvg, BUNDLED_BOOK_SVGS } = require('../../web/src/lib/books');
// The exact keys oddsNormalizer.ALLOWED_BOOKS emits into the pipeline.
const ALLOWED_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle'];
const DEFAULT_FG = '#B8BCC8';
const REPO = path.resolve(__dirname, '../..');
const read = (p) => fs.readFileSync(path.join(REPO, p), 'utf8');
describe('bookInfo — every ALLOWED_BOOK is a real brand (no neutral-gray fall)', () => {
test.each(ALLOWED_BOOKS)('%s resolves to a real brand name + color', (key) => {
const b = bookInfo(key);
// name must be a real brand, not the raw uppercased key echoed back
expect(b.name).toBeTruthy();
expect(b.name.toUpperCase()).not.toBe(key.toUpperCase());
// color must not be the neutral-gray default
expect(b.fg).toBeTruthy();
expect(b.fg.toLowerCase()).not.toBe(DEFAULT_FG.toLowerCase());
// never a bare lowercase feed key in the display name
expect(b.name).not.toBe(key);
});
test('the 6 previously-missing keys now resolve (were gray)', () => {
expect(bookInfo('fanatics').name).toBe('Fanatics');
expect(bookInfo('bet365').name).toBe('bet365');
expect(bookInfo('hardrockbet').name).toBe('Hard Rock');
expect(bookInfo('betrivers').name).toBe('BetRivers');
expect(bookInfo('pointsbet').name).toBe('PointsBet');
expect(bookInfo('pinnacle').name).toBe('Pinnacle');
});
test('case-insensitive: DraftKings / DK / draftkings all resolve the same', () => {
expect(bookInfo('DraftKings').name).toBe('DraftKings');
expect(bookInfo('DK').name).toBe('DraftKings');
expect(bookInfo('draftkings').name).toBe('DraftKings');
});
});
describe('bundled book wordmark SVGs', () => {
const EXPECTED = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers'];
test('BUNDLED_BOOK_SVGS is exactly the 8 major books', () => {
expect([...BUNDLED_BOOK_SVGS].sort()).toEqual([...EXPECTED].sort());
});
test.each(EXPECTED)('web/public/books/%s.svg exists and is a real <svg>', (slug) => {
const svg = read(`web/public/books/${slug}.svg`);
expect(svg).toMatch(/<svg[\s\S]*<\/svg>/);
});
test('bookSlug resolves feed keys + codes to the bundled slug', () => {
expect(bookSlug('draftkings')).toBe('draftkings');
expect(bookSlug('DK')).toBe('draftkings');
expect(bookSlug('hardrockbet')).toBe('hardrockbet');
expect(hasBookSvg('betmgm')).toBe(true);
expect(hasBookSvg('MGM')).toBe(true);
});
test('a book with no bundled SVG (fanatics) still has NO svg but a real name', () => {
expect(hasBookSvg('fanatics')).toBe(false);
expect(bookInfo('fanatics').name).toBe('Fanatics');
});
test('unknown book → no svg, no crash', () => {
expect(hasBookSvg('zzzbook')).toBe(false);
expect(bookSlug('zzzbook')).toBeNull();
});
});
describe('BookWordmark source — SVG-first, styled-text fallback, no lowercase leak', () => {
const src = read('web/src/components/vyndr/BookWordmark.tsx');
test('references the local /books/{slug}.svg path', () => {
expect(src).toContain('/books/');
expect(src).toMatch(/\$\{slug\}\.svg/);
});
test('resolves via the books registry (hasBookSvg + bookInfo + bookSlug)', () => {
expect(src).toContain('hasBookSvg');
expect(src).toContain('bookInfo');
expect(src).toContain('bookSlug');
});
test('falls back to the brand NAME (never the raw lowercase key)', () => {
// the fallback renders b.name (properly-cased brand), not the raw `book` prop
expect(src).toContain('b.name');
expect(src).not.toMatch(/>\s*\{book\}\s*</); // never render the raw prop verbatim
});
});
describe('ledger + public-profile rows render BookWordmark, not bare row.book', () => {
test('ledger page imports + uses BookWordmark', () => {
const src = read('web/src/app/ledger/page.tsx');
expect(src).toMatch(/import\s*\{[^}]*BookWordmark[^}]*\}\s*from\s*'@\/components\/vyndr'/);
expect(src).toContain('<BookWordmark book={row.book}');
// the old bare-text render is gone
expect(src).not.toContain("{row.book || '—'}");
});
test('public profile page imports + uses BookWordmark', () => {
const src = read('web/src/app/u/[handle]/PublicProfile.tsx');
expect(src).toContain('BookWordmark');
expect(src).toContain('<BookWordmark book={row.book}');
expect(src).not.toContain("{row.book || '—'}");
});
});
+19
View File
@@ -18,6 +18,25 @@ describe('resolveTeam — abbr / full name / nickname / alias', () => {
expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ');
expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS');
});
test('ESPN-schedule ball-sport abbr aliases (Wave 2B)', () => {
// NBA abbrs the ESPN schedule emits that used to fall to a monogram
expect(resolveTeam('SA', 'nba').abbr).toBe('SAS'); // Spurs
expect(resolveTeam('NY', 'nba').abbr).toBe('NYK'); // Knicks
expect(resolveTeam('WSH', 'nba').abbr).toBe('WAS'); // Wizards
expect(resolveTeam('BRK', 'nba').abbr).toBe('BKN'); // Nets
// WNBA
expect(resolveTeam('CONN', 'wnba').abbr).toBe('CON'); // Sun
expect(resolveTeam('WSH', 'wnba').abbr).toBe('WAS'); // Mystics
});
test('global alias never shadows a real same-abbr team in another sport', () => {
// WSH is a real MLB abbr (Nationals) — must resolve directly, NOT via the
// NBA/WNBA WSH→WAS alias.
expect(resolveTeam('WSH', 'mlb').name).toBe('Washington Nationals');
// NY is the WNBA Liberty's real abbr — direct hit, not the NBA NY→NYK alias.
expect(resolveTeam('NY', 'wnba').name).toBe('New York Liberty');
// NY is genuinely ambiguous in MLB (NYY/NYM) → stays null, never guessed.
expect(resolveTeam('NY', 'mlb')).toBeNull();
});
test('unknown team → null (never a fake)', () => {
expect(resolveTeam('ZZZ', 'mlb')).toBeNull();
expect(resolveTeam('', 'mlb')).toBeNull();