import { describe, expect, test } from 'bun:test'; import type { AppStatus, ProjectSummary, StoreRelease } from '../src/api'; import { setLang, t } from '../src/i18n'; import { attention, type CatalogDiff, filterReviews, fuzzy, healthSeries, hueOf, laneOf, monogram, parityMatrix, PLAY_THRESHOLDS, type Review, starStats, storeNow, toneFor, trainLanes, versionHistory, } from '../src/lib/model'; import { checkState } from '../src/screens/Portfolio'; import { betaState } from '../src/screens/tabs/TestFlight'; import { phaseLabel } from '../src/ui/kit'; const rel = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase'], o: Partial = {}): StoreRelease => ({ store, version, build: null, state: phase, phase, fraction: null, track: null, ...o }); describe('release timeline', () => { test('lanes: stage and tone per phase, unknown store = -1', () => { expect(laneOf('ios', null)).toMatchObject({ at: -1 }); expect(laneOf('ios', rel('ios', '1', 'draft'))).toMatchObject({ at: 0, tone: 'neutral' }); expect(laneOf('ios', rel('ios', '1', 'draft'), true)).toMatchObject({ at: 1, tone: 'info' }); expect(laneOf('android', rel('android', '1', 'testing'))).toMatchObject({ at: 1 }); expect(laneOf('ios', rel('ios', '1', 'review'))).toMatchObject({ at: 2, tone: 'warn' }); expect(laneOf('ios', rel('ios', '1', 'rejected'))).toMatchObject({ at: 2, tone: 'bad' }); expect(laneOf('ios', rel('ios', '1', 'live'))).toMatchObject({ at: 4, share: 1, tone: 'ok' }); expect(laneOf('android', rel('android', '1', 'rollout', { fraction: 0.2 }))).toMatchObject({ at: 4, share: 0.2, tone: 'accent' }); expect(laneOf('android', rel('android', '1', 'halted', { fraction: 0.05 }))).toMatchObject({ at: 4, share: 0.05, tone: 'bad' }); }); test('TestFlight counts as testing for an iOS draft when a build of that version is in beta', () => { const tf = { groups: [], missingGroups: [], builds: [{ version: '1.5.0', build: '31', processing: 'VALID', internal: 'IN_BETA_TESTING', external: null, uploaded: null }] }; const lanes = trainLanes({ version: '1.5.0', ios: rel('ios', '1.5.0', 'draft'), android: null }, tf); expect(lanes.map(l => l.at)).toEqual([1, -1]); expect(trainLanes({ version: '1.6.0', ios: rel('ios', '1.6.0', 'draft'), android: null }, tf)[0]!.at).toBe(0); }); test('per store: what reaches users now, and what is next', () => { const all = [rel('ios', '2.4.0', 'review'), rel('ios', '2.3.0', 'rollout', { fraction: 0.2 }), rel('ios', '2.2.1', 'other'), rel('android', '2.2.1', 'live'), rel('android', '2.4.0', 'testing')]; expect(storeNow(all, 'ios')).toMatchObject({ now: { version: '2.3.0' }, next: { version: '2.4.0' } }); expect(storeNow(all, 'android')).toMatchObject({ now: { version: '2.2.1' }, next: { version: '2.4.0' } }); expect(storeNow([], 'ios')).toEqual({ now: null, next: null }); expect(versionHistory(all).map(h => [h.version, h.ios?.phase ?? null, h.android?.phase ?? null])).toEqual([['2.4.0', 'review', 'testing'], ['2.3.0', 'rollout', null], ['2.2.1', 'other', 'live']]); }); test('phase labels: rollout share, iOS phased and paused words', () => { setLang('en'); expect(phaseLabel(rel('android', '1', 'rollout', { fraction: 0.2 }))).toEqual({ text: 'Rollout 20%', tone: 'accent' }); expect(phaseLabel(rel('ios', '1', 'rollout', { fraction: 0.5 }))).toEqual({ text: 'Phased 50%', tone: 'accent' }); expect(phaseLabel(rel('ios', '1', 'halted', { fraction: 0.02 }))).toEqual({ text: 'Paused 2%', tone: 'bad' }); expect(phaseLabel(rel('android', '1', 'testing', { track: 'internal' }))).toEqual({ text: 'Testing · internal', tone: 'info' }); expect(phaseLabel(null)).toEqual({ text: 'Not on this store', tone: 'neutral' }); }); }); describe('health', () => { const rows = (vals: number[], w28?: number) => vals.map((v, i) => ({ date: `2026-10-0${i + 1}`, dims: {}, values: { userPerceivedCrashRate: v, ...(i === vals.length - 1 && w28 !== undefined ? { userPerceivedCrashRate28dUserWeighted: w28 } : {}) } })); test('series sorted by date, current = the 28-day value, tone against the threshold', () => { const s = healthSeries([...rows([0.005, 0.006, 0.007], 0.0095)].reverse(), 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); expect(s.values).toEqual([0.005, 0.006, 0.007]); expect(s.current).toBe(0.0095); expect(s.tone).toBe('warn'); expect(healthSeries(rows([0.012]), 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash).tone).toBe('bad'); expect(healthSeries([], 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash)).toMatchObject({ current: null, tone: 'neutral' }); expect(toneFor(0.001, 0.0109)).toBe('ok'); }); }); describe('reviews', () => { const r = (id: string, store: 'apple' | 'play', rating: number, reply = false, date = '2026-10-01'): Review => ({ store, id, rating, body: `body ${id}`, date, ...(reply ? { reply: { text: 'x' } } : {}) }); const rs = [r('a', 'apple', 5, true, '2026-10-03'), r('b', 'play', 1, false, '2026-10-05'), r('c', 'play', 4), r('d', 'apple', 1)]; test('star distribution and average', () => { expect(starStats(rs)).toEqual({ count: 4, average: 2.75, byStar: [2, 0, 0, 1, 1], unanswered: 3 }); expect(starStats([]).average).toBeNull(); }); test('filters: store, stars, unanswered, text; newest first', () => { expect(filterReviews(rs, { store: 'all', stars: [], unanswered: false, query: '' }).map(x => x.id)).toEqual(['b', 'a', 'c', 'd']); expect(filterReviews(rs, { store: 'play', stars: [], unanswered: false, query: '' }).map(x => x.id)).toEqual(['b', 'c']); expect(filterReviews(rs, { store: 'all', stars: [1], unanswered: true, query: '' }).map(x => x.id)).toEqual(['b', 'd']); expect(filterReviews(rs, { store: 'all', stars: [], unanswered: false, query: 'BODY C' }).map(x => x.id)).toEqual(['c']); }); }); describe('catalog parity matrix', () => { const s = { productIds: ['coins', 'no_ads'], subscriptionIds: ['pro'], catalog: { ios: ['coins', 'no_ads', 'pro'], android: ['coins', 'pro'] } } as unknown as ProjectSummary; test('not compared yet: unknown where sold, n/a where not ("unknown, never false")', () => { const m = parityMatrix(s, null); expect(m.map(x => [x.id, x.kind, x.ios.state, x.android.state])).toEqual([['coins', 'product', 'unknown', 'unknown'], ['no_ads', 'product', 'unknown', 'na'], ['pro', 'subscription', 'unknown', 'unknown']]); }); test('compared: worst level per cell, store-only items, the largest price gap', () => { const diff: CatalogDiff = { errors: 1, rows: [ { id: 'coins', store: 'apple', level: 'ok', code: 'ok', msg: '' }, { id: 'coins', store: 'play', level: 'warn', code: 'locale', msg: 'no listing for de-DE' }, { id: 'pro', store: 'play', level: 'error', code: 'missing', msg: 'not on Google Play' }, { id: 'pro', store: 'apple', level: 'ok', code: 'ok', msg: '' }, { id: 'old', store: 'apple', level: 'warn', code: 'extra', msg: 'on the store only' }, ], parity: [{ id: 'coins', region: 'TR', currency: 'TRY', apple: 10, play: 7, diffPct: 30 }, { id: 'coins', region: 'BR', currency: 'BRL', apple: 5, play: 4.5, diffPct: 10 }] }; const m = parityMatrix(s, diff); const by = Object.fromEntries(m.map(x => [x.id, x])); expect(by.coins).toMatchObject({ ios: { state: 'ok' }, android: { state: 'warn', notes: ['no listing for de-DE'] }, maxDiff: 30 }); expect(by.no_ads).toMatchObject({ ios: { state: 'unknown' }, android: { state: 'na' } }); expect(by.pro).toMatchObject({ android: { state: 'error' } }); expect(by.old).toMatchObject({ kind: 'extra', config: false, ios: { state: 'warn' }, android: { state: 'na' } }); }); }); describe('portfolio', () => { const base: AppStatus = { project: '/g', ok: true, app: null, summary: null, check: { ok: true, problems: [], warnings: [] }, release: { source: 'none', current: null, next: null }, catalog: null, storeErrors: {}, errors: [] }; test('config check chip', () => { setLang('en'); expect(checkState(undefined)).toEqual({ text: 'Not checked', tone: 'neutral' }); expect(checkState({ ...base, check: null })).toEqual({ text: 'Config not loaded', tone: 'bad' }); expect(checkState({ ...base, check: { ok: false, problems: ['a', 'b'], warnings: [] } })).toEqual({ text: '2 problems', tone: 'bad' }); expect(checkState({ ...base, check: { ok: true, problems: [], warnings: ['w'] } })).toEqual({ text: '1 warning', tone: 'warn' }); expect(checkState(base)).toEqual({ text: 'Config OK', tone: 'ok' }); }); test('needs attention: problems, unreadable stores, rejected / halted releases, catalog drift, health near the threshold', () => { const live: AppStatus = { ...base, release: { source: 'live', current: null, next: null, all: [rel('ios', '2.0', 'rejected'), rel('android', '1.9', 'halted')] }, storeErrors: { ios: 'HTTP 401' }, catalog: { checked: 'live', products: 2, subscriptions: 0, onlyIos: ['x'], onlyAndroid: [], missingIos: [], missingAndroid: ['y'], ok: false } }; const a = attention('p', 'Demo', { ...base, check: { ok: false, problems: ['bad config'], warnings: ['w1'] } }, live, { date: '', play: { window: { start: '', end: '', days: 28 }, crash: [{ date: '2026-10-01', dims: {}, values: { userPerceivedCrashRate: 0.02 } }], anr: [], issues: [] } }); expect(a.map(x => [x.tone, x.tab])).toEqual([['bad', 'checks'], ['warn', 'checks'], ['warn', 'releases'], ['bad', 'releases'], ['bad', 'releases'], ['warn', 'catalog'], ['bad', 'health']]); expect(a[5]!.text).toBe('2 catalog items differ between the stores'); expect(attention('p', 'Demo', undefined, undefined)).toEqual([]); }); }); describe('small helpers', () => { test('palette matching prefers word starts and runs', () => { expect(fuzzy('rel', 'Releases')).toBeGreaterThan(fuzzy('rel', 'Prism Puddle · Catalog listing')); expect(fuzzy('xyz', 'Releases')).toBe(-1); expect(fuzzy('', 'x')).toBe(0); }); test('monograms and stable hues', () => { expect(monogram('Prism Puddle')).toBe('PP'); expect(monogram('tidy')).toBe('TI'); expect(hueOf('Prism Puddle')).toBe(hueOf('Prism Puddle')); }); test('TestFlight states: expired is history, not an error', () => { expect(betaState('IN_BETA_TESTING').tone).toBe('ok'); expect(betaState('WAITING_FOR_BETA_REVIEW').tone).toBe('warn'); expect(betaState('BETA_REJECTED').tone).toBe('bad'); expect(betaState('EXPIRED').tone).toBe('neutral'); expect(betaState(null).text).toBe('–'); }); test('plural forms', () => { setLang('en'); expect(t('reviews.count', { count: 1 })).toBe('1 review'); expect(t('reviews.count', { count: 3 })).toBe('3 reviews'); setLang('tr'); expect(t('reviews.count', { count: 3 })).toBe('3 yorum'); setLang('en'); }); });