mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-15 02:50:57 +01:00
The REPL passes os.environ to `claude -p`, so a PYTHONPATH set by the launcher reached every hook and every Bash call of the coached session. The old launcher did not do that. The launcher now runs `/usr/bin/python3 -P -m leetcode_coach.leetcode_repl` and relies on install.sh's editable install, the same way the Stop-hook shim does. A test now pins that the launcher sets no PYTHONPATH. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
185 lines
6.8 KiB
Python
185 lines
6.8 KiB
Python
"""Tests for the print-mode /leetcode REPL and its hand-over to the local model."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from leetcode_coach import leetcode_repl, local_session
|
|
|
|
LAUNCHER = Path(leetcode_repl.__file__).with_name("leetcode")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("reply", "hit"),
|
|
[
|
|
(
|
|
{"result": "You've hit your session limit · resets 3pm (Europe/Warsaw)"},
|
|
True,
|
|
),
|
|
({"result": "You\u2019ve hit your weekly limit · resets Mon"}, True),
|
|
({"result": "", "api_error_status": 429}, True),
|
|
({"result": "x", "_stderr": "Claude AI usage limit reached|1790427600"}, True),
|
|
({"result": "Does your loop hit every element?"}, False),
|
|
],
|
|
)
|
|
def test_is_limit_hit(reply: dict[str, Any], *, hit: bool) -> None:
|
|
assert leetcode_repl.is_limit_hit(reply) is hit
|
|
|
|
|
|
def _fake_run(handed: dict[str, Any]) -> Any:
|
|
def fake_run(
|
|
problem: str, history: list[dict[str, str]], first: str, _r: object
|
|
) -> int:
|
|
handed.update(problem=problem, history=history, first=first)
|
|
return 0
|
|
|
|
return fake_run
|
|
|
|
|
|
def test_limit_mid_session_falls_back_with_history(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
replies = iter(
|
|
[
|
|
{"result": "Restated. What feels unclear?", "session_id": "s1"},
|
|
{"result": "You've hit your session limit · resets 8pm (Europe/Warsaw)"},
|
|
]
|
|
)
|
|
monkeypatch.setattr(leetcode_repl, "ask", lambda *_a: next(replies))
|
|
monkeypatch.setattr(leetcode_repl, "read_block", lambda: "the hash map part")
|
|
handed: dict[str, Any] = {}
|
|
monkeypatch.setattr(local_session, "run", _fake_run(handed))
|
|
assert leetcode_repl.main(["two-sum", "maps"]) == 0
|
|
assert handed["problem"] == "two-sum"
|
|
assert handed["first"] == "the hash map part"
|
|
assert handed["history"][1]["content"] == "Restated. What feels unclear?"
|
|
assert capsys.readouterr().out == (
|
|
"Restated. What feels unclear?\n\n...\n"
|
|
"[You've hit your session limit · resets 8pm (Europe/Warsaw) "
|
|
"-- continuing locally]\n"
|
|
)
|
|
|
|
|
|
def test_limit_on_the_first_turn_starts_locally(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
monkeypatch.setattr(leetcode_repl, "ask", lambda *_a: {"api_error_status": 429})
|
|
handed: dict[str, Any] = {}
|
|
monkeypatch.setattr(local_session, "run", _fake_run(handed))
|
|
assert leetcode_repl.run_claude("sonnet", ["two-sum"]) == 0
|
|
assert handed["history"] == []
|
|
assert "not said" in handed["first"]
|
|
assert capsys.readouterr().out == "[usage limit -- switching to local model]\n"
|
|
|
|
|
|
def test_session_loop_until_quit_and_missing_session(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
sent: list[tuple[list[str], str]] = []
|
|
replies = iter([{"result": "Open?", "session_id": "s1"}, {"result": "Next?"}])
|
|
|
|
def ask(args: list[str], prompt: str) -> dict[str, Any]:
|
|
sent.append((args, prompt))
|
|
return next(replies)
|
|
|
|
blocks = iter(["my try", "quit"])
|
|
monkeypatch.setattr(leetcode_repl, "ask", ask)
|
|
monkeypatch.setattr(leetcode_repl, "read_block", lambda: next(blocks))
|
|
assert leetcode_repl.run_claude("sonnet", ["two-sum", "maps"]) == 0
|
|
assert sent == [
|
|
(["--model", "sonnet"], "/leetcode two-sum maps"),
|
|
(["--model", "sonnet", "--resume", "s1"], "my try"),
|
|
]
|
|
assert capsys.readouterr().out == "Open?\n\n...\nNext?\n"
|
|
monkeypatch.setattr(leetcode_repl, "ask", lambda *_a: {"result": "no id"})
|
|
assert leetcode_repl.run_claude("sonnet", ["two-sum"]) == 1
|
|
|
|
|
|
def test_read_block_joins_lines_until_a_blank(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
lines = iter(["", "a", "b", ""])
|
|
monkeypatch.setattr("builtins.input", lambda: next(lines))
|
|
assert leetcode_repl.read_block() == "a\nb"
|
|
assert capsys.readouterr().out == "\nyou> "
|
|
|
|
def eof() -> str:
|
|
raise EOFError
|
|
|
|
monkeypatch.setattr("builtins.input", eof)
|
|
assert leetcode_repl.read_block() is None
|
|
typed = iter(["x"])
|
|
|
|
def then_eof() -> str:
|
|
try:
|
|
return next(typed)
|
|
except StopIteration:
|
|
raise EOFError from None
|
|
|
|
monkeypatch.setattr("builtins.input", then_eof)
|
|
assert leetcode_repl.read_block() == "x"
|
|
|
|
|
|
def test_ask_parses_a_failing_claude_stub(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
stub = tmp_path / "claude"
|
|
body = json.dumps({"is_error": True, "result": "You've hit your session limit"})
|
|
(tmp_path / "out.json").write_text(body)
|
|
stub.write_text(f"#!/bin/sh\ncat {tmp_path / 'out.json'}\necho boom >&2\nexit 1\n")
|
|
stub.chmod(0o755)
|
|
monkeypatch.setattr(leetcode_repl, "CLAUDE", str(stub))
|
|
reply = leetcode_repl.ask([], "hi")
|
|
assert leetcode_repl.is_limit_hit(reply)
|
|
assert reply["_stderr"] == "boom"
|
|
stub.write_text("#!/bin/sh\necho not-json\nexit 3\n")
|
|
assert leetcode_repl.ask([], "hi")["result"].startswith("[claude exited 3]")
|
|
stub.write_text("#!/bin/sh\necho '[1]'\n")
|
|
assert leetcode_repl.ask([], "hi") == {"_stderr": ""}
|
|
|
|
|
|
def test_repl_defaults_to_sonnet(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
seen: list[str] = []
|
|
monkeypatch.delenv("LEETCODE_MODEL", raising=False)
|
|
monkeypatch.setattr(leetcode_repl, "run_claude", lambda m, _a: seen.append(m))
|
|
leetcode_repl.main(["two-sum"])
|
|
assert seen == ["sonnet"]
|
|
|
|
|
|
def test_local_flag_skips_claude(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
handed: dict[str, Any] = {}
|
|
monkeypatch.setattr(local_session, "run", _fake_run(handed))
|
|
assert leetcode_repl.main(["--local", "two-sum", "loops"]) == 0
|
|
assert handed["problem"] == "two-sum"
|
|
assert handed["first"].endswith("stuck on: loops")
|
|
|
|
|
|
def test_help_prints_usage(capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert leetcode_repl.main([]) == 0
|
|
assert capsys.readouterr().out.startswith(
|
|
"Usage: leetcode [--local] <url-or-slug> [what you are stuck on...]\n"
|
|
)
|
|
|
|
|
|
def test_launcher_runs_the_package_repl() -> None:
|
|
# The real launcher finds the package through install.sh's editable
|
|
# install; PYTHONPATH stands in for it here (-P still honours it).
|
|
env = {**os.environ, "PYTHONPATH": str(LAUNCHER.resolve().parents[1])}
|
|
proc = subprocess.run(
|
|
[str(LAUNCHER), "--help"], capture_output=True, text=True, check=False, env=env
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert proc.stdout.startswith("Usage: leetcode [--local]")
|
|
|
|
|
|
def test_launcher_does_not_leak_pythonpath_into_the_session() -> None:
|
|
"""The REPL hands its environment to `claude -p`; the launcher adds nothing."""
|
|
body = LAUNCHER.read_text(encoding="utf-8").split("set -euo pipefail", 1)[1]
|
|
assert "PYTHONPATH" not in body
|