From 59118eea52626b8a94986fd0caf8d83f13c7065e Mon Sep 17 00:00:00 2001 From: Philipp Stadler <hello@phstadler.com> Date: Mon, 15 Jul 2024 15:46:33 +0200 Subject: [PATCH] chore: add tests and CI --- .gitignore | 1 + .gitlab-ci.yml | 19 + Dockerfile | 1 - Makefile | 21 ++ export-apkgs-test-runner/Dockerfile | 16 + test/fixtures/anki/.apkg-spec.yaml | 8 + test/fixtures/anki/Test.apkg | Bin 0 -> 55648 bytes test/fixtures/csv/.apkg-spec.yaml | 18 + test/fixtures/csv/content.csv | 1 + .../csv_updated_content/.apkg-spec.yaml | 18 + test/fixtures/csv_updated_content/content.csv | 1 + .../templates/q_a/.template-spec.yaml | 14 + test/fixtures/templates/q_a/q_a/back.html | 1 + test/fixtures/templates/q_a/q_a/front.html | 1 + .../templates_updated/q_a/.template-spec.yaml | 14 + .../templates_updated/q_a/q_a/back.html | 1 + .../templates_updated/q_a/q_a/front.html | 1 + test/test_export_apkgs.py | 337 ++++++++++++++++++ 18 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 .gitlab-ci.yml create mode 100644 Makefile create mode 100644 export-apkgs-test-runner/Dockerfile create mode 100644 test/fixtures/anki/.apkg-spec.yaml create mode 100644 test/fixtures/anki/Test.apkg create mode 100644 test/fixtures/csv/.apkg-spec.yaml create mode 100644 test/fixtures/csv/content.csv create mode 100644 test/fixtures/csv_updated_content/.apkg-spec.yaml create mode 100644 test/fixtures/csv_updated_content/content.csv create mode 100644 test/fixtures/templates/q_a/.template-spec.yaml create mode 100644 test/fixtures/templates/q_a/q_a/back.html create mode 100644 test/fixtures/templates/q_a/q_a/front.html create mode 100644 test/fixtures/templates_updated/q_a/.template-spec.yaml create mode 100644 test/fixtures/templates_updated/q_a/q_a/back.html create mode 100644 test/fixtures/templates_updated/q_a/q_a/front.html create mode 100644 test/test_export_apkgs.py diff --git a/.gitignore b/.gitignore index e69de29..bee8a64 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..93634ff --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,19 @@ +image: durcheinander/export-apkgs-test-runner:1.0.0 + +default: + cache: + paths: + - .venv + +stages: +- test + +test: + stage: test + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" + when: never + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_TAG + script: + - make check diff --git a/Dockerfile b/Dockerfile index e6b3055..15c903f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,6 @@ python3 \ python3-pip \ zip \ curl - RUN npm install --global \ yarn \ mudslide diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..eeba479 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +ifneq ($(wildcard /bin/export_apkgs),) +# no pipenv needed in container, deps are preinstalled globally +PYTHON := $(shell python-libfaketime | sed 's/export //') python3 +PYTHON_NEEDED := +else +# lazy evaluation in case faketime dep is not installed yet +PYTHON = PIPENV_VENV_IN_PROJECT=1 $(shell .venv/bin/python-libfaketime | sed 's/export //') pipenv run python +# when running outside the container, install python deps for tests if needed +PYTHON_NEEDED := .venv/.project +endif + +.PHONY: all +all: check + +.PHONY: check +check: $(PYTHON_NEEDED) + $(PYTHON) -m unittest discover test + +.venv/.project: Pipfile Pipfile.lock + PIPENV_VENV_IN_PROJECT=1 pipenv install + touch $@ diff --git a/export-apkgs-test-runner/Dockerfile b/export-apkgs-test-runner/Dockerfile new file mode 100644 index 0000000..9b6c82a --- /dev/null +++ b/export-apkgs-test-runner/Dockerfile @@ -0,0 +1,16 @@ +FROM debian:12.4 + +RUN apt-get update && apt-get install -y \ +faketime \ +git \ +make \ +nodejs \ +npm \ +pipenv \ +python3 \ +python3-pip \ +zip \ +curl +RUN npm install --global \ +yarn \ +mudslide diff --git a/test/fixtures/anki/.apkg-spec.yaml b/test/fixtures/anki/.apkg-spec.yaml new file mode 100644 index 0000000..92ef748 --- /dev/null +++ b/test/fixtures/anki/.apkg-spec.yaml @@ -0,0 +1,8 @@ +content_version: 0.0.1 + +templates: +- q_a + +content: +- import_apkg: + note_type: Q/A Testnotetype \ No newline at end of file diff --git a/test/fixtures/anki/Test.apkg b/test/fixtures/anki/Test.apkg new file mode 100644 index 0000000000000000000000000000000000000000..28a3614f2b228aa8ae1ba806c9914101a758cdcd GIT binary patch literal 55648 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/csv/.apkg-spec.yaml b/test/fixtures/csv/.apkg-spec.yaml new file mode 100644 index 0000000..274a5ed --- /dev/null +++ b/test/fixtures/csv/.apkg-spec.yaml @@ -0,0 +1,18 @@ +content_version: 1.0.0 + +templates: +- q_a + +content: +- import_csv: + content_version: 2024-01-19 19:00:00+00:00 + note_type: Q/A Testnotetype + file_patterns: + - '*.csv' + # and to making one deck per card type + deck_name_pattern: '{{card_type}}' + fields_mapping: + - guid + - Question + - Answer + tags: [] \ No newline at end of file diff --git a/test/fixtures/csv/content.csv b/test/fixtures/csv/content.csv new file mode 100644 index 0000000..3c98112 --- /dev/null +++ b/test/fixtures/csv/content.csv @@ -0,0 +1 @@ +test-csv-note-1;What is the scientific name of the only tapir living in Asia?;Tapirus indicus \ No newline at end of file diff --git a/test/fixtures/csv_updated_content/.apkg-spec.yaml b/test/fixtures/csv_updated_content/.apkg-spec.yaml new file mode 100644 index 0000000..c20f61a --- /dev/null +++ b/test/fixtures/csv_updated_content/.apkg-spec.yaml @@ -0,0 +1,18 @@ +content_version: 1.0.0 + +templates: +- q_a + +content: +- import_csv: + content_version: 2024-01-19 20:00:00+00:00 + note_type: Q/A Testnotetype + file_patterns: + - '*.csv' + # and to making one deck per card type + deck_name_pattern: '{{card_type}}' + fields_mapping: + - guid + - Question + - Answer + tags: [] \ No newline at end of file diff --git a/test/fixtures/csv_updated_content/content.csv b/test/fixtures/csv_updated_content/content.csv new file mode 100644 index 0000000..d1f9267 --- /dev/null +++ b/test/fixtures/csv_updated_content/content.csv @@ -0,0 +1 @@ +test-csv-note-1;What is the scientific name of the only tapir living in Asia?;Tapirus indicus, Acrocodia indica being an outdated synonym. \ No newline at end of file diff --git a/test/fixtures/templates/q_a/.template-spec.yaml b/test/fixtures/templates/q_a/.template-spec.yaml new file mode 100644 index 0000000..81ef829 --- /dev/null +++ b/test/fixtures/templates/q_a/.template-spec.yaml @@ -0,0 +1,14 @@ +template_version: 2024-01-20 18:00:00+00:00 + +note_type: + id: 2024-01-20 01:00:00+00:00 + name: Q/A Testnotetype + fields: + - Question + - Answer + +card_types: +- name: Q/A Testcardtype + template: q_a + +resource_paths: [] \ No newline at end of file diff --git a/test/fixtures/templates/q_a/q_a/back.html b/test/fixtures/templates/q_a/q_a/back.html new file mode 100644 index 0000000..aaee1c4 --- /dev/null +++ b/test/fixtures/templates/q_a/q_a/back.html @@ -0,0 +1 @@ +{{Answer}} \ No newline at end of file diff --git a/test/fixtures/templates/q_a/q_a/front.html b/test/fixtures/templates/q_a/q_a/front.html new file mode 100644 index 0000000..14cb9f2 --- /dev/null +++ b/test/fixtures/templates/q_a/q_a/front.html @@ -0,0 +1 @@ +{{Question}} \ No newline at end of file diff --git a/test/fixtures/templates_updated/q_a/.template-spec.yaml b/test/fixtures/templates_updated/q_a/.template-spec.yaml new file mode 100644 index 0000000..02ef56a --- /dev/null +++ b/test/fixtures/templates_updated/q_a/.template-spec.yaml @@ -0,0 +1,14 @@ +template_version: 2024-01-20 19:00:00+00:00 + +note_type: + id: 2024-01-20 01:00:00+00:00 + name: Q/A Testnotetype + fields: + - Question + - Answer + +card_types: +- name: Q/A Testcardtype + template: q_a + +resource_paths: [] diff --git a/test/fixtures/templates_updated/q_a/q_a/back.html b/test/fixtures/templates_updated/q_a/q_a/back.html new file mode 100644 index 0000000..5cc0404 --- /dev/null +++ b/test/fixtures/templates_updated/q_a/q_a/back.html @@ -0,0 +1 @@ +Back: {{Answer}} \ No newline at end of file diff --git a/test/fixtures/templates_updated/q_a/q_a/front.html b/test/fixtures/templates_updated/q_a/q_a/front.html new file mode 100644 index 0000000..51c00f1 --- /dev/null +++ b/test/fixtures/templates_updated/q_a/q_a/front.html @@ -0,0 +1 @@ +Front: {{Question}} \ No newline at end of file diff --git a/test/test_export_apkgs.py b/test/test_export_apkgs.py new file mode 100644 index 0000000..03259b0 --- /dev/null +++ b/test/test_export_apkgs.py @@ -0,0 +1,337 @@ +from anki.collection import Collection, ImportAnkiPackageRequest, ImportAnkiPackageOptions +from anki.import_export_pb2 import ImportAnkiPackageUpdateCondition +from export_apkgs import export_package_from_spec +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest + +@dataclass +class MockArgs: + content: str + templates_dir: str + output_dir: str + dry_run: bool + +CONTENT_PATH_ANKI = 'test/fixtures/anki' +CONTENT_PATH_CSV = 'test/fixtures/csv' +CONTENT_PATH_CSV_UPDATED_CONTENT = 'test/fixtures/csv_updated_content' +TEMPLATES_PATH = 'test/fixtures/templates' +TEMPLATES_PATH_UPDATED = 'test/fixtures/templates_updated' + +class TestExportApkgs(unittest.TestCase): + def test_generate_once_and_reimport(self): + """ + Basic sanity check: if the exported package is imported twice, + everything is a duplicate and nothing should change. + """ + with TemporaryDirectory() as temp_collection_dir: + col = Collection(str(Path(temp_collection_dir) / "test.anki2")) + with TemporaryDirectory() as first_export_dir: + args_first = MockArgs( + content=CONTENT_PATH_CSV, + templates_dir=TEMPLATES_PATH, + output_dir=first_export_dir, + dry_run=False) + package = export_package_from_spec( + Path(CONTENT_PATH_CSV) / '.apkg-spec.yaml', + args_first) + + # import the first time, everything is new + result1 = col.import_anki_package(ImportAnkiPackageRequest( + package_path=str(package), + options=ImportAnkiPackageOptions( + merge_notetypes=True, + update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + with_scheduling=False, + with_deck_configs=False, + ) + )) + self.assertEqual(len(result1.log.new), 1) + self.assertEqual(len(result1.log.duplicate), 0) + + # now import again, nothing should change + result2 = col.import_anki_package(ImportAnkiPackageRequest( + package_path=str(package), + options=ImportAnkiPackageOptions( + merge_notetypes=True, + update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + with_scheduling=False, + with_deck_configs=False, + ) + )) + self.assertEqual(len(result2.log.duplicate) == 1 and len(result2.log.new), 0, + f'Expected the note to be recognized as duplicate.\nLog1:\n{result1.log}\nLog2:\n{result2.log}') + + def test_generate_twice_and_reimport(self): + """ + This checks that if we generate the package twice and import it twice, + then the second import will not change anything because the content is + supposed to be the same. + + If we introduce errors that lead to different note and card IDs being + generated, then this test will fail. + """ + with TemporaryDirectory() as temp_collection_dir: + col = Collection(str(Path(temp_collection_dir) / "test.anki2")) + with TemporaryDirectory() as first_export_dir: + args_first = MockArgs( + content=CONTENT_PATH_CSV, + templates_dir=TEMPLATES_PATH, + output_dir=first_export_dir, + dry_run=False) + with TemporaryDirectory() as second_export_dir: + args_second = MockArgs( + content=CONTENT_PATH_CSV, + templates_dir=TEMPLATES_PATH, + output_dir=second_export_dir, + dry_run=False) + spec_path = Path(CONTENT_PATH_CSV) / '.apkg-spec.yaml' + first_package = export_package_from_spec(spec_path, args_first) + second_package = export_package_from_spec(spec_path, args_second) + + # debug: uncomment to view for testing + # shutil.copy(first_package, './test-export-first.apkg.zip') + # shutil.copy(second_package, './test-export-second.apkg.zip') + + # import the first time, everything is new + result1 = col.import_anki_package(ImportAnkiPackageRequest( + package_path=str(first_package), + options=ImportAnkiPackageOptions( + merge_notetypes=True, + update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + with_scheduling=False, + with_deck_configs=False, + ) + )) + self.assertEqual(len(result1.log.new), 1) + self.assertEqual(len(result1.log.duplicate), 0) + + # now import again, nothing should change + result2 = col.import_anki_package(ImportAnkiPackageRequest( + package_path=str(second_package), + options=ImportAnkiPackageOptions( + merge_notetypes=True, + update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + with_scheduling=False, + with_deck_configs=False, + ) + )) + self.assertTrue( + len(result2.log.duplicate) == 1 and len(result2.log.new) == 0, + f'Expected second import to be a duplicate, but something else happened\nfirst log: {result1.log}\nsecond log:\n{result2.log}') + + def test_content_update_overwrites_previous_note(self): + """ + Tests that bumping the modification time will update notes from previous versions. + """ + with TemporaryDirectory() as temp_collection_dir: + col = Collection(str(Path(temp_collection_dir) / "test.anki2")) + with TemporaryDirectory() as first_export_dir: + args_first = MockArgs( + content=CONTENT_PATH_CSV, + templates_dir=TEMPLATES_PATH, + output_dir=first_export_dir, + dry_run=False) + with TemporaryDirectory() as second_export_dir: + args_second = MockArgs( + content=CONTENT_PATH_CSV_UPDATED_CONTENT, + templates_dir=TEMPLATES_PATH, + output_dir=second_export_dir, + dry_run=False) + spec_path_old = Path(f'{CONTENT_PATH_CSV}/.apkg-spec.yaml') + spec_path_new = Path(f'{CONTENT_PATH_CSV_UPDATED_CONTENT}/.apkg-spec.yaml') + first_package = export_package_from_spec(spec_path_old, args_first) + second_package = export_package_from_spec(spec_path_new, args_second) + + # import the old version version + result1 = col.import_anki_package(ImportAnkiPackageRequest( + package_path=str(first_package), + options=ImportAnkiPackageOptions( + merge_notetypes=True, + update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + with_scheduling=False, + with_deck_configs=False, + ) + )) + self.assertEqual(len(result1.log.updated), 0) + self.assertEqual(len(result1.log.new), 1) + self.assertEqual(len(result1.log.duplicate), 0) + + # now the update + result2 = col.import_anki_package(ImportAnkiPackageRequest( + package_path=str(second_package), + options=ImportAnkiPackageOptions( + merge_notetypes=True, + update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + with_scheduling=False, + with_deck_configs=False, + ) + )) + self.assertTrue(len(result2.log.updated) == 1 and len(result2.log.new) == 0 and len(result2.log.duplicate) == 0, + f'Expected the note to be recognized as update.\nLog1:\n{result1.log}\nLog2:\n{result2.log}') + + + def test_template_update_overwrites_previous_template_csv(self): + """ + Tests that bumping the modification time will update notes from previous versions + of CSV content. + """ + with TemporaryDirectory() as temp_collection_dir: + col = Collection(str(Path(temp_collection_dir) / "test.anki2")) + with TemporaryDirectory() as first_export_dir: + args_first = MockArgs( + content=CONTENT_PATH_CSV, + templates_dir=TEMPLATES_PATH, + output_dir=first_export_dir, + dry_run=False) + with TemporaryDirectory() as second_export_dir: + args_second = MockArgs( + content=CONTENT_PATH_CSV, + templates_dir=TEMPLATES_PATH_UPDATED, + output_dir=second_export_dir, + dry_run=False) + spec_path = Path(f'{CONTENT_PATH_CSV}/.apkg-spec.yaml') + first_package = export_package_from_spec(spec_path, args_first) + second_package = export_package_from_spec(spec_path, args_second) + + # debug: uncomment to view for testing + #shutil.copy(first_package, './test-export-first.apkg.zip') + #shutil.copy(second_package, './test-export-second.apkg.zip') + + # import the old version version + col.import_anki_package(ImportAnkiPackageRequest( + package_path=str(first_package), + options=ImportAnkiPackageOptions( + merge_notetypes=True, + update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + with_scheduling=False, + with_deck_configs=False, + ) + )) + + template = find_template(col, 'Q/A Testnotetype') + self.assertIsNotNone(template, 'template not found in collection') + self.assertEqual( + template['qfmt'], + '{{Question}}') + self.assertEqual( + template['afmt'], + '{{Answer}}') + + # now the update + result = col.import_anki_package(ImportAnkiPackageRequest( + package_path=str(second_package), + options=ImportAnkiPackageOptions( + merge_notetypes=True, + update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + with_scheduling=False, + with_deck_configs=False, + ) + )) + + # for some reason the change is only visible if we reload the collection + col.close() + col = Collection(str(Path(temp_collection_dir) / "test.anki2")) + + template = find_template(col, 'Q/A Testnotetype') + self.assertIsNotNone(template, 'template not found in collection') + self.assertEqual( + template['qfmt'], + 'Front: {{Question}}', + f'Template not updated successfully, log:\n{result.log}') + self.assertEqual( + template['afmt'], + 'Back: {{Answer}}') + + def test_template_update_overwrites_previous_template_anki(self): + """ + Tests that bumping the modification time will update notes from previous versions + of an imported anki package. + """ + with TemporaryDirectory() as temp_collection_dir: + col = Collection(str(Path(temp_collection_dir) / "test.anki2")) + with TemporaryDirectory() as first_export_dir: + args_first = MockArgs( + content=CONTENT_PATH_ANKI, + templates_dir=TEMPLATES_PATH, + output_dir=first_export_dir, + dry_run=False) + with TemporaryDirectory() as second_export_dir: + args_second = MockArgs( + content=CONTENT_PATH_ANKI, + templates_dir=TEMPLATES_PATH_UPDATED, + output_dir=second_export_dir, + dry_run=False) + spec_path = Path(f'{CONTENT_PATH_ANKI}/.apkg-spec.yaml') + first_package = export_package_from_spec(spec_path, args_first) + second_package = export_package_from_spec(spec_path, args_second) + + # debug: uncomment to view for testing + #shutil.copy(first_package, './test-export-first.apkg.zip') + #shutil.copy(second_package, './test-export-second.apkg.zip') + + # import the old version version + col.import_anki_package(ImportAnkiPackageRequest( + package_path=str(first_package), + options=ImportAnkiPackageOptions( + merge_notetypes=True, + update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + with_scheduling=False, + with_deck_configs=False, + ) + )) + + template = find_template(col, 'Q/A Testnotetype') + self.assertIsNotNone(template, 'template not found in collection') + self.assertEqual( + template['qfmt'], + '{{Question}}') + self.assertEqual( + template['afmt'], + '{{Answer}}') + + # now the update + result = col.import_anki_package(ImportAnkiPackageRequest( + package_path=str(second_package), + options=ImportAnkiPackageOptions( + merge_notetypes=True, + update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + with_scheduling=False, + with_deck_configs=False, + ) + )) + + # for some reason the change is only visible if we reload the collection + col.close() + col = Collection(str(Path(temp_collection_dir) / "test.anki2")) + + template = find_template(col, 'Q/A Testnotetype') + self.assertIsNotNone(template, 'template not found in collection') + self.assertEqual( + template['qfmt'], + 'Front: {{Question}}', + f'Template not updated successfully, log:\n{result.log}') + self.assertEqual( + template['afmt'], + 'Back: {{Answer}}') + +def find_template(col: Collection, name: str): + template = None + for name_and_id in col.models.all_names_and_ids(): + if name_and_id.name == name: + if template is None: + template = col.models.get(name_and_id.id)['tmpls'][0] + else: + raise Exception(f'Found more than one template with name {name}') + return template \ No newline at end of file -- GitLab