66d52a9ce0
The product argued with itself: FAB/nav said "Scan", Free tier "5 scans", ticker "MLB slate scanned" — while the Ledger says "MY READS". Swept every user-visible surface to READ: - BottomTabBar FAB + Nav link: 'Scan' → 'Read' - Pricing free tier: '5 scans to try the model' → '5 reads …' - StatStrip: 'Awaiting next scan' → 'Awaiting next read' - Ticker badge + snapshotService event: tag 'SCAN' → 'READ', 'slate scanned' → 'slate read' (readSportOf parses BOTH old and new so cached ticker items dedupe cleanly through the rollover) - upgradePitch: 'You've scanned N parlays' / 'unlimited scans' → read/reads Internal untouched (not user-visible): /api/scan routes, scan_count column, scanning state, DemoScan/ScanIcon, scanlines CSS, the transitional SCAN color-map key. tests/unit/verbLaw.test.js is the enforcement: it fails on user-visible scan/scanned/scans copy across web/src + src/services (skips comments). Suite 270/3254 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
312 lines
14 KiB
JavaScript
312 lines
14 KiB
JavaScript
// 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', 'Read', '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\)/);
|
|
});
|
|
});
|
|
|
|
// ── P0-4 lock — mobile header collapses to ONE line ──────────────────────
|
|
describe('P0-4 — mobile app bar is one line (ticker + heartbeat hidden)', () => {
|
|
const css = read('app/globals.css');
|
|
it('<768px hides the ticker row + the whole heartbeat bar; nav clock shows', () => {
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*767px\)\s*\{[^]*?\.nav-ticker\s*\{[^}]*display:\s*none/);
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*767px\)\s*\{[^]*?\.heartbeat-bar\s*\{[^}]*display:\s*none/);
|
|
expect(css).toMatch(/\.nav-mobile-clock\s*\{\s*display:\s*none/); // desktop hides it
|
|
expect(css).toMatch(/@media[^]*?\.nav-mobile-clock\s*\{[^}]*display:\s*inline-flex/);
|
|
// the clock is wired into the nav, freshness-driven (never silently stale)
|
|
const nav = read('components/Nav.tsx');
|
|
expect(nav).toMatch(/MobileSyncClock/);
|
|
expect(nav).toMatch(/nav-mobile-clock/);
|
|
});
|
|
it('main top padding + slate sticky drop to the 60px bar on mobile (no clipping)', () => {
|
|
expect(css).toMatch(/@media[^]*?main\s*\{[^}]*padding-top:\s*62px/);
|
|
expect(css).toMatch(/\.slate-sticky-head\s*\{\s*top:\s*60px/);
|
|
});
|
|
});
|
|
|
|
// ── P1-5 lock — 390px containment (strengthened: content, not just doc scroll) ─
|
|
describe('P1-5 — dense rows CONTAIN at 390px (not just html overflow-x:hidden)', () => {
|
|
const css = read('app/globals.css');
|
|
it('consensus rows stack at <430px so they never bleed off the right edge', () => {
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*430px\)\s*\{[^]*?\.breadth-row\s*\{[^}]*flex-direction:\s*column/);
|
|
expect(css).toMatch(/\.breadth-row\s*>\s*span\s*\{[^}]*overflow-wrap:\s*anywhere/);
|
|
expect(read('components/vyndr/MarketBreadth.tsx')).toMatch(/breadth-row/);
|
|
});
|
|
it('the starting-pitcher line wraps its name/ERA inside the card (no truncation)', () => {
|
|
const gc = read('components/vyndr/GameCard.tsx');
|
|
// the inner pitcher spans allow wrap + shrink so "2.64 ERA" never gets cut
|
|
expect(gc).toMatch(/g\.pitchers\.away\.era/);
|
|
expect((gc.match(/flexWrap: 'wrap', minWidth: 0/g) || []).length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
// ── P1-7 lock — the flat edge board never leads with a fabricated edge ───────
|
|
describe('P1-7 — edge board guards the broken edge placeholder', () => {
|
|
it('EdgeCell + flattenToEdgeBoard both cap a sane market edge (absent > fake)', () => {
|
|
expect(read('components/vyndr/MobileEdgeBoard.tsx')).toMatch(/SANE_EDGE_MAX/);
|
|
expect(read('lib/slateAdapter.js')).toMatch(/EDGE_BOARD_SANE_MAX/);
|
|
});
|
|
});
|
|
|
|
// ── P1-8 lock — Compare verdict makes no unverifiable "tonight" claim ────────
|
|
describe('P1-8 — Compare verdict is time-honest (form, not a phantom game)', () => {
|
|
const compare = read('app/compare/page.tsx');
|
|
it('the sample verdict never claims "tonight" (NBA has 0 games in July)', () => {
|
|
expect(compare).not.toMatch(/edge tonight/i);
|
|
expect(compare).toMatch(/on current form/i);
|
|
});
|
|
});
|
|
|
|
// ── P2-10 lock — ledger read cards are dense (3-4 per phone screen) ──────────
|
|
describe('P2-10 — ledger cards are tightened for density', () => {
|
|
const ledger = read('app/ledger/page.tsx');
|
|
it('the read card uses tight padding for density (3-4 per phone screen)', () => {
|
|
// the LedgerCard <article> padding dropped 16 -> 12 (showDesc is INLINE, so
|
|
// density comes from padding/margins, not from stripping the archetype meaning)
|
|
expect(ledger).toMatch(/animate-fade-up stagger-[^]*?padding: 12 \}\}>/);
|
|
expect(ledger).toMatch(/ArchetypeBadge[^]*?showDesc/); // meaning preserved (inline, no height cost)
|
|
});
|
|
});
|