1// The social pieces' plain logic: profiles (profile.mjs), the activity 2// feed (activity.mjs), live parties (parties.mjs), the budget's words 3// (budget.mjs) and what a listener played (profile.mjs's playedLately). 4import { describe, expect, it, vi } from 'vitest'; 5import { appendPage, describe as describeItem, loadActivity } from './activity.mjs'; 6import { budgetText } from './budget.mjs'; 7import { joinListed, partyOf } from './parties.mjs'; 8import { closeProfile, openProfile, playedLately, profileStore, profileView, setOnOpenProfile } from './profile.mjs'; 9import { performancePath } from './performance.mjs'; 10import { takeTime } from './takes.mjs'; 11 12const ID = 'L'.repeat(22); 13const answer = (body, status = 200) => async () => new Response(JSON.stringify(body), { status }); 14 15describe('profiles', () => { 16 it('opens a profile for an account id only, and opens the panel', () => { 17 const opened = vi.fn(); 18 setOnOpenProfile(opened); 19 openProfile('not an id'); 20 expect(profileStore.get().id).toBeNull(); 21 openProfile(ID); 22 expect(profileStore.get().id).toBe(ID); 23 expect(opened).toHaveBeenCalledTimes(1); 24 closeProfile(); 25 expect(profileStore.get().id).toBeNull(); 26 }); 27 28 it('shows what is public, counts the rest, and what their tabs play', () => { 29 const view = profileView( 30 { 31 id: ID, 32 name: 'Link', 33 joined: 1, 34 publicSongs: [{ id: 'S'.repeat(22), author: 'Link', authorId: ID, title: 'Boomerang', code: 's("bd")' }], 35 radio: Array.from({ length: 30 }, (_, i) => ({ song: 'jev/dial-up', at: i })), 36 content: { 37 revisions: [{ status: 'public' }, { status: 'held' }, { status: 'pending' }], 38 comments: [{ id: 'c1', status: 'public', body: 'nice' }, { id: 'c2', status: 'held', body: 'spam' }], 39 pitches: [{ id: 'p1', status: 'pending', body: 'idea' }], 40 }, 41 }, 42 [ 43 { id: 't1', account: ID, playing: true, song: 'jev/dial-up' }, 44 { id: 't2', account: ID, playing: false }, 45 { id: 't3', account: 'Z'.repeat(22), playing: true }, 46 ], 47 ); 48 // a listener's song never carries its code where a site song's plays from 49 expect(view.songs[0]).toMatchObject({ id: `listener:${'S'.repeat(22)}`, authorId: ID, foreignCode: 's("bd")' }); 50 expect(view.songs[0].code).toBeUndefined(); 51 expect(view.revisions).toEqual({ pending: 1, held: 1 }); 52 expect(view.comments.shown.map((c) => c.id)).toEqual(['c1']); 53 expect(view.comments.held).toBe(1); 54 expect(view.pitches).toMatchObject({ shown: [], pending: 1 }); 55 expect(view.played).toHaveLength(20); 56 expect(view.playing.map((t) => t.id)).toEqual(['t1']); 57 expect(view.listed).toBe(2); 58 }); 59}); 60 61describe('the activity feed', () => { 62 it('reads a page, and joins pages without repeating the edge', async () => { 63 const fetcher = vi.fn(answer({ items: [{ kind: 'pitch', id: 'a', at: 5 }], next: 5 })); 64 const page = await loadActivity(10, fetcher); 65 expect(fetcher.mock.calls[0][0]).toBe('/jev/data/activity?limit=30&before=10'); 66 expect(page).toEqual({ items: [{ kind: 'pitch', id: 'a', at: 5 }], next: 5 }); 67 const joined = appendPage(page.items, [{ kind: 'pitch', id: 'a', at: 5 }, { kind: 'pitch', id: 'b', at: 4 }]); 68 expect(joined.map((i) => i.id)).toEqual(['a', 'b']); 69 }); 70 71 it('says what happened, and Jev’s part', () => { 72 const title = (id) => ({ 'jev/dial-up': 'DIAL-UP' })[id] ?? id; 73 expect(describeItem({ kind: 'revision', rev: 1, title: 'Boomerang', screen: 'fine', art: 0.62 }, title)).toBe( 74 'published “Boomerang”: public, 62% art', 75 ); 76 expect(describeItem({ kind: 'revision', rev: 3, title: 'Boomerang', screen: 'spam', art: null })).toBe( 77 'revised “Boomerang” (rev 3): held by Jev as spam', 78 ); 79 expect(describeItem({ kind: 'comment', on: { site: 'jev/dial-up' }, screen: 'fine', body: 'hi' }, title)).toBe( 80 'commented on DIAL-UP', 81 ); 82 expect(describeItem({ kind: 'pitch', screen: 'pending', body: null })).toBe('pitched a song: waiting on Jev'); 83 expect(describeItem({ kind: 'take', song: 'jev/dial-up', title: null, sections: 6, ended: 'finished' }, title)).toBe( 84 'played DIAL-UP, 6 sections to its end', 85 ); 86 expect(describeItem({ kind: 'take', song: `listener:${ID}`, title: 'Boomerang', sections: 1, ended: 'stopped' }, title)).toBe( 87 'played “Boomerang”, 1 section and stopped', 88 ); 89 }); 90}); 91 92describe('live parties', () => { 93 it('keeps only the fields the directory sends, checked', () => { 94 expect(partyOf({ id: 'x'.repeat(64), song: '<script>', people: 2, host: true, since: 1, extra: 'no' })).toEqual({ 95 id: null, 96 song: null, 97 people: 2, 98 host: true, 99 since: 1, 100 }); 101 }); 102 103 it('joins a listed party as a guest, by the room the room gives', async () => { 104 const join = vi.fn(); 105 const id = 'a'.repeat(64); 106 await joinListed(id, answer({ room: 'the-room-0123456789' }), join); 107 expect(join).toHaveBeenCalledWith('the-room-0123456789'); 108 await expect(joinListed(id, answer({ error: 'over' }, 404), join)).rejects.toThrow(/over, or no longer listed/); 109 await expect(joinListed('not-an-id', answer({}), join)).rejects.toThrow(); 110 }); 111}); 112 113describe('the budget in words', () => { 114 it('says whose budget it is', () => { 115 expect(budgetText({ user: null, budget: null }).text).toBe('budget: shared'); 116 expect(budgetText({ user: { id: ID }, budget: { used: 58, limit: 1000 } })).toMatchObject({ text: 'budget 58/1000', spent: false }); 117 expect(budgetText({ user: { id: ID }, budget: { used: 1000, limit: 1000 } }).spent).toBe(true); 118 }); 119}); 120 121describe('your history', () => { 122 it('lists takes, each with its replay, and radio plays that have none, newest first', () => { 123 const take = (id, song, at) => ({ id: id.repeat(22), song, at, title: null, day: '2026-09-27', path: null }); 124 const radio = [ 125 { song: 'jev/dial-up', at: 100 }, 126 { song: 'jev/pong', at: 200 }, 127 { song: 'jev/dial-up', at: 300 }, 128 { song: 'jev/pong', at: 400 }, 129 ]; 130 const takes = [ 131 // the radio's dial-up, stored a moment after the radio's next play began 132 take('a', 'jev/dial-up', 205), 133 // pong from its card, after the radio's pong had its take 134 take('b', 'jev/pong', 250), 135 take('c', 'jev/pong', 260), 136 // a listener's song, which the radio never plays 137 { ...take('d', `listener:${ID}`, 350), title: 'Boomerang' }, 138 ]; 139 const played = playedLately(radio, takes); 140 expect(played.map((e) => [e.song, e.at, e.take?.id[0] ?? null])).toEqual([ 141 ['jev/pong', 400, null], 142 [`listener:${ID}`, 350, 'd'], 143 ['jev/dial-up', 300, null], 144 ['jev/pong', 260, 'c'], 145 ['jev/pong', 250, 'b'], 146 ['jev/dial-up', 205, 'a'], 147 ]); 148 expect(played[1].title).toBe('Boomerang'); 149 expect(playedLately(radio, takes, 2)).toHaveLength(2); 150 expect(playedLately(undefined, undefined)).toEqual([]); 151 }); 152 153 it("links a take's replay: a site song's page, or a listener song's in the sandbox", () => { 154 const id = 'P'.repeat(22); 155 expect(performancePath('jev/dial-up', id)).toBe(`/songs/jev/dial-up/?performance=${id}`); 156 expect(performancePath(`listener:${ID}`, id)).toBe(`/?listener=${ID}&performance=${id}`); 157 }); 158 159 it('dates a take to the minute, or to its day when stored before the time was kept', () => { 160 expect(takeTime({ day: '2026-09-27', at: Date.parse('2026-09-27T14:05:31Z') })).toBe('2026-09-27 14:05 UTC'); 161 expect(takeTime({ day: '2026-09-20', at: Date.parse('2026-09-20T12:00:00Z') })).toBe('2026-09-20'); 162 }); 163});