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