Repository: optapy/optapy (language: python)

Issue:
Constraint Verifier



Discussion / hints:
Goal: provide an equivalent `ConstraintVerifier` (https://www.optaplanner.org/docs/optaplanner/latest/constraint-streams/constraint-streams.html#constraintStreamsTesting) to allow Python users to test their constraint stream.

Fix patch:
diff --git a/optapy-core/pom.xml b/optapy-core/pom.xml
index b649f55..dafdfb3 100644
--- a/optapy-core/pom.xml
+++ b/optapy-core/pom.xml
@@ -29,6 +29,10 @@
       <groupId>org.optaplanner</groupId>
       <artifactId>optaplanner-core</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.optaplanner</groupId>
+      <artifactId>optaplanner-test</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.optaplanner</groupId>
       <artifactId>jpyinterpreter</artifactId>
@@ -88,7 +92,9 @@
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-dependency-plugin</artifactId>
         <configuration>
-          <ignoredUnusedDeclaredDependencies>org.optaplanner:optaplanner-core</ignoredUnusedDeclaredDependencies>
+          <ignoredUnusedDeclaredDependencies>
+            org.optaplanner:optaplanner-core,org.optaplanner:optaplanner-test
+          </ignoredUnusedDeclaredDependencies>
           <ignoredUsedUndeclaredDependencies>org.optaplanner:optaplanner-core-impl</ignoredUsedUndeclaredDependencies>
         </configuration>
         <executions>
diff --git a/optapy-core/src/main/java/org/optaplanner/optapy/PythonSolver.java b/optapy-core/src/main/java/org/optaplanner/optapy/PythonSolver.java
index 0c3996c..f8c829d 100644
--- a/optapy-core/src/main/java/org/optaplanner/optapy/PythonSolver.java
+++ b/optapy-core/src/main/java/org/optaplanner/optapy/PythonSolver.java
@@ -1,8 +1,11 @@
 package org.optaplanner.optapy;
 
+import java.lang.reflect.InvocationTargetException;
 import java.util.Collections;
 import java.util.IdentityHashMap;
+import java.util.Map;
 
+import org.optaplanner.core.api.score.stream.ConstraintProvider;
 import org.optaplanner.jpyinterpreter.CPythonBackedPythonInterpreter;
 import org.optaplanner.jpyinterpreter.types.wrappers.OpaquePythonReference;
 
@@ -15,12 +18,16 @@ public class PythonSolver {
      */
     public static boolean onlyUseJavaSetters = false;
 
+    public static Map<Number, Object> getNewReferenceMap() {
+        return new MirrorWithExtrasMap<>(CPythonBackedPythonInterpreter.pythonObjectIdToConvertedObjectMap);
+    }
+
     public static Object wrapProblem(Class<?> solutionClass, OpaquePythonReference problem) {
         try {
             final boolean onlyUseJavaSettersForThisInstance = onlyUseJavaSetters;
             onlyUseJavaSetters = false;
             PythonObject out = (PythonObject) PythonWrapperGenerator.wrap(solutionClass, problem,
-                    new MirrorWithExtrasMap<>(CPythonBackedPythonInterpreter.pythonObjectIdToConvertedObjectMap),
+                    getNewReferenceMap(),
                     onlyUseJavaSettersForThisInstance ? PythonWrapperGenerator.NONE_PYTHON_SETTER
                             : PythonWrapperGenerator.pythonObjectIdAndAttributeSetter);
             out.visitIds(out.get__optapy_reference_map());
@@ -42,4 +49,36 @@ public static Object wrapProblem(Class<?> solutionClass, OpaquePythonReference p
                     " on a function that return a list of int).", t);
         }
     }
+
+    public static Object wrapFact(Class<?> factClass, OpaquePythonReference fact, Map<Number, Object> referenceMap) {
+        try {
+            // We are wrapping a fact; so setters will not be used
+            PythonObject out = (PythonObject) PythonWrapperGenerator.wrap(factClass, fact,
+                    referenceMap,
+                    PythonWrapperGenerator.NONE_PYTHON_SETTER);
+            out.visitIds(out.get__optapy_reference_map());
+
+            // Mirror the reference map (not pass a reference to it)
+            // so Score + list variables can be safely garbage collected in Python
+            // (if it was not mirrored, reading the python object would add entries for them to the map,
+            //  which is used when cloning. If score/list variable was garbage collected by Python, another
+            //  Python Object can have the same id, leading to the old value in the map being returned,
+            //  causing an exception (or worse, a subtle bug))
+            out.readFromPythonObject(Collections.newSetFromMap(new IdentityHashMap<>()),
+                    new MirrorWithExtrasMap<>(out.get__optapy_reference_map()));
+            return out;
+        } catch (Throwable t) {
+            throw new OptaPyException("A problem occurred when wrapping the python fact (" +
+                    PythonWrapperGenerator.getPythonObjectString(fact) + ").\n"
+                    + "Maybe an annotation was passed an incorrect type " +
+                    "(for example, @problem_fact_collection_property(str) " +
+                    " on a function that returns a list of int).", t);
+        }
+    }
+
+    public static ConstraintProvider createConstraintProvider(Class<? extends ConstraintProvider> constraintProviderClass)
+            throws NoSuchMethodException,
+            InvocationTargetException, InstantiationException, IllegalAccessException {
+        return constraintProviderClass.getConstructor().newInstance();
+    }
 }
diff --git a/setup.py b/setup.py
index 8ea6dce..56e198f 100644
--- a/setup.py
+++ b/setup.py
@@ -119,7 +119,7 @@ def find_stub_files(stub_root: str):
         'License :: OSI Approved :: Apache Software License',
         'Operating System :: OS Independent'
     ],
-    packages=['optapy', 'optapy.config', 'optapy.constraint', 'optapy.score', 'optapy.types',
+    packages=['optapy', 'optapy.config', 'optapy.constraint', 'optapy.score', 'optapy.types', 'optapy.test',
               'jpyinterpreter',
               'java-stubs', 'jpype-stubs', 'org-stubs'],
     package_dir={


Test patch:
diff --git a/optapy-core/src/main/python/test/__init__.py b/optapy-core/src/main/python/test/__init__.py
new file mode 100644
index 0000000..5602bf5
--- /dev/null
+++ b/optapy-core/src/main/python/test/__init__.py
@@ -0,0 +1,485 @@
+from typing import Callable, Generic, List, Type, TypeVar, TYPE_CHECKING, overload, Union
+from jpype import JProxy
+
+from ..jpype_type_conversions import PythonBiFunction
+from ..optaplanner_java_interop import get_class
+from ..constraint_stream import PythonConstraintFactory, BytecodeTranslation
+
+if TYPE_CHECKING:
+    # These imports require a JVM to be running, so only import if type checking
+    from org.optaplanner.core.api.score.stream import Constraint, ConstraintFactory
+    from org.optaplanner.core.config.solver import SolverConfig
+    from org.optaplanner.core.api.score import Score
+
+
+Solution_ = TypeVar('Solution_')
+
+
+class ConstraintVerifier(Generic[Solution_]):
+    """
+    Entry point for the ConstraintVerifier API, which is used to test constraints defined by
+    a @constraint_provider function.
+    """
+    def __init__(self, delegate):
+        self.delegate = delegate
+        self.bytecode_translation = BytecodeTranslation.IF_POSSIBLE
+
+    def with_bytecode_translation(self, bytecode_translation: BytecodeTranslation) ->\
+            'ConstraintVerifier[Solution_]':
+        """
+        All subsequent calls to verify_that(constraint_function) will translate bytecode according to the rules
+        of the given BytecodeTranslation
+
+        :param bytecode_translation: A BytecodeTranslation member.
+        :return: self, for chaining
+        """
+        self.bytecode_translation = bytecode_translation
+        return self
+
+    @overload
+    def verify_that(self) -> 'MultiConstraintVerification[Solution_]':
+        """
+        Creates a constraint verifier for all constraints of the ConstraintProvider.
+        """
+        ...
+
+    @overload
+    def verify_that(self, constraint_function: Callable[['ConstraintFactory'], 'Constraint']) -> \
+            'SingleConstraintVerification[Solution_]':
+        """
+        Creates a constraint verifier for a given Constraint of the ConstraintProvider.
+        :param constraint_function: The constraint to verify
+        """
+        ...
+
+    def verify_that(self, constraint_function: Callable[['ConstraintFactory'], 'Constraint'] = None):
+        """
+        Creates a constraint verifier for a given Constraint of the ConstraintProvider.
+        :param constraint_function: Sometimes None, the constraint to verify. If not provided, all
+                                    constraints will be tested
+        """
+        if constraint_function is None:
+            return MultiConstraintVerification(self.delegate.verifyThat())
+        else:
+            return SingleConstraintVerification(self.delegate.verifyThat(
+                PythonBiFunction(lambda _, constraint_factory:
+                                 constraint_function(PythonConstraintFactory(constraint_factory,
+                                                                             self.bytecode_translation)))))
+
+
+class SingleConstraintVerification(Generic[Solution_]):
+    def __init__(self, delegate):
+        self.delegate = delegate
+
+    def given(self, *facts) -> 'SingleConstraintAssertion':
+        """
+        Set the facts for this assertion
+        :param facts: Never None, at least one
+        """
+        from org.optaplanner.optapy import PythonSolver  # noqa
+        from org.optaplanner.jpyinterpreter import CPythonBackedPythonInterpreter  # noqa
+        from org.optaplanner.jpyinterpreter.types import CPythonBackedPythonLikeObject  # noqa
+        from org.optaplanner.jpyinterpreter.types.wrappers import OpaquePythonReference  # noqa
+        reference_map = PythonSolver.getNewReferenceMap()
+        wrapped_facts = []
+
+        for fact in facts:
+            fact_class = get_class(type(fact))
+            wrapped_fact = PythonSolver.wrapFact(fact_class, fact, reference_map)
+            wrapped_facts.append(wrapped_fact)
+
+        for fact in reference_map.values():
+            if isinstance(fact, CPythonBackedPythonLikeObject):
+                CPythonBackedPythonInterpreter.updateJavaObjectFromPythonObject(fact, JProxy(OpaquePythonReference,
+                                                                                inst=getattr(fact, '$cpythonReference'),
+                                                                                convert=True), reference_map)
+
+        return SingleConstraintAssertion(self.delegate.given(wrapped_facts))
+
+
+    def given_solution(self, solution: 'Solution_') -> 'SingleConstraintAssertion':
+        """
+        Set the solution to be used for this assertion
+        :param solution: Never None
+        """
+        from org.optaplanner.optapy import PythonSolver  # noqa
+        solution_class = get_class(type(solution))
+        wrapped_solution = PythonSolver.wrapProblem(solution_class, solution)
+        return SingleConstraintAssertion(self.delegate.givenSolution(wrapped_solution))
+
+
+class MultiConstraintVerification(Generic[Solution_]):
+    def __init__(self, delegate):
+        self.delegate = delegate
+
+    def given(self, *facts) -> 'MultiConstraintAssertion':
+        """
+        Set the facts for this assertion
+        :param facts: Never None, at least one
+        """
+        from org.optaplanner.optapy import PythonSolver  # noqa
+        from org.optaplanner.jpyinterpreter import CPythonBackedPythonInterpreter  # noqa
+        from org.optaplanner.jpyinterpreter.types import CPythonBackedPythonLikeObject  # noqa
+        from org.optaplanner.jpyinterpreter.types.wrappers import OpaquePythonReference  # noqa
+        reference_map = PythonSolver.getNewReferenceMap()
+        wrapped_facts = []
+
+        for fact in facts:
+            fact_class = get_class(type(fact))
+            wrapped_fact = PythonSolver.wrapFact(fact_class, fact, reference_map)
+            wrapped_facts.append(wrapped_fact)
+
+        for fact in reference_map.values():
+            if isinstance(fact, CPythonBackedPythonLikeObject):
+                CPythonBackedPythonInterpreter.updateJavaObjectFromPythonObject(fact, JProxy(OpaquePythonReference,
+                                                                                inst=getattr(fact, '$cpythonReference'),
+                                                                                convert=True), reference_map)
+
+        return MultiConstraintAssertion(self.delegate.given(wrapped_facts))
+
+    def given_solution(self, solution: 'Solution_') -> 'MultiConstraintAssertion':
+        """
+        Set the solution to be used for this assertion
+        :param solution: Never None
+        """
+        from org.optaplanner.optapy import PythonSolver  # noqa
+        solution_class = get_class(type(solution))
+        wrapped_solution = PythonSolver.wrapProblem(solution_class, solution)
+        return MultiConstraintAssertion(self.delegate.givenSolution(wrapped_solution))
+
+
+class SingleConstraintAssertion:
+    def __init__(self, delegate):
+        self.delegate = delegate
+
+    @overload
+    def penalizes(self) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in any number of penalties.
+
+        Ignores the constraint and match weights: it only asserts the number of matches
+        For example: if there are two matches with weight of 10 each, this assertion will succeed.
+        If there are no matches, it will fail.
+
+        :raises AssertionError: when there are no penalties
+        """
+        ...
+
+    @overload
+    def penalizes(self, times: int) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in a given number of penalties.
+
+        Ignores the constraint and match weights: it only asserts the number of matches
+        For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
+
+        :param times: the expected number of penalties
+        :raises AssertionError: when the expected penalty is not observed
+        """
+        ...
+
+    @overload
+    def penalizes(self, message: str) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in any number of penalties.
+
+        Ignores the constraint and match weights: it only asserts the number of matches
+        For example: if there are two matches with weight of 10 each, this assertion will succeed.
+        If there are no matches, it will fail.
+
+        :param message: sometimes None, description of the scenario being asserted
+
+        :raises AssertionError: when there are no penalties
+        """
+        ...
+
+    @overload
+    def penalizes(self, times: int, message: str) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in a given number of penalties.
+
+        Ignores the constraint and match weights: it only asserts the number of matches
+        For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
+
+        :param times: the expected number of penalties
+        :param message: sometimes None, description of the scenario being asserted
+
+        :raises AssertionError: when the expected penalty is not observed
+        """
+        ...
+
+    def penalizes(self, times: Union[int, str] = None, message: str = None) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in a given number of penalties.
+
+        Ignores the constraint and match weights: it only asserts the number of matches
+        For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
+
+        :param times: sometimes None, the expected number of penalties. If not provided, it raises an AssertionError
+                      when there are no penalties
+        :param message: sometimes None, description of the scenario being asserted
+
+        :raises AssertionError: when the expected penalty is not observed if times is provided, or
+                                when there are no penalties if times is not provided
+
+        """
+        from java.lang import AssertionError as JavaAssertionError  # noqa
+        try:
+            if times is None and message is None:
+                self.delegate.penalizes()
+            elif times is not None and message is None:
+                self.delegate.penalizes(times)
+            elif times is None and message is not None:
+                self.delegate.penalizes(message)
+            else:
+                self.delegate.penalizes(times, message)
+        except JavaAssertionError as e:
+            raise AssertionError(e.getMessage())
+
+    @overload
+    def penalizes_by(self, match_weight_total: int) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in a specific penalty.
+
+        Ignores the constraint weight: it only asserts the match weights.
+        For example: a match with a match weight of 10 on a constraint with a constraint weight of -2hard reduces the
+        score by -20hard. In that case, this assertion checks for 10.
+
+        :param match_weight_total: the expected penalty
+        :raises AssertionError: when the expected penalty is not observed
+        """
+        ...
+
+    @overload
+    def penalizes_by(self, match_weight_total: int, message: str) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in a specific penalty.
+
+        Ignores the constraint weight: it only asserts the match weights.
+        For example: a match with a match weight of 10 on a constraint with a constraint weight of -2hard reduces the
+        score by -20hard. In that case, this assertion checks for 10.
+
+        :param match_weight_total: the expected penalty
+        :param message: sometimes None, description of the scenario being asserted
+        :raises AssertionError: when the expected penalty is not observed
+        """
+        ...
+
+    def penalizes_by(self, match_weight_total: int, message: str = None):
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in a specific penalty.
+
+        Ignores the constraint weight: it only asserts the match weights.
+        For example: a match with a match weight of 10 on a constraint with a constraint weight of -2hard reduces the
+        score by -20hard. In that case, this assertion checks for 10.
+
+        :param match_weight_total: the expected penalty
+        :param message: sometimes None, description of the scenario being asserted
+        :raises AssertionError: when the expected penalty is not observed
+        """
+        from java.lang import AssertionError as JavaAssertionError  # noqa
+        try:
+            if message is None:
+                self.delegate.penalizesBy(match_weight_total)
+            else:
+                self.delegate.penalizesBy(match_weight_total, message)
+        except JavaAssertionError as e:
+            raise AssertionError(e.getMessage())
+
+    @overload
+    def rewards(self) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in any number of rewards.
+
+        Ignores the constraint and match weights: it only asserts the number of matches
+        For example: if there are two matches with weight of 10 each, this assertion will succeed.
+        If there are no matches, it will fail.
+
+        :raises AssertionError: when there are no rewards
+        """
+        ...
+
+    @overload
+    def rewards(self, times: int) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in a given number of rewards.
+
+        Ignores the constraint and match weights: it only asserts the number of matches
+        For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
+
+        :param times: the expected number of rewards
+        :raises AssertionError: when the expected reward is not observed
+        """
+        ...
+
+    @overload
+    def rewards(self, message: str) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in any number of rewards.
+
+        Ignores the constraint and match weights: it only asserts the number of matches
+        For example: if there are two matches with weight of 10 each, this assertion will succeed.
+        If there are no matches, it will fail.
+
+        :param message: sometimes None, description of the scenario being asserted
+        :raises AssertionError: when there are no rewards
+        """
+        ...
+
+    @overload
+    def rewards(self, times: int, message: str) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in a given number of rewards.
+
+        Ignores the constraint and match weights: it only asserts the number of matches
+        For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
+
+        :param times: the expected number of rewards
+        :param message: sometimes None, description of the scenario being asserted
+        :raises AssertionError: when the expected reward is not observed
+        """
+        ...
+
+    def rewards(self, times: int = None, message: str = None):
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in a given number of rewards.
+
+        Ignores the constraint and match weights: it only asserts the number of matches
+        For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
+
+        :param times: sometimes None, the expected number of rewards. If not provided, it raises an AssertionError
+                      when there are no rewards
+        :param message: sometimes None, description of the scenario being asserted
+
+        :raises AssertionError: when the expected reward is not observed if times is provided, or
+                                when there are no rewards if times is not provided
+
+        """
+        from java.lang import AssertionError as JavaAssertionError  # noqa
+        try:
+            if times is None and message is None:
+                self.delegate.rewards()
+            elif times is not None and message is None:
+                self.delegate.rewards(times)
+            elif times is None and message is not None:
+                self.delegate.rewards(message)
+            else:
+                self.delegate.rewards(times, message)
+        except JavaAssertionError as e:
+            raise AssertionError(e.getMessage())
+
+    @overload
+    def rewards_with(self, match_weight_total: int) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in a specific reward.
+
+        Ignores the constraint weight: it only asserts the match weights.
+        For example: a match with a match weight of 10 on a constraint with a constraint weight of -2hard reduces the
+        score by -20hard. In that case, this assertion checks for 10.
+
+        :param match_weight_total: the expected reward
+        :raises AssertionError: when the expected reward is not observed
+        """
+        ...
+
+    @overload
+    def rewards_with(self, match_weight_total: int, message: str) -> None:
+        """
+        Asserts that the Constraint being tested, given a set of facts, results in a specific reward.
+
+        Ignores the constraint weight: it only asserts the match weights.
+        For example: a match with a match weight of 10 on a constraint with a constraint weight of -2hard reduces the
+        score by -20hard. In that case, this assertion checks for 10.
+
+        :param match_weight_total: the expected reward
+        :param message: sometimes None, description of the scenario being asserted
+        :raises AssertionError: when the expected reward is not observed
+        """
+        ...
+
+    def rewards_with(self, match_weight_total: int, message: str = None):
+        from java.lang import AssertionError as JavaAssertionError  # noqa
+        try:
+            if message is None:
+                self.delegate.rewardsWith(match_weight_total)
+            else:
+                self.delegate.rewardsWith(match_weight_total, message)
+        except JavaAssertionError as e:
+            raise AssertionError(e.getMessage())
+
+
+class MultiConstraintAssertion:
+    def __init__(self, delegate):
+        self.delegate = delegate
+
+    @overload
+    def scores(self, score: 'Score') -> None:
+        """
+        Asserts that the ConstraintProvider under test, given a set of facts, results in a specific Score.
+        :param score: total score calculated for the given set of facts
+        :raises AssertionError: when the expected score does not match the calculated score
+        """
+        ...
+
+    @overload
+    def scores(self, score: 'Score', message: str) -> None:
+        """
+        Asserts that the ConstraintProvider under test, given a set of facts, results in a specific Score.
+        :param score: total score calculated for the given set of facts
+        :param message: sometimes None, description of the scenario being asserted
+        :raises AssertionError: when the expected score does not match the calculated score
+        """
+        ...
+
+    def scores(self, score: 'Score', message: str = None):
+        """
+        Asserts that the ConstraintProvider under test, given a set of facts, results in a specific Score.
+        :param score: total score calculated for the given set of facts
+        :param message: sometimes None, description of the scenario being asserted
+        :raises AssertionError: when the expected score does not match the calculated score
+        """
+        from java.lang import AssertionError as JavaAssertionError  # noqa
+        try:
+            if message is None:
+                self.delegate.scores(score)
+            else:
+                self.delegate.scores(score, message)
+        except JavaAssertionError as e:
+            raise AssertionError(e.getMessage())
+
+
+def constraint_verifier_build(constraint_provider: Callable[['ConstraintFactory'], List['Constraint']],
+                              planning_solution_class: Type[Solution_], *entity_classes: Type) -> \
+        ConstraintVerifier[Solution_]:
+    """
+    Build a constraint verifier for the given @constraint_provider function.
+
+    :param constraint_provider: The constraint provider function (decorated with @constraint_provider) to
+                                create the ConstraintVerifier for
+    :param planning_solution_class: The type of the planning solution (decorated with @planning_solution)
+    :param entity_classes: The types of the entity classes (each decorated with @planning_entity)
+
+    :return: A ConstraintVerifier that can be used to test constraints
+    """
+    from org.optaplanner.test.api.score.stream import ConstraintVerifier as JavaConstraintVerifier  # noqa
+    from org.optaplanner.optapy import PythonSolver  # noqa
+    constraint_provider_instance = PythonSolver.createConstraintProvider(get_class(constraint_provider))
+    planning_solution_java_class = get_class(planning_solution_class)
+    entity_java_classes = list(map(get_class, entity_classes))
+    return ConstraintVerifier(JavaConstraintVerifier.build(constraint_provider_instance,
+                                                           planning_solution_java_class,
+                                                           entity_java_classes))
+
+
+def constraint_verifier_create(solver_config: 'SolverConfig') -> ConstraintVerifier:
+    """
+    Uses a SolverConfig to build a ConstraintVerifier. Alternative to build(ConstraintProvider, Type, Type).
+
+    :param solver_config: never None, must have a PlanningSolution class, PlanningEntity classes and a
+                          ConstraintProvider configured.
+
+    :return: A ConstraintVerifier that can be used to test constraints configured using the solver_config's
+             planning solution class, planning entity classes and constraint provider function.
+    """
+    from org.optaplanner.test.api.score.stream import ConstraintVerifier as JavaConstraintVerifier  # noqa
+    return ConstraintVerifier(JavaConstraintVerifier.create(solver_config))
diff --git a/tests/test_constraint_verifier.py b/tests/test_constraint_verifier.py
new file mode 100644
index 0000000..984ec48
--- /dev/null
+++ b/tests/test_constraint_verifier.py
@@ -0,0 +1,293 @@
+import pytest
+
+import optapy
+import optapy.types
+import optapy.score
+import optapy.config
+import optapy.constraint
+import optapy.test
+
+
+def verifier_suite(verifier: optapy.test.ConstraintVerifier, same_value, is_value_one,
+                   solution, e1, e2, e3, v1, v2, v3):
+    verifier.verify_that(same_value) \
+        .given(e1, e2) \
+        .penalizes(0)
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given(e1, e2) \
+            .rewards()
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given(e1, e2) \
+            .penalizes()
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given(e1, e2) \
+            .penalizes(1)
+
+    e1.value = v1
+    e2.value = v1
+    e3.value = v1
+
+    verifier.verify_that(same_value) \
+        .given(e1, e2) \
+        .penalizes(1)
+
+    verifier.verify_that(same_value) \
+        .given(e1, e2) \
+        .penalizes()
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given(e1, e2) \
+            .rewards(1)
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given(e1, e2) \
+            .penalizes(0)
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given(e1, e2) \
+            .penalizes(2)
+
+    verifier.verify_that(same_value) \
+        .given(e1, e2, e3) \
+        .penalizes(3)
+
+    verifier.verify_that(same_value) \
+        .given(e1, e2, e3) \
+        .penalizes()
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given(e1, e2, e3) \
+            .rewards(3)
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given(e1, e2, e3) \
+            .penalizes(2)
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given(e1, e2, e3) \
+            .penalizes(4)
+
+    verifier.verify_that(same_value) \
+        .given_solution(solution) \
+        .penalizes(3)
+
+    verifier.verify_that(same_value) \
+        .given_solution(solution) \
+        .penalizes()
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given_solution(solution) \
+            .rewards(3)
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given_solution(solution) \
+            .penalizes(2)
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(same_value) \
+            .given_solution(solution) \
+            .penalizes(4)
+
+    verifier.verify_that(is_value_one) \
+        .given(e1, e2, e3) \
+        .rewards(3)
+
+    verifier.verify_that(is_value_one) \
+        .given(e1, e2, e3) \
+        .rewards()
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(is_value_one) \
+            .given(e1, e2, e3) \
+            .penalizes()
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(is_value_one) \
+            .given(e1, e2, e3) \
+            .penalizes(3)
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(is_value_one) \
+            .given(e1, e2, e3) \
+            .rewards(2)
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that(is_value_one) \
+            .given(e1, e2, e3) \
+            .rewards(4)
+
+    verifier.verify_that() \
+        .given(e1, e2, e3) \
+        .scores(optapy.score.SimpleScore.of(0))
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that() \
+            .given(e1, e2, e3) \
+            .scores(optapy.score.SimpleScore.of(1))
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that() \
+            .given(e1, e2, e3) \
+            .scores(optapy.score.SimpleScore.of(-1))
+
+    verifier.verify_that() \
+        .given_solution(solution) \
+        .scores(optapy.score.SimpleScore.of(0))
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that() \
+            .given_solution(solution) \
+            .scores(optapy.score.SimpleScore.of(1))
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that() \
+            .given_solution(solution) \
+            .scores(optapy.score.SimpleScore.of(-1))
+
+    e1.value = v1
+    e2.value = v2
+    e3.value = v3
+    verifier.verify_that() \
+            .given(e1, e2, e3) \
+            .scores(optapy.score.SimpleScore.of(1))
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that() \
+            .given(e1, e2, e3) \
+            .scores(optapy.score.SimpleScore.of(2))
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that() \
+            .given(e1, e2, e3) \
+            .scores(optapy.score.SimpleScore.of(0))
+
+    verifier.verify_that() \
+        .given_solution(solution) \
+        .scores(optapy.score.SimpleScore.of(1))
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that() \
+            .given_solution(solution) \
+            .scores(optapy.score.SimpleScore.of(2))
+
+    with pytest.raises(AssertionError):
+        verifier.verify_that() \
+            .given_solution(solution) \
+            .scores(optapy.score.SimpleScore.of(0))
+
+
+def test_constraint_verifier_create():
+    @optapy.problem_fact
+    class Value:
+        def __init__(self, code):
+            self.code = code
+
+        def __str__(self):
+            return f'Value(code={self.code})'
+
+
+    @optapy.planning_entity
+    class Entity:
+        def __init__(self, code, value=None):
+            self.code = code
+            self.value = value
+
+        @optapy.planning_variable(Value, value_range_provider_refs=['value_range'])
+        def get_value(self):
+            return self.value
+
+        def set_value(self, value):
+            self.value = value
+
+
+    def same_value(constraint_factory: optapy.constraint.ConstraintFactory):
+        return (constraint_factory.for_each(Entity)
+                    .join(Entity, optapy.constraint.Joiners.less_than(lambda e: e.code),
+                                  optapy.constraint.Joiners.equal(lambda e: e.value))
+                    .penalize('Same value', optapy.score.SimpleScore.ONE)
+                )
+
+    def is_value_one(constraint_factory: optapy.constraint.ConstraintFactory):
+        return (constraint_factory.for_each(Entity)
+                    .filter(lambda e: e.value.code == 'v1')
+                    .reward('Value 1', optapy.score.SimpleScore.ONE)
+                )
+
+    @optapy.constraint_provider
+    def my_constraints(constraint_factory: optapy.constraint.ConstraintFactory):
+        return [
+            same_value(constraint_factory),
+            is_value_one(constraint_factory)
+        ]
+
+    @optapy.planning_solution
+    class Solution:
+        def __init__(self, entities, values, score=None):
+            self.entities = entities
+            self.values = values
+            self.score = score
+
+        @optapy.planning_entity_collection_property(Entity)
+        def get_entities(self):
+            return self.entities
+
+        @optapy.problem_fact_collection_property(Value)
+        @optapy.value_range_provider(range_id='value_range')
+        def get_values(self):
+            return self.values
+
+        @optapy.planning_score(optapy.score.SimpleScore)
+        def get_score(self) -> optapy.score.SimpleScore:
+            return self.score
+
+        def set_score(self, score):
+            self.score = score
+
+    solver_config = optapy.config.solver.SolverConfig()
+    solver_config.withSolutionClass(Solution) \
+        .withEntityClasses(Entity) \
+        .withConstraintProviderClass(my_constraints)
+
+    verifier = optapy.test.constraint_verifier_create(solver_config)
+
+    e1 = Entity('e1')
+    e2 = Entity('e2')
+    e3 = Entity('e3')
+
+    v1 = Value('v1')
+    v2 = Value('v2')
+    v3 = Value('v3')
+
+    solution = Solution([e1, e2, e3], [v1, v2, v3])
+
+    verifier_suite(verifier, same_value, is_value_one,
+                   solution, e1, e2, e3, v1, v2, v3)
+
+    verifier = optapy.test.constraint_verifier_build(my_constraints, Solution, Entity)
+
+    e1 = Entity('e1')
+    e2 = Entity('e2')
+    e3 = Entity('e3')
+
+    v1 = Value('v1')
+    v2 = Value('v2')
+    v3 = Value('v3')
+
+    solution = Solution([e1, e2, e3], [v1, v2, v3])
+
+    verifier_suite(verifier, same_value, is_value_one,
+                   solution, e1, e2, e3, v1, v2, v3)
diff --git a/tests/test_domain.py b/tests/test_domain.py
index 73143b7..f4164e5 100644
--- a/tests/test_domain.py
+++ b/tests/test_domain.py
@@ -6,6 +6,95 @@
 import dataclasses
 
 
+def test_solve_partial():
+    class Code:
+        def __init__(self, value):
+            self.value = value
+
+    @optapy.problem_fact
+    class Value:
+        def __init__(self, code):
+            self.code = Code(code)
+
+    @optapy.planning_entity
+    class Entity:
+        def __init__(self, code, value=None):
+            self.code = Code(code)
+            self.value = value
+
+        @optapy.planning_variable(Value, value_range_provider_refs=['value_range'])
+        def get_value(self):
+            return self.value
+
+        def set_value(self, value):
+            self.value = value
+
+
+    def is_value_one(constraint_factory: optapy.constraint.ConstraintFactory):
+        return (constraint_factory.for_each(Entity)
+                .filter(lambda e: e.value.code.value == 'v1')
+                .reward('Value 1', optapy.score.SimpleScore.ONE)
+                )
+
+    @optapy.constraint_provider
+    def my_constraints(constraint_factory: optapy.constraint.ConstraintFactory):
+        return [
+            is_value_one(constraint_factory)
+        ]
+
+    @optapy.planning_solution
+    class Solution:
+        def __init__(self, entities, values, score=None):
+            self.entities = entities
+            self.values = values
+            self.score = score
+
+        @optapy.planning_entity_collection_property(Entity)
+        def get_entities(self):
+            return self.entities
+
+        @optapy.problem_fact_collection_property(Value)
+        @optapy.value_range_provider(range_id='value_range')
+        def get_values(self):
+            return self.values
+
+        @optapy.planning_score(optapy.score.SimpleScore)
+        def get_score(self) -> optapy.score.SimpleScore:
+            return self.score
+
+        def set_score(self, score):
+            self.score = score
+
+    solver_config = optapy.config.solver.SolverConfig()
+    termination_config = optapy.config.solver.termination.TerminationConfig()
+    termination_config.setBestScoreLimit('3')
+    solver_config.withSolutionClass(Solution) \
+        .withEntityClasses(Entity) \
+        .withConstraintProviderClass(my_constraints) \
+        .withTerminationConfig(termination_config)
+
+    e1 = Entity('e1')
+    e2 = Entity('e2')
+    e3 = Entity('e3')
+
+    v1 = Value('v1')
+    v2 = Value('v2')
+    v3 = Value('v3')
+
+    e1.value = v1
+    e2.value = v2
+    e3.value = v3
+
+    problem = Solution([e1, e2, e3], [v1, v2, v3])
+    solver = optapy.solver_factory_create(solver_config).buildSolver()
+    solution = solver.solve(problem)
+
+    assert solution.score.getScore() == 3
+    assert solution.entities[0].value == v1
+    assert solution.entities[1].value == v1
+    assert solution.entities[2].value == v1
+
+
 def test_single_property():
     @optapy.planning_entity
     class Entity: