Jelleas / CheckPy

A Python tool for running tests on Python source files. Intended to be used by students whom are taking courses in the Minor Programming at the UvA.
MIT License
9 stars 4 forks source link

literal tests #29

Open Jelleas opened 10 months ago

Jelleas commented 10 months ago
@literal()
def testGuess():
     """number of guesses decreases after incorrect guess"""
     hangman = Hangman("hello", 5)
     assert hangman.number_of_guesses() == 5
     assert not hangman.guess("a")
     assert hangman.number_of_guesses() == 4
     assert hangman.guess("e")
     assert hangman.number_of_guesses() == 4

produces the following output on AssertionError:

This check failed. Run the following code in the terminal to see why:
$ python3
>>> from hangman import *
>>> hangman = Hangman("hello", 5)
>>> assert hangman.number_of_guesses() == 5
>>> assert not hangman.guess("a")
>>> assert hangman.number_of_guesses() == 4
>>> assert hangman.guess("e")
>>> assert hangman.number_of_guesses() == 4
Jelleas commented 10 months ago

https://github.com/minprog/python/blob/main/tests/hangman/hangmanTest.py for inspiration

Jelleas commented 3 months ago
(declarative.repl()
        .run("from war import Card, Deck, DrawPile")
        .stdout("")
        .run("deck = Deck()")
        .run("assert deck.deal() == Card('A', '2')")
)()

Some initial work:

class repl:
    def __init__(self, fileName: Optional[str]=None):
        self._initialState: ReplState = ReplState(fileName=fileName)
        self._stack: List[Callable[["ReplState"], None]] = []
        self._description: Optional[str] = None

        init = self.do(lambda replState: ...)
        self._stack = init._stack
        self.__name__ = init.__name__
        self.__doc__ = init.__doc__

    def run(self, statement: str) -> Self:
        def run(state: ReplState) -> None:
            import checkpy.lib
            with checkpy.lib.captureStdout() as stdout:
                exec(statement, state.env)

            state.addStatement(statement, stdout.read())

        return self.do(run)

    def stdout(self, expected: str) -> Self:
        """Assert that the last statement printed expected."""
        def testStdout(state: ReplState):
            nonlocal expected
            expected = str(expected)
            actual = state._stdoutOutputs[-1] # TODO
            if expected != actual:
                msg = (
                    "Expected the following output:\n" +
                    expected +
                    "\nBut found:\n" +
                    actual +
                    "\n"
                )
                raise AssertionError(
                    msg +
                    state.replLog()
                )

        return self.do(testStdout)

    def do(self, function: Callable[["ReplState"], None]) -> Self:
        """
        Put function on the internal stack and call it after all previous calls have resolved.
        .do serves as an entry point for extensibility. Allowing you, the test writer, to insert
        specific and custom asserts, hints, and the like. For example:
    def checkDataFileIsUnchanged(state: "FunctionState"):
        with open("data.txt") as f:
            assert f.read() == "42\\n", "make sure not to change the file data.txt"

    testDataUnchanged = test()(function("process_data").call("data.txt").do(checkDataFileIsUnchanged))
    ```
    """
    self = deepcopy(self)
    self._stack.append(function)

    self.__name__ = f"declarative_repl_test_{uuid4()}"
    self.__doc__ = self._description if self._description is not None else self._initialState.description

    return self

def __call__(self, test: Optional[checkpy.tests.Test]=None) -> "FunctionState":
    """Run the test."""
    if test is None:
        test = checkpy.tester.getActiveTest()

    initialDescription = ""
    if test is not None\
        and test.description != test.PLACEHOLDER_DESCRIPTION\
        and test.description != self._initialState.description:
        initialDescription = test.description

    stack = list(self._stack)
    state = deepcopy(self._initialState)

    for step in stack:
        step(state)

    if initialDescription:
        state.setDescriptionFormatter(lambda descr, state: descr)
        state.description = initialDescription
    elif state.wasCalled:
        state.description = f"{state.getFunctionCallRepr()} works as expected"
    else:
        state.description = f"{state.name} is correctly defined"

    return state

class ReplState: def init(self, fileName: Optional[str]=None): self._description: str = f"TODO" self._fileName = fileName self._isDescriptionMutable: bool = True self._statements: List[str] = [] self._stdoutOutputs: List[str] = [] self.env: dict = {}

@staticmethod
def _descriptionFormatter(descr: str, state: "ReplState") -> str:
    return f"testing" + (f" >> {descr}" if descr else "")

@property
def description(self) -> str:
    """The description of the test, what is ultimately shown on the screen."""
    return self._descriptionFormatter(self._description, self)

@description.setter
def description(self, newDescription: str):
    if not self.isDescriptionMutable:
        return
    self._description = newDescription

    test = checkpy.tester.getActiveTest()
    if test is None:
        raise checkpy.entities.exception.CheckpyError(
            message=f"Cannot change description while there is no test running."
        )
    test.description = self.description

@property
def fileName(self) -> Optional[str]:
    """
    The name of the Python file to run and import.
    If this is not set (`None`), the default file (`checkpy.file.name`) is used.
    """
    return self._fileName

@fileName.setter
def fileName(self, newFileName: Optional[str]):
    self._fileName = newFileName

def addStatement(self, statement: str, stdout: str):
    self._statements.append(statement)
    self._stdoutOutputs.append(stdout)

@property
def isDescriptionMutable(self):
    """Can the description be changed (mutated)?"""
    return self._isDescriptionMutable

@isDescriptionMutable.setter
def isDescriptionMutable(self, newIsDescriptionMutable: bool):
    self._isDescriptionMutable = newIsDescriptionMutable

def setDescriptionFormatter(self, formatter: Callable[[str, "ReplState"], str]):
    """
    The test's description is formatted by a function accepting the new description and the state.
    This method allows you to overwrite that function, for instance:

    `state.setDescriptionFormatter(lambda descr, state: f"Testing your function {state.name}: {descr}")`
    """
    self._descriptionFormatter = formatter # type:ignore [method-assign, assignment]

    test = checkpy.tester.getActiveTest()
    if test is None:
        raise checkpy.entities.exception.CheckpyError(
            message=f"Cannot change descriptionFormatter while there is no test running."
        )
    test.description = self.description

def replLog(self):
    """
    Helper function that formats each line as if it were fed to Python's repl.
    """
    def fixLine(line: str) -> str:
        line = line.rstrip("\n")

        if line.startswith(" "):
            return "... " + line
        if not line.startswith(">>> "):
            return ">>> " + line
        return line

    # break-up multi-line statements
    actualLines = []
    for line in self._statements:
        actualLines.extend([l for l in line.split("\n") if l])

    # prepend >>> and ... (what you'd see in the REPL)
    # replace any "assert " statements with "True" on the next line
    fixedLines = [fixLine(l) for l in actualLines]

    pre = (
        'This check failed. Run the following code in the terminal to find out why:\n'
        '$ python3\n'
    )

    return pre + "\n".join(fixedLines)