element-web/test/components/views/messages/MPollBody-test.tsx
Andy Balaam d705fdd6e4
Display and send votes in polls (#7158)
Co-authored-by: Travis Ralston <travpc@gmail.com>
2021-11-23 10:27:44 +00:00

476 lines
18 KiB
TypeScript

/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { mount, ReactWrapper } from "enzyme";
import sdk from "../../../skinned-sdk";
import * as TestUtils from "../../../test-utils";
import { Callback, IContent, MatrixEvent } from "matrix-js-sdk";
import { ISendEventResponse } from "matrix-js-sdk/src/@types/requests";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { IPollAnswer, IPollContent } from "../../../../src/polls/consts";
import { UserVote, allVotes } from "../../../../src/components/views/messages/MPollBody";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
const _MPollBody = sdk.getComponent("views.messages.MPollBody");
const MPollBody = TestUtils.wrapInMatrixClientContext(_MPollBody);
MatrixClientPeg.matrixClient = {
getUserId: () => "@me:example.com",
sendEvent: () => Promise.resolve({ "event_id": "fake_send_id" }),
};
describe("MPollBody", () => {
it("finds no votes if there are none", () => {
expect(allVotes(newPollRelations([]))).toEqual([]);
});
it("can find all the valid responses to a poll", () => {
const ev1 = responseEvent();
const ev2 = responseEvent();
const badEvent = badResponseEvent();
const pollRelations = newPollRelations([ev1, badEvent, ev2]);
expect(allVotes(pollRelations)).toEqual([
new UserVote(
ev1.getTs(),
ev1.getSender(),
ev1.getContent()["org.matrix.msc3381.poll.response"].answers,
),
new UserVote(
ev2.getTs(),
ev2.getSender(),
ev2.getContent()["org.matrix.msc3381.poll.response"].answers,
),
]);
});
it("finds no votes if none were made", () => {
const votes = [];
const body = newMPollBody(votes);
expect(votesCount(body, "pizza")).toBe("0 votes");
expect(votesCount(body, "poutine")).toBe("0 votes");
expect(votesCount(body, "italian")).toBe("0 votes");
expect(votesCount(body, "wings")).toBe("0 votes");
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 0 votes");
});
it("finds votes from multiple people", () => {
const votes = [
responseEvent("@andyb:example.com", "pizza"),
responseEvent("@bellc:example.com", "pizza"),
responseEvent("@catrd:example.com", "poutine"),
responseEvent("@dune2:example.com", "wings"),
];
const body = newMPollBody(votes);
expect(votesCount(body, "pizza")).toBe("2 votes");
expect(votesCount(body, "poutine")).toBe("1 vote");
expect(votesCount(body, "italian")).toBe("0 votes");
expect(votesCount(body, "wings")).toBe("1 vote");
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 4 votes");
});
it("takes someone's most recent vote if they voted several times", () => {
const votes = [
responseEvent("@fiona:example.com", "pizza", 12),
responseEvent("@fiona:example.com", "wings", 20), // latest fiona
responseEvent("@qbert:example.com", "pizza", 14),
responseEvent("@qbert:example.com", "poutine", 16), // latest qbert
responseEvent("@qbert:example.com", "wings", 15),
];
const body = newMPollBody(votes);
expect(votesCount(body, "pizza")).toBe("0 votes");
expect(votesCount(body, "poutine")).toBe("1 vote");
expect(votesCount(body, "italian")).toBe("0 votes");
expect(votesCount(body, "wings")).toBe("1 vote");
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 2 votes");
});
it("uses my local vote", () => {
// Given I haven't voted
const votes = [
responseEvent("@nf:example.com", "pizza", 15),
responseEvent("@fg:example.com", "pizza", 15),
responseEvent("@hi:example.com", "pizza", 15),
];
const body = newMPollBody(votes);
// When I vote for Italian
clickRadio(body, "italian");
// My vote is counted
expect(votesCount(body, "pizza")).toBe("3 votes");
expect(votesCount(body, "poutine")).toBe("0 votes");
expect(votesCount(body, "italian")).toBe("1 vote");
expect(votesCount(body, "wings")).toBe("0 votes");
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 4 votes");
});
it("overrides my other votes with my local vote", () => {
// Given two of us have voted for Italian
const votes = [
responseEvent("@me:example.com", "pizza", 12),
responseEvent("@me:example.com", "poutine", 13),
responseEvent("@me:example.com", "italian", 14),
responseEvent("@nf:example.com", "italian", 15),
];
const body = newMPollBody(votes);
// When I click Wings
clickRadio(body, "wings");
// Then my vote is counted for Wings, and not for Italian
expect(votesCount(body, "pizza")).toBe("0 votes");
expect(votesCount(body, "poutine")).toBe("0 votes");
expect(votesCount(body, "italian")).toBe("1 vote");
expect(votesCount(body, "wings")).toBe("1 vote");
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 2 votes");
});
it("ignores extra answers", () => {
// When cb votes for 2 things, we consider the first only
const votes = [
responseEvent("@cb:example.com", ["pizza", "wings"]),
responseEvent("@da:example.com", "wings"),
];
const body = newMPollBody(votes);
expect(votesCount(body, "pizza")).toBe("1 vote");
expect(votesCount(body, "poutine")).toBe("0 votes");
expect(votesCount(body, "italian")).toBe("0 votes");
expect(votesCount(body, "wings")).toBe("1 vote");
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 2 votes");
});
it("allows un-voting by passing an empty vote", () => {
const votes = [
responseEvent("@nc:example.com", "pizza", 12),
responseEvent("@nc:example.com", [], 13),
responseEvent("@md:example.com", "italian"),
];
const body = newMPollBody(votes);
expect(votesCount(body, "pizza")).toBe("0 votes");
expect(votesCount(body, "poutine")).toBe("0 votes");
expect(votesCount(body, "italian")).toBe("1 vote");
expect(votesCount(body, "wings")).toBe("0 votes");
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 1 vote");
});
it("allows re-voting after un-voting", () => {
const votes = [
responseEvent("@op:example.com", "pizza", 12),
responseEvent("@op:example.com", [], 13),
responseEvent("@op:example.com", "italian", 14),
responseEvent("@qr:example.com", "italian"),
];
const body = newMPollBody(votes);
expect(votesCount(body, "pizza")).toBe("0 votes");
expect(votesCount(body, "poutine")).toBe("0 votes");
expect(votesCount(body, "italian")).toBe("2 votes");
expect(votesCount(body, "wings")).toBe("0 votes");
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 2 votes");
});
it("treats any invalid answer as a spoiled ballot", () => {
// Note that tr's second vote has a valid first answer, but
// the ballot is still spoiled because the second answer is
// invalid, even though we would ignore it if we continued.
const votes = [
responseEvent("@tr:example.com", "pizza", 12),
responseEvent("@tr:example.com", ["pizza", "doesntexist"], 13),
responseEvent("@uy:example.com", "italian", 14),
responseEvent("@uy:example.com", "doesntexist", 15),
];
const body = newMPollBody(votes);
expect(votesCount(body, "pizza")).toBe("0 votes");
expect(votesCount(body, "poutine")).toBe("0 votes");
expect(votesCount(body, "italian")).toBe("0 votes");
expect(votesCount(body, "wings")).toBe("0 votes");
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 0 votes");
});
it("allows re-voting after a spoiled ballot", () => {
const votes = [
responseEvent("@tr:example.com", "pizza", 12),
responseEvent("@tr:example.com", ["pizza", "doesntexist"], 13),
responseEvent("@uy:example.com", "italian", 14),
responseEvent("@uy:example.com", "doesntexist", 15),
responseEvent("@uy:example.com", "poutine", 16),
];
const body = newMPollBody(votes);
expect(votesCount(body, "pizza")).toBe("0 votes");
expect(votesCount(body, "poutine")).toBe("1 vote");
expect(votesCount(body, "italian")).toBe("0 votes");
expect(votesCount(body, "wings")).toBe("0 votes");
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 1 vote");
});
it("renders nothing if poll has no answers", () => {
const answers = [];
const votes = [];
const body = newMPollBody(votes, answers);
expect(body.html()).toBe("");
});
it("renders nothing if poll has more than 20 answers", () => {
const answers = [...Array(21).keys()].map((i) => {
return { "id": `id${i}`, "org.matrix.msc1767.text": `Name ${i}` };
});
const votes = [];
const body = newMPollBody(votes, answers);
expect(body.html()).toBe("");
});
it("sends a vote event when I choose an option", () => {
const receivedEvents = [];
MatrixClientPeg.matrixClient.sendEvent = (
roomId: string,
eventType: string,
content: IContent,
txnId?: string,
callback?: Callback,
): Promise<ISendEventResponse> => {
receivedEvents.push( { roomId, eventType, content, txnId, callback } );
return Promise.resolve({ "event_id": "fake_tracked_send_id" });
};
const votes = [];
const body = newMPollBody(votes);
clickRadio(body, "wings");
expect(receivedEvents).toEqual([
expectedResponseEvent("wings"),
]);
});
it("sends only one vote event when I click several times", () => {
const receivedEvents = [];
MatrixClientPeg.matrixClient.sendEvent = (
roomId: string,
eventType: string,
content: IContent,
txnId?: string,
callback?: Callback,
): Promise<ISendEventResponse> => {
receivedEvents.push( { roomId, eventType, content, txnId, callback } );
return Promise.resolve({ "event_id": "fake_tracked_send_id" });
};
const votes = [];
const body = newMPollBody(votes);
clickRadio(body, "wings");
clickRadio(body, "wings");
clickRadio(body, "wings");
clickRadio(body, "wings");
expect(receivedEvents).toEqual([
expectedResponseEvent("wings"),
]);
});
it("sends several events when I click different options", () => {
const receivedEvents = [];
MatrixClientPeg.matrixClient.sendEvent = (
roomId: string,
eventType: string,
content: IContent,
txnId?: string,
callback?: Callback,
): Promise<ISendEventResponse> => {
receivedEvents.push( { roomId, eventType, content, txnId, callback } );
return Promise.resolve({ "event_id": "fake_tracked_send_id" });
};
const votes = [];
const body = newMPollBody(votes);
clickRadio(body, "wings");
clickRadio(body, "italian");
clickRadio(body, "poutine");
expect(receivedEvents).toEqual([
expectedResponseEvent("wings"),
expectedResponseEvent("italian"),
expectedResponseEvent("poutine"),
]);
});
it("renders a poll with no votes", () => {
const votes = [];
const body = newMPollBody(votes);
expect(body).toMatchSnapshot();
});
it("renders a poll with only non-local votes", () => {
const votes = [
responseEvent("@op:example.com", "pizza", 12),
responseEvent("@op:example.com", [], 13),
responseEvent("@op:example.com", "italian", 14),
responseEvent("@st:example.com", "wings", 15),
responseEvent("@qr:example.com", "italian", 16),
];
const body = newMPollBody(votes);
expect(body).toMatchSnapshot();
});
it("renders a poll with local, non-local and invalid votes", () => {
const votes = [
responseEvent("@a:example.com", "pizza", 12),
responseEvent("@b:example.com", [], 13),
responseEvent("@c:example.com", "italian", 14),
responseEvent("@d:example.com", "italian", 14),
responseEvent("@e:example.com", "wings", 15),
responseEvent("@me:example.com", "italian", 16),
];
const body = newMPollBody(votes);
clickRadio(body, "italian");
expect(body).toMatchSnapshot();
});
});
function newPollRelations(relationEvents: Array<MatrixEvent>): Relations {
const pollRelations = new Relations(
"m.reference", "org.matrix.msc3381.poll.response", null);
for (const ev of relationEvents) {
pollRelations.addEvent(ev);
}
return pollRelations;
}
function newMPollBody(
relationEvents: Array<MatrixEvent>,
answers?: IPollAnswer[],
): ReactWrapper {
const pollRelations = new Relations(
"m.reference", "org.matrix.msc3381.poll.response", null);
for (const ev of relationEvents) {
pollRelations.addEvent(ev);
}
return mount(<MPollBody
mxEvent={new MatrixEvent({
"event_id": "$mypoll",
"room_id": "#myroom:example.com",
"content": newPollStart(answers),
})}
getRelationsForEvent={
(eventId: string, relationType: string, eventType: string) => {
expect(eventId).toBe("$mypoll");
expect(relationType).toBe("m.reference");
expect(eventType).toBe("org.matrix.msc3381.poll.response");
return pollRelations;
}
}
/>);
}
function clickRadio(wrapper: ReactWrapper, value: string) {
wrapper.find(`StyledRadioButton[value="${value}"]`).simulate("click");
}
function votesCount(wrapper: ReactWrapper, value: string): string {
return wrapper.find(
`StyledRadioButton[value="${value}"] .mx_MPollBody_optionVoteCount`,
).text();
}
function newPollStart(answers?: IPollAnswer[]): IPollContent {
if (!answers) {
answers = [
{ "id": "pizza", "org.matrix.msc1767.text": "Pizza" },
{ "id": "poutine", "org.matrix.msc1767.text": "Poutine" },
{ "id": "italian", "org.matrix.msc1767.text": "Italian" },
{ "id": "wings", "org.matrix.msc1767.text": "Wings" },
];
}
return {
"org.matrix.msc3381.poll.start": {
"question": {
"org.matrix.msc1767.text": "What should we order for the party?",
},
"kind": "org.matrix.msc3381.poll.disclosed",
"answers": answers,
},
"org.matrix.msc1767.text": "What should we order for the party?\n" +
"1. Pizza\n2. Poutine\n3. Italian\n4. Wings",
};
}
function badResponseEvent(): MatrixEvent {
return new MatrixEvent(
{
"event_id": nextId(),
"type": "org.matrix.msc3381.poll.response",
"content": {
"m.relates_to": {
"rel_type": "m.reference",
"event_id": "$mypoll",
},
// Does not actually contain a response
},
},
);
}
function responseEvent(
sender = "@alice:example.com",
answers: string | Array<string> = "italian",
ts = 0,
): MatrixEvent {
const ans = typeof answers === "string" ? [answers] : answers;
return new MatrixEvent(
{
"event_id": nextId(),
"room_id": "#myroom:example.com",
"origin_server_ts": ts,
"type": "org.matrix.msc3381.poll.response",
"sender": sender,
"content": {
"m.relates_to": {
"rel_type": "m.reference",
"event_id": "$mypoll",
},
"org.matrix.msc3381.poll.response": {
"answers": ans,
},
},
},
);
}
function expectedResponseEvent(answer: string) {
return {
"content": {
"org.matrix.msc3381.poll.response": {
"answers": [answer],
},
"m.relates_to": {
"event_id": "$mypoll",
"rel_type": "m.reference",
},
},
"eventType": "org.matrix.msc3381.poll.response",
"roomId": "#myroom:example.com",
"txnId": undefined,
"callback": undefined,
};
}
let EVENT_ID = 0;
function nextId(): string {
EVENT_ID++;
return EVENT_ID.toString();
}