Files
vyndr/tests/unit/vyndrParityQA.test.js
T
builtbykev 7c7ab24be5 M1b: mobile app-bar clock — Design's resting clock + STALE reaction (Hybrid)
Design's mobile app bar shows a wall clock, no SYNC/STALE readout. Building it
literally would drop the Ship-A staleness signal on the device most users are
on. Kev's ruling: Hybrid — the wall clock is the RESTING state (the stillness),
STALE/amber is the REACTION (the punctuation), driven by the SAME real signal
as desktop (refreshed_at vs expected_interval_s, thresholds 1.5×/3×). Never
silently stale on mobile. Design drew the happy path; we keep the failure state.

- web/src/lib/freshness.js — extracted the freshness tier as ONE shared source
  (CommonJS, unit-tested); desktop HeartbeatBar + mobile clock both key off it,
  so mobile can't silently disagree with desktop about staleness.
- LiveLayer: <768px hides SIGNAL LIVE + EKG + graded + the SYNC label and shows
  a single right-aligned clock — ticking wall clock when calm, amber SYNC / red
  STALE when the tier reacts. Resting dot is static (the clock is the pulse).
- Test-lock: freshness.test.js (tier thresholds, absent≠false-stale, no negative
  age) + vyndrParityQA (mobile clock wired, one shared freshness source).

Built to Design's mobile spec, VISUALLY UNVERIFIED at 390px. NEXT shell
increments (still M1b): merge the clock into the logo row (Design's single-row
app bar), the breadth strip (EDGES/AVG CLV/GAMES — replaces the graded count
mobile lost here), and relocate the wire to the bottom of the board.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:26:00 -04:00

180 lines
7.5 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', '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/);
});
});