From a17b043725a57ae096fcc4bcbea52d4b11f81d39 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 +++-- .../__tests__/calculateVotePercentage.spec.js | 39 ++++++++++ src/utils/calculateVotePercentage.ts | 72 +++++++++++++++++++ 3 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 src/utils/__tests__/calculateVotePercentage.spec.js create mode 100644 src/utils/calculateVotePercentage.ts diff --git a/src/components/PollViewer/PollViewer.vue b/src/components/PollViewer/PollViewer.vue index 59181712df5..0a8a79da3ee 100644 --- a/src/components/PollViewer/PollViewer.vue +++ b/src/components/PollViewer/PollViewer.vue @@ -39,7 +39,7 @@

{{ option }}

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

@@ -140,6 +140,7 @@ import { POLL } from '../../constants.js' import { hasTalkFeature } from '../../services/CapabilitiesManager.ts' import { EventBus } from '../../services/EventBus.ts' import { usePollsStore } from '../../stores/polls.ts' +import { calculateVotePercentage } from '../../utils/calculateVotePercentage.ts' import { convertToJSONDataURI } from '../../utils/fileDownload.ts' export default { @@ -265,6 +266,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 calculateVotePercentage(votes, this.poll.numVoters) + }, }, watch: { @@ -381,13 +387,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__/calculateVotePercentage.spec.js b/src/utils/__tests__/calculateVotePercentage.spec.js new file mode 100644 index 00000000000..0fd0251c928 --- /dev/null +++ b/src/utils/__tests__/calculateVotePercentage.spec.js @@ -0,0 +1,39 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { calculateVotePercentage } from '../calculateVotePercentage.ts' + +describe('calculateVotePercentage', () => { + 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 = calculateVotePercentage(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/calculateVotePercentage.ts b/src/utils/calculateVotePercentage.ts new file mode 100644 index 00000000000..c934f7f9355 --- /dev/null +++ b/src/utils/calculateVotePercentage.ts @@ -0,0 +1,72 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * 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 calculateVotePercentage(votes: number[], total: number) { + if (!total) { + return votes + } + + const rounded: number[] = [] + const wholes: number[] = [] + const remainders: number[] = [] + let sumRounded = 0 + let sumWholes = 0 + + for (const i in votes) { + const quota = votes[i] / total * 100 + rounded.push(Math.round(quota)) + wholes.push(Math.floor(quota)) + remainders.push(Math.round((quota % 1) * 1000)) + sumRounded += rounded[i] + sumWholes += wholes[i] + } + + // Check if simple round gives 100% + if (sumRounded === 100) { + return rounded + } + + // Increase values by largest remainder method if difference allows + for (let i = 100 - sumWholes; 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 +}