import { describe, expect, test } from 'bun:test'; import type { PortfolioResult } from '../src/api'; import { DEFAULT_SETTINGS } from '../src/api'; import { inQuietHours, isMuted, kindEnabled, mayNotify, nextReadDelay, releaseChanges } from '../src/lib/notify'; import { normalizeSettings } from '../src/lib/normalize'; import { parseRouteJson } from '../src/nav'; import { traySummary, trayPhases } from '../src/lib/tray'; type Rel = { store: 'ios' | 'android'; version: string; state: string; phase: string; reason?: string }; const snap = (...rels: Rel[]): PortfolioResult => ({ apps: [{ project: '/a', ok: true, app: { id: '/a', name: 'A', platforms: { ios: null, android: null } }, summary: null, check: null, catalog: null, storeErrors: {}, errors: [], release: { source: 'live' as const, current: null, next: null, all: rels.map(r => ({ build: '1', fraction: null, track: null, ...r })) } }], at: 'x', } as unknown as PortfolioResult); const ios = (state: string, phase: string, extra: Partial = {}): Rel => ({ store: 'ios', version: '2.0', state, phase, ...extra }); const play = (state: string, phase: string): Rel => ({ store: 'android', version: '2.0', state, phase }); describe('releaseChanges: transitions the owner waits for', () => { test('App Store in review -> rejected', () => { const c = releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('REJECTED', 'rejected'))); expect(c).toHaveLength(1); expect(c[0]).toMatchObject({ store: 'ios', version: '2.0', from: 'review', to: 'rejected', kind: 'rejected' }); }); test('metadata rejected counts, and the reason text comes along', () => { const c = releaseChanges(snap(ios('WAITING_FOR_REVIEW', 'review')), snap(ios('METADATA_REJECTED', 'rejected', { reason: ' Guideline 2.1 - missing demo account ' }))); expect(c[0]).toMatchObject({ kind: 'rejected', reason: 'Guideline 2.1 - missing demo account' }); }); test('Play in review -> rejected', () => { const c = releaseChanges(snap(play('inProgress', 'review')), snap(play('rejected', 'rejected'))); expect(c[0]).toMatchObject({ store: 'android', kind: 'rejected' }); }); test('approved (pending developer release)', () => { const c = releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('PENDING_DEVELOPER_RELEASE', 'approved'))); expect(c[0]).toMatchObject({ to: 'approved', kind: 'approved' }); }); test('live, from review and from approved; a rollout counts as live', () => { expect(releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('READY_FOR_SALE', 'live')))[0]!.kind).toBe('live'); expect(releaseChanges(snap(ios('PENDING_DEVELOPER_RELEASE', 'approved')), snap(ios('READY_FOR_SALE', 'live')))[0]!.kind).toBe('live'); expect(releaseChanges(snap(play('inProgress', 'review')), snap(play('inProgress', 'rollout')))[0]!.kind).toBe('live'); }); test('halted rollout', () => { expect(releaseChanges(snap(play('inProgress', 'rollout')), snap(play('halted', 'halted')))[0]!.kind).toBe('halted'); }); test('submitted -> in review is the generic kind; nothing changed or a first read is silent', () => { expect(releaseChanges(snap(ios('PREPARE_FOR_SUBMISSION', 'draft')), snap(ios('WAITING_FOR_REVIEW', 'review')))[0]!.kind).toBe('state'); expect(releaseChanges(snap(ios('REJECTED', 'rejected')), snap(ios('REJECTED', 'rejected')))).toEqual([]); expect(releaseChanges(null, snap(ios('REJECTED', 'rejected')))).toEqual([]); }); test('a version the developer withdrew is not a store decision', () => { expect(releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('DEVELOPER_REJECTED', 'rejected')))).toEqual([]); }); test('one store rejected while the other is live: only the rejected one', () => { const c = releaseChanges(snap(ios('IN_REVIEW', 'review'), play('completed', 'live')), snap(ios('REJECTED', 'rejected'), play('completed', 'live'))); expect(c.map(x => x.store)).toEqual(['ios']); }); }); describe('who may notify: toggles, quiet hours, mute', () => { const n = DEFAULT_SETTINGS.notify; const at = (h: number, m = 0) => new Date(2026, 9, 22, h, m); test('each kind has its own toggle (halted shares the crash toggle)', () => { expect(kindEnabled({ ...n, rejected: false }, 'rejected')).toBe(false); expect(kindEnabled({ ...n, rejected: false }, 'approved')).toBe(true); expect(kindEnabled({ ...n, approved: false }, 'approved')).toBe(false); expect(kindEnabled({ ...n, live: false }, 'live')).toBe(false); expect(kindEnabled({ ...n, crash: false }, 'halted')).toBe(false); expect(kindEnabled({ ...n, crash: false }, 'crash')).toBe(false); expect(kindEnabled({ ...n, lowReviews: false }, 'lowReviews')).toBe(false); expect(kindEnabled({ ...n, reviewState: false }, 'state')).toBe(false); }); test('quiet hours are off by default and cross midnight', () => { expect(inQuietHours(n.quietHours, at(23, 30))).toBe(false); const q = { enabled: true, from: '23:00', to: '08:00' }; expect(inQuietHours(q, at(22, 59))).toBe(false); expect(inQuietHours(q, at(23, 0))).toBe(true); expect(inQuietHours(q, at(3))).toBe(true); expect(inQuietHours(q, at(7, 59))).toBe(true); expect(inQuietHours(q, at(8, 0))).toBe(false); expect(inQuietHours(q, at(12))).toBe(false); }); test('quiet hours inside one day, equal ends and bad times', () => { const q = { enabled: true, from: '13:00', to: '14:30' }; expect(inQuietHours(q, at(13, 0))).toBe(true); expect(inQuietHours(q, at(14, 30))).toBe(false); expect(inQuietHours({ enabled: true, from: '09:00', to: '09:00' }, at(9))).toBe(false); expect(inQuietHours({ enabled: true, from: '9am', to: '10:00' }, at(9, 30))).toBe(false); }); test('mute for an hour', () => { const now = at(12).getTime(); expect(isMuted(0, now)).toBe(false); expect(isMuted(now + 3_600_000, now)).toBe(true); expect(isMuted(now - 1, now)).toBe(false); }); test('mayNotify combines them; a test notification always shows', () => { const q = { ...n, quietHours: { enabled: true, from: '23:00', to: '08:00' } }; expect(mayNotify(n, 'rejected', { now: at(12) })).toBe(true); expect(mayNotify(q, 'rejected', { now: at(2) })).toBe(false); expect(mayNotify(n, 'rejected', { now: at(12), muteUntil: at(12).getTime() + 1000 })).toBe(false); expect(mayNotify({ ...n, rejected: false }, 'rejected', { now: at(12) })).toBe(false); expect(mayNotify(q, 'test', { now: at(2), muteUntil: at(2).getTime() + 1000 })).toBe(true); }); test('settings from an older file get the new notify fields', () => { const s = normalizeSettings({ notify: { reviewState: false, quietHours: { enabled: true, from: 'x', to: '07:30' } } }); expect(s.notify).toMatchObject({ reviewState: false, rejected: true, approved: true, live: true, quietHours: { enabled: true, from: '23:00', to: '07:30' } }); expect(normalizeSettings({ refresh: { everyMinutes: 30 } }).refresh.everyMinutes).toBe(30); expect(DEFAULT_SETTINGS.refresh.everyMinutes).toBe(15); }); }); describe('background reading pace', () => { test('interval with jitter, doubled per failure, capped', () => { expect(nextReadDelay(15, 0, 0.5)).toBe(15 * 60_000); expect(nextReadDelay(15, 0, 0)).toBe(Math.round(15 * 60_000 * 0.9)); expect(nextReadDelay(15, 0, 0.999)).toBeLessThanOrEqual(Math.round(15 * 60_000 * 1.1)); expect(nextReadDelay(15, 1, 0.5)).toBe(30 * 60_000); expect(nextReadDelay(15, 2, 0.5)).toBe(60 * 60_000); expect(nextReadDelay(15, 9, 0.5)).toBe(2 * 3_600_000); expect(nextReadDelay(5, -3, 0.5)).toBe(5 * 60_000); }); }); describe('tray summary and routes', () => { const app = (key: string, name: string, ...rels: Rel[]) => ({ key, name, status: snap(...rels).apps[0] }); const tr = { phaseText: (p: string) => p, store: (s: 'ios' | 'android') => (s === 'ios' ? 'App Store' : 'Play'), none: '-', muted: 'muted', tipReview: (n: number) => `${n} in review`, tipAttention: (n: number) => `${n} needs you`, labels: { open: 'o', readNow: 'r', apps: 'a', noApps: 'n', mute: 'm', unmute: 'u', update: 'U', settings: 's', quit: 'q' } }; test('counts, attention and one-word states', () => { const s = traySummary([ app('1', 'One', ios('IN_REVIEW', 'review'), play('completed', 'live')), app('2', 'Two', ios('REJECTED', 'rejected')), app('3', 'Three', ios('READY_FOR_SALE', 'live'), ios('PREPARE_FOR_SUBMISSION', 'draft')), { key: '4', name: 'Four', status: undefined }, ], false, tr); expect(s.tooltip).toBe('storebound · 1 in review · 1 needs you'); expect(s.attention).toBe(1); expect(s.apps.map(a => a.state)).toEqual(['App Store: review · Play: live', 'App Store: rejected', 'App Store: live', '-']); }); test('muted shows in the tooltip; nothing going on is just the name', () => { expect(traySummary([app('1', 'One', ios('READY_FOR_SALE', 'live'))], true, tr).tooltip).toBe('storebound · muted'); expect(traySummary([], false, tr).tooltip).toBe('storebound'); }); test('the most pressing release of a store wins', () => { expect(trayPhases(snap(ios('READY_FOR_SALE', 'live'), { ...ios('IN_REVIEW', 'review'), version: '3.0' }).apps[0]).ios).toBe('review'); }); test('routes from the host are checked', () => { expect(parseRouteJson('{"screen":"app","appId":"k","tab":"reviews"}')).toEqual({ screen: 'app', appId: 'k', tab: 'reviews' }); expect(parseRouteJson('{"screen":"app","appId":"k","tab":"nope"}')).toEqual({ screen: 'app', appId: 'k', tab: 'releases' }); expect(parseRouteJson('{"screen":"settings","section":"updates"}')).toEqual({ screen: 'settings', section: 'updates' }); expect(parseRouteJson('{"screen":"app"}')).toBeNull(); expect(parseRouteJson('{"screen":"x"}')).toBeNull(); expect(parseRouteJson('junk')).toBeNull(); }); });