// 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)', () => { // Session 57 (Phase 0) — Terminal retired; Explore holds its slot. it('BottomTabBar declares Slate/Explore/Scan/Ledger/More', () => { const src = read('components/BottomTabBar.tsx'); ['Slate', 'Explore', '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 }); }); // ── Session 60 (night2/H) — §2 design-pass locks for tonight's surfaces ───── // One meaning per color: green = edge/favorable, amber = caution/market // signal, red = miss/error. Data is mono. Glow stays on A-tier/chrome. describe('QA.20 — movement + status color semantics (Phase 2.5 surfaces)', () => { it('STEAM is amber (caution: entry compressed), VALUE is green (edge)', () => { const strip = read('components/vyndr/StatStrip.tsx'); expect(strip).toMatch(/steam \? 'var\(--amber/); expect(strip).toMatch(/: 'var\(--g-a/); }); it('SYNC goes red ONLY at the STALE tier (error meaning), amber at 1.5x', () => { const live = read('components/vyndr/LiveLayer.tsx'); expect(live).toContain("tier === 'stale' ? 'var(--miss)'"); expect(live).toContain("tier === 'amber' ? 'var(--amber)'"); }); it('a revision renders the ORIGINAL grade struck through (public, never silent)', () => { expect(read('components/vyndr/StatStrip.tsx')).toContain("textDecoration: 'line-through'"); expect(read('app/ledger/page.tsx')).toContain("textDecoration: 'line-through'"); }); it('lens difficulty: tougher = amber, softer = green — never inverted', () => { // DS4 rebuilt the STREAKS billboard: difficulty coloring moved into a `sev` // object, but the semantics are locked here — step-up must be amber, step- // down must be green, and the two must not be swapped. const panel = read('components/StreaksPanel.tsx'); expect(panel).toMatch(/step up'\s*\?\s*\{ color: 'var\(--amber/); expect(panel).toMatch(/step down'\s*\?\s*\{ color: 'var\(--g-a/); }); }); describe('QA.21 — new data surfaces are mono', () => { it.each([ 'components/vyndr/PriorReads.tsx', 'components/vyndr/PlayerStreaks.tsx', 'components/vyndr/ModelRecord.tsx', ])('%s renders its data rows in mono', (rel) => { expect(read(rel)).toContain('className="mono"'); }); }); describe('QA.22 — honest waiting copy everywhere (no passive mystery)', () => { it.each([ 'components/vyndr/StatStrip.tsx', 'app/dashboard/page.tsx', 'components/ExploreHub.tsx', ])('%s derives its waiting state from the real schedule', (rel) => { expect(read(rel)).toContain('nextRunLabelET'); }); }); // ── DESIGN COMPLETION TRAIN — M1 mobile foundation (2026-07-16) ────────── // Structural mobile rules become failing tests (M4 "test-lock what's // lockable"). These assert the RULES exist in source — they do NOT prove // visual correctness at 390px (that needs the Chrome audit). describe('M1 — mobile foundation is wired (structural, not visual)', () => { const css = read('app/globals.css'); it('mobile app-bar clock: desktop heartbeat pieces hidden, clock shown <768px', () => { // Design's mobile drops SIGNAL LIVE + EKG + graded + SYNC-label for a single // right-aligned clock (Hybrid: wall clock rests, STALE reacts). expect(css).toMatch(/\.hb-ekg,\s*\.hb-desktop\s*\{[^}]*display:\s*none/); expect(css).toMatch(/\.hb-mobile-clock\s*\{\s*display:\s*inline-flex/); const live = read('components/vyndr/LiveLayer.tsx'); expect(live).toMatch(/hb-mobile-clock/); expect(live).toMatch(/hb-desktop/); // freshness is the ONE shared tier, not a re-implementation expect(live).toMatch(/from '@\/lib\/freshness'/); // never silently stale: the mobile clock reacts to the stale tier expect(live).toMatch(/STALE/); }); it('primary CTA meets a 44px touch target on mobile', () => { expect(css).toMatch(/\.vbtn\s*\{[^}]*min-height:\s*44px/); // full-size VBtn carries the class; dense `small` buttons opt out expect(read('components/vyndr/VBtn.tsx')).toMatch(/small\s*\?\s*undefined\s*:\s*'vbtn'/); }); it('the M1 mobile-foundation block is present and honestly labelled unverified', () => { expect(css).toMatch(/DESIGN COMPLETION TRAIN — M1 MOBILE FOUNDATION/); expect(css).toMatch(/VISUALLY UNVERIFIED at 390px/); }); it('document never scrolls sideways on phones (overflow contained)', () => { expect(css).toMatch(/@media\s*\(max-width:\s*767px\)\s*\{[^]*?html,\s*body\s*\{[^}]*overflow-x:\s*hidden/); }); }); // ── M4 — structural mobile locks (design completion train) ────────────── describe('M4 — mobile structural rules locked (source, not visual)', () => { const css = read('app/globals.css'); it('the flat EDGE BOARD is the mobile board; game cards are desktop-only', () => { // Design screen 01: <768px shows the flat edge-ranked board, ≥768px keeps // the game-grouped cards. expect(css).toMatch(/\.edge-board-mobile\s*\{\s*display:\s*none/); expect(css).toMatch(/@media\s*\(max-width:\s*767px\)[^]*?\.edge-board-mobile\s*\{\s*display:\s*block/); expect(css).toMatch(/\.edge-board-desktop\s*\{\s*display:\s*none\s*!important/); const slate = read('components/Slate.tsx'); expect(slate).toMatch(/MobileEdgeBoard/); expect(slate).toMatch(/flattenToEdgeBoard/); }); it('the mobile edge board renders matchup chips + tier grade + one hero edge%', () => { const b = read('components/vyndr/MobileEdgeBoard.tsx'); expect(b).toMatch(/TeamChip/); // Rev 3 matchup chips expect(b).toMatch(/GradeBadge/); // tier-colored grade expect(b).toMatch(/var\(--miss\)/); // negative edge = red by sign expect(b).toMatch(/rampOpacity/); // Design's ranked opacity ramp }); it('document still never scrolls sideways on phones (overflow contained)', () => { expect(css).toMatch(/@media\s*\(max-width:\s*767px\)\s*\{[^]*?html,\s*body\s*\{[^}]*overflow-x:\s*hidden/); }); }); // ── P0-1 regression — blue-B / amber-C grade colors must never return ───── describe('P0-1 — grade badges are token-derived; blue-B / amber-C banished', () => { const css = read('app/globals.css'); it('the dead grade-blue #4a9eff / rgba(74,158,255) appears NOWHERE in web/src', () => { const banned = /#4a9eff|rgba\(\s*74\s*,\s*158\s*,\s*255/i; 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); continue; } if (!/\.(tsx?|css|js)$/.test(e.name)) continue; if (banned.test(fs.readFileSync(fp, 'utf8'))) offenders.push(path.relative(WEB, fp)); } }; walk(WEB); expect(offenders).toEqual([]); }); it('GradePill (ledger/u cards) derives bg/border from the grade token, no old-palette rgba', () => { const gc = read('components/GradeCard.tsx'); // the B/C tiers must NOT hardcode blue/amber rgba; must go through color-mix on the token expect(gc).not.toMatch(/rgba\(\s*74\s*,\s*158\s*,\s*255/); // blue-B expect(gc).not.toMatch(/bg:\s*'rgba\(\s*255\s*,\s*179\s*,\s*71/); // amber-C badge bg expect(gc).toMatch(/color-mix\(in srgb, \$\{tok\}/); }); it('GLOW is A-tier only — grade-glow-b/c/d carry NO text-shadow', () => { const glowBlock = css.slice(css.indexOf('.grade-glow-aplus'), css.indexOf('.grade-glow-d') + 60); expect(glowBlock).toMatch(/\.grade-glow-b\s*\{\s*color:[^}]*\}/); expect(glowBlock).not.toMatch(/\.grade-glow-b\s*\{[^}]*text-shadow/); expect(glowBlock).not.toMatch(/\.grade-glow-c\s*\{[^}]*text-shadow/); expect(glowBlock).not.toMatch(/\.grade-glow-d\s*\{[^}]*text-shadow/); }); it('grade-*-bg utility classes are token-derived (color-mix), not raw rgba', () => { expect(css).toMatch(/\.grade-b-bg\s*\{[^}]*color-mix\(in srgb, var\(--grade-b\)/); expect(css).toMatch(/\.grade-c-bg\s*\{[^}]*color-mix\(in srgb, var\(--grade-c\)/); }); });