diff --git a/backend/openapi.json b/backend/openapi.json index 55d6433d0..d9675163a 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -445,43 +445,43 @@ "fewer_ballots_count": { "type": "integer", "format": "int32", - "description": "Number of fewer counted ballots (\"Er zijn minder stembiljetten geteld. Noteer hoeveel stembiljetten er minder zijn geteld\")", + "description": "Number of fewer counted ballots (\"Er zijn minder stembiljetten geteld. Hoeveel stembiljetten zijn er minder geteld\")", "minimum": 0 }, "more_ballots_count": { "type": "integer", "format": "int32", - "description": "Number of more counted ballots (\"Er zijn méér stembiljetten geteld. Noteer hoeveel stembiljetten er meer zijn geteld\")", + "description": "Number of more counted ballots (\"Er zijn méér stembiljetten geteld. Hoeveel stembiljetten zijn er meer geteld?\")", "minimum": 0 }, "no_explanation_count": { "type": "integer", "format": "int32", - "description": "Number of no explanations (\"Aantal keren dat er geen verklaring is voor het verschil\")", + "description": "Number of no explanations (\"Hoe vaak is er geen verklaring voor het verschil?\")", "minimum": 0 }, "other_explanation_count": { "type": "integer", "format": "int32", - "description": "Number of other explanations (\"Aantal keren dat er een andere verklaring is voor het verschil\")", + "description": "Number of other explanations (\"Hoe vaak is er een andere verklaring voor het verschil?\")", "minimum": 0 }, "too_few_ballots_handed_out_count": { "type": "integer", "format": "int32", - "description": "Number of fewer ballots handed out (\"Aantal keren dat er een stembiljet te weinig is uitgereikt\")", + "description": "Number of fewer ballots handed out (\"Hoe vaak is er een stembiljet te weinig uitgereikt?\")", "minimum": 0 }, "too_many_ballots_handed_out_count": { "type": "integer", "format": "int32", - "description": "Number of more ballots handed out (\"Aantal keren dat er een stembiljet teveel is uitgereikt\")", + "description": "Number of more ballots handed out (\"Hoe vaak is er een stembiljet te veel uitgereikt?\")", "minimum": 0 }, "unreturned_ballots_count": { "type": "integer", "format": "int32", - "description": "Number of unreturned ballots (\"Aantal keren dat een kiezer het stembiljet niet heeft ingeleverd\")", + "description": "Number of unreturned ballots (\"Hoe vaak heeft een kiezer het stembiljet niet ingeleverd?\")", "minimum": 0 } } @@ -697,8 +697,9 @@ }, "PollingStationResults": { "type": "object", - "description": "PollingStationResults, following the fields in\n\"Model N 10-1. Proces-verbaal van een stembureau\"\n", + "description": "PollingStationResults, following the fields in\n\"Model Na 31-2. Proces-verbaal van een gemeentelijk stembureau/stembureau voor het openbaar lichaam\nin een gemeente/openbaar lichaam waar een centrale stemopneming wordt verricht\"\n\"Bijlage 2: uitkomsten per stembureau\"\n utoipa::openapi::OpenApi { polling_station::PollingStationType, polling_station::PollingStation, polling_station::VotersCounts, + polling_station::VotersRecounts, polling_station::VotesCounts, ), ), diff --git a/backend/src/polling_station/mod.rs b/backend/src/polling_station/mod.rs index 50a83514d..30656b9b5 100644 --- a/backend/src/polling_station/mod.rs +++ b/backend/src/polling_station/mod.rs @@ -177,6 +177,7 @@ mod tests { async fn test_polling_station_data_entry_valid(pool: SqlitePool) { let mut request_body = DataEntryRequest { data: PollingStationResults { + recounted: false, voters_counts: VotersCounts { poll_card_count: 100, // incorrect proxy_certificate_count: 1, @@ -189,6 +190,7 @@ mod tests { invalid_votes_count: 2, total_votes_cast_count: 100, }, + voters_recounts: None, differences_counts: DifferencesCounts { more_ballots_count: 0, fewer_ballots_count: 0, diff --git a/backend/src/polling_station/structs.rs b/backend/src/polling_station/structs.rs index 6e6c737fa..00b717375 100644 --- a/backend/src/polling_station/structs.rs +++ b/backend/src/polling_station/structs.rs @@ -69,17 +69,23 @@ impl From for PollingStationType { } /// PollingStationResults, following the fields in -/// "Model N 10-1. Proces-verbaal van een stembureau" -/// +/// "Model Na 31-2. Proces-verbaal van een gemeentelijk stembureau/stembureau voor het openbaar lichaam +/// in een gemeente/openbaar lichaam waar een centrale stemopneming wordt verricht" +/// "Bijlage 2: uitkomsten per stembureau" +/// , + /// Differences counts ("3. Verschil tussen het aantal toegelaten kiezers en het aantal getelde stembiljetten") pub differences_counts: DifferencesCounts, - /// Vote counts for each candidate in each political group ("Aantal stemmen per lijst en kandidaat") + /// Vote counts per list and candidate (5. "Aantal stemmen per lijst en kandidaat") pub political_group_votes: Vec, } @@ -90,33 +96,154 @@ impl Validate for PollingStationResults { validation_results: &mut ValidationResults, field_name: String, ) -> Result<(), DataError> { - self.voters_counts.validate( - election, - validation_results, - format!("{field_name}.voters_counts"), - )?; self.votes_counts.validate( election, validation_results, format!("{field_name}.votes_counts"), )?; - self.political_group_votes.validate( - election, - validation_results, - format!("{field_name}.political_group_votes"), - )?; - if identical_counts(&self.voters_counts, &self.votes_counts) { + let total_votes_counts = self.votes_counts.total_votes_cast_count; + let total_voters_counts: Count; + + // if recounted = true + if let Some(voters_recounts) = &self.voters_recounts { + total_voters_counts = voters_recounts.total_admitted_voters_recount; + voters_recounts.validate( + election, + validation_results, + format!("{field_name}.voters_recounts"), + )?; + + // W.210 validate that the numbers in voters_recounts and votes_counts are not the same + if identical_voters_recounts_and_votes_counts(voters_recounts, &self.votes_counts) { + validation_results.warnings.push(ValidationResult { + fields: vec![ + format!("{field_name}.voters_recounts"), + format!("{field_name}.votes_counts"), + ], + code: ValidationResultCode::EqualInput, + }); + } + // if recounted = false + } else { + total_voters_counts = self.voters_counts.total_admitted_voters_count; + self.voters_counts.validate( + election, + validation_results, + format!("{field_name}.voters_counts"), + )?; + + // W.209 validate that the numbers in voters_counts and votes_counts are not the same + if identical_voters_counts_and_votes_counts(&self.voters_counts, &self.votes_counts) { + validation_results.warnings.push(ValidationResult { + fields: vec![ + format!("{field_name}.voters_counts"), + format!("{field_name}.votes_counts"), + ], + code: ValidationResultCode::EqualInput, + }); + } + } + + // F.301 (recounted = false) or F.302 (recounted = true) + // validate that the difference for more ballots counted is correct + if total_voters_counts < total_votes_counts + && (total_votes_counts - total_voters_counts + != self.differences_counts.more_ballots_count) + { + validation_results.errors.push(ValidationResult { + fields: vec![format!( + "{field_name}.differences_counts.more_ballots_count" + )], + code: ValidationResultCode::IncorrectDifference, + }); + } + + // F.303 (recounted = false) or F.304 (recounted = true) + // validate that the difference for fewer ballots counted is correct + if total_voters_counts > total_votes_counts + && (total_voters_counts - total_votes_counts + != self.differences_counts.fewer_ballots_count) + { + validation_results.errors.push(ValidationResult { + fields: vec![format!( + "{field_name}.differences_counts.fewer_ballots_count" + )], + code: ValidationResultCode::IncorrectDifference, + }); + } + + // W.301 validate that only more or fewer ballots counted is filled in when there is a difference in the totals + if total_voters_counts != total_votes_counts + && (self.differences_counts.more_ballots_count != 0 + && self.differences_counts.fewer_ballots_count != 0) + { + validation_results.warnings.push(ValidationResult { + fields: vec![ + format!("{field_name}.differences_counts.more_ballots_count"), + format!("{field_name}.differences_counts.fewer_ballots_count"), + ], + code: ValidationResultCode::ConflictingDifferences, + }); + } + + // W.304 (recounted = false) or W.305 (recounted = true) + // validate that no difference should be filled in when there is no difference in the totals + if total_voters_counts == total_votes_counts + && (self.differences_counts.more_ballots_count != 0 + || self.differences_counts.fewer_ballots_count != 0) + { + if self.differences_counts.more_ballots_count != 0 { + validation_results.warnings.push(ValidationResult { + fields: vec![format!( + "{field_name}.differences_counts.more_ballots_count" + )], + code: ValidationResultCode::NoDifferenceExpected, + }); + } + if self.differences_counts.fewer_ballots_count != 0 { + validation_results.warnings.push(ValidationResult { + fields: vec![format!( + "{field_name}.differences_counts.fewer_ballots_count" + )], + code: ValidationResultCode::NoDifferenceExpected, + }); + } + } + + // W.306 validate that no difference specifics should be filled in when there is no difference in the totals + if total_voters_counts == total_votes_counts + && (self.differences_counts.unreturned_ballots_count != 0 + || self.differences_counts.too_few_ballots_handed_out_count != 0 + || self.differences_counts.too_many_ballots_handed_out_count != 0 + || self.differences_counts.other_explanation_count != 0 + || self.differences_counts.no_explanation_count != 0) + { validation_results.warnings.push(ValidationResult { fields: vec![ - format!("{field_name}.voters_counts"), - format!("{field_name}.votes_counts"), + format!("{field_name}.differences_counts.unreturned_ballots_count"), + format!("{field_name}.differences_counts.too_few_ballots_handed_out_count"), + format!("{field_name}.differences_counts.too_many_ballots_handed_out_count"), + format!("{field_name}.differences_counts.other_explanation_count"), + format!("{field_name}.differences_counts.no_explanation_count"), ], - code: ValidationResultCode::EqualInput, + code: ValidationResultCode::NoDifferenceExpected, }); } - // validate that the total number of valid votes is equal to the sum of all political group totals + self.differences_counts.validate( + election, + validation_results, + format!("{field_name}.differences_counts"), + )?; + + self.political_group_votes.validate( + election, + validation_results, + format!("{field_name}.political_group_votes"), + )?; + + // F.204 validate that the total number of valid votes is equal to the sum of all political group totals if self.votes_counts.votes_candidates_counts as u64 != self .political_group_votes @@ -171,7 +298,7 @@ pub struct VotersCounts { /// Check if all voters counts and votes counts are equal to zero. /// Used in validations where this is an edge case that needs to be handled. -fn all_zero(voters: &VotersCounts, votes: &VotesCounts) -> bool { +fn all_zero_voters_counts_and_votes_counts(voters: &VotersCounts, votes: &VotesCounts) -> bool { voters.poll_card_count == 0 && voters.proxy_certificate_count == 0 && voters.voter_card_count == 0 @@ -182,13 +309,12 @@ fn all_zero(voters: &VotersCounts, votes: &VotesCounts) -> bool { && votes.total_votes_cast_count == 0 } -/// Check if the voters counts and votes counts are identical, which should -/// result in a warning. +/// Check if the voters counts and votes counts are identical, which should result in a warning. /// /// This is not implemented as Eq because there is no true equality relation /// between these two sets of numbers. -fn identical_counts(voters: &VotersCounts, votes: &VotesCounts) -> bool { - !all_zero(voters, votes) +fn identical_voters_counts_and_votes_counts(voters: &VotersCounts, votes: &VotesCounts) -> bool { + !all_zero_voters_counts_and_votes_counts(voters, votes) && voters.poll_card_count == votes.votes_candidates_counts && voters.proxy_certificate_count == votes.blank_votes_count && voters.voter_card_count == votes.invalid_votes_count @@ -224,7 +350,7 @@ impl Validate for VotersCounts { format!("{field_name}.total_admitted_voters_count"), )?; - // validate that total_admitted_voters_count == poll_card_count + proxy_certificate_count + voter_card_count + // F.201 validate that total_admitted_voters_count == poll_card_count + proxy_certificate_count + voter_card_count if self.poll_card_count + self.proxy_certificate_count + self.voter_card_count != self.total_admitted_voters_count { @@ -289,7 +415,7 @@ impl Validate for VotesCounts { format!("{field_name}.total_votes_cast_count"), )?; - // validate that total_votes_cast_count == votes_candidates_counts + blank_votes_count + invalid_votes_count + // F.202 validate that total_votes_cast_count == votes_candidates_counts + blank_votes_count + invalid_votes_count if self.votes_candidates_counts + self.blank_votes_count + self.invalid_votes_count != self.total_votes_cast_count { @@ -309,7 +435,7 @@ impl Validate for VotesCounts { return Ok(()); } - // validate that number of blank votes is no more than 3% + // W.201 validate that number of blank votes is no more than 3% if above_percentage_threshold(self.blank_votes_count, self.total_votes_cast_count, 3) { validation_results.warnings.push(ValidationResult { fields: vec![ @@ -320,7 +446,7 @@ impl Validate for VotesCounts { }); } - // validate that number of invalid votes is no more than 3% + // W.202 validate that number of invalid votes is no more than 3% if above_percentage_threshold(self.invalid_votes_count, self.total_votes_cast_count, 3) { validation_results.warnings.push(ValidationResult { fields: vec![ @@ -334,33 +460,213 @@ impl Validate for VotesCounts { } } +/// Voters recounts, part of the polling station results. +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq, Eq, Hash)] +pub struct VotersRecounts { + /// Number of valid poll cards ("Aantal geldige stempassen") + #[schema(value_type = u32)] + pub poll_card_recount: Count, + /// Number of valid proxy certificates ("Aantal geldige volmachtbewijzen") + #[schema(value_type = u32)] + pub proxy_certificate_recount: Count, + /// Number of valid voter cards ("Aantal geldige kiezerspassen") + #[schema(value_type = u32)] + pub voter_card_recount: Count, + /// Total number of admitted voters ("Totaal aantal toegelaten kiezers") + #[schema(value_type = u32)] + pub total_admitted_voters_recount: Count, +} + +/// Check if all voters recounts and votes counts are equal to zero. +/// Used in validations where this is an edge case that needs to be handled. +fn all_zero_voters_recounts_and_votes_counts(voters: &VotersRecounts, votes: &VotesCounts) -> bool { + voters.poll_card_recount == 0 + && voters.proxy_certificate_recount == 0 + && voters.voter_card_recount == 0 + && voters.total_admitted_voters_recount == 0 + && votes.votes_candidates_counts == 0 + && votes.blank_votes_count == 0 + && votes.invalid_votes_count == 0 + && votes.total_votes_cast_count == 0 +} + +/// Check if the voters recounts and votes counts are identical, which should result in a warning. +/// +/// This is not implemented as Eq because there is no true equality relation +/// between these two sets of numbers. +fn identical_voters_recounts_and_votes_counts( + voters: &VotersRecounts, + votes: &VotesCounts, +) -> bool { + !all_zero_voters_recounts_and_votes_counts(voters, votes) + && voters.poll_card_recount == votes.votes_candidates_counts + && voters.proxy_certificate_recount == votes.blank_votes_count + && voters.voter_card_recount == votes.invalid_votes_count + && voters.total_admitted_voters_recount == votes.total_votes_cast_count +} + +impl Validate for VotersRecounts { + fn validate( + &self, + election: &Election, + validation_results: &mut ValidationResults, + field_name: String, + ) -> Result<(), DataError> { + // validate all counts + self.poll_card_recount.validate( + election, + validation_results, + format!("{field_name}.poll_card_recount"), + )?; + self.proxy_certificate_recount.validate( + election, + validation_results, + format!("{field_name}.proxy_certificate_recount"), + )?; + self.voter_card_recount.validate( + election, + validation_results, + format!("{field_name}.voter_card_recount"), + )?; + self.total_admitted_voters_recount.validate( + election, + validation_results, + format!("{field_name}.total_admitted_voters_recount"), + )?; + + // F.203 validate that total_admitted_voters_recount == poll_card_recount + proxy_certificate_recount + voter_card_recount + if self.poll_card_recount + self.proxy_certificate_recount + self.voter_card_recount + != self.total_admitted_voters_recount + { + validation_results.errors.push(ValidationResult { + fields: vec![ + format!("{field_name}.total_admitted_voters_recount"), + format!("{field_name}.poll_card_recount"), + format!("{field_name}.proxy_certificate_recount"), + format!("{field_name}.voter_card_recount"), + ], + code: ValidationResultCode::IncorrectTotal, + }); + } + Ok(()) + } +} + /// Differences counts, part of the polling station results. #[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq, Eq, Hash)] pub struct DifferencesCounts { - /// Number of more counted ballots ("Er zijn méér stembiljetten geteld. Noteer hoeveel stembiljetten er meer zijn geteld") + /// Number of more counted ballots ("Er zijn méér stembiljetten geteld. Hoeveel stembiljetten zijn er meer geteld?") #[schema(value_type = u32)] pub more_ballots_count: Count, - /// Number of fewer counted ballots ("Er zijn minder stembiljetten geteld. Noteer hoeveel stembiljetten er minder zijn geteld") + /// Number of fewer counted ballots ("Er zijn minder stembiljetten geteld. Hoeveel stembiljetten zijn er minder geteld") #[schema(value_type = u32)] pub fewer_ballots_count: Count, - /// Number of unreturned ballots ("Aantal keren dat een kiezer het stembiljet niet heeft ingeleverd") + /// Number of unreturned ballots ("Hoe vaak heeft een kiezer het stembiljet niet ingeleverd?") #[schema(value_type = u32)] pub unreturned_ballots_count: Count, - /// Number of fewer ballots handed out ("Aantal keren dat er een stembiljet te weinig is uitgereikt") + /// Number of fewer ballots handed out ("Hoe vaak is er een stembiljet te weinig uitgereikt?") #[schema(value_type = u32)] pub too_few_ballots_handed_out_count: Count, - /// Number of more ballots handed out ("Aantal keren dat er een stembiljet teveel is uitgereikt") + /// Number of more ballots handed out ("Hoe vaak is er een stembiljet te veel uitgereikt?") #[schema(value_type = u32)] pub too_many_ballots_handed_out_count: Count, - /// Number of other explanations ("Aantal keren dat er een andere verklaring is voor het verschil") + /// Number of other explanations ("Hoe vaak is er een andere verklaring voor het verschil?") #[schema(value_type = u32)] pub other_explanation_count: Count, - /// Number of no explanations ("Aantal keren dat er geen verklaring is voor het verschil") + /// Number of no explanations ("Hoe vaak is er geen verklaring voor het verschil?") #[schema(value_type = u32)] pub no_explanation_count: Count, } -// TODO: impl Validate for DifferencesCounts +impl Validate for DifferencesCounts { + fn validate( + &self, + election: &Election, + validation_results: &mut ValidationResults, + field_name: String, + ) -> Result<(), DataError> { + // validate all counts + self.more_ballots_count.validate( + election, + validation_results, + format!("{field_name}.more_ballots_count"), + )?; + self.fewer_ballots_count.validate( + election, + validation_results, + format!("{field_name}.fewer_ballots_count"), + )?; + self.unreturned_ballots_count.validate( + election, + validation_results, + format!("{field_name}.unreturned_ballots_count"), + )?; + self.too_few_ballots_handed_out_count.validate( + election, + validation_results, + format!("{field_name}.too_few_ballots_handed_out_count"), + )?; + self.too_many_ballots_handed_out_count.validate( + election, + validation_results, + format!("{field_name}.too_many_ballots_handed_out_count"), + )?; + self.other_explanation_count.validate( + election, + validation_results, + format!("{field_name}.other_explanation_count"), + )?; + self.no_explanation_count.validate( + election, + validation_results, + format!("{field_name}.no_explanation_count"), + )?; + + // W.302 validate that more_ballots_count == too_many_ballots_handed_out_count + other_explanation_count + no_explanation_count - unreturned_ballots_count - too_few_ballots_handed_out_count + if self.more_ballots_count != 0 + && (i64::from(self.too_many_ballots_handed_out_count) + + i64::from(self.other_explanation_count) + + i64::from(self.no_explanation_count) + - i64::from(self.unreturned_ballots_count) + - i64::from(self.too_few_ballots_handed_out_count) + != i64::from(self.more_ballots_count)) + { + validation_results.warnings.push(ValidationResult { + fields: vec![ + format!("{field_name}.more_ballots_count"), + format!("{field_name}.too_many_ballots_handed_out_count"), + format!("{field_name}.other_explanation_count"), + format!("{field_name}.no_explanation_count"), + format!("{field_name}.unreturned_ballots_count"), + format!("{field_name}.too_few_ballots_handed_out_count"), + ], + code: ValidationResultCode::IncorrectTotal, + }); + } + // W.303 validate that fewer_ballots_count == unreturned_ballots_count + too_few_ballots_handed_out_count + other_explanation_count + no_explanation_count + if self.fewer_ballots_count != 0 + && (i64::from(self.unreturned_ballots_count) + + i64::from(self.too_few_ballots_handed_out_count) + + i64::from(self.other_explanation_count) + + i64::from(self.no_explanation_count) + - i64::from(self.too_many_ballots_handed_out_count) + != i64::from(self.fewer_ballots_count)) + { + validation_results.warnings.push(ValidationResult { + fields: vec![ + format!("{field_name}.fewer_ballots_count"), + format!("{field_name}.unreturned_ballots_count"), + format!("{field_name}.too_few_ballots_handed_out_count"), + format!("{field_name}.too_many_ballots_handed_out_count"), + format!("{field_name}.other_explanation_count"), + format!("{field_name}.no_explanation_count"), + ], + code: ValidationResultCode::IncorrectTotal, + }); + } + Ok(()) + } +} #[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq, Eq, Hash)] pub struct PoliticalGroupVotes { @@ -436,7 +742,7 @@ impl Validate for PoliticalGroupVotes { self.total .validate(election, validation_results, format!("{field_name}.total"))?; - // validate whether if the total number of votes matches the sum of all candidate votes, + // F.401 validate whether the total number of votes matches the sum of all candidate votes, // cast to u64 to avoid overflow if self.total as u64 != self @@ -480,23 +786,26 @@ mod tests { use super::*; #[test] - fn test_polling_station_results_validation() { + fn test_polling_station_results_incorrect_total_and_difference_validation() { + // test F.201 incorrect total, F.202 incorrect total and F.301 incorrect difference let mut validation_results = ValidationResults::default(); let polling_station_results = PollingStationResults { + recounted: false, voters_counts: VotersCounts { poll_card_count: 1, proxy_certificate_count: 2, voter_card_count: 3, - total_admitted_voters_count: 5, // incorrect total + total_admitted_voters_count: 5, // F.201 incorrect total }, votes_counts: VotesCounts { - votes_candidates_counts: 5, - blank_votes_count: 6, - invalid_votes_count: 7, - total_votes_cast_count: 20, // incorrect total + votes_candidates_counts: 2, + blank_votes_count: 1, + invalid_votes_count: 4, + total_votes_cast_count: 8, // F.202 incorrect total }, + voters_recounts: None, differences_counts: DifferencesCounts { - more_ballots_count: 0, + more_ballots_count: 0, // F.301 incorrect difference fewer_ballots_count: 0, unreturned_ballots_count: 0, too_few_ballots_handed_out_count: 0, @@ -506,10 +815,95 @@ mod tests { }, political_group_votes: vec![PoliticalGroupVotes { number: 1, - total: 5, + total: 2, + candidate_votes: vec![CandidateVotes { + number: 1, + votes: 2, + }], + }], + }; + let election = election_fixture(&[1]); + polling_station_results + .validate( + &election, + &mut validation_results, + "polling_station_results".to_string(), + ) + .unwrap(); + assert_eq!(validation_results.errors.len(), 3); + assert_eq!(validation_results.warnings.len(), 0); + assert_eq!( + validation_results.errors[0].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.errors[0].fields, + vec![ + "polling_station_results.votes_counts.total_votes_cast_count", + "polling_station_results.votes_counts.votes_candidates_counts", + "polling_station_results.votes_counts.blank_votes_count", + "polling_station_results.votes_counts.invalid_votes_count" + ] + ); + assert_eq!( + validation_results.errors[1].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.errors[1].fields, + vec![ + "polling_station_results.voters_counts.total_admitted_voters_count", + "polling_station_results.voters_counts.poll_card_count", + "polling_station_results.voters_counts.proxy_certificate_count", + "polling_station_results.voters_counts.voter_card_count" + ] + ); + assert_eq!( + validation_results.errors[2].code, + ValidationResultCode::IncorrectDifference + ); + assert_eq!( + validation_results.errors[2].fields, + vec!["polling_station_results.differences_counts.more_ballots_count"] + ); + + // test F.201 incorrect total, F.202 incorrect total, F.203 incorrect total and F.302 incorrect difference + validation_results = ValidationResults::default(); + let polling_station_results = PollingStationResults { + recounted: true, + voters_counts: VotersCounts { + poll_card_count: 1, + proxy_certificate_count: 2, + voter_card_count: 3, + total_admitted_voters_count: 5, // F.201 incorrect total + }, + votes_counts: VotesCounts { + votes_candidates_counts: 3, + blank_votes_count: 2, + invalid_votes_count: 1, + total_votes_cast_count: 5, // F.202 incorrect total + }, + voters_recounts: Some(VotersRecounts { + poll_card_recount: 2, + proxy_certificate_recount: 1, + voter_card_recount: 4, + total_admitted_voters_recount: 8, // F.203 incorrect total + }), + differences_counts: DifferencesCounts { + more_ballots_count: 0, + fewer_ballots_count: 0, // F.302 incorrect difference + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 0, + no_explanation_count: 0, + }, + political_group_votes: vec![PoliticalGroupVotes { + number: 1, + total: 3, candidate_votes: vec![CandidateVotes { number: 1, - votes: 5, + votes: 3, }], }], }; @@ -521,27 +915,277 @@ mod tests { "polling_station_results".to_string(), ) .unwrap(); - assert_eq!(validation_results.errors.len(), 2); + assert_eq!(validation_results.errors.len(), 3); assert_eq!(validation_results.warnings.len(), 0); + assert_eq!( + validation_results.errors[0].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.errors[0].fields, + vec![ + "polling_station_results.votes_counts.total_votes_cast_count", + "polling_station_results.votes_counts.votes_candidates_counts", + "polling_station_results.votes_counts.blank_votes_count", + "polling_station_results.votes_counts.invalid_votes_count" + ] + ); + assert_eq!( + validation_results.errors[1].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.errors[1].fields, + vec![ + "polling_station_results.voters_recounts.total_admitted_voters_recount", + "polling_station_results.voters_recounts.poll_card_recount", + "polling_station_results.voters_recounts.proxy_certificate_recount", + "polling_station_results.voters_recounts.voter_card_recount" + ] + ); + assert_eq!( + validation_results.errors[2].code, + ValidationResultCode::IncorrectDifference + ); + assert_eq!( + validation_results.errors[2].fields, + vec!["polling_station_results.differences_counts.fewer_ballots_count"] + ); } #[test] - fn test_polling_station_identical_counts_validation() { + fn test_polling_station_results_wrong_and_no_difference_validation() { + // test W.301 conflicting differences let mut validation_results = ValidationResults::default(); let polling_station_results = PollingStationResults { + recounted: false, + voters_counts: VotersCounts { + poll_card_count: 50, + proxy_certificate_count: 2, + voter_card_count: 4, + total_admitted_voters_count: 56, + }, + votes_counts: VotesCounts { + votes_candidates_counts: 50, + blank_votes_count: 1, + invalid_votes_count: 1, + total_votes_cast_count: 52, + }, + voters_recounts: None, + differences_counts: DifferencesCounts { + more_ballots_count: 4, // W.301 conflicting differences + fewer_ballots_count: 4, // W.301 conflicting differences + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 2, + no_explanation_count: 2, + }, + political_group_votes: vec![PoliticalGroupVotes { + number: 1, + total: 50, + candidate_votes: vec![CandidateVotes { + number: 1, + votes: 50, + }], + }], + }; + let election = election_fixture(&[1]); + polling_station_results + .validate( + &election, + &mut validation_results, + "polling_station_results".to_string(), + ) + .unwrap(); + assert_eq!(validation_results.errors.len(), 0); + assert_eq!(validation_results.warnings.len(), 1); + assert_eq!( + validation_results.warnings[0].code, + ValidationResultCode::ConflictingDifferences + ); + assert_eq!( + validation_results.warnings[0].fields, + vec![ + "polling_station_results.differences_counts.more_ballots_count", + "polling_station_results.differences_counts.fewer_ballots_count" + ] + ); + + // test W.304 and W.306 no difference expected + validation_results = ValidationResults::default(); + let polling_station_results = PollingStationResults { + recounted: false, + voters_counts: VotersCounts { + poll_card_count: 46, + proxy_certificate_count: 2, + voter_card_count: 4, + total_admitted_voters_count: 52, + }, + votes_counts: VotesCounts { + votes_candidates_counts: 50, + blank_votes_count: 1, + invalid_votes_count: 1, + total_votes_cast_count: 52, + }, + voters_recounts: None, + differences_counts: DifferencesCounts { + more_ballots_count: 4, // W.304 no difference expected + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 2, // W.306 no difference expected + no_explanation_count: 2, // W.306 no difference expected + }, + political_group_votes: vec![PoliticalGroupVotes { + number: 1, + total: 50, + candidate_votes: vec![CandidateVotes { + number: 1, + votes: 50, + }], + }], + }; + let election = election_fixture(&[1]); + polling_station_results + .validate( + &election, + &mut validation_results, + "polling_station_results".to_string(), + ) + .unwrap(); + assert_eq!(validation_results.errors.len(), 0); + assert_eq!(validation_results.warnings.len(), 2); + assert_eq!( + validation_results.warnings[0].code, + ValidationResultCode::NoDifferenceExpected + ); + assert_eq!( + validation_results.warnings[0].fields, + vec!["polling_station_results.differences_counts.more_ballots_count",] + ); + assert_eq!( + validation_results.warnings[1].code, + ValidationResultCode::NoDifferenceExpected + ); + assert_eq!( + validation_results.warnings[1].fields, + vec![ + "polling_station_results.differences_counts.unreturned_ballots_count", + "polling_station_results.differences_counts.too_few_ballots_handed_out_count", + "polling_station_results.differences_counts.too_many_ballots_handed_out_count", + "polling_station_results.differences_counts.other_explanation_count", + "polling_station_results.differences_counts.no_explanation_count", + ] + ); + + // test W.305 and W.306 no difference expected and F.204 incorrect total + validation_results = ValidationResults::default(); + let polling_station_results = PollingStationResults { + recounted: true, + voters_counts: VotersCounts { + poll_card_count: 50, + proxy_certificate_count: 2, + voter_card_count: 4, + total_admitted_voters_count: 56, + }, + votes_counts: VotesCounts { + votes_candidates_counts: 50, + blank_votes_count: 1, + invalid_votes_count: 1, + total_votes_cast_count: 52, + }, + voters_recounts: Some(VotersRecounts { + poll_card_recount: 46, + proxy_certificate_recount: 2, + voter_card_recount: 4, + total_admitted_voters_recount: 52, + }), + differences_counts: DifferencesCounts { + more_ballots_count: 0, + fewer_ballots_count: 4, // W.305 no difference expected + unreturned_ballots_count: 1, // W.306 no difference expected + too_few_ballots_handed_out_count: 1, // W.306 no difference expected + too_many_ballots_handed_out_count: 0, + other_explanation_count: 1, // W.306 no difference expected + no_explanation_count: 1, // W.306 no difference expected + }, + political_group_votes: vec![PoliticalGroupVotes { + number: 1, + total: 49, // F.204 incorrect total + candidate_votes: vec![CandidateVotes { + number: 1, + votes: 49, + }], + }], + }; + let election = election_fixture(&[1]); + polling_station_results + .validate( + &election, + &mut validation_results, + "polling_station_results".to_string(), + ) + .unwrap(); + assert_eq!(validation_results.errors.len(), 1); + assert_eq!(validation_results.warnings.len(), 2); + assert_eq!( + validation_results.errors[0].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.errors[0].fields, + vec![ + "polling_station_results.votes_counts.votes_candidates_counts", + "polling_station_results.political_group_votes" + ] + ); + assert_eq!( + validation_results.warnings[0].code, + ValidationResultCode::NoDifferenceExpected + ); + assert_eq!( + validation_results.warnings[0].fields, + vec!["polling_station_results.differences_counts.fewer_ballots_count"] + ); + assert_eq!( + validation_results.warnings[1].code, + ValidationResultCode::NoDifferenceExpected + ); + assert_eq!( + validation_results.warnings[1].fields, + vec![ + "polling_station_results.differences_counts.unreturned_ballots_count", + "polling_station_results.differences_counts.too_few_ballots_handed_out_count", + "polling_station_results.differences_counts.too_many_ballots_handed_out_count", + "polling_station_results.differences_counts.other_explanation_count", + "polling_station_results.differences_counts.no_explanation_count" + ] + ); + } + + #[test] + fn test_polling_station_identical_counts_validation() { + let mut validation_results = ValidationResults::default(); + // test W.209 equal input + let mut polling_station_results = PollingStationResults { + recounted: false, voters_counts: VotersCounts { + // W.209 equal input poll_card_count: 1000, proxy_certificate_count: 1, voter_card_count: 1, total_admitted_voters_count: 1002, }, - // same as above votes_counts: VotesCounts { + // W.209 equal input votes_candidates_counts: 1000, blank_votes_count: 1, invalid_votes_count: 1, total_votes_cast_count: 1002, }, + voters_recounts: None, differences_counts: DifferencesCounts { more_ballots_count: 0, fewer_ballots_count: 0, @@ -574,12 +1218,58 @@ mod tests { validation_results.warnings[0].code, ValidationResultCode::EqualInput ); + assert_eq!( + validation_results.warnings[0].fields, + vec![ + "polling_station_results.voters_counts", + "polling_station_results.votes_counts" + ] + ); + + // test W.210 equal input + validation_results = ValidationResults::default(); + polling_station_results.recounted = true; + // voters_counts is not equal to votes_counts + polling_station_results.voters_counts = VotersCounts { + poll_card_count: 998, + proxy_certificate_count: 1, + voter_card_count: 1, + total_admitted_voters_count: 1000, + }; + // voters_recounts is now equal to votes_counts: W.210 equal input + polling_station_results.voters_recounts = Some(VotersRecounts { + poll_card_recount: 1000, + proxy_certificate_recount: 1, + voter_card_recount: 1, + total_admitted_voters_recount: 1002, + }); + polling_station_results + .validate( + &election, + &mut validation_results, + "polling_station_results".to_string(), + ) + .unwrap(); + assert_eq!(validation_results.errors.len(), 0); + assert_eq!(validation_results.warnings.len(), 1); + assert_eq!( + validation_results.warnings[0].code, + ValidationResultCode::EqualInput + ); + assert_eq!( + validation_results.warnings[0].fields, + vec![ + "polling_station_results.voters_recounts", + "polling_station_results.votes_counts" + ] + ); } #[test] fn test_voters_counts_validation() { let mut validation_results = ValidationResults::default(); - let voters_counts = VotersCounts { + // test out of range + let mut voters_counts = VotersCounts { poll_card_count: 1_000_000_001, // out of range proxy_certificate_count: 2, voter_card_count: 3, @@ -592,17 +1282,65 @@ mod tests { "voters_counts".to_string(), ); assert!(res.is_err()); + + // test F.201 incorrect total + validation_results = ValidationResults::default(); + voters_counts = VotersCounts { + poll_card_count: 5, + proxy_certificate_count: 6, + voter_card_count: 7, + total_admitted_voters_count: 20, // F.201 incorrect total + }; + + voters_counts + .validate( + &election, + &mut validation_results, + "voters_counts".to_string(), + ) + .unwrap(); + assert_eq!(validation_results.errors.len(), 1); + assert_eq!(validation_results.warnings.len(), 0); + assert_eq!( + validation_results.errors[0].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.errors[0].fields, + vec![ + "voters_counts.total_admitted_voters_count", + "voters_counts.poll_card_count", + "voters_counts.proxy_certificate_count", + "voters_counts.voter_card_count" + ] + ); } #[test] fn test_votes_counts_validation() { - // test incorrect total let mut validation_results = ValidationResults::default(); - let votes_counts = VotesCounts { + // test out of range + let mut votes_counts = VotesCounts { + votes_candidates_counts: 1_000_000_001, // out of range + blank_votes_count: 2, + invalid_votes_count: 3, + total_votes_cast_count: 1_000_000_006, // correct but out of range + }; + let election = election_fixture(&[]); + let res = votes_counts.validate( + &election, + &mut validation_results, + "votes_counts".to_string(), + ); + assert!(res.is_err()); + + // test F.202 incorrect total + validation_results = ValidationResults::default(); + votes_counts = VotesCounts { votes_candidates_counts: 5, blank_votes_count: 6, invalid_votes_count: 7, - total_votes_cast_count: 20, // incorrect total + total_votes_cast_count: 20, // F.202 incorrect total }; let election = election_fixture(&[]); votes_counts @@ -614,12 +1352,25 @@ mod tests { .unwrap(); assert_eq!(validation_results.errors.len(), 1); assert_eq!(validation_results.warnings.len(), 0); + assert_eq!( + validation_results.errors[0].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.errors[0].fields, + vec![ + "votes_counts.total_votes_cast_count", + "votes_counts.votes_candidates_counts", + "votes_counts.blank_votes_count", + "votes_counts.invalid_votes_count" + ] + ); - // test high number of blank votes - let mut validation_results = ValidationResults::default(); - let votes_counts = VotesCounts { + // test W.201 high number of blank votes + validation_results = ValidationResults::default(); + votes_counts = VotesCounts { votes_candidates_counts: 100, - blank_votes_count: 10, // high number of blank votes + blank_votes_count: 10, // W.201 above threshold invalid_votes_count: 1, total_votes_cast_count: 111, }; @@ -632,13 +1383,24 @@ mod tests { .unwrap(); assert_eq!(validation_results.errors.len(), 0); assert_eq!(validation_results.warnings.len(), 1); + assert_eq!( + validation_results.warnings[0].code, + ValidationResultCode::AboveThreshold + ); + assert_eq!( + validation_results.warnings[0].fields, + vec![ + "votes_counts.blank_votes_count", + "votes_counts.total_votes_cast_count" + ] + ); - // test high number of invalid votes - let mut validation_results = ValidationResults::default(); - let votes_counts = VotesCounts { + // test W.202 high number of invalid votes + validation_results = ValidationResults::default(); + votes_counts = VotesCounts { votes_candidates_counts: 100, blank_votes_count: 1, - invalid_votes_count: 10, // high number of invalid votes + invalid_votes_count: 10, // W.202 above threshold total_votes_cast_count: 111, }; votes_counts @@ -650,10 +1412,243 @@ mod tests { .unwrap(); assert_eq!(validation_results.errors.len(), 0); assert_eq!(validation_results.warnings.len(), 1); + assert_eq!( + validation_results.warnings[0].code, + ValidationResultCode::AboveThreshold + ); + assert_eq!( + validation_results.warnings[0].fields, + vec![ + "votes_counts.invalid_votes_count", + "votes_counts.total_votes_cast_count" + ] + ); + } + + #[test] + fn test_voters_recounts_validation() { + let mut validation_results = ValidationResults::default(); + // test out of range + let mut voters_recounts = VotersRecounts { + poll_card_recount: 1_000_000_001, // out of range + proxy_certificate_recount: 2, + voter_card_recount: 3, + total_admitted_voters_recount: 1_000_000_006, // correct but out of range + }; + let election = election_fixture(&[]); + let res = voters_recounts.validate( + &election, + &mut validation_results, + "voters_recounts".to_string(), + ); + assert!(res.is_err()); + + // test F.203 incorrect total + validation_results = ValidationResults::default(); + voters_recounts = VotersRecounts { + poll_card_recount: 5, + proxy_certificate_recount: 6, + voter_card_recount: 7, + total_admitted_voters_recount: 20, // F.203 incorrect total + }; + let election = election_fixture(&[]); + voters_recounts + .validate( + &election, + &mut validation_results, + "voters_recounts".to_string(), + ) + .unwrap(); + assert_eq!(validation_results.errors.len(), 1); + assert_eq!(validation_results.warnings.len(), 0); + assert_eq!( + validation_results.errors[0].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.errors[0].fields, + vec![ + "voters_recounts.total_admitted_voters_recount", + "voters_recounts.poll_card_recount", + "voters_recounts.proxy_certificate_recount", + "voters_recounts.voter_card_recount" + ] + ); + } + + #[test] + fn test_differences_counts_validation() { + let mut validation_results = ValidationResults::default(); + // test out of range + let mut differences_counts = DifferencesCounts { + more_ballots_count: 1_000_000_002, // correct but out of range + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 1, + other_explanation_count: 1, + no_explanation_count: 1_000_000_000, // out of range + }; + let election = election_fixture(&[]); + let res = differences_counts.validate( + &election, + &mut validation_results, + "differences_counts".to_string(), + ); + assert!(res.is_err()); + + // test calculation for more_ballots_count does not add up and becomes minus + validation_results = ValidationResults::default(); + differences_counts = DifferencesCounts { + more_ballots_count: 1, + fewer_ballots_count: 0, + unreturned_ballots_count: 2, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 1, + other_explanation_count: 0, + no_explanation_count: 0, + }; + let election = election_fixture(&[]); + differences_counts + .validate( + &election, + &mut validation_results, + "differences_counts".to_string(), + ) + .unwrap(); + assert_eq!(validation_results.errors.len(), 0); + assert_eq!(validation_results.warnings.len(), 1); + assert_eq!( + validation_results.warnings[0].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.warnings[0].fields, + vec![ + "differences_counts.more_ballots_count", + "differences_counts.too_many_ballots_handed_out_count", + "differences_counts.other_explanation_count", + "differences_counts.no_explanation_count", + "differences_counts.unreturned_ballots_count", + "differences_counts.too_few_ballots_handed_out_count" + ] + ); + + // test W.302 incorrect total + validation_results = ValidationResults::default(); + differences_counts = DifferencesCounts { + more_ballots_count: 5, // F.302 incorrect total + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 1, + other_explanation_count: 1, + no_explanation_count: 1, + }; + let election = election_fixture(&[]); + differences_counts + .validate( + &election, + &mut validation_results, + "differences_counts".to_string(), + ) + .unwrap(); + assert_eq!(validation_results.errors.len(), 0); + assert_eq!(validation_results.warnings.len(), 1); + assert_eq!( + validation_results.warnings[0].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.warnings[0].fields, + vec![ + "differences_counts.more_ballots_count", + "differences_counts.too_many_ballots_handed_out_count", + "differences_counts.other_explanation_count", + "differences_counts.no_explanation_count", + "differences_counts.unreturned_ballots_count", + "differences_counts.too_few_ballots_handed_out_count" + ] + ); + + // test calculation for fewer_ballots_count does not add up and becomes minus + validation_results = ValidationResults::default(); + differences_counts = DifferencesCounts { + more_ballots_count: 0, + fewer_ballots_count: 1, + unreturned_ballots_count: 1, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 2, + other_explanation_count: 0, + no_explanation_count: 0, + }; + let election = election_fixture(&[]); + differences_counts + .validate( + &election, + &mut validation_results, + "differences_counts".to_string(), + ) + .unwrap(); + assert_eq!(validation_results.errors.len(), 0); + assert_eq!(validation_results.warnings.len(), 1); + assert_eq!( + validation_results.warnings[0].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.warnings[0].fields, + vec![ + "differences_counts.fewer_ballots_count", + "differences_counts.unreturned_ballots_count", + "differences_counts.too_few_ballots_handed_out_count", + "differences_counts.too_many_ballots_handed_out_count", + "differences_counts.other_explanation_count", + "differences_counts.no_explanation_count" + ] + ); + + // test W.303 incorrect total + validation_results = ValidationResults::default(); + differences_counts = DifferencesCounts { + more_ballots_count: 0, + fewer_ballots_count: 5, // W.303 incorrect total + unreturned_ballots_count: 1, + too_few_ballots_handed_out_count: 1, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 1, + no_explanation_count: 1, + }; + let election = election_fixture(&[]); + differences_counts + .validate( + &election, + &mut validation_results, + "differences_counts".to_string(), + ) + .unwrap(); + assert_eq!(validation_results.errors.len(), 0); + assert_eq!(validation_results.warnings.len(), 1); + assert_eq!( + validation_results.warnings[0].code, + ValidationResultCode::IncorrectTotal + ); + assert_eq!( + validation_results.warnings[0].fields, + vec![ + "differences_counts.fewer_ballots_count", + "differences_counts.unreturned_ballots_count", + "differences_counts.too_few_ballots_handed_out_count", + "differences_counts.too_many_ballots_handed_out_count", + "differences_counts.other_explanation_count", + "differences_counts.no_explanation_count" + ] + ); } #[test] fn test_political_group_votes_validation() { + let mut validation_results = ValidationResults::default(); // create a valid political group votes with two groups and two candidates each let mut political_group_votes = vec![ PoliticalGroupVotes { @@ -672,7 +1667,7 @@ mod tests { }, PoliticalGroupVotes { number: 2, - total: 1_000_000_000, // F.01 out of range + total: 1_000_000_000, // out of range candidate_votes: vec![ CandidateVotes { number: 1, @@ -680,15 +1675,14 @@ mod tests { }, CandidateVotes { number: 2, - votes: 1_000_000_000, // F.01 out of range + votes: 1_000_000_000, // out of range }, ], }, ]; - let election = election_fixture(&[2, 2]); + let mut election = election_fixture(&[2, 2]); // validate out of range number of candidates - let mut validation_results = ValidationResults::default(); let res = political_group_votes.validate( &election, &mut validation_results, @@ -697,10 +1691,10 @@ mod tests { assert!(res.is_err()); // validate with correct in range votes for second political group but incorrect total for first political group + validation_results = ValidationResults::default(); political_group_votes[1].candidate_votes[1].votes = 20; political_group_votes[1].total = 20; political_group_votes[0].total = 20; - let mut validation_results = ValidationResults::default(); political_group_votes .validate( &election, @@ -714,11 +1708,15 @@ mod tests { validation_results.errors[0].code, ValidationResultCode::IncorrectTotal ); + assert_eq!( + validation_results.errors[0].fields, + vec!["political_group_votes[0].total"] + ); // validate with incorrect number of candidates for the first political group + validation_results = ValidationResults::default(); + election = election_fixture(&[3, 2]); political_group_votes[0].total = 25; - let election = election_fixture(&[3, 2]); - let mut validation_results = ValidationResults::default(); let res = political_group_votes.validate( &election, &mut validation_results, @@ -727,8 +1725,8 @@ mod tests { assert!(res.is_err()); // validate with incorrect number of political groups - let election = election_fixture(&[2, 2, 2]); - let mut validation_results = ValidationResults::default(); + validation_results = ValidationResults::default(); + election = election_fixture(&[2, 2, 2]); let res = political_group_votes.validate( &election, &mut validation_results, @@ -737,9 +1735,9 @@ mod tests { assert!(res.is_err()); // validate with correct number of political groups but mixed up numbers - let election = election_fixture(&[2, 2]); + validation_results = ValidationResults::default(); + election = election_fixture(&[2, 2]); political_group_votes[0].number = 2; - let mut validation_results = ValidationResults::default(); let res = political_group_votes.validate( &election, &mut validation_results, diff --git a/backend/src/validation.rs b/backend/src/validation.rs index 79439692a..91e043aa0 100644 --- a/backend/src/validation.rs +++ b/backend/src/validation.rs @@ -35,6 +35,10 @@ pub enum ValidationResultCode { IncorrectTotal, AboveThreshold, EqualInput, + MissingRecounts, + IncorrectDifference, + ConflictingDifferences, + NoDifferenceExpected, } impl fmt::Display for ValidationResultCode { @@ -43,6 +47,10 @@ impl fmt::Display for ValidationResultCode { ValidationResultCode::IncorrectTotal => write!(f, "Incorrect sum"), ValidationResultCode::AboveThreshold => write!(f, "Above threshold"), ValidationResultCode::EqualInput => write!(f, "Equal input"), + ValidationResultCode::MissingRecounts => write!(f, "Missing recounts"), + ValidationResultCode::IncorrectDifference => write!(f, "Incorrect difference"), + ValidationResultCode::ConflictingDifferences => write!(f, "Conflicting differences"), + ValidationResultCode::NoDifferenceExpected => write!(f, "No difference expected"), } } } diff --git a/backend/tests/data_entries_integration_test.rs b/backend/tests/data_entries_integration_test.rs index 782676c0c..4d4aa659d 100644 --- a/backend/tests/data_entries_integration_test.rs +++ b/backend/tests/data_entries_integration_test.rs @@ -21,6 +21,7 @@ async fn test_polling_station_data_entry_valid(pool: SqlitePool) { let request_body = DataEntryRequest { data: PollingStationResults { + recounted: false, voters_counts: VotersCounts { poll_card_count: 100, proxy_certificate_count: 2, @@ -33,6 +34,7 @@ async fn test_polling_station_data_entry_valid(pool: SqlitePool) { invalid_votes_count: 1, total_votes_cast_count: 104, }, + voters_recounts: None, differences_counts: DifferencesCounts { more_ballots_count: 0, fewer_ballots_count: 0, @@ -91,6 +93,7 @@ async fn test_polling_station_data_entry_validation(pool: SqlitePool) { let request_body = json!({ "data": { + "recounted": false, "voters_counts": { "poll_card_count": 1, "proxy_certificate_count": 2, @@ -103,6 +106,7 @@ async fn test_polling_station_data_entry_validation(pool: SqlitePool) { "invalid_votes_count": 7, "total_votes_cast_count": 8 }, + "voters_recounts": null, "differences_counts": { "more_ballots_count": 4, "fewer_ballots_count": 0, @@ -153,10 +157,10 @@ async fn test_polling_station_data_entry_validation(pool: SqlitePool) { assert_eq!( errors[0].fields, vec![ - "data.voters_counts.total_admitted_voters_count", - "data.voters_counts.poll_card_count", - "data.voters_counts.proxy_certificate_count", - "data.voters_counts.voter_card_count" + "data.votes_counts.total_votes_cast_count", + "data.votes_counts.votes_candidates_counts", + "data.votes_counts.blank_votes_count", + "data.votes_counts.invalid_votes_count" ] ); // error 2 @@ -164,10 +168,10 @@ async fn test_polling_station_data_entry_validation(pool: SqlitePool) { assert_eq!( errors[1].fields, vec![ - "data.votes_counts.total_votes_cast_count", - "data.votes_counts.votes_candidates_counts", - "data.votes_counts.blank_votes_count", - "data.votes_counts.invalid_votes_count" + "data.voters_counts.total_admitted_voters_count", + "data.voters_counts.poll_card_count", + "data.voters_counts.proxy_certificate_count", + "data.voters_counts.voter_card_count" ] ); // error 3 @@ -219,6 +223,7 @@ async fn test_polling_station_data_entry_only_for_existing(pool: SqlitePool) { let request_body = DataEntryRequest { data: PollingStationResults { + recounted: false, voters_counts: VotersCounts { poll_card_count: 100, proxy_certificate_count: 2, @@ -231,6 +236,7 @@ async fn test_polling_station_data_entry_only_for_existing(pool: SqlitePool) { invalid_votes_count: 1, total_votes_cast_count: 104, }, + voters_recounts: None, differences_counts: DifferencesCounts { more_ballots_count: 0, fewer_ballots_count: 0, diff --git a/backend/tests/election_integration_test.rs b/backend/tests/election_integration_test.rs index 20b1e6abf..9306f8fb8 100644 --- a/backend/tests/election_integration_test.rs +++ b/backend/tests/election_integration_test.rs @@ -1,14 +1,215 @@ #![cfg(test)] use reqwest::StatusCode; +use serde_json::json; use sqlx::SqlitePool; -use backend::election::{ElectionDetailsResponse, ElectionListResponse}; - use crate::utils::serve_api; +use backend::election::{ElectionDetailsResponse, ElectionListResponse}; +use backend::polling_station::{ + CandidateVotes, DataEntryRequest, DataEntryResponse, DifferencesCounts, PoliticalGroupVotes, + PollingStationResults, VotersCounts, VotesCounts, +}; +use backend::validation::ValidationResultCode::IncorrectTotal; +use backend::ErrorResponse; mod utils; +#[sqlx::test(fixtures("../fixtures/elections.sql", "../fixtures/polling_stations.sql"))] +async fn test_polling_station_data_entry_valid(pool: SqlitePool) { + let addr = serve_api(pool).await; + + let request_body = DataEntryRequest { + data: PollingStationResults { + recounted: false, + voters_counts: VotersCounts { + poll_card_count: 100, + proxy_certificate_count: 2, + voter_card_count: 2, + total_admitted_voters_count: 104, + }, + votes_counts: VotesCounts { + votes_candidates_counts: 102, + blank_votes_count: 1, + invalid_votes_count: 1, + total_votes_cast_count: 104, + }, + voters_recounts: None, + differences_counts: DifferencesCounts { + more_ballots_count: 0, + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 0, + no_explanation_count: 0, + }, + political_group_votes: vec![PoliticalGroupVotes { + number: 1, + total: 102, + candidate_votes: vec![ + CandidateVotes { + number: 1, + votes: 54, + }, + CandidateVotes { + number: 2, + votes: 48, + }, + ], + }], + }, + }; + + let url = format!("http://{addr}/api/polling_stations/1/data_entries/1"); + let response = reqwest::Client::new() + .post(&url) + .json(&request_body) + .send() + .await + .unwrap(); + + // Ensure the response is what we expect + let status = response.status(); + if status != StatusCode::OK { + println!("Response body: {:?}", &response.text().await.unwrap()); + panic!("Unexpected response status: {:?}", status); + } + let validation_results: DataEntryResponse = response.json().await.unwrap(); + assert_eq!(validation_results.validation_results.errors.len(), 0); + assert_eq!(validation_results.validation_results.warnings.len(), 0); +} + +#[sqlx::test] +async fn test_polling_station_data_entry_invalid(pool: SqlitePool) { + let addr = serve_api(pool).await; + + let url = format!("http://{addr}/api/polling_stations/1/data_entries/1"); + let response = reqwest::Client::new() + .post(&url) + .header("content-type", "application/json") + .body(r##"{"data":null}"##) + .send() + .await + .unwrap(); + + // Ensure the response is what we expect + let status = response.status(); + let body: ErrorResponse = response.json().await.unwrap(); + println!("response body: {:?}", &body); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + body.error, + "Failed to deserialize the JSON body into the target type: data: \ + invalid type: null, expected struct PollingStationResults at line 1 column 12" + ); +} + +#[sqlx::test(fixtures("../fixtures/elections.sql", "../fixtures/polling_stations.sql"))] +async fn test_polling_station_data_entry_validation(pool: SqlitePool) { + let addr = serve_api(pool).await; + + let request_body = json!({ + "data": { + "recounted": false, + "voters_counts": { + "poll_card_count": 1, + "proxy_certificate_count": 2, + "voter_card_count": 3, + "total_admitted_voters_count": 4 + }, + "votes_counts": { + "votes_candidates_counts": 5, + "blank_votes_count": 6, + "invalid_votes_count": 7, + "total_votes_cast_count": 8 + }, + "voters_recounts": null, + "differences_counts": { + "more_ballots_count": 4, + "fewer_ballots_count": 0, + "unreturned_ballots_count": 0, + "too_few_ballots_handed_out_count": 0, + "too_many_ballots_handed_out_count": 2, + "other_explanation_count": 1, + "no_explanation_count": 1, + }, + "political_group_votes": [ + { + "number": 1, + "total": 11, + "candidate_votes": [ + { + "number": 1, + "votes": 6 + }, + { + "number": 2, + "votes": 4 + } + ] + } + ] + } + }); + + let url = format!("http://{addr}/api/polling_stations/1/data_entries/1"); + let response = reqwest::Client::new() + .post(&url) + .json(&request_body) + .send() + .await + .unwrap(); + + // Ensure the response is what we expect + let status = response.status(); + if status != StatusCode::OK { + println!("response body: {:?}", &response.text().await.unwrap()); + panic!("Unexpected response status: {:?}", status); + } + let body: DataEntryResponse = response.json().await.unwrap(); + let errors = body.validation_results.errors; + assert_eq!(errors.len(), 4); + // error 1 + assert_eq!(errors[0].code, IncorrectTotal); + assert_eq!( + errors[0].fields, + vec![ + "data.votes_counts.total_votes_cast_count", + "data.votes_counts.votes_candidates_counts", + "data.votes_counts.blank_votes_count", + "data.votes_counts.invalid_votes_count" + ] + ); + // error 2 + assert_eq!(errors[1].code, IncorrectTotal); + assert_eq!( + errors[1].fields, + vec![ + "data.voters_counts.total_admitted_voters_count", + "data.voters_counts.poll_card_count", + "data.voters_counts.proxy_certificate_count", + "data.voters_counts.voter_card_count" + ] + ); + // error 3 + assert_eq!(errors[2].code, IncorrectTotal); + assert_eq!( + errors[2].fields, + vec!["data.political_group_votes[0].total"] + ); + // error 4 + assert_eq!(errors[3].code, IncorrectTotal); + assert_eq!( + errors[3].fields, + vec![ + "data.votes_counts.votes_candidates_counts", + "data.political_group_votes" + ] + ); + assert_eq!(body.validation_results.warnings.len(), 0); +} + #[sqlx::test(fixtures("../fixtures/elections.sql"))] async fn test_election_list_works(pool: SqlitePool) { let addr = serve_api(pool).await; diff --git a/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.test.tsx b/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.test.tsx index 92818a13c..450eae5a8 100644 --- a/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.test.tsx +++ b/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.test.tsx @@ -25,23 +25,7 @@ const Component = ( const rootRequest: POLLING_STATION_DATA_ENTRY_REQUEST_BODY = { data: { - political_group_votes: electionMock.political_groups.map((group) => ({ - number: group.number, - total: 0, - candidate_votes: group.candidates.map((candidate) => ({ - number: candidate.number, - votes: 0, - })), - })), - differences_counts: { - more_ballots_count: 0, - fewer_ballots_count: 0, - unreturned_ballots_count: 0, - too_few_ballots_handed_out_count: 0, - too_many_ballots_handed_out_count: 0, - other_explanation_count: 0, - no_explanation_count: 0, - }, + recounted: false, voters_counts: { poll_card_count: 0, proxy_certificate_count: 0, @@ -54,6 +38,24 @@ const rootRequest: POLLING_STATION_DATA_ENTRY_REQUEST_BODY = { invalid_votes_count: 0, total_votes_cast_count: 0, }, + voters_recounts: undefined, + differences_counts: { + more_ballots_count: 0, + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 0, + no_explanation_count: 0, + }, + political_group_votes: electionMock.political_groups.map((group) => ({ + number: group.number, + total: 0, + candidate_votes: group.candidates.map((candidate) => ({ + number: candidate.number, + votes: 0, + })), + })), }, }; @@ -319,7 +321,7 @@ describe("Test CandidatesVotesForm", () => { }); describe("CandidatesVotesForm errors", () => { - test("F.31 IncorrectTotal group total", async () => { + test("F.401 IncorrectTotal group total", async () => { overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { validation_results: { errors: [ diff --git a/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.tsx b/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.tsx index 7fec2ce16..f5db0949b 100644 --- a/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.tsx +++ b/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.tsx @@ -64,7 +64,7 @@ export function CandidatesVotesForm({ group }: CandidatesVotesFormProps) { const errorsAndWarnings = useErrorsAndWarnings(errors, warnings, inputMaskWarnings); - //const blocker = useBlocker() use const blocker to render confirmation UI. + //const blocker = useBlocker() use const blocker to render confirmation UI. useBlocker(() => { if (formRef.current && !isCalled) { const elements = formRef.current.elements; @@ -86,7 +86,8 @@ export function CandidatesVotesForm({ group }: CandidatesVotesFormProps) { const hasValidationError = errors.length > 0; const hasValidationWarning = warnings.length > 0; - const success = isCalled && !hasValidationError && !hasValidationWarning && !loading; + const success = + isCalled && !serverError && !hasValidationError && !hasValidationWarning && !loading; return (
{/* Temporary while not navigating through form sections */} diff --git a/frontend/app/component/form/differences/DifferencesForm.test.tsx b/frontend/app/component/form/differences/DifferencesForm.test.tsx index f536e26ec..3e6bfffea 100644 --- a/frontend/app/component/form/differences/DifferencesForm.test.tsx +++ b/frontend/app/component/form/differences/DifferencesForm.test.tsx @@ -4,45 +4,33 @@ import { userEvent } from "@testing-library/user-event"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { getUrlMethodAndBody, overrideOnce, render, screen } from "app/test/unit"; +import { getUrlMethodAndBody, overrideOnce, render, screen, userTypeInputs } from "app/test/unit"; import { POLLING_STATION_DATA_ENTRY_REQUEST_BODY, PollingStationFormController, + PollingStationValues, } from "@kiesraad/api"; import { electionMock, pollingStationMock } from "@kiesraad/api-mocks"; import { DifferencesForm } from "./DifferencesForm"; -const Component = ( - - - -); +function renderForm(defaultValues: Partial = {}) { + return render( + + + , + ); +} const rootRequest: POLLING_STATION_DATA_ENTRY_REQUEST_BODY = { data: { - political_group_votes: electionMock.political_groups.map((group) => ({ - number: group.number, - total: 0, - candidate_votes: group.candidates.map((candidate) => ({ - number: candidate.number, - votes: 0, - })), - })), - differences_counts: { - more_ballots_count: 0, - fewer_ballots_count: 0, - unreturned_ballots_count: 0, - too_few_ballots_handed_out_count: 0, - too_many_ballots_handed_out_count: 0, - other_explanation_count: 0, - no_explanation_count: 0, - }, + recounted: false, voters_counts: { poll_card_count: 0, proxy_certificate_count: 0, @@ -55,6 +43,24 @@ const rootRequest: POLLING_STATION_DATA_ENTRY_REQUEST_BODY = { invalid_votes_count: 0, total_votes_cast_count: 0, }, + voters_recounts: undefined, + differences_counts: { + more_ballots_count: 0, + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 0, + no_explanation_count: 0, + }, + political_group_votes: electionMock.political_groups.map((group) => ({ + number: group.number, + total: 0, + candidate_votes: group.candidates.map((candidate) => ({ + number: candidate.number, + votes: 0, + })), + })), }, }; @@ -67,7 +73,7 @@ describe("Test DifferencesForm", () => { test("hitting enter key does not result in api call", async () => { const user = userEvent.setup(); - render(Component); + renderForm(); const spy = vi.spyOn(global, "fetch"); const moreBallotsCount = await screen.findByTestId("more_ballots_count"); @@ -86,7 +92,7 @@ describe("Test DifferencesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); const moreBallotsCount = await screen.findByTestId("more_ballots_count"); expect(moreBallotsCount).toHaveFocus(); @@ -148,9 +154,25 @@ describe("Test DifferencesForm", () => { describe("DifferencesForm API request and response", () => { test("DifferencesForm request body is equal to the form data", async () => { + const votersAndVotesValues = { + voters_counts: { + poll_card_count: 50, + proxy_certificate_count: 1, + voter_card_count: 2, + total_admitted_voters_count: 53, + }, + votes_counts: { + votes_candidates_counts: 52, + blank_votes_count: 1, + invalid_votes_count: 2, + total_votes_cast_count: 55, + }, + }; + const expectedRequest = { data: { ...rootRequest.data, + ...votersAndVotesValues, differences_counts: { more_ballots_count: 2, fewer_ballots_count: 0, @@ -165,37 +187,12 @@ describe("Test DifferencesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm({ ...votersAndVotesValues }); const spy = vi.spyOn(global, "fetch"); - await user.type( - await screen.findByTestId("more_ballots_count"), - expectedRequest.data.differences_counts.more_ballots_count.toString(), - ); - await user.type( - screen.getByTestId("fewer_ballots_count"), - expectedRequest.data.differences_counts.fewer_ballots_count.toString(), - ); - await user.type( - screen.getByTestId("unreturned_ballots_count"), - expectedRequest.data.differences_counts.unreturned_ballots_count.toString(), - ); - await user.type( - screen.getByTestId("too_few_ballots_handed_out_count"), - expectedRequest.data.differences_counts.too_few_ballots_handed_out_count.toString(), - ); - await user.type( - screen.getByTestId("too_many_ballots_handed_out_count"), - expectedRequest.data.differences_counts.too_many_ballots_handed_out_count.toString(), - ); - await user.type( - screen.getByTestId("other_explanation_count"), - expectedRequest.data.differences_counts.other_explanation_count.toString(), - ); - await user.type( - screen.getByTestId("no_explanation_count"), - expectedRequest.data.differences_counts.no_explanation_count.toString(), - ); + await userTypeInputs(user, { + ...expectedRequest.data.differences_counts, + }); const submitButton = screen.getByRole("button", { name: "Volgende" }); await user.click(submitButton); @@ -217,7 +214,7 @@ describe("Test DifferencesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); const submitButton = await screen.findByRole("button", { name: "Volgende" }); await user.click(submitButton); @@ -232,7 +229,7 @@ describe("Test DifferencesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); const submitButton = await screen.findByRole("button", { name: "Volgende" }); await user.click(submitButton); @@ -242,13 +239,176 @@ describe("Test DifferencesForm", () => { }); describe("DifferencesForm errors", () => { - // TODO: Add validation test once backend validation is implemented - test.skip("Incorrect total is caught by validation", async () => { + test("F.301 IncorrectDifference", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { + errors: [ + { + fields: ["data.differences_counts.more_ballots_count"], + code: "IncorrectDifference", + }, + ], + warnings: [], + }, + }); + const user = userEvent.setup(); - render(Component); + renderForm({ recounted: false }); + + // Since the component does not allow to change values in other components, + // not inputting any values and just clicking the submit button. + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackError = await screen.findByTestId("feedback-error"); + expect(feedbackError).toHaveTextContent(/^IncorrectDifference$/); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); + }); + + test("F.302 IncorrectDifference", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { + errors: [ + { + fields: ["data.differences_counts.more_ballots_count"], + code: "IncorrectDifference", + }, + ], + warnings: [], + }, + }); + + const user = userEvent.setup(); + + renderForm({ recounted: true }); + + // Since the component does not allow to change values in other components, + // not inputting any values and just clicking the submit button. + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackError = await screen.findByTestId("feedback-error"); + expect(feedbackError).toHaveTextContent(/^IncorrectDifference$/); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); + }); + + test("F.303 IncorrectDifference", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { + errors: [ + { + fields: ["data.differences_counts.fewer_ballots_count"], + code: "IncorrectDifference", + }, + ], + warnings: [], + }, + }); + + const user = userEvent.setup(); + + renderForm({ recounted: false }); + + // Since the component does not allow to change values in other components, + // not inputting any values and just clicking the submit button. + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackError = await screen.findByTestId("feedback-error"); + expect(feedbackError).toHaveTextContent(/^IncorrectDifference$/); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); + }); + + test("F.304 IncorrectDifference", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { + errors: [ + { + fields: ["data.differences_counts.fewer_ballots_count"], + code: "IncorrectDifference", + }, + ], + warnings: [], + }, + }); + + const user = userEvent.setup(); - await user.type(screen.getByTestId("more_ballots_count"), "2"); + renderForm({ recounted: true }); + + // Since the component does not allow to change values in other components, + // not inputting any values and just clicking the submit button. + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackError = await screen.findByTestId("feedback-error"); + expect(feedbackError).toHaveTextContent(/^IncorrectDifference$/); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); + }); + }); + + describe("DifferencesForm warnings", () => { + test("W.301 ConflictingDifferences", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { + errors: [ + { + fields: [ + "data.differences_counts.more_ballots_count", + "data.differences_counts.fewer_ballots_count", + ], + code: "ConflictingDifferences", + }, + ], + warnings: [], + }, + }); + + const user = userEvent.setup(); + + renderForm(); + + // Since the component does not allow to change values in other components, + // not inputting any values and just clicking the submit button. + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackError = await screen.findByTestId("feedback-error"); + expect(feedbackError).toHaveTextContent(/^ConflictingDifferences$/); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); + }); + + test("W.302 Incorrect total", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { + errors: [], + warnings: [ + { + fields: [ + "data.differences_counts.more_ballots_count", + "data.differences_counts.too_many_ballots_handed_out_count", + "data.differences_counts.too_few_ballots_handed_out_count", + "data.differences_counts.unreturned_ballots", + "data.differences_counts.other_explanation_count", + "data.differences_counts.no_explanation_count", + ], + code: "IncorrectTotal", + }, + ], + }, + }); + + const user = userEvent.setup(); + + renderForm(); + + await user.type(screen.getByTestId("more_ballots_count"), "3"); await user.type(screen.getByTestId("fewer_ballots_count"), "0"); await user.type(screen.getByTestId("unreturned_ballots_count"), "0"); await user.type(screen.getByTestId("too_few_ballots_handed_out_count"), "0"); @@ -259,21 +419,115 @@ describe("Test DifferencesForm", () => { const submitButton = screen.getByRole("button", { name: "Volgende" }); await user.click(submitButton); - const feedbackError = await screen.findByTestId("feedback-error"); - expect(feedbackError).toHaveTextContent(/^IncorrectTotal,IncorrectTotal$/); + const feedbackWarning = await screen.findByTestId("feedback-warning"); + expect(feedbackWarning).toHaveTextContent(/^IncorrectTotal$/); + expect(screen.queryByTestId("feedback-error")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); }); - }); - describe("DifferencesForm warnings", () => { - // TODO: Unskip test once validation is implemented in frontend and backend - test.skip("Warnings can be displayed", async () => { + test("W.303 Incorrect total", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { + errors: [], + warnings: [ + { + fields: [ + "data.differences_counts.fewer_ballots_count", + "data.differences_counts.unreturned_ballots_count", + "data.differences_counts.too_few_ballots_handed_out_count", + "data.differences_counts.too_few_ballots_handed_out_count", + "data.differences_counts.other_explanation_count", + "data.differences_counts.no_explanation_count", + ], + code: "IncorrectTotal", + }, + ], + }, + }); + + const user = userEvent.setup(); + + renderForm(); + + await user.type(screen.getByTestId("more_ballots_count"), "0"); + await user.type(screen.getByTestId("fewer_ballots_count"), "4"); + await user.type(screen.getByTestId("unreturned_ballots_count"), "0"); + await user.type(screen.getByTestId("too_few_ballots_handed_out_count"), "1"); + await user.type(screen.getByTestId("too_many_ballots_handed_out_count"), "0"); + await user.type(screen.getByTestId("other_explanation_count"), "1"); + await user.type(screen.getByTestId("no_explanation_count"), "1"); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackWarning = await screen.findByTestId("feedback-warning"); + expect(feedbackWarning).toHaveTextContent(/^IncorrectTotal$/); + expect(screen.queryByTestId("feedback-error")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); + }); + + test("W.304 and W.306 No difference expected", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { + errors: [], + warnings: [ + { + fields: ["data.differences_counts.fewer_ballots_count"], + code: "NoDifferenceExpected", + }, + { + fields: [ + "data.differences_counts.unreturned_ballots_count", + "data.differences_counts.too_few_ballots_handed_out_count", + "data.differences_counts.too_few_ballots_handed_out_count", + "data.differences_counts.other_explanation_count", + "data.differences_counts.no_explanation_count", + ], + code: "NoDifferenceExpected", + }, + ], + }, + }); + + const user = userEvent.setup(); + + renderForm({ recounted: false }); + + await user.type(screen.getByTestId("more_ballots_count"), "0"); + await user.type(screen.getByTestId("fewer_ballots_count"), "4"); + await user.type(screen.getByTestId("unreturned_ballots_count"), "1"); + await user.type(screen.getByTestId("too_few_ballots_handed_out_count"), "1"); + await user.type(screen.getByTestId("too_many_ballots_handed_out_count"), "0"); + await user.type(screen.getByTestId("other_explanation_count"), "1"); + await user.type(screen.getByTestId("no_explanation_count"), "1"); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackWarning = await screen.findByTestId("feedback-warning"); + expect(feedbackWarning).toHaveTextContent(/^NoDifferenceExpectedNoDifferenceExpected$/); + expect(screen.queryByTestId("feedback-error")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); + }); + + test("W.305 and W.306 No difference expected", async () => { overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { validation_results: { errors: [], warnings: [ { fields: ["data.differences_counts.more_ballots_count"], - code: "NotAnActualWarning", + code: "NoDifferenceExpected", + }, + { + fields: [ + "data.differences_counts.unreturned_ballots_count", + "data.differences_counts.too_few_ballots_handed_out_count", + "data.differences_counts.too_few_ballots_handed_out_count", + "data.differences_counts.other_explanation_count", + "data.differences_counts.no_explanation_count", + ], + code: "NoDifferenceExpected", }, ], }, @@ -281,17 +535,23 @@ describe("Test DifferencesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm({ recounted: true }); + + await user.type(screen.getByTestId("more_ballots_count"), "4"); + await user.type(screen.getByTestId("fewer_ballots_count"), "0"); + await user.type(screen.getByTestId("unreturned_ballots_count"), "0"); + await user.type(screen.getByTestId("too_few_ballots_handed_out_count"), "0"); + await user.type(screen.getByTestId("too_many_ballots_handed_out_count"), "2"); + await user.type(screen.getByTestId("other_explanation_count"), "1"); + await user.type(screen.getByTestId("no_explanation_count"), "1"); - // Since no warnings exist for the fields on this page, - // not inputting any values and just clicking submit. const submitButton = screen.getByRole("button", { name: "Volgende" }); await user.click(submitButton); const feedbackWarning = await screen.findByTestId("feedback-warning"); - expect(feedbackWarning).toHaveTextContent(/^NotAnActualWarning$/); - expect(screen.queryByTestId("feedback-server-error")).toBeNull(); + expect(feedbackWarning).toHaveTextContent(/^NoDifferenceExpectedNoDifferenceExpected$/); expect(screen.queryByTestId("feedback-error")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); }); }); }); diff --git a/frontend/app/component/form/differences/DifferencesForm.tsx b/frontend/app/component/form/differences/DifferencesForm.tsx index cb5266d43..00950124b 100644 --- a/frontend/app/component/form/differences/DifferencesForm.tsx +++ b/frontend/app/component/form/differences/DifferencesForm.tsx @@ -61,12 +61,9 @@ export function DifferencesForm() { [deformat], ); - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - const elements = event.currentTarget.elements; - setSectionValues(getValues(elements)); - } - //const blocker = useBlocker() use const blocker to render confirmation UI. + const errorsAndWarnings = useErrorsAndWarnings(errors, warnings, inputMaskWarnings); + + //const blocker = useBlocker() use const blocker to render confirmation UI. useBlocker(() => { if (formRef.current && !isCalled) { const elements = formRef.current.elements as DifferencesFormElement["elements"]; @@ -79,7 +76,11 @@ export function DifferencesForm() { return false; }); - const errorsAndWarnings = useErrorsAndWarnings(errors, warnings, inputMaskWarnings); + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const elements = event.currentTarget.elements; + setSectionValues(getValues(elements)); + } React.useEffect(() => { if (isCalled) { @@ -89,7 +90,8 @@ export function DifferencesForm() { const hasValidationError = errors.length > 0; const hasValidationWarning = warnings.length > 0; - const success = isCalled && !hasValidationError && !hasValidationWarning && !loading; + const success = + isCalled && !serverError && !hasValidationError && !hasValidationWarning && !loading; return ( {/* Temporary while not navigating through form sections */} @@ -181,7 +183,7 @@ export function DifferencesForm() { key="M" field="M" id="too_many_ballots_handed_out_count" - title="Teveel uitgereikte stembiljetten" + title="Te veel uitgereikte stembiljetten" errorsAndWarnings={errorsAndWarnings} inputProps={register()} format={format} diff --git a/frontend/app/component/form/recounted/RecountedForm.test.tsx b/frontend/app/component/form/recounted/RecountedForm.test.tsx index c25b89cf8..c2679d123 100644 --- a/frontend/app/component/form/recounted/RecountedForm.test.tsx +++ b/frontend/app/component/form/recounted/RecountedForm.test.tsx @@ -1,108 +1,165 @@ import { userEvent } from "@testing-library/user-event"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { overrideOnce, render, screen } from "app/test/unit"; +import { getUrlMethodAndBody, overrideOnce, render, screen } from "app/test/unit"; + +import { + POLLING_STATION_DATA_ENTRY_REQUEST_BODY, + PollingStationFormController, +} from "@kiesraad/api"; +import { electionMock } from "@kiesraad/api-mocks"; import { RecountedForm } from "./RecountedForm"; +const Component = ( + + + +); + +const rootRequest: POLLING_STATION_DATA_ENTRY_REQUEST_BODY = { + data: { + recounted: false, + voters_counts: { + poll_card_count: 0, + proxy_certificate_count: 0, + voter_card_count: 0, + total_admitted_voters_count: 0, + }, + votes_counts: { + votes_candidates_counts: 0, + blank_votes_count: 0, + invalid_votes_count: 0, + total_votes_cast_count: 0, + }, + voters_recounts: undefined, + differences_counts: { + more_ballots_count: 0, + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 0, + no_explanation_count: 0, + }, + political_group_votes: electionMock.political_groups.map((group) => ({ + number: group.number, + total: 0, + candidate_votes: group.candidates.map((candidate) => ({ + number: candidate.number, + votes: 0, + })), + })), + }, +}; + describe("Test RecountedForm", () => { afterEach(() => { vi.restoreAllMocks(); // ToDo: tests pass without this, so not needed? }); - test("hitting enter key does not result in api call", async () => { - const spy = vi.spyOn(global, "fetch"); + describe("RecountedForm user interactions", () => { + test("hitting enter key does not result in api call", async () => { + const spy = vi.spyOn(global, "fetch"); - const user = userEvent.setup(); - render(); + const user = userEvent.setup(); - const yes = screen.getByTestId("yes"); - await user.click(yes); - expect(yes).toBeChecked(); + render(Component); - await user.keyboard("{enter}"); + const yes = screen.getByTestId("yes"); + await user.click(yes); + expect(yes).toBeChecked(); - expect(spy).not.toHaveBeenCalled(); - }); + await user.keyboard("{enter}"); - test("Form field entry and keybindings", async () => { - overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { - validation_results: { errors: [], warnings: [] }, + expect(spy).not.toHaveBeenCalled(); }); - const user = userEvent.setup(); + test("Form field entry and keybindings", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { errors: [], warnings: [] }, + }); - render(); + const user = userEvent.setup(); - const yes = screen.getByTestId("yes"); - const no = screen.getByTestId("no"); - const submitButton = screen.getByRole("button", { name: "Volgende" }); + render(Component); - expect(yes).not.toBeChecked(); - expect(no).not.toBeChecked(); + const yes = screen.getByTestId("yes"); + const no = screen.getByTestId("no"); - await user.click(submitButton); + expect(yes).not.toBeChecked(); + expect(no).not.toBeChecked(); - const validationError = screen.getByText("Controleer het papieren proces-verbaal"); - expect(validationError).toBeVisible(); + await user.click(yes); + expect(yes).toBeChecked(); + expect(no).not.toBeChecked(); + await user.click(no); + expect(no).toBeChecked(); + expect(yes).not.toBeChecked(); - await user.click(yes); - expect(yes).toBeChecked(); - expect(no).not.toBeChecked(); - await user.click(no); - expect(no).toBeChecked(); - expect(yes).not.toBeChecked(); - - await user.click(submitButton); + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); - // const result = await screen.findByTestId("result"); - // - // expect(result).toHaveTextContent(/^Success$/); + const result = await screen.findByTestId("result"); + expect(result).toHaveTextContent(/^Success$/); + }); }); - // TODO: Add tests once submit is added - describe("RecountedForm Api call", () => { - test.skip("RecountedForm request body is equal to the form data", async () => { + describe("RecountedForm API request and response", () => { + test("RecountedForm request body is equal to the form data", async () => { const spy = vi.spyOn(global, "fetch"); - const expectedRequest = {}; + const expectedRequest = { + data: { + ...rootRequest.data, + recounted: true, + }, + }; const user = userEvent.setup(); - render(); + render(Component); + + const yes = screen.getByTestId("yes"); + await user.click(yes); const submitButton = screen.getByRole("button", { name: "Volgende" }); await user.click(submitButton); - expect(spy).toHaveBeenCalledWith("http://testhost/api/polling_stations/1/data_entries/1", { - method: "POST", - body: JSON.stringify(expectedRequest), - headers: { - "Content-Type": "application/json", - }, - }); + expect(spy).toHaveBeenCalled(); + const { url, method, body } = getUrlMethodAndBody(spy.mock.calls); + + expect(url).toEqual("http://testhost/api/polling_stations/1/data_entries/1"); + expect(method).toEqual("POST"); + expect(body).toEqual(expectedRequest); const result = await screen.findByTestId("result"); expect(result).toHaveTextContent(/^Success$/); }); }); - test.skip("422 response results in display of error message", async () => { + test("422 response results in display of error message", async () => { overrideOnce("post", "/api/polling_stations/1/data_entries/1", 422, { message: "422 error from mock", }); const user = userEvent.setup(); - render(); + render(Component); + + const no = screen.getByTestId("no"); + await user.click(no); const submitButton = screen.getByRole("button", { name: "Volgende" }); await user.click(submitButton); - const result = await screen.findByTestId("result"); - expect(result).toHaveTextContent(/^Error 422 error from mock$/); + const feedbackServerError = await screen.findByTestId("feedback-server-error"); + expect(feedbackServerError).toHaveTextContent(/^Error422 error from mock$/); + + expect(screen.queryByTestId("result")).not.toBeNull(); + expect(screen.queryByTestId("result")).toHaveTextContent(/^422 error from mock$/); }); - test.skip("500 response results in display of error message", async () => { + test("500 response results in display of error message", async () => { overrideOnce("post", "/api/polling_stations/1/data_entries/1", 500, { message: "500 error from mock", errorCode: "500_ERROR", @@ -110,11 +167,41 @@ describe("Test RecountedForm", () => { const user = userEvent.setup(); - render(); + render(Component); + + const no = screen.getByTestId("no"); + await user.click(no); const submitButton = screen.getByRole("button", { name: "Volgende" }); await user.click(submitButton); - const result = await screen.findByTestId("result"); - expect(result).toHaveTextContent(/^Error 500_ERROR 500 error from mock$/); + const feedbackServerError = await screen.findByTestId("feedback-server-error"); + expect(feedbackServerError).toHaveTextContent(/^Error500 error from mock$/); + + expect(screen.queryByTestId("result")).not.toBeNull(); + expect(screen.queryByTestId("result")).toHaveTextContent(/^500 error from mock$/); + }); + + describe("RecountedForm errors", () => { + test("F.101 No radio selected", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { errors: [], warnings: [] }, + }); + + const user = userEvent.setup(); + + render(Component); + + const yes = screen.getByTestId("yes"); + const no = screen.getByTestId("no"); + const submitButton = screen.getByRole("button", { name: "Volgende" }); + + expect(yes).not.toBeChecked(); + expect(no).not.toBeChecked(); + + await user.click(submitButton); + + const validationError = screen.getByText("Controleer het papieren proces-verbaal"); + expect(validationError).toBeVisible(); + }); }); }); diff --git a/frontend/app/component/form/recounted/RecountedForm.tsx b/frontend/app/component/form/recounted/RecountedForm.tsx index 44d2331ea..83499c6c8 100644 --- a/frontend/app/component/form/recounted/RecountedForm.tsx +++ b/frontend/app/component/form/recounted/RecountedForm.tsx @@ -1,8 +1,9 @@ import * as React from "react"; -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useBlocker } from "react-router-dom"; +import { Recounted, useRecounted } from "@kiesraad/api"; import { BottomBar, Button, Feedback } from "@kiesraad/ui"; +import { usePreventFormEnterSubmit } from "@kiesraad/util"; interface FormElements extends HTMLFormControlsCollection { yes: HTMLInputElement; @@ -14,9 +15,36 @@ interface RecountedFormElement extends HTMLFormElement { } export function RecountedForm() { - const navigate = useNavigate(); - const [hasValidationError, setHasValidationError] = useState(false); + const [hasValidationError, setHasValidationError] = React.useState(false); const formRef = React.useRef(null); + usePreventFormEnterSubmit(formRef); + + const { + sectionValues, + setSectionValues, + loading, + errors, + warnings, + serverError, + isCalled, + setTemporaryCache, + } = useRecounted(); + + const getValues = React.useCallback((elements: RecountedFormElement["elements"]): Recounted => { + return { yes: elements.yes.checked, no: elements.no.checked }; + }, []); + + useBlocker(() => { + if (formRef.current && !isCalled) { + const elements = formRef.current.elements as RecountedFormElement["elements"]; + const values = getValues(elements); + setTemporaryCache({ + key: "recounted", + data: values, + }); + } + return false; + }); function handleSubmit(event: React.FormEvent) { event.preventDefault(); @@ -26,20 +54,42 @@ export function RecountedForm() { setHasValidationError(true); } else { setHasValidationError(false); - navigate("../numbers"); + setSectionValues(getValues(elements)); } } + React.useEffect(() => { + if (isCalled) { + window.scrollTo(0, 0); + } + }, [isCalled]); + + if (errors.length > 0) { + setHasValidationError(true); + } + const hasValidationWarning = warnings.length > 0; + const success = + isCalled && !serverError && !hasValidationError && !hasValidationWarning && !loading; return ( + {/* Temporary while not navigating through form sections */} + {success &&
Success
}

Is er herteld?

+ {serverError && ( + +
+

Error

+

{serverError.message}

+
+
+ )} {hasValidationError && ( - +
Is op pagina 1 aangegeven dat er in opdracht van het Gemeentelijk Stembureau is herteld?
    -
  • Controleer of rubriek 6 is ingevuld. Is dat zo? Kies hieronder 'ja'
  • -
  • Wel een vinkje, maar rubriek 6 niet ingevuld? Overleg met de coördinator
  • +
  • Controleer of rubriek 3 is ingevuld. Is dat zo? Kies hieronder 'ja'
  • +
  • Wel een vinkje, maar rubriek 3 niet ingevuld? Overleg met de coördinator
  • Geen vinkje? Kies dan 'nee'.
@@ -51,11 +101,11 @@ export function RecountedForm() {

diff --git a/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.test.tsx b/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.test.tsx index 9da6316f4..82082046f 100644 --- a/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.test.tsx +++ b/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.test.tsx @@ -9,40 +9,28 @@ import { getUrlMethodAndBody, overrideOnce, render, screen, userTypeInputs } fro import { POLLING_STATION_DATA_ENTRY_REQUEST_BODY, PollingStationFormController, + PollingStationValues, } from "@kiesraad/api"; import { electionMock, pollingStationMock } from "@kiesraad/api-mocks"; import { VotersAndVotesForm } from "./VotersAndVotesForm"; -const Component = ( - - - -); +function renderForm(defaultValues: Partial = {}) { + return render( + + + , + ); +} const rootRequest: POLLING_STATION_DATA_ENTRY_REQUEST_BODY = { data: { - political_group_votes: electionMock.political_groups.map((group) => ({ - number: group.number, - total: 0, - candidate_votes: group.candidates.map((candidate) => ({ - number: candidate.number, - votes: 0, - })), - })), - differences_counts: { - more_ballots_count: 0, - fewer_ballots_count: 0, - unreturned_ballots_count: 0, - too_few_ballots_handed_out_count: 0, - too_many_ballots_handed_out_count: 0, - other_explanation_count: 0, - no_explanation_count: 0, - }, + recounted: false, voters_counts: { poll_card_count: 0, proxy_certificate_count: 0, @@ -55,6 +43,24 @@ const rootRequest: POLLING_STATION_DATA_ENTRY_REQUEST_BODY = { invalid_votes_count: 0, total_votes_cast_count: 0, }, + voters_recounts: undefined, + differences_counts: { + more_ballots_count: 0, + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 0, + no_explanation_count: 0, + }, + political_group_votes: electionMock.political_groups.map((group) => ({ + number: group.number, + total: 0, + candidate_votes: group.candidates.map((candidate) => ({ + number: candidate.number, + votes: 0, + })), + })), }, }; @@ -67,7 +73,7 @@ describe("Test VotersAndVotesForm", () => { test("hitting enter key does not result in api call", async () => { const user = userEvent.setup(); - render(Component); + renderForm(); const spy = vi.spyOn(global, "fetch"); const pollCards = await screen.findByTestId("poll_card_count"); @@ -86,7 +92,7 @@ describe("Test VotersAndVotesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); const pollCards = await screen.findByTestId("poll_card_count"); expect(pollCards).toHaveFocus(); @@ -173,7 +179,7 @@ describe("Test VotersAndVotesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); const spy = vi.spyOn(global, "fetch"); await userTypeInputs(user, { @@ -202,7 +208,7 @@ describe("Test VotersAndVotesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); const submitButton = await screen.findByRole("button", { name: "Volgende" }); await user.click(submitButton); @@ -221,7 +227,7 @@ describe("Test VotersAndVotesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); const submitButton = await screen.findByRole("button", { name: "Volgende" }); await user.click(submitButton); @@ -234,7 +240,7 @@ describe("Test VotersAndVotesForm", () => { }); describe("VotersAndVotesForm errors", () => { - test("F.11 IncorrectTotal Voters", async () => { + test("F.201 IncorrectTotal Voters counts", async () => { overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { validation_results: { errors: [ @@ -254,7 +260,7 @@ describe("Test VotersAndVotesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); // We await the first element to appear, so we know the page is loaded await user.type(await screen.findByTestId("poll_card_count"), "1"); @@ -271,7 +277,7 @@ describe("Test VotersAndVotesForm", () => { expect(screen.queryByTestId("server-feedback-error")).toBeNull(); }); - test("F.12 IncorrectTotal Votes", async () => { + test("F.202 IncorrectTotal Votes counts", async () => { overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { validation_results: { errors: [ @@ -284,10 +290,6 @@ describe("Test VotersAndVotesForm", () => { ], code: "IncorrectTotal", }, - { - fields: ["data.votes_counts.votes_candidates_counts", "data.political_group_votes"], - code: "IncorrectTotal", - }, ], warnings: [], }, @@ -295,7 +297,7 @@ describe("Test VotersAndVotesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); // We await the first element to appear, so we know the page is loaded await user.type(await screen.findByTestId("votes_candidates_counts"), "1"); @@ -312,6 +314,42 @@ describe("Test VotersAndVotesForm", () => { expect(screen.queryByTestId("server-feedback-error")).toBeNull(); }); + test("F.203 IncorrectTotal Voters recounts", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { + errors: [ + { + fields: [ + "data.voters_recounts.total_admitted_voters_recount", + "data.voters_recounts.poll_card_recount", + "data.voters_recounts.proxy_certificate_recount", + "data.voters_recounts.voter_card_recount", + ], + code: "IncorrectTotal", + }, + ], + warnings: [], + }, + }); + + const user = userEvent.setup(); + + renderForm({ recounted: true }); + + await user.type(screen.getByTestId("poll_card_recount"), "1"); + await user.type(screen.getByTestId("proxy_certificate_recount"), "1"); + await user.type(screen.getByTestId("voter_card_recount"), "1"); + await user.type(screen.getByTestId("total_admitted_voters_recount"), "4"); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackError = await screen.findByTestId("feedback-error"); + expect(feedbackError).toHaveTextContent(/^IncorrectTotal$/); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); + }); + test("Error with non-existing fields is not displayed", async () => { overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { validation_results: { @@ -330,7 +368,7 @@ describe("Test VotersAndVotesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); // Since the component does not allow to input values for non-existing fields, // not inputting any values and just clicking the submit button. @@ -345,7 +383,7 @@ describe("Test VotersAndVotesForm", () => { }); describe("VotersAndVotesForm warnings", () => { - test("W.21 AboveThreshold blank votes", async () => { + test("W.201 AboveThreshold blank votes", async () => { overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { validation_results: { errors: [], @@ -363,7 +401,7 @@ describe("Test VotersAndVotesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); // We await the first element to appear, so we know the page is loaded await user.type(await screen.findByTestId("votes_candidates_counts"), "0"); @@ -380,7 +418,7 @@ describe("Test VotersAndVotesForm", () => { expect(screen.queryByTestId("feedback-error")).toBeNull(); }); - test("W.22 AboveThreshold invalid votes", async () => { + test("W.202 AboveThreshold invalid votes", async () => { overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { validation_results: { errors: [], @@ -398,7 +436,7 @@ describe("Test VotersAndVotesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); // We await the first element to appear, so we know the page is loaded await user.type(await screen.findByTestId("votes_candidates_counts"), "0"); @@ -415,15 +453,10 @@ describe("Test VotersAndVotesForm", () => { expect(screen.queryByTestId("feedback-error")).toBeNull(); }); - test("W.27 EqualInput voters and votes", async () => { + test("W.209 EqualInput voters counts and votes counts", async () => { overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { validation_results: { - errors: [ - { - fields: ["data.votes_counts.votes_candidates_counts", "data.political_group_votes"], - code: "IncorrectTotal", - }, - ], + errors: [], warnings: [ { fields: ["data.voters_counts", "data.votes_counts"], @@ -435,7 +468,7 @@ describe("Test VotersAndVotesForm", () => { const user = userEvent.setup(); - render(Component); + renderForm(); // We await the first element to appear, so we know the page is loaded await user.type(await screen.findByTestId("poll_card_count"), "1"); @@ -456,5 +489,41 @@ describe("Test VotersAndVotesForm", () => { expect(screen.queryByTestId("feedback-server-error")).toBeNull(); expect(screen.queryByTestId("feedback-error")).toBeNull(); }); + + test("W.210 EqualInput voters recounts and votes counts", async () => { + overrideOnce("post", "/api/polling_stations/1/data_entries/1", 200, { + validation_results: { + errors: [], + warnings: [ + { + fields: ["data.voters_recounts", "data.votes_counts"], + code: "EqualInput", + }, + ], + }, + }); + + const user = userEvent.setup(); + + renderForm({ recounted: true }); + + await user.type(screen.getByTestId("votes_candidates_counts"), "1"); + await user.type(screen.getByTestId("blank_votes_count"), "0"); + await user.type(screen.getByTestId("invalid_votes_count"), "0"); + await user.type(screen.getByTestId("total_votes_cast_count"), "1"); + + await user.type(screen.getByTestId("poll_card_recount"), "1"); + await user.type(screen.getByTestId("proxy_certificate_recount"), "0"); + await user.type(screen.getByTestId("voter_card_recount"), "0"); + await user.type(screen.getByTestId("total_admitted_voters_recount"), "1"); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackWarning = await screen.findByTestId("feedback-warning"); + expect(feedbackWarning).toHaveTextContent(/^EqualInput$/); + expect(screen.queryByTestId("feedback-server-error")).toBeNull(); + expect(screen.queryByTestId("feedback-error")).toBeNull(); + }); }); }); diff --git a/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx b/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx index 97ea49bb2..e88fd6e78 100644 --- a/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx +++ b/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx @@ -14,6 +14,10 @@ interface FormElements extends HTMLFormControlsCollection { blank_votes_count: HTMLInputElement; invalid_votes_count: HTMLInputElement; total_votes_cast_count: HTMLInputElement; + poll_card_recount: HTMLInputElement; + proxy_certificate_recount: HTMLInputElement; + voter_card_recount: HTMLInputElement; + total_admitted_voters_recount: HTMLInputElement; } interface VotersAndVotesFormElement extends HTMLFormElement { @@ -40,6 +44,7 @@ export function VotersAndVotesForm() { serverError, isCalled, setTemporaryCache, + recounted, } = useVotersAndVotes(); useTooltip({ @@ -48,7 +53,7 @@ export function VotersAndVotesForm() { const getValues = React.useCallback( (elements: VotersAndVotesFormElement["elements"]): VotersAndVotesValues => { - return { + const values: VotersAndVotesValues = { voters_counts: { poll_card_count: deformat(elements.poll_card_count.value), proxy_certificate_count: deformat(elements.proxy_certificate_count.value), @@ -61,17 +66,24 @@ export function VotersAndVotesForm() { invalid_votes_count: deformat(elements.invalid_votes_count.value), total_votes_cast_count: deformat(elements.total_votes_cast_count.value), }, + voters_recounts: undefined, }; + if (recounted) { + values.voters_recounts = { + poll_card_recount: deformat(elements.poll_card_recount.value), + proxy_certificate_recount: deformat(elements.proxy_certificate_recount.value), + voter_card_recount: deformat(elements.voter_card_recount.value), + total_admitted_voters_recount: deformat(elements.total_admitted_voters_recount.value), + }; + } + return values; }, - [deformat], + [deformat, recounted], ); - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - const elements = event.currentTarget.elements; - setSectionValues(getValues(elements)); - } - //const blocker = useBlocker() use const blocker to render confirmation UI. + const errorsAndWarnings = useErrorsAndWarnings(errors, warnings, inputMaskWarnings); + + //const blocker = useBlocker() use const blocker to render confirmation UI. useBlocker(() => { if (formRef.current && !isCalled) { const elements = formRef.current.elements as VotersAndVotesFormElement["elements"]; @@ -84,7 +96,11 @@ export function VotersAndVotesForm() { return false; }); - const errorsAndWarnings = useErrorsAndWarnings(errors, warnings, inputMaskWarnings); + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const elements = event.currentTarget.elements; + setSectionValues(getValues(elements)); + } React.useEffect(() => { if (isCalled) { @@ -94,7 +110,8 @@ export function VotersAndVotesForm() { const hasValidationError = errors.length > 0; const hasValidationWarning = warnings.length > 0; - const success = isCalled && !hasValidationError && !hasValidationWarning && !loading; + const success = + isCalled && !serverError && !hasValidationError && !hasValidationWarning && !loading; return ( {/* Temporary while not navigating through form sections */} @@ -225,6 +242,63 @@ export function VotersAndVotesForm() { /> + {recounted && ( + <> +

+ Toegelaten kiezers na hertelling door gemeentelijk stembureau +

+ + + Veld + Geteld aantal + Omschrijving + + + + + + + + + + )}