view COBRAxy/testing.py @ 218:8d1988935e1f draft

Uploaded
author luca_milaz
date Sat, 14 Dec 2024 18:50:08 +0000
parents 41f35c2f0c7b
children
line wrap: on
line source

# This is a general-purpose "testing utilities" module for the COBRAxy tool.
# This code was written entirely by m.ferrari133@campus.unimib.it and then (hopefully) many
# more people contributed by writing tests for this tool's modules, feel free to send an email for
# any questions.

# How the testing module works:
# The testing module allows you to easily set up unit tests for functions in a module, obtaining
# information on what each method returns, when and how it fails and so on.

# How do I test a module?
# - create a function at the very bottom, before the __main__
# - import the stuff you need
# - create a UnitTester instance, follow the documentation
# - fill it up with UnitTest instances, follow the documentation
# - each UnitTest tests the function by passing specific parameters to it and by veryfing the correctness
#   of the output via a CheckingMode instance
# - call testModule() on the UnitTester

# TODO(s):
# - This module was written before the utilities were introduced, it may want to use some of those functions.
# - I never got around to writing a CheckingMode for methods you WANT to fail in certain scenarios, I
#   like the name "MustPanic".
# - It's good practice to enforce boolean arguments of a function to be passed as kwargs and I did it a lot
#   in the code I wrote for these tool's modules, but the current implementation of UnitTest doesn't allow
#   you to pass kwargs to the functions you test.
# - Implement integration tests as well, maybe!

## Imports:
from typing import Dict, Callable, Type, List
from enum import Enum, auto
from collections.abc import Iterable

## Generic utilities:
class TestResult:
    """
    Represents the result of a test and contains all the relevant information about it. Loosely models two variants:
    - Ok: The test passed, no further information is saved besides the target's name.
    - Err: The test failed, an error message and further contextual details are also saved.

    This class does not ensure a static proof of the two states' behaviour, their meaning or mutual exclusivity outside
    of the :bool property "isPass", meant for outside reads.
    """
    def __init__(self, isPass :bool, targetName :str, errMsg = "", details = "") -> None:
        """
        (Private) Initializes an instance of TestResult.

        Args:
            isPass : distinction between TestResult.Ok (True) and TestResult.Err (False).
            targetName : the name of the target object / property / function / module being tested, not always set
            to a meaningful value at this stage.
            
            errMsg : concise error message explaining the test's failure.
            details : contextual details about the error.
        
        Returns:
            None : practically, a TestResult instance.
        """
        self.isPass = isPass
        self.isFail = not isPass # Convenience above all

        self.targetName = targetName
        if isPass: return

        self.errMsg   = errMsg
        self.details  = details

    @classmethod
    def Ok(cls, targetName = "") -> "TestResult":
        """
        Factory method for TestResult.Ok, where all we need to know is that our test passed.

        Args:
            targetName : the name of the target object / property / function / module being tested, not always set
            to a meaningful value at this stage.
        
        Returns:
            TestResult : a new Ok instance.
        """
        return cls(True, targetName)

    @classmethod
    def Err(cls, errMsg :str, details :str, targetName = "") -> "TestResult":
        """
        Factory method for TestResult.Err, where we store relevant error information.

        Args:
            errMsg : concise error message explaining the test's failure.
            details : contextual details about the error.
            targetName : the name of the target object / property / function / module being tested, not always set
            to a meaningful value at this stage.
        
        Returns:
            TestResult : a new Err instance.
        """
        return cls(False, targetName, errMsg, details)

    def log(self, isCompact = True) -> str:
        """
        Dumps all the available information in a :str, ready for logging.

        Args:
            isCompact : if True limits the amount of information displayed to the targetName.
        
        Returns:
            str : information about this test result.

        """
        if isCompact:
            return f"{TestResult.__name__}::{'Ok' if self.isPass else 'Err'}(Unit test on {self.targetName})"
        
        logMsg = f"Unit test on {self.targetName} {'passed' if self.isPass else f'failed because {self.errMsg}'}"
        if self.details: logMsg += f", {self.details}"
        return logMsg

    def throw(self) -> None:
        #TODO: finer Exception typing would be desirable
        """
        Logs the result information and panics.

        Raises:
            Exception : an error containing log information about the test result.

        Returns:
            None

        """
        raise Exception(self.log())

class CheckingMode:
    """
    (Private) Represents a way to check a value for correctness, in the context of "testing" it.
    """

    def __init__(self) -> None:
        """
        (Private) Implemented on child classes, initializes an instance of CheckingMode.

        Returns:
            None : practically, a CheckingMode instance.
        """
        self.logMsg = "CheckingMode base class should not be used directly"

    def __checkPasses__(self, _) -> bool:
        """
        (Private) Implemented on child classes, performs the actual correctness check on a received value.

        Returns:
            bool : True if the check passed, False if it failed.
        """
        return True
    
    def check(self, value) -> TestResult:
        """
        Converts the :bool evaluation of the value's correctness to a TestResult.

        Args:
            value : the value to check.

        Returns:
            TestResult : the result of the check.
        """
        return TestResult.Ok() if self.__checkPasses__(value) else TestResult.Err(self.logMsg, f"got {value} instead")
    
    def __repr__(self) -> str:
        """
        (Private) Implemented on child classes, formats :object as :str.
        """
        return self.__class__.__name__

class ExactValue(CheckingMode):
    """
    CheckingMode subclass variant to be used when the checked value needs to match another exactly.
    """
    
    #I suggest solving the more complex equality checking edge cases with the "Satisfies" and "MatchingShape" variants.
    def __init__(self, value) -> None:
        self.value  = value
        self.logMsg = f"value needed to match {value} exactly"
    
    def __checkPasses__(self, value) -> bool:
        return self.value == value

    def __repr__(self) -> str:
        return f"{super().__repr__()}({self.value})"

class AcceptedValues(CheckingMode):
    """
    CheckingMode subclass variant to be used when the checked value needs to appear in a list of accepted values.
    """
    def __init__(self, *values) -> None:
        self.values = values
        self.logMsg = f"value needed to be one of these: {values}"
    
    def __checkPasses__(self, value) -> bool:
        return value in self.values

    def __repr__(self) -> str:
        return f"{super().__repr__()}{self.values}"

class SatisfiesPredicate(CheckingMode):
    """
    CheckingMode subclass variant to be used when the checked value needs to verify a given predicate, as in
    the predicate accepts it as input and returns True.
    """
    def __init__(self, pred :Callable[..., bool], predName = "") -> None:
        self.pred = pred
        self.logMsg = f"value needed to verify a predicate{bool(predName) * f' called {predName}'}"
    
    def __checkPasses__(self, *params) -> bool:
        return self.pred(*params)

    def __repr__(self) -> str:
        return f"{super().__repr__()}(T) -> bool"

class IsOfType(CheckingMode):
    """
    CheckingMode subclass variant to be used when the checked value needs to be of a certain type.
    """
    def __init__(self, type :Type) -> None:
        self.type = type
        self.logMsg = f"value needed to be of type {type.__name__}"
    
    def __checkPasses__(self, value :Type) -> bool:
        return isinstance(value, self.type)

    def __repr__(self) -> str:
        return f"{super().__repr__()}:{self.type.__name__}"

class Exists(CheckingMode):
    """
    CheckingMode subclass variant to be used when the checked value needs to exist (or not!). Mainly employed as a quick default
    check that always passes, it still upholds its contract when it comes to checking for existing properties in objects
    without much concern on what value they contain.
    """
    def __init__(self, exists = True) -> None:
        self.exists = exists
        self.logMsg = f"value needed to {(not exists) * 'not '}exist"
    
    def __checkPasses__(self, _) -> bool: return self.exists

    def __repr__(self) -> str:
        return f"{super().__repr__() if self.exists else 'IsMissing'}"

class MatchingShape(CheckingMode):
    """
    CheckingMode subclass variant to be used when the checked value is an object that needs to have a certain shape,
    as in to posess properties with a given name and value. Each property is checked for existance and correctness with
    its own given CheckingMode.
    """
    def __init__(self, props :Dict[str, CheckingMode], objName = "") -> None:
        """
        (Private) Initializes an instance of MatchingShape.

        Args:
            props : :dict using property names as keys and checking modes for the property's value as values.
            objName : label for the object we're testing the shape of.

        Returns:
            None : practically, a MatchingShape instance.
        """
        self.props   = props
        self.objName = objName

        self.shapeRepr = " {\n" + "\n".join([f"  {propName} : {prop}" for propName, prop in props.items()]) + "\n}"
    
    def check(self, obj :object) -> TestResult:
        objIsDict = isinstance(obj, dict) # Python forces us to distinguish between object properties and dict keys
        for propName, checkingMode in self.props.items():
            # Checking if the property exists:
            if (not objIsDict and not hasattr(obj, propName)) or (objIsDict and propName not in obj):
                if not isinstance(checkingMode, Exists): return TestResult.Err(
                    f"property \"{propName}\" doesn't exist on object {self.objName}", "", self.objName)
                
                if not checkingMode.exists: return TestResult.Ok(self.objName)
                # Either the property value is meant to be checked (checkingMode is anything but Exists)
                # or we want the property to not exist, all other cases are handled correctly ahead
        
            checkRes = checkingMode.check(obj[propName] if objIsDict else getattr(obj, propName))
            if checkRes.isPass: continue

            checkRes.targetName = self.objName
            return TestResult.Err(
                f"property \"{propName}\" failed check {checkingMode} on shape {obj}",
                checkRes.log(isCompact = False),
                self.objName)

        return TestResult.Ok(self.objName)

    def __repr__(self) -> str:
        return super().__repr__() + self.shapeRepr

class Many(CheckingMode):
    """
    CheckingMode subclass variant to be used when the checked value is an Iterable we want to check item by item.
    """
    def __init__(self, *values :CheckingMode) -> None:
        self.values = values
        self.shapeRepr = " [\n" + "\n".join([f"  {value}" for value in values]) + "\n]"

    def check(self, coll :Iterable) -> TestResult:
        amt         = len(coll)
        expectedAmt = len(self.values)
        # Length equality is forced:
        if amt != expectedAmt: return TestResult.Err(
            "items' quantities don't match", f"expected {expectedAmt} items, but got {amt}")

        # Items in the given collection value are paired in order with the corresponding checkingMode meant for each of them
        for item, checkingMode in zip(coll, self.values):
            checkRes = checkingMode.check(item)
            if checkRes.isFail: return TestResult.Err(
                f"item in list failed check {checkingMode}",
                checkRes.log(isCompact = False))

        return TestResult.Ok()

    def __repr__(self) -> str:
        return super().__repr__() + self.shapeRepr

class LogMode(Enum):
    """
    Represents the level of detail of a logged message. Models 4 variants, in order of increasing detail:
    - Minimal  : Logs the overall test result for the entire module.
    - Default  : Also logs all single test fails, in compact mode.
    - Detailed : Logs all function test results, in compact mode.
    - Pedantic : Also logs all single test results in detailed mode.
    """
    Minimal  = auto()
    Default  = auto()
    Detailed = auto()
    Pedantic = auto()

    def isMoreVerbose(self, requiredMode :"LogMode") -> bool:
        """
        Compares the instance's level of detail with that of another.

        Args:
            requiredMode : the other instance.
        
        Returns:
            bool : True if the caller instance is a more detailed variant than the other.
        """
        return self.value >= requiredMode.value

## Specific Unit Testing utilities:
class UnitTest:
    """
    Represents a unit test, the test of a single function's isolated correctness.
    """
    def __init__(self, func :Callable, inputParams :list, expectedRes :CheckingMode) -> None:
        """
        (Private) Initializes an instance of UnitTest.

        Args:
            func : the function to test.
            inputParams : list of parameters to pass as inputs to the function, in order.
            expectedRes : checkingMode to test the function's return value for correctness.

        Returns:
            None : practically, a UnitTest instance.
        """
        self.func        = func
        self.inputParams = inputParams
        self.expectedRes = expectedRes

        self.funcName = func.__name__
    
    def test(self) -> TestResult:
        """
        Tests the function.

        Returns:
            TestResult : the test's result.
        """
        result = None
        try: result = self.func(*self.inputParams)
        except Exception as e: return TestResult.Err("the function panicked at runtime", e, self.funcName)

        checkRes = self.expectedRes.check(result)
        checkRes.targetName = self.funcName
        return checkRes

class UnitTester:
    """
    Manager class for unit testing an entire module, groups single UnitTests together and executes them in order on a
    per-function basis (tests about the same function are executed consecutively) giving back as much information as
    possible depending on the selected logMode. More customization options are available.
    """
    def __init__(self, moduleName :str, logMode = LogMode.Default, stopOnFail = True, *funcTests :'UnitTest') -> None:
        """
        (Private) initializes an instance of UnitTester.

        Args:
            moduleName : name of the tested module.
            logMode : level of detail applied to all messages logged during the test.
            stopOnFail : if True, the test stops entirely after one unit test fails.
            funcTests : the unit tests to perform on the module.

        Returns:
            None : practically, a UnitTester instance.
        """
        self.logMode    = logMode
        self.moduleName = moduleName
        self.stopOnFail = stopOnFail

        # This ensures the per-function order:
        self.funcTests :Dict[str, List[UnitTest]]= {}
        for test in funcTests:
            if test.funcName in self.funcTests: self.funcTests[test.funcName].append(test)
            else: self.funcTests[test.funcName] = [test]

    def logTestResult(self, testRes :TestResult) -> None:
        """
        Prints the formatted result information of a unit test.

        Args:
            testRes : the result of the test.
        
        Returns:
            None
        """
        if testRes.isPass: return self.log("Passed!", LogMode.Detailed, indent = 2)
        
        failMsg = "Failed! "
        # Doing it this way prevents .log computations when not needed
        if self.logMode.isMoreVerbose(LogMode.Detailed):
            # Given that Pedantic is the most verbose variant, there's no point in comparing with LogMode.isMoreVerbose
            failMsg += testRes.log(self.logMode is not LogMode.Pedantic)
        
        self.log(failMsg, indent = 2)

    def log(self, msg :str, minRequiredMode = LogMode.Default, indent = 0) -> None:
        """
        Prints and formats a message only when the UnitTester instance is set to a level of detail at least equal
        to a minimum requirement, given as input.

        Args:
            msg : the message to print.
            minRequiredMode : minimum detail requirement.
            indent : formatting information, counter from 0 that adds 2 spaces each number up
        
        Returns:
            None
        """
        if self.logMode.isMoreVerbose(minRequiredMode): print("  " * indent + msg)

    def testFunction(self, name :str) -> TestResult:
        """
        Perform all unit tests relative to the same function, plus the surrounding logs and checks.

        Args:
            name : the name of the tested function.
        
        Returns :
            TestResult : the overall Ok result of all the tests passing or the first Err. This behaviour is unrelated
            to that of the overall testing procedure (stopOnFail), it always works like this for tests about the
            same function.
        """
        self.log(f"Unit testing {name}...", indent = 1)

        allPassed = True
        for unitTest in self.funcTests[name]:
            testRes = unitTest.test()
            self.logTestResult(testRes)
            if testRes.isPass: continue
            
            allPassed = False
            if self.stopOnFail: break
        
        self.log("", LogMode.Detailed) # Provides one extra newline of space when needed, to better format the output
        if allPassed: return TestResult.Ok(name)

        if self.logMode is LogMode.Default: self.log("")        
        return TestResult.Err(f"Unlogged err", "unit test failed", name)
    
    def testModule(self) -> None:
        """
        Runs all the provided unit tests in order but on a per-function basis.

        Returns:
            None
        """
        self.log(f"Unit testing module {self.moduleName}...", LogMode.Minimal)
        
        fails = 0
        testStatusMsg = "complete"
        for funcName in self.funcTests.keys():
            if self.testFunction(funcName).isPass: continue
            fails += 1

            if self.stopOnFail:
                testStatusMsg = "interrupted"
                break

        self.log(f"Testing {testStatusMsg}: {fails} problem{'s' * (fails != 1)} found.\n", LogMode.Minimal)
        # ^^^ Manually applied an extra newline of space.

## Unit testing all the modules:
def unit_cobraxy() -> None:
    import cobraxy as m
    import math
    import lxml.etree as ET
    import utils.general_utils as utils

    #m.ARGS = m.process_args()

    ids = ["react1", "react2", "react3", "react4", "react5"]
    metabMap = utils.Model.ENGRO2.getMap()
    class_pat = {
        "dataset1" :[
            [2.3, 4, 7, 0, 0.01, math.nan, math.nan],
            [math.nan, math.nan, math.nan, math.nan, math.nan, math.nan, math.nan],
            [2.3, 4, 7, 0, 0.01, 5, 9],
            [math.nan, math.nan, 2.3, 4, 7, 0, 0.01],
            [2.3, 4, 7, math.nan, 2.3, 0, 0.01]],
        
        "dataset2" :[
            [2.3, 4, 7, math.nan, 2.3, 0, 0.01],
            [2.3, 4, 7, 0, 0.01, math.nan, math.nan],
            [math.nan, math.nan, 2.3, 4, 7, 0, 0.01],
            [2.3, 4, 7, 0, 0.01, 5, 9],
            [math.nan, math.nan, math.nan, math.nan, math.nan, math.nan, math.nan]]
    }

    unitTester = UnitTester("cobraxy", LogMode.Pedantic, False,
        UnitTest(m.name_dataset, ["customName", 12], ExactValue("customName")),
        UnitTest(m.name_dataset, ["Dataset", 12], ExactValue("Dataset_12")),

        UnitTest(m.fold_change, [0.5, 0.5], ExactValue(0.0)),
        UnitTest(m.fold_change, [0, 0.35], ExactValue("-INF")),
        UnitTest(m.fold_change, [0.5, 0], ExactValue("INF")),
        UnitTest(m.fold_change, [0, 0], ExactValue(0)),

        UnitTest(
            m.Arrow(m.Arrow.MAX_W, m.ArrowColor.DownRegulated, isDashed = True).toStyleStr, [],
            ExactValue(";stroke:#0000FF;stroke-width:12;stroke-dasharray:5,5")),
        
        UnitTest(m.computeEnrichment, [metabMap, class_pat, ids], ExactValue(None)),
        
        UnitTest(m.computePValue, [class_pat["dataset1"][0], class_pat["dataset2"][0]], SatisfiesPredicate(math.isnan)),
        
        UnitTest(m.reactionIdIsDirectional, ["reactId"], ExactValue(m.ReactionDirection.Unknown)),
        UnitTest(m.reactionIdIsDirectional, ["reactId_F"], ExactValue(m.ReactionDirection.Direct)),
        UnitTest(m.reactionIdIsDirectional, ["reactId_B"], ExactValue(m.ReactionDirection.Inverse)),

        UnitTest(m.ArrowColor.fromFoldChangeSign, [-2], ExactValue(m.ArrowColor.DownRegulated)),
        UnitTest(m.ArrowColor.fromFoldChangeSign, [2], ExactValue(m.ArrowColor.UpRegulated)),

        UnitTest(
            m.Arrow(m.Arrow.MAX_W, m.ArrowColor.UpRegulated).styleReactionElements,
            [metabMap, "reactId"],
            ExactValue(None)),
        
        UnitTest(m.getArrowBodyElementId, ["reactId"], ExactValue("R_reactId")),
        UnitTest(m.getArrowBodyElementId, ["reactId_F"], ExactValue("R_reactId")),

        UnitTest(
            m.getArrowHeadElementId, ["reactId"],
            Many(ExactValue("F_reactId"), ExactValue("B_reactId"))),
        
        UnitTest(
            m.getArrowHeadElementId, ["reactId_F"],
            Many(ExactValue("F_reactId"), ExactValue(""))),
        
        UnitTest(
            m.getArrowHeadElementId, ["reactId_B"],
            Many(ExactValue("B_reactId"), ExactValue(""))),
        
        UnitTest(
            m.getElementById, ["reactId_F", metabMap],
            SatisfiesPredicate(lambda res : res.isErr and isinstance(res.value, utils.Result.ResultErr))),
        
        UnitTest(
            m.getElementById, ["F_tyr_L_t", metabMap],
            SatisfiesPredicate(lambda res : res.isOk and res.unwrap().get("id") == "F_tyr_L_t")),
    ).testModule()

def unit_rps_generator() -> None:
    import rps_generator as rps
    import math
    import pandas as pd
    import utils.general_utils as utils
    dataset = pd.DataFrame({
        "cell lines" : ["normal", "cancer"],
        "pyru_vate"  : [5.3, 7.01],
        "glu,cose"   : [8.2, 4.0],
        "unknown"    : [3.0, 3.97],
        "()atp"      : [7.05, 8.83],
    })

    abundancesNormalRaw = {
        "pyru_vate" : 5.3,
        "glu,cose"  : 8.2,
        "unknown"   : 3.0,
        "()atp"     : 7.05,
    }

    abundancesNormal = {
        "pyr"    : 5.3,
        "glc__D" : 8.2,
        "atp"    : 7.05,
    }

    # TODO: this currently doesn't work due to "the pickle extension problem", see FileFormat class for details.
    synsDict = utils.readPickle(utils.FilePath("synonyms", utils.FileFormat.PICKLE, prefix = "./local/pickle files"))

    reactionsDict = {
        "r1" : {
            "glc__D" : 1
        },
    
        "r2" : {
            "co2" : 2,
            "pyr" : 3,
        },

        "r3" : {
            "atp"    : 2,
            "glc__D" : 4,
        },
        
        "r4" : {
            "atp" : 3,
        }
    }

    abundancesNormalEdited = {
        "pyr"    : 5.3,
        "glc__D" : 8.2,
        "atp"    : 7.05,
        "co2"    : 1,
    }

    blackList = ["atp"] # No jokes allowed!
    missingInDataset = ["co2"]

    normalRpsShape = MatchingShape({
        "r1" : ExactValue(8.2 ** 1),
        "r2" : ExactValue((1 ** 2) * (5.3 ** 3)),
        "r3" : ExactValue((8.2 ** 4) * (7.05 ** 2)),
        "r4" : SatisfiesPredicate(lambda n : math.isnan(n))
    }, "rps dict")

    UnitTester("rps_generator", LogMode.Pedantic, False,
        UnitTest(rps.get_abund_data, [dataset, 0], MatchingShape({
            "pyru_vate" : ExactValue(5.3),
            "glu,cose"  : ExactValue(8.2),
            "unknown"   : ExactValue(3.0),
            "()atp"     : ExactValue(7.05),
            "name"      : ExactValue("normal")
        }, "abundance series")),
        
        UnitTest(rps.get_abund_data, [dataset, 1], MatchingShape({
            "pyru_vate" : ExactValue(7.01),
            "glu,cose"  : ExactValue(4.0),
            "unknown"   : ExactValue(3.97),
            "()atp"     : ExactValue(8.83),
            "name"      : ExactValue("cancer")
        }, "abundance series")),

        UnitTest(rps.get_abund_data, [dataset, -1], ExactValue(None)),

        UnitTest(rps.check_missing_metab, [reactionsDict, abundancesNormal.copy()], Many(MatchingShape({
            "pyr"    : ExactValue(5.3),
            "glc__D" : ExactValue(8.2),
            "atp"    : ExactValue(7.05),
            "co2"    : ExactValue(1)
        }, "updated abundances"), Many(ExactValue("co2")))),

        UnitTest(rps.clean_metabolite_name, ["4,4'-diphenylmethane diisocyanate"], ExactValue("44diphenylmethanediisocyanate")),

        UnitTest(rps.get_metabolite_id, ["tryptophan", synsDict], ExactValue("trp__L")),

        UnitTest(rps.calculate_rps, [reactionsDict, abundancesNormalEdited, blackList, missingInDataset], normalRpsShape),

        UnitTest(rps.rps_for_cell_lines, [dataset, reactionsDict, blackList, synsDict, "", True], Many(normalRpsShape, MatchingShape({
            "r1" : ExactValue(4.0 ** 1),
            "r2" : ExactValue((1 ** 2) * (7.01 ** 3)),
            "r3" : ExactValue((4.0 ** 4) * (8.83 ** 2)),
            "r4" : SatisfiesPredicate(lambda n : math.isnan(n))
        }, "rps dict"))),

        #UnitTest(rps.main, [], ExactValue(None)) # Complains about sys argvs
    ).testModule()

def unit_custom_data_generator() -> None:
    import custom_data_generator as cdg

    UnitTester("custom data generator", LogMode.Pedantic, False,
        UnitTest(lambda :True, [], ExactValue(True)), # No tests can be done without a model at hand!
    ).testModule()

def unit_utils() -> None:
    import utils.general_utils as utils
    import utils.rule_parsing as ruleUtils
    import utils.reaction_parsing as reactionUtils

    UnitTester("utils", LogMode.Pedantic, False,
        UnitTest(utils.CustomErr, ["myMsg", "more details"], MatchingShape({
            "details" : ExactValue("more details"),
            "msg"     : ExactValue("myMsg"),
            "id"      : ExactValue(0) # this will fail if any custom errors happen anywhere else before!
        })),

        UnitTest(utils.CustomErr, ["myMsg", "more details", 42], MatchingShape({
            "details" : ExactValue("more details"),
            "msg"     : ExactValue("myMsg"),
            "id"      : ExactValue(42)
        })),

        UnitTest(utils.Bool("someArg").check, ["TrUe"],  ExactValue(True)),
        UnitTest(utils.Bool("someArg").check, ["FALse"], ExactValue(False)),
        UnitTest(utils.Bool("someArg").check, ["foo"],   Exists(False)), # should panic!

        UnitTest(utils.Model.ENGRO2.getRules, ["."], IsOfType(dict)),
        UnitTest(utils.Model.Custom.getRules, [".", ""], Exists(False)), # expected panic

        # rule utilities tests:
        UnitTest(ruleUtils.parseRuleToNestedList, ["A"], Many(ExactValue("A"))),
        UnitTest(ruleUtils.parseRuleToNestedList, ["A or B"],  Many(ExactValue("A"), ExactValue("B"))),
        UnitTest(ruleUtils.parseRuleToNestedList, ["A and B"], Many(ExactValue("A"), ExactValue("B"))),
        UnitTest(ruleUtils.parseRuleToNestedList, ["A foo B"], Exists(False)), # expected panic
        UnitTest(ruleUtils.parseRuleToNestedList, ["A)"], Exists(False)), # expected panic

        UnitTest(
            ruleUtils.parseRuleToNestedList, ["A or B"],
            MatchingShape({ "op" : ExactValue(ruleUtils.RuleOp.OR)})),
        
        UnitTest(
            ruleUtils.parseRuleToNestedList, ["A and B"],
            MatchingShape({ "op" : ExactValue(ruleUtils.RuleOp.AND)})),
        
        UnitTest(
            ruleUtils.parseRuleToNestedList, ["A or B and C"],
            MatchingShape({ "op" : ExactValue(ruleUtils.RuleOp.OR)})),

        UnitTest(
            ruleUtils.parseRuleToNestedList, ["A or B and C or (D and E)"],
            Many(
                ExactValue("A"), 
                Many(ExactValue("B"), ExactValue("C")),
                Many(ExactValue("D"), ExactValue("E"))
            )),

        UnitTest(lambda s : ruleUtils.RuleOp(s), ["or"],  ExactValue(ruleUtils.RuleOp.OR)),
        UnitTest(lambda s : ruleUtils.RuleOp(s), ["and"], ExactValue(ruleUtils.RuleOp.AND)),
        UnitTest(lambda s : ruleUtils.RuleOp(s), ["foo"], Exists(False)), # expected panic

        UnitTest(ruleUtils.RuleOp.isOperator, ["or"],  ExactValue(True)),
        UnitTest(ruleUtils.RuleOp.isOperator, ["and"], ExactValue(True)),
        UnitTest(ruleUtils.RuleOp.isOperator, ["foo"], ExactValue(False)),

        # reaction utilities tests:
        UnitTest(reactionUtils.ReactionDir.fromReaction, ["atp <=> adp + pi"], ExactValue(reactionUtils.ReactionDir.REVERSIBLE)),
        UnitTest(reactionUtils.ReactionDir.fromReaction, ["atp --> adp + pi"], ExactValue(reactionUtils.ReactionDir.FORWARD)),
        UnitTest(reactionUtils.ReactionDir.fromReaction, ["atp <-- adp + pi"], ExactValue(reactionUtils.ReactionDir.BACKWARD)),
        UnitTest(reactionUtils.ReactionDir.fromReaction, ["atp ??? adp + pi"], Exists(False)), # should panic

        UnitTest(
            reactionUtils.create_reaction_dict,
            [{'shdgd': '2 pyruvate + 1 h2o <=> 1 h2o + 2 acetate', 'sgwrw': '2 co2 + 6 h2o --> 3 atp'}], 
            MatchingShape({
                "shdgd_B" : MatchingShape({
                    "acetate" : ExactValue(2),
                    "h2o" : ExactValue(1),
                }),

                "shdgd_F" : MatchingShape({
                    "pyruvate" : ExactValue(2),
                    "h2o" : ExactValue(1)
                }),

                "sgwrw" : MatchingShape({
                    "co2" : ExactValue(2),
                    "h2o" : ExactValue(6),
                })
            }, "reaction dict")),   
    ).testModule()

    rule = "A and B or C or D and (E or F and G) or H"
    print(f"rule \"{rule}\" should comes out as: {ruleUtils.parseRuleToNestedList(rule)}")

def unit_ras_generator() -> None:
    import ras_generator as ras
    import utils.rule_parsing as ruleUtils

    # Making an alias to mask the name of the inner function and separate the 2 tests:
    def opListAlias(op_list, dataset):
        ras.ARGS.none = False
        return ras.ras_op_list(op_list, dataset)
    
    ras.ARGS = ras.process_args()
    rule = ruleUtils.OpList(ruleUtils.RuleOp.AND)
    rule.extend(["foo", "bar", "baz"])

    dataset = { "foo" : 5, "bar" : 2, "baz" : None }
    
    UnitTester("ras generator", LogMode.Pedantic, False,
        UnitTest(ras.ras_op_list, [rule, dataset], ExactValue(2)),
        UnitTest(opListAlias, [rule, dataset], ExactValue(None)),
    ).testModule()

if __name__ == "__main__":
    unit_cobraxy()
    unit_custom_data_generator()
    unit_utils()
    unit_ras_generator()