From cbcb05a4bba5f3fdb8f86f770bbe8a08879347a6 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 14 Oct 2024 23:25:52 +0200 Subject: [PATCH] fix: use method of largest remainder to count poll votes Signed-off-by: Maksim Sukharev --- src/components/PollViewer/PollViewer.vue | 17 +++--- src/utils/__tests__/countPollVotes.spec.js | 39 +++++++++++++ src/utils/countPollVotes.ts | 67 ++++++++++++++++++++++ 3 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 src/utils/__tests__/countPollVotes.spec.js create mode 100644 src/utils/countPollVotes.ts diff --git a/src/components/PollViewer/PollViewer.vue b/src/components/PollViewer/PollViewer.vue index 2085a089050..c5cdd9aed7e 100644 --- a/src/components/PollViewer/PollViewer.vue +++ b/src/components/PollViewer/PollViewer.vue @@ -39,7 +39,7 @@

{{ option }}

- {{ getVotePercentage(index) + '%' }} + {{ votePercentage[index] + '%' }}

@@ -109,6 +109,7 @@ import { useIsInCall } from '../../composables/useIsInCall.js' import { POLL } from '../../constants.js' import { EventBus } from '../../services/EventBus.js' import { usePollsStore } from '../../stores/polls.ts' +import { countPollVotes } from '../../utils/countPollVotes.ts' export default { name: 'PollViewer', @@ -222,6 +223,11 @@ export default { canEndPoll() { return this.isPollOpen && this.selfIsOwnerOrModerator }, + + votePercentage() { + const votes = Object.keys(Object(this.poll?.options)).map(index => this.poll?.votes['option-' + index] ?? 0) + return countPollVotes(votes, this.poll.numVoters) + }, }, watch: { @@ -331,13 +337,6 @@ export default { getFilteredDetails(index) { return (this.poll?.details || []).filter(item => item.optionId === index) }, - - getVotePercentage(index) { - if (!this.poll?.votes['option-' + index] || !this.poll?.numVoters) { - return 0 - } - return parseInt(this.poll?.votes['option-' + index] / this.poll?.numVoters * 100) - }, }, } diff --git a/src/utils/__tests__/countPollVotes.spec.js b/src/utils/__tests__/countPollVotes.spec.js new file mode 100644 index 00000000000..def71cdf27c --- /dev/null +++ b/src/utils/__tests__/countPollVotes.spec.js @@ -0,0 +1,39 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { countPollVotes } from '../countPollVotes.ts' + +describe('countPollVotes', () => { + const tests = [ + [0, [], 0], + [1, [1], 100], + // Math rounded to 100% + [4, [1, 3], 100], + [11, [1, 2, 8], 100], + [13, [11, 2], 100], + [13, [9, 4], 100], + [26, [16, 5, 5], 100], + // Rounded to 100% by largest remainder + [1000, [132, 494, 92, 282], 100], + [1000, [135, 480, 97, 288], 100], + // Best effort is 99% + [3, [1, 1, 1], 99], + [7, [2, 2, 3], 99], + [1000, [133, 491, 93, 283], 99], + [1000, [134, 488, 94, 284], 99], + // Best effort is 98% + [1000, [136, 482, 96, 286], 98], + [1000, [135, 140, 345, 95, 285], 98], + // Best effort is 97% + [1000, [137, 132, 347, 97, 287], 97], + ] + + it.each(tests)('test %d votes in %o distribution rounds to %d%%', (total, votes, result) => { + const percentageMap = countPollVotes(votes, total) + + expect(votes.reduce((a, b) => a + b, 0)).toBe(total) + expect(percentageMap.reduce((a, b) => a + b, 0)).toBe(result) + }) +}) diff --git a/src/utils/countPollVotes.ts b/src/utils/countPollVotes.ts new file mode 100644 index 00000000000..b7c508e9d56 --- /dev/null +++ b/src/utils/countPollVotes.ts @@ -0,0 +1,67 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +type VoteAccumulator = { rounded: number[], wholes: number[], remainders: number[] } + +/** + * Finds indexes of largest remainders to distribute quota + * @param array array of numbers to compare + */ +function getLargestIndexes(array: number[]) { + let maxValue = 0 + const maxIndexes: number[] = [] + + for (let i = 0; i < array.length; i++) { + if (array[i] > maxValue) { + maxValue = array[i] + maxIndexes.length = 0 + maxIndexes.push(i) + } else if (array[i] === maxValue) { + maxIndexes.push(i) + } + } + + return maxIndexes +} + +/** + * Provide percentage distribution closest to 100 by method of largest remainder + * @param votes array of given votes + * @param total amount of votes + */ +export function countPollVotes(votes: number[], total: number) { + if (!total) { + return votes + } + + const { rounded, wholes, remainders } = votes.reduce((acc: VoteAccumulator, vote, idx) => { + const quota = vote / total * 100 + acc.rounded[idx] = Math.round(quota) + acc.wholes[idx] = Math.floor(quota) + acc.remainders[idx] = Math.round((quota - Math.floor(quota)) * 1000) + return acc + }, { rounded: [], wholes: [], remainders: [] }) + + // Check if simple round gives 100% + if (rounded.reduce((acc, value) => acc + value) === 100) { + return rounded + } + + // Increase values by largest remainder method if difference allows + for (let i = 100 - wholes.reduce((acc, value) => acc + value); i > 0;) { + const largest = getLargestIndexes(remainders) + if (largest.length > i) { + return wholes + } + + for (const idx of largest) { + wholes[idx]++ + remainders[idx] = 0 + i-- + } + } + + return wholes +}