Session 29: Content generation templates — slate threads, POTD, recaps, matchup previews (1660 tests)
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
// Integration: /api/content routes (Session 29).
|
||||
// The template service is mocked at the collector boundary via redis +
|
||||
// real generators; recap reads a mocked results cache.
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const mockStore = {};
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: jest.fn(async (k) => (k in mockStore ? mockStore[k] : null)),
|
||||
getRedisClient: () => ({ scan: async () => ['0', []], lrange: async () => [] }),
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
// Mock collectSlateData so the route doesn't hit live adapters; keep the
|
||||
// real generators so we exercise the actual content shapes.
|
||||
jest.mock('../../src/services/contentTemplateService', () => {
|
||||
const actual = jest.requireActual('../../src/services/contentTemplateService');
|
||||
return { ...actual, collectSlateData: jest.fn() };
|
||||
});
|
||||
const template = require('../../src/services/contentTemplateService');
|
||||
|
||||
function mountApp() {
|
||||
delete require.cache[require.resolve('../../src/routes/content')];
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/content', require('../../src/routes/content'));
|
||||
return app;
|
||||
}
|
||||
|
||||
const SLATE = {
|
||||
sport: 'mlb',
|
||||
schedule: [{ id: 'g1', homeTeam: { name: 'Reds', abbreviation: 'CIN' }, awayTeam: { name: 'D-backs', abbreviation: 'ARI' }, gameTime: '2026-06-13T23:10:00Z', venue: 'GABP' }],
|
||||
gameLines: { '20260613_ARI@CIN': { homeTeam: 'CIN', awayTeam: 'ARI', books: { bet365: { homeML: '-120', awayML: '+100', total: '9.5', homeSpread: '-1.5' } } } },
|
||||
grades: [],
|
||||
streaks: [{ player: 'Acuna', team: 'ARI', description: '3-game hit streak', currentStreak: 3 }],
|
||||
movers: [],
|
||||
bestLines: [],
|
||||
dataLevel: 'lines',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(mockStore)) delete mockStore[k];
|
||||
jest.clearAllMocks();
|
||||
template.collectSlateData.mockResolvedValue(SLATE);
|
||||
});
|
||||
|
||||
describe('GET /api/content/slate/:sport', () => {
|
||||
test('returns a slate thread structure', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/slate/mlb');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe('slate_thread');
|
||||
expect(res.body.dataLevel).toBe('lines');
|
||||
expect(res.body.posts[0].role).toBe('hook');
|
||||
});
|
||||
|
||||
test('?format=text adds formatted post strings', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/slate/mlb?format=text');
|
||||
expect(Array.isArray(res.body.text)).toBe(true);
|
||||
expect(res.body.text[0]).toMatch(/VYNDR SLATE/);
|
||||
});
|
||||
|
||||
test('unsupported sport → 404', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/slate/cricket');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/content/potd/:sport', () => {
|
||||
test('lines data → game of the day', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/potd/mlb');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.subtype).toBe('game_of_the_day');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/content/recap/:sport', () => {
|
||||
test('no resolved grades → available:false', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/recap/mlb');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.available).toBe(false);
|
||||
});
|
||||
|
||||
test('with cached results → record computed', async () => {
|
||||
const utc = new Date().toISOString().split('T')[0];
|
||||
mockStore[`results:mlb:${utc}`] = [
|
||||
{ player: 'A', grade: 'A', confidence: 80, result: 'win' },
|
||||
{ player: 'B', grade: 'B', confidence: 60, result: 'loss' },
|
||||
];
|
||||
const res = await request(mountApp()).get('/api/content/recap/mlb');
|
||||
expect(res.body.available).toBe(true);
|
||||
expect(res.body.record).toEqual({ wins: 1, losses: 1, pushes: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/content/preview/:sport/:gameId', () => {
|
||||
test('returns matchup data for a scheduled game', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/preview/mlb/g1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe('matchup_preview');
|
||||
expect(res.body.home.abbreviation).toBe('CIN');
|
||||
expect(res.body.lines.bookCount).toBe(1);
|
||||
expect(res.body.playerStreaks.map((s) => s.player)).toEqual(['Acuna']);
|
||||
});
|
||||
|
||||
test('unknown game → 404', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/preview/mlb/nope');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Unit: content formatter (Session 29).
|
||||
|
||||
const fmt = require('../../src/services/contentFormatter');
|
||||
|
||||
describe('contentFormatter — slate thread', () => {
|
||||
test('formats each post role into readable text', () => {
|
||||
const thread = {
|
||||
posts: [
|
||||
{ role: 'hook', text: '🔥 VYNDR SLATE' },
|
||||
{ role: 'pick', player: 'Wemby', stat: 'points', side: 'over', line: 28.5, grade: 'A', confidence: 78, edge: 6.2, analysis: 'Edge on the line.' },
|
||||
{ role: 'game_highlight', game: 'ARI @ CIN', time: null, bestHomeML: { odds: '-120', book: 'betmgm' }, total: 9.5, bookCount: 2 },
|
||||
{ role: 'movers', movers: [{ player: 'X', stat: 'hits', opening: 1.5, current: 2.5, delta: 1, sharpSignal: false }] },
|
||||
{ role: 'schedule', games: [{ away: 'ATL', home: 'NYM', time: null }] },
|
||||
{ role: 'cta', text: 'Full slate at vyndr.app' },
|
||||
],
|
||||
};
|
||||
const out = fmt.formatSlateThread(thread);
|
||||
expect(out[0]).toBe('🔥 VYNDR SLATE');
|
||||
expect(out[1]).toContain('Wemby · points over 28.5');
|
||||
expect(out[1]).toContain('Grade: A · 78% confidence');
|
||||
expect(out[1]).toContain('Edge: +6.2%');
|
||||
expect(out[2]).toContain('ARI @ CIN');
|
||||
expect(out[2]).toContain('betmgm');
|
||||
expect(out[3]).toContain('Biggest line moves');
|
||||
expect(out[4]).toContain('ATL @ NYM');
|
||||
expect(out[5]).toBe('Full slate at vyndr.app');
|
||||
});
|
||||
|
||||
test('never emits "undefined"', () => {
|
||||
const out = fmt.formatSlateThread({ posts: [{ role: 'pick', player: 'X', stat: 'pts', grade: 'B' }] });
|
||||
expect(out[0]).not.toMatch(/undefined/);
|
||||
});
|
||||
|
||||
test('non-thread input → empty array', () => {
|
||||
expect(fmt.formatSlateThread(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contentFormatter — POTD + recap', () => {
|
||||
test('POTD full', () => {
|
||||
const t = fmt.formatPOTD({ dataLevel: 'full', player: 'Wemby', stat: 'points', side: 'over', line: 28.5, grade: 'A', confidence: 78 });
|
||||
expect(t).toContain('PROP OF THE DAY');
|
||||
expect(t).toContain('Wemby');
|
||||
});
|
||||
test('POTD game of the day', () => {
|
||||
const t = fmt.formatPOTD({ dataLevel: 'lines', subtype: 'game_of_the_day', game: 'ARI @ CIN', total: 9.5 });
|
||||
expect(t).toContain('GAME OF THE DAY');
|
||||
expect(t).toContain('O/U 9.5');
|
||||
});
|
||||
test('POTD unavailable', () => {
|
||||
expect(fmt.formatPOTD({ available: false })).toMatch(/vyndr\.app/);
|
||||
});
|
||||
test('recap formats record + win rate', () => {
|
||||
const t = fmt.formatRecap({ available: true, date: 'Friday', record: { wins: 3, losses: 2, pushes: 1 }, winRate: 0.6, topHits: [{ player: 'A', stat: 'points', side: 'over', line: 20 }], metrics: { brierScore: 0.18 } });
|
||||
expect(t).toContain('3-2-1 (60%)');
|
||||
expect(t).toContain('✅ A points over 20');
|
||||
expect(t).toContain('Brier: 0.18');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// Unit: content template engine (Session 29). Pure generators.
|
||||
|
||||
const svc = require('../../src/services/contentTemplateService');
|
||||
|
||||
// ---- fixtures ----
|
||||
const schedule = [
|
||||
{ id: '1', homeTeam: { name: 'Cincinnati Reds', abbreviation: 'CIN' }, awayTeam: { name: 'Arizona Diamondbacks', abbreviation: 'ARI' }, gameTime: '2026-06-13T23:10:00Z', venue: 'GABP' },
|
||||
{ id: '2', homeTeam: { name: 'New York Mets', abbreviation: 'NYM' }, awayTeam: { name: 'Atlanta Braves', abbreviation: 'ATL' }, gameTime: '2026-06-13T23:40:00Z', venue: 'Citi Field' },
|
||||
];
|
||||
const gameLines = {
|
||||
'20260613_ARI@CIN': {
|
||||
homeTeam: 'CIN', awayTeam: 'ARI',
|
||||
books: {
|
||||
bet365: { homeML: '-130', awayML: '+110', total: '9.5', homeSpread: '-1.5' },
|
||||
betmgm: { homeML: '-120', awayML: '+100', total: '9', homeSpread: '-1.5' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const grades = [
|
||||
{ player: 'Wembanyama', stat: 'points', line: 28.5, side: 'over', grade: 'A', confidence: 78, edge: 6.2, projection: 31.1 },
|
||||
{ player_name: 'Brunson', stat_type: 'assists', line: 7.5, direction: 'under', grade: 'B+', confidence: 64, edge_pct: 3.1, projection: 6.8 },
|
||||
];
|
||||
|
||||
describe('determineDataLevel', () => {
|
||||
test('full when grades exist', () => {
|
||||
expect(svc.determineDataLevel({ grades, gameLines, schedule })).toBe('full');
|
||||
});
|
||||
test('lines when only game lines', () => {
|
||||
expect(svc.determineDataLevel({ grades: [], gameLines, schedule })).toBe('lines');
|
||||
});
|
||||
test('schedule when only schedule', () => {
|
||||
expect(svc.determineDataLevel({ grades: [], gameLines: {}, schedule })).toBe('schedule');
|
||||
});
|
||||
test('empty when nothing', () => {
|
||||
expect(svc.determineDataLevel({ grades: [], gameLines: {}, schedule: [] })).toBe('empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSlateThread', () => {
|
||||
test('full data → hook + up-to-5 picks + CTA', () => {
|
||||
const t = svc.generateSlateThread('nba', { schedule, gameLines, grades, dataLevel: 'full' });
|
||||
expect(t.posts[0].role).toBe('hook');
|
||||
expect(t.posts[t.posts.length - 1].role).toBe('cta');
|
||||
const picks = t.posts.filter((p) => p.role === 'pick');
|
||||
expect(picks).toHaveLength(2);
|
||||
// Sorted by confidence desc — Wemby (78) first.
|
||||
expect(picks[0].player).toBe('Wembanyama');
|
||||
expect(picks[1].player).toBe('Brunson'); // normalized from player_name
|
||||
expect(picks[1].side).toBe('under'); // normalized from direction
|
||||
});
|
||||
|
||||
test('lines-only → game highlights (+ movers when present)', () => {
|
||||
const t = svc.generateSlateThread('mlb', { schedule, gameLines, grades: [], movers: [{ player: 'X', delta: 2 }] });
|
||||
expect(t.dataLevel).toBe('lines');
|
||||
const highlights = t.posts.filter((p) => p.role === 'game_highlight');
|
||||
expect(highlights.length).toBeGreaterThan(0);
|
||||
expect(highlights[0].bookCount).toBe(2);
|
||||
expect(t.posts.some((p) => p.role === 'movers')).toBe(true);
|
||||
});
|
||||
|
||||
test('schedule-only → game list', () => {
|
||||
const t = svc.generateSlateThread('mlb', { schedule, gameLines: {}, grades: [] });
|
||||
expect(t.dataLevel).toBe('schedule');
|
||||
const sched = t.posts.find((p) => p.role === 'schedule');
|
||||
expect(sched.games).toHaveLength(2);
|
||||
expect(sched.games[0].home).toBe('Cincinnati Reds');
|
||||
});
|
||||
|
||||
test('hook text adapts to data level', () => {
|
||||
expect(svc.generateSlateThread('mlb', { schedule, grades }).posts[0].text).toMatch(/graded/i);
|
||||
expect(svc.generateSlateThread('mlb', { schedule, gameLines, grades: [] }).posts[0].text).toMatch(/sportsbooks/i);
|
||||
});
|
||||
|
||||
test('empty schedule → minimal thread, not a crash', () => {
|
||||
const t = svc.generateSlateThread('mlb', { schedule: [], gameLines: {}, grades: [] });
|
||||
expect(t.dataLevel).toBe('empty');
|
||||
expect(t.posts[0].role).toBe('hook');
|
||||
expect(t.posts.some((p) => p.role === 'cta')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePOTD', () => {
|
||||
test('full → best grade selected', () => {
|
||||
const p = svc.generatePOTD('nba', { grades, dataLevel: 'full' });
|
||||
expect(p.dataLevel).toBe('full');
|
||||
expect(p.player).toBe('Wembanyama'); // highest confidence
|
||||
expect(p.grade).toBe('A');
|
||||
});
|
||||
test('lines → game of the day', () => {
|
||||
const p = svc.generatePOTD('mlb', { grades: [], gameLines, schedule });
|
||||
expect(p.dataLevel).toBe('lines');
|
||||
expect(p.subtype).toBe('game_of_the_day');
|
||||
expect(p.game).toContain('@');
|
||||
});
|
||||
test('no data → available:false', () => {
|
||||
const p = svc.generatePOTD('mlb', { grades: [], gameLines: {}, schedule: [] });
|
||||
expect(p.available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateResultsRecap', () => {
|
||||
const resolved = [
|
||||
{ player: 'A', stat: 'points', grade: 'A', confidence: 80, result: 'win', clv: 1.2 },
|
||||
{ player: 'B', stat: 'hits', grade: 'A-', confidence: 70, result: 'win', clv: 0.8 },
|
||||
{ player: 'C', stat: 'reb', grade: 'B', confidence: 60, result: 'win', clv: -0.2 },
|
||||
{ player: 'D', stat: 'ast', grade: 'A', confidence: 75, result: 'loss', clv: -1.0 },
|
||||
{ player: 'E', stat: 'ks', grade: 'B', confidence: 55, result: 'loss', clv: 0.1 },
|
||||
{ player: 'F', stat: 'tb', grade: 'C', confidence: 50, result: 'push' },
|
||||
];
|
||||
test('record + win rate', () => {
|
||||
const r = svc.generateResultsRecap('mlb', resolved);
|
||||
expect(r.record).toEqual({ wins: 3, losses: 2, pushes: 1 });
|
||||
expect(r.winRate).toBe(0.6); // 3 / (3+2)
|
||||
});
|
||||
test('top hits sorted by confidence', () => {
|
||||
const r = svc.generateResultsRecap('mlb', resolved);
|
||||
expect(r.topHits[0].player).toBe('A'); // 80 conf win
|
||||
expect(r.topHits).toHaveLength(3);
|
||||
});
|
||||
test('biggest miss = highest-confidence loss', () => {
|
||||
const r = svc.generateResultsRecap('mlb', resolved);
|
||||
expect(r.biggestMiss.player).toBe('D'); // 75 conf loss
|
||||
});
|
||||
test('by-tier breakdown', () => {
|
||||
const r = svc.generateResultsRecap('mlb', resolved);
|
||||
expect(r.byTier.A).toEqual({ wins: 2, losses: 1, total: 3 });
|
||||
expect(r.byTier.B).toEqual({ wins: 1, losses: 1, total: 2 });
|
||||
});
|
||||
test('brier + clv computed', () => {
|
||||
const r = svc.generateResultsRecap('mlb', resolved);
|
||||
expect(typeof r.metrics.brierScore).toBe('number');
|
||||
expect(typeof r.metrics.clv).toBe('number');
|
||||
});
|
||||
test('no resolved grades → available:false', () => {
|
||||
expect(svc.generateResultsRecap('mlb', []).available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMatchupPreview', () => {
|
||||
const streaks = [
|
||||
{ player: 'Acuna', team: 'ARI', description: '3-game hit streak', currentStreak: 3 },
|
||||
{ player: 'Soto', team: 'NYM', description: '2-game HR streak', currentStreak: 2 },
|
||||
];
|
||||
test('includes teams, time, venue', () => {
|
||||
const p = svc.generateMatchupPreview(schedule[0], gameLines['20260613_ARI@CIN'], streaks);
|
||||
expect(p.home.abbreviation).toBe('CIN');
|
||||
expect(p.away.abbreviation).toBe('ARI');
|
||||
expect(p.venue).toBe('GABP');
|
||||
});
|
||||
test('lines summary from game lines', () => {
|
||||
const p = svc.generateMatchupPreview(schedule[0], gameLines['20260613_ARI@CIN'], streaks);
|
||||
expect(p.lines.bookCount).toBe(2);
|
||||
expect(p.lines.total).toBe(9.3); // consensus of 9.5 + 9, rounded to 1dp
|
||||
expect(p.lines.homeFavorite).toBe(true); // homeSpread -1.5
|
||||
});
|
||||
test('player streaks matched to the two teams', () => {
|
||||
const p = svc.generateMatchupPreview(schedule[0], gameLines['20260613_ARI@CIN'], streaks);
|
||||
expect(p.playerStreaks.map((s) => s.player)).toEqual(['Acuna']); // only ARI matches this game
|
||||
});
|
||||
test('no game lines → lines:null, not a crash', () => {
|
||||
const p = svc.generateMatchupPreview(schedule[0], null, streaks);
|
||||
expect(p.lines).toBeNull();
|
||||
expect(p.narrative).toContain('@'.length ? '' : ''); // narrative present
|
||||
expect(typeof p.narrative).toBe('string');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user