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>
216 lines
7.6 KiB
TypeScript
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
|
|
});
|