146 lines
11 KiB
TypeScript
146 lines
11 KiB
TypeScript
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> = {}): 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');
|
|
});
|
|
});
|