diff --git a/CHANGELOG.md b/CHANGELOG.md index d280c1a..5f5a17f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added + +- DigiTally, a tool to count the ballots + ### Fixed - replaced non-ASCII characters in encrypted filename (#41) diff --git a/DigiTally/DigiTally.csproj b/DigiTally/DigiTally.csproj new file mode 100644 index 0000000..c325d6e --- /dev/null +++ b/DigiTally/DigiTally.csproj @@ -0,0 +1,20 @@ + + + + DigitaleBriefwahl.Tally + DigiTally + Exe + ../output/$(Configuration)/DigiTally + + + + + + + + + + + + + diff --git a/DigiTally/Program.cs b/DigiTally/Program.cs new file mode 100644 index 0000000..0d8ebb6 --- /dev/null +++ b/DigiTally/Program.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2024 Eberhard Beilharz +// This software is licensed under the GNU General Public License version 3 +// (https://opensource.org/licenses/GPL-3.0) + +using System; +using System.IO; +using System.Text; +using DigitaleBriefwahl.ExceptionHandling; +using DigitaleBriefwahl.Model; + +namespace DigitaleBriefwahl.Tally +{ + public class Program + { + public static string TallyBallots(string configName, string ballotsDirectory) + { + var bldr = new StringBuilder(); + var configuration = Configuration.Configure(configName); + var readBallot = new ReadBallots(configName); + + foreach (var filename in Directory.GetFiles(ballotsDirectory)) + { + readBallot.AddBallot(filename); + } + + bldr.AppendLine(configuration.Title); + bldr.AppendLine(new string('=', configuration.Title.Length)); + bldr.AppendLine(); + bldr.AppendLine( + $"Total of {readBallot.NumberOfBallots} ballots, thereof {readBallot.NumberOfInvalidBallots} at least partially invalid."); + bldr.AppendLine(); + bldr.Append(readBallot.GetResultString()); + return bldr.ToString(); + } + public static void Main(string[] args) + { + ExceptionLogging.Initialize("5012aef9a281f091c1fceea40c03003b", "DigiTally", args); + + // Setup command line options + if (args.Length <= 0 || args[0] == "--help") + { + Console.WriteLine("Usage:"); + Console.WriteLine("DigiTally.exe ballotsDirectory [configFile]"); + Console.WriteLine("ballotsDirectory: directory that contains the decrypted ballots"); + Console.WriteLine("configFile: optional name and path of the config file"); + return; + } + var configName = args.Length > 1 ? args[1] : Configuration.ConfigName; + if (!File.Exists(configName)) + { + Console.WriteLine($"Can't find {configName}. Exiting."); + return; + } + + Console.WriteLine(TallyBallots(configName, args[0])); + } + } +} \ No newline at end of file diff --git a/DigiTally/ReadBallots.cs b/DigiTally/ReadBallots.cs new file mode 100644 index 0000000..eb0c725 --- /dev/null +++ b/DigiTally/ReadBallots.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2024 Eberhard Beilharz +// This software is licensed under the GNU General Public License version 3 +// (https://opensource.org/licenses/GPL-3.0) + +using System.Collections.Generic; +using System.IO; +using System.Text; +using DigitaleBriefwahl.Model; + +namespace DigitaleBriefwahl.Tally +{ + public class ReadBallots + { + private Configuration _configuration; + private Dictionary> _results; + + public ReadBallots(string configFileName) + { + _results = new Dictionary>(); + _configuration = Configuration.Configure(configFileName); + } + + public bool AddBallot(string ballotFileName) + { + using var ballotFile = new StreamReader(ballotFileName); + var title = ballotFile.ReadLine(); // The election + var separator = ballotFile.ReadLine(); // ============ + if (string.IsNullOrWhiteSpace(separator) || !separator.StartsWith("=")) + { + return false; + } + SkipEmptyLines(ballotFile); + var ballotIsPartiallyInvalid = false; + foreach (var election in _configuration.Elections) + { + var electionTitle = ballotFile.ReadLine(); // Election + separator = ballotFile.ReadLine(); // -------- + if (string.IsNullOrWhiteSpace(separator) || !separator.StartsWith("--") || electionTitle != election.Name) + { + return false; + } + + var prevInvalid = election.Invalid; + _results[election] = election.ReadVotesFromBallot(ballotFile, _results.TryGetValue(election, out var result) ? result: null); + ballotIsPartiallyInvalid |= election.Invalid > prevInvalid; + } + + NumberOfBallots++; + + if (ballotIsPartiallyInvalid) + NumberOfInvalidBallots++; + + return true; + } + + private void SkipEmptyLines(StreamReader stream) + { + int peek; + while ((peek = stream.Peek()) > -1) + { + if ((char)peek != '\r' && (char)peek != '\n') + return; + + stream.ReadLine(); + } + } + + public int NumberOfBallots { get; private set; } + public int NumberOfInvalidBallots { get; private set; } + public Dictionary> Results => _results; + + public string GetResultString() + { + var strBuilder = new StringBuilder(); + foreach (var election in _results) + { + strBuilder.AppendLine($"{election.Key.Name}"); + strBuilder.AppendLine(new string('-', election.Key.Name.Length)); + strBuilder.AppendLine(election.Key.GetResultString(election.Value)); + } + return strBuilder.ToString(); + } + } +} \ No newline at end of file diff --git a/DigiTallyTests/AcceptanceTests.cs b/DigiTallyTests/AcceptanceTests.cs new file mode 100644 index 0000000..00934b1 --- /dev/null +++ b/DigiTallyTests/AcceptanceTests.cs @@ -0,0 +1,157 @@ +// Copyright (c) 2024 Eberhard Beilharz +// This software is licensed under the GNU General Public License version 3 +// (https://opensource.org/licenses/GPL-3.0) + +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using NUnit.Framework; + +namespace DigitaleBriefwahl.Tally.Tests +{ + [TestFixture] + public class AcceptanceTests + { + private string _configFileName; + private string _ballotDirectoryName; + + [SetUp] + public void Setup() + { + _ballotDirectoryName = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_ballotDirectoryName); + _configFileName = Path.GetTempFileName(); + File.WriteAllText(_configFileName, @"[Wahlen] +Titel=The election +Wahl1=Election1 +Wahl2=Election2 +Email=election@example.com +PublicKey=12345678.asc + +[Election1] +Text=Some description +Typ=Weighted +Stimmen=2 +Kandidat1=Mickey Mouse +Kandidat2=Donald Duck +Kandidat3=Dagobert Duck +Kandidat4=Daisy Duck + +[Election2] +Text=Some description +Typ=YesNo +Stimmen=2 +Kandidat1=One +Kandidat2=Two +Kandidat3=Three +Kandidat4=Four +"); + } + + [TearDown] + public void Teardown() + { + File.Delete(_configFileName); + Directory.Delete(_ballotDirectoryName, true); + } + + [Test] + public void EndToEnd() + { + var ballotFileName1 = Path.Combine(_ballotDirectoryName, Path.GetRandomFileName()); + File.WriteAllText(ballotFileName1, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election1\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "2. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "1. Dagobert Duck\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "Election2\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [J] One\r\n" + + "2. [N] Two\r\n" + + "3. [J] Three\r\n" + + "4. [E] Four\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + + var ballotFileName2 = Path.Combine(_ballotDirectoryName, Path.GetRandomFileName()); + File.WriteAllText(ballotFileName2, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election1\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + " Mickey Mouse\r\n" + + "1. Donald Duck\r\n" + + "2. Dagobert Duck\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "Election2\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [N] One\r\n" + + "2. [J] Two\r\n" + + "3. [E] Three\r\n" + + "4. [E] Four\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + + var ballotFileName3 = Path.Combine(_ballotDirectoryName, Path.GetRandomFileName()); + File.WriteAllText(ballotFileName3, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election1\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "1. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "2. Dagobert Duck\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "Election2\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [X] One\r\n" + + "2. [E] Two\r\n" + + "3. [N] Three\r\n" + + "4. [E] Four\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + + var output = Program.TallyBallots(_configFileName, _ballotDirectoryName); + + Assert.That(output, Is.EqualTo(@"The election +============ + +Total of 3 ballots, thereof 1 at least partially invalid. + +Election1 +--------- +1. Dagobert Duck (4 points) +2. Mickey Mouse (3 points) + Donald Duck (2 points) + Daisy Duck (0 points) +(3 ballots, thereof 0 invalid) + +Election2 +--------- +One: 1 J, 1 N, 0 E +Two: 1 J, 1 N, 0 E +Three: 1 J, 0 N, 1 E +Four: 0 J, 0 N, 2 E +(3 ballots, thereof 1 invalid) + +")); + } + + } +} \ No newline at end of file diff --git a/DigiTallyTests/DigiTallyTests.csproj b/DigiTallyTests/DigiTallyTests.csproj new file mode 100644 index 0000000..af1def4 --- /dev/null +++ b/DigiTallyTests/DigiTallyTests.csproj @@ -0,0 +1,18 @@ + + + DigitaleBriefwahl.Tally.Tests + DigiTallyTests + prompt + 4 + bin/$(Configuration) + + + + + + + + + + + diff --git a/DigiTallyTests/ReadBallotsTests.cs b/DigiTallyTests/ReadBallotsTests.cs new file mode 100644 index 0000000..3718319 --- /dev/null +++ b/DigiTallyTests/ReadBallotsTests.cs @@ -0,0 +1,194 @@ +// Copyright (c) 2024 Eberhard Beilharz +// This software is licensed under the GNU General Public License version 3 +// (https://opensource.org/licenses/GPL-3.0) + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; + +namespace DigitaleBriefwahl.Tally.Tests +{ + [TestFixture] + public class ReadBallotsTests + { + private string _configFileName; + private List _ballotFileNames; + + [SetUp] + public void Setup() + { + _ballotFileNames = new List(); + _configFileName = Path.GetTempFileName(); + File.WriteAllText(_configFileName, @"[Wahlen] +Titel=The election +Wahl1=Election1 +Wahl2=Election2 +Email=election@example.com +PublicKey=12345678.asc + +[Election1] +Text=Some description +Typ=Weighted +Stimmen=2 +Kandidat1=Mickey Mouse +Kandidat2=Donald Duck +Kandidat3=Dagobert Duck +Kandidat4=Daisy Duck + +[Election2] +Text=Some description +Typ=YesNo +Stimmen=2 +Kandidat1=One +Kandidat2=Two +Kandidat3=Three +Kandidat4=Four +"); + } + + [TearDown] + public void Teardown() + { + File.Delete(_configFileName); + foreach (var file in _ballotFileNames) + File.Delete(file); + } + + [Test] + public void AddBallot_SingleBallot() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election1\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "2. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "1. Dagobert Duck\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "Election2\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [J] One\r\n" + + "2. [N] Two\r\n" + + "3. [J] Three\r\n" + + "4. [E] Four\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + + Assert.That(sut.AddBallot(ballotFileName), Is.True); + + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(0)); + + var election1 = sut.Results.First(); + Assert.That(election1.Key.BallotsProcessed, Is.EqualTo(1)); + Assert.That(election1.Key.Invalid, Is.EqualTo(0)); + var election1Result = election1.Value; + ReadBallotsTests_Weighted.CheckWeightedResult(election1Result["Mickey Mouse"], 1); + ReadBallotsTests_Weighted.CheckWeightedResult(election1Result["Donald Duck"], 0); + ReadBallotsTests_Weighted.CheckWeightedResult(election1Result["Dagobert Duck"], 2); + ReadBallotsTests_Weighted.CheckWeightedResult(election1Result["Daisy Duck"], 0); + + var election2 = sut.Results.Last(); + Assert.That(election2.Key.BallotsProcessed, Is.EqualTo(1)); + Assert.That(election2.Key.Invalid, Is.EqualTo(0)); + var election2Result = election2.Value; + ReadBallotsTests_YesNo.CheckYesNoResult(election2Result["One"], 1, 1, 0, 0); + ReadBallotsTests_YesNo.CheckYesNoResult(election2Result["Two"], 1, 0, 1, 0); + ReadBallotsTests_YesNo.CheckYesNoResult(election2Result["Three"], 1, 1, 0, 0); + ReadBallotsTests_YesNo.CheckYesNoResult(election2Result["Four"], 1, 0, 0, 1); + } + + [Test] + public void AddBallot_OneInvalid() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election1\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "2. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "1. Dagobert Duck\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "Election2\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [J] One\r\n" + + "2. [X] Two\r\n" + + "3. [J] Three\r\n" + + "4. [E] Four\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(1)); + + var election1 = sut.Results.First(); + Assert.That(election1.Key.BallotsProcessed, Is.EqualTo(1)); + Assert.That(election1.Key.Invalid, Is.EqualTo(0)); + var election1Result = election1.Value; + ReadBallotsTests_Weighted.CheckWeightedResult(election1Result["Mickey Mouse"], 1); + ReadBallotsTests_Weighted.CheckWeightedResult(election1Result["Donald Duck"], 0); + ReadBallotsTests_Weighted.CheckWeightedResult(election1Result["Dagobert Duck"], 2); + ReadBallotsTests_Weighted.CheckWeightedResult(election1Result["Daisy Duck"], 0); + + var election2 = sut.Results.Last(); + Assert.That(election2.Key.BallotsProcessed, Is.EqualTo(1)); + Assert.That(election2.Key.Invalid, Is.EqualTo(1)); + } + + [Test] + public void AddBallot_BothInvalid() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election1\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "1. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "1. Dagobert Duck\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "Election2\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [J] One\r\n" + + "2. [X] Two\r\n" + + "3. [J] Three\r\n" + + "4. [E] Four\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(1)); + + var election1 = sut.Results.First(); + Assert.That(election1.Key.BallotsProcessed, Is.EqualTo(1)); + Assert.That(election1.Key.Invalid, Is.EqualTo(1)); + + var election2 = sut.Results.Last(); + Assert.That(election2.Key.BallotsProcessed, Is.EqualTo(1)); + Assert.That(election2.Key.Invalid, Is.EqualTo(1)); + } + } +} \ No newline at end of file diff --git a/DigiTallyTests/ReadBallotsTests_Weighted.cs b/DigiTallyTests/ReadBallotsTests_Weighted.cs new file mode 100644 index 0000000..0322b83 --- /dev/null +++ b/DigiTallyTests/ReadBallotsTests_Weighted.cs @@ -0,0 +1,345 @@ +// Copyright (c) 2024 Eberhard Beilharz +// This software is licensed under the GNU General Public License version 3 +// (https://opensource.org/licenses/GPL-3.0) +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using DigitaleBriefwahl.Model; +using NUnit.Framework; + +namespace DigitaleBriefwahl.Tally.Tests +{ + [TestFixture] + public class ReadBallotsTests_Weighted + { + private string _configFileName; + private List _ballotFileNames; + + internal static void CheckWeightedResult(CandidateResult result, int expectedPoints, [CallerLineNumber] int lineNumber = 0) + { + var weightedResult = result as WeightedCandidateResult; + Assert.That(weightedResult?.Points, Is.EqualTo(expectedPoints), + $"Expected {expectedPoints} points but got {weightedResult?.Points} in line {lineNumber}"); + } + + [SetUp] + public void Setup() + { + _ballotFileNames = new List(); + _configFileName = Path.GetTempFileName(); + File.WriteAllText(_configFileName, @"[Wahlen] +Titel=The election +Wahl1=Election +Email=election@example.com +PublicKey=12345678.asc + +[Election] +Text=Some description +Typ=Weighted +Stimmen=2 +Kandidat1=Mickey Mouse +Kandidat2=Donald Duck +Kandidat3=Dagobert Duck +Kandidat4=Daisy Duck +"); + } + + [TearDown] + public void Teardown() + { + File.Delete(_configFileName); + foreach (var file in _ballotFileNames) + File.Delete(file); + } + + [Test] + public void WeightedElection_SingleBallot() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "2. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "1. Dagobert Duck\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(0)); + var election = sut.Results.First().Value; + + CheckWeightedResult(election["Mickey Mouse"], 1); + CheckWeightedResult(election["Donald Duck"], 0); + CheckWeightedResult(election["Dagobert Duck"], 2); + CheckWeightedResult(election["Daisy Duck"], 0); + } + + [Test] + public void WeightedElection_MultipleBallots() + { + // Setup + var ballotFileName1 = Path.GetTempFileName(); + File.WriteAllText(ballotFileName1, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "1. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + " Dagobert Duck\r\n" + + "2. Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName1); + var ballotFileName2 = Path.GetTempFileName(); + File.WriteAllText(ballotFileName2, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + " Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "2. Dagobert Duck\r\n" + + "1. Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12346\r\n"); + _ballotFileNames.Add(ballotFileName2); + + // Execute + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName1), Is.True); + Assert.That(sut.AddBallot(ballotFileName2), Is.True); + + // Verify + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(0)); + var election = sut.Results.First().Value; + + CheckWeightedResult(election["Mickey Mouse"], 2); + CheckWeightedResult(election["Donald Duck"], 0); + CheckWeightedResult(election["Dagobert Duck"], 1); + CheckWeightedResult(election["Daisy Duck"], 3); + } + + [Test] + public void WeightedElection_Valid_IncompleteTop() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "1. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + " Dagobert Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(0)); + var election = sut.Results.First().Value; + + CheckWeightedResult(election["Mickey Mouse"], 2); + CheckWeightedResult(election["Donald Duck"], 0); + CheckWeightedResult(election["Dagobert Duck"], 0); + CheckWeightedResult(election["Daisy Duck"], 0); + } + + [Test] + public void WeightedElection_Valid_IncompleteBottom() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "2. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + " Dagobert Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(0)); + var election = sut.Results.First().Value; + + CheckWeightedResult(election["Mickey Mouse"], 1); + CheckWeightedResult(election["Donald Duck"], 0); + CheckWeightedResult(election["Dagobert Duck"], 0); + CheckWeightedResult(election["Daisy Duck"], 0); + } + + [Test] + public void WeightedElection_Invalid_SameRankTwice() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "1. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "1. Dagobert Duck\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(1)); + var election = sut.Results.First().Value; + Assert.That(election.Keys.Count, Is.EqualTo(0)); + } + + [Test] + public void WeightedElection_Invalid_SameNameTwice() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "1. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "2. Mickey Mouse\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(1)); + var election = sut.Results.First().Value; + Assert.That(election.Keys.Count, Is.EqualTo(0)); + } + + [Test] + public void WeightedElection_Invalid_WrongName() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "1. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "2. Dagobert Mouse\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(1)); + var election = sut.Results.First().Value; + Assert.That(election.Keys.Count, Is.EqualTo(0)); + } + + [Test] + public void GetResultString() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "2. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "1. Dagobert Duck\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.GetResultString(), Is.EqualTo(@"Election +-------- +1. Dagobert Duck (2 points) +2. Mickey Mouse (1 points) + Daisy Duck (0 points) + Donald Duck (0 points) +(1 ballots, thereof 0 invalid) + +")); + } + + [Test] + public void GetResultString_SameVotes() + { + var ballotFileName1 = Path.GetTempFileName(); + File.WriteAllText(ballotFileName1, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "2. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "1. Dagobert Duck\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName1); + var ballotFileName2 = Path.GetTempFileName(); + File.WriteAllText(ballotFileName2, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(2 Stimmen; Wahl der Reihenfolge nach mit 1.-2. kennzeichnen)\r\n" + + "1. Mickey Mouse\r\n" + + " Donald Duck\r\n" + + "2. Dagobert Duck\r\n" + + " Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName2); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName1), Is.True); + Assert.That(sut.AddBallot(ballotFileName2), Is.True); + Assert.That(sut.GetResultString(), Is.EqualTo(@"Election +-------- +1. Dagobert Duck (3 points) +1. Mickey Mouse (3 points) + Daisy Duck (0 points) + Donald Duck (0 points) +(2 ballots, thereof 0 invalid) + +")); + } + } + +} \ No newline at end of file diff --git a/DigiTallyTests/ReadBallotsTests_YesNo.cs b/DigiTallyTests/ReadBallotsTests_YesNo.cs new file mode 100644 index 0000000..221db7c --- /dev/null +++ b/DigiTallyTests/ReadBallotsTests_YesNo.cs @@ -0,0 +1,342 @@ +// Copyright (c) 2024 Eberhard Beilharz +// This software is licensed under the GNU General Public License version 3 +// (https://opensource.org/licenses/GPL-3.0) + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using DigitaleBriefwahl.Model; +using NUnit.Framework; + +namespace DigitaleBriefwahl.Tally.Tests +{ + [TestFixture] + public class ReadBallotsTests_YesNo + { + private string _configFileName; + private List _ballotFileNames; + + internal static void CheckYesNoResult(CandidateResult result, int expectedTotal, int expectedYes, int expectedNo, int expectedAbstain, [CallerLineNumber] int lineNumber = 0) + { + var ynResult = result as YesNoCandidateResult; + Assert.That(ynResult.Yes, Is.EqualTo(expectedYes), + $"Expected {expectedYes} Yes but got {ynResult.Yes} in line {lineNumber}"); + Assert.That(ynResult.No, Is.EqualTo(expectedNo), + $"Expected {expectedNo} No but got {ynResult.No} in line {lineNumber}"); + Assert.That(ynResult.Abstention, Is.EqualTo(expectedAbstain), + $"Expected {expectedAbstain} Abstain but got {ynResult.Abstention} in line {lineNumber}"); + Assert.That(ynResult.Yes + ynResult.No + ynResult.Abstention, Is.EqualTo(expectedTotal), + $"Expected {expectedTotal} votes, but got {ynResult.Yes + ynResult.No + ynResult.Abstention} in line {lineNumber}"); + } + + [SetUp] + public void Setup() + { + _ballotFileNames = new List(); + _configFileName = Path.GetTempFileName(); + File.WriteAllText(_configFileName, @"[Wahlen] +Titel=The election +Wahl1=Election +Email=election@example.com +PublicKey=12345678.asc + +[Election] +Text=Some description +Typ=YesNo +Stimmen=2 +Kandidat1=Mickey Mouse +Kandidat2=Donald Duck +Kandidat3=Dagobert Duck +Kandidat4=Daisy Duck +"); + } + + [TearDown] + public void Teardown() + { + File.Delete(_configFileName); + foreach (var file in _ballotFileNames) + File.Delete(file); + } + + [Test] + public void YesNoElection_SingleBallot() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [J] Mickey Mouse\r\n" + + "2. [N] Donald Duck\r\n" + + "3. [J] Dagobert Duck\r\n" + + "4. [E] Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(0)); + var election = sut.Results.First().Value; + + CheckYesNoResult(election["Mickey Mouse"], 1, 1, 0, 0); + CheckYesNoResult(election["Donald Duck"], 1, 0, 1, 0); + CheckYesNoResult(election["Dagobert Duck"], 1, 1, 0, 0); + CheckYesNoResult(election["Daisy Duck"], 1, 0, 0, 1); + } + + [Test] + public void YesNoElection_MultipleBallots() + { + // Setup + var ballotFileName1 = Path.GetTempFileName(); + File.WriteAllText(ballotFileName1, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [J] Mickey Mouse\r\n" + + "2. [N] Donald Duck\r\n" + + "3. [J] Dagobert Duck\r\n" + + "4. [E] Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName1); + var ballotFileName2 = Path.GetTempFileName(); + File.WriteAllText(ballotFileName2, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [N] Mickey Mouse\r\n" + + "2. [E] Donald Duck\r\n" + + "3. [J] Dagobert Duck\r\n" + + "4. [J] Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12346\r\n"); + _ballotFileNames.Add(ballotFileName2); + + // Execute + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName1), Is.True); + Assert.That(sut.AddBallot(ballotFileName2), Is.True); + + // Verify + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(0)); + var election = sut.Results.First().Value; + + CheckYesNoResult(election["Mickey Mouse"], 2, 1, 1, 0); + CheckYesNoResult(election["Donald Duck"], 2, 0, 1, 1); + CheckYesNoResult(election["Dagobert Duck"], 2, 2, 0, 0); + CheckYesNoResult(election["Daisy Duck"], 2, 1, 0, 1); + } + + [Test] + public void YesNoElection_Invalid_SameNameTwice() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [J] Mickey Mouse\r\n" + + "2. [N] Donald Duck\r\n" + + "3. [N] Mickey Mouse\r\n" + + "4. [E] Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(1)); + var election = sut.Results.First().Value; + Assert.That(election.Keys.Count, Is.EqualTo(0)); + } + + [Test] + public void YesNoElection_Invalid_IllegalMarking() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [X] Mickey Mouse\r\n" + + "2. [N] Donald Duck\r\n" + + "3. [J] Dagobert Duck\r\n" + + "4. [E] Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(1)); + var election = sut.Results.First().Value; + Assert.That(election.Keys.Count, Is.EqualTo(0)); + } + + [Test] + public void YesNoElection_Valid_Incomplete_Space() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [ ] Mickey Mouse\r\n" + + "2. [N] Donald Duck\r\n" + + "3. [J] Dagobert Duck\r\n" + + "4. [E] Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(0)); + var election = sut.Results.First().Value; + + CheckYesNoResult(election["Mickey Mouse"], 1, 0, 0, 1); + CheckYesNoResult(election["Donald Duck"], 1, 0, 1, 0); + CheckYesNoResult(election["Dagobert Duck"], 1, 1, 0, 0); + CheckYesNoResult(election["Daisy Duck"], 1, 0, 0, 1); + } + + [Test] + public void YesNoElection_Valid_Incomplete_DoubleSpace() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [ ] Mickey Mouse\r\n" + + "2. [N] Donald Duck\r\n" + + "3. [J] Dagobert Duck\r\n" + + "4. [E] Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(0)); + var election = sut.Results.First().Value; + + CheckYesNoResult(election["Mickey Mouse"], 1, 0, 0, 1); + CheckYesNoResult(election["Donald Duck"], 1, 0, 1, 0); + CheckYesNoResult(election["Dagobert Duck"], 1, 1, 0, 0); + CheckYesNoResult(election["Daisy Duck"], 1, 0, 0, 1); + } + + [Test] + public void YesNoElection_Valid_Incomplete_NoSpace() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [] Mickey Mouse\r\n" + + "2. [N] Donald Duck\r\n" + + "3. [J] Dagobert Duck\r\n" + + "4. [E] Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.NumberOfInvalidBallots, Is.EqualTo(0)); + var election = sut.Results.First().Value; + + CheckYesNoResult(election["Mickey Mouse"], 1, 0, 0, 1); + CheckYesNoResult(election["Donald Duck"], 1, 0, 1, 0); + CheckYesNoResult(election["Dagobert Duck"], 1, 1, 0, 0); + CheckYesNoResult(election["Daisy Duck"], 1, 0, 0, 1); + } + + [Test] + public void GetResultString() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [J] Mickey Mouse\r\n" + + "2. [N] Donald Duck\r\n" + + "3. [J] Dagobert Duck\r\n" + + "4. [E] Daisy Duck\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.GetResultString(), Is.EqualTo(@"Election +-------- +Mickey Mouse: 1 J, 0 N, 0 E +Donald Duck: 0 J, 1 N, 0 E +Dagobert Duck: 1 J, 0 N, 0 E +Daisy Duck: 0 J, 0 N, 1 E +(1 ballots, thereof 0 invalid) + +")); + } + + [Test] + public void GetResultString_DifferentOrder() + { + var ballotFileName = Path.GetTempFileName(); + File.WriteAllText(ballotFileName, "The election\r\n" + + "============\r\n" + + "\r\n" + + "Election\r\n" + + "--------\r\n" + + "(J=Ja, E=Enthaltung, N=Nein)\r\n" + + "1. [E] Daisy Duck\r\n" + + "2. [J] Dagobert Duck\r\n" + + "3. [N] Donald Duck\r\n" + + "4. [J] Mickey Mouse\r\n" + + "\r\n" + + "\r\n" + + "12345\r\n"); + _ballotFileNames.Add(ballotFileName); + var sut = new ReadBallots(_configFileName); + Assert.That(sut.AddBallot(ballotFileName), Is.True); + Assert.That(sut.GetResultString(), Is.EqualTo(@"Election +-------- +Mickey Mouse: 1 J, 0 N, 0 E +Donald Duck: 0 J, 1 N, 0 E +Dagobert Duck: 1 J, 0 N, 0 E +Daisy Duck: 0 J, 0 N, 1 E +(1 ballots, thereof 0 invalid) + +")); + } + + } +} \ No newline at end of file diff --git a/DigitaleBriefwahl.sln b/DigitaleBriefwahl.sln index b33074f..41176ce 100644 --- a/DigitaleBriefwahl.sln +++ b/DigitaleBriefwahl.sln @@ -32,6 +32,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitaleBriefwahl.Launcher. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitaleBriefwahl.ExceptionHandlingTests", "DigitaleBriefwahl.ExceptionHandlingTests\DigitaleBriefwahl.ExceptionHandlingTests.csproj", "{6AA420EF-E130-47E9-8E60-49E7053FD1FF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigiTally", "DigiTally\DigiTally.csproj", "{3A5F2619-8AB5-451A-9165-A4FF79E03623}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigiTallyTests", "DigiTallyTests\DigiTallyTests.csproj", "{B9F5501B-3754-464A-BE58-40590CC6016F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,6 +85,18 @@ Global {6AA420EF-E130-47E9-8E60-49E7053FD1FF}.Release|Any CPU.Build.0 = Release|Any CPU {6AA420EF-E130-47E9-8E60-49E7053FD1FF}.ReleaseMac|Any CPU.ActiveCfg = Debug|Any CPU {6AA420EF-E130-47E9-8E60-49E7053FD1FF}.ReleaseMac|Any CPU.Build.0 = Debug|Any CPU + {3A5F2619-8AB5-451A-9165-A4FF79E03623}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A5F2619-8AB5-451A-9165-A4FF79E03623}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A5F2619-8AB5-451A-9165-A4FF79E03623}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A5F2619-8AB5-451A-9165-A4FF79E03623}.Release|Any CPU.Build.0 = Release|Any CPU + {3A5F2619-8AB5-451A-9165-A4FF79E03623}.ReleaseMac|Any CPU.ActiveCfg = Debug|Any CPU + {3A5F2619-8AB5-451A-9165-A4FF79E03623}.ReleaseMac|Any CPU.Build.0 = Debug|Any CPU + {B9F5501B-3754-464A-BE58-40590CC6016F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9F5501B-3754-464A-BE58-40590CC6016F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9F5501B-3754-464A-BE58-40590CC6016F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9F5501B-3754-464A-BE58-40590CC6016F}.Release|Any CPU.Build.0 = Release|Any CPU + {B9F5501B-3754-464A-BE58-40590CC6016F}.ReleaseMac|Any CPU.ActiveCfg = Debug|Any CPU + {B9F5501B-3754-464A-BE58-40590CC6016F}.ReleaseMac|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DigitaleBriefwahl/Model/CandidateResult.cs b/DigitaleBriefwahl/Model/CandidateResult.cs new file mode 100644 index 0000000..90d298f --- /dev/null +++ b/DigitaleBriefwahl/Model/CandidateResult.cs @@ -0,0 +1,10 @@ +using System.Text; + +namespace DigitaleBriefwahl.Model +{ + public abstract class CandidateResult + { + public int Invalid { get; protected set; } + public abstract void CopyFrom(CandidateResult other); + } +} \ No newline at end of file diff --git a/DigitaleBriefwahl/Model/ElectionModel.cs b/DigitaleBriefwahl/Model/ElectionModel.cs index 4bb787b..ac87bf2 100644 --- a/DigitaleBriefwahl/Model/ElectionModel.cs +++ b/DigitaleBriefwahl/Model/ElectionModel.cs @@ -1,9 +1,11 @@ -// 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; using IniParser.Model; using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Text; namespace DigitaleBriefwahl.Model @@ -93,6 +95,12 @@ protected ElectionModel(string name, IniData data) public abstract List EmptyVotes { get; } + protected abstract Dictionary ReadVotesFromBallotInternal(StreamReader stream); + + public int Invalid { get; protected set; } + + public int BallotsProcessed { get; protected set; } + public override string ToString() { return @@ -105,6 +113,36 @@ protected static string NormalizeLineEndings(StringBuilder bldr) // regardless of whether it's run on Windows or Linux. We use Windows line endings. return bldr.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", "\r\n").ToString(); } + + public Dictionary ReadVotesFromBallot(StreamReader stream, Dictionary results) + { + BallotsProcessed++; + + results ??= new Dictionary(); + var votes = ReadVotesFromBallotInternal(stream); + + if (votes == null) + return results; + + foreach (var vote in votes) + { + if (results.TryGetValue(vote.Key, out var result)) + { + result.CopyFrom(vote.Value); + } + else + { + results.Add(vote.Key, vote.Value); + } + } + + return results; + } + + public virtual string GetResultString(Dictionary results) + { + return $"({BallotsProcessed} ballots, thereof {Invalid} invalid)"; + } } } diff --git a/DigitaleBriefwahl/Model/WeightedCandidateResult.cs b/DigitaleBriefwahl/Model/WeightedCandidateResult.cs new file mode 100644 index 0000000..393186d --- /dev/null +++ b/DigitaleBriefwahl/Model/WeightedCandidateResult.cs @@ -0,0 +1,13 @@ +namespace DigitaleBriefwahl.Model +{ + public class WeightedCandidateResult: CandidateResult + { + public int Points { get; internal set; } + + public override void CopyFrom(CandidateResult other) + { + if (other is WeightedCandidateResult result) + Points += result.Points; + } + } +} \ No newline at end of file diff --git a/DigitaleBriefwahl/Model/WeightedElectionModel.cs b/DigitaleBriefwahl/Model/WeightedElectionModel.cs index bb81210..6e2ea81 100644 --- a/DigitaleBriefwahl/Model/WeightedElectionModel.cs +++ b/DigitaleBriefwahl/Model/WeightedElectionModel.cs @@ -1,10 +1,19 @@ -// Copyright (c) 2017 Eberhard Beilharz +// Copyright (c) 2017-2024 Eberhard Beilharz // This software is licensed under the GNU General Public License version 3 // (https://opensource.org/licenses/GPL-3.0) +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 { @@ -59,5 +68,153 @@ public override List EmptyVotes return votes; } } + + protected override Dictionary ReadVotesFromBallotInternal(StreamReader stream) + { + var skipLine = stream.ReadLine(); + var instructions = new Regex("\\(\\d Stimme.+"); + if (string.IsNullOrEmpty(skipLine) || !instructions.IsMatch(skipLine)) + { + Logger.Error($"Missing line '({Votes} Stimmen; Wahl der Reihenfolge nach mit 1.-{Votes}. kennzeichnen)'. Got {skipLine}"); + Invalid++; + return null; + } + + var votes = new Dictionary(); + foreach (var nominee in Nominees) + { + votes[nominee] = new WeightedCandidateResult(); + } + + var nomineesSeen = new List(); + var rankSeen = new List(); + var invalid = false; + + for (var line = stream.ReadLine(); !string.IsNullOrEmpty(line); line = stream.ReadLine()) + { + // 1. Mickey Mouse + CandidateResult res = null; + var regex = new Regex("(([0-9]+).| ) (.+)"); + if (!regex.IsMatch(line)) + { + Logger.Error($"Can't interpret {line}"); + continue; + } + + var match = regex.Match(line); + var name = match.Groups[3].Value; + if (nomineesSeen.Contains(name)) + { + // we saw this name before - INVALID + Logger.Error($"Double name {name}"); + invalid = true; + continue; + } + + nomineesSeen.Add(name); + if (!votes.TryGetValue(name, out res)) + { + // Name is not in the nominee list - INVALID + Logger.Error($"{name} is not nominated."); + invalid = true; + continue; + } + + if (string.IsNullOrWhiteSpace(match.Groups[1].Value)) + continue; + + if (!Int32.TryParse(match.Groups[2].Value, out var rank)) + { + Logger.Error($"Invalid rank {match.Groups[2].Value} in line {line}"); + invalid = true; + continue; + } + + if (rankSeen.Contains(rank)) + { + Logger.Error($"Double rank: {rank} ({name})"); + invalid = true; + continue; + } + rankSeen.Add(rank); + + if (res is WeightedCandidateResult result) + { + result.Points += Votes - rank + 1; + } + } + + if (invalid) + { + Invalid++; + return null; + } + + return votes; + } + + private class FindIndexPredicate + { + private string Name; + public FindIndexPredicate(string name) + { + Name = name; + } + + public bool Find(KeyValuePair kv) + { + return kv.Key == Name; + } + } + + private class RankComparer : IComparer> + { + public RankComparer(List> results) + { + _results = results; + } + + private readonly List> _results; + + public int Compare(KeyValuePair x, KeyValuePair y) + { + var pointsX = ((WeightedCandidateResult)x.Value).Points; + var pointsY = ((WeightedCandidateResult)y.Value).Points; + // if the points are equal, we compare the names. But since we're sorting + // the ranks descending we'll have to reverse the order of the names. + return pointsX == pointsY ? string.Compare(x.Key, y.Key, StringComparison.Ordinal) * -1 : pointsX.CompareTo(pointsY); + } + } + + private static int GetRank(List> results, string + candidate) + { + var index = results.FindIndex(new FindIndexPredicate(candidate).Find); + if (index > 0 && ((WeightedCandidateResult)results[index].Value).Points == ((WeightedCandidateResult)results[index-1].Value).Points) + { + return index; + } + + return index + 1; + } + + public override string GetResultString(Dictionary results) + { + var bldr = new StringBuilder(); + var comparer = new RankComparer(results.ToList()); + var orderedResults = results.OrderByDescending(kv => kv, comparer).ToList(); + foreach (var kv in orderedResults) + { + var candidate = kv.Key; + var weightedResult = (WeightedCandidateResult)kv.Value; + + var rank = GetRank(orderedResults, candidate); + var placing = rank <= Votes ? $"{rank}." : " "; + bldr.AppendLine($"{placing} {candidate} ({weightedResult.Points} points)"); + } + + bldr.AppendLine(base.GetResultString(results)); + return bldr.ToString(); + } } } \ No newline at end of file diff --git a/DigitaleBriefwahl/Model/YesNoCandidateResult.cs b/DigitaleBriefwahl/Model/YesNoCandidateResult.cs new file mode 100644 index 0000000..dbf1d68 --- /dev/null +++ b/DigitaleBriefwahl/Model/YesNoCandidateResult.cs @@ -0,0 +1,19 @@ +namespace DigitaleBriefwahl.Model +{ + public class YesNoCandidateResult: CandidateResult + { + public int Yes { get; internal set; } + public int No { get; internal set; } + public int Abstention { get; internal set; } + + public override void CopyFrom(CandidateResult other) + { + if (!(other is YesNoCandidateResult result)) + return; + + Yes += result.Yes; + No += result.No; + Abstention += result.Abstention; + } + } +} \ No newline at end of file diff --git a/DigitaleBriefwahl/Model/YesNoElectionModel.cs b/DigitaleBriefwahl/Model/YesNoElectionModel.cs index 807af98..cd8403d 100644 --- a/DigitaleBriefwahl/Model/YesNoElectionModel.cs +++ b/DigitaleBriefwahl/Model/YesNoElectionModel.cs @@ -1,16 +1,34 @@ -// Copyright (c) 2017 Eberhard Beilharz +// Copyright (c) 2017-2024 Eberhard Beilharz // This software is licensed under the GNU General Public License version 3 // (https://opensource.org/licenses/GPL-3.0) using System; using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Text; +using System.Text.RegularExpressions; +using DigitaleBriefwahl.ExceptionHandling; using IniParser.Model; namespace DigitaleBriefwahl.Model { public class YesNoElectionModel: ElectionModel { + private class NomineeOrderComparer : IComparer + { + public NomineeOrderComparer(List nominees) + { + Nominees = nominees; + } + private List Nominees { get; } + + public int Compare(string x, string y) + { + return Nominees.IndexOf(x).CompareTo(Nominees.IndexOf(y)); + } + } + public YesNoElectionModel(string name, IniData data) : base(name, data) { } @@ -84,5 +102,86 @@ public override List EmptyVotes return votes; } } + + protected override Dictionary ReadVotesFromBallotInternal(StreamReader stream) + { + var skipLine = stream.ReadLine(); + if (skipLine != "(J=Ja, E=Enthaltung, N=Nein)") + { + Logger.Error($"Missing line '(J=Ja, E=Enthaltung, N=Nein)'. Got {skipLine}"); + return null; + } + + var nomineesSeen = new List(); + var votes = new Dictionary(); + var invalid = false; + + for (var line = stream.ReadLine(); !string.IsNullOrEmpty(line); line = stream.ReadLine()) + { + // 1. [J] Mickey Mouse + var regex = new Regex("[0-9]+. \\[(J|E|N|( )*)\\] (.+)"); + if (!regex.IsMatch(line)) + { + Logger.Error($"Can't interpret {line}"); + invalid = true; + continue; + } + + var match = regex.Match(line); + var name = match.Groups[3].Value; + if (nomineesSeen.Contains(name)) + { + // we saw this name before - INVALID + Logger.Error($"Double name {name}"); + invalid = true; + continue; + } + + nomineesSeen.Add(name); + if (!votes.TryGetValue(name, out var res)) + { + res = new YesNoCandidateResult(); + votes[name] = res; + } + + var result = res as YesNoCandidateResult; + switch (match.Groups[1].Value) + { + case Yes: + result.Yes++; + break; + case No: + result.No++; + break; + case Abstention: + default: + result.Abstention++; + break; + } + } + + if (invalid) + { + Invalid++; + return null; + } + return votes; + } + + public override string GetResultString(Dictionary results) + { + var bldr = new StringBuilder(); + var comparer = new NomineeOrderComparer(Nominees); + + var candidates = results.Keys.OrderBy(n => n, comparer); + foreach (var candidate in candidates) + { + var ynResult = (YesNoCandidateResult)results[candidate]; + bldr.AppendLine($"{candidate}: {ynResult.Yes} J, {ynResult.No} N, {ynResult.Abstention} E"); + } + + bldr.AppendLine(base.GetResultString(results)); + return bldr.ToString(); + } } } \ No newline at end of file