From 8f1a2cc5fd0ed048a581a423950b3fe076163794 Mon Sep 17 00:00:00 2001 From: oakkitten Date: Wed, 30 Mar 2022 20:16:39 +0100 Subject: [PATCH] Convert all tests to pytest 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. --- tests/conftest.py | 267 ++++++++++++++++++++++++++++++++++++++++ tests/test_cards.py | 131 +++++++++----------- tests/test_debug.py | 23 ---- tests/test_decks.py | 161 ++++++++++-------------- tests/test_graphical.py | 185 +++++++++++++++++++--------- tests/test_media.py | 74 ++++++----- tests/test_misc.py | 108 +++++++--------- tests/test_models.py | 144 ++++++++++++++-------- tests/test_notes.py | 265 +++++++++++++++++++-------------------- tests/test_server.py | 149 ++++++++++++++++++++++ tests/test_stats.py | 78 ++++-------- tests/util.py | 21 ---- 12 files changed, 1010 insertions(+), 596 deletions(-) create mode 100644 tests/conftest.py delete mode 100755 tests/test_debug.py create mode 100644 tests/test_server.py delete mode 100644 tests/util.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a025004 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,267 @@ +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() diff --git a/tests/test_cards.py b/tests/test_cards.py index 80e82d2..57705d4 100755 --- a/tests/test_cards.py +++ b/tests/test_cards.py @@ -1,95 +1,78 @@ -#!/usr/bin/env python +import pytest +from anki.errors import NotFoundError # noqa -import unittest -import util +from conftest import ac -class TestCards(unittest.TestCase): - def setUp(self): - util.invoke('createDeck', deck='test') - note = { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': {'Front': 'front1', 'Back': 'back1'}, - 'tags': ['tag1'], - 'options': { - 'allowDuplicate': True - } - } - self.noteId = util.invoke('addNote', note=note) +def test_findCards(setup): + card_ids = ac.findCards(query="deck:test_deck") + assert len(card_ids) == 4 - def tearDown(self): - util.invoke('deleteDecks', decks=['test'], cardsToo=True) +class TestEaseFactors: + def test_setEaseFactors(self, setup): + result = ac.setEaseFactors(cards=setup.card_ids, easeFactors=[4200] * 4) + assert result == [True] * 4 + + def test_setEaseFactors_with_invalid_card_id(self, setup): + result = ac.setEaseFactors(cards=[123], easeFactors=[4200]) + assert result == [False] + + def test_getEaseFactors(self, setup): + ac.setEaseFactors(cards=setup.card_ids, easeFactors=[4200] * 4) + result = ac.getEaseFactors(cards=setup.card_ids) + assert result == [4200] * 4 + + def test_getEaseFactors_with_invalid_card_id(self, setup): + assert ac.getEaseFactors(cards=[123]) == [None] - def runTest(self): - incorrectId = 1234 +class TestSuspending: + def test_suspend(self, setup): + assert ac.suspend(cards=setup.card_ids) is True - # findCards - cardIds = util.invoke('findCards', query='deck:test') - self.assertEqual(len(cardIds), 1) + def test_suspend_fails_with_incorrect_id(self, setup): + with pytest.raises(NotFoundError): + assert ac.suspend(cards=[123]) - # setEaseFactors - EASE_TO_TRY = 4200 - easeFactors = [EASE_TO_TRY for card in cardIds] - couldGetEaseFactors = util.invoke('setEaseFactors', cards=cardIds, easeFactors=easeFactors) - self.assertEqual([True for card in cardIds], couldGetEaseFactors) - couldGetEaseFactors = util.invoke('setEaseFactors', cards=[incorrectId], easeFactors=[EASE_TO_TRY]) - self.assertEqual([False], couldGetEaseFactors) + def test_areSuspended_returns_False_for_regular_cards(self, setup): + result = ac.areSuspended(cards=setup.card_ids) + assert result == [False] * 4 - # getEaseFactors - easeFactorsFound = util.invoke('getEaseFactors', cards=cardIds) - self.assertEqual(easeFactors, easeFactorsFound) - easeFactorsFound = util.invoke('getEaseFactors', cards=[incorrectId]) - self.assertEqual([None], easeFactorsFound) + def test_areSuspended_returns_True_for_suspended_cards(self, setup): + ac.suspend(setup.card_ids) + result = ac.areSuspended(cards=setup.card_ids) + assert result == [True] * 4 - # suspend - util.invoke('suspend', cards=cardIds) - self.assertRaises(Exception, lambda: util.invoke('suspend', cards=[incorrectId])) - # areSuspended (part 1) - suspendedStates = util.invoke('areSuspended', cards=cardIds) - self.assertEqual(len(cardIds), len(suspendedStates)) - self.assertNotIn(False, suspendedStates) - self.assertEqual([None], util.invoke('areSuspended', cards=[incorrectId])) +def test_areDue_returns_True_for_new_cards(setup): + result = ac.areDue(cards=setup.card_ids) + assert result == [True] * 4 - # unsuspend - util.invoke('unsuspend', cards=cardIds) - # areSuspended (part 2) - suspendedStates = util.invoke('areSuspended', cards=cardIds) - self.assertEqual(len(cardIds), len(suspendedStates)) - self.assertNotIn(True, suspendedStates) +def test_getIntervals(setup): + ac.getIntervals(cards=setup.card_ids, complete=False) + ac.getIntervals(cards=setup.card_ids, complete=True) - # areDue - dueStates = util.invoke('areDue', cards=cardIds) - self.assertEqual(len(cardIds), len(dueStates)) - self.assertNotIn(False, dueStates) - # getIntervals - util.invoke('getIntervals', cards=cardIds, complete=True) - util.invoke('getIntervals', cards=cardIds, complete=False) +def test_cardsToNotes(setup): + result = ac.cardsToNotes(cards=setup.card_ids) + assert {*result} == {setup.note1_id, setup.note2_id} - # cardsToNotes - noteIds = util.invoke('cardsToNotes', cards=cardIds) - self.assertEqual(len(noteIds), len(cardIds)) - self.assertIn(self.noteId, noteIds) - # cardsInfo - cardsInfo = util.invoke('cardsInfo', cards=cardIds) - self.assertEqual(len(cardsInfo), len(cardIds)) - for i, cardInfo in enumerate(cardsInfo): - self.assertEqual(cardInfo['cardId'], cardIds[i]) - cardsInfo = util.invoke('cardsInfo', cards=[incorrectId]) - self.assertEqual(len(cardsInfo), 1) - self.assertDictEqual(cardsInfo[0], dict()) +class TestCardInfo: + def test_with_valid_ids(self, setup): + result = ac.cardsInfo(cards=setup.card_ids) + assert [item["cardId"] for item in result] == setup.card_ids - # forgetCards - util.invoke('forgetCards', cards=cardIds) + def test_with_incorrect_id(self, setup): + result = ac.cardsInfo(cards=[123]) + assert result == [{}] - # relearnCards - util.invoke('relearnCards', cards=cardIds) -if __name__ == '__main__': - unittest.main() +def test_forgetCards(setup): + ac.forgetCards(cards=setup.card_ids) + + +def test_relearnCards(setup): + ac.relearnCards(cards=setup.card_ids) diff --git a/tests/test_debug.py b/tests/test_debug.py deleted file mode 100755 index ad1ef02..0000000 --- a/tests/test_debug.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python - -import unittest -import util - - -class TestNotes(unittest.TestCase): - def setUp(self): - util.invoke('createDeck', deck='test') - - - def tearDown(self): - util.invoke('deleteDecks', decks=['test'], cardsToo=True) - - - def test_bug164(self): - note = {'deckName': 'test', 'modelName': 'Basic', 'fields': {'Front': ' Whitespace\n', 'Back': ''}, 'options': { 'allowDuplicate': False, 'duplicateScope': 'deck'}} - util.invoke('addNote', note=note) - self.assertRaises(Exception, lambda: util.invoke('addNote', note=note)) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_decks.py b/tests/test_decks.py index d451217..04e6107 100755 --- a/tests/test_decks.py +++ b/tests/test_decks.py @@ -1,98 +1,73 @@ -#!/usr/bin/env python +import pytest -import unittest -import util +from conftest import ac -class TestDecks(unittest.TestCase): - def runTest(self): - # deckNames (part 1) - deckNames = util.invoke('deckNames') - self.assertIn('Default', deckNames) - - # deckNamesAndIds - result = util.invoke('deckNamesAndIds') - self.assertIn('Default', result) - self.assertEqual(result['Default'], 1) - - # createDeck - util.invoke('createDeck', deck='test1') - - # deckNames (part 2) - deckNames = util.invoke('deckNames') - self.assertIn('test1', deckNames) - - # changeDeck - note = {'deckName': 'test1', 'modelName': 'Basic', 'fields': {'Front': 'front', 'Back': 'back'}, 'tags': ['tag']} - noteId = util.invoke('addNote', note=note) - cardIds = util.invoke('findCards', query='deck:test1') - util.invoke('changeDeck', cards=cardIds, deck='test2') - - # deckNames (part 3) - deckNames = util.invoke('deckNames') - self.assertIn('test2', deckNames) - - # deleteDecks - util.invoke('deleteDecks', decks=['test1', 'test2'], cardsToo=True) - - # deckNames (part 4) - deckNames = util.invoke('deckNames') - self.assertNotIn('test1', deckNames) - self.assertNotIn('test2', deckNames) - - # getDeckConfig - deckConfig = util.invoke('getDeckConfig', deck='Default') - self.assertEqual('Default', deckConfig['name']) - - # saveDeckConfig - deckConfig = util.invoke('saveDeckConfig', config=deckConfig) - - # setDeckConfigId - setDeckConfigId = util.invoke('setDeckConfigId', decks=['Default'], configId=1) - self.assertTrue(setDeckConfigId) - - # cloneDeckConfigId (part 1) - deckConfigId = util.invoke('cloneDeckConfigId', cloneFrom=1, name='test') - self.assertTrue(deckConfigId) - - # removeDeckConfigId (part 1) - removedDeckConfigId = util.invoke('removeDeckConfigId', configId=deckConfigId) - self.assertTrue(removedDeckConfigId) - - # removeDeckConfigId (part 2) - removedDeckConfigId = util.invoke('removeDeckConfigId', configId=deckConfigId) - self.assertFalse(removedDeckConfigId) - - # cloneDeckConfigId (part 2) - deckConfigId = util.invoke('cloneDeckConfigId', cloneFrom=deckConfigId, name='test') - self.assertFalse(deckConfigId) - - # updateCompleteDeck - util.invoke('updateCompleteDeck', data={ - 'deck': 'test3', - 'cards': { - '12': { - 'id': 12, 'nid': 23, 'ord': 0, 'type': 0, 'queue': 0, - 'due': 1186031, 'factor': 0, 'ivl': 0, 'reps': 0, 'lapses': 0, 'left': 0 - } - }, - 'notes': { - '23': { - 'id': 23, 'mid': 34, 'fields': ['frontValue', 'backValue'], 'tags': ['aTag'] - } - }, - 'models': { - '34': { - 'id': 34, 'fields': ['Front', 'Back'], 'templateNames': ['Card 1'], 'name': 'anotherModel', - } - } - }) - deckNames = util.invoke('deckNames') - self.assertIn('test3', deckNames) - cardIDs = util.invoke('findCards', query='deck:test3') - self.assertEqual(len(cardIDs), 1) - self.assertEqual(cardIDs[0], 12) +def test_deckNames(session_with_profile_loaded): + result = ac.deckNames() + assert result == ["Default"] -if __name__ == '__main__': - unittest.main() +def test_deckNamesAndIds(session_with_profile_loaded): + result = ac.deckNamesAndIds() + assert result == {"Default": 1} + + +def test_createDeck(session_with_profile_loaded): + ac.createDeck("foo") + assert {*ac.deckNames()} == {"Default", "foo"} + + +def test_changeDeck(setup): + ac.changeDeck(cards=setup.card_ids, deck="bar") + assert "bar" in ac.deckNames() + + +def test_deleteDeck(setup): + before = ac.deckNames() + ac.deleteDecks(decks=["test_deck"], cardsToo=True) + after = ac.deckNames() + assert {*before} - {*after} == {"test_deck"} + + +@pytest.mark.skipif( + condition=ac._anki21_version < 28, + reason=f"Not applicable to Anki < 2.1.28" +) +def test_deleteDeck_must_be_called_with_cardsToo_set_to_True_on_later_api(setup): + with pytest.raises(Exception): + ac.deleteDecks(decks=["test_deck"]) + with pytest.raises(Exception): + ac.deleteDecks(decks=["test_deck"], cardsToo=False) + + +def test_getDeckConfig(session_with_profile_loaded): + result = ac.getDeckConfig(deck="Default") + assert result["name"] == "Default" + + +def test_saveDeckConfig(session_with_profile_loaded): + config = ac.getDeckConfig(deck="Default") + result = ac.saveDeckConfig(config=config) + assert result is True + + +def test_setDeckConfigId(session_with_profile_loaded): + result = ac.setDeckConfigId(decks=["Default"], configId=1) + assert result is True + + +def test_cloneDeckConfigId(session_with_profile_loaded): + result = ac.cloneDeckConfigId(cloneFrom=1, name="test") + assert isinstance(result, int) + + +def test_removedDeckConfigId(session_with_profile_loaded): + new_config_id = ac.cloneDeckConfigId(cloneFrom=1, name="test") + assert ac.removeDeckConfigId(configId=new_config_id) is True + + +def test_removedDeckConfigId_fails_with_invalid_id(session_with_profile_loaded): + new_config_id = ac.cloneDeckConfigId(cloneFrom=1, name="test") + assert ac.removeDeckConfigId(configId=new_config_id) is True + assert ac.removeDeckConfigId(configId=new_config_id) is False diff --git a/tests/test_graphical.py b/tests/test_graphical.py index d42f2ea..3eae40d 100755 --- a/tests/test_graphical.py +++ b/tests/test_graphical.py @@ -1,76 +1,151 @@ -#!/usr/bin/env python +import aqt +import pytest -import unittest -import util +from conftest import ac, wait, wait_until, \ + close_all_dialogs_and_wait_for_them_to_run_closing_callbacks -class TestGui(unittest.TestCase): - def runTest(self): - # guiBrowse - util.invoke('guiBrowse', query='deck:Default') +def test_guiBrowse(setup): + ac.guiBrowse() - # guiSelectedNotes - util.invoke('guiSelectedNotes') - # guiAddCards - util.invoke('guiAddCards') +def test_guiDeckBrowser(setup): + ac.guiDeckBrowser() - # guiAddCards with preset - util.invoke('createDeck', deck='test') - note = { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': { - 'Front': 'front1', - 'Back': 'back1' - }, - 'tags': ['tag1'], +# todo executing this test without running background tasks on main thread +# rarely causes media server (`aqt.mediasrv`) to fail: +# its `run` method raises OSError: invalid file descriptor. +# this can cause other tests to fail to tear down; +# particularly, any dialogs with editor may fail to close +# due to their trying to save the note first, which is done via web view, +# which fails to complete due to corrupt media server. investigate? +def test_guiCheckDatabase(setup, run_background_tasks_on_main_thread): + ac.guiCheckDatabase() + + +def test_guiDeckOverview(setup): + assert ac.guiDeckOverview(name="test_deck") is True + + +class TestAddCards: + note = { + "deckName": "test_deck", + "modelName": "Basic", + "fields": {"Front": "new front1", "Back": "new back1"}, + "tags": ["tag1"] + } + + options_closeAfterAdding = { + "options": { + "closeAfterAdding": True } - util.invoke('guiAddCards', note=note) + } - # guiAddCards with preset and closeAfterAdding - util.invoke('guiAddCards', note={ - **note, - 'options': { 'closeAfterAdding': True }, - }) + # an actual small image, you can see it if you run the test with GUI + # noinspection SpellCheckingInspection + base64_gif = "R0lGODlhBQAEAHAAACwAAAAABQAEAIH///8AAAAAAAAAAAACB0QMqZcXDwoAOw==" - util.invoke('guiAddCards', note={ - **note, - 'picture': [{ - 'url': 'https://via.placeholder.com/150.png', - 'filename': 'placeholder.png', - 'fields': ['Front'], - }] - }) + picture = { + "picture": [ + { + "data": base64_gif, + "filename": "smiley.gif", + "fields": ["Front"], + } + ] + } - # guiCurrentCard - # util.invoke('guiCurrentCard') + @staticmethod + def click_on_add_card_dialog_save_button(dialog_name="AddCards"): + dialog = aqt.dialogs._dialogs[dialog_name][1] + dialog.addButton.click() - # guiStartCardTimer - util.invoke('guiStartCardTimer') + # todo previously, these tests were verifying + # that the return value of `guiAddCards` is `int`. + # while it is indeed `int`, on modern Anki it is also always a `0`, + # so we consider it useless. update documentation? + def test_without_note(self, setup): + ac.guiAddCards() - # guiShowQuestion - util.invoke('guiShowQuestion') + def test_with_note(self, setup): + ac.guiAddCards(note=self.note) + self.click_on_add_card_dialog_save_button() + close_all_dialogs_and_wait_for_them_to_run_closing_callbacks() - # guiShowAnswer - util.invoke('guiShowAnswer') + assert len(ac.findCards(query="new")) == 1 - # guiAnswerCard - util.invoke('guiAnswerCard', ease=1) + def test_with_note_and_a_picture(self, setup): + ac.guiAddCards(note={**self.note, **self.picture}) + self.click_on_add_card_dialog_save_button() + close_all_dialogs_and_wait_for_them_to_run_closing_callbacks() - # guiDeckOverview - util.invoke('guiDeckOverview', name='Default') + assert len(ac.findCards(query="new")) == 1 + assert ac.retrieveMediaFile(filename="smiley.gif") == self.base64_gif - # guiDeckBrowser - util.invoke('guiDeckBrowser') + # todo the tested method, when called with option `closeAfterAdding=True`, + # is broken for the following reasons: + # * it uses the note that comes with dialog's Editor. + # this note might be of a different model than the proposed note, + # and field values from the proposed note can't be put into it. + # * most crucially, `AddCardsAndClose` is trying to override the method + # `_addCards` that is no longer present or called by the superclass. + # also, it creates and registers a new class each time it is called. + # todo fix the method, or ignore/disallow the option `closeAfterAdding`? + @pytest.mark.skip("API method `guiAddCards` is broken " + "when called with note option `closeAfterAdding=True`") + def test_with_note_and_closeAfterAdding(self, setup): + def find_AddCardsAndClose_dialog_registered_name(): + for name in aqt.dialogs._dialogs.keys(): + if name.startswith("AddCardsAndClose"): + return name - # guiDatabaseCheck - util.invoke('guiDatabaseCheck') + def dialog_is_open(name): + return aqt.dialogs._dialogs[name][1] is not None - # guiExitAnki - # util.invoke('guiExitAnki') + ac.guiAddCards(note={**self.note, **self.options_closeAfterAdding}) + + dialog_name = find_AddCardsAndClose_dialog_registered_name() + assert dialog_is_open(dialog_name) + self.click_on_add_card_dialog_save_button(dialog_name) + wait_until(aqt.dialogs.allClosed) -if __name__ == '__main__': - unittest.main() +class TestReviewActions: + @pytest.fixture + def reviewing_started(self, setup): + assert ac.guiDeckReview(name="test_deck") is True + + def test_startCardTimer(self, reviewing_started): + assert ac.guiStartCardTimer() is True + + def test_guiShowQuestion(self, reviewing_started): + assert ac.guiShowQuestion() is True + assert ac.reviewer().state == "question" + + def test_guiShowAnswer(self, reviewing_started): + assert ac.guiShowAnswer() is True + assert ac.reviewer().state == "answer" + + def test_guiAnswerCard(self, reviewing_started): + ac.guiShowAnswer() + reviews_before = ac.cardReviews(deck="test_deck", startID=0) + assert ac.guiAnswerCard(ease=4) is True + + reviews_after = ac.cardReviews(deck="test_deck", startID=0) + assert len(reviews_after) == len(reviews_before) + 1 + + +class TestSelectedNotes: + def test_with_valid_deck_query(self, setup): + ac.guiBrowse(query="deck:test_deck") + wait_until(ac.guiSelectedNotes) + assert ac.guiSelectedNotes()[0] in {setup.note1_id, setup.note2_id} + + + def test_with_invalid_deck_query(self, setup): + ac.guiBrowse(query="deck:test_deck") + wait_until(ac.guiSelectedNotes) + + ac.guiBrowse(query="deck:invalid") + wait_until(lambda: not ac.guiSelectedNotes()) diff --git a/tests/test_media.py b/tests/test_media.py index cc2bd38..b667c8e 100755 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -1,33 +1,51 @@ -#!/usr/bin/env python +import base64 -import unittest -import util +from conftest import ac -class TestMedia(unittest.TestCase): - def runTest(self): - filename = '_test.txt' - data = 'test' - - # storeMediaFile - util.invoke('storeMediaFile', filename=filename, data=data) - filename2 = util.invoke('storeMediaFile', filename=filename, data='testtest', deleteExisting=False) - self.assertNotEqual(filename2, filename) - - # retrieveMediaFile (part 1) - media = util.invoke('retrieveMediaFile', filename=filename) - self.assertEqual(media, data) - - names = util.invoke('getMediaFilesNames', pattern='_tes*.txt') - self.assertEqual(set(names), set([filename, filename2])) - - # deleteMediaFile - util.invoke('deleteMediaFile', filename=filename) - - # retrieveMediaFile (part 2) - media = util.invoke('retrieveMediaFile', filename=filename) - self.assertFalse(media) +FILENAME = "_test.txt" +BASE64_DATA_1 = base64.b64encode(b"test 1").decode("ascii") +BASE64_DATA_2 = base64.b64encode(b"test 2").decode("ascii") -if __name__ == '__main__': - unittest.main() +def store_one_media_file(): + return ac.storeMediaFile(filename=FILENAME, data=BASE64_DATA_1) + + +def store_two_media_files(): + filename_1 = ac.storeMediaFile(filename=FILENAME, data=BASE64_DATA_1) + filename_2 = ac.storeMediaFile(filename=FILENAME, data=BASE64_DATA_2, + deleteExisting=False) + return filename_1, filename_2 + + +############################################################################## + + +def test_storeMediaFile_one_file(session_with_profile_loaded): + filename_1 = store_one_media_file() + assert FILENAME == filename_1 + + +def test_storeMediaFile_two_files_with_the_same_name(session_with_profile_loaded): + filename_1, filename_2 = store_two_media_files() + assert FILENAME == filename_1 != filename_2 + + +def test_retrieveMediaFile(session_with_profile_loaded): + store_one_media_file() + result = ac.retrieveMediaFile(filename=FILENAME) + assert result == BASE64_DATA_1 + + +def test_getMediaFilesNames(session_with_profile_loaded): + filenames = store_two_media_files() + result = ac.getMediaFilesNames(pattern="_tes*.txt") + assert {*filenames} == {*result} + + +def test_deleteMediaFile(session_with_profile_loaded): + filename_1, filename_2 = store_two_media_files() + ac.deleteMediaFile(filename=filename_1) + assert ac.retrieveMediaFile(filename=filename_1) is False + assert ac.getMediaFilesNames(pattern="_tes*.txt") == [filename_2] diff --git a/tests/test_misc.py b/tests/test_misc.py index ac3f4d4..1e390cc 100755 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,68 +1,50 @@ -#!/usr/bin/env python +import aqt -import os -import tempfile -import unittest -import util +from conftest import ac, anki_connect_config_loaded, \ + set_up_test_deck_and_test_model_and_two_notes, \ + current_decks_and_models_etc_preserved, wait -class TestMisc(unittest.TestCase): - def runTest(self): - # version - self.assertEqual(util.invoke('version'), 6) - - # sync - util.invoke('sync') - - # getProfiles - profiles = util.invoke('getProfiles') - self.assertIsInstance(profiles, list) - self.assertGreater(len(profiles), 0) - - # loadProfile - util.invoke('loadProfile', name=profiles[0]) - - # multi - actions = [util.request('version'), util.request('version'), util.request('version')] - results = util.invoke('multi', actions=actions) - self.assertEqual(len(results), len(actions)) - for result in results: - self.assertIsNone(result['error']) - self.assertEqual(result['result'], 6) - - # exportPackage - fd, newname = tempfile.mkstemp(prefix='testexport', suffix='.apkg') - os.close(fd) - os.unlink(newname) - result = util.invoke('exportPackage', deck='Default', path=newname) - self.assertTrue(result) - self.assertTrue(os.path.exists(newname)) - - # importPackage - deckName = 'importTest' - fd, newname = tempfile.mkstemp(prefix='testimport', suffix='.apkg') - os.close(fd) - os.unlink(newname) - util.invoke('createDeck', deck=deckName) - note = { - 'deckName': deckName, - 'modelName': 'Basic', - 'fields': {'Front': 'front1', 'Back': 'back1'}, - 'tags': '', - 'options': { - 'allowDuplicate': True - } - } - noteId = util.invoke('addNote', note=note) - util.invoke('exportPackage', deck=deckName, path=newname) - util.invoke('deleteDecks', decks=[deckName], cardsToo=True) - util.invoke('importPackage', path=newname) - deckNames = util.invoke('deckNames') - self.assertIn(deckName, deckNames) - - # reloadCollection - util.invoke('reloadCollection') +# version is retrieved from config +def test_version(session_with_profile_loaded): + with anki_connect_config_loaded( + session=session_with_profile_loaded, + web_bind_port=0, + ): + assert ac.version() == 6 -if __name__ == '__main__': - unittest.main() +def test_reloadCollection(setup): + ac.reloadCollection() + + +class TestProfiles: + def test_getProfiles(self, session_with_profile_loaded): + result = ac.getProfiles() + assert result == ["User 1"] + + # waiting a little while gets rid of the cryptic warning: + # Qt warning: QXcbConnection: XCB error: 8 (BadMatch), sequence: 658, + # resource id: 2097216, major code: 42 (SetInputFocus), minor code: 0 + def test_loadProfile(self, session_with_profile_loaded): + aqt.mw.unloadProfileAndShowProfileManager() + wait(0.1) + ac.loadProfile(name="User 1") + + +class TestExportImport: + def test_exportPackage(self, session_with_profile_loaded, setup): + filename = session_with_profile_loaded.base + "/export.apkg" + ac.exportPackage(deck="test_deck", path=filename) + + def test_importPackage(self, session_with_profile_loaded): + filename = session_with_profile_loaded.base + "/export.apkg" + + with current_decks_and_models_etc_preserved(): + set_up_test_deck_and_test_model_and_two_notes() + ac.exportPackage(deck="test_deck", path=filename) + + with current_decks_and_models_etc_preserved(): + assert "test_deck" not in ac.deckNames() + ac.importPackage(path=filename) + assert "test_deck" in ac.deckNames() diff --git a/tests/test_models.py b/tests/test_models.py index 20161f6..f7e9131 100755 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,66 +1,112 @@ -#!/usr/bin/env python - -import unittest -import util -import uuid +from conftest import ac -MODEL_1_NAME = str(uuid.uuid4()) -MODEL_2_NAME = str(uuid.uuid4()) +def test_modelNames(setup): + result = ac.modelNames() + assert "test_model" in result -CSS = 'some random css' -NEW_CSS = 'new random css' -CARD_1_TEMPLATE = {'Front': 'field1', 'Back': 'field2'} -NEW_CARD_1_TEMPLATE = {'Front': 'question: field1', 'Back': 'answer: field2'} +def test_modelNamesAndIds(setup): + result = ac.modelNamesAndIds() + assert isinstance(result["test_model"], int) -TEXT_TO_REPLACE = "new random css" -REPLACE_WITH_TEXT = "new updated css" -class TestModels(unittest.TestCase): - def runTest(self): - # modelNames - modelNames = util.invoke('modelNames') - self.assertGreater(len(modelNames), 0) +def test_modelFieldNames(setup): + result = ac.modelFieldNames(modelName="test_model") + assert result == ["field1", "field2"] - # modelNamesAndIds - modelNamesAndIds = util.invoke('modelNamesAndIds') - self.assertGreater(len(modelNames), 0) - # modelFieldNames - modelFields = util.invoke('modelFieldNames', modelName=modelNames[0]) +def test_modelFieldsOnTemplates(setup): + result = ac.modelFieldsOnTemplates(modelName="test_model") + assert result == { + "Card 1": [["field1"], ["field2"]], + "Card 2": [["field2"], ["field1"]], + } - # modelFieldsOnTemplates - modelFieldsOnTemplates = util.invoke('modelFieldsOnTemplates', modelName=modelNames[0]) - # createModel with css - newModel = util.invoke('createModel', modelName=MODEL_1_NAME, inOrderFields=['field1', 'field2'], cardTemplates=[CARD_1_TEMPLATE], css=CSS) +class TestCreateModel: + createModel_kwargs = { + "modelName": "test_model_foo", + "inOrderFields": ["field1", "field2"], + "cardTemplates": [{"Front": "{{field1}}", "Back": "{{field2}}"}], + } - # createModel without css - newModel = util.invoke('createModel', modelName=MODEL_2_NAME, inOrderFields=['field1', 'field2'], cardTemplates=[CARD_1_TEMPLATE]) + def test_createModel_without_css(self, session_with_profile_loaded): + ac.createModel(**self.createModel_kwargs) - # modelStyling: get model 1 css - css = util.invoke('modelStyling', modelName=MODEL_1_NAME) - self.assertEqual({'css': CSS}, css) + def test_createModel_with_css(self, session_with_profile_loaded): + ac.createModel(**self.createModel_kwargs, css="* {}") - # modelTemplates: get model 1 templates - templates = util.invoke('modelTemplates', modelName=MODEL_1_NAME) - self.assertEqual({'Card 1': CARD_1_TEMPLATE}, templates) - # updateModelStyling: change and verify model css - util.invoke('updateModelStyling', model={'name': MODEL_1_NAME, 'css': NEW_CSS}) - new_css = util.invoke('modelStyling', modelName=MODEL_1_NAME) - self.assertEqual({'css': NEW_CSS}, new_css) +class TestStyling: + def test_modelStyling(self, setup): + result = ac.modelStyling(modelName="test_model") + assert result == {"css": "* {}"} - # updateModelTemplates: change and verify model 1 templates - util.invoke('updateModelTemplates', model={'name': MODEL_1_NAME, 'templates': {'Card 1': NEW_CARD_1_TEMPLATE}}) - templates = util.invoke('modelTemplates', modelName=MODEL_1_NAME) - self.assertEqual({'Card 1': NEW_CARD_1_TEMPLATE}, templates) + def test_updateModelStyling(self, setup): + ac.updateModelStyling(model={ + "name": "test_model", + "css": "* {color: red;}" + }) - # findAndReplaceInModels: find and replace text in all models or model by name - util.invoke('findAndReplaceInModels', modelName=MODEL_1_NAME, findText=TEXT_TO_REPLACE, replaceText=REPLACE_WITH_TEXT, front=True, back=True, css=True) - new_css = util.invoke('modelStyling', modelName=MODEL_1_NAME) - self.assertEqual({'css': REPLACE_WITH_TEXT}, new_css) + assert ac.modelStyling(modelName="test_model") == { + "css": "* {color: red;}" + } -if __name__ == '__main__': - unittest.main() \ No newline at end of file + +class TestModelTemplates: + def test_modelTemplates(self, setup): + result = ac.modelTemplates(modelName="test_model") + assert result == { + "Card 1": {"Front": "{{field1}}", "Back": "{{field2}}"}, + "Card 2": {"Front": "{{field2}}", "Back": "{{field1}}"} + } + + def test_updateModelTemplates(self, setup): + ac.updateModelTemplates(model={ + "name": "test_model", + "templates": {"Card 1": {"Front": "{{field1}}", "Back": "foo"}} + }) + + assert ac.modelTemplates(modelName="test_model") == { + "Card 1": {"Front": "{{field1}}", "Back": "foo"}, + "Card 2": {"Front": "{{field2}}", "Back": "{{field1}}"} + } + + +def test_findAndReplaceInModels(setup): + ac.findAndReplaceInModels( + modelName="test_model", + findText="}}", + replaceText="}}!", + front=True, + back=False, + css=False, + ) + + ac.findAndReplaceInModels( + modelName="test_model", + findText="}}", + replaceText="}}?", + front=True, + back=True, + css=False, + ) + + ac.findAndReplaceInModels( + modelName="test_model", + findText="}", + replaceText="color: blue;}", + front=False, + back=False, + css=True, + ) + + assert ac.modelTemplates(modelName="test_model") == { + "Card 1": {"Front": "{{field1}}?!", "Back": "{{field2}}?"}, + "Card 2": {"Front": "{{field2}}?!", "Back": "{{field1}}?"} + } + + assert ac.modelStyling(modelName="test_model") == { + "css": "* {color: blue;}" + } diff --git a/tests/test_notes.py b/tests/test_notes.py index a87b3b0..f4bc874 100755 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -1,155 +1,142 @@ -#!/usr/bin/env python +import pytest +from anki.errors import NotFoundError # noqa -import unittest -import util +from conftest import ac -class TestNotes(unittest.TestCase): - def setUp(self): - util.invoke('createDeck', deck='test') +def make_note(*, front="front1", allow_duplicates=False): + note = { + "deckName": "test_deck", + "modelName": "Basic", + "fields": {"Front": front, "Back": "back1"}, + "tags": ["tag1"], + } + + if allow_duplicates: + return {**note, "options": {"allowDuplicate": True}} + else: + return note - def tearDown(self): - util.invoke('deleteDecks', decks=['test'], cardsToo=True) +############################################################################## - def runTest(self): - options = { - 'allowDuplicate': True +class TestNoteAddition: + def test_addNote(self, setup): + result = ac.addNote(note=make_note()) + assert isinstance(result, int) + + def test_addNote_will_not_allow_duplicates_by_default(self, setup): + ac.addNote(make_note()) + with pytest.raises(Exception, match="it is a duplicate"): + ac.addNote(make_note()) + + def test_addNote_will_allow_duplicates_if_options_say_aye(self, setup): + ac.addNote(make_note()) + ac.addNote(make_note(allow_duplicates=True)) + + def test_addNotes(self, setup): + result = ac.addNotes(notes=[ + make_note(front="foo"), + make_note(front="bar"), + make_note(front="foo"), + ]) + + assert len(result) == 3 + assert isinstance(result[0], int) + assert isinstance(result[1], int) + assert result[2] is None + + def test_bug164(self, setup): + note = { + "deckName": "test_deck", + "modelName": "Basic", + "fields": {"Front": " Whitespace\n", "Back": ""}, + "options": {"allowDuplicate": False, "duplicateScope": "deck"} } - note1 = { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': {'Front': 'front1', 'Back': 'back1'}, - 'tags': ['tag1'], - 'options': options - } + ac.addNote(note=note) + with pytest.raises(Exception, match="it is a duplicate"): + ac.addNote(note=note) - note2 = { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': {'Front': 'front1', 'Back': 'back1'}, - 'tags': ['tag1'] - } - notes1 = [ - { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': {'Front': 'front3', 'Back': 'back3'}, - 'tags': ['tag'], - 'options': options - }, - { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': {'Front': 'front4', 'Back': 'back4'}, - 'tags': ['tag'], - 'options': options - } - ] - - notes2 = [ - { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': {'Front': 'front3', 'Back': 'back3'}, - 'tags': ['tag'] - }, - { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': {'Front': 'front4', 'Back': 'back4'}, - 'tags': ['tag'] - } +def test_notesInfo(setup): + result = ac.notesInfo(notes=[setup.note1_id]) + assert len(result) == 1 + assert result[0]["noteId"] == setup.note1_id + assert result[0]["tags"] == ["tag1"] + assert result[0]["fields"]["field1"]["value"] == "note1 field1" + + +class TestTags: + def test_addTags(self, setup): + ac.addTags(notes=[setup.note1_id], tags="tag2") + tags = ac.notesInfo(notes=[setup.note1_id])[0]["tags"] + assert {*tags} == {"tag1", "tag2"} + + def test_getTags(self, setup): + result = ac.getTags() + assert {*result} == {"tag1", "tag2"} + + def test_removeTags(self, setup): + ac.removeTags(notes=[setup.note2_id], tags="tag2") + assert ac.notesInfo(notes=[setup.note2_id])[0]["tags"] == [] + + def test_replaceTags(self, setup): + ac.replaceTags(notes=[setup.note1_id, 123], + tag_to_replace="tag1", replace_with_tag="foo") + notes_info = ac.notesInfo(notes=[setup.note1_id]) + assert notes_info[0]["tags"] == ["foo"] + + def test_replaceTagsInAllNotes(self, setup): + ac.replaceTagsInAllNotes(tag_to_replace="tag1", replace_with_tag="foo") + notes_info = ac.notesInfo(notes=[setup.note1_id]) + assert notes_info[0]["tags"] == ["foo"] + + def test_clearUnusedTags(self, setup): + ac.removeTags(notes=[setup.note2_id], tags="tag2") + ac.clearUnusedTags() + assert ac.getTags() == ["tag1"] + + +class TestUpdateNoteFields: + def test_updateNoteFields(self, setup): + new_fields = {"field1": "foo", "field2": "bar"} + good_note = {"id": setup.note1_id, "fields": new_fields} + ac.updateNoteFields(note=good_note) + notes_info = ac.notesInfo(notes=[setup.note1_id]) + assert notes_info[0]["fields"]["field2"]["value"] == "bar" + + def test_updateNoteFields_will_note_update_invalid_notes(self, setup): + bad_note = {"id": 123, "fields": make_note()["fields"]} + with pytest.raises(NotFoundError): + ac.updateNoteFields(note=bad_note) + + +class TestCanAddNotes: + foo_bar_notes = [make_note(front="foo"), make_note(front="bar")] + + def test_canAddNotes(self, setup): + result = ac.canAddNotes(notes=self.foo_bar_notes) + assert result == [True, True] + + def test_canAddNotes_will_not_add_duplicates_if_options_do_not_say_aye(self, setup): + ac.addNotes(notes=self.foo_bar_notes) + notes = [ + make_note(front="foo"), + make_note(front="baz"), + make_note(front="foo", allow_duplicates=True) ] + result = ac.canAddNotes(notes=notes) + assert result == [False, True, True] - # addNote - noteId = util.invoke('addNote', note=note1) - self.assertRaises(Exception, lambda: util.invoke('addNote', note=note2)) +def test_findNotes(setup): + result = ac.findNotes(query="deck:test_deck") + assert {*result} == {setup.note1_id, setup.note2_id} - # addTags - util.invoke('addTags', notes=[noteId], tags='tag2') - # notesInfo (part 1) - noteInfos = util.invoke('notesInfo', notes=[noteId]) - self.assertEqual(len(noteInfos), 1) - noteInfo = noteInfos[0] - self.assertEqual(noteInfo['noteId'], noteId) - self.assertSetEqual(set(noteInfo['tags']), {'tag1', 'tag2'}) - self.assertEqual(noteInfo['fields']['Front']['value'], 'front1') - self.assertEqual(noteInfo['fields']['Back']['value'], 'back1') - - # getTags - allTags = util.invoke('getTags') - self.assertIn('tag1', allTags) - self.assertIn('tag2', allTags) - - # removeTags - util.invoke('removeTags', notes=[noteId], tags='tag2') - - # updateNoteFields - incorrectId = 1234 - noteUpdateIncorrectId = {'id': incorrectId, 'fields': {'Front': 'front2', 'Back': 'back2'}} - self.assertRaises(Exception, lambda: util.invoke('updateNoteFields', note=noteUpdateIncorrectId)) - noteUpdate = {'id': noteId, 'fields': {'Front': 'front2', 'Back': 'back2'}} - util.invoke('updateNoteFields', note=noteUpdate) - - # replaceTags - util.invoke('replaceTags', notes=[noteId, incorrectId], tag_to_replace='tag1', replace_with_tag='new_tag') - - # notesInfo (part 2) - noteInfos = util.invoke('notesInfo', notes=[noteId, incorrectId]) - self.assertEqual(len(noteInfos), 2) - self.assertDictEqual(noteInfos[1], dict()) # Test that returns empty dict if incorrect id was passed - noteInfo = noteInfos[0] - self.assertSetEqual(set(noteInfo['tags']), {'new_tag'}) - self.assertIn('new_tag', noteInfo['tags']) - self.assertNotIn('tag2', noteInfo['tags']) - self.assertEqual(noteInfo['fields']['Front']['value'], 'front2') - self.assertEqual(noteInfo['fields']['Back']['value'], 'back2') - - # canAddNotes (part 1) - noteStates = util.invoke('canAddNotes', notes=notes1) - self.assertEqual(len(noteStates), len(notes1)) - self.assertNotIn(False, noteStates) - - # addNotes (part 1) - noteIds = util.invoke('addNotes', notes=notes1) - self.assertEqual(len(noteIds), len(notes1)) - for noteId in noteIds: - self.assertNotEqual(noteId, None) - - # replaceTagsInAllNotes - currentTag = notes1[0]['tags'][0] - new_tag = 'new_tag' - util.invoke('replaceTagsInAllNotes', tag_to_replace=currentTag, replace_with_tag=new_tag) - noteInfos = util.invoke('notesInfo', notes=noteIds) - for noteInfo in noteInfos: - self.assertIn(new_tag, noteInfo['tags']) - self.assertNotIn(currentTag, noteInfo['tags']) - - # canAddNotes (part 2) - noteStates = util.invoke('canAddNotes', notes=notes2) - self.assertNotIn(True, noteStates) - self.assertEqual(len(noteStates), len(notes2)) - - # addNotes (part 2) - noteIds = util.invoke('addNotes', notes=notes2) - self.assertEqual(len(noteIds), len(notes2)) - for noteId in noteIds: - self.assertEqual(noteId, None) - - # findNotes - noteIds = util.invoke('findNotes', query='deck:test') - self.assertEqual(len(noteIds), len(notes1) + 1) - - # deleteNotes - util.invoke('deleteNotes', notes=noteIds) - noteIds = util.invoke('findNotes', query='deck:test') - self.assertEqual(len(noteIds), 0) - -if __name__ == '__main__': - unittest.main() +def test_deleteNotes(setup): + ac.deleteNotes(notes=[setup.note1_id, setup.note2_id]) + result = ac.findNotes(query="deck:test_deck") + assert result == [] diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..7e651e5 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,149 @@ +import json +import multiprocessing +import time +import urllib.error +import urllib.request +from contextlib import contextmanager +from dataclasses import dataclass +from functools import partial + +import pytest +from pytest_anki._launch import anki_running # noqa +from pytest_anki._util import find_free_port # noqa + +from plugin import AnkiConnect +from tests.conftest import wait_until, \ + empty_anki_session_started, \ + anki_connect_config_loaded, \ + profile_loaded + + +@contextmanager +def function_running_in_a_process(context, function): + process = context.Process(target=function) + process.start() + + try: + yield process + finally: + process.join() + + +# todo stop the server? +@contextmanager +def anki_connect_web_server_started(): + plugin = AnkiConnect() + plugin.startWebServer() + yield plugin + + +@dataclass +class Client: + port: int + + @staticmethod + def make_request(action, **params): + return {"action": action, "params": params, "version": 6} + + def send_request(self, action, **params): + request_url = f"http://localhost:{self.port}" + request_data = self.make_request(action, **params) + request_json = json.dumps(request_data).encode("utf-8") + request = urllib.request.Request(request_url, request_json) + response = json.load(urllib.request.urlopen(request)) + return response + + def wait_for_web_server_to_come_live(self, at_most_seconds=30): + deadline = time.time() + at_most_seconds + + while time.time() < deadline: + try: + self.send_request("version") + return + except urllib.error.URLError: + time.sleep(0.01) + + raise Exception(f"Anki-Connect web server did not come live " + f"in {at_most_seconds} seconds") + + +# spawning requires a top-level function for pickling +def external_anki_entry_function(web_bind_port, exit_event): + with empty_anki_session_started() as session: + with anki_connect_config_loaded(session, web_bind_port): + with anki_connect_web_server_started(): + with profile_loaded(session): + wait_until(exit_event.is_set) + + +@contextmanager +def external_anki_running(process_run_method): + context = multiprocessing.get_context(process_run_method) + exit_event = context.Event() + web_bind_port = find_free_port() + function = partial(external_anki_entry_function, web_bind_port, exit_event) + + with function_running_in_a_process(context, function) as process: + client = Client(port=web_bind_port) + client.wait_for_web_server_to_come_live() + + try: + yield client + finally: + exit_event.set() + + assert process.exitcode == 0 + + +# if a Qt app was already launched in current process, +# launching a new Qt app, even from grounds up, fails or hangs. +# of course, this includes forked processes. therefore, +# * if launching without --forked, use the `spawn` process run method; +# * otherwise, use the `fork` method, as it is significantly faster. +# with --forked, each test has its fixtures assembled inside the fork, +# which means that when the test begins, Qt was never started in the fork. +@pytest.fixture(scope="module") +def external_anki(request): + """ + Runs Anki in an external process, with the plugin loaded and started. + On exit, neatly ends the process and makes sure its exit code is 0. + Yields a client that can send web request to the external process. + """ + with external_anki_running( + "fork" if request.config.option.forked else "spawn" + ) as client: + yield client + + +############################################################################## + + +def test_successful_request(external_anki): + response = external_anki.send_request("version") + assert response == {"error": None, "result": 6} + + +def test_can_handle_multiple_requests(external_anki): + assert external_anki.send_request("version") == {"error": None, "result": 6} + assert external_anki.send_request("version") == {"error": None, "result": 6} + + +def test_multi_request(external_anki): + version_request = Client.make_request("version") + response = external_anki.send_request("multi", actions=[version_request] * 3) + assert response == { + "error": None, + "result": [{"error": None, "result": 6}] * 3 + } + + +def test_failing_request_due_to_bad_arguments(external_anki): + response = external_anki.send_request("addNote", bad="request") + assert response["result"] is None + assert "unexpected keyword argument" in response["error"] + + +def test_failing_request_due_to_anki_raising_exception(external_anki): + response = external_anki.send_request("suspend", cards=[-123]) + assert response["result"] is None + assert "Card was not found" in response["error"] diff --git a/tests/test_stats.py b/tests/test_stats.py index 3b80ac7..a412ca1 100755 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,55 +1,31 @@ -#!/usr/bin/env python - -import os -import tempfile -import unittest -import util +from conftest import ac -class TestStats(unittest.TestCase): - def setUp(self): - util.invoke('createDeck', deck='test') - note = { - 'deckName': 'test', - 'modelName': 'Basic', - 'fields': {'Front': 'front1', 'Back': 'back1'}, - 'tags': ['tag1'], - 'options': { - 'allowDuplicate': True - } - } - self.noteId = util.invoke('addNote', note=note) - - def tearDown(self): - util.invoke('deleteDecks', decks=['test'], cardsToo=True) - - def runTest(self): - # getNumCardsReviewedToday - result = util.invoke('getNumCardsReviewedToday') - self.assertIsInstance(result, int) - - # getNumCardsReviewedByDay - result = util.invoke('getNumCardsReviewedByDay') - self.assertIsInstance(result, list) - - # collectionStats - result = util.invoke('getCollectionStatsHTML') - self.assertIsInstance(result, str) - - # no reviews for new deck - self.assertEqual(len(util.invoke('cardReviews', deck='test', startID=0)), 0) - self.assertEqual(util.invoke('getLatestReviewID', deck='test'), 0) - - # # add reviews - # cardId = int(util.invoke('findCards', query='deck:test')[0]) - # latestID = 123456 # small enough to not interfere with existing reviews - # util.invoke('insertReviews', reviews=[ - # [latestID-1, cardId, -1, 3, 4, -60, 2500, 6157, 0], - # [latestID, cardId, -1, 1, -60, -60, 0, 4846, 0] - # ]) - # self.assertEqual(len(util.invoke('cardReviews', deck='test', startID=0)), 2) - # self.assertEqual(util.invoke('getLatestReviewID', deck='test'), latestID) +def test_getNumCardsReviewedToday(setup): + result = ac.getNumCardsReviewedToday() + assert isinstance(result, int) -if __name__ == '__main__': - unittest.main() +def test_getNumCardsReviewedByDay(setup): + result = ac.getNumCardsReviewedByDay() + assert isinstance(result, list) + + +def test_getCollectionStatsHTML(setup): + result = ac.getCollectionStatsHTML() + assert isinstance(result, str) + + +class TestReviews: + def test_zero_reviews_for_a_new_deck(self, setup): + assert ac.cardReviews(deck="test_deck", startID=0) == [] + assert ac.getLatestReviewID(deck="test_deck") == 0 + + def test_some_reviews_for_a_reviewed_deck(self, setup): + ac.insertReviews(reviews=[ + (456, setup.card_ids[0], -1, 3, 4, -60, 2500, 6157, 0), + (789, setup.card_ids[1], -1, 1, -60, -60, 0, 4846, 0) + ]) + + assert len(ac.cardReviews(deck="test_deck", startID=0)) == 2 + assert ac.getLatestReviewID(deck="test_deck") == 789 diff --git a/tests/util.py b/tests/util.py deleted file mode 100644 index 2074981..0000000 --- a/tests/util.py +++ /dev/null @@ -1,21 +0,0 @@ -import json -import urllib.request - - -def request(action, **params): - return {'action': action, 'params': params, 'version': 6} - -def invoke(action, **params): - requestJson = json.dumps(request(action, **params)).encode('utf-8') - response = json.load(urllib.request.urlopen(urllib.request.Request('http://localhost:8765', requestJson))) - - if len(response) != 2: - raise Exception('response has an unexpected number of fields') - if 'error' not in response: - raise Exception('response is missing required error field') - if 'result' not in response: - raise Exception('response is missing required result field') - if response['error'] is not None: - raise Exception(response['error']) - - return response['result']