mirror of https://github.com/razor-ai/soup.git
fix(ci): ANSI-robust serve help test + restore 77% coverage gate (v0.71.1)
The v0.71.1 release commit (514761c) went red on CI for two reasons:
- test_flag_in_help asserted a raw "--record-thumbs" substring, but Rich
splits an option name's dashes with ANSI codes under CI's FORCE_COLOR
(it passes locally without color). Strip ANSI before the substring check.
- Coverage fell to 76.96% (< 77% gate): CI installs [dev], which has no
FastAPI, so the new /v1/thumbs endpoint + record-thumbs startup block in
serve.py are uncovered there. Restore the gate honestly (no lowering, no
pragma) by adding 19 genuine no-FastAPI tests for previously-uncovered
pure-CLI paths: lock show / lock check (no-drift / drift exit 3 / missing),
env check (no-drift / missing / drift exit 3), env fix error branches,
env lock null-byte output, and load_evidence_file (the
`eval unlearning --evidence` loader).
CI-equivalent (no-fastapi) coverage: 76.96% -> 77.24%. Tests: 12134 -> 12153.
This commit is contained in:
parent
514761c89a
commit
0ee5f78986
|
|
@ -116,7 +116,7 @@ src/soup_cli/
|
|||
templates/ - 17 built-in soup.yaml templates (YAML + manifest.json) with load_template loader (v0.39.0, +bco v0.40.0)
|
||||
ui/ - Web UI (FastAPI + HTML/JS SPA)
|
||||
|
||||
tests/ - Test suite (271 files, 12134 tests)
|
||||
tests/ - Test suite (271 files, 12153 tests)
|
||||
examples/ - Real-world config examples and datasets
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -279,13 +279,18 @@ class TestRecordThumbs:
|
|||
return TestClient(app), db
|
||||
|
||||
def test_flag_in_help(self):
|
||||
import re
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from soup_cli.cli import app
|
||||
|
||||
result = CliRunner().invoke(app, ["serve", "--help"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--record-thumbs" in result.output
|
||||
# Under color (CI sets FORCE_COLOR), Rich inserts ANSI codes between the
|
||||
# two dashes of an option name, so strip them before the substring check.
|
||||
clean = re.sub(r"\x1b\[[0-9;]*m", "", result.output)
|
||||
assert "--record-thumbs" in clean
|
||||
|
||||
def test_thumbs_endpoint_registered_when_db_set(self, tmp_path, monkeypatch):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ from __future__ import annotations
|
|||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -507,6 +509,80 @@ class TestCli:
|
|||
# ---------- Fixtures ----------
|
||||
|
||||
|
||||
class TestLoadEvidenceFile:
|
||||
"""v0.71.1 — cover the `soup eval unlearning --evidence` loader."""
|
||||
|
||||
def test_happy_returns_dict(self, tmp_path, monkeypatch):
|
||||
from soup_cli.utils.unlearning_eval import load_evidence_file
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "ev.json").write_text(
|
||||
json.dumps({"forget_quality": {"pre_loss": 1.0, "post_loss": 2.0}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = load_evidence_file("ev.json")
|
||||
assert isinstance(data, dict)
|
||||
assert "forget_quality" in data
|
||||
|
||||
def test_missing_file_raises(self, tmp_path, monkeypatch):
|
||||
from soup_cli.utils.unlearning_eval import load_evidence_file
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_evidence_file("nope.json")
|
||||
|
||||
def test_non_string_path_rejected(self):
|
||||
from soup_cli.utils.unlearning_eval import load_evidence_file
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
load_evidence_file(123) # type: ignore[arg-type]
|
||||
|
||||
def test_empty_path_rejected(self):
|
||||
from soup_cli.utils.unlearning_eval import load_evidence_file
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
load_evidence_file("")
|
||||
|
||||
def test_null_byte_rejected(self):
|
||||
from soup_cli.utils.unlearning_eval import load_evidence_file
|
||||
|
||||
with pytest.raises(ValueError, match="null"):
|
||||
load_evidence_file("a\x00b.json")
|
||||
|
||||
def test_outside_cwd_rejected(self, tmp_path, monkeypatch):
|
||||
from soup_cli.utils.unlearning_eval import load_evidence_file
|
||||
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "ev.json").write_text("{}", encoding="utf-8")
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
monkeypatch.chdir(sub)
|
||||
with pytest.raises(ValueError, match="cwd"):
|
||||
load_evidence_file(str(outside / "ev.json"))
|
||||
|
||||
def test_non_dict_root_rejected(self, tmp_path, monkeypatch):
|
||||
from soup_cli.utils.unlearning_eval import load_evidence_file
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "arr.json").write_text("[]", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="JSON object"):
|
||||
load_evidence_file("arr.json")
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="symlink creation needs admin on Windows"
|
||||
)
|
||||
def test_symlink_rejected(self, tmp_path, monkeypatch):
|
||||
from soup_cli.utils.unlearning_eval import load_evidence_file
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "real.json").write_text("{}", encoding="utf-8")
|
||||
link = tmp_path / "link.json"
|
||||
os.symlink(tmp_path / "real.json", link)
|
||||
with pytest.raises(ValueError, match="symlink"):
|
||||
load_evidence_file("link.json")
|
||||
|
||||
|
||||
class TestFixtures:
|
||||
def test_tofu_fixture_exists(self):
|
||||
from soup_cli.utils.unlearning_eval import get_fixture_path
|
||||
|
|
|
|||
|
|
@ -662,3 +662,62 @@ def test_render_install_plan_requirements_conda_comment():
|
|||
# line rather than a bare `name==version` pip pin (v0.71.1 #209).
|
||||
plan = render_install_plan(_lock_with_conda(), fmt="requirements")
|
||||
assert "# conda: mkl==2023.1" in plan
|
||||
|
||||
|
||||
def test_cli_env_fix_corrupt_lock(tmp_path, monkeypatch):
|
||||
from soup_cli.cli import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "soup-env.lock").write_text("{ this is not valid json", encoding="utf-8")
|
||||
result = runner.invoke(app, ["env", "fix"])
|
||||
assert result.exit_code == 2, result.output
|
||||
|
||||
|
||||
def test_cli_env_fix_bad_format(tmp_path, monkeypatch):
|
||||
from soup_cli.cli import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert runner.invoke(app, ["env", "lock"]).exit_code == 0
|
||||
result = runner.invoke(app, ["env", "fix", "--format", "bogus-format"])
|
||||
assert result.exit_code == 2, result.output
|
||||
|
||||
|
||||
def test_cli_env_check_no_drift(tmp_path, monkeypatch):
|
||||
from soup_cli.cli import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert runner.invoke(app, ["env", "lock"]).exit_code == 0
|
||||
# Nothing changed between snapshots, so the env is ABI-clean.
|
||||
result = runner.invoke(app, ["env", "check"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "ABI-clean" in result.output
|
||||
|
||||
|
||||
def test_cli_env_check_missing_lock(tmp_path, monkeypatch):
|
||||
from soup_cli.cli import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["env", "check"])
|
||||
assert result.exit_code == 1
|
||||
assert "soup env lock" in result.output
|
||||
|
||||
|
||||
def test_cli_env_check_drift_exits_3(tmp_path, monkeypatch):
|
||||
from soup_cli.cli import app
|
||||
from soup_cli.utils.env_lock import snapshot_env, write_lock
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# Write a lock that claims a different Python version → ABI drift on check.
|
||||
env = snapshot_env()
|
||||
drifted = dataclasses.replace(env, python_version="2.0.0")
|
||||
write_lock(drifted, "soup-env.lock")
|
||||
result = runner.invoke(app, ["env", "check"])
|
||||
assert result.exit_code == 3, result.output
|
||||
|
||||
|
||||
def test_cli_env_lock_null_byte_output_rejected(tmp_path, monkeypatch):
|
||||
from soup_cli.cli import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["env", "lock", "--output", "a\x00b"])
|
||||
assert result.exit_code == 2, result.output
|
||||
|
|
|
|||
|
|
@ -400,6 +400,104 @@ class TestCliSmoke:
|
|||
written = json.loads((tmp_path / "soup.lock").read_text(encoding="utf-8"))
|
||||
assert written["env_hash"] == "c" * 64
|
||||
|
||||
def _write_lock(self, runner, tmp_path) -> None:
|
||||
from soup_cli.commands.lock import app
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"write",
|
||||
"--base-model", "test-model",
|
||||
"--base-sha", "a" * 64,
|
||||
"--dataset-sha", "b" * 64,
|
||||
"--env-hash", "c" * 64,
|
||||
"--output", "soup.lock",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, (result.output, repr(result.exception))
|
||||
|
||||
def test_lock_show_command(self, tmp_path, monkeypatch) -> None:
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from soup_cli.commands.lock import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
self._write_lock(runner, tmp_path)
|
||||
result = runner.invoke(app, ["show", "soup.lock"])
|
||||
assert result.exit_code == 0, (result.output, repr(result.exception))
|
||||
assert "test-model" in result.output
|
||||
|
||||
def test_lock_show_missing_file(self, tmp_path, monkeypatch) -> None:
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from soup_cli.commands.lock import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = CliRunner().invoke(app, ["show", "nonexistent.lock"])
|
||||
assert result.exit_code == 2
|
||||
|
||||
def test_lock_check_no_drift(self, tmp_path, monkeypatch) -> None:
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from soup_cli.commands.lock import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
self._write_lock(runner, tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"check", "soup.lock",
|
||||
"--base-model", "test-model",
|
||||
"--base-sha", "a" * 64,
|
||||
"--dataset-sha", "b" * 64,
|
||||
"--env-hash", "c" * 64,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, (result.output, repr(result.exception))
|
||||
assert "OK" in result.output
|
||||
|
||||
def test_lock_check_drift_exits_3(self, tmp_path, monkeypatch) -> None:
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from soup_cli.commands.lock import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
self._write_lock(runner, tmp_path)
|
||||
# A changed base-sha drifts both base_model_sha and the closure.
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"check", "soup.lock",
|
||||
"--base-model", "test-model",
|
||||
"--base-sha", "d" * 64,
|
||||
"--dataset-sha", "b" * 64,
|
||||
"--env-hash", "c" * 64,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 3
|
||||
assert "DRIFT" in result.output
|
||||
|
||||
def test_lock_check_missing_file(self, tmp_path, monkeypatch) -> None:
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from soup_cli.commands.lock import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"check", "nonexistent.lock",
|
||||
"--base-model", "test-model",
|
||||
"--base-sha", "a" * 64,
|
||||
"--dataset-sha", "b" * 64,
|
||||
"--env-hash", "c" * 64,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 2
|
||||
|
||||
|
||||
class TestSourceWiring:
|
||||
def test_no_top_level_heavy_imports(self) -> None:
|
||||
|
|
|
|||
Loading…
Reference in New Issue