// The tray card's data mapping: rejections first, moving releases with rollout share, recent and low reviews, Play // crash / ANR alerts, console links only for apps on the card; and the card words in every language. import { expect, test } from 'bun:test'; import type { AppStatus } from '../src/api'; import { cardSnapshot, healthAlerts, type CardInput } from '../src/card/model'; import { ago, CARD_TEXT, cardT } from '../src/card/text'; const NOW = Date.parse('2026-10-20T12:00:00Z'); const daysAgo = (n: number) => new Date(NOW - n * 86_400_000).toISOString(); function status(all: { store: 'ios' | 'android'; phase: string; version?: string; fraction?: number | null; reason?: string }[]): AppStatus { return { release: { source: 'live', current: null, next: null, all: all.map(r => ({ store: r.store, version: r.version ?? '1.0', build: null, state: 'X', phase: r.phase, fraction: r.fraction ?? null, track: null, reason: r.reason ?? null })) } } as unknown as AppStatus; } const rows = (rate: number) => [{ date: '2026-10-19', dims: {}, values: { userPerceivedCrashRate: rate, userPerceivedCrashRate28dUserWeighted: rate } }]; test('maps releases, rejections, reviews and health', () => { const apps: CardInput[] = [ { key: 'a', name: 'Alpha', hue: 10, status: status([{ store: 'ios', phase: 'rejected', reason: 'Guideline 2.1\nincomplete' }, { store: 'android', phase: 'rollout', fraction: 0.2 }]), asc: 'https://appstoreconnect.apple.com/apps/1/distribution', play: 'https://play.google.com/console' }, { key: 'b', name: 'Beta', hue: 20, status: status([{ store: 'ios', phase: 'sent' }, { store: 'android', phase: 'live' }]), reviews: [ { store: 'play', id: '1', rating: 1, title: 'Bad', body: 'Crashes', date: daysAgo(1) }, { store: 'apple', id: '2', rating: 5, body: 'Great', date: daysAgo(2) }, { store: 'apple', id: '3', rating: 2, body: 'Old and low', date: daysAgo(20) }, ] }, { key: 'c', name: 'Gamma', hue: 30, status: status([{ store: 'ios', phase: 'approved' }, { store: 'android', phase: 'halted' }]), health: { date: '2026-10-19', play: { window: { start: '', end: '', days: 28 }, crash: rows(0.05), anr: [] } } as never }, { key: 'd', name: 'Delta', hue: 40, status: status([{ store: 'ios', phase: 'live' }]) }, ]; const s = cardSnapshot(apps, { at: daysAgo(0), busy: true, muted: false, now: NOW }); expect(s.busy).toBe(true); expect(s.apps).toBe(4); expect(s.rejections).toEqual([{ app: 'a', name: 'Alpha', hue: 10, store: 'ios', version: '1.0', reason: 'Guideline 2.1 incomplete' }]); expect(s.releases.map(r => `${r.app}:${r.store}:${r.phase}:${r.fraction}`)).toEqual(['c:android:halted:null', 'b:ios:review:null', 'c:ios:approved:null', 'a:android:rollout:0.2']); expect(s.reviews.recent).toBe(2); expect(s.reviews.low).toEqual([{ app: 'b', name: 'Beta', store: 'play', rating: 1, text: 'Bad · Crashes', date: daysAgo(1) }]); expect(s.health).toEqual([{ app: 'c', name: 'Gamma', hue: 30, kinds: ['crash'] }]); expect(s.links.map(l => l.key)).toEqual(['a', 'b', 'c']); expect(s.links[0]!.asc).toContain('appstoreconnect.apple.com'); }); test('nothing read: an empty, calm snapshot', () => { const s = cardSnapshot([{ key: 'x', name: 'X', hue: 0, status: undefined }], { at: null, busy: false, muted: true, now: NOW }); expect(s).toMatchObject({ at: null, muted: true, rejections: [], releases: [], health: [], reviews: { recent: 0, low: [] }, links: [] }); }); test('health errors and healthy rates raise no alert', () => { expect(healthAlerts(null)).toEqual([]); expect(healthAlerts({ date: '', play: { error: 'no access' } } as never)).toEqual([]); expect(healthAlerts({ date: '', play: { window: { start: '', end: '', days: 28 }, crash: rows(0.001), anr: [] } } as never)).toEqual([]); }); test('card words: every language has every key, placeholders match', () => { const ph = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map(m => m[1]).sort().join(','); for (const [lang, cat] of Object.entries(CARD_TEXT)) { for (const [k, v] of Object.entries(CARD_TEXT.en)) { const got = (cat as Record)[k]; expect(got, `${lang}.${k}`).toBeTruthy(); expect(ph(got!), `${lang}.${k}`).toBe(ph(v)); } } expect(cardT('tr', 'rolloutPct', { pct: 20 })).toBe('%20 yayılıyor'); expect(ago('en', null, NOW)).toBe('never'); expect(ago('en', daysAgo(0), NOW)).toBe('just now'); expect(ago('en', new Date(NOW - 5 * 60_000).toISOString(), NOW)).toBe('5 min ago'); expect(ago('tr', new Date(NOW - 3 * 3600_000).toISOString(), NOW)).toBe('3 sa önce'); });