Merge Wave 4B (wiring/data): Parlay Lab page + live grade-shift timeline
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// Wave 4B — LIVE GRADE-SHIFT timeline helper + component (source-asserted).
|
||||
// Locks the color law (green toward / amber against / dim flat), the revision
|
||||
// strike-through, and the <3-point self-hide.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
buildGradeTimeline,
|
||||
classifyMove,
|
||||
cleanHistory,
|
||||
TOWARD_COLOR,
|
||||
AGAINST_COLOR,
|
||||
FLAT_COLOR,
|
||||
MIN_POINTS,
|
||||
} = require('../../web/src/lib/gradeShift');
|
||||
|
||||
const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8');
|
||||
|
||||
describe('gradeShift helper — color law', () => {
|
||||
it('color tokens are green / amber / dim (never red)', () => {
|
||||
expect(TOWARD_COLOR).toBe('var(--g-a)');
|
||||
expect(AGAINST_COLOR).toBe('var(--amber)');
|
||||
expect(FLAT_COLOR).toBe('var(--text-1)');
|
||||
for (const c of [TOWARD_COLOR, AGAINST_COLOR, FLAT_COLOR]) {
|
||||
expect(c).not.toBe('var(--miss)');
|
||||
}
|
||||
});
|
||||
|
||||
it('OVER: line up = TOWARD (green), line down = AGAINST (amber), no move = FLAT (dim)', () => {
|
||||
expect(classifyMove(0.5, 'Over')).toEqual({ dir: 'toward', color: TOWARD_COLOR });
|
||||
expect(classifyMove(-0.5, 'Over')).toEqual({ dir: 'against', color: AGAINST_COLOR });
|
||||
expect(classifyMove(0, 'Over')).toEqual({ dir: 'flat', color: FLAT_COLOR });
|
||||
});
|
||||
|
||||
it('UNDER inverts the sign: line down = TOWARD (green), line up = AGAINST (amber)', () => {
|
||||
expect(classifyMove(-0.5, 'Under')).toEqual({ dir: 'toward', color: TOWARD_COLOR });
|
||||
expect(classifyMove(0.5, 'Under')).toEqual({ dir: 'against', color: AGAINST_COLOR });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gradeShift helper — timeline assembly', () => {
|
||||
it('self-hides below 3 real captured points', () => {
|
||||
expect(buildGradeTimeline({ history: null }).show).toBe(false);
|
||||
expect(buildGradeTimeline({ history: [{ t: '1', line: 5.5 }] }).show).toBe(false);
|
||||
expect(buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: 5.5 }] }).show).toBe(false);
|
||||
expect(buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }] }).show).toBe(true);
|
||||
});
|
||||
|
||||
it('drops non-numeric points (Number(null)===0 guard)', () => {
|
||||
const tl = buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: null }, { t: '3', line: 6.5 }] });
|
||||
expect(tl.points.length).toBe(2); // the null point is dropped, not coerced to 0
|
||||
expect(cleanHistory([{ line: null }, { line: 3 }])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('net move on an OVER that rose reads TOWARD (green)', () => {
|
||||
const tl = buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }], side: 'Over' });
|
||||
expect(tl.net.dir).toBe('toward');
|
||||
expect(tl.net.color).toBe(TOWARD_COLOR);
|
||||
expect(tl.firstLine).toBe(5.5);
|
||||
expect(tl.lastLine).toBe(6.5);
|
||||
});
|
||||
|
||||
it('net move on an OVER that fell reads AGAINST (amber)', () => {
|
||||
const tl = buildGradeTimeline({ history: [{ t: '1', line: 6.5 }, { t: '2', line: 6 }, { t: '3', line: 5.5 }], side: 'Over' });
|
||||
expect(tl.net.dir).toBe('against');
|
||||
expect(tl.net.color).toBe(AGAINST_COLOR);
|
||||
});
|
||||
|
||||
it('a revision surfaces the ORIGINAL grade struck (from) alongside the new (to)', () => {
|
||||
const tl = buildGradeTimeline({
|
||||
history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }],
|
||||
grade: 'B',
|
||||
revisedFrom: 'A',
|
||||
});
|
||||
expect(tl.revision).toEqual({ from: 'A', to: 'B' });
|
||||
});
|
||||
|
||||
it('no revision when there was no prior grade or it is unchanged', () => {
|
||||
const base = [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }];
|
||||
expect(buildGradeTimeline({ history: base, grade: 'A' }).revision).toBeNull();
|
||||
expect(buildGradeTimeline({ history: base, grade: 'A', revisedFrom: 'A' }).revision).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts the snapshot field name revised_from_grade too', () => {
|
||||
const tl = buildGradeTimeline({
|
||||
history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }],
|
||||
grade: 'C',
|
||||
revised_from_grade: 'B',
|
||||
});
|
||||
expect(tl.revision).toEqual({ from: 'B', to: 'C' });
|
||||
});
|
||||
|
||||
it('MIN_POINTS matches the LineSparkline floor of 3', () => {
|
||||
expect(MIN_POINTS).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GradeShift component', () => {
|
||||
const src = read('components/vyndr/GradeShift.tsx');
|
||||
it('self-hides on !tl.show (returns null)', () => {
|
||||
expect(src).toContain('if (!tl.show) return null');
|
||||
});
|
||||
it('renders the original grade struck-through when revised', () => {
|
||||
expect(src).toContain('tl.revision');
|
||||
expect(src).toContain('line-through');
|
||||
expect(src).toContain('tl.revision.from');
|
||||
expect(src).toContain('tl.revision.to');
|
||||
});
|
||||
it('is a data surface — mono, no glitch classes', () => {
|
||||
expect(src).toContain('mono');
|
||||
expect(src).not.toMatch(/wm-tear|glitch-shift|head-tear|glitch-hover/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GradeResultCard mounts the timeline', () => {
|
||||
const src = read('components/vyndr/GradeResultCard.tsx');
|
||||
it('imports and renders GradeShift with the optional history fields', () => {
|
||||
expect(src).toContain("import GradeShift from '@/components/vyndr/GradeShift'");
|
||||
expect(src).toContain('<GradeShift');
|
||||
expect(src).toContain('history?: Array<{ t: string; line: number }> | null');
|
||||
expect(src).toContain('revisedFrom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Wave 4B — Parlay Lab page (/parlay). Source-asserts the dedicated builder:
|
||||
// an independent (non-slate) leg source, useParlay/legKey wiring, the
|
||||
// correlation-flag caution, and the tier-aware leg cap.
|
||||
|
||||
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('Parlay Lab page exists at /parlay', () => {
|
||||
it('the route file is present', () => {
|
||||
expect(fs.existsSync(path.join(WEB, 'app', 'parlay', 'page.tsx'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/parlay — independent leg source (not the live slate)', () => {
|
||||
const src = read('app/parlay/page.tsx');
|
||||
it('browses pre-graded props from /api/snapshot/:sport', () => {
|
||||
expect(src).toContain('/api/snapshot/');
|
||||
});
|
||||
it('resolves players via /api/players/search (per sport)', () => {
|
||||
expect(src).toContain('/api/players/search');
|
||||
});
|
||||
it('does NOT depend on the live Slate component', () => {
|
||||
expect(src).not.toContain("from '@/components/Slate'");
|
||||
expect(src).not.toContain('slateAdapter');
|
||||
});
|
||||
it('offers the three parlay sports', () => {
|
||||
for (const s of ['MLB', 'NBA', 'WNBA']) expect(src).toContain(s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/parlay — assembles a slip via useParlay + legKey', () => {
|
||||
const src = read('app/parlay/page.tsx');
|
||||
it('imports useParlay and legKey', () => {
|
||||
expect(src).toContain('useParlay');
|
||||
expect(src).toContain('legKey');
|
||||
});
|
||||
it('adds legs through addLeg and dedupes/toggles by leg key', () => {
|
||||
expect(src).toContain('addLeg(');
|
||||
expect(src).toContain('hasLeg(');
|
||||
expect(src).toContain('removeLeg(');
|
||||
});
|
||||
it('renders the PARLAY SLIP with combined grade + correlation + payout from context', () => {
|
||||
expect(src).toContain('PARLAY SLIP');
|
||||
expect(src).toContain('combined');
|
||||
expect(src).toContain('correlation');
|
||||
expect(src).toContain('payout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/parlay — correlation-flag caution', () => {
|
||||
const src = read('app/parlay/page.tsx');
|
||||
it('surfaces parlayService correlation warning as a caution flag', () => {
|
||||
expect(src).toContain('correlation?.warning');
|
||||
expect(src).toContain('CORRELATION FLAG');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/parlay — tier-aware leg cap + free upsell', () => {
|
||||
const src = read('app/parlay/page.tsx');
|
||||
it('drives the leg cap from the user tier (free 2 / analyst 4 / desk 6)', () => {
|
||||
expect(src).toContain('useAuth');
|
||||
expect(src).toContain('setMaxLegs');
|
||||
expect(src).toContain('free: 2');
|
||||
expect(src).toContain('desk: 6');
|
||||
});
|
||||
it('honors atCap and blurs the payout for free tier with the paywall upsell', () => {
|
||||
expect(src).toContain('atCap');
|
||||
expect(src).toContain('__goPaywall');
|
||||
expect(src).toContain("blur(");
|
||||
});
|
||||
});
|
||||
|
||||
describe('/parlay is reachable (open route, not gated)', () => {
|
||||
const routes = require('../../web/src/lib/routes.js');
|
||||
it('is an OPEN route (free funnel, like scan/dashboard)', () => {
|
||||
expect(routes.OPEN_ROUTES).toContain('/parlay');
|
||||
expect(routes.isGatedRoute('/parlay')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy ParlayTray is retired', () => {
|
||||
it('the dead component file is gone', () => {
|
||||
expect(fs.existsSync(path.join(WEB, 'components', 'ParlayTray.tsx'))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user