diff --git a/html/worker.js b/html/worker.js
index 68a4893..914ebf3 100644
--- a/html/worker.js
+++ b/html/worker.js
@@ -133,7 +133,7 @@ function handleException(ex) {
if (py.isinstance(ex, py.pyRCV2.ties.RequireInput)) {
// Signals we require input to break a tie
postMessage({'type': 'require_input', 'message': ex.message});
- } else if (py.isinstance(ex, py.pyRCV2.method.base_stv.STVException)) {
+ } else if (py.isinstance(ex, py.pyRCV2.exceptions.BaseRCVException)) {
console.error(ex);
postMessage({'type': 'stv_exception', 'message': ex.message});
} else {
diff --git a/pyRCV2/constraints.py b/pyRCV2/constraints.py
index cd86d4c..7429ea2 100644
--- a/pyRCV2/constraints.py
+++ b/pyRCV2/constraints.py
@@ -16,10 +16,11 @@
__pragma__ = lambda x: None
+from pyRCV2.exceptions import BaseRCVException
from pyRCV2.model import CandidateState
from pyRCV2.safedict import SafeDict
-class ConstraintException(Exception):
+class ConstraintException(BaseRCVException):
pass
class ConstraintMatrix:
diff --git a/pyRCV2/exceptions.py b/pyRCV2/exceptions.py
new file mode 100644
index 0000000..45009fe
--- /dev/null
+++ b/pyRCV2/exceptions.py
@@ -0,0 +1,20 @@
+# pyRCV2: Preferential vote counting
+# Copyright © 2020–2021 Lee Yingtong Li (RunasSudo)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+class BaseRCVException(Exception):
+ def __init__(self, message):
+ Exception.__init__(self, message)
+ self.message = message
diff --git a/pyRCV2/method/base_stv.py b/pyRCV2/method/base_stv.py
index ddfa839..f29eec5 100644
--- a/pyRCV2/method/base_stv.py
+++ b/pyRCV2/method/base_stv.py
@@ -17,16 +17,15 @@
__pragma__ = lambda x: None
from pyRCV2 import constraints
+from pyRCV2.exceptions import BaseRCVException
from pyRCV2.model import BallotInCount, CandidateState, CountCard, CountCompleted, CountStepResult
import pyRCV2.numbers
from pyRCV2.numbers import Num
from pyRCV2.safedict import SafeDict
import pyRCV2.ties
-class STVException(Exception):
- def __init__(self, message):
- Exception.__init__(self)
- self.message = message
+class STVException(BaseRCVException):
+ pass
class BaseSTVCounter:
"""
@@ -87,6 +86,7 @@ class BaseSTVCounter:
self._exclusion = None # Optimisation to avoid re-collating/re-sorting ballots
if self.election.constraints:
+ self.election.validate_constraints()
constraints.init_matrix(self)
constraints.stabilise_matrix(self)
self.logs.extend(constraints.guard_or_doom(self))
diff --git a/pyRCV2/method/meek.py b/pyRCV2/method/meek.py
index c6608d2..e9141b7 100644
--- a/pyRCV2/method/meek.py
+++ b/pyRCV2/method/meek.py
@@ -127,6 +127,7 @@ class MeekSTVCounter(BaseSTVCounter):
self._exclusion = None # Optimisation to avoid re-collating/re-sorting ballots
if self.election.constraints:
+ self.election.validate_constraints()
constraints.init_matrix(self)
constraints.stabilise_matrix(self)
self.logs.extend(constraints.guard_or_doom(self))
diff --git a/pyRCV2/model.py b/pyRCV2/model.py
index 167e070..50b9e1f 100644
--- a/pyRCV2/model.py
+++ b/pyRCV2/model.py
@@ -96,8 +96,18 @@ class Election:
def validate_constraints(self):
"""Confirm that each constraint features each candidate once and only once"""
- # TODO
- return True
+ from pyRCV2.constraints import ConstraintException
+
+ for category_name, category in self.constraints.items():
+ candidates = [x for x in self.candidates]
+ for group_name, group in category.items():
+ for candidate in group.candidates:
+ if candidate in candidates:
+ candidates.remove(candidate)
+ else:
+ raise ConstraintException('Candidate "' + candidate.name + '" duplicated in category "' + category_name + '"')
+ if len(candidates) > 0:
+ raise ConstraintException('Candidate "' + candidates[0].name + '" not assigned to a group in category "' + category_name + '"')
class CountCard:
"""
diff --git a/pyRCV2/transcrypt.py b/pyRCV2/transcrypt.py
index e24fb31..9854998 100644
--- a/pyRCV2/transcrypt.py
+++ b/pyRCV2/transcrypt.py
@@ -17,6 +17,7 @@
import pyRCV2.blt
import pyRCV2.con
import pyRCV2.constraints
+import pyRCV2.exceptions
import pyRCV2.model
import pyRCV2.method, pyRCV2.method.base_stv, pyRCV2.method.gregory, pyRCV2.method.meek
import pyRCV2.numbers
diff --git a/tests/data/prsa1_invalid1.con b/tests/data/prsa1_invalid1.con
new file mode 100644
index 0000000..34625cc
--- /dev/null
+++ b/tests/data/prsa1_invalid1.con
@@ -0,0 +1 @@
+"Gender" "Men" 0 2 2 3 4 6
diff --git a/tests/data/prsa1_invalid2.con b/tests/data/prsa1_invalid2.con
new file mode 100644
index 0000000..c80790b
--- /dev/null
+++ b/tests/data/prsa1_invalid2.con
@@ -0,0 +1,2 @@
+"Gender" "Men" 0 2 2 3 4 2 6
+"Gender" "Women" 2 99 1 5 7
diff --git a/tests/test_constraints_validation.py b/tests/test_constraints_validation.py
new file mode 100644
index 0000000..6bc0b40
--- /dev/null
+++ b/tests/test_constraints_validation.py
@@ -0,0 +1,40 @@
+# pyRCV2: Preferential vote counting
+# Copyright © 2020–2021 Lee Yingtong Li (RunasSudo)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import pytest
+
+import pyRCV2.blt, pyRCV2.con
+from pyRCV2.constraints import ConstraintException
+
+def test_constraints_validation_invalid1():
+ with open('tests/data/prsa1.blt', 'r') as f:
+ election = pyRCV2.blt.readBLT(f.read())
+
+ with open('tests/data/prsa1_invalid1.con', 'r') as f:
+ election.constraints = pyRCV2.con.readCON(f.read(), election)
+
+ with pytest.raises(ConstraintException, match='Candidate "Evans" not assigned to a group in category "Gender"'):
+ election.validate_constraints()
+
+def test_constraints_validation_invalid2():
+ with open('tests/data/prsa1.blt', 'r') as f:
+ election = pyRCV2.blt.readBLT(f.read())
+
+ with open('tests/data/prsa1_invalid2.con', 'r') as f:
+ election.constraints = pyRCV2.con.readCON(f.read(), election)
+
+ with pytest.raises(ConstraintException, match='Candidate "Grey" duplicated in category "Gender"'):
+ election.validate_constraints()