From 8f32e747bee09de772eb6fd46935009515db2d74 Mon Sep 17 00:00:00 2001 From: Lionqueen94 Date: Fri, 30 Jan 2026 18:37:30 +0100 Subject: [PATCH 1/6] Made sure apportionment works with non-consecutive list numbers --- .../src/candidate_nomination/mod.rs | 61 +++-- .../apportionment/src/seat_assignment/mod.rs | 247 +++++++++++++----- .../src/seat_assignment/structs.rs | 2 +- backend/apportionment/src/structs.rs | 11 +- backend/apportionment/src/test_helpers.rs | 57 +++- 5 files changed, 281 insertions(+), 97 deletions(-) diff --git a/backend/apportionment/src/candidate_nomination/mod.rs b/backend/apportionment/src/candidate_nomination/mod.rs index f85f62990..180d511fa 100644 --- a/backend/apportionment/src/candidate_nomination/mod.rs +++ b/backend/apportionment/src/candidate_nomination/mod.rs @@ -3,13 +3,13 @@ mod structs; use tracing::{debug, info}; use self::structs::{Candidate, ListCandidateNomination, PreferenceThreshold}; -pub use structs::CandidateNominationResult; - use super::{ ApportionmentError, ApportionmentInput, CandidateVotesTrait, ListVotesTrait, fraction::Fraction, structs::{CandidateNominationInputType, CandidateNumber, LARGE_COUNCIL_THRESHOLD}, }; +use crate::structs::ListNumber; +pub use structs::CandidateNominationResult; /// Candidate nomination pub(crate) fn candidate_nomination<'a, T: ApportionmentInput>( @@ -97,19 +97,22 @@ fn candidate_nomination_per_list<'a, T: ListVotesTrait>( seats: u32, list_votes: &'a [T], preference_threshold: Fraction, - total_seats: &[u32], + total_seats: &[(ListNumber, u32)], ) -> Result>, ApportionmentError> { let mut list_candidate_nomination: Vec> = vec![]; - for (index, list) in list_votes.iter().enumerate() { - let list_seats = total_seats[index]; + for list in list_votes { + let (list_number, list_seats) = total_seats + .iter() + .find(|(number, _)| *number == list.number()) + .expect("Total seats exists"); let candidate_votes = list.candidate_votes(); let candidate_votes_meeting_preference_threshold = candidate_votes_meeting_preference_threshold(preference_threshold, candidate_votes); let preferential_candidate_nomination = preferential_candidate_nomination::( &candidate_votes_meeting_preference_threshold, - list_seats, + *list_seats, )?; - let non_assigned_seats = list_seats as usize - preferential_candidate_nomination.len(); + let non_assigned_seats = *list_seats as usize - preferential_candidate_nomination.len(); // [Artikel P 17 Kieswet](https://wetten.overheid.nl/BWBR0004627/2026-01-01/#AfdelingII_HoofdstukP_Paragraaf3_ArtikelP17) let other_candidate_nomination = other_candidate_nomination( @@ -121,7 +124,7 @@ fn candidate_nomination_per_list<'a, T: ListVotesTrait>( // [Artikel P 19 Kieswet](https://wetten.overheid.nl/BWBR0004627/2026-01-01/#AfdelingII_HoofdstukP_Paragraaf3_ArtikelP19) let updated_candidate_ranking: Vec = if candidate_votes_meeting_preference_threshold.is_empty() - || (seats >= LARGE_COUNCIL_THRESHOLD && list_seats == 0) + || (seats >= LARGE_COUNCIL_THRESHOLD && *list_seats == 0) { vec![] } else { @@ -142,9 +145,9 @@ fn candidate_nomination_per_list<'a, T: ListVotesTrait>( } }; - list_candidate_nomination.push(ListCandidateNomination:: { - list_number: list.number(), - list_seats, + list_candidate_nomination.push(ListCandidateNomination { + list_number: *list_number, + list_seats: *list_seats, preferential_candidate_nomination, other_candidate_nomination, updated_candidate_ranking, @@ -258,6 +261,11 @@ fn update_candidate_ranking( mod tests { use test_log::test; + use crate::test_helpers::{ + candidate_nomination_fixture_with_given_list_numbers_and_number_of_seats, + seat_assignment_fixture_with_given_candidate_votes, + seat_assignment_fixture_with_given_list_numbers_and_candidate_votes, + }; use crate::{ ApportionmentError, CandidateVotesTrait, candidate_nomination::{ @@ -353,22 +361,29 @@ mod tests { #[test] fn test_with_lt_19_seats_and_preferential_candidate_nomination_and_updated_candidate_ranking() { let quota = Fraction::new(5104, 15); - let seat_assignment_input = seat_assignment_fixture_with_given_candidate_votes( - 15, - vec![ - vec![1069, 303, 321, 210, 36, 101, 79, 121, 150, 149, 15, 17], + let seat_assignment_input = + seat_assignment_fixture_with_given_list_numbers_and_candidate_votes( + 15, vec![ - 452, 39, 81, 76, 35, 109, 29, 25, 17, 6, 18, 9, 25, 30, 5, 18, 3, + ( + 1, + vec![1069, 303, 321, 210, 36, 101, 79, 121, 150, 149, 15, 17], + ), + ( + 2, + vec![ + 452, 39, 81, 76, 35, 109, 29, 25, 17, 6, 18, 9, 25, 30, 5, 18, 3, + ], + ), + (4, vec![229, 63, 65, 9, 10, 58, 29, 50, 6, 11, 37]), + (5, vec![347, 33, 14, 82, 30, 30]), + (7, vec![266, 36, 39, 36, 38, 38]), ], - vec![229, 63, 65, 9, 10, 58, 29, 50, 6, 11, 37], - vec![347, 33, 14, 82, 30, 30], - vec![266, 36, 39, 36, 38, 38], - ], - ); - let input = candidate_nomination_fixture_with_given_number_of_seats( + ); + let input = candidate_nomination_fixture_with_given_list_numbers_and_number_of_seats( quota, &seat_assignment_input, - vec![8, 3, 2, 1, 1], + vec![(1, 8), (2, 3), (4, 2), (5, 1), (7, 1)], ); let result = candidate_nomination::(&input).unwrap(); diff --git a/backend/apportionment/src/seat_assignment/mod.rs b/backend/apportionment/src/seat_assignment/mod.rs index c10a54def..940b5e2a7 100644 --- a/backend/apportionment/src/seat_assignment/mod.rs +++ b/backend/apportionment/src/seat_assignment/mod.rs @@ -111,11 +111,13 @@ pub(crate) fn seat_assignment( }) } -pub fn get_total_seats_from_apportionment_result(result: &SeatAssignmentResult) -> Vec { +pub fn get_total_seats_per_list_number_from_apportionment_result( + result: &SeatAssignmentResult, +) -> Vec<(ListNumber, u32)> { result .final_standing .iter() - .map(|p| p.total_seats) + .map(|p| (p.list_number, p.total_seats)) .collect::>() } @@ -279,6 +281,7 @@ fn reassign_residual_seats_for_exhausted_lists( #[cfg(test)] pub(crate) mod tests { use crate::{ + SeatAssignmentResult, fraction::Fraction, seat_assignment::{ListStanding, SeatChange, list_numbers}, structs::ListNumber, @@ -292,6 +295,25 @@ pub(crate) mod tests { } } + fn check_total_seats_per_list( + result: &SeatAssignmentResult, + expected_total_seats: Vec<(u32, u32)>, + ) { + let total_seats_per_list_number = result + .final_standing + .iter() + .map(|p| (p.list_number, p.total_seats)) + .collect::>(); + let expected_total_seats_per_list_number: Vec<(ListNumber, u32)> = expected_total_seats + .iter() + .map(|(number, seats)| (ListNumber::from(*number), *seats)) + .collect(); + assert_eq!( + expected_total_seats_per_list_number, + total_seats_per_list_number + ); + } + #[test] fn test_list_numbers() { let standing = [ @@ -348,10 +370,9 @@ pub(crate) mod tests { mod lt_19_seats { use test_log::test; + use super::check_total_seats_per_list; use crate::{ - ApportionmentError, - seat_assignment::{get_total_seats_from_apportionment_result, seat_assignment}, - structs::ListNumber, + ApportionmentError, seat_assignment::seat_assignment, structs::ListNumber, test_helpers::seat_assignment_fixture_with_default_50_candidates, }; @@ -368,8 +389,10 @@ pub(crate) mod tests { assert_eq!(result.full_seats, 15); assert_eq!(result.residual_seats, 0); assert_eq!(result.steps.len(), 0); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![6, 2, 2, 2, 1, 1, 1]); + check_total_seats_per_list( + &result, + vec![(1, 6), (2, 2), (3, 2), (4, 2), (5, 1), (6, 1), (7, 1)], + ); } /// Apportionment with residual seats assigned with largest remainders method @@ -396,8 +419,19 @@ pub(crate) mod tests { result.steps[1].change.list_number_assigned(), ListNumber::from(7) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![7, 2, 2, 1, 1, 1, 1, 0]); + check_total_seats_per_list( + &result, + vec![ + (1, 7), + (2, 2), + (3, 2), + (4, 1), + (5, 1), + (6, 1), + (7, 1), + (8, 0), + ], + ); } /// Apportionment with residual seats assigned with largest remainders and highest averages methods @@ -440,8 +474,19 @@ pub(crate) mod tests { result.steps[4].change.list_number_assigned(), ListNumber::from(4) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![12, 1, 1, 1, 0, 0, 0, 0]); + check_total_seats_per_list( + &result, + vec![ + (1, 12), + (2, 1), + (3, 1), + (4, 1), + (5, 0), + (6, 0), + (7, 0), + (8, 0), + ], + ); } /// Apportionment with residual seats assigned with largest remainders method @@ -473,8 +518,19 @@ pub(crate) mod tests { result.steps[2].change.list_number_assigned(), ListNumber::from(3) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![7, 4, 4, 0, 0, 0, 0, 0]); + check_total_seats_per_list( + &result, + vec![ + (1, 7), + (2, 4), + (3, 4), + (4, 0), + (5, 0), + (6, 0), + (7, 0), + (8, 0), + ], + ); } /// Apportionment with residual seats assigned with highest averages method @@ -507,8 +563,21 @@ pub(crate) mod tests { result.steps[2].change.list_number_assigned(), ListNumber::from(3) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![1, 1, 1, 0, 0, 0, 0, 0, 0, 0]); + check_total_seats_per_list( + &result, + vec![ + (1, 1), + (2, 1), + (3, 1), + (4, 0), + (5, 0), + (6, 0), + (7, 0), + (8, 0), + (9, 0), + (10, 0), + ], + ); } /// Apportionment with residual seats assigned with largest remainders and highest averages methods @@ -539,8 +608,10 @@ pub(crate) mod tests { result.steps[2].change.list_number_assigned(), ListNumber::from(5) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, [0, 0, 0, 0, 1, 9]); + check_total_seats_per_list( + &result, + vec![(1, 0), (2, 0), (3, 0), (4, 0), (5, 1), (6, 9)], + ); } /// Apportionment with 0 votes on candidates @@ -597,8 +668,7 @@ pub(crate) mod tests { result.steps[3].change.list_number_assigned(), ListNumber::from(1) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![8, 3, 2, 1, 1]); + check_total_seats_per_list(&result, vec![(1, 8), (2, 3), (3, 2), (4, 1), (5, 1)]); } mod drawing_of_lots { @@ -663,14 +733,18 @@ pub(crate) mod tests { mod list_exhaustion { use test_log::test; + use crate::seat_assignment::tests::check_total_seats_per_list; use crate::{ ApportionmentError, - seat_assignment::{get_total_seats_from_apportionment_result, seat_assignment}, + seat_assignment::seat_assignment, structs::ListNumber, - test_helpers::seat_assignment_fixture_with_given_candidate_votes, + test_helpers::{ + seat_assignment_fixture_with_given_candidate_votes, + seat_assignment_fixture_with_given_list_numbers_and_candidate_votes, + }, }; - /// Apportionment with no residual seats + /// Apportionment with no residual seats /// This test triggers Kieswet Article P 10 /// /// Full seats: [5, 4, 3, 2, 1] - Remainder seats: 0 @@ -680,14 +754,14 @@ pub(crate) mod tests { /// 2 - largest remainder: seat assigned to list 5 #[test] fn test_with_list_exhaustion_during_full_seats_assignment() { - let input = seat_assignment_fixture_with_given_candidate_votes( + let input = seat_assignment_fixture_with_given_list_numbers_and_candidate_votes( 15, vec![ - vec![500, 500, 500, 500], - vec![400, 400, 400, 400], - vec![400, 400, 400], - vec![400, 400], - vec![200, 200], + (1, vec![500, 500, 500, 500]), + (2, vec![400, 400, 400, 400]), + (4, vec![400, 400, 400]), + (5, vec![400, 400]), + (7, vec![200, 200]), ], ); let result = seat_assignment(&input).unwrap(); @@ -705,10 +779,9 @@ pub(crate) mod tests { ); assert_eq!( result.steps[1].change.list_number_assigned(), - ListNumber::from(5) + ListNumber::from(7) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![4, 4, 3, 2, 2]); + check_total_seats_per_list(&result, vec![(1, 4), (2, 4), (4, 3), (5, 2), (7, 2)]); } /// Apportionment with residual seats assigned with largest remainders method @@ -785,8 +858,22 @@ pub(crate) mod tests { result.steps[7].change.list_number_assigned(), ListNumber::from(6) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![3, 1, 2, 2, 1, 2, 1, 0, 3, 1, 1]); + check_total_seats_per_list( + &result, + vec![ + (1, 3), + (2, 1), + (3, 2), + (4, 2), + (5, 1), + (6, 2), + (7, 1), + (8, 0), + (9, 3), + (10, 1), + (11, 1), + ], + ); } /// Apportionment with residual seats assigned with largest remainders and highest averages methods @@ -843,8 +930,10 @@ pub(crate) mod tests { result.steps[4].change.list_number_assigned(), ListNumber::from(4) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, [0, 0, 0, 1, 1, 8]); + check_total_seats_per_list( + &result, + vec![(1, 0), (2, 0), (3, 0), (4, 1), (5, 1), (6, 8)], + ); } /// Apportionment with residual seats assigned with largest remainders and highest averages methods @@ -933,8 +1022,7 @@ pub(crate) mod tests { result.steps[8].change.list_number_assigned(), ListNumber::from(2) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, [2, 2, 2]); + check_total_seats_per_list(&result, vec![(1, 2), (2, 2), (3, 2)]); } /// Apportionment with residual seats assigned with largest remainders and highest averages methods @@ -1023,8 +1111,7 @@ pub(crate) mod tests { result.steps[8].change.list_number_assigned(), ListNumber::from(2) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, [2, 2, 2]); + check_total_seats_per_list(&result, vec![(1, 2), (2, 2), (3, 2)]); } /// Apportionment with residual seats assigned with largest remainders method @@ -1093,8 +1180,7 @@ pub(crate) mod tests { result.steps[5].change.list_number_assigned(), ListNumber::from(4) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![7, 3, 2, 2, 1]); + check_total_seats_per_list(&result, vec![(1, 7), (2, 3), (3, 2), (4, 2), (5, 1)]); } /// Apportionment with residual seats assigned with largest remainders and highest averages methods @@ -1165,8 +1251,7 @@ pub(crate) mod tests { result.steps[5].change.list_number_assigned(), ListNumber::from(3) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, [4, 3, 1]); + check_total_seats_per_list(&result, vec![(1, 4), (2, 3), (3, 1)]); } /// Apportionment with residual seats assigned with largest remainders and highest averages methods @@ -1253,8 +1338,7 @@ pub(crate) mod tests { result.steps[7].change.list_number_assigned(), ListNumber::from(2) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, [2, 1, 5]); + check_total_seats_per_list(&result, vec![(1, 2), (2, 1), (3, 5)]); } /// Apportionment with no residual seats @@ -1314,10 +1398,9 @@ pub(crate) mod tests { mod gte_19_seats { use test_log::test; + use super::check_total_seats_per_list; use crate::{ - ApportionmentError, - seat_assignment::{get_total_seats_from_apportionment_result, seat_assignment}, - structs::ListNumber, + ApportionmentError, seat_assignment::seat_assignment, structs::ListNumber, test_helpers::seat_assignment_fixture_with_default_50_candidates, }; @@ -1334,8 +1417,10 @@ pub(crate) mod tests { assert_eq!(result.full_seats, 25); assert_eq!(result.residual_seats, 0); assert_eq!(result.steps.len(), 0); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![12, 6, 2, 2, 2, 1]); + check_total_seats_per_list( + &result, + vec![(1, 12), (2, 6), (3, 2), (4, 2), (5, 2), (6, 1)], + ); } /// Apportionment with residual seats assigned with highest averages method @@ -1369,8 +1454,7 @@ pub(crate) mod tests { result.steps[3].change.list_number_assigned(), ListNumber::from(4) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![12, 6, 1, 2, 2]); + check_total_seats_per_list(&result, vec![(1, 12), (2, 6), (3, 1), (4, 2), (5, 2)]); } /// Apportionment with residual seats assigned with highest averages method @@ -1421,8 +1505,20 @@ pub(crate) mod tests { result.steps[6].change.list_number_assigned(), ListNumber::from(1) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![15, 1, 1, 1, 1, 0, 0, 0, 0]); + check_total_seats_per_list( + &result, + vec![ + (1, 15), + (2, 1), + (3, 1), + (4, 1), + (5, 1), + (6, 0), + (7, 0), + (8, 0), + (9, 0), + ], + ); } /// Apportionment with 0 votes on candidates @@ -1493,8 +1589,19 @@ pub(crate) mod tests { result.steps[6].change.list_number_assigned(), ListNumber::from(1) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![13, 2, 2, 2, 2, 2, 1, 0]); + check_total_seats_per_list( + &result, + vec![ + (1, 13), + (2, 2), + (3, 2), + (4, 2), + (5, 2), + (6, 2), + (7, 1), + (8, 0), + ], + ); } mod drawing_of_lots { @@ -1545,14 +1652,13 @@ pub(crate) mod tests { mod list_exhaustion { use test_log::test; + use crate::seat_assignment::tests::check_total_seats_per_list; use crate::{ - ApportionmentError, - seat_assignment::{get_total_seats_from_apportionment_result, seat_assignment}, - structs::ListNumber, + ApportionmentError, seat_assignment::seat_assignment, structs::ListNumber, test_helpers::seat_assignment_fixture_with_given_candidate_votes, }; - /// Apportionment with no residual seats + /// Apportionment with no residual seats /// This test triggers Kieswet Article P 10 /// /// Full seats: [5, 5, 4, 4, 2] - Remainder seats: 0 @@ -1588,8 +1694,7 @@ pub(crate) mod tests { result.steps[1].change.list_number_assigned(), ListNumber::from(5) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![4, 5, 4, 4, 3]); + check_total_seats_per_list(&result, vec![(1, 4), (2, 5), (3, 4), (4, 4), (5, 3)]); } /// Apportionment with residual seats assigned with highest averages method @@ -1633,8 +1738,7 @@ pub(crate) mod tests { result.steps[2].change.list_number_assigned(), ListNumber::from(1) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![5, 4, 4, 4, 2]); + check_total_seats_per_list(&result, vec![(1, 5), (2, 4), (3, 4), (4, 4), (5, 2)]); } /// Apportionment with residual seats assigned with highest averages method @@ -1720,8 +1824,19 @@ pub(crate) mod tests { result.steps[8].change.list_number_assigned(), ListNumber::from(7) ); - let total_seats = get_total_seats_from_apportionment_result(&result); - assert_eq!(total_seats, vec![12, 2, 2, 2, 2, 2, 2, 0]); + check_total_seats_per_list( + &result, + vec![ + (1, 12), + (2, 2), + (3, 2), + (4, 2), + (5, 2), + (6, 2), + (7, 2), + (8, 0), + ], + ); } /// Apportionment with no residual seats diff --git a/backend/apportionment/src/seat_assignment/structs.rs b/backend/apportionment/src/seat_assignment/structs.rs index ced0cc46f..024ee9fd9 100644 --- a/backend/apportionment/src/seat_assignment/structs.rs +++ b/backend/apportionment/src/seat_assignment/structs.rs @@ -20,7 +20,7 @@ pub struct SeatAssignmentResult { #[derive(Debug, PartialEq)] pub struct ListSeatAssignment { /// List number for which this assignment applies - list_number: ListNumber, + pub list_number: ListNumber, /// The number of votes cast for this group votes_cast: u64, /// The remainder votes that were not used to get full seats assigned to this list diff --git a/backend/apportionment/src/structs.rs b/backend/apportionment/src/structs.rs index 48a8f11e6..2b1982e64 100644 --- a/backend/apportionment/src/structs.rs +++ b/backend/apportionment/src/structs.rs @@ -2,7 +2,9 @@ use super::{ candidate_nomination::CandidateNominationResult, fraction::Fraction, int_newtype_macro::int_newtype, - seat_assignment::{SeatAssignmentResult, get_total_seats_from_apportionment_result}, + seat_assignment::{ + SeatAssignmentResult, get_total_seats_per_list_number_from_apportionment_result, + }, }; use std::fmt::Debug; @@ -51,8 +53,7 @@ pub(crate) struct CandidateNominationInput<'a, L: ListVotesTrait> { pub number_of_seats: u32, pub list_votes: &'a [L], pub quota: Fraction, - // TODO: #2785 Should be mapped by ListNumber, not index - pub total_seats_per_list: Vec, + pub total_seats_per_list: Vec<(ListNumber, u32)>, } pub(crate) type CandidateNominationInputType<'a, T> = @@ -66,6 +67,8 @@ pub(crate) fn as_candidate_nomination_input<'a, T: ApportionmentInput>( number_of_seats: input.number_of_seats(), list_votes: input.list_votes(), quota: seat_assignment.quota, - total_seats_per_list: get_total_seats_from_apportionment_result(seat_assignment), + total_seats_per_list: get_total_seats_per_list_number_from_apportionment_result( + seat_assignment, + ), } } diff --git a/backend/apportionment/src/test_helpers.rs b/backend/apportionment/src/test_helpers.rs index 14fc73b8a..c060aca3d 100644 --- a/backend/apportionment/src/test_helpers.rs +++ b/backend/apportionment/src/test_helpers.rs @@ -84,6 +84,8 @@ impl ListVotesMock { } } +/// Create a CandidateNominationInput with consecutive list numbers and +/// given quota, number of seats, candidate votes and total seats per list. pub fn candidate_nomination_fixture_with_given_number_of_seats( quota: Fraction, seat_assignment_input: &ApportionmentInputMock, @@ -93,11 +95,35 @@ pub fn candidate_nomination_fixture_with_given_number_of_seats( number_of_seats: seat_assignment_input.number_of_seats, list_votes: &seat_assignment_input.list_votes, quota, - total_seats_per_list, + total_seats_per_list: total_seats_per_list + .iter() + .enumerate() + .map(|(list_index, total_seats)| { + (ListNumber::try_from(list_index + 1).unwrap(), *total_seats) + }) + .collect(), } } -/// Create a ApportionmentInputMock with given total votes and list votes. +/// Create a CandidateNominationInput with given quota, number of seats, +/// candidate votes per list number and total seats per list number. +pub fn candidate_nomination_fixture_with_given_list_numbers_and_number_of_seats( + quota: Fraction, + seat_assignment_input: &ApportionmentInputMock, + total_seats_per_list_number: Vec<(u32, u32)>, +) -> CandidateNominationInput<'_, ListVotesMock> { + CandidateNominationInput { + number_of_seats: seat_assignment_input.number_of_seats, + list_votes: &seat_assignment_input.list_votes, + quota, + total_seats_per_list: total_seats_per_list_number + .iter() + .map(|(number, total_seats)| (ListNumber::from(*number), *total_seats)) + .collect(), + } +} + +/// Create a ApportionmentInputMock with consecutive list numbers and given total votes and list votes. pub fn seat_assignment_fixture_with_default_50_candidates( number_of_seats: u32, list_vote_counts: Vec, @@ -123,7 +149,32 @@ pub fn seat_assignment_fixture_with_default_50_candidates( } } -/// Create a ApportionmentInputMock with given votes per list. +/// Create a ApportionmentInputMock with given total votes and list numbers and votes. +pub fn seat_assignment_fixture_with_given_list_numbers_and_candidate_votes( + number_of_seats: u32, + list_candidate_votes: Vec<(u32, Vec)>, +) -> ApportionmentInputMock { + let total_votes = list_candidate_votes + .iter() + .map(|(_, candidate_votes)| candidate_votes.iter().sum::()) + .sum(); + + let mut list_votes: Vec = vec![]; + for (list_number, list_candidate_votes) in list_candidate_votes.iter() { + list_votes.push(ListVotesMock::from_test_data_auto( + ListNumber::from(*list_number), + list_candidate_votes, + )) + } + + ApportionmentInputMock { + number_of_seats, + total_votes, + list_votes, + } +} + +/// Create a ApportionmentInputMock with consecutive list numbers and given votes per list. /// The number of lists is the length of the `list_votes` vector. /// The number of candidates in each list is by default 50. pub fn seat_assignment_fixture_with_given_candidate_votes( From 122bf10d9a5c898a782ffe10539ea01cfca486b6 Mon Sep 17 00:00:00 2001 From: Lionqueen94 Date: Mon, 2 Feb 2026 10:46:29 +0100 Subject: [PATCH 2/6] Prevent dereferencing --- .../apportionment/src/candidate_nomination/mod.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/backend/apportionment/src/candidate_nomination/mod.rs b/backend/apportionment/src/candidate_nomination/mod.rs index 180d511fa..2bbe83d0a 100644 --- a/backend/apportionment/src/candidate_nomination/mod.rs +++ b/backend/apportionment/src/candidate_nomination/mod.rs @@ -104,15 +104,16 @@ fn candidate_nomination_per_list<'a, T: ListVotesTrait>( let (list_number, list_seats) = total_seats .iter() .find(|(number, _)| *number == list.number()) - .expect("Total seats exists"); - let candidate_votes = list.candidate_votes(); + .expect("Total seats exists") + .to_owned(); + let candidate_votes = &list.candidate_votes(); let candidate_votes_meeting_preference_threshold = candidate_votes_meeting_preference_threshold(preference_threshold, candidate_votes); let preferential_candidate_nomination = preferential_candidate_nomination::( &candidate_votes_meeting_preference_threshold, - *list_seats, + list_seats, )?; - let non_assigned_seats = *list_seats as usize - preferential_candidate_nomination.len(); + let non_assigned_seats = list_seats as usize - preferential_candidate_nomination.len(); // [Artikel P 17 Kieswet](https://wetten.overheid.nl/BWBR0004627/2026-01-01/#AfdelingII_HoofdstukP_Paragraaf3_ArtikelP17) let other_candidate_nomination = other_candidate_nomination( @@ -124,7 +125,7 @@ fn candidate_nomination_per_list<'a, T: ListVotesTrait>( // [Artikel P 19 Kieswet](https://wetten.overheid.nl/BWBR0004627/2026-01-01/#AfdelingII_HoofdstukP_Paragraaf3_ArtikelP19) let updated_candidate_ranking: Vec = if candidate_votes_meeting_preference_threshold.is_empty() - || (seats >= LARGE_COUNCIL_THRESHOLD && *list_seats == 0) + || (seats >= LARGE_COUNCIL_THRESHOLD && list_seats == 0) { vec![] } else { @@ -146,8 +147,8 @@ fn candidate_nomination_per_list<'a, T: ListVotesTrait>( }; list_candidate_nomination.push(ListCandidateNomination { - list_number: *list_number, - list_seats: *list_seats, + list_number, + list_seats, preferential_candidate_nomination, other_candidate_nomination, updated_candidate_ranking, From b6c6e3767f22683d1f0fbce885d1863d17d086b2 Mon Sep 17 00:00:00 2001 From: Lionqueen94 Date: Thu, 5 Feb 2026 15:38:12 +0100 Subject: [PATCH 3/6] Re-added removed spaces in docstrings --- backend/apportionment/src/seat_assignment/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/apportionment/src/seat_assignment/mod.rs b/backend/apportionment/src/seat_assignment/mod.rs index 940b5e2a7..baae5d474 100644 --- a/backend/apportionment/src/seat_assignment/mod.rs +++ b/backend/apportionment/src/seat_assignment/mod.rs @@ -733,10 +733,9 @@ pub(crate) mod tests { mod list_exhaustion { use test_log::test; - use crate::seat_assignment::tests::check_total_seats_per_list; use crate::{ ApportionmentError, - seat_assignment::seat_assignment, + seat_assignment::{seat_assignment, tests::check_total_seats_per_list}, structs::ListNumber, test_helpers::{ seat_assignment_fixture_with_given_candidate_votes, @@ -1652,9 +1651,10 @@ pub(crate) mod tests { mod list_exhaustion { use test_log::test; - use crate::seat_assignment::tests::check_total_seats_per_list; use crate::{ - ApportionmentError, seat_assignment::seat_assignment, structs::ListNumber, + ApportionmentError, + seat_assignment::{seat_assignment, tests::check_total_seats_per_list}, + structs::ListNumber, test_helpers::seat_assignment_fixture_with_given_candidate_votes, }; From 510ca9b6dae3ec18bc739013e22df745957bfe47 Mon Sep 17 00:00:00 2001 From: Lionqueen94 Date: Thu, 5 Feb 2026 21:34:26 +0100 Subject: [PATCH 4/6] Added test helper to prevent duplicate code --- .../src/candidate_nomination/mod.rs | 7 ++---- .../apportionment/src/seat_assignment/mod.rs | 23 ++++++++++--------- backend/apportionment/src/test_helpers.rs | 18 +++++++++++---- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/backend/apportionment/src/candidate_nomination/mod.rs b/backend/apportionment/src/candidate_nomination/mod.rs index 2bbe83d0a..40057bc05 100644 --- a/backend/apportionment/src/candidate_nomination/mod.rs +++ b/backend/apportionment/src/candidate_nomination/mod.rs @@ -262,11 +262,6 @@ fn update_candidate_ranking( mod tests { use test_log::test; - use crate::test_helpers::{ - candidate_nomination_fixture_with_given_list_numbers_and_number_of_seats, - seat_assignment_fixture_with_given_candidate_votes, - seat_assignment_fixture_with_given_list_numbers_and_candidate_votes, - }; use crate::{ ApportionmentError, CandidateVotesTrait, candidate_nomination::{ @@ -276,8 +271,10 @@ mod tests { structs::ListNumber, test_helpers::{ ApportionmentInputMock, CandidateVotesMock, + candidate_nomination_fixture_with_given_list_numbers_and_number_of_seats, candidate_nomination_fixture_with_given_number_of_seats, seat_assignment_fixture_with_given_candidate_votes, + seat_assignment_fixture_with_given_list_numbers_and_candidate_votes, }, }; diff --git a/backend/apportionment/src/seat_assignment/mod.rs b/backend/apportionment/src/seat_assignment/mod.rs index baae5d474..2b2ad1b1e 100644 --- a/backend/apportionment/src/seat_assignment/mod.rs +++ b/backend/apportionment/src/seat_assignment/mod.rs @@ -283,8 +283,12 @@ pub(crate) mod tests { use crate::{ SeatAssignmentResult, fraction::Fraction, - seat_assignment::{ListStanding, SeatChange, list_numbers}, + seat_assignment::{ + ListStanding, SeatChange, get_total_seats_per_list_number_from_apportionment_result, + list_numbers, + }, structs::ListNumber, + test_helpers::convert_total_seats_per_u32_list_number_to_total_seats_per_list_number, }; use test_log::test; @@ -297,17 +301,14 @@ pub(crate) mod tests { fn check_total_seats_per_list( result: &SeatAssignmentResult, - expected_total_seats: Vec<(u32, u32)>, + expected_total_seats_per_list: Vec<(u32, u32)>, ) { - let total_seats_per_list_number = result - .final_standing - .iter() - .map(|p| (p.list_number, p.total_seats)) - .collect::>(); - let expected_total_seats_per_list_number: Vec<(ListNumber, u32)> = expected_total_seats - .iter() - .map(|(number, seats)| (ListNumber::from(*number), *seats)) - .collect(); + let total_seats_per_list_number = + get_total_seats_per_list_number_from_apportionment_result(result); + let expected_total_seats_per_list_number = + convert_total_seats_per_u32_list_number_to_total_seats_per_list_number( + expected_total_seats_per_list, + ); assert_eq!( expected_total_seats_per_list_number, total_seats_per_list_number diff --git a/backend/apportionment/src/test_helpers.rs b/backend/apportionment/src/test_helpers.rs index c060aca3d..c6506e0aa 100644 --- a/backend/apportionment/src/test_helpers.rs +++ b/backend/apportionment/src/test_helpers.rs @@ -84,6 +84,16 @@ impl ListVotesMock { } } +#[cfg(test)] +pub fn convert_total_seats_per_u32_list_number_to_total_seats_per_list_number( + total_seats_per_list_number: Vec<(u32, u32)>, +) -> Vec<(ListNumber, u32)> { + total_seats_per_list_number + .iter() + .map(|(number, total_seats)| (ListNumber::from(*number), *total_seats)) + .collect() +} + /// Create a CandidateNominationInput with consecutive list numbers and /// given quota, number of seats, candidate votes and total seats per list. pub fn candidate_nomination_fixture_with_given_number_of_seats( @@ -116,10 +126,10 @@ pub fn candidate_nomination_fixture_with_given_list_numbers_and_number_of_seats( number_of_seats: seat_assignment_input.number_of_seats, list_votes: &seat_assignment_input.list_votes, quota, - total_seats_per_list: total_seats_per_list_number - .iter() - .map(|(number, total_seats)| (ListNumber::from(*number), *total_seats)) - .collect(), + total_seats_per_list: + convert_total_seats_per_u32_list_number_to_total_seats_per_list_number( + total_seats_per_list_number, + ), } } From 50681a623081727446692c838493875442ffd587 Mon Sep 17 00:00:00 2001 From: Lionqueen94 Date: Fri, 6 Feb 2026 13:57:56 +0100 Subject: [PATCH 5/6] Added test for non-consecutive candidate numbers and improved comments --- .../src/candidate_nomination/mod.rs | 17 +++--- backend/apportionment/src/test_helpers.rs | 52 ++++++++++++++++--- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/backend/apportionment/src/candidate_nomination/mod.rs b/backend/apportionment/src/candidate_nomination/mod.rs index 40057bc05..13e1192c8 100644 --- a/backend/apportionment/src/candidate_nomination/mod.rs +++ b/backend/apportionment/src/candidate_nomination/mod.rs @@ -273,6 +273,7 @@ mod tests { ApportionmentInputMock, CandidateVotesMock, candidate_nomination_fixture_with_given_list_numbers_and_number_of_seats, candidate_nomination_fixture_with_given_number_of_seats, + seat_assignment_fixture_with_given_candidate_numbers_and_votes, seat_assignment_fixture_with_given_candidate_votes, seat_assignment_fixture_with_given_list_numbers_and_candidate_votes, }, @@ -445,14 +446,14 @@ mod tests { #[test] fn test_with_lt_19_seats_and_no_preferential_candidate_nomination() { let quota = Fraction::new(105, 5); - let seat_assignment_input = seat_assignment_fixture_with_given_candidate_votes( + let seat_assignment_input = seat_assignment_fixture_with_given_candidate_numbers_and_votes( 5, vec![ - vec![5, 4, 4, 4, 4], - vec![4, 5, 4, 4, 4], - vec![4, 4, 5, 4, 4], - vec![4, 4, 4, 5, 4], - vec![4, 4, 4, 4, 5], + vec![(1, 5), (3, 4), (4, 4), (6, 4), (7, 4)], + vec![(2, 4), (3, 5), (5, 4), (6, 4), (8, 4)], + vec![(1, 4), (2, 4), (3, 5), (4, 4), (6, 4)], + vec![(2, 4), (3, 4), (4, 4), (5, 5), (7, 4)], + vec![(1, 4), (2, 4), (3, 4), (4, 4), (5, 5)], ], ); let input = candidate_nomination_fixture_with_given_number_of_seats( @@ -468,9 +469,9 @@ mod tests { quota * Fraction::new(result.preference_threshold.percentage, 100) ); check_list_candidate_nomination(&result.list_candidate_nomination[0], &[], &[1], &[]); - check_list_candidate_nomination(&result.list_candidate_nomination[1], &[], &[1], &[]); + check_list_candidate_nomination(&result.list_candidate_nomination[1], &[], &[2], &[]); check_list_candidate_nomination(&result.list_candidate_nomination[2], &[], &[1], &[]); - check_list_candidate_nomination(&result.list_candidate_nomination[3], &[], &[1], &[]); + check_list_candidate_nomination(&result.list_candidate_nomination[3], &[], &[2], &[]); check_list_candidate_nomination(&result.list_candidate_nomination[4], &[], &[1], &[]); let lists = input.list_votes; diff --git a/backend/apportionment/src/test_helpers.rs b/backend/apportionment/src/test_helpers.rs index c6506e0aa..27b975661 100644 --- a/backend/apportionment/src/test_helpers.rs +++ b/backend/apportionment/src/test_helpers.rs @@ -95,7 +95,7 @@ pub fn convert_total_seats_per_u32_list_number_to_total_seats_per_list_number( } /// Create a CandidateNominationInput with consecutive list numbers and -/// given quota, number of seats, candidate votes and total seats per list. +/// given quota, seat assignment input and total seats per list. pub fn candidate_nomination_fixture_with_given_number_of_seats( quota: Fraction, seat_assignment_input: &ApportionmentInputMock, @@ -115,8 +115,8 @@ pub fn candidate_nomination_fixture_with_given_number_of_seats( } } -/// Create a CandidateNominationInput with given quota, number of seats, -/// candidate votes per list number and total seats per list number. +/// Create a CandidateNominationInput with given quota, seat assignment input +/// and total seats per list number. pub fn candidate_nomination_fixture_with_given_list_numbers_and_number_of_seats( quota: Fraction, seat_assignment_input: &ApportionmentInputMock, @@ -133,7 +133,8 @@ pub fn candidate_nomination_fixture_with_given_list_numbers_and_number_of_seats( } } -/// Create a ApportionmentInputMock with consecutive list numbers and given total votes and list votes. +/// Create a ApportionmentInputMock with consecutive list numbers +/// and given list votes and number of seats. pub fn seat_assignment_fixture_with_default_50_candidates( number_of_seats: u32, list_vote_counts: Vec, @@ -159,7 +160,7 @@ pub fn seat_assignment_fixture_with_default_50_candidates( } } -/// Create a ApportionmentInputMock with given total votes and list numbers and votes. +/// Create a ApportionmentInputMock with given number of seats and list numbers and votes. pub fn seat_assignment_fixture_with_given_list_numbers_and_candidate_votes( number_of_seats: u32, list_candidate_votes: Vec<(u32, Vec)>, @@ -184,9 +185,9 @@ pub fn seat_assignment_fixture_with_given_list_numbers_and_candidate_votes( } } -/// Create a ApportionmentInputMock with consecutive list numbers and given votes per list. -/// The number of lists is the length of the `list_votes` vector. -/// The number of candidates in each list is by default 50. +/// Create a ApportionmentInputMock with consecutive list numbers +/// and given candidate votes per list and number of seats. +/// The number of lists is the length of the `candidate_votes` vector. pub fn seat_assignment_fixture_with_given_candidate_votes( number_of_seats: u32, candidate_votes: Vec>, @@ -206,3 +207,38 @@ pub fn seat_assignment_fixture_with_given_candidate_votes( list_votes, } } + +/// Create a ApportionmentInputMock with consecutive list numbers and +/// given candidate numbers and votes per list and number of seats. +/// The number of lists is the length of the `candidate_votes` vector. +pub fn seat_assignment_fixture_with_given_candidate_numbers_and_votes( + number_of_seats: u32, + candidate_votes: Vec>, +) -> ApportionmentInputMock { + let mut total_votes = 0; + let mut list_votes: Vec = vec![]; + for (list_index, list_candidate_votes) in candidate_votes.iter().enumerate() { + let list_total_votes = list_candidate_votes + .iter() + .map(|(_, candidate_votes)| candidate_votes) + .sum(); + total_votes += list_total_votes; + list_votes.push(ListVotesMock { + number: ListNumber::try_from(list_index + 1).unwrap(), + total_votes: list_total_votes, + candidate_votes: list_candidate_votes + .iter() + .map(|(number, candidate_votes)| CandidateVotesMock { + number: CandidateNumber::from(*number), + votes: *candidate_votes, + }) + .collect(), + }) + } + + ApportionmentInputMock { + number_of_seats, + total_votes, + list_votes, + } +} From cdeadf6c6b44ab9ece381231d533740e4c34c4dc Mon Sep 17 00:00:00 2001 From: Lionqueen94 Date: Fri, 6 Feb 2026 14:05:32 +0100 Subject: [PATCH 6/6] Update mod.rs Re-added removed spaces in docstring (fmt is too strict here) --- backend/apportionment/src/seat_assignment/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/apportionment/src/seat_assignment/mod.rs b/backend/apportionment/src/seat_assignment/mod.rs index 2b2ad1b1e..394983bf4 100644 --- a/backend/apportionment/src/seat_assignment/mod.rs +++ b/backend/apportionment/src/seat_assignment/mod.rs @@ -744,7 +744,7 @@ pub(crate) mod tests { }, }; - /// Apportionment with no residual seats + /// Apportionment with no residual seats /// This test triggers Kieswet Article P 10 /// /// Full seats: [5, 4, 3, 2, 1] - Remainder seats: 0 @@ -1659,7 +1659,7 @@ pub(crate) mod tests { test_helpers::seat_assignment_fixture_with_given_candidate_votes, }; - /// Apportionment with no residual seats + /// Apportionment with no residual seats /// This test triggers Kieswet Article P 10 /// /// Full seats: [5, 5, 4, 4, 2] - Remainder seats: 0