From 32bc9939bcdc1a53ade870c61309d551a61528f4 Mon Sep 17 00:00:00 2001 From: ereverter <38497803+ereverter@users.noreply.github.com> Date: Sat, 28 Feb 2026 13:22:25 +0100 Subject: [PATCH] tests --- .github/workflows/ci.yml | 30 ++++++++ .gitignore | 1 + pyproject.toml | 3 +- src/anki_cli/cli.py | 53 ++++++++++++--- src/anki_cli/stats.html | 2 +- tests/test_cli_export_import.py | 117 ++++++++++++++++++++++++++++++++ tests/test_cli_search.py | 27 ++++++++ uv.lock | 45 ++++++++++++ 8 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/test_cli_export_import.py create mode 100644 tests/test_cli_search.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c07b504 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: uv sync --group dev + + - name: Ruff lint + run: uv run ruff check src/ tests/ + + - name: Ruff format + run: uv run ruff format --check src/ tests/ + + - name: Tests + run: uv run pytest tests/ -v diff --git a/.gitignore b/.gitignore index 2c4d934..d6857a8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ __pycache__/ *.egg-info/ dist/ .venv/ +.ruff_cache/ .claude/* !.claude/skills/ diff --git a/pyproject.toml b/pyproject.toml index 48cd8bc..3aa55ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,10 +14,11 @@ dependencies = [ dev = [ "ruff>=0.8", "pre-commit>=4", + "pytest>=8", ] [project.scripts] -anki = "anki_cli.cli:app" +anki = "anki_cli.cli:main" [build-system] requires = ["hatchling"] diff --git a/src/anki_cli/cli.py b/src/anki_cli/cli.py index f472db6..be2f1a0 100644 --- a/src/anki_cli/cli.py +++ b/src/anki_cli/cli.py @@ -12,12 +12,23 @@ from rich.panel import Panel from rich.table import Table -from anki_cli.client import AnkiClient, AnkiError +from anki_cli.client import AnkiClient, AnkiConnectionError, AnkiError app = typer.Typer(no_args_is_help=True) console = Console() +def main() -> None: + try: + app() + except AnkiConnectionError as exc: + console.print(f"[red]{exc}[/red]") + raise SystemExit(1) from None + except AnkiError as exc: + console.print(f"[red]Anki error:[/red] {exc}") + raise SystemExit(1) from None + + def _parse_fields(raw: list[str]) -> dict[str, str]: result: dict[str, str] = {} for f in raw: @@ -91,6 +102,8 @@ def _fuzzy_score(query: str, note: dict[str, Any]) -> float: if not words: return 0.0 query_words = query.lower().split() + if not query_words: + return 0.0 return sum(max(fuzz.ratio(qw, w) for w in words) for qw in query_words) / len(query_words) @@ -130,6 +143,10 @@ def search( fuzzy: Annotated[bool, typer.Option("--fuzzy", "-f", help="Fuzzy text search")] = False, ) -> None: """Search notes using Anki query syntax.""" + if fuzzy and not query.strip(): + console.print("[red]Fuzzy search requires a non-empty query.[/red]") + raise typer.Exit(2) + client = AnkiClient() if fuzzy: @@ -341,7 +358,11 @@ def _write_json(notes: list[dict], path: Path) -> None: def _write_csv(notes: list[dict], path: Path) -> None: if not notes: return - field_names = list(notes[0]["fields"].keys()) + seen: dict[str, None] = {} + for note in notes: + for key in note["fields"]: + seen.setdefault(key, None) + field_names = list(seen) with path.open("w", newline="") as f: writer = csv.writer(f) writer.writerow(["noteId", "modelName", "tags"] + field_names) @@ -411,13 +432,26 @@ def import_( dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview without applying")] = False, ) -> None: """Import notes from JSON or CSV.""" + if not file.exists(): + console.print(f"[red]File not found:[/red] {file}") + raise typer.Exit(1) + ext = file.suffix.lower() - if ext == ".json": - notes = _read_json(file) - elif ext == ".csv": - notes = _read_csv(file) - else: - console.print(f"[red]Unsupported format:[/red] {ext} (use .json or .csv)") + try: + if ext == ".json": + notes = _read_json(file) + elif ext == ".csv": + notes = _read_csv(file) + else: + console.print(f"[red]Unsupported format:[/red] {ext} (use .json or .csv)") + raise typer.Exit(1) + except (json.JSONDecodeError, csv.Error, KeyError, ValueError) as exc: + console.print(f"[red]Failed to parse {file.name}:[/red] {exc}") + raise typer.Exit(1) from None + + has_new_notes = any(not n.get("noteId") for n in notes) + if has_new_notes and not deck: + console.print("[red]--deck required for new notes (missing noteId)[/red]") raise typer.Exit(1) client = AnkiClient() @@ -457,9 +491,6 @@ def import_( updated += 1 else: model = note.get("modelName", "Basic") - if not deck: - console.print("[red]--deck required for new notes (missing noteId)[/red]") - raise typer.Exit(1) if dry_run: console.print(f" [green]create[/green] {model} → {deck}") created += 1 diff --git a/src/anki_cli/stats.html b/src/anki_cli/stats.html index 07f85bb..6e126d3 100644 --- a/src/anki_cli/stats.html +++ b/src/anki_cli/stats.html @@ -2,7 +2,7 @@
- + diff --git a/tests/test_cli_export_import.py b/tests/test_cli_export_import.py new file mode 100644 index 0000000..fb4f565 --- /dev/null +++ b/tests/test_cli_export_import.py @@ -0,0 +1,117 @@ +"""Tests for CSV export (mixed models) and import (error handling, --deck validation).""" + +from __future__ import annotations + +import csv +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from anki_cli.cli import _write_csv, app + +runner = CliRunner() + + +class TestWriteCsvMixedModels(unittest.TestCase): + """CSV export should include the union of all field names across note models.""" + + def test_union_of_fields_in_header(self) -> None: + notes = [ + { + "noteId": 1, + "modelName": "Basic", + "tags": "", + "fields": {"Front": "q1", "Back": "a1"}, + }, + { + "noteId": 2, + "modelName": "Cloze", + "tags": "", + "fields": {"Text": "c1", "Extra": "e1"}, + }, + ] + with tempfile.NamedTemporaryFile(mode="r", suffix=".csv", delete=False) as f: + path = Path(f.name) + try: + _write_csv(notes, path) + text = path.read_text() + reader = csv.reader(io.StringIO(text)) + header = next(reader) + expected = ["noteId", "modelName", "tags", "Front", "Back", "Text", "Extra"] + self.assertEqual(header, expected) + rows = list(reader) + self.assertEqual(rows[0], ["1", "Basic", "", "q1", "a1", "", ""]) + self.assertEqual(rows[1], ["2", "Cloze", "", "", "", "c1", "e1"]) + finally: + path.unlink(missing_ok=True) + + def test_empty_notes_writes_nothing(self) -> None: + with tempfile.NamedTemporaryFile(mode="r", suffix=".csv", delete=False) as f: + path = Path(f.name) + try: + _write_csv([], path) + self.assertEqual(path.read_text(), "") + finally: + path.unlink(missing_ok=True) + + +class TestImportErrorHandling(unittest.TestCase): + """Import should produce clean errors for missing files and malformed input.""" + + def test_missing_file(self) -> None: + result = runner.invoke(app, ["import", "/nonexistent/file.json"]) + self.assertEqual(result.exit_code, 1) + self.assertIn("File not found", result.stdout) + + def test_malformed_json(self) -> None: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("{not valid json!!") + path = f.name + try: + result = runner.invoke(app, ["import", path]) + self.assertEqual(result.exit_code, 1) + self.assertIn("Failed to parse", result.stdout) + finally: + Path(path).unlink(missing_ok=True) + + +class TestImportDeckValidation(unittest.TestCase): + """--deck check should fire before any AnkiClient calls.""" + + def test_fails_before_api_calls(self) -> None: + notes = [{"fields": {"Front": "q", "Back": "a"}, "modelName": "Basic"}] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(notes, f) + path = f.name + try: + with patch("anki_cli.cli.AnkiClient") as mock_client: + result = runner.invoke(app, ["import", path]) + self.assertEqual(result.exit_code, 1) + self.assertIn("--deck required", result.stdout) + mock_client.assert_not_called() + finally: + Path(path).unlink(missing_ok=True) + + def test_passes_with_deck_and_existing_notes(self) -> None: + """Notes with noteId should not require --deck.""" + notes = [{"noteId": 123, "fields": {"Front": "q"}, "modelName": "Basic", "tags": ""}] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(notes, f) + path = f.name + try: + with patch("anki_cli.cli.AnkiClient") as mock_client: + mock_instance = mock_client.return_value + mock_instance.notes_info.return_value = [{"noteId": 123, "tags": []}] + result = runner.invoke(app, ["import", path]) + self.assertEqual(result.exit_code, 0) + finally: + Path(path).unlink(missing_ok=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_search.py b/tests/test_cli_search.py new file mode 100644 index 0000000..2850bb0 --- /dev/null +++ b/tests/test_cli_search.py @@ -0,0 +1,27 @@ +import unittest + +from typer.testing import CliRunner + +from anki_cli.cli import _fuzzy_score, app + +runner = CliRunner() + + +class SearchCommandTests(unittest.TestCase): + def test_empty_fuzzy_query_fails_cleanly(self) -> None: + result = runner.invoke(app, ["search", "", "--fuzzy", "--limit", "1"]) + self.assertEqual(result.exit_code, 2) + self.assertIn("Fuzzy search requires a non-empty query.", result.stdout) + + def test_fuzzy_score_empty_query_returns_zero(self) -> None: + note = { + "fields": { + "Front": {"value": "What is Python?"}, + "Back": {"value": "A programming language"}, + } + } + self.assertEqual(_fuzzy_score("", note), 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 33d0c0f..cde3310 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pre-commit" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -30,6 +31,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pre-commit", specifier = ">=4" }, + { name = "pytest", specifier = ">=8" }, { name = "ruff", specifier = ">=0.8" }, ] @@ -167,6 +169,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -197,6 +208,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + [[package]] name = "platformdirs" version = "4.9.2" @@ -206,6 +226,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pre-commit" version = "4.5.1" @@ -231,6 +260,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-discovery" version = "1.1.0"