diff --git a/src/results.rs b/src/results.rs index c1f5a1c..952bfc2 100644 --- a/src/results.rs +++ b/src/results.rs @@ -1,6 +1,6 @@ //! This module defines the logic to calculate vote results. -use std::{collections::HashMap, fmt}; +use std::{collections::BTreeMap, fmt}; use anyhow::{bail, Result}; use serde::{Deserialize, Serialize}; @@ -113,7 +113,7 @@ pub(crate) struct VoteResults { pub binding: i64, pub non_binding: i64, pub allowed_voters: i64, - pub votes: HashMap, + pub votes: BTreeMap, pub pending_voters: Vec, } @@ -141,7 +141,7 @@ pub(crate) async fn calculate<'a>( gh.get_allowed_voters(inst_id, &vote.cfg, owner, repo, vote.organization.as_ref()).await?; // Track users votes - let mut votes: HashMap = HashMap::new(); + let mut votes: BTreeMap = BTreeMap::new(); let mut multiple_options_voters: Vec = Vec::new(); for reaction in reactions { // Get vote option from reaction @@ -345,7 +345,7 @@ mod tests { not_voted: 0, binding: 1, non_binding: 0, - votes: HashMap::from([ + votes: BTreeMap::from([ ( USER1.to_string(), UserVote { @@ -393,7 +393,7 @@ mod tests { not_voted: 1, binding: 0, non_binding: 0, - votes: HashMap::new(), + votes: BTreeMap::new(), allowed_voters: 1, pending_voters: vec![USER1.to_string()], } @@ -445,7 +445,7 @@ mod tests { not_voted: 1, binding: 3, non_binding: 1, - votes: HashMap::from([ + votes: BTreeMap::from([ ( USER1.to_string(), UserVote { @@ -525,7 +525,7 @@ mod tests { not_voted: 1, binding: 3, non_binding: 0, - votes: HashMap::from([ + votes: BTreeMap::from([ ( USER1.to_string(), UserVote { diff --git a/src/testdata/templates/config-not-found.golden b/src/testdata/templates/config-not-found.golden new file mode 100644 index 0000000..95c0cd3 --- /dev/null +++ b/src/testdata/templates/config-not-found.golden @@ -0,0 +1 @@ +Configuration file not found. Please see . \ No newline at end of file diff --git a/src/testdata/templates/config-profile-not-found.golden b/src/testdata/templates/config-profile-not-found.golden new file mode 100644 index 0000000..159db80 --- /dev/null +++ b/src/testdata/templates/config-profile-not-found.golden @@ -0,0 +1 @@ +The requested configuration profile was not found in the configuration file. \ No newline at end of file diff --git a/src/testdata/templates/invalid-config.golden b/src/testdata/templates/invalid-config.golden new file mode 100644 index 0000000..16fc6db --- /dev/null +++ b/src/testdata/templates/invalid-config.golden @@ -0,0 +1,5 @@ +Something went wrong while processing the configuration file: + +```text +Missing required field: pass_threshold +``` \ No newline at end of file diff --git a/src/testdata/templates/no-vote-in-progress-issue.golden b/src/testdata/templates/no-vote-in-progress-issue.golden new file mode 100644 index 0000000..2e85a30 --- /dev/null +++ b/src/testdata/templates/no-vote-in-progress-issue.golden @@ -0,0 +1 @@ +There is no vote in progress to cancel in this issue @testuser. \ No newline at end of file diff --git a/src/testdata/templates/no-vote-in-progress-pr.golden b/src/testdata/templates/no-vote-in-progress-pr.golden new file mode 100644 index 0000000..ed7a7cf --- /dev/null +++ b/src/testdata/templates/no-vote-in-progress-pr.golden @@ -0,0 +1 @@ +There is no vote in progress to cancel in this pull request @testuser. \ No newline at end of file diff --git a/src/testdata/templates/vote-cancelled-issue.golden b/src/testdata/templates/vote-cancelled-issue.golden new file mode 100644 index 0000000..ddfdf82 --- /dev/null +++ b/src/testdata/templates/vote-cancelled-issue.golden @@ -0,0 +1,3 @@ +## Vote cancelled + +@testuser has cancelled the vote in progress in this issue. \ No newline at end of file diff --git a/src/testdata/templates/vote-cancelled-pr.golden b/src/testdata/templates/vote-cancelled-pr.golden new file mode 100644 index 0000000..4b03406 --- /dev/null +++ b/src/testdata/templates/vote-cancelled-pr.golden @@ -0,0 +1,3 @@ +## Vote cancelled + +@testuser has cancelled the vote in progress in this pull request. \ No newline at end of file diff --git a/src/testdata/templates/vote-checked-recently.golden b/src/testdata/templates/vote-checked-recently.golden new file mode 100644 index 0000000..acdbece --- /dev/null +++ b/src/testdata/templates/vote-checked-recently.golden @@ -0,0 +1 @@ +Votes can only be checked once a day. \ No newline at end of file diff --git a/src/testdata/templates/vote-closed-announcement.golden b/src/testdata/templates/vote-closed-announcement.golden new file mode 100644 index 0000000..feecb7e --- /dev/null +++ b/src/testdata/templates/vote-closed-announcement.golden @@ -0,0 +1,22 @@ +The vote for "**Implement RFC-42** (**#123**)" is now closed. + +## Vote results + +The vote **passed**! 🎉 + +`66.67%` of the users with binding vote were in favor and `0.00%` were against (passing threshold: `50%`). + +### Summary + +| In favor | Against | Abstain | Not voted | +| :--------------------: | :-------------------: | :------------------: | :---------------------: | +| 2 | 0 | 1 | 0 | + +### Binding votes (3) + +| User | Vote | Timestamp | +| ---- | :---: | :-------: | +| @alice | In favor | 2023-01-04 10:00:00.0 +00:00:00 | +| @bob | In favor | 2023-01-04 11:00:00.0 +00:00:00 | +| @charlie | Abstain | 2023-01-04 12:00:00.0 +00:00:00 | + \ No newline at end of file diff --git a/src/testdata/templates/vote-closed-failed.golden b/src/testdata/templates/vote-closed-failed.golden new file mode 100644 index 0000000..17ee44e --- /dev/null +++ b/src/testdata/templates/vote-closed-failed.golden @@ -0,0 +1,22 @@ +## Vote closed + +The vote **did not pass**. + +`40.00%` of the users with binding vote were in favor and `60.00%` were against (passing threshold: `50%`). + +### Summary + +| In favor | Against | Abstain | Not voted | +| :--------------------: | :-------------------: | :------------------: | :---------------------: | +| 2 | 3 | 0 | 0 | + +### Binding votes (5) + +| User | Vote | Timestamp | +| ---- | :---: | :-------: | +| @alice | Against | 2023-01-02 10:00:00.0 +00:00:00 | +| @bob | In favor | 2023-01-02 11:00:00.0 +00:00:00 | +| @charlie | Against | 2023-01-02 12:00:00.0 +00:00:00 | +| @dave | In favor | 2023-01-02 13:00:00.0 +00:00:00 | +| @eve | Against | 2023-01-02 14:00:00.0 +00:00:00 | + \ No newline at end of file diff --git a/src/testdata/templates/vote-closed-passed.golden b/src/testdata/templates/vote-closed-passed.golden new file mode 100644 index 0000000..ef17b3b --- /dev/null +++ b/src/testdata/templates/vote-closed-passed.golden @@ -0,0 +1,31 @@ +## Vote closed + +The vote **passed**! 🎉 + +`80.00%` of the users with binding vote were in favor and `20.00%` were against (passing threshold: `50%`). + +### Summary + +| In favor | Against | Abstain | Not voted | +| :--------------------: | :-------------------: | :------------------: | :---------------------: | +| 4 | 1 | 0 | 0 | + +### Binding votes (5) + +| User | Vote | Timestamp | +| ---- | :---: | :-------: | +| @alice | In favor | 2023-01-01 10:00:00.0 +00:00:00 | +| @bob | In favor | 2023-01-01 11:00:00.0 +00:00:00 | +| @charlie | Against | 2023-01-01 12:00:00.0 +00:00:00 | +| @dave | In favor | 2023-01-01 13:00:00.0 +00:00:00 | +| @eve | In favor | 2023-01-01 14:00:00.0 +00:00:00 | + +
+

Non-binding votes (2)

+ +| User | Vote | Timestamp | +| ---- | :---: | :-------: | +| @supporter1 | In favor | 2023-01-01 15:00:00.0 +00:00:00 | +| @supporter2 | In favor | 2023-01-01 16:00:00.0 +00:00:00 | +
+ \ No newline at end of file diff --git a/src/testdata/templates/vote-created-all-collaborators.golden b/src/testdata/templates/vote-created-all-collaborators.golden new file mode 100644 index 0000000..f5aa7dc --- /dev/null +++ b/src/testdata/templates/vote-created-all-collaborators.golden @@ -0,0 +1,19 @@ +## Vote created + +**@user** has called for a vote on `Test title` (#1). + +All repository collaborators have binding votes. + +Non-binding votes are also appreciated as a sign of support! + +## How to vote + +You can cast your vote by reacting to `this` comment. The following reactions are supported: + +| In favor | Against | Abstain | +| :------: | :-----: | :-----: | +| 👍 | 👎 | 👀 | + +*Please note that voting for multiple options is not allowed and those votes won't be counted.* + +The vote will be open for `1day`. It will pass if at least `75%` of the users with binding votes vote `In favor 👍`. Once it's closed, results will be published here as a new comment. \ No newline at end of file diff --git a/src/testdata/templates/vote-created-with-teams-and-users.golden b/src/testdata/templates/vote-created-with-teams-and-users.golden new file mode 100644 index 0000000..79bf4a8 --- /dev/null +++ b/src/testdata/templates/vote-created-with-teams-and-users.golden @@ -0,0 +1,29 @@ +## Vote created + +**@user** has called for a vote on `Add new feature X` (#42). + +The members of the following teams have binding votes: +| Team | +| ---- | +| @org/core-team | +| @org/maintainers | + +The following users have binding votes: +| User | +| ---- | +| @alice | +| @bob | + +Non-binding votes are also appreciated as a sign of support! + +## How to vote + +You can cast your vote by reacting to `this` comment. The following reactions are supported: + +| In favor | Against | Abstain | +| :------: | :-----: | :-----: | +| 👍 | 👎 | 👀 | + +*Please note that voting for multiple options is not allowed and those votes won't be counted.* + +The vote will be open for `3days`. It will pass if at least `51%` of the users with binding votes vote `In favor 👍`. Once it's closed, results will be published here as a new comment. \ No newline at end of file diff --git a/src/testdata/templates/vote-in-progress-issue.golden b/src/testdata/templates/vote-in-progress-issue.golden new file mode 100644 index 0000000..474ac00 --- /dev/null +++ b/src/testdata/templates/vote-in-progress-issue.golden @@ -0,0 +1,3 @@ +There is already a vote in progress in this issue @testuser. + +Please wait until it is closed before creating a new one. \ No newline at end of file diff --git a/src/testdata/templates/vote-in-progress-pr.golden b/src/testdata/templates/vote-in-progress-pr.golden new file mode 100644 index 0000000..c797411 --- /dev/null +++ b/src/testdata/templates/vote-in-progress-pr.golden @@ -0,0 +1,3 @@ +There is already a vote in progress in this pull request @testuser. + +Please wait until it is closed before creating a new one. \ No newline at end of file diff --git a/src/testdata/templates/vote-restricted.golden b/src/testdata/templates/vote-restricted.golden new file mode 100644 index 0000000..a7aeeb8 --- /dev/null +++ b/src/testdata/templates/vote-restricted.golden @@ -0,0 +1,3 @@ +Only repository collaborators can create a vote @testuser. + +For organization-owned repositories, the list of collaborators includes outside collaborators, organization members that are direct collaborators, organization members with access through team memberships, organization members with access through default organization permissions, and organization owners. \ No newline at end of file diff --git a/src/testdata/templates/vote-status-in-progress.golden b/src/testdata/templates/vote-status-in-progress.golden new file mode 100644 index 0000000..334b851 --- /dev/null +++ b/src/testdata/templates/vote-status-in-progress.golden @@ -0,0 +1,25 @@ +## Vote status + +So far `33.33%` of the users with binding vote are in favor and `0.00%` are against (passing threshold: `50%`). + +### Summary + +| In favor | Against | Abstain | Not voted | +| :--------------------: | :-------------------: | :------------------: | :---------------------: | +| 1 | 0 | 1 | 1 | + +### Binding votes (2) + +| User | Vote | Timestamp | +| ---- | :---: | :-------: | +| alice | In favor | 2023-01-03 10:00:00.0 +00:00:00 | +| bob | Abstain | 2023-01-03 11:00:00.0 +00:00:00 | +| @charlie | *Pending* | | + +
+

Non-binding votes (1)

+ +| User | Vote | Timestamp | +| ---- | :---: | :-------: | +| supporter | In favor | 2023-01-03 12:00:00.0 +00:00:00 | +
diff --git a/src/testutil.rs b/src/testutil.rs index 507b6ec..975a033 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -1,6 +1,6 @@ //! This modules defines some test utilities. -use std::{collections::HashMap, fs, path::Path, time::Duration}; +use std::{collections::BTreeMap, fs, path::Path, time::Duration}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; use uuid::Uuid; @@ -166,7 +166,7 @@ pub(crate) fn setup_test_vote_results() -> VoteResults { not_voted: 0, binding: 1, non_binding: 0, - votes: HashMap::from([( + votes: BTreeMap::from([( USER1.to_string(), UserVote { vote_option: VoteOption::InFavor, diff --git a/src/tmpl.rs b/src/tmpl.rs index 49fbdf4..f4c10a4 100644 --- a/src/tmpl.rs +++ b/src/tmpl.rs @@ -207,15 +207,16 @@ impl<'a> VoteStatus<'a> { } mod filters { + use std::collections::BTreeMap; + use crate::{github::UserName, results::UserVote}; - use std::collections::HashMap; /// Template filter that returns up to the requested number of non-binding /// votes from the votes collection provided sorted by timestamp (oldest /// first). #[allow(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)] pub(crate) fn non_binding( - votes: &HashMap, + votes: &BTreeMap, _: &dyn askama::Values, max: &i64, ) -> askama::Result> { @@ -226,3 +227,454 @@ mod filters { Ok(non_binding_votes.into_iter().take(*max as usize).collect()) } } + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, env, fs}; + + use askama::Template; + use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + + use crate::{ + cmd::CreateVoteInput, + github::Event, + results::{UserVote, VoteOption, VoteResults}, + testutil::*, + }; + + use super::*; + + fn golden_file_path(name: &str) -> String { + format!("{TESTDATA_PATH}/templates/{name}.golden") + } + + fn read_golden_file(name: &str) -> String { + let path = golden_file_path(name); + fs::read_to_string(&path).unwrap_or_else(|_| panic!("error reading golden file: {path}")) + } + + fn write_golden_file(name: &str, content: &str) { + let path = golden_file_path(name); + fs::write(&path, content).expect("write golden file should succeed"); + } + + fn check_golden_file(name: &str, actual: &str) { + if env::var("REGENERATE_GOLDEN_FILES").is_ok() { + write_golden_file(name, actual); + } else { + let expected = read_golden_file(name); + assert_eq!(actual, expected, "output does not match golden file ({name})"); + } + } + + #[test] + fn test_config_not_found() { + let tmpl = ConfigNotFound {}; + let output = tmpl.render().unwrap(); + check_golden_file("config-not-found", &output); + } + + #[test] + fn test_config_profile_not_found() { + let tmpl = ConfigProfileNotFound {}; + let output = tmpl.render().unwrap(); + check_golden_file("config-profile-not-found", &output); + } + + #[test] + fn test_vote_checked_recently() { + let tmpl = VoteCheckedRecently {}; + let output = tmpl.render().unwrap(); + check_golden_file("vote-checked-recently", &output); + } + + #[test] + fn test_invalid_config() { + let tmpl = InvalidConfig::new("Missing required field: pass_threshold"); + let output = tmpl.render().unwrap(); + check_golden_file("invalid-config", &output); + } + + #[test] + fn test_no_vote_in_progress_issue() { + let tmpl = NoVoteInProgress::new("testuser", false); + let output = tmpl.render().unwrap(); + check_golden_file("no-vote-in-progress-issue", &output); + } + + #[test] + fn test_no_vote_in_progress_pr() { + let tmpl = NoVoteInProgress::new("testuser", true); + let output = tmpl.render().unwrap(); + check_golden_file("no-vote-in-progress-pr", &output); + } + + #[test] + fn test_vote_cancelled_issue() { + let tmpl = VoteCancelled::new("testuser", false); + let output = tmpl.render().unwrap(); + check_golden_file("vote-cancelled-issue", &output); + } + + #[test] + fn test_vote_cancelled_pr() { + let tmpl = VoteCancelled::new("testuser", true); + let output = tmpl.render().unwrap(); + check_golden_file("vote-cancelled-pr", &output); + } + + #[test] + fn test_vote_in_progress_issue() { + let tmpl = VoteInProgress::new("testuser", false); + let output = tmpl.render().unwrap(); + check_golden_file("vote-in-progress-issue", &output); + } + + #[test] + fn test_vote_in_progress_pr() { + let tmpl = VoteInProgress::new("testuser", true); + let output = tmpl.render().unwrap(); + check_golden_file("vote-in-progress-pr", &output); + } + + #[test] + fn test_vote_restricted() { + let tmpl = VoteRestricted::new("testuser"); + let output = tmpl.render().unwrap(); + check_golden_file("vote-restricted", &output); + } + + #[test] + fn test_vote_created_all_collaborators() { + let event = Event::Issue(setup_test_issue_event()); + let input = CreateVoteInput::new(None, &event); + let cfg = CfgProfile { + duration: std::time::Duration::from_secs(86_400), // 1 day + pass_threshold: 75.0, + ..Default::default() + }; + + let tmpl = VoteCreated::new(&input, &cfg); + let output = tmpl.render().unwrap(); + check_golden_file("vote-created-all-collaborators", &output); + } + + #[test] + fn test_vote_created_with_teams_and_users() { + let mut event = setup_test_issue_event(); + event.issue.title = "Add new feature X".to_string(); + event.issue.number = 42; + let event = Event::Issue(event); + let input = CreateVoteInput::new(None, &event); + + let cfg = CfgProfile { + duration: std::time::Duration::from_secs(259_200), // 3 days + pass_threshold: 51.0, + allowed_voters: Some(crate::cfg_repo::AllowedVoters { + teams: Some(vec!["core-team".into(), "maintainers".into()]), + users: Some(vec!["alice".into(), "bob".into()]), + exclude_team_maintainers: None, + }), + ..Default::default() + }; + + let tmpl = VoteCreated::new(&input, &cfg); + let output = tmpl.render().unwrap(); + check_golden_file("vote-created-with-teams-and-users", &output); + } + + #[test] + fn test_vote_closed_passed() { + let mut votes = BTreeMap::new(); + votes.insert( + "alice".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T10:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "bob".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T11:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "charlie".to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp: OffsetDateTime::parse("2023-01-01T12:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "dave".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T13:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "eve".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T14:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "supporter1".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T15:00:00Z", &Rfc3339).unwrap(), + binding: false, + }, + ); + votes.insert( + "supporter2".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T16:00:00Z", &Rfc3339).unwrap(), + binding: false, + }, + ); + + let results = VoteResults { + passed: true, + in_favor_percentage: 80.0, + pass_threshold: 50.0, + in_favor: 4, + against: 1, + against_percentage: 20.0, + abstain: 0, + not_voted: 0, + binding: 5, + non_binding: 2, + allowed_voters: 5, + votes: votes.into_iter().collect(), + pending_voters: vec![], + }; + + let tmpl = VoteClosed::new(&results); + let output = tmpl.render().unwrap(); + check_golden_file("vote-closed-passed", &output); + } + + #[test] + fn test_vote_closed_failed() { + let mut votes = BTreeMap::new(); + votes.insert( + "alice".to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp: OffsetDateTime::parse("2023-01-02T10:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "bob".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-02T11:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "charlie".to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp: OffsetDateTime::parse("2023-01-02T12:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "dave".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-02T13:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "eve".to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp: OffsetDateTime::parse("2023-01-02T14:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + + let results = VoteResults { + passed: false, + in_favor_percentage: 40.0, + pass_threshold: 50.0, + in_favor: 2, + against: 3, + against_percentage: 60.0, + abstain: 0, + not_voted: 0, + binding: 5, + non_binding: 0, + allowed_voters: 5, + votes: votes.into_iter().collect(), + pending_voters: vec![], + }; + + let tmpl = VoteClosed::new(&results); + let output = tmpl.render().unwrap(); + check_golden_file("vote-closed-failed", &output); + } + + #[test] + fn test_vote_status_in_progress() { + let mut votes = BTreeMap::new(); + votes.insert( + "alice".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-03T10:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "bob".to_string(), + UserVote { + vote_option: VoteOption::Abstain, + timestamp: OffsetDateTime::parse("2023-01-03T11:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "supporter".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-03T12:00:00Z", &Rfc3339).unwrap(), + binding: false, + }, + ); + + let results = VoteResults { + passed: false, + in_favor_percentage: 33.33, + pass_threshold: 50.0, + in_favor: 1, + against: 0, + against_percentage: 0.0, + abstain: 1, + not_voted: 1, + binding: 2, + non_binding: 1, + allowed_voters: 3, + votes: votes.into_iter().collect(), + pending_voters: vec!["charlie".to_string()], + }; + + let tmpl = VoteStatus::new(&results); + let output = tmpl.render().unwrap(); + check_golden_file("vote-status-in-progress", &output); + } + + #[test] + fn test_vote_closed_announcement() { + let mut votes = BTreeMap::new(); + votes.insert( + "alice".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-04T10:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "bob".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-04T11:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "charlie".to_string(), + UserVote { + vote_option: VoteOption::Abstain, + timestamp: OffsetDateTime::parse("2023-01-04T12:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + + let results = VoteResults { + passed: true, + in_favor_percentage: 66.67, + pass_threshold: 50.0, + in_favor: 2, + against: 0, + against_percentage: 0.0, + abstain: 1, + not_voted: 0, + binding: 3, + non_binding: 0, + allowed_voters: 3, + votes: votes.into_iter().collect(), + pending_voters: vec![], + }; + + let tmpl = VoteClosedAnnouncement::new(123, "Implement RFC-42", &results); + let output = tmpl.render().unwrap(); + check_golden_file("vote-closed-announcement", &output); + } + + #[test] + fn test_non_binding_filter() { + // Create a dummy struct that implements askama::Values + struct DummyValues; + impl askama::Values for DummyValues { + fn get_value(&self, _: &str) -> Option<&(dyn std::any::Any + 'static)> { + None + } + } + + let mut votes = BTreeMap::new(); + + // Add some binding votes + votes.insert( + "alice".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-05T10:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + + // Add non-binding votes with different timestamps + for i in 0..5 { + votes.insert( + format!("supporter{i}"), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(&format!("2023-01-05T{:02}:00:00Z", 11 + i), &Rfc3339) + .unwrap(), + binding: false, + }, + ); + } + + // Test with limit of 3 + let dummy_values = DummyValues; + + let filtered = filters::non_binding(&votes, &dummy_values, &3).unwrap(); + assert_eq!(filtered.len(), 3); + + // Verify they are sorted by timestamp + assert_eq!(filtered[0].0, "supporter0"); + assert_eq!(filtered[1].0, "supporter1"); + assert_eq!(filtered[2].0, "supporter2"); + + // Test with limit larger than available non-binding votes + let filtered = filters::non_binding(&votes, &dummy_values, &10).unwrap(); + assert_eq!(filtered.len(), 5); + } +} diff --git a/templates/vote-closed-announcement.md b/templates/vote-closed-announcement.md index 5a4e65a..30881ab 100644 --- a/templates/vote-closed-announcement.md +++ b/templates/vote-closed-announcement.md @@ -1,7 +1,7 @@ -{% extends "vote-closed.md" %} - -{% block introduction %} +{%- extends "vote-closed.md" -%} +{% block introduction -%} The vote for "**{{ issue_title }}** (**#{{ issue_number }}**)" is now closed. -{% endblock %} +{{ "" +}} +{% endblock +%} -{% block title %}Vote results{% endblock %} +{%- block title %}Vote results{% endblock -%} diff --git a/templates/vote-closed.md b/templates/vote-closed.md index 5066710..d38fdec 100644 --- a/templates/vote-closed.md +++ b/templates/vote-closed.md @@ -1,38 +1,37 @@ -{% block introduction %}{% endblock %} - +{%- block introduction %}{%+ endblock -%} ## {% block title %}Vote closed{% endblock %} The vote {% if results.passed %}**passed**! 🎉{% else %}**did not pass**.{% endif %} `{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with binding vote were in favor and `{{ "{:.2}"|format(results.against_percentage) }}%` were against (passing threshold: `{{ results.pass_threshold }}%`). + ### Summary | In favor | Against | Abstain | Not voted | | :--------------------: | :-------------------: | :------------------: | :---------------------: | | {{ results.in_favor }} | {{ results.against }} | {{ results.abstain}} | {{ results.not_voted }} | -{% if !results.votes.is_empty() %} - +{%~ if !results.votes.is_empty() -%} {%- if results.binding > 0 ~%} ### Binding votes ({{ results.binding }}) - + {{ "" }} {{~ "| User | Vote | Timestamp |" }} {{~ "| ---- | :---: | :-------: |" }} {%- for (user, vote) in results.votes ~%} {%- if vote.binding ~%} | @{{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} {{ "|" -}} {% endif -%} - {% endfor -%} + {% endfor %} {% endif -%} {% if results.non_binding > 0 ~%}

Non-binding votes ({{ results.non_binding }})

- {% let max_non_binding = 300 -%} - {% if results.non_binding > max_non_binding %} + {%~ let max_non_binding = 300 %} + {%- if results.non_binding > max_non_binding %} (displaying only the first {{ max_non_binding }} non-binding votes) - {% endif %} + {%- endif %} {{~ "| User | Vote | Timestamp |" }} {{~ "| ---- | :---: | :-------: |" }} @@ -40,6 +39,5 @@ The vote {% if results.passed %}**passed**! 🎉{% else %}**did not pass**.{% en | @{{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} {{ "|" -}} {% endfor ~%}
- {% endif %} - -{% endif %} + {% endif -%} +{% endif -%} diff --git a/templates/vote-created.md b/templates/vote-created.md index cfde361..ba556ac 100644 --- a/templates/vote-created.md +++ b/templates/vote-created.md @@ -2,7 +2,7 @@ **@{{ creator }}** has called for a vote on `{{ issue_title }}` (#{{ issue_number }}). -{% if !teams.is_empty() || !users.is_empty() %} +{%- if !teams.is_empty() || !users.is_empty() %} {% if !teams.is_empty() ~%} The members of the following teams have binding votes: @@ -24,9 +24,9 @@ {% endif -%} {% else ~%} + {{ " " ~}} All repository collaborators have binding votes. {% endif %} - Non-binding votes are also appreciated as a sign of support! ## How to vote diff --git a/templates/vote-status.md b/templates/vote-status.md index 8bfdc66..b5bac0b 100644 --- a/templates/vote-status.md +++ b/templates/vote-status.md @@ -19,14 +19,13 @@ So far `{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with bi {% endfor -%} {%- for user in results.pending_voters ~%} | @{{ user }} | *Pending* | {{ "|" -}} -{% endfor -%} - +{%- endfor %} {% if results.non_binding > 0 ~%}

Non-binding votes ({{ results.non_binding }})

- {% let max_non_binding = 300 -%} - {% if results.non_binding > max_non_binding %} + {%~ let max_non_binding = 300 %} + {%- if results.non_binding > max_non_binding %} (displaying only the first {{ max_non_binding }} non-binding votes) {% endif %}