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()