jevstrudel.git / website / src / jev / social.test.mjs

The social pieces' plain logic: profiles (profile.mjs), the activity feed (activity.mjs), live parties (parties.mjs), the budget's words (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';
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});