8f1a2cc5fd
Previously, tests were run against Anki launched by user. Now, * most tests run against isolated Anki in current process; * tests in `test_server.py` launch another Anki in a separate process and run a few commands to test the server; * nearly all tests were preserved in the sense that what was being tested is tested still. A few tests in `test_graphical.py` are skipped due to a problem with the method tests, see the comments; * tests can be run: * In a single profile, using --no-tear-down-profile-after-each-test; * In a single app instance, but with the profile being torn down after each test--default; * In separate processes, using --forked.
268 lines
8.1 KiB
Python
268 lines
8.1 KiB
Python
import concurrent.futures
|
|
import time
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
|
|
import aqt.operations.note
|
|
import pytest
|
|
from PyQt5 import QtTest
|
|
from _pytest.monkeypatch import MonkeyPatch
|
|
from pytest_anki._launch import anki_running # noqa
|
|
|
|
from plugin import AnkiConnect
|
|
from plugin.edit import Edit
|
|
from plugin.util import DEFAULT_CONFIG
|
|
|
|
|
|
ac = AnkiConnect()
|
|
|
|
|
|
# wait for n seconds, while events are being processed
|
|
def wait(seconds):
|
|
milliseconds = int(seconds * 1000)
|
|
QtTest.QTest.qWait(milliseconds) # noqa
|
|
|
|
|
|
def wait_until(booleanish_function, at_most_seconds=30):
|
|
deadline = time.time() + at_most_seconds
|
|
|
|
while time.time() < deadline:
|
|
if booleanish_function():
|
|
return
|
|
wait(0.01)
|
|
|
|
raise Exception(f"Function {booleanish_function} never once returned "
|
|
f"a positive value in {at_most_seconds} seconds")
|
|
|
|
|
|
def delete_model(model_name):
|
|
model = ac.collection().models.byName(model_name)
|
|
ac.collection().models.remove(model["id"])
|
|
|
|
|
|
def close_all_dialogs_and_wait_for_them_to_run_closing_callbacks():
|
|
aqt.dialogs.closeAll(onsuccess=lambda: None)
|
|
wait_until(aqt.dialogs.allClosed)
|
|
|
|
|
|
# largely analogous to `aqt.mw.pm.remove`.
|
|
# by default, the profile is moved to trash. this is a problem for us,
|
|
# as on some systems trash folders may not exist.
|
|
# we can't delete folder and *then* call `aqt.mw.pm.remove`,
|
|
# as it calls `profileFolder` and that *creates* the folder!
|
|
def remove_current_profile():
|
|
import os
|
|
import shutil
|
|
|
|
def send2trash(profile_folder):
|
|
assert profile_folder.endswith("User 1")
|
|
if os.path.exists(profile_folder):
|
|
shutil.rmtree(profile_folder)
|
|
|
|
with MonkeyPatch().context() as monkey:
|
|
monkey.setattr(aqt.profiles, "send2trash", send2trash)
|
|
aqt.mw.pm.remove(aqt.mw.pm.name)
|
|
|
|
|
|
@contextmanager
|
|
def empty_anki_session_started():
|
|
with anki_running(
|
|
qtbot=None, # noqa
|
|
enable_web_debugging=False,
|
|
) as session:
|
|
yield session
|
|
|
|
|
|
# backups are run in a thread and can lead to warnings when the thread dies
|
|
# after trying to open collection after it's been deleted
|
|
@contextmanager
|
|
def profile_loaded(session):
|
|
with session.profile_loaded():
|
|
aqt.mw.pm.profile["numBackups"] = 0
|
|
yield session
|
|
|
|
|
|
@contextmanager
|
|
def anki_connect_config_loaded(session, web_bind_port):
|
|
with session.addon_config_created(
|
|
package_name="plugin",
|
|
default_config=DEFAULT_CONFIG,
|
|
user_config={**DEFAULT_CONFIG, "webBindPort": web_bind_port}
|
|
):
|
|
yield
|
|
|
|
|
|
@contextmanager
|
|
def current_decks_and_models_etc_preserved():
|
|
deck_names_before = ac.deckNames()
|
|
model_names_before = ac.modelNames()
|
|
|
|
try:
|
|
yield
|
|
finally:
|
|
deck_names_after = ac.deckNames()
|
|
model_names_after = ac.modelNames()
|
|
|
|
deck_names_to_delete = {*deck_names_after} - {*deck_names_before}
|
|
model_names_to_delete = {*model_names_after} - {*model_names_before}
|
|
|
|
ac.deleteDecks(decks=deck_names_to_delete, cardsToo=True)
|
|
for model_name in model_names_to_delete:
|
|
delete_model(model_name)
|
|
|
|
ac.guiDeckBrowser()
|
|
|
|
|
|
@dataclass
|
|
class Setup:
|
|
deck_id: int
|
|
note1_id: int
|
|
note2_id: int
|
|
card_ids: "list[int]"
|
|
|
|
|
|
def set_up_test_deck_and_test_model_and_two_notes():
|
|
ac.createModel(
|
|
modelName="test_model",
|
|
inOrderFields=["field1", "field2"],
|
|
cardTemplates=[
|
|
{"Front": "{{field1}}", "Back": "{{field2}}"},
|
|
{"Front": "{{field2}}", "Back": "{{field1}}"}
|
|
],
|
|
css="* {}",
|
|
)
|
|
|
|
deck_id = ac.createDeck("test_deck")
|
|
|
|
note1_id = ac.addNote(dict(
|
|
deckName="test_deck",
|
|
modelName="test_model",
|
|
fields={"field1": "note1 field1", "field2": "note1 field2"},
|
|
tags={"tag1"},
|
|
))
|
|
|
|
note2_id = ac.addNote(dict(
|
|
deckName="test_deck",
|
|
modelName="test_model",
|
|
fields={"field1": "note2 field1", "field2": "note2 field2"},
|
|
tags={"tag2"},
|
|
))
|
|
|
|
card_ids = ac.findCards(query="deck:test_deck")
|
|
|
|
return Setup(
|
|
deck_id=deck_id,
|
|
note1_id=note1_id,
|
|
note2_id=note2_id,
|
|
card_ids=card_ids,
|
|
)
|
|
|
|
|
|
#############################################################################
|
|
|
|
|
|
def pytest_addoption(parser):
|
|
parser.addoption("--tear-down-profile-after-each-test",
|
|
action="store_true",
|
|
default=True)
|
|
parser.addoption("--no-tear-down-profile-after-each-test", "-T",
|
|
action="store_false",
|
|
dest="tear_down_profile_after_each_test")
|
|
|
|
|
|
def pytest_report_header(config):
|
|
if config.option.forked:
|
|
return "test isolation: perfect; each test is run in a separate process"
|
|
if config.option.tear_down_profile_after_each_test:
|
|
return "test isolation: good; user profile is torn down after each test"
|
|
else:
|
|
return "test isolation: poor; only newly created decks and models " \
|
|
"are cleaned up between tests"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def run_background_tasks_on_main_thread(request, monkeypatch): # noqa
|
|
"""
|
|
Makes background operations such as card deletion execute on main thread
|
|
and execute the callback immediately
|
|
"""
|
|
def run_in_background(task, on_done=None, kwargs=None):
|
|
future = concurrent.futures.Future()
|
|
|
|
try:
|
|
future.set_result(task(**kwargs if kwargs is not None else {}))
|
|
except BaseException as e:
|
|
future.set_exception(e)
|
|
|
|
if on_done is not None:
|
|
on_done(future)
|
|
|
|
monkeypatch.setattr(aqt.mw.taskman, "run_in_background",
|
|
run_in_background)
|
|
|
|
|
|
# don't use run_background_tasks_on_main_thread for tests that don't run Anki
|
|
def pytest_generate_tests(metafunc):
|
|
if (
|
|
run_background_tasks_on_main_thread.__name__ in metafunc.fixturenames
|
|
and session_scope_empty_session.__name__ not in metafunc.fixturenames
|
|
):
|
|
metafunc.fixturenames.remove(run_background_tasks_on_main_thread.__name__)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def session_scope_empty_session():
|
|
with empty_anki_session_started() as session:
|
|
yield session
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def session_scope_session_with_profile_loaded(session_scope_empty_session):
|
|
with profile_loaded(session_scope_empty_session):
|
|
yield session_scope_empty_session
|
|
|
|
|
|
@pytest.fixture
|
|
def session_with_profile_loaded(session_scope_empty_session, request):
|
|
"""
|
|
Like anki_session fixture from pytest-anki, but:
|
|
* Default profile is loaded
|
|
* It's relying on session-wide app instance so that
|
|
it can be used without forking every test;
|
|
this can be useful to speed up tests and also
|
|
to examine Anki's stdout/stderr, which is not visible with forking.
|
|
* If command line option --no-tear-down-profile-after-each-test is passed,
|
|
only the newly created decks and models are deleted.
|
|
Otherwise, the profile is completely torn down after each test.
|
|
Tearing down the profile is significantly slower.
|
|
"""
|
|
if request.config.option.tear_down_profile_after_each_test:
|
|
try:
|
|
with profile_loaded(session_scope_empty_session):
|
|
yield session_scope_empty_session
|
|
finally:
|
|
remove_current_profile()
|
|
else:
|
|
session = request.getfixturevalue(
|
|
session_scope_session_with_profile_loaded.__name__
|
|
)
|
|
with current_decks_and_models_etc_preserved():
|
|
yield session
|
|
|
|
|
|
@pytest.fixture
|
|
def setup(session_with_profile_loaded):
|
|
"""
|
|
Like session_with_profile_loaded, but also:
|
|
* Added are:
|
|
* A deck `test_deck`
|
|
* A model `test_model` with fields `filed1` and `field2`
|
|
and two cards per note
|
|
* Two notes with two valid cards each using the above deck and model
|
|
* Edit dialog is registered with dialog manager
|
|
* Any dialogs, if open, are safely closed on exit
|
|
"""
|
|
Edit.register_with_dialog_manager()
|
|
yield set_up_test_deck_and_test_model_and_two_notes()
|
|
close_all_dialogs_and_wait_for_them_to_run_closing_callbacks()
|