diff --git a/common/src/module/canvas.mjs b/common/src/module/canvas.mjs index 913442f..ba6d5ca 100644 --- a/common/src/module/canvas.mjs +++ b/common/src/module/canvas.mjs @@ -530,14 +530,14 @@ class CanvasAPI extends Module { if(this.#redrawCount === currentRedrawCount) callback(imgData) else - console.log("1Discard render of", imgData.source, this.#redrawCount, currentRedrawCount) + console.log("1. Discard render of", imgData.source, this.#redrawCount, currentRedrawCount) }) } else { // Callback directly if(this.#redrawCount === currentRedrawCount) callback(imgData) else - console.log("2Discard render of", imgData.source, this.#redrawCount, currentRedrawCount) + console.log("2. Discard render of", imgData.source, this.#redrawCount, currentRedrawCount) } } const prerendered = Latex.findPrerendered(ltxText, this.textsize, color) diff --git a/common/test/mock/latex.mjs b/common/test/mock/latex.mjs index ab56999..c0a029c 100644 --- a/common/test/mock/latex.mjs +++ b/common/test/mock/latex.mjs @@ -23,6 +23,10 @@ const PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVR4AWNgAAAAA export class MockLatex { constructor() { } + + get supportsAsyncRender() { + return true + } /** * Creates a simple string hash. @@ -55,13 +59,23 @@ export class MockLatex { return `${TMP}/${name}.png` } + /** + * @param {string} markup - LaTeX markup to render + * @param {number} fontSize - Font size (in pt) to render + * @param {string} color - Color of the text to render + * @returns {Promise} - Comma separated data of the image (source, width, height) + */ + async renderAsync(markup, fontSize, color) { + return this.renderSync(markup, fontSize, color) + } + /** * @param {string} markup - LaTeX markup to render * @param {number} fontSize - Font size (in pt) to render * @param {string} color - Color of the text to render * @returns {string} - Comma separated data of the image (source, width, height) */ - render(markup, fontSize, color) { + renderSync(markup, fontSize, color) { const file = this.__getFileName(markup, fontSize, color) writeFileSync(file, PIXEL, "base64") return `${file},1,1` @@ -87,4 +101,4 @@ export class MockLatex { checkLatexInstallation() { return true // We're not *actually* doing any latex. } -} \ No newline at end of file +} diff --git a/runtime-pyside6/LogarithmPlotter/util/js.py b/runtime-pyside6/LogarithmPlotter/util/js.py index 05f30d5..944c2b0 100644 --- a/runtime-pyside6/LogarithmPlotter/util/js.py +++ b/runtime-pyside6/LogarithmPlotter/util/js.py @@ -75,6 +75,7 @@ class PyJSValue: return value def type(self) -> any: + ret = None matcher = [ (lambda: self.qjs_value.isArray(), list), (lambda: self.qjs_value.isBool(), bool), @@ -93,8 +94,9 @@ class PyJSValue: ] for (test, value) in matcher: if test(): - return value - return None + ret = value + break + return ret def primitive(self): """ diff --git a/runtime-pyside6/LogarithmPlotter/util/promise.py b/runtime-pyside6/LogarithmPlotter/util/promise.py index e475b36..3c2d223 100644 --- a/runtime-pyside6/LogarithmPlotter/util/promise.py +++ b/runtime-pyside6/LogarithmPlotter/util/promise.py @@ -31,7 +31,7 @@ def check_callable(function: Callable|QJSValue) -> Callable|None: """ if isinstance(function, QJSValue) and function.isCallable(): return PyJSValue(function) - elif isinstance(function, Callable): + elif callable(function): return function return None @@ -51,9 +51,7 @@ class PyPromiseRunner(QRunnable): def run(self): try: data = self.runner(*self.args) - if isinstance(data, QObject): - data = data - elif type(data) in [int, str, float, bool, bytes]: + if type(data) in [int, str, float, bool]: data = QJSValue(data) elif data is None: data = QJSValue.SpecialValue.UndefinedValue @@ -62,7 +60,7 @@ class PyPromiseRunner(QRunnable): elif isinstance(data, PyJSValue): data = data.qjs_value else: - raise InvalidReturnValue("Must return either a primitive, a valid QObject, JS Value, or None.") + raise InvalidReturnValue("Must return either a primitive, a JS Value, or None.") self.promise.fulfilled.emit(data) except Exception as e: try: @@ -79,7 +77,7 @@ class PyPromise(QObject): Runs to_run in another thread, and calls fulfilled (populated by then) with its return value. """ fulfilled = Signal((QJSValue,), (QObject,)) - rejected = Signal(Exception) + rejected = Signal(str) def __init__(self, to_run: Callable|QJSValue, args=[], start_automatically=True): QObject.__init__(self) @@ -94,7 +92,7 @@ class PyPromise(QObject): raise ValueError("New PyPromise created with invalid function") self._runner = PyPromiseRunner(to_run, self, args) if start_automatically: - self._start() + self.start() @Slot() def start(self, *args, **kwargs): @@ -122,6 +120,35 @@ class PyPromise(QObject): if on_reject is not None: self._rejects.append(on_reject) return self + + def calls_upon_fulfillment(self, function: Callable | QJSValue) -> bool: + """ + Returns True if the given function will be callback upon the promise fulfillment. + False otherwise. + """ + return self._calls_in(function, self._fulfills) + + def calls_upon_rejection(self, function: Callable | QJSValue) -> bool: + """ + Returns True if the given function will be callback upon the promise rejection. + False otherwise. + """ + return self._calls_in(function, self._rejects) + + def _calls_in(self, function: Callable | QJSValue, within: list) -> bool: + """ + Returns True if the given function resides in the given within list, False otherwise. + Internal method of calls_upon_fulfill + """ + function = check_callable(function) + ret = False + if isinstance(function, PyJSValue): + found = next((f for f in within if f.qjs_value == function.qjs_value), None) + ret = found is not None + elif callable(function): + found = next((f for f in within if f == function), None) + ret = found is not None + return ret @Slot(QJSValue) @Slot(QObject) diff --git a/runtime-pyside6/tests/spy.py b/runtime-pyside6/tests/spy.py new file mode 100644 index 0000000..01f9aa0 --- /dev/null +++ b/runtime-pyside6/tests/spy.py @@ -0,0 +1,254 @@ +""" + * LogarithmPlotter - 2D plotter software to make BODE plots, sequences and distribution functions. + * Copyright (C) 2021-2024 Ad5001 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +""" +from typing import Callable, Self + +from PySide6.QtQml import QJSValue + +PRINT_PREFIX = (" " * 24) + +class SpyAssertionFailed(Exception): + def __init__(self, message, calls): + self.message = message + "\n" + if len(calls) > 0: + self.message += self.render_calls(calls) + else: + self.message += f"{PRINT_PREFIX}0 registered calls." + + def repr(self, data): + if isinstance(data, QJSValue): + variant = data.toVariant() + return f"QJSValue<{type(variant).__name__}>({repr(variant)})" + else: + return repr(data) + + def render_calls(self, calls): + lines = [f"{PRINT_PREFIX}{len(calls)} registered call(s):"] + for call in calls: + repr_args = [self.repr(arg) for arg in call[0]] + repr_kwargs =[f"{key}={self.repr(arg)}" for key, arg in call[1].items()] + lines.append(f" - {', '.join([*repr_args, *repr_kwargs])}") + return ("\n" + PRINT_PREFIX).join(lines) + + def __str__(self): + return self.message + +class Methods: + AT_LEAST_ONCE = "AT_LEAST_ONCE" + EXACTLY = "EXACTLY" + AT_LEAST = "AT_LEAST" + AT_MOST = "AT_MOST" + MORE_THAN = "AT_LEAST" + LESS_THAN = "AT_MOST" + +class CalledInterface: + """ + Internal class generated by Spy.was_called. + """ + + def __init__(self, calls: list[tuple[list, dict]]): + self.__calls = calls + self.__method = Methods.AT_LEAST_ONCE + self.__times = None + + def __apply_method(self, calls): + required = self.__times + calls_count = len(calls) + match self.__method: + case Methods.AT_LEAST_ONCE: + compare = len(calls) >= 1 + error = "Method was not called" + case Methods.EXACTLY: + compare = len(calls) == required + error = f"Method was not called {required} times ({required} != {calls_count})" + case Methods.AT_LEAST: + compare = len(calls) >= required + error = f"Method was not called at least {required} times ({required} > {calls_count})" + case Methods.AT_MOST: + compare = len(calls) <= required + error = f"Method was not called at most {required} times ({required} < {calls_count})" + case Methods.MORE_THAN: + compare = len(calls) > required + error = f"Method was not called more than {required} times ({required} >= {calls_count})" + case Methods.LESS_THAN: + compare = len(calls) < required + error = f"Method was not called less than {required} times ({required} <= {calls_count})" + case _: + raise RuntimeError(f"Unknown method {self.__method}.") + return compare, error + + def __bool__(self): + """ + Converts to boolean on assertion. + """ + compare, error = self.__apply_method(self.__calls) + if not compare: + raise SpyAssertionFailed(error+".") + return compare + + + """ + Chaining methods + """ + def __call__(self, *args, **kwargs) -> Self: + if len(args) != 1: + raise RuntimeError("Cannot call called interface with more than one argument.") + self.__times = int(args[0]) + if self.__method == Methods.AT_LEAST_ONCE: + self.__method = Methods.EXACTLY + return self + + @property + def never(self) -> Self: + return self(0) + + @property + def once(self) -> Self: + return self(1) + + @property + def twice(self) -> Self: + return self(2) + + @property + def thrice(self) -> Self: + return self(3) + + @property + def at_least(self) -> Self: + self.__method = Methods.AT_LEAST + return self + + @property + def at_most(self) -> Self: + self.__method = Methods.AT_MOST + return self + + @property + def more_than(self) -> Self: + self.__method = Methods.MORE_THAN + return self + + @property + def less_than(self) -> Self: + self.__method = Methods.LESS_THAN + return self + + @property + def times(self) -> Self: + return self + + """ + Class properties. + """ + def __match_calls_for_condition(self, condition: Callable[[list, dict], bool]) -> tuple[bool, str]: + calls = [] + for call in self.__calls: + if condition(call[0], call[1]): + calls.append(call) + compare, error = self.__apply_method(calls) + return compare, error + + + def with_arguments(self, *args, **kwargs): + """ + Checks if the Spy has been called the given number of times + with at least the given arguments. + """ + def some_args_matched(a, kw): + args_matched = all(( + arg in a + for arg in args + )) + kwargs_matched = all(( + key in kw and kw[key] == arg + for key, arg in kwargs.items() + )) + return args_matched and kwargs_matched + compare, error = self.__match_calls_for_condition(some_args_matched) + if not compare: + repr_args = ', '.join([repr(arg) for arg in args]) + repr_kwargs = ', '.join([f"{key}={repr(arg)}" for key, arg in kwargs.items()]) + raise SpyAssertionFailed(f"{error} with arguments ({repr_args}) and keyword arguments ({repr_kwargs}).", self.__calls) + return compare + + + def with_arguments_matching(self, test_condition: Callable[[list, dict], bool]): + """ + Checks if the Spy has been called the given number of times + with arguments matching the given conditions. + """ + compare, error = self.__match_calls_for_condition(test_condition) + if not compare: + raise SpyAssertionFailed(f"{error} with arguments matching given conditions.", self.__calls) + return compare + + + def with_exact_arguments(self, *args, **kwargs): + """ + Checks if the Spy has been called the given number of times + with all the given arguments. + """ + compare, error = self.__match_calls_for_condition(lambda a, kw: a == args and kw == kwargs) + if not compare: + repr_args = ', '.join([repr(arg) for arg in args]) + repr_kwargs = ', '.join([f"{key}={repr(arg)}" for key, arg in kwargs.items()]) + raise SpyAssertionFailed(f"{error} with exact arguments ({repr_args}) and keyword arguments ({repr_kwargs}).", self.__calls) + return compare + + def with_no_argument(self): + """ + Checks if the Spy has been called the given number of times + with all the given arguments. + """ + compare, error = self.__match_calls_for_condition(lambda a, kw: len(a) == 0 and len(kw) == 0) + if not compare: + raise SpyAssertionFailed(f"{error} with no arguments.", self.__calls) + return compare + + + +class Spy: + """ + Class to spy into method calls with natural language expressions. + """ + + def __init__(self, function: Callable = None): + self.function = function + self.calls = [] + + def __call__(self, *args, **kwargs): + self.calls.append((args, kwargs)) + if self.function is not None: + self.function(*args, **kwargs) + + @property + def was_called(self) -> CalledInterface: + """ + Returns a boolean-able interface to check conditions for a given number of + time the spy was called. + """ + return CalledInterface(self.calls) + + @property + def was_not_called(self) -> CalledInterface: + """ + Returns a boolean-able interface to check that conditions were never fulfilled + in the times the spy was called. + """ + ret = CalledInterface(self.calls) + return ret(0) diff --git a/runtime-pyside6/tests/test_config.py b/runtime-pyside6/tests/test_config.py index c919c6f..cef3673 100644 --- a/runtime-pyside6/tests/test_config.py +++ b/runtime-pyside6/tests/test_config.py @@ -1,3 +1,21 @@ +""" + * LogarithmPlotter - 2D plotter software to make BODE plots, sequences and distribution functions. + * Copyright (C) 2021-2024 Ad5001 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +""" + import pytest from LogarithmPlotter.util import config from tempfile import TemporaryDirectory diff --git a/runtime-pyside6/tests/test_helper.py b/runtime-pyside6/tests/test_helper.py index 5cfb239..6bc1f0d 100644 --- a/runtime-pyside6/tests/test_helper.py +++ b/runtime-pyside6/tests/test_helper.py @@ -30,7 +30,7 @@ from PySide6.QtWidgets import QApplication from LogarithmPlotter import __VERSION__ as version from LogarithmPlotter.util import config, helper -from LogarithmPlotter.util.helper import Helper, InvalidFileException +from LogarithmPlotter.util.helper import Helper, fetch_changelog, read_changelog, InvalidFileException pwd = getcwd() helper.SHOW_GUI_MESSAGES = False @@ -60,19 +60,23 @@ CHANGELOG_BASE_PATH = path.realpath(path.join(path.dirname(path.realpath(__file_ class TestHelper: def test_changelog(self, temporary, qtbot): + # Exists helper.CHANGELOG_VERSION = '0.5.0' tmpfile, directory = temporary obj = Helper(pwd, tmpfile) promise = obj.fetchChangelog() create_changelog_callback_asserter(promise, expect_404=False) - qtbot.waitSignal(promise.fulfilled, timeout=10000) - # No exist + with qtbot.waitSignal(promise.fulfilled, timeout=10000): + pass + assert type(fetch_changelog()) == str + # Does not exist helper.CHANGELOG_VERSION = '2.0.0' tmpfile, directory = temporary obj = Helper(pwd, tmpfile) promise = obj.fetchChangelog() create_changelog_callback_asserter(promise, expect_404=True) - qtbot.waitSignal(promise.fulfilled, timeout=10000) + with qtbot.waitSignal(promise.fulfilled, timeout=10000): + pass # Local tmpfile, directory = temporary obj = Helper(pwd, tmpfile) @@ -81,7 +85,9 @@ class TestHelper: assert path.exists(helper.CHANGELOG_CACHE_PATH) promise = obj.fetchChangelog() create_changelog_callback_asserter(promise, expect_404=False) - qtbot.waitSignal(promise.fulfilled, timeout=10000) # Local + with qtbot.waitSignal(promise.fulfilled, timeout=100): # Local + pass + assert type(read_changelog()) == str def test_read(self, temporary): # Test file reading and information loading. diff --git a/runtime-pyside6/tests/test_latex.py b/runtime-pyside6/tests/test_latex.py index 6fd5c97..124186b 100644 --- a/runtime-pyside6/tests/test_latex.py +++ b/runtime-pyside6/tests/test_latex.py @@ -22,6 +22,7 @@ from shutil import which from os.path import exists from re import match from PySide6.QtGui import QColor +from PySide6.QtQml import QJSValue from LogarithmPlotter.util import latex @@ -38,10 +39,24 @@ def latex_obj(): directory.cleanup() +BLACK = QColor(0, 0, 0, 255) +BLUE = QColor(128, 128, 255, 255) + +def check_render_results(result): + if isinstance(result, QJSValue): + result = result.toVariant() + assert type(result) == str + [path, width, height] = result.split(",") + assert exists(path) + assert match(r"\d+", width) + assert match(r"\d+", height) + return True + class TestLatex: def test_check_install(self, latex_obj: latex.Latex) -> None: assert latex_obj.latexSupported == True assert latex_obj.checkLatexInstallation() == True + assert type(latex_obj.supportsAsyncRender) is bool bkp = [latex.DVIPNG_PATH, latex.LATEX_PATH] # Check what happens when one is missing. latex.DVIPNG_PATH = None @@ -55,25 +70,25 @@ class TestLatex: [latex.DVIPNG_PATH, latex.LATEX_PATH] = bkp def test_render_sync(self, latex_obj: latex.Latex) -> None: - result = latex_obj.renderSync(r"\frac{d\sqrt{\mathrm{f}(x \times 2.3)}}{dx}", 14, QColor(0, 0, 0, 255)) + result = latex_obj.renderSync("\\frac{d \\sqrt{\\mathrm{f}(x \\times 2.3)}}{dx}", 14, BLACK) # Ensure result format - assert type(result) == str - [path, width, height] = result.split(",") - assert exists(path) - assert match(r"\d+", width) - assert match(r"\d+", height) + check_render_results(result) # Ensure it returns errors on invalid latex. with pytest.raises(latex.RenderError): - latex_obj.renderSync(r"\nonexistant", 14, QColor(0, 0, 0, 255)) + latex_obj.renderSync("\\nonexistant", 14, BLACK) # Replace latex bin with one that returns errors bkp = latex.LATEX_PATH latex.LATEX_PATH = which("false") with pytest.raises(latex.RenderError): - latex_obj.renderSync(r"\mathrm{f}(x)", 14, QColor(0, 0, 0, 255)) + latex_obj.renderSync("\\mathrm{f}(x)", 14, BLACK) + # Replace latex bin with one goes indefinitely + latex.LATEX_PATH = which("import") + with pytest.raises(latex.RenderError): + latex_obj.renderSync("\\mathrm{f}(x)", 14, BLACK) latex.LATEX_PATH = bkp def test_prerendered(self, latex_obj: latex.Latex) -> None: - args = [r"\frac{d\sqrt{\mathrm{f}(x \times 2.3)}}{dx}", 14, QColor(0, 0, 0, 255)] + args = ["\\frac{d \\sqrt{\\mathrm{f}(x \\times 2.3)}}{dx}", 14, BLACK] latex_obj.renderSync(*args) prerendered = latex_obj.findPrerendered(*args) assert type(prerendered) == str @@ -84,3 +99,27 @@ class TestLatex: prerendered2 = latex_obj.findPrerendered(args[0], args[1]+2, args[2]) assert prerendered2 == "" + def test_render_async(self, latex_obj: latex.Latex, qtbot) -> None: + formula = "\\int\\limits^{3x}_{-\\infty}9\\mathrm{f}(x)^3+t dx" + og_promise = latex_obj.renderAsync(formula, 14, BLACK) + # Ensure we get the same locked one if we try to render it again. + assert og_promise == latex_obj.renderAsync(formula, 14, BLACK) + # Ensure queued renders. + promises = [ + latex_obj.renderAsync(formula, 14, BLUE), + latex_obj.renderAsync(formula, 10, BLACK), + latex_obj.renderAsync(formula, 10, BLUE), + ] + for prom in promises: + assert og_promise.calls_upon_fulfillment(prom.start) + # Ensure other renders get done in parallel. + other_promise = latex_obj.renderAsync(formula+" dt", 10, BLACK) + assert not og_promise.calls_upon_fulfillment(other_promise.start) + # Ensure all of them render. + proms = [og_promise, *promises, other_promise] + with qtbot.waitSignals( + [p.fulfilled for p in proms], + raising=True, timeout=10000, + check_params_cbs=[check_render_results]*len(proms) + ): + pass diff --git a/runtime-pyside6/tests/test_native.py b/runtime-pyside6/tests/test_native.py index 6fc2d65..3ca4834 100644 --- a/runtime-pyside6/tests/test_native.py +++ b/runtime-pyside6/tests/test_native.py @@ -17,7 +17,6 @@ """ import pytest -from os.path import exists from PySide6.QtCore import QEvent, QObject, QUrl from PySide6.QtGui import QActionEvent, QFileOpenEvent diff --git a/runtime-pyside6/tests/test_promise.py b/runtime-pyside6/tests/test_promise.py new file mode 100644 index 0000000..06c0b8a --- /dev/null +++ b/runtime-pyside6/tests/test_promise.py @@ -0,0 +1,115 @@ +""" + * LogarithmPlotter - 2D plotter software to make BODE plots, sequences and distribution functions. + * Copyright (C) 2021-2024 Ad5001 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +""" +from time import sleep + +import pytest +from PySide6.QtCore import QObject +from PySide6.QtQml import QJSValue + +from spy import Spy +from LogarithmPlotter.util.js import PyJSValue +from LogarithmPlotter.util.promise import PyPromise + + +def check_promise_result(value): + def got_result(args, kwargs, val=value): + valid = len(args) == 1 and len(kwargs) == 0 + if valid: + got_value = args[0].toVariant() if isinstance(args[0], QJSValue) else args[0] + valid = got_value == val + return valid + return got_result + +def create_async_func(value): + def async_function(val=value): + sleep(1) + return val + + return async_function + + +def qjs_eq(origin): + def compare(result): + res = result.toVariant() == origin + print("Unknown res!", res, repr(result.toVariant()), repr(origin)) + return res + return compare + + +def async_throw(): + sleep(1) + raise Exception("aaaa") + + +class TestPyPromise: + + def test_fulfill_values(self, qtbot): + qjsv = QJSValue(3) + values = [ + [True, qjs_eq(True)], + [3, qjs_eq(3)], + [2.2, qjs_eq(2.2)], + ["String", qjs_eq("String")], + [qjsv, qjs_eq(3)], + [None, qjs_eq(None)], + [PyJSValue(QJSValue("aaa")), qjs_eq("aaa")] + ] + for [value, test] in values: + promise = PyPromise(create_async_func(value)) + with qtbot.assertNotEmitted(promise.rejected, wait=1000): + print("Testing", value) + with qtbot.waitSignal(promise.fulfilled, check_params_cb=test, timeout=2000): + assert promise.state == "pending" + assert promise.state == "fulfilled" + + def test_reject(self, qtbot): + promise = PyPromise(async_throw) + with qtbot.assertNotEmitted(promise.fulfilled, wait=1000): + with qtbot.waitSignal(promise.rejected, timeout=10000, + check_params_cb=lambda t: t == "Exception('aaaa')"): + assert promise.state == "pending" + assert promise.state == "rejected" + + def test_fulfill(self, qtbot): + spy_fulfilled = Spy() + spy_rejected = Spy() + promise = PyPromise(create_async_func(3)) + then_res = promise.then(spy_fulfilled, spy_rejected) + # Check if the return value is the same promise (so we can chain then) + assert then_res == promise + # Check on our spy. + with qtbot.waitSignal(promise.fulfilled, timeout=10000): + pass + assert spy_fulfilled.was_called.once + assert spy_fulfilled.was_not_called.with_arguments(3) + assert spy_fulfilled.was_called.with_arguments_matching(check_promise_result(3)) + assert spy_rejected.was_not_called + + def test_rejected(self, qtbot): + spy_fulfilled = Spy() + spy_rejected = Spy() + promise = PyPromise(async_throw) + then_res = promise.then(spy_fulfilled, spy_rejected) + # Check if the return value is the same promise (so we can chain then) + assert then_res == promise + # Check on our spies. + with qtbot.waitSignal(promise.rejected, timeout=10000): + pass + assert spy_rejected.was_called.once + assert spy_rejected.was_called.with_arguments("Exception('aaaa')") + assert spy_fulfilled.was_not_called diff --git a/runtime-pyside6/tests/test_update.py b/runtime-pyside6/tests/test_update.py index 5e1a705..ff71e61 100644 --- a/runtime-pyside6/tests/test_update.py +++ b/runtime-pyside6/tests/test_update.py @@ -49,19 +49,20 @@ def test_update(qtbot): update_info_newer = UpdateInformation() update_info_newer.got_update_info.connect(check_newer) runnable = UpdateCheckerRunnable('1.0.0', update_info_newer) - runnable.run() - qtbot.waitSignal(update_info_newer.got_update_info, timeout=10000) + with qtbot.waitSignal(update_info_newer.got_update_info, timeout=10000): + runnable.run() runnable = UpdateCheckerRunnable('0.1.0', update_info_older) - runnable.run() - qtbot.waitSignal(update_info_older.got_update_info, timeout=10000) + with qtbot.waitSignal(update_info_older.got_update_info, timeout=10000): + runnable.run() runnable = UpdateCheckerRunnable('0.5.0+dev0+git20240101', update_info_older) - runnable.run() - qtbot.waitSignal(update_info_older.got_update_info, timeout=10000) + with qtbot.waitSignal(update_info_older.got_update_info, timeout=10000): + runnable.run() def test_update_checker(qtbot): update_info = check_for_updates('0.6.0', MockWindow()) assert QThreadPool.globalInstance().activeThreadCount() >= 1 - qtbot.waitSignal(update_info.got_update_info, timeout=10000) + with qtbot.waitSignal(update_info.got_update_info, timeout=10000): + pass argv.append("--no-check-for-updates") update_info = check_for_updates('0.6.0', MockWindow()) assert QThreadPool.globalInstance().activeThreadCount() < 2 # No new update checks where added diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 1410244..ac1fdc5 100644 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -23,6 +23,7 @@ fi # Run python tests +rm -rf build/runtime-pyside6/tests cp -r runtime-pyside6/tests build/runtime-pyside6 cp -r ci CHANGELOG.md build/runtime-pyside6 cd build/runtime-pyside6 || exit 1