// VYNDR 2.0 — Phase H parity QA (Session 39). Locks the automated §13 // checklist invariants so the design conversion can't silently regress. 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'); describe('QA.6 — glitch discipline (data NEVER glitches)', () => { const GLITCH = /wm-tear|glitch-shift|head-tear|glitch-hover/; it.each([ 'components/vyndr/GradeResultCard.tsx', 'components/vyndr/GameCard.tsx', 'components/GameCard.tsx', 'components/vyndr/ProcessingGrade.tsx', ])('%s contains no glitch classes', (rel) => { expect(GLITCH.test(read(rel))).toBe(false); }); }); describe('QA.1 — token resolution (no literals that duplicate a token)', () => { it('ProcessingGrade uses the A+ token, not the raw #00ffb8 literal', () => { const src = read('components/vyndr/ProcessingGrade.tsx'); expect(src).not.toContain('#00ffb8'); expect(src).toContain('var(--g-ap)'); }); it('game detail uses sport tokens, not duplicated sport hex', () => { const src = read('app/game/[id]/page.tsx'); expect(src).toContain('var(--s-nba)'); expect(src).not.toMatch(/#E94B3C/); }); }); describe('QA.4 — Bloomberg best/worst line pattern', () => { it('legacy GameCard (live slate) tints best green / worst red', () => { const src = read('components/GameCard.tsx'); expect(src).toContain('rgba(0,212,160,.13)'); expect(src).toContain('rgba(255,82,82,.07)'); expect(src).toMatch(/bestAway|bestHome/); }); }); describe('QA.5 — wordmark everywhere', () => { it.each([ 'components/Nav.tsx', 'components/Footer.tsx', 'app/login/page.tsx', 'app/about/page.tsx', 'app/not-found.tsx', ])('%s renders the Wordmark', (rel) => { expect(read(rel)).toContain('Wordmark'); }); }); describe('QA.11 — mobile parity (5-tab bar)', () => { it('BottomTabBar declares Slate/Terminal/Scan/Ledger/More', () => { const src = read('components/BottomTabBar.tsx'); ['Slate', 'Terminal', 'Scan', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`)); }); }); describe('QA.16 — no dead buttons', () => { it.each(['components', 'app'])('no empty onClick handlers under web/src/%s', (dir) => { const root = path.join(WEB, dir); const offenders = []; const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const fp = path.join(d, e.name); if (e.isDirectory()) walk(fp); else if (e.name.endsWith('.tsx') && /onClick=\{\}/.test(fs.readFileSync(fp, 'utf8'))) offenders.push(fp); } }; walk(root); expect(offenders).toEqual([]); }); }); describe('QA.18 — auth gate integration', () => { it('AuthGate gates via lib/routes and bounces to /login?next=', () => { const src = read('components/AuthGate.tsx'); expect(src).toContain('isGatedRoute'); expect(src).toContain('/login?next='); }); it('gated route list covers the personal surfaces', () => { const routes = require('../../web/src/lib/routes'); ['/ledger', '/tracker', '/account', '/notifications'].forEach((r) => expect(routes.isGatedRoute(r)).toBe(true), ); expect(routes.isGatedRoute('/dashboard')).toBe(false); // free funnel stays open }); });