Mercurial > repos > bimib > cobraxy
diff COBRAxy/testing.py @ 4:41f35c2f0c7b draft
Uploaded
author | luca_milaz |
---|---|
date | Wed, 18 Sep 2024 10:59:10 +0000 |
parents | |
children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/COBRAxy/testing.py Wed Sep 18 10:59:10 2024 +0000 @@ -0,0 +1,806 @@ +# 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() \ No newline at end of file