Skip to content

Commit

Permalink
[BMD] Provide clearer audio feedback after using all votes in a conte…
Browse files Browse the repository at this point in the history
…st (#5547)
  • Loading branch information
kofi-q authored Oct 23, 2024
1 parent e11d374 commit 4ed2678
Show file tree
Hide file tree
Showing 6 changed files with 76 additions and 48 deletions.
83 changes: 49 additions & 34 deletions libs/mark-flow-ui/src/components/candidate_contest.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
CandidateContest as CandidateContestInterface,
CandidateVote,
getCandidateParties,
} from '@votingworks/types';
import { electionGeneralDefinition } from '@votingworks/fixtures';
Expand All @@ -15,6 +16,7 @@ import {
import { VirtualKeyboard, VirtualKeyboardProps } from '@votingworks/ui';
import { screen, within, render } from '../../test/react_testing_library';
import { CandidateContest } from './candidate_contest';
import { UpdateVoteFunction } from '../config/types';

jest.mock('@votingworks/ui', (): typeof import('@votingworks/ui') => ({
...jest.requireActual('@votingworks/ui'),
Expand Down Expand Up @@ -461,40 +463,47 @@ describe('supports write-in candidates', () => {

describe('audio cues', () => {
test('updates the screen reader text to indicate selection state', () => {
const updateVote = jest.fn();
const updateVote: jest.MockedFunction<UpdateVoteFunction> = jest.fn();
const twoSeatContest: CandidateContestInterface = {
...candidateContest,
seats: 2,
};

const { rerender } = render(
<CandidateContest
election={electionDefinition.election}
contest={candidateContest}
contest={twoSeatContest}
vote={[]}
updateVote={updateVote}
/>
);

const candidate = candidateContest.candidates[0];
const firstCandidateChoice = screen
.getByText(candidate.name)
.closest('button')!;
updateVote.mockImplementation((_, votes) => {
rerender(
<CandidateContest
election={electionDefinition.election}
contest={twoSeatContest}
vote={votes as CandidateVote}
updateVote={updateVote}
/>
);
});

const [candidateA, candidateB] = twoSeatContest.candidates;
const firstCandidateChoice = screen.getByRole('option', {
name: new RegExp(candidateA.name),
selected: false,
});

// initially, the candidate is not selected
expect(firstCandidateChoice).toHaveAccessibleName(
expect.stringContaining(candidate.name)
expect.stringContaining(candidateA.name)
);

// select the candidate and manually update the vote
// select the first candidate to update the vote and trigger audio prompt:
userEvent.click(firstCandidateChoice);
rerender(
<CandidateContest
election={electionDefinition.election}
contest={candidateContest}
vote={[candidate]}
updateVote={updateVote}
/>
);

// the candidate is now selected
screen.getByRole('option', {
name: new RegExp(`Selected.+${candidate.name}.+votes remaining.+0`, 'i'),
name: new RegExp(`Selected.+${candidateA.name}.+votes remaining.+1`, 'i'),
selected: true,
});

Expand All @@ -506,32 +515,38 @@ describe('audio cues', () => {

const lastCandidateParty = getCandidateParties(
electionDefinition.election.parties,
candidate
candidateA
).slice(-1)[0];

screen.getByRole('option', {
name: new RegExp(
`^Selected.+${candidate.name}.+${lastCandidateParty.name}$`,
`^Selected.+${candidateA.name}.+${lastCandidateParty.name}$`,
'i'
),
selected: true,
});

// deselect the candidate and manually update the vote
userEvent.click(firstCandidateChoice);
rerender(
<CandidateContest
election={electionDefinition.election}
contest={candidateContest}
vote={[]}
updateVote={updateVote}
/>
);
// select the second candidate:
const secondCandidateChoice = screen.getByRole('option', {
name: new RegExp(candidateB.name),
selected: false,
});

userEvent.click(secondCandidateChoice);

// the candidate is no longer selected
screen.getByRole('option', {
name: new RegExp(
`Deselected.+${candidate.name}.+votes remaining.+1`,
`Selected.+${candidateB.name}.+you've completed your selections`,
'i'
),
selected: true,
});

// deselect the first candidate:
userEvent.click(firstCandidateChoice);
screen.getByRole('option', {
name: new RegExp(
`Deselected.+${candidateA.name}.+votes remaining.+1`,
'i'
),
selected: false,
Expand All @@ -545,7 +560,7 @@ describe('audio cues', () => {
advanceTimers(1);

screen.getByRole('option', {
name: new RegExp(`^${candidate.name}.+${lastCandidateParty.name}$`, 'i'),
name: new RegExp(`^${candidateA.name}.+${lastCandidateParty.name}$`, 'i'),
selected: false,
});
});
Expand Down
25 changes: 12 additions & 13 deletions libs/mark-flow-ui/src/components/candidate_contest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,30 +277,29 @@ export function CandidateContest({
}
let prefixAudioText: ReactNode = null;
let suffixAudioText: ReactNode = null;

const numVotesRemaining = contest.seats - vote.length;
if (isChecked) {
prefixAudioText = appStrings.labelSelected();

if (recentlySelectedCandidate === candidate.id) {
suffixAudioText = (
<React.Fragment>
{appStrings.labelNumVotesRemaining()}{' '}
<NumberString
value={contest.seats - vote.length}
weight="bold"
/>
</React.Fragment>
);
suffixAudioText =
numVotesRemaining > 0 ? (
<React.Fragment>
{appStrings.labelNumVotesRemaining()}{' '}
<NumberString value={numVotesRemaining} weight="bold" />
</React.Fragment>
) : (
appStrings.noteBmdContestCompleted()
);
}
} else if (recentlyDeselectedCandidate === candidate.id) {
prefixAudioText = appStrings.labelDeselected();

suffixAudioText = (
<React.Fragment>
{appStrings.labelNumVotesRemaining()}{' '}
<NumberString
value={contest.seats - vote.length}
weight="bold"
/>
<NumberString value={numVotesRemaining} weight="bold" />
</React.Fragment>
);
}
Expand Down
4 changes: 3 additions & 1 deletion libs/mark-flow-ui/src/components/yes_no_contest.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ test('audio cue for vote', () => {
);

// now the choice is selected
getOption(/Selected.+Ballot Measure 3.+yes/i);
getOption(
/Selected.+Ballot Measure 3.+yes.*you've completed your selections/i
);

// unselect the choice
userEvent.click(yesButton);
Expand Down
3 changes: 3 additions & 0 deletions libs/mark-flow-ui/src/components/yes_no_contest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@ export function YesNoContest({
handleChangeVoteAlert(option.id);
}
let prefixAudioText: ReactNode = null;
let suffixAudioText: ReactNode = null;
if (isChecked) {
prefixAudioText = appStrings.labelSelectedOption();
suffixAudioText = appStrings.noteBmdContestCompleted();
} else if (deselectedVote === option.id) {
prefixAudioText = appStrings.labelDeselectedOption();
}
Expand All @@ -128,6 +130,7 @@ export function YesNoContest({
{electionStrings.contestTitle(contest)} |{' '}
</AudioOnly>
{electionStrings.contestOptionLabel(option)}
<AudioOnly>{suffixAudioText}</AudioOnly>
</React.Fragment>
}
/>
Expand Down
8 changes: 8 additions & 0 deletions libs/ui/src/ui_strings/app_strings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,14 @@ export const appStrings = {
<UiString uiStringKey="noteBmdClearingBallot">Clearing ballot</UiString>
),

noteBmdContestCompleted: () => (
<UiString uiStringKey="noteBmdContestCompleted">
You've completed your selections on this contest. Press the right arrow
button to advance to the next contest. You may continue navigating in this
contest to change your selections."
</UiString>
),

noteBmdEitherNeitherNoSelection: () => (
<UiString uiStringKey="noteBmdEitherNeitherNoSelection">
First, vote "for either" or "against both". Then select your preferred
Expand Down
1 change: 1 addition & 0 deletions libs/ui/src/ui_strings/app_strings_catalog/latest.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@
"noteBmdBallotSheetLoaded": "The ballot sheet has been loaded. You will have a chance to review your selections before reprinting your ballot.",
"noteBmdCastingBallot": "Casting Ballot...",
"noteBmdClearingBallot": "Clearing ballot",
"noteBmdContestCompleted": "You've completed your selections on this contest. Press the right arrow button to advance to the next contest. You may continue navigating in this contest to change your selections.\"",
"noteBmdEitherNeitherNoSelection": "First, vote \"for either\" or \"against both\". Then select your preferred measure.",
"noteBmdEitherNeitherSelectedEither": "You have selected \"for either\". <2>Now select your preferred measure.</2>",
"noteBmdEitherNeitherSelectedEitherAndPreferred": "You have selected \"for either\" and your preferred measure.",
Expand Down

0 comments on commit 4ed2678

Please sign in to comment.