import { test } from 'node:test'; import assert from 'node:assert/strict'; import { tallyResponses, resultsVisible, winningAnswerIds, parsePollStart, parseResponseAnswerIds, validateSelections, PollResponse, } from './poll'; const me = '@me:server'; test('tallyResponses counts one vote per selection', () => { const responses: PollResponse[] = [ { sender: '@a:s', ts: 1, answerIds: ['1'] }, { sender: '@b:s', ts: 1, answerIds: ['1'] }, { sender: '@c:s', ts: 1, answerIds: ['2'] }, ]; const { counts, total } = tallyResponses(responses, me); assert.equal(counts.get('1'), 2); assert.equal(counts.get('2'), 1); assert.equal(total, 3); }); test('tallyResponses keeps only the latest response per sender', () => { const responses: PollResponse[] = [ { sender: '@a:s', ts: 1, answerIds: ['1'] }, { sender: '@a:s', ts: 5, answerIds: ['2'] }, // changed vote { sender: '@a:s', ts: 3, answerIds: ['1'] }, // older, ignored ]; const { counts, total } = tallyResponses(responses, me); assert.equal(counts.get('1'), undefined); assert.equal(counts.get('2'), 1); assert.equal(total, 1); }); test('tallyResponses handles multi-select and reports myVotes', () => { const responses: PollResponse[] = [ { sender: me, ts: 2, answerIds: ['1', '3'] }, { sender: '@b:s', ts: 1, answerIds: ['3'] }, ]; const { counts, myVotes, total } = tallyResponses(responses, me); assert.equal(counts.get('1'), 1); assert.equal(counts.get('3'), 2); assert.deepEqual([...myVotes].sort(), ['1', '3']); assert.equal(total, 2); }); test('tallyResponses: a cleared (empty) latest response removes the voter', () => { const responses: PollResponse[] = [ { sender: '@a:s', ts: 1, answerIds: ['1'] }, { sender: '@a:s', ts: 2, answerIds: [] }, // latest is empty → sender dropped entirely ]; const { counts, total } = tallyResponses(responses, me); assert.equal(counts.get('1'), undefined); assert.equal(total, 0); }); test('tallyResponses: re-voting after an empty clear counts the newest non-empty', () => { const responses: PollResponse[] = [ { sender: '@a:s', ts: 1, answerIds: ['1'] }, { sender: '@a:s', ts: 2, answerIds: [] }, // cleared { sender: '@a:s', ts: 3, answerIds: ['2'] }, // re-voted, newest wins ]; const { counts, total } = tallyResponses(responses, me); assert.equal(counts.get('1'), undefined); assert.equal(counts.get('2'), 1); assert.equal(total, 1); }); test('resultsVisible: disclosed always, undisclosed only when ended', () => { assert.equal(resultsVisible(false, false), true); // disclosed, open assert.equal(resultsVisible(false, true), true); // disclosed, ended assert.equal(resultsVisible(true, false), false); // undisclosed, open → hidden assert.equal(resultsVisible(true, true), true); // undisclosed, ended → revealed }); test('winningAnswerIds returns the single top answer', () => { const counts = new Map([ ['1', 3], ['2', 1], ]); assert.deepEqual(winningAnswerIds(counts), ['1']); }); test('winningAnswerIds returns all tied answers', () => { const counts = new Map([ ['1', 2], ['2', 2], ['3', 1], ]); assert.deepEqual(winningAnswerIds(counts).sort(), ['1', '2']); }); test('winningAnswerIds is empty when there are no votes', () => { assert.deepEqual(winningAnswerIds(new Map()), []); }); // --- wire-format round-trip: exactly what PollCreator + handleVote send (STABLE) --- test('parsePollStart reads the STABLE m.poll format Lotus sends', () => { const content = { 'm.poll': { question: { 'm.text': 'Favorite color?' }, answers: [ { 'm.id': '0', 'm.text': 'Red' }, { 'm.id': '1', 'm.text': 'Blue' }, ], max_selections: 1, kind: 'm.poll.disclosed', }, body: 'Favorite color?\n1. Red\n2. Blue', msgtype: 'm.text', }; const parsed = parsePollStart(content); assert.ok(parsed); assert.equal(parsed!.question, 'Favorite color?'); assert.deepEqual(parsed!.answers, [ { id: '0', text: 'Red' }, { id: '1', text: 'Blue' }, ]); assert.equal(parsed!.maxSelections, 1); assert.equal(parsed!.isUndisclosed, false); }); test('parsePollStart reads an UNSTABLE (Element-authored) poll + defaults kind to undisclosed', () => { const content = { 'org.matrix.msc3381.poll.start': { question: { 'org.matrix.msc1767.text': 'Lunch?' }, answers: [ { id: 'a', 'org.matrix.msc1767.text': 'Pizza' }, { id: 'b', 'org.matrix.msc1767.text': 'Tacos' }, ], max_selections: 2, // no kind → undisclosed per spec }, }; const parsed = parsePollStart(content); assert.ok(parsed); assert.equal(parsed!.question, 'Lunch?'); assert.deepEqual(parsed!.answers, [ { id: 'a', text: 'Pizza' }, { id: 'b', text: 'Tacos' }, ]); assert.equal(parsed!.maxSelections, 2); assert.equal(parsed!.isUndisclosed, true); }); test('parsePollStart returns null for non-poll content', () => { assert.equal(parsePollStart({ body: 'hi', msgtype: 'm.text' }), null); }); test('parseResponseAnswerIds reads stable m.selections and unstable nested answers', () => { assert.deepEqual(parseResponseAnswerIds({ 'm.selections': ['0', '1'] }), ['0', '1']); assert.deepEqual( parseResponseAnswerIds({ 'org.matrix.msc3381.poll.response': { answers: ['a'] } }), ['a'] ); assert.deepEqual(parseResponseAnswerIds({}), []); }); test('validateSelections filters unknown ids, de-dupes, and caps to max_selections', () => { const valid = new Set(['0', '1', '2']); assert.deepEqual(validateSelections(['0', 'x', '1'], valid, 3), ['0', '1']); // drops unknown 'x' assert.deepEqual(validateSelections(['1', '1', '2'], valid, 3), ['1', '2']); // de-dupes assert.deepEqual(validateSelections(['0', '1', '2'], valid, 1), ['0']); // caps to first max_selections assert.deepEqual(validateSelections(['nope'], valid, 3), []); // all invalid → spoiled/empty });