From 3ee96edb94e3470f983f74f07d7bcdcae58b3421 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 8 Apr 2024 16:28:22 +0200 Subject: [PATCH] Add option to accept missing votes in weighted elections --- CHANGELOG.md | 1 + DigitaleBriefwahl/Model/Configuration.cs | 3 + DigitaleBriefwahl/Model/ElectionModel.cs | 3 + .../Model/WeightedElectionModel.cs | 64 +++++++++- DigitaleBriefwahl/Model/YesNoElectionModel.cs | 11 ++ DigitaleBriefwahl/Views/ElectionViewBase.cs | 2 - .../Views/WeightedElectionView.cs | 77 ++++-------- DigitaleBriefwahl/Views/YesNoElectionView.cs | 9 +- .../WeightedElectionModelTests.cs | 117 +++++++++++++++++- 9 files changed, 225 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5a17f..50576f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - DigiTally, a tool to count the ballots +- `FehlendOk` option to allow missing votes in weighted elections ### Fixed diff --git a/DigitaleBriefwahl/Model/Configuration.cs b/DigitaleBriefwahl/Model/Configuration.cs index 729beca..ef72a07 100644 --- a/DigitaleBriefwahl/Model/Configuration.cs +++ b/DigitaleBriefwahl/Model/Configuration.cs @@ -23,6 +23,9 @@ public class Configuration internal const string NomineeLimitKey = "LimitKandidat"; internal const string PublicKeyKey = "PublicKey"; internal const string Email = "Email"; + internal const string MissingOk = "FehlendOk"; + internal const string Abstention = ""; + public const string ConfigName = "wahl.ini"; diff --git a/DigitaleBriefwahl/Model/ElectionModel.cs b/DigitaleBriefwahl/Model/ElectionModel.cs index ac87bf2..8b75fbb 100644 --- a/DigitaleBriefwahl/Model/ElectionModel.cs +++ b/DigitaleBriefwahl/Model/ElectionModel.cs @@ -143,6 +143,9 @@ public virtual string GetResultString(Dictionary result { return $"({BallotsProcessed} ballots, thereof {Invalid} invalid)"; } + + public abstract bool SkipNominee(string name, int iVote); + public abstract HashSet GetInvalidVotes(List electedNominees); } } diff --git a/DigitaleBriefwahl/Model/WeightedElectionModel.cs b/DigitaleBriefwahl/Model/WeightedElectionModel.cs index 6e2ea81..dcb1398 100644 --- a/DigitaleBriefwahl/Model/WeightedElectionModel.cs +++ b/DigitaleBriefwahl/Model/WeightedElectionModel.cs @@ -5,22 +5,21 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.Linq; -using System.Runtime.InteropServices.ComTypes; using System.Text; using System.Text.RegularExpressions; using DigitaleBriefwahl.ExceptionHandling; using IniParser.Model; -using SIL.Extensions; namespace DigitaleBriefwahl.Model { public class WeightedElectionModel: ElectionModel { + private bool MissingOk { get; } public WeightedElectionModel(string name, IniData data) : base(name, data) { + MissingOk = data[name].ContainsKey(Configuration.MissingOk) && data[name][Configuration.MissingOk] == "true"; } public override string GetResult(List electedNominees, bool writeEmptyBallot) @@ -216,5 +215,64 @@ public override string GetResultString(Dictionary resul bldr.AppendLine(base.GetResultString(results)); return bldr.ToString(); } + + public override bool SkipNominee(string name, int iVote) + { + var vote = iVote + 1; + if (!NomineeLimits.TryGetValue(name, out var limit)) + return false; + + return vote < limit.Item1 || vote > limit.Item2; + } + + public override HashSet GetInvalidVotes(List electedNominees) + { + var invalid = new HashSet(); + var lim = Math.Min(Votes, electedNominees.Count); + for (int i = lim; i < Votes; i++) + { + if (!MissingOk) + { + // Missing votes + invalid.Add(i); + } + + } + + for (var i = 0; i < lim; i++) + { + var electedNominee = electedNominees[i].Trim(); + if (string.IsNullOrEmpty(electedNominee)) + { + if (!MissingOk) + invalid.Add(i); + continue; + } + + if (!Nominees.Contains(electedNominee)) + { + invalid.Add(i); + continue; + } + + for (var j = i + 1; j < lim; j++) + { + if (electedNominee != electedNominees[j] || + electedNominee == Configuration.Abstention) + { + continue; + } + + invalid.Add(i); + invalid.Add(j); + } + + if (!SkipNominee(electedNominee, i)) + continue; + + invalid.Add(i); + } + return invalid; + } } } \ No newline at end of file diff --git a/DigitaleBriefwahl/Model/YesNoElectionModel.cs b/DigitaleBriefwahl/Model/YesNoElectionModel.cs index cd8403d..83a79af 100644 --- a/DigitaleBriefwahl/Model/YesNoElectionModel.cs +++ b/DigitaleBriefwahl/Model/YesNoElectionModel.cs @@ -183,5 +183,16 @@ public override string GetResultString(Dictionary resul bldr.AppendLine(base.GetResultString(results)); return bldr.ToString(); } + + public override bool SkipNominee(string name, int iVote) + { + return false; + } + + public override HashSet GetInvalidVotes(List electedNominees) + { + // Unused - handled by the UI + return null; + } } } \ No newline at end of file diff --git a/DigitaleBriefwahl/Views/ElectionViewBase.cs b/DigitaleBriefwahl/Views/ElectionViewBase.cs index a0d171a..2223711 100644 --- a/DigitaleBriefwahl/Views/ElectionViewBase.cs +++ b/DigitaleBriefwahl/Views/ElectionViewBase.cs @@ -12,8 +12,6 @@ internal abstract class ElectionViewBase { private TabPage _page; - public const string Abstention = ""; - protected ElectionModel Election { get; } protected ElectionViewBase(ElectionModel election) diff --git a/DigitaleBriefwahl/Views/WeightedElectionView.cs b/DigitaleBriefwahl/Views/WeightedElectionView.cs index 5baae6a..59385c7 100644 --- a/DigitaleBriefwahl/Views/WeightedElectionView.cs +++ b/DigitaleBriefwahl/Views/WeightedElectionView.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2016 Eberhard Beilharz +// Copyright (c) 2016-2024 Eberhard Beilharz // This software is licensed under the GNU General Public License version 3 // (https://opensource.org/licenses/GPL-3.0) using System; @@ -12,7 +12,7 @@ namespace DigitaleBriefwahl.Views { internal class WeightedElectionView: ElectionViewBase { - private List _ComboBoxes; + private List _comboBoxes; private Color _defaultTextColor; public WeightedElectionView(ElectionModel election) @@ -25,7 +25,7 @@ public override TabPage Layout() var page = base.Layout(); var layout = page.Content as StackLayout; - _ComboBoxes = new List(Election.Votes); + _comboBoxes = new List(Election.Votes); for (var i = 0; i < Election.Votes; i++) { if (Election.TextBefore[i] != null) @@ -45,84 +45,55 @@ public override TabPage Layout() } }; var combo = new ComboBox(); - _ComboBoxes.Add(combo); + _comboBoxes.Add(combo); foreach (var nominee in Election.Nominees) { - if (SkipNominee(nominee, i)) + if (Election.SkipNominee(nominee, i)) continue; combo.Items.Add(new ListItem { Text = nominee }); } - combo.Items.Add(new ListItem { Text = Abstention }); + combo.Items.Add(new ListItem { Text = Configuration.Abstention }); voteLine.Items.Add(combo); layout.Items.Add(new StackLayoutItem(voteLine)); } if (Election.Votes > 0) - _defaultTextColor = _ComboBoxes[0].TextColor; + _defaultTextColor = _comboBoxes[0].TextColor; return page; } - private bool SkipNominee(string name, int iVote) - { - var vote = iVote + 1; - if (!Election.NomineeLimits.ContainsKey(name)) - return false; - - var limit = Election.NomineeLimits[name]; - return vote < limit.Item1 || vote > limit.Item2; - } - public override bool VerifyOk() { - var allOk = true; for (var i = 0; i < Election.Votes; i++) - _ComboBoxes[i].TextColor = _defaultTextColor; + _comboBoxes[i].TextColor = _defaultTextColor; - for (var i = 0; i < Election.Votes; i++) + var invalids = Election.GetInvalidVotes(GetResultList()); + var isFirst = true; + foreach (var invalid in invalids) { - if (string.IsNullOrEmpty(_ComboBoxes[i].SelectedKey)) + _comboBoxes[invalid].TextColor = Colors.Red; + if (isFirst) { - _ComboBoxes[i].TextColor = Colors.Red; - if (allOk) - _ComboBoxes[i].Focus(); - allOk = false; - continue; + _comboBoxes[invalid].Focus(); + isFirst = false; } - - for (var j = i + 1; j < Election.Votes; j++) - { - if (_ComboBoxes[i].SelectedKey != _ComboBoxes[j].SelectedKey || - _ComboBoxes[i].SelectedKey == Abstention) - { - continue; - } - - _ComboBoxes[i].TextColor = Colors.Red; - _ComboBoxes[j].TextColor = Colors.Red; - if (allOk) - _ComboBoxes[i].Focus(); - allOk = false; - } - - if (!SkipNominee(_ComboBoxes[i].SelectedKey, i)) - continue; - - _ComboBoxes[i].TextColor = Colors.Red; - if (allOk) - _ComboBoxes[i].Focus(); - allOk = false; } - return allOk; + return invalids.Count == 0; } - public override string GetResult(bool writeEmptyBallot) + private List GetResultList() { var electedNominees = new List(); for (var i = 0; i < Election.Votes; i++) { - electedNominees.Add(_ComboBoxes[i].SelectedKey); + electedNominees.Add(_comboBoxes[i].SelectedKey); } - return Election.GetResult(electedNominees, writeEmptyBallot); + return electedNominees; + } + + public override string GetResult(bool writeEmptyBallot) + { + return Election.GetResult(GetResultList(), writeEmptyBallot); } } } diff --git a/DigitaleBriefwahl/Views/YesNoElectionView.cs b/DigitaleBriefwahl/Views/YesNoElectionView.cs index f3b1a19..53b8ca0 100644 --- a/DigitaleBriefwahl/Views/YesNoElectionView.cs +++ b/DigitaleBriefwahl/Views/YesNoElectionView.cs @@ -83,7 +83,7 @@ private void SetTextColorForRadioButtonGroup(int i, Color color) _radioButtons[i][2].TextColor = color; } - public override string GetResult(bool writeEmptyBallot) + private List GetResultList() { var votes = new List(); for (var i = 0; i < Election.Nominees.Count; i++) @@ -96,7 +96,12 @@ public override string GetResult(bool writeEmptyBallot) votes.Add(YesNoElectionModel.Abstention); } - return Election.GetResult(votes, writeEmptyBallot); + return votes; + } + + public override string GetResult(bool writeEmptyBallot) + { + return Election.GetResult(GetResultList(), writeEmptyBallot); } } } diff --git a/DigitaleBriefwahlTests/WeightedElectionModelTests.cs b/DigitaleBriefwahlTests/WeightedElectionModelTests.cs index abf5374..eda8422 100644 --- a/DigitaleBriefwahlTests/WeightedElectionModelTests.cs +++ b/DigitaleBriefwahlTests/WeightedElectionModelTests.cs @@ -3,6 +3,7 @@ // (https://opensource.org/licenses/GPL-3.0) using System; +using System.Collections.Generic; using System.Linq; using DigitaleBriefwahl.Model; using NUnit.Framework; @@ -61,10 +62,122 @@ public void EmptyVotes() Kandidat3=Dagobert Duck "; var data = ElectionModelTests.ReadIniDataFromString(ini); - var model = ElectionModelFactory.Create("Election", data); + var sut = ElectionModelFactory.Create("Election", data); // Exercise/Verify - Assert.That(model.EmptyVotes, Is.EqualTo(new[] { "Mickey Mouse", "Donald Duck"})); + Assert.That(sut.EmptyVotes, Is.EqualTo(new[] { "Mickey Mouse", "Donald Duck"})); + } + + [Test] + public void GetInvalidVotes_AllOk() + { + // Setup + const string ini = @"[Election] +Text=Some description +Typ=Weighted +Stimmen=3 +Kandidat1=Mickey Mouse +Kandidat2=Donald Duck +Kandidat3=Dagobert Duck +"; + var data = ElectionModelTests.ReadIniDataFromString(ini); + var sut = ElectionModelFactory.Create("Election", data); + + var result = sut.GetInvalidVotes(new List(new[] + { "Mickey Mouse", "Donald Duck", "Dagobert Duck"})); + + Assert.That(result.Count, Is.EqualTo(0)); + } + + [Test] + public void GetInvalidVotes_DuplicateName() + { + // Setup + const string ini = @"[Election] +Text=Some description +Typ=Weighted +Stimmen=3 +Kandidat1=Mickey Mouse +Kandidat2=Donald Duck +Kandidat3=Dagobert Duck +"; + var data = ElectionModelTests.ReadIniDataFromString(ini); + var sut = ElectionModelFactory.Create("Election", data); + + var result = sut.GetInvalidVotes(new List(new[] + { "Mickey Mouse", "Donald Duck", "Donald Duck"})); + + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result.First(), Is.EqualTo(1)); + Assert.That(result.Last(), Is.EqualTo(2)); + } + + [Test] + public void GetInvalidVotes_InvalidName() + { + // Setup + const string ini = @"[Election] +Text=Some description +Typ=Weighted +Stimmen=3 +Kandidat1=Mickey Mouse +Kandidat2=Donald Duck +Kandidat3=Dagobert Duck +"; + var data = ElectionModelTests.ReadIniDataFromString(ini); + var sut = ElectionModelFactory.Create("Election", data); + + var result = sut.GetInvalidVotes(new List(new[] + { "Mickey Mouse", "X", "Donald Duck"})); + + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result.First(), Is.EqualTo(1)); + } + + [TestCase(new [] { "Mickey Mouse", "Donald Duck"}, 2)] + [TestCase(new [] { "Mickey Mouse", "Donald Duck", ""}, 2)] + [TestCase(new [] { "", "Mickey Mouse", "Donald Duck", }, 0)] + public void GetInvalidVotes_Incomplete(string[] votes, int expectedInvalid) + { + // Setup + const string ini = @"[Election] +Text=Some description +Typ=Weighted +Stimmen=3 +Kandidat1=Mickey Mouse +Kandidat2=Donald Duck +Kandidat3=Dagobert Duck +"; + var data = ElectionModelTests.ReadIniDataFromString(ini); + var sut = ElectionModelFactory.Create("Election", data); + + var result = sut.GetInvalidVotes(new List(votes)); + + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result.First(), Is.EqualTo(expectedInvalid)); + } + + [TestCase(new [] { "Mickey Mouse", "Donald Duck"}, 2)] + [TestCase(new [] { "Mickey Mouse", "Donald Duck", ""}, 2)] + [TestCase(new [] { "", "Mickey Mouse", "Donald Duck", }, 0)] + public void GetInvalidVotes_IncompleteOk(string[] votes, int expectedInvalid) + { + // Setup + const string ini = @"[Election] +Text=Some description +Typ=Weighted +Stimmen=3 +FehlendOk=true +Kandidat1=Mickey Mouse +Kandidat2=Donald Duck +Kandidat3=Dagobert Duck +"; + var data = ElectionModelTests.ReadIniDataFromString(ini); + var sut = ElectionModelFactory.Create("Election", data); + + var result = sut.GetInvalidVotes(new List(votes)); + + Assert.That(result.Count, Is.EqualTo(0)); } } } \ No newline at end of file