mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 12:30:59 +01:00
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
153 lines
4.6 KiB
Python
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 & 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
|