Daily Bluesky bot for AT Mot. Invites players and congratulates yesterday's solvers.
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});