From 37d3cac7d2f9e8aa1dfc2a21f9969e6042ea0ea3 Mon Sep 17 00:00:00 2001
From: Nolan Lawson <nolan@nolanlawson.com>
Date: Mon, 27 May 2019 12:31:35 -0700
Subject: [PATCH] fix: add tests for polls, improve a11y of poll form (#1239)

---
 src/routes/_components/status/StatusPoll.html | 43 +++++----
 tests/serverActions.js                        | 14 +++
 tests/spec/126-polls.js                       | 94 +++++++++++++++++++
 tests/spec/127-compose-polls.js               | 67 +++++++++++++
 tests/utils.js                                | 38 ++++++++
 5 files changed, 236 insertions(+), 20 deletions(-)
 create mode 100644 tests/spec/126-polls.js
 create mode 100644 tests/spec/127-compose-polls.js

diff --git a/src/routes/_components/status/StatusPoll.html b/src/routes/_components/status/StatusPoll.html
index b1a8acca..82483e27 100644
--- a/src/routes/_components/status/StatusPoll.html
+++ b/src/routes/_components/status/StatusPoll.html
@@ -1,6 +1,6 @@
 <div class={computedClass} aria-busy={loading} >
   {#if voted || expired }
-    <ul class="options" aria-label="Poll results">
+    <ul aria-label="Poll results">
       {#each options as option}
         <li class="option">
           <div class="option-text">
@@ -14,19 +14,21 @@
     </ul>
   {:else}
     <form class="poll-form" aria-label="Vote on poll" on:submit="onSubmit(event)" ref:form>
-      {#each options as option, i}
-        <div class="poll-form-option">
-          <input type="{multiple ? 'checkbox' : 'radio'}"
-                 id="poll-choice-{uuid}-{i}"
-                 name="poll-choice-{uuid}"
-                 value="{i}"
-                 on:change="onChange()"
-          >
-          <label for="poll-choice-{uuid}-{i}">
-            {option.title}
-          </label>
-        </div>
-      {/each}
+      <ul aria-label="Poll choices">
+        {#each options as option, i}
+          <li class="poll-form-option">
+            <input type="{multiple ? 'checkbox' : 'radio'}"
+                   id="poll-choice-{uuid}-{i}"
+                   name="poll-choice-{uuid}"
+                   value="{i}"
+                   on:change="onChange()"
+            >
+            <label for="poll-choice-{uuid}-{i}">
+              {option.title}
+            </label>
+          </li>
+        {/each}
+      </ul>
       <button disabled={formDisabled} type="submit">Vote</button>
     </form>
   {/if}
@@ -71,13 +73,18 @@
     pointer-events: none;
   }
 
-  ul.options {
+  ul {
     list-style: none;
     margin: 0;
     padding: 0;
   }
 
-  li.option {
+  li {
+    margin: 0;
+    padding: 0;
+  }
+
+  .option {
     margin: 0 0 10px 0;
     padding: 0;
     display: flex;
@@ -86,10 +93,6 @@
     stroke-width: 10px;
   }
 
-  li.option:last-child {
-    margin: 0;
-  }
-
   .option-text {
     word-wrap: break-word;
     white-space: pre-wrap;
diff --git a/tests/serverActions.js b/tests/serverActions.js
index 34bc7d36..c55e24dd 100644
--- a/tests/serverActions.js
+++ b/tests/serverActions.js
@@ -9,6 +9,8 @@ import { followAccount, unfollowAccount } from '../src/routes/_api/follow'
 import { updateCredentials } from '../src/routes/_api/updateCredentials'
 import { reblogStatus } from '../src/routes/_api/reblog'
 import { submitMedia } from './submitMedia'
+import { voteOnPoll } from '../src/routes/_api/polls'
+import { POLL_EXPIRY_DEFAULT } from '../src/routes/_static/polls'
 
 global.fetch = fetch
 global.File = FileApi.File
@@ -68,3 +70,15 @@ export async function unfollowAs (username, userToFollow) {
 export async function updateUserDisplayNameAs (username, displayName) {
   return updateCredentials(instanceName, users[username].accessToken, { display_name: displayName })
 }
+
+export async function createPollAs (username, content, options, multiple) {
+  return postStatus(instanceName, users[username].accessToken, content, null, null, false, null, 'public', {
+    options,
+    multiple,
+    expires_in: POLL_EXPIRY_DEFAULT
+  })
+}
+
+export async function voteOnPollAs (username, pollId, choices) {
+  return voteOnPoll(instanceName, users[username].accessToken, pollId, choices.map(_ => _.toString()))
+}
diff --git a/tests/spec/126-polls.js b/tests/spec/126-polls.js
new file mode 100644
index 00000000..5df62678
--- /dev/null
+++ b/tests/spec/126-polls.js
@@ -0,0 +1,94 @@
+import {
+  getNthStatusContent,
+  getNthStatusPollOption,
+  getNthStatusPollVoteButton,
+  getNthStatusPollForm,
+  getNthStatusPollResult,
+  sleep,
+  getNthStatusPollRefreshButton,
+  getNthStatusPollVoteCount,
+  getNthStatusRelativeDate, getUrl, goBack
+} from '../utils'
+import { loginAsFoobar } from '../roles'
+import { createPollAs, voteOnPollAs } from '../serverActions'
+
+fixture`126-polls.js`
+  .page`http://localhost:4002`
+
+test('Can vote on polls', async t => {
+  await loginAsFoobar(t)
+  await createPollAs('admin', 'vote on my cool poll', ['yes', 'no'], false)
+  await t
+    .expect(getNthStatusContent(1).innerText).contains('vote on my cool poll')
+    .expect(getNthStatusPollVoteCount(1).innerText).eql('0 votes')
+    .click(getNthStatusPollOption(1, 2))
+    .click(getNthStatusPollVoteButton(1))
+    .expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 })
+    .expect(getNthStatusPollResult(1, 1).innerText).eql('0% yes')
+    .expect(getNthStatusPollResult(1, 2).innerText).eql('100% no')
+    .expect(getNthStatusPollVoteCount(1).innerText).eql('1 vote')
+})
+
+test('Can vote on multiple-choice polls', async t => {
+  await loginAsFoobar(t)
+  await createPollAs('admin', 'vote on my other poll', ['yes', 'no', 'maybe'], true)
+  await t
+    .expect(getNthStatusContent(1).innerText).contains('vote on my other poll')
+    .click(getNthStatusPollOption(1, 1))
+    .click(getNthStatusPollOption(1, 3))
+    .click(getNthStatusPollVoteButton(1))
+    .expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 })
+    .expect(getNthStatusPollResult(1, 1).innerText).eql('50% yes')
+    .expect(getNthStatusPollResult(1, 2).innerText).eql('0% no')
+    .expect(getNthStatusPollResult(1, 3).innerText).eql('50% maybe')
+    .expect(getNthStatusPollVoteCount(1).innerText).eql('2 votes')
+})
+
+test('Can update poll results', async t => {
+  const { poll } = await createPollAs('admin', 'vote on this poll', ['yes', 'no', 'maybe'], false)
+  const { id: pollId } = poll
+  await voteOnPollAs('baz', pollId, [1])
+  await voteOnPollAs('ExternalLinks', pollId, [1])
+  await voteOnPollAs('foobar', pollId, [2])
+  await sleep(1000)
+  await loginAsFoobar(t)
+  await t
+    .expect(getNthStatusContent(1).innerText).contains('vote on this poll')
+    .expect(getNthStatusPollForm(1).exists).notOk()
+    .expect(getNthStatusPollResult(1, 1).innerText).eql('0% yes')
+    .expect(getNthStatusPollResult(1, 2).innerText).eql('67% no')
+    .expect(getNthStatusPollResult(1, 3).innerText).eql('33% maybe')
+    .expect(getNthStatusPollVoteCount(1).innerText).eql('3 votes')
+  await sleep(1000)
+  await voteOnPollAs('quux', pollId, [0])
+  await sleep(1000)
+  await t
+    .click(getNthStatusPollRefreshButton(1))
+    .expect(getNthStatusPollResult(1, 1).innerText).eql('25% yes', { timeout: 20000 })
+    .expect(getNthStatusPollResult(1, 2).innerText).eql('50% no')
+    .expect(getNthStatusPollResult(1, 3).innerText).eql('25% maybe')
+    .expect(getNthStatusPollVoteCount(1).innerText).eql('4 votes')
+})
+
+test('Poll results refresh everywhere', async t => {
+  await loginAsFoobar(t)
+  await createPollAs('admin', 'another poll', ['yes', 'no'], false)
+  await t
+    .expect(getNthStatusContent(1).innerText).contains('another poll')
+    .click(getNthStatusRelativeDate(1))
+    .expect(getUrl()).contains('/statuses')
+    .expect(getNthStatusContent(1).innerText).contains('another poll')
+    .click(getNthStatusPollOption(1, 1))
+    .click(getNthStatusPollVoteButton(1))
+    .expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 })
+    .expect(getNthStatusPollResult(1, 1).innerText).eql('100% yes')
+    .expect(getNthStatusPollResult(1, 2).innerText).eql('0% no')
+    .expect(getNthStatusPollVoteCount(1).innerText).eql('1 vote')
+  await goBack()
+  await t
+    .expect(getUrl()).eql('http://localhost:4002/')
+    .expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 })
+    .expect(getNthStatusPollResult(1, 1).innerText).eql('100% yes')
+    .expect(getNthStatusPollResult(1, 2).innerText).eql('0% no')
+    .expect(getNthStatusPollVoteCount(1).innerText).eql('1 vote')
+})
diff --git a/tests/spec/127-compose-polls.js b/tests/spec/127-compose-polls.js
new file mode 100644
index 00000000..785009db
--- /dev/null
+++ b/tests/spec/127-compose-polls.js
@@ -0,0 +1,67 @@
+import {
+  getNthStatusContent,
+  getNthStatusPollForm,
+  getNthStatusPollResult,
+  getNthStatusPollVoteCount,
+  pollButton,
+  getComposePollNthInput,
+  composePoll,
+  composePollMultipleChoice,
+  composePollExpiry, composePollAddButton, getComposePollRemoveNthButton, postStatusButton, composeInput
+} from '../utils'
+import { loginAsFoobar } from '../roles'
+import { POLL_EXPIRY_DEFAULT } from '../../src/routes/_static/polls'
+
+fixture`127-compose-polls.js`
+  .page`http://localhost:4002`
+
+test('Can add and remove poll', async t => {
+  await loginAsFoobar(t)
+  await t
+    .expect(composePoll.exists).notOk()
+    .expect(pollButton.getAttribute('aria-label')).eql('Add poll')
+    .click(pollButton)
+    .expect(composePoll.exists).ok()
+    .expect(getComposePollNthInput(1).value).eql('')
+    .expect(getComposePollNthInput(2).value).eql('')
+    .expect(getComposePollNthInput(3).exists).notOk()
+    .expect(getComposePollNthInput(4).exists).notOk()
+    .expect(composePollMultipleChoice.checked).notOk()
+    .expect(composePollExpiry.value).eql(POLL_EXPIRY_DEFAULT.toString())
+    .expect(pollButton.getAttribute('aria-label')).eql('Remove poll')
+    .click(pollButton)
+    .expect(composePoll.exists).notOk()
+})
+
+test('Can add and remove poll options', async t => {
+  await loginAsFoobar(t)
+  await t
+    .expect(composePoll.exists).notOk()
+    .expect(pollButton.getAttribute('aria-label')).eql('Add poll')
+    .click(pollButton)
+    .expect(composePoll.exists).ok()
+    .typeText(getComposePollNthInput(1), 'first', { paste: true })
+    .typeText(getComposePollNthInput(2), 'second', { paste: true })
+    .click(composePollAddButton)
+    .typeText(getComposePollNthInput(3), 'third', { paste: true })
+    .expect(getComposePollNthInput(1).value).eql('first')
+    .expect(getComposePollNthInput(2).value).eql('second')
+    .expect(getComposePollNthInput(3).value).eql('third')
+    .expect(getComposePollNthInput(4).exists).notOk()
+    .click(getComposePollRemoveNthButton(2))
+    .expect(getComposePollNthInput(1).value).eql('first')
+    .expect(getComposePollNthInput(2).value).eql('third')
+    .expect(getComposePollNthInput(3).exists).notOk()
+    .expect(getComposePollNthInput(4).exists).notOk()
+    .click(composePollAddButton)
+    .typeText(getComposePollNthInput(3), 'fourth', { paste: true })
+    .typeText(composeInput, 'Vote on my poll!!!', { paste: true })
+    .click(postStatusButton)
+    .expect(getNthStatusContent(1).innerText).contains('Vote on my poll!!!')
+    .expect(getNthStatusPollForm(1).exists).notOk()
+    .expect(getNthStatusPollResult(1, 1).innerText).eql('0% first')
+    .expect(getNthStatusPollResult(1, 2).innerText).eql('0% third')
+    .expect(getNthStatusPollResult(1, 3).innerText).eql('0% fourth')
+    .expect(getNthStatusPollResult(1, 4).exists).notOk()
+    .expect(getNthStatusPollVoteCount(1).innerText).eql('0 votes')
+})
diff --git a/tests/utils.js b/tests/utils.js
index 43e78785..8dc14ccc 100644
--- a/tests/utils.js
+++ b/tests/utils.js
@@ -22,6 +22,7 @@ export const composeButton = $('.compose-box-button')
 export const composeLengthIndicator = $('.compose-box-length')
 export const emojiButton = $('.compose-box-toolbar button:first-child')
 export const mediaButton = $('.compose-box-toolbar button:nth-child(2)')
+export const pollButton = $('.compose-box-toolbar button:nth-child(3)')
 export const postPrivacyButton = $('.compose-box-toolbar button:nth-child(4)')
 export const contentWarningButton = $('.compose-box-toolbar button:nth-child(5)')
 export const emailInput = $('input#user_email')
@@ -58,6 +59,11 @@ export const composeModalContentWarningInput = $('.modal-dialog .content-warning
 export const composeModalEmojiButton = $('.modal-dialog .compose-box-toolbar button:nth-child(1)')
 export const composeModalPostPrivacyButton = $('.modal-dialog .compose-box-toolbar button:nth-child(4)')
 
+export const composePoll = $('.compose-poll')
+export const composePollMultipleChoice = $('.compose-poll input[type="checkbox"]')
+export const composePollExpiry = $('.compose-poll select')
+export const composePollAddButton = $('.compose-poll button:last-of-type')
+
 export const postPrivacyDialogButtonUnlisted = $('[aria-label="Post privacy dialog"] li:nth-child(2) button')
 
 export const accountProfileFilterStatuses = $('.account-profile-filters li:nth-child(1)')
@@ -220,6 +226,38 @@ export function getNthPostPrivacyButton (n) {
   return $(`${getNthStatusSelector(n)} .compose-box-toolbar button:nth-child(4)`)
 }
 
+export function getNthStatusPollOption (n, i) {
+  return $(`${getNthStatusSelector(n)} .poll li:nth-child(${i}) input`)
+}
+
+export function getNthStatusPollVoteButton (n) {
+  return $(`${getNthStatusSelector(n)} .poll button`)
+}
+
+export function getNthStatusPollForm (n) {
+  return $(`${getNthStatusSelector(n)} .poll form`)
+}
+
+export function getNthStatusPollResult (n, i) {
+  return $(`${getNthStatusSelector(n)} .poll li:nth-child(${i})`)
+}
+
+export function getNthStatusPollRefreshButton (n) {
+  return $(`${getNthStatusSelector(n)} button.poll-stat`)
+}
+
+export function getNthStatusPollVoteCount (n) {
+  return $(`${getNthStatusSelector(n)} .poll .poll-stat:nth-child(1) .poll-stat-text`)
+}
+
+export function getComposePollNthInput (n) {
+  return $(`.compose-poll input[type="text"]:nth-of-type(${n})`)
+}
+
+export function getComposePollRemoveNthButton (n) {
+  return $(`.compose-poll button:nth-of-type(${n})`)
+}
+
 export function getNthAutosuggestionResult (n) {
   return $(`.compose-autosuggest-list-item:nth-child(${n})`)
 }