import { describe, expect, test } from 'bun:test'; import { createHash } from 'node:crypto'; import { appleDaily, appleTotals, bucketName, checksumOk, compareAroundEvents, decodeText, effectMarkdown, effectTable, latestPerDate, mergeSeries, monthsBetween, parseGitLog, parseReport, pickReport, playDaily, playObjects, playTotals, type Snapshot, } from '../cli/lib/analytics'; import { validateConfig } from '../cli/lib/config'; import { TOOLS, toArgv } from '../cli/mcp-tools'; const ENG = 'Date\tApp Name\tApp Apple Identifier\tEvent\tPage Type\tSource Type\tEngagement Type\tDevice\tPlatform Version\tTerritory\tCounts\tUnique Counts\n' + '2026-10-01\tExample\t1\tImpression\tProduct page\tApp Store search\t\tiPhone\tiOS 26\tUS\t100\t80\n' + '2026-10-01\tExample\t1\tImpression\tProduct page\tApp Store browse\t\tiPhone\tiOS 26\tTR\t50\t20\n' + '2026-10-01\tExample\t1\tPage view\tProduct page\tApp Store search\t\tiPhone\tiOS 26\tUS\t30\t25\n' + '2026-10-01\tExample\t1\tTap\tProduct page\tApp Store search\t\tiPhone\tiOS 26\tUS\t9\t9\n'; const DL = 'Date,App Name,App Apple Identifier,Download Type,App Version,Device,Platform Version,Source Type,Page Type,Pre-Order,Territory,Counts\n' + '2026-10-01,Example,1,First-time Download,1.0,iPhone,iOS 26,App Store search,Product page,,US,10\n' + '2026-10-01,Example,1,Redownload,1.0,iPhone,iOS 26,App Store search,Product page,,US,2\n' + '2026-10-01,Example,1,Auto-update,1.0,iPhone,iOS 26,,,,US,500\n'; describe('apple analytics reports', () => { test('pick the Standard report by name', () => { const rs = [{ id: 'd', attributes: { name: 'App Store Discovery and Engagement Detailed' } }, { id: 's', attributes: { name: 'App Store Discovery and Engagement Standard' } }, { id: 'x', attributes: { name: 'App Sessions Standard' } }]; expect(pickReport(rs, /discovery and engagement/i)!.id).toBe('s'); expect(pickReport(rs.slice(0, 1), /discovery and engagement/i)!.id).toBe('d'); expect(pickReport(rs, /nope/)).toBeNull(); }); test('tab and comma files by column name; daily sums', () => { const eng = parseReport(ENG), dl = parseReport(DL); expect(eng[0]!.unique_counts).toBe('80'); expect(dl[0]!.download_type).toBe('First-time Download'); expect(appleDaily(eng, dl)).toEqual([{ date: '2026-10-01', impressions: 150, uniqueImpressions: 100, pageViews: 30, uniquePageViews: 25, downloads: 12, firstTimeDownloads: 10 }]); expect(appleTotals(appleDaily(eng, dl)).conversion).toBeCloseTo(0.12, 10); }); test('a later processingDate replaces the same Date', () => { const rows = latestPerDate([ { processingDate: '2026-10-02', rows: [{ date: '2026-10-01', counts: '1' }] }, { processingDate: '2026-10-04', rows: [{ date: '2026-10-01', counts: '5' }, { date: '2026-10-03', counts: '7' }] }, ]); expect(rows).toEqual([{ date: '2026-10-01', counts: '5' }, { date: '2026-10-03', counts: '7' }]); }); test('md5 checksum and text decoding', () => { const b = new TextEncoder().encode('hello'); expect(checksumOk(b, createHash('md5').update(b).digest('hex').toUpperCase())).toBe(true); expect(checksumOk(b, '00')).toBe(false); const utf16 = new Uint8Array([0xff, 0xfe, ...[...'Date,x'].flatMap(c => [c.charCodeAt(0), 0])]); expect(decodeText(utf16)).toBe('Date,x'); expect(decodeText(new TextEncoder().encode('ok'))).toBe('ok'); }); }); describe('play statistics exports', () => { test('file names, months, bucket', () => { expect(monthsBetween('2026-11-20', '2027-02-01')).toEqual(['202611', '202612', '202701', '202702']); expect(playObjects('com.example.game', '202610')).toEqual({ storePerformance: 'stats/store_performance/store_performance_com.example.game_202610_country.csv', installs: 'stats/installs/installs_com.example.game_202610_country.csv' }); expect(bucketName('gs://pubsite_prod_rev_0000/stats')).toBe('pubsite_prod_rev_0000'); }); test('daily sums across countries', () => { const sp = parseReport('Date,Package name,Country / region,Store listing acquisitions,Store listing visitors,Store listing conversion rate\n2026-10-01,com.example.game,US,10,100,10%\n2026-10-01,com.example.game,TR,5,50,10%\n'); const inst = parseReport('Date,Package Name,Country,Daily Device Installs,Daily User Installs\n2026-10-01,com.example.game,US,12,11\n2026-10-02,com.example.game,US,3,3\n'); const d = playDaily(sp, inst); expect(d).toEqual([{ date: '2026-10-01', visitors: 150, acquisitions: 15, installs: 11 }, { date: '2026-10-02', visitors: 0, acquisitions: 0, installs: 3 }]); expect(playTotals(d).conversion).toBeCloseTo(0.1, 10); }); test('config: reports bucket', () => { const base = { app: { name: 'X', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'a@example.com' }, languages: [{ id: 'en' }] }; expect(validateConfig({ ...base, play: { packageName: 'com.example.game', reportsBucket: 'gs://pubsite_prod_rev_0000/' } })).toEqual([]); expect(validateConfig({ ...base, play: { packageName: 'com.example.game', reportsBucket: 'my-bucket' } })[0]).toContain('play.reportsBucket'); }); }); describe('compare with listing changes', () => { const days = (from: number, n: number, f: (i: number) => { impressions: number; uniqueImpressions: number; downloads: number }) => Array.from({ length: n }, (_, i) => ({ date: `2026-10-${String(from + i).padStart(2, '0')}`, pageViews: 0, uniquePageViews: 0, firstTimeDownloads: 0, ...f(i) })); const snaps: Snapshot[] = [ { date: '2026-10-08', window: { start: '2026-10-01', end: '2026-10-07', days: 7 }, apple: { daily: days(1, 7, () => ({ impressions: 100, uniqueImpressions: 100, downloads: 5 })), totals: {} } }, { date: '2026-10-15', window: { start: '2026-10-08', end: '2026-10-14', days: 7 }, apple: { daily: days(8, 7, () => ({ impressions: 150, uniqueImpressions: 150, downloads: 12 })), totals: {} }, play: { error: 'no bucket' } }, ]; test('git log parsing', () => { const out = '\x1eabc1234\x1f2026-10-08\x1fnew first screenshot\n\nstore/shots/copy.json\nstore/appstore.md\n\x1edef5678\x1f2026-10-02\x1fsubtitle tweak\n\nstore/appstore.md\n'; expect(parseGitLog(out)).toEqual([ { commit: 'abc1234', date: '2026-10-08', subject: 'new first screenshot', files: ['store/shots/copy.json', 'store/appstore.md'] }, { commit: 'def5678', date: '2026-10-02', subject: 'subtitle tweak', files: ['store/appstore.md'] }]); }); test('before / after per event', () => { const series = mergeSeries(snaps); expect(series.apple).toHaveLength(14); const fx = compareAroundEvents(series, [{ date: '2026-10-08', commit: 'abc', subject: 'new first screenshot', files: [] }], 7); expect(fx).toHaveLength(1); expect(fx[0]).toMatchObject({ store: 'apple', daysBefore: 7, daysAfter: 7 }); expect(fx[0]!.before.conversion).toBeCloseTo(0.05, 10); expect(fx[0]!.after.conversion).toBeCloseTo(0.08, 10); const t = effectTable(fx); expect(t).toContain('100 -> 150 +50%'); expect(t).toContain('5.00% -> 8.00%'); expect(effectMarkdown(fx)).toContain('| 2026-10-08 | apple | new first screenshot |'); expect(effectTable([])).toContain('no listing changes'); }); test('mcp tools', () => { const t = (n: string) => TOOLS.find(x => x.name === n)!; expect(toArgv(t('analytics_snapshot'), { days: 14 }).argv).toEqual(['analytics', 'snapshot', '--days', '14', '--dry']); expect(t('analytics_compare')).toMatchObject({ write: false, network: false }); expect(toArgv(t('analytics_setup'), {}).argv).toEqual(['analytics', 'setup', '--dry']); }); });