From aa22014671cc37b3e540d9d06be9c88e9f337ac0 Mon Sep 17 00:00:00 2001 From: Ellen Date: Tue, 25 Feb 2025 09:45:53 +0100 Subject: [PATCH] Implement Kieswet article P 9 (#1051) Co-authored-by: cikzh Co-authored-by: Mark Janssen <20283+praseodym@users.noreply.github.com> --- backend/openapi.json | 67 +- backend/src/apportionment/api.rs | 4 +- backend/src/apportionment/mod.rs | 347 +++++++-- .../LargestAveragesFor19OrMoreSeatsTable.tsx | 23 +- ...LargestAveragesForLessThan19SeatsTable.tsx | 14 +- .../apportionment/LargestSurplusesTable.tsx | 13 +- .../test-data/19-or-more-seats.ts | 4 + .../test-data/absolute-majority-change.ts | 716 ++++++++++++++++++ .../test-data/less-than-19-seats.ts | 5 + .../page/Apportionment.module.css | 4 + .../ApportionmentResidualSeatsPage.test.tsx | 46 ++ .../page/ApportionmentResidualSeatsPage.tsx | 87 ++- frontend/lib/api/gen/openapi.ts | 13 +- .../lib/i18n/locales/nl/apportionment.json | 1 + 14 files changed, 1222 insertions(+), 122 deletions(-) create mode 100644 frontend/app/component/apportionment/test-data/absolute-majority-change.ts diff --git a/backend/openapi.json b/backend/openapi.json index e25f4eb63..240c8ce33 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -1398,6 +1398,28 @@ }, "components": { "schemas": { + "AbsoluteMajorityChange": { + "type": "object", + "description": "Contains information about the enactment of article P 9 of the Kieswet.", + "required": [ + "pg_retracted_seat", + "pg_assigned_seat" + ], + "properties": { + "pg_assigned_seat": { + "type": "integer", + "format": "int32", + "description": "Political group number which the residual seat is assigned to", + "minimum": 0 + }, + "pg_retracted_seat": { + "type": "integer", + "format": "int32", + "description": "Political group number which the residual seat is retracted from", + "minimum": 0 + } + } + }, "ApportionmentResult": { "type": "object", "description": "The result of the apportionment procedure. This contains the number of seats and the quota\nthat was used. It then contains the initial standing after whole seats were assigned,\nand each of the changes and intermediate standings. The final standing contains the\nnumber of seats per political group that was assigned after all seats were assigned.", @@ -1510,6 +1532,27 @@ } } ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/AbsoluteMajorityChange" + }, + { + "type": "object", + "required": [ + "assigned_by" + ], + "properties": { + "assigned_by": { + "type": "string", + "enum": [ + "AbsoluteMajorityChange" + ] + } + } + } + ] } ], "description": "Records the political group and specific change for a specific residual seat" @@ -2106,9 +2149,19 @@ "required": [ "selected_pg_number", "pg_options", + "pg_assigned", "votes_per_seat" ], "properties": { + "pg_assigned": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "description": "The list of political groups with the same average, that have been assigned a seat" + }, "pg_options": { "type": "array", "items": { @@ -2116,7 +2169,7 @@ "format": "int32", "minimum": 0 }, - "description": "The list from which the political group was selected, all of them having the same votes per seat" + "description": "The list of political groups with the same average, that have not been assigned a seat" }, "selected_pg_number": { "type": "integer", @@ -2136,9 +2189,19 @@ "required": [ "selected_pg_number", "pg_options", + "pg_assigned", "surplus_votes" ], "properties": { + "pg_assigned": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "description": "The list of political groups with the same surplus, that have been assigned a seat" + }, "pg_options": { "type": "array", "items": { @@ -2146,7 +2209,7 @@ "format": "int32", "minimum": 0 }, - "description": "The list from which the political group was selected, all of them having the same number of surplus votes" + "description": "The list of political groups with the same surplus, that have not been assigned a seat" }, "selected_pg_number": { "type": "integer", diff --git a/backend/src/apportionment/api.rs b/backend/src/apportionment/api.rs index b7b945cbb..627d15bc1 100644 --- a/backend/src/apportionment/api.rs +++ b/backend/src/apportionment/api.rs @@ -7,7 +7,7 @@ use utoipa::ToSchema; use crate::{ APIError, ErrorResponse, - apportionment::{ApportionmentError, ApportionmentResult, seat_allocation}, + apportionment::{ApportionmentError, ApportionmentResult, apportionment}, data_entry::{ repository::{PollingStationDataEntries, PollingStationResultsEntries}, status::DataEntryStatusName, @@ -56,7 +56,7 @@ pub async fn election_apportionment( .list_with_polling_stations(polling_stations_repo, election.id) .await?; let election_summary = ElectionSummary::from_results(&election, &results)?; - let apportionment = seat_allocation(election.number_of_seats.into(), &election_summary)?; + let apportionment = apportionment(election.number_of_seats.into(), &election_summary)?; Ok(Json(ElectionApportionmentResponse { apportionment, election_summary, diff --git a/backend/src/apportionment/mod.rs b/backend/src/apportionment/mod.rs index 96d8588f8..4372e2f91 100644 --- a/backend/src/apportionment/mod.rs +++ b/backend/src/apportionment/mod.rs @@ -24,6 +24,17 @@ pub struct ApportionmentResult { pub final_standing: Vec, } +/// Contains information about the enactment of article P 9 of the Kieswet. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] +pub struct AbsoluteMajorityChange { + /// Political group number which the residual seat is retracted from + #[schema(value_type = u32)] + pg_retracted_seat: PGNumber, + /// Political group number which the residual seat is assigned to + #[schema(value_type = u32)] + pg_assigned_seat: PGNumber, +} + /// Contains information about the final assignment of seats for a specific political group. #[derive(Debug, PartialEq, Serialize, Deserialize, ToSchema)] pub struct PoliticalGroupSeatAssignment { @@ -139,13 +150,13 @@ fn initial_whole_seats_per_political_group( /// per seat if one additional seat would be assigned to each political group. /// /// It then returns all the political groups for which this fraction is the highest. -/// If there are more political groups than there are remaining seats to be assigned, +/// If there are more political groups than there are residual seats to be assigned, /// a drawing of lots is required. /// /// This function will always return at least one group. fn political_groups_with_largest_average<'a>( assigned_seats: impl IntoIterator, - remaining_seats: u64, + residual_seats: u64, ) -> Result, ApportionmentError> { // We are now going to find the political groups that have the highest average // votes per seat if we would were to add one additional seat to them @@ -175,10 +186,10 @@ fn political_groups_with_largest_average<'a>( ); // Check if we can actually assign all these political groups a seat, otherwise we would need to draw lots - if political_groups.len() as u64 > remaining_seats { - // TODO: #788 if multiple political groups have the same highest average and not enough remaining seats are available, use drawing of lots + if political_groups.len() as u64 > residual_seats { + // TODO: #788 if multiple political groups have the same highest average and not enough residual seats are available, use drawing of lots debug!( - "Drawing of lots is required for political groups: {:?}, only {remaining_seats} seats available", + "Drawing of lots is required for political groups: {:?}, only {residual_seats} seats available", political_group_numbers(&political_groups) ); Err(ApportionmentError::DrawingOfLotsNotImplemented) @@ -187,9 +198,16 @@ fn political_groups_with_largest_average<'a>( } } +/// Compute the political groups with the largest votes surplus. +/// +/// It returns all the political groups for which this surplus fraction is the highest. +/// If there are more political groups than there are residual seats to be assigned, +/// a drawing of lots is required. +/// +/// This function will always return at least one group. fn political_groups_with_highest_surplus<'a>( assigned_seats: impl IntoIterator, - remaining_seats: u64, + residual_seats: u64, ) -> Result, ApportionmentError> { // We are now going to find the political groups that have the highest surplus let (max_surplus, political_groups) = assigned_seats.into_iter().fold( @@ -218,10 +236,10 @@ fn political_groups_with_highest_surplus<'a>( ); // Check if we can actually assign all these political groups - if political_groups.len() as u64 > remaining_seats { - // TODO: #788 if multiple political groups have the same highest surplus and not enough remaining seats are available, use drawing of lots + if political_groups.len() as u64 > residual_seats { + // TODO: #788 if multiple political groups have the same highest surplus and not enough residual seats are available, use drawing of lots debug!( - "Drawing of lots is required for political groups: {:?}, only {remaining_seats} seats available", + "Drawing of lots is required for political groups: {:?}, only {residual_seats} seats available", political_group_numbers(&political_groups) ); Err(ApportionmentError::DrawingOfLotsNotImplemented) @@ -230,8 +248,69 @@ fn political_groups_with_highest_surplus<'a>( } } +/// If a political group got the absolute majority of votes but not the absolute majority of seats, +/// re-assign the last residual seat to the political group with the absolute majority. +/// This re-assignment is done according to article P 9 of the Kieswet. +fn reallocate_residual_seat_for_absolute_majority( + seats: u64, + totals: &ElectionSummary, + pgs_last_residual_seat: &[PGNumber], + standing: Vec, +) -> Result<(Vec, Option), ApportionmentError> { + let half_of_votes_count: Fraction = + Fraction::from(totals.votes_counts.votes_candidates_count) * Fraction::new(1, 2); + + // Find political group with an absolute majority of votes. Return early if we find none + let Some(majority_pg_votes) = totals + .political_group_votes + .iter() + .find(|pg| Fraction::from(pg.total) > half_of_votes_count) + else { + return Ok((standing, None)); + }; + + let half_of_seats_count: Fraction = Fraction::from(seats) * Fraction::new(1, 2); + let pg_final_standing_majority_votes = standing + .iter() + .find(|pg_standing| pg_standing.pg_number == majority_pg_votes.number) + .expect("PG exists"); + + let pg_seats = Fraction::from(pg_final_standing_majority_votes.total_seats()); + + if pg_seats <= half_of_seats_count { + if pgs_last_residual_seat.len() > 1 { + debug!( + "Drawing of lots is required for political groups: {:?} to pick a political group which the residual seat gets retracted from", + pgs_last_residual_seat + ); + return Err(ApportionmentError::DrawingOfLotsNotImplemented); + } + + // Do the reassignment of the seat + let mut standing = standing.clone(); + standing[pgs_last_residual_seat[0] as usize - 1].residual_seats -= 1; + standing[majority_pg_votes.number as usize - 1].residual_seats += 1; + + info!( + "Residual seat first allocated to list {} has been re-allocated to list {} in accordance with Article P 9 Kieswet", + pgs_last_residual_seat[0], majority_pg_votes.number + ); + Ok(( + standing, + Some(AssignedSeat::AbsoluteMajorityChange( + AbsoluteMajorityChange { + pg_retracted_seat: pgs_last_residual_seat[0], + pg_assigned_seat: majority_pg_votes.number, + }, + )), + )) + } else { + Ok((standing, None)) + } +} + /// Apportionment -pub fn seat_allocation( +pub fn apportionment( seats: u64, totals: &ElectionSummary, ) -> Result { @@ -240,29 +319,26 @@ pub fn seat_allocation( info!("Seats: {}", seats); // Article P 5 Kieswet - // Calculate quota (kiesdeler) as a proper fraction + // Calculate electoral quota (kiesdeler) as a proper fraction let quota = Fraction::from(totals.votes_counts.votes_candidates_count) / Fraction::from(seats); info!("Quota: {}", quota); // Article P 6 Kieswet let initial_standing = initial_whole_seats_per_political_group(&totals.political_group_votes, quota); - let whole_seats_count = initial_standing + let whole_seats = initial_standing .iter() .map(|pg| pg.whole_seats) .sum::(); - let remaining_seats = seats - whole_seats_count; + let residual_seats = seats - whole_seats; - let (steps, final_standing) = if remaining_seats > 0 { - allocate_remainder(&initial_standing, seats, remaining_seats)? + let (steps, final_standing) = if residual_seats > 0 { + allocate_remainder(&initial_standing, totals, seats, residual_seats)? } else { info!("All seats have been allocated without any residual seats"); - (vec![], initial_standing.clone()) + (vec![], initial_standing) }; - // TODO: #785 Article P 9 Kieswet check for absolute majority - // (list with absolute majority should have absolute majority of votes) - // TODO: #797 Article P 19a Kieswet mark deceased candidates // TODO: #787 Article P 10 Kieswet check for list exhaustion @@ -273,8 +349,8 @@ pub fn seat_allocation( Ok(ApportionmentResult { seats, - whole_seats: whole_seats_count, - residual_seats: remaining_seats, + whole_seats, + residual_seats, quota, steps, final_standing: final_standing.into_iter().map(Into::into).collect(), @@ -286,25 +362,30 @@ pub fn seat_allocation( /// depending on how many total seats are available in the election. fn allocate_remainder( initial_standing: &[PoliticalGroupStanding], + totals: &ElectionSummary, seats: u64, - total_remaining_seats: u64, + total_residual_seats: u64, ) -> Result<(Vec, Vec), ApportionmentError> { let mut steps = vec![]; let mut residual_seat_number = 0; let mut current_standing = initial_standing.to_vec(); - while residual_seat_number != total_remaining_seats { - let remaining_seats = total_remaining_seats - residual_seat_number; + while residual_seat_number != total_residual_seats { + let residual_seats = total_residual_seats - residual_seat_number; residual_seat_number += 1; let step = if seats >= 19 { // Article P 7 Kieswet - step_allocate_remainder_using_highest_averages(¤t_standing, remaining_seats)? + step_allocate_remainder_using_highest_averages( + ¤t_standing, + residual_seats, + &steps, + )? } else { // Article P 8 Kieswet step_allocate_remainder_using_highest_surplus( ¤t_standing, - remaining_seats, + residual_seats, &steps, )? }; @@ -332,19 +413,88 @@ fn allocate_remainder( }); } + // Apply Article P 9 Kieswet + let (current_standing, assigned_seat) = if let Some(last_step) = steps.last() { + reallocate_residual_seat_for_absolute_majority( + seats, + totals, + &last_step.change.pg_assigned(), + current_standing, + )? + } else { + (current_standing, None) + }; + + if let Some(assigned_seat) = assigned_seat { + // add the absolute majority change to the remainder assignment steps + steps.push(ApportionmentStep { + standing: current_standing.clone(), + residual_seat_number, + change: assigned_seat, + }); + } + Ok((steps, current_standing)) } +/// Get a vector with the political group number that was assigned the last residual seat. +/// If the last residual seat was assigned to a political group with the same surplus +/// as political groups assigned a seat in previous steps, +/// return all political group numbers that had the same surplus. +fn pg_assigned_from_previous_surplus_step( + selected_pg: &PoliticalGroupStanding, + previous: &[ApportionmentStep], +) -> Vec { + let mut pg_assigned = Vec::new(); + if let Some(previous_step) = previous.last() { + if previous_step.change.is_assigned_by_highest_surplus() + && previous_step + .change + .pg_options() + .contains(&selected_pg.pg_number) + { + pg_assigned = previous_step.change.pg_assigned() + } + } + pg_assigned.push(selected_pg.pg_number); + pg_assigned +} + +/// Get a vector with the political group number that was assigned the last residual seat. +/// If the last residual seat was assigned to a political group with the same votes per seat +/// as political groups assigned a seat in previous steps, +/// return all political group numbers that had the same votes per seat. +fn pg_assigned_from_previous_average_step( + selected_pg: &PoliticalGroupStanding, + previous: &[ApportionmentStep], +) -> Vec { + let mut pg_assigned = Vec::new(); + if let Some(previous_step) = previous.last() { + if previous_step.change.is_assigned_by_highest_average() + && previous_step + .change + .pg_options() + .contains(&selected_pg.pg_number) + { + pg_assigned = previous_step.change.pg_assigned() + } + } + pg_assigned.push(selected_pg.pg_number); + pg_assigned +} + /// Assign the next residual seat, and return which group that seat was assigned to. /// This assignment is done according to the rules for elections with 19 seats or more. fn step_allocate_remainder_using_highest_averages( standing: &[PoliticalGroupStanding], - remaining_seats: u64, + residual_seats: u64, + previous: &[ApportionmentStep], ) -> Result { - let selected_pgs = political_groups_with_largest_average(standing, remaining_seats)?; + let selected_pgs = political_groups_with_largest_average(standing, residual_seats)?; let selected_pg = selected_pgs[0]; Ok(AssignedSeat::HighestAverage(HighestAverageAssignedSeat { selected_pg_number: selected_pg.pg_number, + pg_assigned: pg_assigned_from_previous_average_step(selected_pg, previous), pg_options: selected_pgs.iter().map(|pg| pg.pg_number).collect(), votes_per_seat: selected_pg.next_votes_per_seat, })) @@ -362,7 +512,7 @@ fn political_groups_qualifying_for_highest_surplus<'a>( standing.iter().filter(move |p| { p.meets_surplus_threshold && !previous.iter().any(|prev| { - prev.change.is_assigned_by_surplus() + prev.change.is_assigned_by_highest_surplus() && prev.change.political_group_number() == p.pg_number }) }) @@ -387,7 +537,7 @@ fn political_groups_qualifying_for_unique_highest_average<'a>( /// This assignment is done according to the rules for elections with less than 19 seats. fn step_allocate_remainder_using_highest_surplus( assigned_seats: &[PoliticalGroupStanding], - remaining_seats: u64, + residual_seats: u64, previous: &[ApportionmentStep], ) -> Result { // first we check if there are any political groups that still qualify for a highest surplus allocated seat @@ -397,10 +547,11 @@ fn step_allocate_remainder_using_highest_surplus( // If there is at least one element in the iterator, we know we can still do a highest surplus allocation if qualifying_for_surplus.peek().is_some() { let selected_pgs = - political_groups_with_highest_surplus(qualifying_for_surplus, remaining_seats)?; + political_groups_with_highest_surplus(qualifying_for_surplus, residual_seats)?; let selected_pg = selected_pgs[0]; Ok(AssignedSeat::HighestSurplus(HighestSurplusAssignedSeat { selected_pg_number: selected_pg.pg_number, + pg_assigned: pg_assigned_from_previous_surplus_step(selected_pg, previous), pg_options: selected_pgs.iter().map(|pg| pg.pg_number).collect(), surplus_votes: selected_pg.surplus_votes, })) @@ -411,23 +562,18 @@ fn step_allocate_remainder_using_highest_surplus( let mut qualifying_for_unique_highest_average = political_groups_qualifying_for_unique_highest_average(assigned_seats, previous) .peekable(); - if qualifying_for_unique_highest_average.peek().is_some() { - let selected_pgs = political_groups_with_largest_average( - qualifying_for_unique_highest_average, - remaining_seats, - )?; - let selected_pg = selected_pgs[0]; - Ok(AssignedSeat::HighestAverage(HighestAverageAssignedSeat { - selected_pg_number: selected_pg.pg_number, - pg_options: selected_pgs.iter().map(|pg| pg.pg_number).collect(), - votes_per_seat: selected_pg.next_votes_per_seat, - })) + if let Some(&&assigned_seats) = qualifying_for_unique_highest_average.peek() { + step_allocate_remainder_using_highest_averages( + &[assigned_seats], + residual_seats, + previous, + ) } else { // We've now even exhausted unique highest average seats: every group that qualified // got a highest surplus seat, and every group also had at least a single residual seat - // assigned to them. We now allow any remaining seats to be assigned using the highest + // assigned to them. We now allow any residual seats to be assigned using the highest // averages procedure - step_allocate_remainder_using_highest_averages(assigned_seats, remaining_seats) + step_allocate_remainder_using_highest_averages(assigned_seats, residual_seats, previous) } } } @@ -447,19 +593,39 @@ pub struct ApportionmentStep { pub enum AssignedSeat { HighestAverage(HighestAverageAssignedSeat), HighestSurplus(HighestSurplusAssignedSeat), + AbsoluteMajorityChange(AbsoluteMajorityChange), } impl AssignedSeat { - /// Get the political group number for the group this step has assigned a seat + /// Get the political group number for the group this step has assigned a seat to fn political_group_number(&self) -> PGNumber { match self { AssignedSeat::HighestAverage(highest_average) => highest_average.selected_pg_number, AssignedSeat::HighestSurplus(highest_surplus) => highest_surplus.selected_pg_number, + AssignedSeat::AbsoluteMajorityChange(_) => unimplemented!(), } } - /// Returns true if the seat was assigned through a surplus - pub fn is_assigned_by_surplus(&self) -> bool { + /// Get the list of political groups with the same average, that have not been assigned a seat + fn pg_options(&self) -> Vec { + match self { + AssignedSeat::HighestAverage(highest_average) => highest_average.pg_options.clone(), + AssignedSeat::HighestSurplus(highest_surplus) => highest_surplus.pg_options.clone(), + AssignedSeat::AbsoluteMajorityChange(_) => unimplemented!(), + } + } + + /// Get the list of political groups with the same average, that have been assigned a seat + fn pg_assigned(&self) -> Vec { + match self { + AssignedSeat::HighestAverage(highest_average) => highest_average.pg_assigned.clone(), + AssignedSeat::HighestSurplus(highest_surplus) => highest_surplus.pg_assigned.clone(), + AssignedSeat::AbsoluteMajorityChange(_) => unimplemented!(), + } + } + + /// Returns true if the seat was assigned through the highest surplus + pub fn is_assigned_by_highest_surplus(&self) -> bool { matches!(self, AssignedSeat::HighestSurplus(_)) } @@ -467,6 +633,11 @@ impl AssignedSeat { pub fn is_assigned_by_highest_average(&self) -> bool { matches!(self, AssignedSeat::HighestAverage(_)) } + + /// Returns true if the seat was reassigned through the absolute majority change + pub fn is_assigned_by_absolute_majority_change(&self) -> bool { + matches!(self, AssignedSeat::AbsoluteMajorityChange(_)) + } } /// Contains the details for an assigned seat, assigned through the highest average method. @@ -475,9 +646,12 @@ pub struct HighestAverageAssignedSeat { /// The political group that was selected for this seat has this political group number #[schema(value_type = u32)] selected_pg_number: PGNumber, - /// The list from which the political group was selected, all of them having the same votes per seat + /// The list of political groups with the same average, that have not been assigned a seat #[schema(value_type = Vec)] pg_options: Vec, + /// The list of political groups with the same average, that have been assigned a seat + #[schema(value_type = Vec)] + pg_assigned: Vec, /// This is the votes per seat achieved by the selected political group votes_per_seat: Fraction, } @@ -488,9 +662,12 @@ pub struct HighestSurplusAssignedSeat { /// The political group that was selected for this seat has this political group number #[schema(value_type = u32)] selected_pg_number: PGNumber, - /// The list from which the political group was selected, all of them having the same number of surplus votes + /// The list of political groups with the same surplus, that have not been assigned a seat #[schema(value_type = Vec)] pg_options: Vec, + /// The list of political groups with the same surplus, that have been assigned a seat + #[schema(value_type = Vec)] + pg_assigned: Vec, /// The number of surplus votes achieved by the selected political group surplus_votes: Fraction, } @@ -519,7 +696,7 @@ pub fn get_total_seats_from_apportionment_result(result: ApportionmentResult) -> mod tests { use crate::{ apportionment::{ - ApportionmentError, get_total_seats_from_apportionment_result, seat_allocation, + ApportionmentError, apportionment, get_total_seats_from_apportionment_result, }, data_entry::{Count, PoliticalGroupVotes, VotersCounts, VotesCounts}, election::PGNumber, @@ -557,18 +734,18 @@ mod tests { } #[test] - fn test_seat_allocation_less_than_19_seats_without_remaining_seats() { + fn test_seat_allocation_less_than_19_seats_without_residual_seats() { let totals = get_election_summary(vec![480, 160, 160, 160, 80, 80, 80]); - let result = seat_allocation(15, &totals).unwrap(); + let result = apportionment(15, &totals).unwrap(); 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]); } #[test] - fn test_seat_allocation_less_than_19_seats_with_remaining_seats_assigned_with_surplus_system() { + fn test_seat_allocation_less_than_19_seats_with_residual_seats_assigned_with_surplus_system() { let totals = get_election_summary(vec![540, 160, 160, 80, 80, 80, 60, 40]); - let result = seat_allocation(15, &totals).unwrap(); + let result = apportionment(15, &totals).unwrap(); assert_eq!(result.steps.len(), 2); let total_seats = get_total_seats_from_apportionment_result(result); assert_eq!(total_seats, vec![7, 2, 2, 1, 1, 1, 1, 0]); @@ -578,7 +755,7 @@ mod tests { fn test_seat_allocation_less_than_19_seats_with_remaining_seats_assigned_with_surplus_and_averages_system_only_1_surplus_meets_threshold() { let totals = get_election_summary(vec![808, 59, 58, 57, 56, 55, 54, 53]); - let result = seat_allocation(15, &totals).unwrap(); + let result = apportionment(15, &totals).unwrap(); assert_eq!(result.steps.len(), 5); let total_seats = get_total_seats_from_apportionment_result(result); assert_eq!(total_seats, vec![12, 1, 1, 1, 0, 0, 0, 0]); @@ -588,56 +765,94 @@ mod tests { fn test_seat_allocation_less_than_19_seats_with_0_votes_assigned_with_surplus_and_averages_system() { let totals = get_election_summary(vec![0, 0, 0, 0, 0]); - let result = seat_allocation(10, &totals).unwrap(); + let result = apportionment(10, &totals).unwrap(); assert_eq!(result.steps.len(), 10); let total_seats = get_total_seats_from_apportionment_result(result); assert_eq!(total_seats, vec![2, 2, 2, 2, 2]); } + #[test] + fn test_seat_allocation_less_than_19_seats_with_absolute_majority_of_votes_but_not_seats() { + // This test triggers Kieswet Article P 9 (Actual case from GR2022) + let totals = get_election_summary(vec![2571, 977, 567, 536, 453]); + let result = apportionment(15, &totals).unwrap(); + assert_eq!(result.steps.len(), 4); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![8, 3, 2, 1, 1]); + } + + #[test] + fn test_seat_allocation_less_than_19_seats_with_absolute_majority_of_votes_but_not_seats_with_drawing_of_lots_error() + { + // This test triggers Kieswet Article P 9 + let totals = get_election_summary(vec![2552, 511, 511, 511, 509, 509]); + let result = apportionment(15, &totals); + assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); + } + #[test] fn test_seat_allocation_less_than_19_seats_with_0_votes_assigned_with_surplus_and_averages_system_drawing_of_lots_error_in_2nd_round_averages_system() { let totals = get_election_summary(vec![0, 0, 0, 0, 0]); - let result = seat_allocation(15, &totals); + let result = apportionment(15, &totals); assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); } #[test] fn test_seat_allocation_less_than_19_seats_with_drawing_of_lots_error_with_0_surpluses() { let totals = get_election_summary(vec![540, 160, 160, 80, 80, 80, 55, 45]); - let result = seat_allocation(15, &totals); + let result = apportionment(15, &totals); assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); } #[test] fn test_seat_allocation_less_than_19_seats_with_drawing_of_lots_error() { let totals = get_election_summary(vec![500, 140, 140, 140, 140, 140]); - let result = seat_allocation(15, &totals); + let result = apportionment(15, &totals); assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); } #[test] - fn test_seat_allocation_19_or_more_seats_without_remaining_seats() { + fn test_seat_allocation_19_or_more_seats_without_residual_seats() { let totals = get_election_summary(vec![576, 288, 96, 96, 96, 48]); - let result = seat_allocation(25, &totals).unwrap(); + let result = apportionment(25, &totals).unwrap(); 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]); } #[test] - fn test_seat_allocation_19_or_more_seats_with_remaining_seats() { + fn test_seat_allocation_19_or_more_seats_with_residual_seats() { let totals = get_election_summary(vec![600, 302, 98, 99, 101]); - let result = seat_allocation(23, &totals).unwrap(); + let result = apportionment(23, &totals).unwrap(); assert_eq!(result.steps.len(), 4); let total_seats = get_total_seats_from_apportionment_result(result); assert_eq!(total_seats, vec![12, 6, 1, 2, 2]); } + #[test] + fn test_seat_allocation_19_or_more_seats_with_absolute_majority_of_votes_but_not_seats() { + // This test triggers Kieswet Article P 9 + let totals = get_election_summary(vec![7501, 1249, 1249, 1249, 1249, 1249, 1248, 7]); + let result = apportionment(24, &totals).unwrap(); + assert_eq!(result.steps.len(), 7); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![13, 2, 2, 2, 2, 2, 1, 0]); + } + + #[test] + fn test_seat_allocation_19_or_more_seats_with_absolute_majority_of_votes_but_not_seats_with_drawing_of_lots_error() + { + // This test triggers Kieswet Article P 9 + let totals = get_election_summary(vec![7501, 1249, 1249, 1249, 1249, 1248, 1248, 8]); + let result = apportionment(24, &totals); + assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); + } + #[test] fn test_seat_allocation_19_or_more_seats_with_0_votes() { let totals = get_election_summary(vec![0]); - let result = seat_allocation(19, &totals).unwrap(); + let result = apportionment(19, &totals).unwrap(); assert_eq!(result.steps.len(), 19); let total_seats = get_total_seats_from_apportionment_result(result); assert_eq!(total_seats, vec![19]); @@ -646,14 +861,14 @@ mod tests { #[test] fn test_seat_allocation_19_or_more_seats_with_0_votes_with_drawing_of_lots_error() { let totals = get_election_summary(vec![0, 0, 0, 0, 0]); - let result = seat_allocation(19, &totals); + let result = apportionment(19, &totals); assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); } #[test] fn test_seat_allocation_19_or_more_seats_with_drawing_of_lots_error() { let totals = get_election_summary(vec![500, 140, 140, 140, 140, 140]); - let result = seat_allocation(23, &totals); + let result = apportionment(23, &totals); assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); } } diff --git a/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.tsx b/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.tsx index 0fc3b9692..de30e835a 100644 --- a/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.tsx +++ b/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.tsx @@ -1,4 +1,9 @@ -import { ApportionmentStep, PoliticalGroup, PoliticalGroupSeatAssignment } from "@kiesraad/api"; +import { + ApportionmentStep, + HighestAverageAssignedSeat, + PoliticalGroup, + PoliticalGroupSeatAssignment, +} from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { Table } from "@kiesraad/ui"; import { cn } from "@kiesraad/util"; @@ -47,13 +52,14 @@ export function LargestAveragesFor19OrMoreSeatsTable({ {politicalGroups[pg_seat_assignment.pg_number - 1]?.name || ""} {highestAverageSteps.map((step: ApportionmentStep) => { + const change = step.change as HighestAverageAssignedSeat; const average = step.standing[pg_seat_assignment.pg_number - 1]?.next_votes_per_seat; if (average) { return ( {average} @@ -72,11 +78,14 @@ export function LargestAveragesFor19OrMoreSeatsTable({ {t("apportionment.residual_seat_assigned_to_list")} - {highestAverageSteps.map((step: ApportionmentStep) => ( - - {step.change.selected_pg_number} - - ))} + {highestAverageSteps.map((step: ApportionmentStep) => { + const change = step.change as HighestAverageAssignedSeat; + return ( + + {change.selected_pg_number} + + ); + })} diff --git a/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.tsx b/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.tsx index 42702980c..1849a118c 100644 --- a/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.tsx +++ b/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.tsx @@ -1,4 +1,9 @@ -import { ApportionmentStep, PoliticalGroup, PoliticalGroupSeatAssignment } from "@kiesraad/api"; +import { + ApportionmentStep, + HighestAverageAssignedSeat, + PoliticalGroup, + PoliticalGroupSeatAssignment, +} from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { Table } from "@kiesraad/ui"; import { cn } from "@kiesraad/util"; @@ -30,9 +35,10 @@ export function LargestAveragesForLessThan19SeatsTable({ {finalStanding.map((pg_seat_assignment) => { const average = highestAverageSteps[0]?.standing[pg_seat_assignment.pg_number - 1]?.next_votes_per_seat; - const residual_seats = highestAverageSteps.filter( - (step) => step.change.selected_pg_number == pg_seat_assignment.pg_number, - ).length; + const residual_seats = highestAverageSteps.filter((step) => { + const change = step.change as HighestAverageAssignedSeat; + return change.selected_pg_number == pg_seat_assignment.pg_number; + }).length; return ( diff --git a/frontend/app/component/apportionment/LargestSurplusesTable.tsx b/frontend/app/component/apportionment/LargestSurplusesTable.tsx index 4cd4c0425..06c522791 100644 --- a/frontend/app/component/apportionment/LargestSurplusesTable.tsx +++ b/frontend/app/component/apportionment/LargestSurplusesTable.tsx @@ -1,4 +1,9 @@ -import { ApportionmentStep, PoliticalGroup, PoliticalGroupSeatAssignment } from "@kiesraad/api"; +import { + ApportionmentStep, + HighestSurplusAssignedSeat, + PoliticalGroup, + PoliticalGroupSeatAssignment, +} from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { Table } from "@kiesraad/ui"; import { cn } from "@kiesraad/util"; @@ -33,8 +38,10 @@ export function LargestSurplusesTable({ {finalStandingPgsMeetingThreshold.map((pg_seat_assignment) => { const residual_seats = - highestSurplusSteps.filter((step) => step.change.selected_pg_number == pg_seat_assignment.pg_number) - .length || 0; + highestSurplusSteps.filter((step) => { + const change = step.change as HighestSurplusAssignedSeat; + return change.selected_pg_number == pg_seat_assignment.pg_number; + }).length || 0; return ( diff --git a/frontend/app/component/apportionment/test-data/19-or-more-seats.ts b/frontend/app/component/apportionment/test-data/19-or-more-seats.ts index 5c5fbf8d7..4b43261f1 100644 --- a/frontend/app/component/apportionment/test-data/19-or-more-seats.ts +++ b/frontend/app/component/apportionment/test-data/19-or-more-seats.ts @@ -16,6 +16,7 @@ export const apportionment: ApportionmentResult = { assigned_by: "HighestAverage", selected_pg_number: 5, pg_options: [5], + pg_assigned: [5], votes_per_seat: { integer: 50, numerator: 1, @@ -116,6 +117,7 @@ export const apportionment: ApportionmentResult = { assigned_by: "HighestAverage", selected_pg_number: 2, pg_options: [2], + pg_assigned: [2], votes_per_seat: { integer: 50, numerator: 2, @@ -216,6 +218,7 @@ export const apportionment: ApportionmentResult = { assigned_by: "HighestAverage", selected_pg_number: 1, pg_options: [1], + pg_assigned: [1], votes_per_seat: { integer: 50, numerator: 0, @@ -316,6 +319,7 @@ export const apportionment: ApportionmentResult = { assigned_by: "HighestAverage", selected_pg_number: 4, pg_options: [4], + pg_assigned: [4], votes_per_seat: { integer: 49, numerator: 1, diff --git a/frontend/app/component/apportionment/test-data/absolute-majority-change.ts b/frontend/app/component/apportionment/test-data/absolute-majority-change.ts new file mode 100644 index 000000000..df27c8594 --- /dev/null +++ b/frontend/app/component/apportionment/test-data/absolute-majority-change.ts @@ -0,0 +1,716 @@ +import { ApportionmentResult, Election, ElectionSummary } from "@kiesraad/api"; + +export const apportionment: ApportionmentResult = { + seats: 15, + whole_seats: 12, + residual_seats: 3, + quota: { + integer: 340, + numerator: 4, + denominator: 15, + }, + steps: [ + { + residual_seat_number: 1, + change: { + assigned_by: "HighestSurplus", + selected_pg_number: 2, + pg_options: [2], + pg_assigned: [2], + surplus_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + }, + standing: [ + { + pg_number: 1, + votes_cast: 2571, + surplus_votes: { + integer: 189, + numerator: 2, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 321, + numerator: 3, + denominator: 8, + }, + whole_seats: 7, + residual_seats: 0, + }, + { + pg_number: 2, + votes_cast: 977, + surplus_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 325, + numerator: 2, + denominator: 3, + }, + whole_seats: 2, + residual_seats: 0, + }, + { + pg_number: 3, + votes_cast: 567, + surplus_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 283, + numerator: 1, + denominator: 2, + }, + whole_seats: 1, + residual_seats: 0, + }, + { + pg_number: 4, + votes_cast: 536, + surplus_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 268, + numerator: 0, + denominator: 2, + }, + whole_seats: 1, + residual_seats: 0, + }, + { + pg_number: 5, + votes_cast: 453, + surplus_votes: { + integer: 112, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 226, + numerator: 1, + denominator: 2, + }, + whole_seats: 1, + residual_seats: 0, + }, + ], + }, + { + residual_seat_number: 2, + change: { + assigned_by: "HighestSurplus", + selected_pg_number: 3, + pg_options: [3], + pg_assigned: [3], + surplus_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + }, + standing: [ + { + pg_number: 1, + votes_cast: 2571, + surplus_votes: { + integer: 189, + numerator: 2, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 321, + numerator: 3, + denominator: 8, + }, + whole_seats: 7, + residual_seats: 0, + }, + { + pg_number: 2, + votes_cast: 977, + surplus_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 244, + numerator: 1, + denominator: 4, + }, + whole_seats: 2, + residual_seats: 1, + }, + { + pg_number: 3, + votes_cast: 567, + surplus_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 283, + numerator: 1, + denominator: 2, + }, + whole_seats: 1, + residual_seats: 0, + }, + { + pg_number: 4, + votes_cast: 536, + surplus_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 268, + numerator: 0, + denominator: 2, + }, + whole_seats: 1, + residual_seats: 0, + }, + { + pg_number: 5, + votes_cast: 453, + surplus_votes: { + integer: 112, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 226, + numerator: 1, + denominator: 2, + }, + whole_seats: 1, + residual_seats: 0, + }, + ], + }, + { + residual_seat_number: 3, + change: { + assigned_by: "HighestSurplus", + selected_pg_number: 4, + pg_options: [4], + pg_assigned: [4], + surplus_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + }, + standing: [ + { + pg_number: 1, + votes_cast: 2571, + surplus_votes: { + integer: 189, + numerator: 2, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 321, + numerator: 3, + denominator: 8, + }, + whole_seats: 7, + residual_seats: 0, + }, + { + pg_number: 2, + votes_cast: 977, + surplus_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 244, + numerator: 1, + denominator: 4, + }, + whole_seats: 2, + residual_seats: 1, + }, + { + pg_number: 3, + votes_cast: 567, + surplus_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 189, + numerator: 0, + denominator: 3, + }, + whole_seats: 1, + residual_seats: 1, + }, + { + pg_number: 4, + votes_cast: 536, + surplus_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 268, + numerator: 0, + denominator: 2, + }, + whole_seats: 1, + residual_seats: 0, + }, + { + pg_number: 5, + votes_cast: 453, + surplus_votes: { + integer: 112, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 226, + numerator: 1, + denominator: 2, + }, + whole_seats: 1, + residual_seats: 0, + }, + ], + }, + { + residual_seat_number: 3, + change: { + assigned_by: "AbsoluteMajorityChange", + pg_retracted_seat: 4, + pg_assigned_seat: 1, + }, + standing: [ + { + pg_number: 1, + votes_cast: 2571, + surplus_votes: { + integer: 189, + numerator: 2, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 321, + numerator: 3, + denominator: 8, + }, + whole_seats: 7, + residual_seats: 1, + }, + { + pg_number: 2, + votes_cast: 977, + surplus_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 244, + numerator: 1, + denominator: 4, + }, + whole_seats: 2, + residual_seats: 1, + }, + { + pg_number: 3, + votes_cast: 567, + surplus_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 189, + numerator: 0, + denominator: 3, + }, + whole_seats: 1, + residual_seats: 1, + }, + { + pg_number: 4, + votes_cast: 536, + surplus_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 178, + numerator: 2, + denominator: 3, + }, + whole_seats: 1, + residual_seats: 0, + }, + { + pg_number: 5, + votes_cast: 453, + surplus_votes: { + integer: 112, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + next_votes_per_seat: { + integer: 226, + numerator: 1, + denominator: 2, + }, + whole_seats: 1, + residual_seats: 0, + }, + ], + }, + ], + final_standing: [ + { + pg_number: 1, + votes_cast: 2571, + surplus_votes: { + integer: 189, + numerator: 2, + denominator: 15, + }, + meets_surplus_threshold: true, + whole_seats: 7, + residual_seats: 1, + total_seats: 8, + }, + { + pg_number: 2, + votes_cast: 977, + surplus_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + meets_surplus_threshold: true, + whole_seats: 2, + residual_seats: 1, + total_seats: 3, + }, + { + pg_number: 3, + votes_cast: 567, + surplus_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + whole_seats: 1, + residual_seats: 1, + total_seats: 2, + }, + { + pg_number: 4, + votes_cast: 536, + surplus_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + whole_seats: 1, + residual_seats: 0, + total_seats: 1, + }, + { + pg_number: 5, + votes_cast: 453, + surplus_votes: { + integer: 112, + numerator: 11, + denominator: 15, + }, + meets_surplus_threshold: true, + whole_seats: 1, + residual_seats: 0, + total_seats: 1, + }, + ], +}; + +export const election_summary: ElectionSummary = { + voters_counts: { + poll_card_count: 5104, + proxy_certificate_count: 0, + voter_card_count: 0, + total_admitted_voters_count: 5104, + }, + votes_counts: { + votes_candidates_count: 5104, + blank_votes_count: 0, + invalid_votes_count: 0, + total_votes_cast_count: 5104, + }, + differences_counts: { + more_ballots_count: { + count: 0, + polling_stations: [], + }, + fewer_ballots_count: { + count: 0, + polling_stations: [], + }, + unreturned_ballots_count: { + count: 0, + polling_stations: [], + }, + too_few_ballots_handed_out_count: { + count: 0, + polling_stations: [], + }, + too_many_ballots_handed_out_count: { + count: 0, + polling_stations: [], + }, + other_explanation_count: { + count: 0, + polling_stations: [], + }, + no_explanation_count: { + count: 0, + polling_stations: [], + }, + }, + recounted_polling_stations: [], + political_group_votes: [ + { + number: 1, + total: 2571, + candidate_votes: [ + { + number: 1, + votes: 1571, + }, + { + number: 2, + votes: 1000, + }, + ], + }, + { + number: 2, + total: 977, + candidate_votes: [ + { + number: 1, + votes: 577, + }, + { + number: 2, + votes: 400, + }, + ], + }, + { + number: 3, + total: 567, + candidate_votes: [ + { + number: 1, + votes: 367, + }, + { + number: 2, + votes: 200, + }, + ], + }, + { + number: 4, + total: 536, + candidate_votes: [ + { + number: 1, + votes: 336, + }, + { + number: 2, + votes: 200, + }, + ], + }, + { + number: 5, + total: 453, + candidate_votes: [ + { + number: 1, + votes: 253, + }, + { + number: 2, + votes: 200, + }, + ], + }, + ], +}; + +export const election: Election = { + id: 3, + name: "Test Election Absolute Majority Change", + location: "Test Location", + number_of_voters: 6000, + category: "Municipal", + number_of_seats: 15, + election_date: "2026-01-01", + nomination_date: "2026-01-01", + status: "DataEntryInProgress", + political_groups: [ + { + number: 1, + name: "Political Group A", + candidates: [ + { + number: 1, + initials: "A.", + first_name: "Alice", + last_name: "Foo", + locality: "Amsterdam", + gender: "Female", + }, + { + number: 2, + initials: "C.", + first_name: "Charlie", + last_name: "Doe", + locality: "Rotterdam", + }, + ], + }, + { + number: 2, + name: "Political Group B", + candidates: [ + { + number: 1, + initials: "A.", + first_name: "Alice", + last_name: "Foo", + locality: "Amsterdam", + gender: "Female", + }, + { + number: 2, + initials: "C.", + first_name: "Charlie", + last_name: "Doe", + locality: "Rotterdam", + }, + ], + }, + { + number: 3, + name: "Political Group C", + candidates: [ + { + number: 1, + initials: "A.", + first_name: "Alice", + last_name: "Foo", + locality: "Amsterdam", + gender: "Female", + }, + { + number: 2, + initials: "C.", + first_name: "Charlie", + last_name: "Doe", + locality: "Rotterdam", + }, + ], + }, + { + number: 4, + name: "Political Group D", + candidates: [ + { + number: 1, + initials: "A.", + first_name: "Alice", + last_name: "Foo", + locality: "Amsterdam", + gender: "Female", + }, + { + number: 2, + initials: "C.", + first_name: "Charlie", + last_name: "Doe", + locality: "Rotterdam", + }, + ], + }, + { + number: 5, + name: "Political Group E", + candidates: [ + { + number: 1, + initials: "A.", + first_name: "Alice", + last_name: "Foo", + locality: "Amsterdam", + gender: "Female", + }, + { + number: 2, + initials: "C.", + first_name: "Charlie", + last_name: "Doe", + locality: "Rotterdam", + }, + ], + }, + ], +}; diff --git a/frontend/app/component/apportionment/test-data/less-than-19-seats.ts b/frontend/app/component/apportionment/test-data/less-than-19-seats.ts index ea0d03bd1..e0f5180a3 100644 --- a/frontend/app/component/apportionment/test-data/less-than-19-seats.ts +++ b/frontend/app/component/apportionment/test-data/less-than-19-seats.ts @@ -7,6 +7,7 @@ export const highest_surplus_steps: ApportionmentStep[] = [ assigned_by: "HighestSurplus", selected_pg_number: 2, pg_options: [2], + pg_assigned: [2], surplus_votes: { integer: 60, numerator: 0, @@ -158,6 +159,7 @@ export const highest_surplus_steps: ApportionmentStep[] = [ assigned_by: "HighestSurplus", selected_pg_number: 1, pg_options: [1], + pg_assigned: [1], surplus_votes: { integer: 8, numerator: 0, @@ -312,6 +314,7 @@ export const highest_average_steps: ApportionmentStep[] = [ assigned_by: "HighestAverage", selected_pg_number: 1, pg_options: [1], + pg_assigned: [1], votes_per_seat: { integer: 67, numerator: 4, @@ -463,6 +466,7 @@ export const highest_average_steps: ApportionmentStep[] = [ assigned_by: "HighestAverage", selected_pg_number: 3, pg_options: [3], + pg_assigned: [3], votes_per_seat: { integer: 58, numerator: 0, @@ -614,6 +618,7 @@ export const highest_average_steps: ApportionmentStep[] = [ assigned_by: "HighestAverage", selected_pg_number: 4, pg_options: [4], + pg_assigned: [4], votes_per_seat: { integer: 57, numerator: 0, diff --git a/frontend/app/module/apportionment/page/Apportionment.module.css b/frontend/app/module/apportionment/page/Apportionment.module.css index 5a168aa0e..8965fa7ba 100644 --- a/frontend/app/module/apportionment/page/Apportionment.module.css +++ b/frontend/app/module/apportionment/page/Apportionment.module.css @@ -14,3 +14,7 @@ width: 39rem; margin-bottom: 2rem; } + +.absolute-majority-change-information { + width: 53rem; +} diff --git a/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.test.tsx b/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.test.tsx index 868c2b98b..d51e2e724 100644 --- a/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.test.tsx +++ b/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.test.tsx @@ -6,6 +6,11 @@ import { election as election_19_or_more_seats, election_summary as election_summary_19_or_more_seats, } from "app/component/apportionment/test-data/19-or-more-seats"; +import { + apportionment as apportionment_absolute_majority_change, + election as election_absolute_majority_change, + election_summary as election_summary_absolute_majority_change, +} from "app/component/apportionment/test-data/absolute-majority-change"; import { apportionment as apportionment_less_than_19_seats, election as election_less_than_19_seats, @@ -63,6 +68,7 @@ describe("ApportionmentResidualSeatsPage", () => { expect(screen.queryByTestId("largest_surpluses_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("largest_averages_for_less_than_19_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("absolute_majority_change_information")).not.toBeInTheDocument(); }); test("Residual seats allocation tables for less than 19 seats with both systems visible", async () => { @@ -108,6 +114,7 @@ describe("ApportionmentResidualSeatsPage", () => { ]); expect(screen.queryByTestId("largest_averages_for_19_or_more_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("absolute_majority_change_information")).not.toBeInTheDocument(); }); test("Residual seats allocation tables for less than 19 seats with only surplus system visible", async () => { @@ -138,6 +145,43 @@ describe("ApportionmentResidualSeatsPage", () => { ["2", "Political Group B", "0", "60", "", "1"], ]); + expect(screen.queryByTestId("largest_averages_for_19_or_more_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("largest_averages_for_less_than_19_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("absolute_majority_change_information")).not.toBeInTheDocument(); + }); + + test("Residual seats allocation table for less than 19 seats and absolute majority change information visible", async () => { + overrideOnce("get", "/api/elections/1", 200, getElectionMockData(election_absolute_majority_change)); + overrideOnce("post", "/api/elections/1/apportionment", 200, { + apportionment: apportionment_absolute_majority_change, + election_summary: election_summary_absolute_majority_change, + } satisfies ElectionApportionmentResponse); + + renderApportionmentResidualSeatsPage(); + + expect(await screen.findByRole("heading", { level: 1, name: "Verdeling van de restzetels" })); + + expect( + await screen.findByRole("heading", { + level: 2, + name: "De restzetels gaan naar de partijen met de grootste overschotten", + }), + ); + const largest_surpluses_table = await screen.findByTestId("largest_surpluses_table"); + expect(largest_surpluses_table).toBeVisible(); + expect(largest_surpluses_table).toHaveTableContent([ + ["Lijst", "Lijstnaam", "Aantal volle zetels", "Overschot", "Aantal restzetels"], + ["1", "Political Group A", "7", "189", "2/15", "0"], + ["2", "Political Group B", "2", "296", "7/15", "1"], + ["3", "Political Group C", "1", "226", "11/15", "1"], + ["4", "Political Group D", "1", "195", "11/15", "1"], + ["5", "Political Group E", "1", "112", "11/15", "0"], + ]); + + expect(await screen.findByTestId("absolute_majority_change_information")).toHaveTextContent( + "Overeenkomstig artikel P 9 van de Kieswet (volstrekte meerderheid) wordt aan lijst 1 alsnog één zetel toegewezen en vervalt daartegenover één zetel, die eerder was toegewezen aan lijst 4.", + ); + expect(screen.queryByTestId("largest_averages_for_19_or_more_seats_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("largest_averages_for_less_than_19_seats_table")).not.toBeInTheDocument(); }); @@ -164,6 +208,7 @@ describe("ApportionmentResidualSeatsPage", () => { expect(screen.queryByTestId("largest_averages_for_19_or_more_seats_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("largest_surpluses_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("largest_averages_for_less_than_19_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("absolute_majority_change_information")).not.toBeInTheDocument(); }); test("Not available because drawing of lots is not implemented yet", async () => { @@ -187,6 +232,7 @@ describe("ApportionmentResidualSeatsPage", () => { expect(screen.queryByTestId("largest_averages_for_19_or_more_seats_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("largest_surpluses_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("largest_averages_for_less_than_19_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("absolute_majority_change_information")).not.toBeInTheDocument(); }); test("Internal Server Error renders error page", async () => { diff --git a/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.tsx b/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.tsx index 9f1631d46..bd285a76e 100644 --- a/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.tsx +++ b/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.tsx @@ -6,7 +6,7 @@ import { LargestSurplusesTable, } from "app/component/apportionment"; -import { useApportionmentContext, useElection } from "@kiesraad/api"; +import { AbsoluteMajorityChange, useApportionmentContext, useElection } from "@kiesraad/api"; import { t, tx } from "@kiesraad/i18n"; import { Alert, FormLayout, PageTitle } from "@kiesraad/ui"; @@ -25,15 +25,15 @@ function render_title_and_header() { ); } -function render_information(seats: number, residual_seats: number) { +function render_information(seats: number, residualSeats: number) { return ( {tx( - `apportionment.whole_seats_information_link.${residual_seats > 1 ? "plural" : "singular"}`, + `apportionment.whole_seats_information_link.${residualSeats > 1 ? "plural" : "singular"}`, { link: (title) => {title}, }, - { num_residual_seats: residual_seats }, + { num_residual_seats: residualSeats }, )}

@@ -66,57 +66,70 @@ export function ApportionmentResidualSeatsPage() { if (apportionment) { const highestSurplusSteps = apportionment.steps.filter((step) => step.change.assigned_by === "HighestSurplus"); const highestAverageSteps = apportionment.steps.filter((step) => step.change.assigned_by === "HighestAverage"); + const absoluteMajorityChange = apportionment.steps + .map((step) => step.change) + .find((change) => change.assigned_by === "AbsoluteMajorityChange") as AbsoluteMajorityChange | undefined; return ( <> {render_title_and_header()}
{apportionment.residual_seats > 0 ? ( - apportionment.seats >= 19 ? ( -
-

{t("apportionment.residual_seats_largest_averages")}

- {render_information(apportionment.seats, apportionment.residual_seats)} - {highestAverageSteps.length > 0 && ( - - )} -
- ) : ( - <> + <> + {apportionment.seats >= 19 ? (
-

{t("apportionment.residual_seats_largest_surpluses")}

+

{t("apportionment.residual_seats_largest_averages")}

{render_information(apportionment.seats, apportionment.residual_seats)} - {highestSurplusSteps.length > 0 && ( - 0 && ( + )}
- {highestAverageSteps.length > 0 && ( + ) : ( + <>
-

{t("apportionment.leftover_residual_seats_assignment")}

- - {t( - `apportionment.leftover_residual_seats_amount_and_information.${highestAverageSteps.length > 1 ? "plural" : "singular"}`, - { num_seats: highestAverageSteps.length }, - )} - - { - {t("apportionment.residual_seats_largest_surpluses")} + {render_information(apportionment.seats, apportionment.residual_seats)} + {highestSurplusSteps.length > 0 && ( + - } + )}
- )} - - ) + {highestAverageSteps.length > 0 && ( +
+

{t("apportionment.leftover_residual_seats_assignment")}

+ + {t( + `apportionment.leftover_residual_seats_amount_and_information.${highestAverageSteps.length > 1 ? "plural" : "singular"}`, + { num_seats: highestAverageSteps.length }, + )} + + { + + } +
+ )} + + )} + {absoluteMajorityChange && ( + + {t("apportionment.absolute_majority_change", { + pg_assigned_seat: absoluteMajorityChange.pg_assigned_seat, + pg_retracted_seat: absoluteMajorityChange.pg_retracted_seat, + })} + + )} + ) : ( {t("apportionment.no_residual_seats_to_assign")} )} diff --git a/frontend/lib/api/gen/openapi.ts b/frontend/lib/api/gen/openapi.ts index 4a924025f..6602a5dcf 100644 --- a/frontend/lib/api/gen/openapi.ts +++ b/frontend/lib/api/gen/openapi.ts @@ -138,6 +138,14 @@ export type USER_UPDATE_REQUEST_BODY = UpdateUserRequest; /** TYPES **/ +/** + * Contains information about the enactment of article P 9 of the Kieswet. + */ +export interface AbsoluteMajorityChange { + pg_assigned_seat: number; + pg_retracted_seat: number; +} + /** * The result of the apportionment procedure. This contains the number of seats and the quota that was used. It then contains the initial standing after whole seats were assigned, @@ -168,7 +176,8 @@ export interface ApportionmentStep { */ export type AssignedSeat = | (HighestAverageAssignedSeat & { assigned_by: "HighestAverage" }) - | (HighestSurplusAssignedSeat & { assigned_by: "HighestSurplus" }); + | (HighestSurplusAssignedSeat & { assigned_by: "HighestSurplus" }) + | (AbsoluteMajorityChange & { assigned_by: "AbsoluteMajorityChange" }); /** * Candidate @@ -401,6 +410,7 @@ export interface GetDataEntryResponse { * Contains the details for an assigned seat, assigned through the highest average method. */ export interface HighestAverageAssignedSeat { + pg_assigned: number[]; pg_options: number[]; selected_pg_number: number; votes_per_seat: Fraction; @@ -410,6 +420,7 @@ export interface HighestAverageAssignedSeat { * Contains the details for an assigned seat, assigned through the highest surplus method. */ export interface HighestSurplusAssignedSeat { + pg_assigned: number[]; pg_options: number[]; selected_pg_number: number; surplus_votes: Fraction; diff --git a/frontend/lib/i18n/locales/nl/apportionment.json b/frontend/lib/i18n/locales/nl/apportionment.json index 97b318642..29b6715f4 100644 --- a/frontend/lib/i18n/locales/nl/apportionment.json +++ b/frontend/lib/i18n/locales/nl/apportionment.json @@ -1,4 +1,5 @@ { + "absolute_majority_change": "Overeenkomstig artikel P 9 van de Kieswet (volstrekte meerderheid) wordt aan lijst {pg_assigned_seat} alsnog één zetel toegewezen en vervalt daartegenover één zetel, die eerder was toegewezen aan lijst {pg_retracted_seat}.", "average": "Gemiddelde", "details_residual_seats": "Verdeling van de restzetels", "details_whole_seats": "Verdeling van de volle zetels",