Files
cinny/src/app/utils/poll.test.ts
T
jaredandClaude Opus 4.8 85ac8de5d9 style: apply prettier across fork files
check:prettier was not part of my gate routine, so formatting drift accumulated
across the session's touched files (and a few older ones). Run prettier --write
to bring the repo back to 'All matched files use Prettier code style!'.
Formatting only — no logic changes. tsc/tests/build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:36 -04:00

216 lines
7.6 KiB
TypeScript

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('tallyResponses: voters list the senders per answer, consistent with counts', () => {
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, voters } = tallyResponses(responses, me);
assert.deepEqual([...(voters.get('1') ?? [])].sort(), ['@a:s', '@b:s']);
assert.deepEqual(voters.get('2'), ['@c:s']);
// voters must always agree with counts
for (const [id, list] of voters) {
assert.equal(list.length, counts.get(id));
}
});
test('tallyResponses: a re-vote moves the sender to the new answer in voters', () => {
const responses: PollResponse[] = [
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
{ sender: '@a:s', ts: 5, answerIds: ['2'] }, // changed vote
];
const { voters } = tallyResponses(responses, me);
assert.equal(voters.get('1'), undefined); // no longer under the old answer
assert.deepEqual(voters.get('2'), ['@a:s']);
});
test('tallyResponses: a cleared latest response drops the sender from every voter list', () => {
const responses: PollResponse[] = [
{ sender: '@a:s', ts: 1, answerIds: ['1', '2'] },
{ sender: '@a:s', ts: 2, answerIds: [] }, // cleared
];
const { voters } = tallyResponses(responses, me);
assert.equal(voters.get('1'), undefined);
assert.equal(voters.get('2'), undefined);
});
test('tallyResponses: multi-select lists a sender under each chosen answer', () => {
const responses: PollResponse[] = [
{ sender: '@a:s', ts: 1, answerIds: ['1', '3'] },
{ sender: '@b:s', ts: 1, answerIds: ['3'] },
];
const { voters } = tallyResponses(responses, me);
assert.deepEqual(voters.get('1'), ['@a:s']);
assert.deepEqual([...(voters.get('3') ?? [])].sort(), ['@a:s', '@b:s']);
});
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
});