189 lines
14 KiB
TypeScript
189 lines
14 KiB
TypeScript
// One app card per product across studios and store accounts: folder matching, automatic pairing (same id, close id,
|
|
// same name), manual pair / unpair, links, studio moves, the ignore list, and the portfolio filter / sort / grouping.
|
|
import { describe, expect, test } from 'bun:test';
|
|
import type { Account, Project, ProjectSummary, StoreApp } from '../src/api';
|
|
import {
|
|
buildApps, filterSort, groupByStudio, type ListRow, NO_OVERRIDES, normId, normName, type Overrides, pairCandidates, storeTarget, studioCounts,
|
|
withIgnored, withLink, withPair, withSideIgnored, withStudio, withUnpair,
|
|
} from '../src/lib/identity';
|
|
import { crashCrossed, newLowReviews, releaseChanges } from '../src/lib/notify';
|
|
|
|
const P1 = '0a1b2c3d4e5f6071', A1 = '2c3d4e5f60718293', A2 = '3d4e5f6071829304';
|
|
const acc = (id: string, kind: 'play' | 'apple', studio: string | null): Account => ({ id, label: id, kind, created: '2026-10-01T00:00:00Z', studio });
|
|
const ios = (id: string, bundleId: string, name: string): StoreApp => ({ store: 'ios', id, bundleId, name, platforms: ['IOS'], icon: null });
|
|
const droid = (pkg: string, name: string): StoreApp => ({ store: 'android', id: pkg, bundleId: pkg, name, platforms: ['ANDROID'], icon: null });
|
|
const summary = (bundleId: string | null, pkg: string | null, appId: string | null = null): ProjectSummary => ({
|
|
dir: '/x', config: '/x/c', name: 'x', languages: ['en'], apple: bundleId ? { bundleId, appId } : null, play: pkg ? { packageName: pkg } : null,
|
|
platforms: [], products: 0, productIds: [], subscriptions: 0, subscriptionIds: [], catalog: { ios: [], android: [] }, shots: false, testflightGroups: 0,
|
|
});
|
|
const proj = (id: string, name: string, play: string | null, apple: string | null): Project => ({ id, path: `/apps/${id}`, name, playAccount: play, appleAccount: apple, added: '2026-10-01T00:00:00Z' });
|
|
|
|
const accounts = [acc(P1, 'play', 's1'), acc(A1, 'apple', 's1'), acc(A2, 'apple', 's2')];
|
|
const storeApps = [
|
|
{ account: P1, apps: [droid('com.example.prism', 'Prism'), droid('com.example.orbit', 'Orbit Hop'), droid('com.example.block', 'Block Garden'), droid('com.example.lantern.words', 'Word Lantern'), droid('com.example.tycoon', 'Pocket Tycoon')] },
|
|
{ account: A1, apps: [ios('101', 'com.example.prism', 'Prism'), ios('102', 'com.example.orbit', 'Orbit Hop'), ios('103', 'com.example.Block.ios', 'Block Garden HD'), ios('104', 'com.example.wordlantern', 'Word Lantern')] },
|
|
{ account: A2, apps: [ios('201', 'com.example.habit', 'Habit Hatch')] },
|
|
];
|
|
const prism = proj('p1', 'Prism Puddle', P1, A1);
|
|
const build = (o: Overrides = NO_OVERRIDES, projects = [prism]) => buildApps({ projects, accounts, storeApps, overrides: o, summaryOf: p => (p.id === 'p1' ? summary('com.example.prism', 'com.example.prism') : summary(null, null)) });
|
|
const byName = (apps: ReturnType<typeof build>, n: string) => apps.find(a => a.name === n)!;
|
|
|
|
describe('identity pairing', () => {
|
|
test('a folder takes the store apps its config names; the rest pair by id, close id, then name', () => {
|
|
const apps = build();
|
|
expect(apps.map(a => [a.name, a.pairing, !!a.ios, !!a.android])).toEqual([
|
|
['Prism Puddle', 'folder', true, true],
|
|
['Orbit Hop', 'id', true, true],
|
|
['Block Garden HD', 'close', true, true],
|
|
['Word Lantern', 'name', true, true],
|
|
['Pocket Tycoon', 'single', false, true],
|
|
['Habit Hatch', 'single', true, false],
|
|
]);
|
|
expect(byName(apps, 'Prism Puddle')).toMatchObject({ key: 'p1', target: 'p1', studio: 's1', ios: { key: `ios:${A1}:101` }, android: { key: `android:${P1}:com.example.prism` } });
|
|
const orbit = byName(apps, 'Orbit Hop');
|
|
expect(orbit.target).toBe(`store:ios:${A1}:102:com.example.orbit;android:${P1}:com.example.orbit`);
|
|
expect(orbit.key).toBe(orbit.target);
|
|
expect(orbit.ignoreKeys).toEqual([`app:ios:${A1}:102`, `app:android:${P1}:com.example.orbit`]);
|
|
expect(byName(apps, 'Habit Hatch').studio).toBe('s2');
|
|
});
|
|
test('the app id in the config wins over the bundle id; accounts mapped to the folder are preferred', () => {
|
|
const twin = [...storeApps, { account: A2, apps: [ios('999', 'com.example.prism', 'Prism (old team)')] }];
|
|
const apps = buildApps({ projects: [prism], accounts, storeApps: twin, overrides: NO_OVERRIDES, summaryOf: () => summary('com.example.prism', 'com.example.prism', '999') });
|
|
expect(apps[0]!.ios?.app.id).toBe('999');
|
|
const apps2 = buildApps({ projects: [prism], accounts, storeApps: twin, overrides: NO_OVERRIDES, summaryOf: () => summary('com.example.prism', 'com.example.prism') });
|
|
expect(apps2[0]!.ios?.account).toBe(A1);
|
|
});
|
|
test('a folder with a config summary claims its iOS and Android records; ignore flags do not break it', () => {
|
|
for (const o of [NO_OVERRIDES, withIgnored(NO_OVERRIDES, ['project:p1:ios'], true), withIgnored(NO_OVERRIDES, ['project:p1:android'], true)]) {
|
|
const apps = build(o);
|
|
expect(apps.filter(a => a.ios?.app.bundleId === 'com.example.prism' || a.android?.app.id === 'com.example.prism').length).toBeLessThanOrEqual(1);
|
|
expect(apps.filter(a => a.name === 'Prism' && !a.project)).toEqual([]);
|
|
}
|
|
});
|
|
test('without a summary the folder claims nothing (the start-up race); a store app of a folder bundle id is never shown separately', () => {
|
|
const none = buildApps({ projects: [prism], accounts, storeApps, overrides: NO_OVERRIDES, summaryOf: () => null });
|
|
expect(none.filter(a => !a.project && a.name === 'Prism')).toHaveLength(1);
|
|
// guard: the folder is linked to other records, its summary's ids still hide the duplicates
|
|
const linked = { ...NO_OVERRIDES, links: [{ key: `ios:${A1}:102`, project: 'p1' }] };
|
|
const apps = build(linked);
|
|
expect(apps.filter(a => !a.project && (a.ios?.app.bundleId === 'com.example.prism' || a.android?.app.id === 'com.example.prism'))).toEqual([]);
|
|
});
|
|
test('unpair blocks the automatic pair; manual pair beats it', () => {
|
|
const o1 = withUnpair(NO_OVERRIDES, `ios:${A1}:102`, `android:${P1}:com.example.orbit`);
|
|
const apps1 = build(o1);
|
|
expect(apps1.filter(a => a.name === 'Orbit Hop').map(a => [!!a.ios, !!a.android, a.pairing])).toEqual([[false, true, 'single'], [true, false, 'single']]);
|
|
const o2 = withPair(o1, `ios:${A1}:102`, `android:${P1}:com.example.tycoon`);
|
|
expect(o2.unpairs).toHaveLength(1);
|
|
const apps2 = build(o2);
|
|
const manual = apps2.find(a => a.pairing === 'manual')!;
|
|
expect([manual.ios?.app.id, manual.android?.app.id]).toEqual(['102', 'com.example.tycoon']);
|
|
// pairing the unpaired two again drops the unpair
|
|
expect(withPair(o1, `ios:${A1}:102`, `android:${P1}:com.example.orbit`).unpairs).toEqual([]);
|
|
});
|
|
test('links: a store-only app joins a folder by hand', () => {
|
|
const orbit = byName(build(), 'Orbit Hop');
|
|
const fresh = proj('p2', 'Orbit (folder)', null, null);
|
|
const apps = build(withLink(NO_OVERRIDES, orbit, 'p2'), [prism, fresh]);
|
|
const linked = apps.find(a => a.key === 'p2')!;
|
|
expect([linked.ios?.app.id, linked.android?.app.id, linked.pairing]).toEqual(['102', 'com.example.orbit', 'folder']);
|
|
expect(apps.some(a => a.name === 'Orbit Hop' && !a.project)).toBe(false);
|
|
});
|
|
test('ignore one store side: a folder hides only that store, both sides is the whole app, restore splits it', () => {
|
|
const base = byName(build(), 'Prism Puddle');
|
|
expect([base.ios?.app.id, base.android?.app.id, base.ignored, base.ignoredStores]).toEqual(['101', 'com.example.prism', false, []]);
|
|
const noIos = withSideIgnored(NO_OVERRIDES, base, 'ios', true);
|
|
expect(noIos.ignored).toEqual(['project:p1:ios']);
|
|
const half = byName(build(noIos), 'Prism Puddle');
|
|
expect([half.ios, half.android?.app.id, half.ignored, half.ignoredStores]).toEqual([null, 'com.example.prism', false, ['ios']]);
|
|
expect(half.sideKeys.ios).toBe('project:p1:ios');
|
|
// the hidden iOS app does not come back as a store-only app of its own
|
|
expect(build(noIos).filter(a => a.ios?.app.id === '101')).toHaveLength(0);
|
|
const both = withSideIgnored(noIos, half, 'android', true);
|
|
expect(both.ignored).toEqual(['project:p1']);
|
|
const whole = byName(build(both), 'Prism Puddle');
|
|
expect([whole.ignored, whole.ignoredStores]).toEqual([true, []]);
|
|
const back = withSideIgnored(both, whole, 'ios', false);
|
|
expect(back.ignored.sort()).toEqual(['project:p1:android']);
|
|
expect(byName(build(back), 'Prism Puddle').ignoredStores).toEqual(['android']);
|
|
// restoring the whole app clears every key of it
|
|
expect(withIgnored(both, whole.ignoreKeys, false).ignored).toEqual([]);
|
|
});
|
|
test('ignore one side of a paired store-only app; ignoring both sides ignores the app', () => {
|
|
const orbit = byName(build(), 'Orbit Hop');
|
|
expect(orbit.ios && orbit.android).toBeTruthy();
|
|
const o = withSideIgnored(NO_OVERRIDES, orbit, 'android', true);
|
|
const half = byName(build(o), 'Orbit Hop');
|
|
expect([half.ios?.app.id, half.android, half.ignored, half.ignoredStores]).toEqual(['102', null, false, ['android']]);
|
|
const all = withSideIgnored(o, half, 'ios', true);
|
|
const full = byName(build(all), 'Orbit Hop');
|
|
expect(full.ignored).toBe(true);
|
|
expect(full.ios && full.android).toBeTruthy();
|
|
});
|
|
test('ignore: an app (all its store keys) or a whole account; studio moves', () => {
|
|
const tycoon = byName(build(), 'Pocket Tycoon');
|
|
const o = withIgnored(NO_OVERRIDES, tycoon.ignoreKeys, true);
|
|
expect(byName(build(o), 'Pocket Tycoon').ignored).toBe(true);
|
|
expect(withIgnored(o, tycoon.ignoreKeys, false).ignored).toEqual([]);
|
|
const noA1 = build(withIgnored(NO_OVERRIDES, [`account:${A1}`], true));
|
|
expect(noA1.some(a => a.ios?.account === A1)).toBe(false);
|
|
expect(byName(noA1, 'Orbit Hop').ios).toBeNull();
|
|
const moved = build(withStudio(NO_OVERRIDES, tycoon, 's2'));
|
|
expect(byName(moved, 'Pocket Tycoon').studio).toBe('s2');
|
|
const back = withStudio(withStudio(NO_OVERRIDES, tycoon, 's2'), tycoon, null);
|
|
expect(back.appStudio).toEqual([]);
|
|
expect(withStudio(NO_OVERRIDES, byName(build(), 'Prism Puddle'), 's9').appStudio).toEqual([{ key: 'project:p1', studio: 's9' }]);
|
|
expect(studioCounts(build(o))).toEqual({ s1: 4, s2: 1 });
|
|
});
|
|
test('accounts that are gone or never read add nothing', () => {
|
|
const apps = buildApps({ projects: [], accounts: [acc(P1, 'play', null)], storeApps: [{ account: 'deadbeefdeadbeef', apps: [droid('com.example.x', 'X')] }], overrides: NO_OVERRIDES, summaryOf: () => undefined });
|
|
expect(apps).toEqual([]);
|
|
});
|
|
test('helpers: normalised ids and names, store targets, pair candidates', () => {
|
|
expect(normId('com.Example.Block.ios')).toBe(normId('com.example.block'));
|
|
expect(normId('com.example.block-android')).toBe('comexampleblock');
|
|
expect(normName('Word Lantern!')).toBe(normName('word-lantern'));
|
|
expect(storeTarget(null, { key: 'k', account: P1, store: 'android', app: droid('com.a.b', 'B') })).toBe(`store:android:${P1}:com.a.b`);
|
|
const apps = build(withUnpair(NO_OVERRIDES, `ios:${A1}:102`, `android:${P1}:com.example.orbit`));
|
|
const lone = apps.find(a => a.name === 'Orbit Hop' && a.ios)!;
|
|
expect(pairCandidates(lone, apps)[0]!.android?.app.id).toBe('com.example.orbit');
|
|
expect(pairCandidates(byName(apps, 'Prism Puddle'), apps)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('portfolio list', () => {
|
|
const apps = build();
|
|
const rows: ListRow[] = apps.map((app, i) => ({ app, attention: [0, 3, 1, 0, 2, 0][i]!, blockers: [0, 1, 0, 0, 0, 0][i]!, updated: [`2026-10-0${i + 1}`, null, '2026-10-09', null, '2026-09-01', '2026-10-05'][i]! }));
|
|
test('filter by studio and search ids, sort by attention / recent / name', () => {
|
|
expect(filterSort(rows, { studio: 'all', query: '', sort: 'attention' }).map(r => r.app.name).slice(0, 3)).toEqual(['Orbit Hop', 'Pocket Tycoon', 'Block Garden HD']);
|
|
expect(filterSort(rows, { studio: 'all', query: '', sort: 'recent' }).map(r => r.app.name)[0]).toBe('Block Garden HD');
|
|
expect(filterSort(rows, { studio: 's2', query: '', sort: 'name' }).map(r => r.app.name)).toEqual(['Habit Hatch']);
|
|
expect(filterSort(rows, { studio: 'all', query: 'lantern.words', sort: 'name' }).map(r => r.app.name)).toEqual(['Word Lantern']);
|
|
expect(filterSort(rows, { studio: 'none', query: '', sort: 'name' })).toEqual([]);
|
|
});
|
|
test('groups follow the studios\' order, no studio last; ignored apps never listed', () => {
|
|
const g = groupByStudio(rows, [{ id: 's2' }, { id: 's1' }]);
|
|
expect(g.map(x => [x.studio, x.rows.length])).toEqual([['s2', 1], ['s1', 5]]);
|
|
const ignored = rows.map(r => (r.app.name === 'Habit Hatch' ? { ...r, app: { ...r.app, ignored: true } } : r));
|
|
expect(filterSort(ignored, { studio: 'all', query: '', sort: 'name' }).some(r => r.app.name === 'Habit Hatch')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('notifications', () => {
|
|
const status = (phase: string) => ({ 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: [{ store: 'ios' as const, version: '2.0', build: '1', state: phase, phase: phase as 'review', fraction: null, track: null }] } }], at: 'x' });
|
|
test('release phase changes, only against a previous read', () => {
|
|
expect(releaseChanges(null, status('review'))).toEqual([]);
|
|
expect(releaseChanges(status('review'), status('review'))).toEqual([]);
|
|
expect(releaseChanges(status('review'), status('approved'))).toEqual([{ project: '/a', name: 'A', store: 'ios', version: '2.0', from: 'review', to: 'approved', kind: 'approved' }]);
|
|
expect(releaseChanges(status('draft'), status('testing'))).toEqual([]);
|
|
});
|
|
test('new 1-2 star reviews and threshold crossings', () => {
|
|
const r = (id: string, rating: number) => ({ store: 'play' as const, id, rating, body: '', date: '2026-10-01' });
|
|
expect(newLowReviews(null, [r('a', 1)])).toEqual([]);
|
|
expect(newLowReviews([r('a', 1)], [r('a', 1), r('b', 2), r('c', 5)]).map(x => x.id)).toEqual(['b']);
|
|
const h = (crash: number) => ({ date: 'x', play: { window: { start: '', end: '', days: 28 }, anr: [], issues: [], crash: [{ date: '2026-10-01', dims: {}, values: { userPerceivedCrashRate: crash } }] } });
|
|
expect(crashCrossed(h(0.005), h(0.02))).toEqual(['crash']);
|
|
expect(crashCrossed(h(0.02), h(0.03))).toEqual([]);
|
|
expect(crashCrossed(null, h(0.03))).toEqual([]);
|
|
});
|
|
});
|