utils/leetcode_coach/leetcode_coach/tests/test_anki_connect.py
Krzysztof kuhy Rudnicki b730cffaf4 Move the /leetcode coach skill into the repo as leetcode_coach/
Phase A of TODO-coach.md (decisions 1-3). The skill code is now a package here
with the same behaviour as before. Nothing new is added. ~/.claude/skills/leetcode
and ~/.local/bin/leetcode become symlinks into the package, created by
scripts/link_leetcode_coach.sh, which install.sh runs.

- Package imports replace the bare sibling imports. To stay under the 250-line
  cap, the Stop-hook detector is split into gate_detector.py and stop_gate.py,
  the card rules move into card_rules.py, and the prompt text moves into
  local_prompt.py.
- local_coach uses gate_detector directly. It no longer loads the hook file
  with importlib.
- make_cards runs on the system python: a genanki pin (pyproject and
  requirements) replaces the `uv run --script` header. The note guid is
  passed to genanki.Note explicitly, so CardNote is no longer subclassed;
  the guid values are unchanged.
- stop_gate also accepts the package path as a skill-load marker, because the
  harness may print the resolved symlink path.
- Every swallowing except now logs a warning. A NullHandler on the package
  logger keeps stdout/stderr the same as before.
- Modules whose prints are their interface (REPL, local session, card builder,
  fetcher, eval, Stop hook) get T201 per-file ignores, and their tests assert
  on stdout.
- hook_shims/ holds the 3-line ~/.claude/hooks shim. It is excluded from
  ruff, mypy, pylint and coverage, and a test runs it end to end.
- Pre-commit: a requirements-sync entry for each package, leetcode_coach and
  genanki added to the pylint hook, and the name-tests-test exclude widened.
  coverage and ruff are bumped to the latest stable releases because the
  dependency-freshness gate requires it.

Verification: 111 coach tests pass with 100% branch coverage. ruff, mypy
strict and pylint are clean (10.00). On the 370 assistant replies in 39 real
/leetcode transcripts, the old hook and stop_gate give the same verdict
(0 mismatches, 20 blocked). fetch_problem output (live two-sum) and
make_cards --dry-run output are byte-identical to the old skill, and so are
the note guids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 15:06:40 +02:00

153 lines
4.6 KiB
Python

"""Tests for the AnkiConnect side of the /leetcode card builder."""
from __future__ import annotations
import io
import json
from typing import Any, Self
import urllib.error
import urllib.request
import pytest
from leetcode_coach import anki_connect, card_rules
def _card(front: str, back: str = "x") -> dict[str, str]:
return {"front": front, "back": back}
class FakeAnki:
"""Minimal in-memory AnkiConnect: notes keyed by id."""
def __init__(self, notes: dict[int, tuple[str, str]]) -> None:
self.notes = {i: {"Front": f, "Back": b} for i, (f, b) in notes.items()}
self.deleted: list[int] = []
self.next_id = 100
def __call__(self, action: str, **p: Any) -> Any:
if action == "findNotes":
return list(self.notes)
if action == "notesInfo":
return [
{
"noteId": i,
"fields": {k: {"value": v} for k, v in self.notes[i].items()},
}
for i in p["notes"]
]
if action == "updateNoteFields":
self.notes[p["note"]["id"]].update(p["note"]["fields"])
elif action == "addNote":
self.next_id += 1
self.notes[self.next_id] = dict(p["note"]["fields"])
return self.next_id
elif action == "deleteNotes":
self.deleted += p["notes"]
for i in p["notes"]:
del self.notes[i]
elif action == "findCards":
return [1]
return None
def test_sync_notes_replace_keeps_matching_front_and_deletes_the_rest(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake = FakeAnki({1: ("n & m?", "old"), 2: ("dropped?", "gone")})
monkeypatch.setattr(anki_connect, "_call", fake)
cards = card_rules.escaped([_card("n & m?", "new"), _card("added?", "a")])
result = anki_connect.sync_notes("D", "M", "s", cards, replace=True)
assert result == (1, 1, 1)
assert fake.deleted == [2]
assert fake.notes[1] == {"Front": "n &amp; m?", "Back": "new"}
def test_sync_notes_without_replace_deletes_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake = FakeAnki({2: ("dropped?", "gone")})
monkeypatch.setattr(anki_connect, "_call", fake)
assert anki_connect.sync_notes("D", "M", "s", [_card("a?")], replace=False) == (
1,
0,
0,
)
assert fake.deleted == []
def test_sync_notes_on_an_empty_problem_skips_notes_info(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake = FakeAnki({})
monkeypatch.setattr(anki_connect, "_call", fake)
assert anki_connect.sync_notes("D", "M", "s", [_card("a?")], replace=True) == (
1,
0,
0,
)
class FakeResponse(io.BytesIO):
def __enter__(self) -> Self:
return self
def __exit__(self, *exc: object) -> None:
self.close()
def _serve(
monkeypatch: pytest.MonkeyPatch, replies: dict[str, Any]
) -> list[dict[str, Any]]:
sent: list[dict[str, Any]] = []
def urlopen(req: Any, timeout: int) -> FakeResponse:
body = json.loads(req.data)
sent.append(body)
reply = replies[body["action"]]
if isinstance(reply, Exception):
raise reply
return FakeResponse(json.dumps(reply).encode())
monkeypatch.setattr(urllib.request, "urlopen", urlopen)
return sent
def test_call_available_ensure_model_and_sync(monkeypatch: pytest.MonkeyPatch) -> None:
sent = _serve(
monkeypatch,
{
"version": {"result": 6, "error": None},
"modelNames": {"result": ["Basic"], "error": None},
"createModel": {"result": {}, "error": None},
"sync": {"result": None, "error": None},
},
)
assert anki_connect.available() is True
anki_connect.ensure_model("M", ["F"], "q", "a", "css")
anki_connect.sync()
assert [b["action"] for b in sent] == [
"version",
"modelNames",
"createModel",
"sync",
]
def test_ensure_model_skips_existing_and_errors_surface(
monkeypatch: pytest.MonkeyPatch,
) -> None:
sent = _serve(
monkeypatch,
{
"modelNames": {"result": ["M"], "error": None},
"deleteNotes": {"result": None, "error": "boom"},
"version": urllib.error.URLError("refused"),
},
)
anki_connect.ensure_model("M", ["F"], "q", "a", "css")
assert [b["action"] for b in sent] == ["modelNames"]
with pytest.raises(anki_connect.AnkiConnectError, match="deleteNotes: boom"):
anki_connect._call("deleteNotes", notes=[1])
assert anki_connect.available() is False