diff --git a/src/voting/rankedChoice.spec.js b/src/voting/rankedChoice.spec.js index 70c3f905c..2bc6c7f31 100644 --- a/src/voting/rankedChoice.spec.js +++ b/src/voting/rankedChoice.spec.js @@ -1,11 +1,13 @@ -import { test, expect } from 'vitest'; -import RankedChoiceVoting from './rankedChoice'; +import { test, expect, describe } from 'vitest'; +import RankedChoiceVoting, { getFinalRound } from './rankedChoice'; import example from './examples/rankedChoice.json'; +const TEST_CHOICES = ['Alice', 'Bob', 'Carol', 'David']; + const example2 = () => { // Example with multiple (3) strategies const proposal = { - choices: ['Alice', 'Bob', 'Carol', 'David'] + choices: TEST_CHOICES }; const strategies = [ { name: 'ticket', network: 1, params: {} }, @@ -153,6 +155,37 @@ test.each(getScoresByStrategyTests)( } ); +test('getScoresByStrategy should handle empty scores array from getFinalRound', () => { + // Using same votes as majority winner test where some choices have no first-place votes + const votes = [ + { choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice + { choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice + { choice: [1, 4, 2, 3], balance: 150, scores: [150] }, // First: Alice + { choice: [2, 1, 3, 4], balance: 50, scores: [50] } // First: Bob + ]; + + const proposal = { choices: TEST_CHOICES }; + const strategies = [{ name: 'ticket', network: 1, params: {} }]; + + const ranked = new RankedChoiceVoting( + proposal, + votes, + strategies, + example.selectedChoice + ); + + // This should not throw an error when reduce encounters empty scores arrays + const result = ranked.getScoresByStrategy(); + + // Expected: Alice gets all her votes (450), Bob gets his (50), Carol and David get 0 + expect(result).toEqual([ + [450], // Alice: 100 + 200 + 150 + [50], // Bob: 50 + [0], // Carol: no first-place votes + [0] // David: no first-place votes + ]); +}); + const getScoresTotalTests = [ [example.proposal, example.votes, example.strategies, example.scoresTotal], [ @@ -178,7 +211,17 @@ test.each(getScoresTotalTests)( test.each([ [[1, 2, 3, 4], '(1st) Alice, (2nd) Bob, (3rd) Carol, (4th) David'], - [[4, 2, 3, 1], '(1st) David, (2nd) Bob, (3rd) Carol, (4th) Alice'] + [[4, 2, 3, 1], '(1st) David, (2nd) Bob, (3rd) Carol, (4th) Alice'], + // Invalid choices (out of range indices) + [[5], ''], // Choice index 5 doesn't exist (only 1-4 available) + [[0], ''], // Choice index 0 is invalid (choices are 1-indexed) + [[-1], ''], // Negative choice index + [[1, 5], '(1st) Alice'], // Mix of valid (1) and invalid (5) - invalid filtered out + [[0, 2], '(1st) Bob'], // Mix of invalid (0) and valid (2) - invalid filtered out + [[5, 6, 7], ''], // All invalid indices - all filtered out + [[1, 0, 3], '(1st) Alice, (2nd) Carol'], // Valid-invalid-valid pattern - invalid filtered out + [[100], ''], // Very high invalid index + [[], ''] // Empty array ])('getChoiceString %s %s', (selected, expected) => { const ranked = new RankedChoiceVoting( example.proposal, @@ -188,3 +231,128 @@ test.each([ ); expect(ranked.getChoiceString()).toEqual(expected); }); + +describe('isValidChoice', () => { + test.each([ + [[1, 2, 3, 4], TEST_CHOICES], + [[4, 3, 2, 1], TEST_CHOICES], + [[2, 1, 4, 3], TEST_CHOICES], + [[1], ['Alice']] + ])('should accept valid ranked choice: %s', (voteChoice, proposalChoices) => { + expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe( + true + ); + }); + + test.each([ + ['not-array', TEST_CHOICES], + [null, TEST_CHOICES], + [undefined, TEST_CHOICES] + ])('should reject non-array input: %s', (voteChoice, proposalChoices) => { + expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe( + false + ); + }); + + test.each([ + [[], TEST_CHOICES], + [[], []] + ])('should reject empty choice array: %s', (voteChoice, proposalChoices) => { + expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe( + false + ); + }); + + test.each([ + [[1, 5], TEST_CHOICES], + [[0, 1, 2, 3, 4], TEST_CHOICES], + [[-1, 1, 2, 3], TEST_CHOICES], + [[100], TEST_CHOICES] + ])( + 'should reject out-of-range indices: %s', + (voteChoice, proposalChoices) => { + expect( + RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices) + ).toBe(false); + } + ); + + test.each([ + [[1, 1, 2, 3], TEST_CHOICES], + [[1, 2, 2, 3], TEST_CHOICES], + [ + [1, 1], + ['Alice', 'Bob'] + ] + ])('should reject duplicate choices: %s', (voteChoice, proposalChoices) => { + expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe( + false + ); + }); + + test.each([ + [[1], TEST_CHOICES], + [[1, 2], TEST_CHOICES] + ])( + 'should reject incomplete ranking when multiple choices available: %s', + (voteChoice, proposalChoices) => { + expect( + RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices) + ).toBe(false); + } + ); +}); + +describe('getFinalRound', () => { + test('should execute instant runoff voting with multiple elimination rounds', () => { + const votes = [ + { choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice + { choice: [2, 1, 3, 4], balance: 200, scores: [200] }, // First: Bob + { choice: [3, 1, 2, 4], balance: 150, scores: [150] }, // First: Carol + { choice: [4, 1, 2, 3], balance: 50, scores: [50] } // First: David + ]; + + const result = getFinalRound(votes); + + expect(result).toEqual([ + ['2', [350, [350]]], // Bob wins with 350 total (200+150 after IRV) + ['3', [150, [150]]] // Carol second with 150 + ]); + }); + + test('should handle choices with no first-place votes', () => { + const votes = [ + { choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice + { choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice + { choice: [2, 1, 3, 4], balance: 150, scores: [150] }, // First: Bob + { choice: [2, 3, 1, 4], balance: 300, scores: [300] } // First: Bob + ]; + + const result = getFinalRound(votes); + + expect(result).toEqual([ + ['2', [450, [450]]], // Bob wins with 450 (150+300) + ['1', [300, [300]]], // Alice second with 300 (100+200) + ['3', [0, []]], // Carol has no first place votes - empty scores array (our fix) + ['4', [0, []]] // David has no first place votes - empty scores array (our fix) + ]); + }); + + test('should declare winner in first round when candidate has majority', () => { + const votes = [ + { choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice + { choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice + { choice: [1, 4, 2, 3], balance: 150, scores: [150] }, // First: Alice + { choice: [2, 1, 3, 4], balance: 50, scores: [50] } // First: Bob + ]; + + const result = getFinalRound(votes); + + expect(result).toEqual([ + ['1', [450, [450]]], // Alice wins with majority in round 1 (100+200+150) + ['2', [50, [50]]], // Bob gets remaining votes + ['3', [0, []]], // Carol has no first place votes + ['4', [0, []]] // David has no first place votes + ]); + }); +}); diff --git a/src/voting/rankedChoice.ts b/src/voting/rankedChoice.ts index 54c23deac..74e13961d 100644 --- a/src/voting/rankedChoice.ts +++ b/src/voting/rankedChoice.ts @@ -66,7 +66,27 @@ function irv( ); } -function getFinalRound( +/** + * Runs the complete Instant Runoff Voting (IRV) algorithm and returns the final results. + * + * Executes all elimination rounds until a winner is determined or fewer than 3 candidates remain. + * Each round eliminates the candidate with the fewest votes and redistributes their votes + * to voters' next preferences. + * + * @param votes - Array of valid ranked choice votes to process + * @returns Array of tuples representing the final candidate rankings, sorted by vote count (highest first). + * Each tuple contains [candidateIndex, [totalBalance, scoresArray]] where: + * - totalBalance: Sum of voting power from all voters who support this candidate + * - scoresArray: Breakdown of that voting power by voting strategy + * The relationship: totalBalance === scoresArray.reduce((a,b) => a + b, 0) + * + * @example + * // Returns final results after IRV elimination rounds + * // [["2", [150, [60,50,40]]], ["1", [120, [70,30,20]]], ...] + * // Candidate 2 wins with 150 total voting power (60+50+40 from 3 strategies) + * // Candidate 1 has 120 total voting power (70+30+20 from 3 strategies) + */ +export function getFinalRound( votes: RankedChoiceVote[] ): [string, [number, number[]]][] { const rounds = irv( @@ -77,7 +97,26 @@ function getFinalRound( return finalRound.sortedByHighest; } -function getScoresMethod( +/** + * Converts IRV final results into a simple array of scores indexed by proposal choice order. + * + * Takes the ranked results from getFinalRound() (sorted by winner) and transforms them + * into an array where each position corresponds to the original proposal choice index. + * This allows easy lookup of any candidate's final vote total by their position in the proposal. + * + * @param votes - Array of valid ranked choice votes to process + * @param proposal - Proposal object containing the choices array + * @returns Array of total voting power for each choice, indexed by proposal order. + * Position 0 = first choice's votes, position 1 = second choice's votes, etc. + * + * @example + * // proposal.choices = ['Alice', 'Bob', 'Carol', 'David'] + * // After IRV: Bob won (150), David 2nd (120), Alice 3rd (100), Carol 4th (80) + * // Returns: [100, 150, 80, 120] + * // ↑ ↑ ↑ ↑ + * // Alice Bob Carol David (proposal order) + */ +function getFinalScoresByChoice( votes: RankedChoiceVote[], proposal: { choices: string[] } ) { @@ -134,7 +173,7 @@ export default class RankedChoiceVoting { } getScores(): number[] { - return getScoresMethod(this.getValidVotes(), this.proposal); + return getFinalScoresByChoice(this.getValidVotes(), this.proposal); } getScoresByStrategy(): number[][] { @@ -148,16 +187,57 @@ export default class RankedChoiceVoting { ); } + /** + * Returns the total voting power from all submitted votes, including invalid ones. + * + * This method sums the balance (voting power) from ALL votes submitted to the proposal, + * regardless of whether they have valid choice arrays. This is useful for calculating + * total participation, quorum requirements, and percentage of total voting power. + * + * Note: This differs from IRV final results which only include valid votes. Invalid votes + * are excluded from IRV calculations but their voting power is still counted here for + * participation metrics. + * + * @returns Total voting power from all votes (valid + invalid) + * + * @example + * // votes = [ + * // { choice: [1,2,3,4], balance: 1000 }, // Valid + * // { choice: [1,5,2], balance: 500 }, // Invalid (index 5) + * // { choice: [2,1,4,3], balance: 750 } // Valid + * // ] + * // Returns: 2250 (includes invalid vote's 500 balance) + */ getScoresTotal(): number { return this.votes.reduce((a, b: any) => a + b.balance, 0); } + /** + * Converts the selected choice indices into a human-readable string representation. + * + * Note: This method supports partial ranking where not all available choices + * need to be selected. The ordinal positions (1st, 2nd, etc.) reflect the + * order of valid selections only. Invalid choice indices are filtered out. + * + * @returns A formatted string showing the ranked choices with ordinal positions. + * Only valid choices are included, invalid indices are silently ignored. + * + * @example + * // With choices ['Alice', 'Bob', 'Carol', 'David'] and selected [1, 3, 2] + * // Returns: "(1st) Alice, (2nd) Carol, (3rd) Bob" + * + * @example + * // Partial ranking with choices ['Alice', 'Bob', 'Carol', 'David'] and selected [4, 1] + * // Returns: "(1st) David, (2nd) Alice" + * + * @example + * // With invalid choice index 5 in selected [1, 5] + * // Returns: "(1st) Alice" - invalid choice 5 is filtered out + */ getChoiceString(): string { return this.selected - .map((choice) => { - if (this.proposal.choices[choice - 1]) - return this.proposal.choices[choice - 1]; - }) + .map((choice) => this.proposal.choices[choice - 1]) + .filter(Boolean) .map((el, i) => `(${getNumberWithOrdinal(i + 1)}) ${el}`) .join(', '); }