104 lines
7.6 KiB
TypeScript
104 lines
7.6 KiB
TypeScript
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', installsOverview: 'stats/installs/installs_com.example.game_202610_overview.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']);
|
|
});
|
|
});
|