Files
cinny/src/app/utils/poll.test.ts
T
jaredandClaude Opus 4.8 0b06158477 feat(polls): complete the poll lifecycle — voting, undisclosed, end, spec-correct
Upgrade the MSC3381 poll feature from a leaky half-implementation to a complete,
cross-client-correct one:

- End/close a poll (creator or redact-PL mod) via inline confirm → m.poll.end;
  locks voting, reveals results, marks winner(s); only pre-end responses count.
- Honor poll kind: undisclosed polls hide counts/percent/bars/total until ended
  (creator gets a Show-live-results vs Hidden-until-ended toggle; default live).
  Previously every poll was created undisclosed yet the UI leaked live results.
- Enforce max_selections for multi-choice; radiogroup/checkbox a11y with arrow-key
  roving and an AT-announced winner.
- Robust, dual-namespace wire handling: parse BOTH stable (m.poll/m.id/m.selections)
  and unstable (org.matrix.msc3381.poll.*) by hand — matrix-js-sdk 41.7.0's
  PollStart/Response parsers only understand the unstable bodies, so delegating to
  them broke every stable poll (caught in agent review). Use the SDK Poll model only
  for end validation + before-end filtering.
- Pure tally/visibility/winner/parse logic extracted to utils/poll.ts with 14 tests
  incl. a stable/unstable wire-format round-trip.

Reviewed by 3 agents (spec/cross-client, logic, a11y/UI); findings applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:48:58 -04:00

171 lines
5.8 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('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
});