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>
171 lines
5.8 KiB
TypeScript
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
|
|
});
|