Daily Bluesky bot for AT Mot. Invites players and congratulates yesterday's solvers.
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 4.6 kB View raw
1import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 3// Mock the network-facing modules so we can drive yesterdayCounts deterministically. 4vi.mock('../src/constellation.js', () => ({ 5 dailyPlayerCount: vi.fn(), 6 dailyResultBacklinks: vi.fn(), 7 backlinkUri: (r: { did: string; rkey: string }) => `at://${r.did}/c/${r.rkey}`, 8})); 9vi.mock('../src/identity.js', () => ({ getRecordByUri: vi.fn() })); 10 11import { yesterdayCounts } from '../src/counts.js'; 12import { dailyPlayerCount, dailyResultBacklinks } from '../src/constellation.js'; 13import { getRecordByUri } from '../src/identity.js'; 14 15interface Rec { 16 did: string; 17 rkey: string; 18 lang?: string; 19 puzzleNumber?: number; 20 solved?: boolean; 21 /** When set, getRecordByUri resolves to null for this backlink (a dropped read). */ 22 drop?: boolean; 23} 24 25/** Wire the mocks for one scenario. Records default to (lang='en', puzzle=N, solved). */ 26function setup(opts: { players: number | null; records: Rec[]; truncated?: boolean }, lang = 'en', n = 5) { 27 vi.mocked(dailyPlayerCount).mockResolvedValue(opts.players); 28 vi.mocked(dailyResultBacklinks).mockResolvedValue({ 29 records: opts.records.map((r) => ({ did: r.did, collection: 'c', rkey: r.rkey })), 30 truncated: opts.truncated ?? false, 31 }); 32 const byUri = new Map<string, unknown>(); 33 for (const r of opts.records) { 34 if (r.drop) continue; 35 byUri.set(`at://${r.did}/c/${r.rkey}`, { 36 lang: r.lang ?? lang, 37 puzzleNumber: r.puzzleNumber ?? n, 38 solved: r.solved ?? true, 39 }); 40 } 41 vi.mocked(getRecordByUri).mockImplementation(async (uri: string) => (byUri.get(uri) ?? null) as never); 42} 43 44beforeEach(() => vi.clearAllMocks()); 45 46describe('yesterdayCounts', () => { 47 it('dedupes solvers by DID — one player with two winning records counts once', async () => { 48 setup({ 49 players: 3, 50 records: [ 51 { did: 'did:a', rkey: '1' }, 52 { did: 'did:a', rkey: '2' }, // same player, second record 53 { did: 'did:b', rkey: '1' }, 54 ], 55 }); 56 const { solvers } = await yesterdayCounts('en', 5); 57 expect(solvers).toBe(2); 58 }); 59 60 it('clamps solvers to players when the two endpoints disagree', async () => { 61 setup({ 62 players: 1, // distinct-DID count says 1... 63 records: [ 64 { did: 'did:a', rkey: '1' }, 65 { did: 'did:b', rkey: '1' }, // ...but the backlinks index returned two DIDs 66 ], 67 }); 68 const { players, solvers } = await yesterdayCounts('en', 5); 69 expect(players).toBe(1); 70 expect(solvers).toBe(1); // never report more solvers than players 71 }); 72 73 it('ignores records for the wrong lang or puzzle, and losses', async () => { 74 setup({ 75 players: 4, 76 records: [ 77 { did: 'did:a', rkey: '1', solved: true }, 78 { did: 'did:b', rkey: '1', solved: false }, // a loss 79 { did: 'did:c', rkey: '1', lang: 'fr' }, // wrong language 80 { did: 'did:d', rkey: '1', puzzleNumber: 99 }, // wrong puzzle 81 ], 82 }); 83 const { solvers, sampled } = await yesterdayCounts('en', 5); 84 expect(solvers).toBe(1); // only did:a 85 // Only did:a and did:b are valid players for this puzzle; 2 < players(4) => hedge. 86 expect(sampled).toBe(true); 87 }); 88 89 it('is not sampled when every player was resolved and the page was complete', async () => { 90 setup({ 91 players: 2, 92 records: [ 93 { did: 'did:a', rkey: '1', solved: true }, 94 { did: 'did:b', rkey: '1', solved: false }, 95 ], 96 }); 97 const { players, solvers, sampled } = await yesterdayCounts('en', 5); 98 expect(players).toBe(2); 99 expect(solvers).toBe(1); 100 expect(sampled).toBe(false); 101 }); 102 103 it('is sampled when the backlink page was truncated', async () => { 104 setup({ 105 players: 10, 106 records: [{ did: 'did:a', rkey: '1' }], 107 truncated: true, 108 }); 109 const { sampled } = await yesterdayCounts('en', 5); 110 expect(sampled).toBe(true); 111 }); 112 113 it('is sampled when a record read is dropped (fewer resolved than players)', async () => { 114 setup({ 115 players: 2, 116 records: [ 117 { did: 'did:a', rkey: '1', solved: true }, 118 { did: 'did:b', rkey: '1', drop: true }, // transient read failure 119 ], 120 }); 121 const { solvers, sampled } = await yesterdayCounts('en', 5); 122 expect(solvers).toBe(1); 123 expect(sampled).toBe(true); 124 }); 125 126 it('propagates a null player count (Constellation unreachable) as sampled', async () => { 127 setup({ players: null, records: [{ did: 'did:a', rkey: '1' }] }); 128 const { players, sampled } = await yesterdayCounts('en', 5); 129 expect(players).toBeNull(); 130 expect(sampled).toBe(true); 131 }); 132});