| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| """Tests for ``skill_evals.runner``.""" |
| |
| from __future__ import annotations |
| |
| import json |
| import shlex |
| import textwrap |
| from pathlib import Path |
| |
| import pytest |
| |
| from skill_evals.runner import ( |
| DEFAULT_GRADER_CLI, |
| DEFAULT_PROSE_FIELDS, |
| batch_grade_prose_fields, |
| batch_judge_assertions, |
| build_corpus_text, |
| build_roster_text, |
| collect_diffs, |
| collect_tag_counts, |
| compare_outputs, |
| compare_structural, |
| compare_with_grader, |
| evaluate_deterministic_assertion, |
| extract_json_from_output, |
| extract_skill_section, |
| find_cases, |
| find_repo_root, |
| grade_prose_field, |
| is_structural_expected, |
| load_assertions, |
| load_case, |
| load_case_tags, |
| load_grading_schema, |
| load_step_config, |
| main, |
| run_cli, |
| ) |
| |
| _TESTS_DIR = Path(__file__).resolve().parent |
| _GRADER_YES = f"python3 {_TESTS_DIR / '_grader_yes.py'}" |
| _GRADER_NO = f"python3 {_TESTS_DIR / '_grader_no.py'}" |
| _JUDGE_YES = f"python3 {_TESTS_DIR / '_judge_yes.py'}" |
| _JUDGE_NO = f"python3 {_TESTS_DIR / '_judge_no.py'}" |
| |
| |
| def _grader_count_cli(counter_path: Path) -> str: |
| """Return a grader-cli string that records each call to ``counter_path``. |
| |
| Wrapped in ``bash -c`` so the env-var prefix is honoured: ``run_cli`` now |
| uses ``shell=False`` with ``shlex.split``, which would otherwise treat |
| ``GRADER_COUNTER_FILE=...`` as a literal argv[0] binary name. |
| """ |
| script = _TESTS_DIR / "_grader_count.py" |
| inner = f"GRADER_COUNTER_FILE={shlex.quote(str(counter_path))} python3 {shlex.quote(str(script))}" |
| return f"bash -c {shlex.quote(inner)}" |
| |
| |
| def _count_grader_calls(counter_path: Path) -> int: |
| if not counter_path.exists(): |
| return 0 |
| return sum(1 for _ in counter_path.read_text().splitlines() if _) |
| |
| |
| # --------------------------------------------------------------------------- |
| # Helpers |
| # --------------------------------------------------------------------------- |
| |
| |
| def _make_repo(tmp_path: Path) -> Path: |
| """Return a directory that looks like a git repo root.""" |
| (tmp_path / ".git").mkdir() |
| return tmp_path |
| |
| |
| def _make_fixtures_dir( |
| parent: Path, |
| *, |
| step_config: dict | None = None, |
| system_prompt: str | None = None, |
| output_spec: str | None = None, |
| user_prompt_template: str | None = None, |
| ) -> Path: |
| fixtures_dir = parent / "fixtures" |
| fixtures_dir.mkdir(parents=True, exist_ok=True) |
| if step_config is not None: |
| (fixtures_dir / "step-config.json").write_text(json.dumps(step_config)) |
| if system_prompt is not None: |
| (fixtures_dir / "system-prompt.md").write_text(system_prompt) |
| if output_spec is not None: |
| (fixtures_dir / "output-spec.md").write_text(output_spec) |
| if user_prompt_template is not None: |
| (fixtures_dir / "user-prompt-template.md").write_text(user_prompt_template) |
| return fixtures_dir |
| |
| |
| def _make_case( |
| fixtures_dir: Path, name: str, *, report: str = "report text", expected: dict | None = None |
| ) -> Path: |
| case_dir = fixtures_dir / name |
| case_dir.mkdir(parents=True, exist_ok=True) |
| (case_dir / "report.md").write_text(report) |
| (case_dir / "expected.json").write_text(json.dumps(expected or {"verdict": "ok"})) |
| return case_dir |
| |
| |
| def _run_main(capsys: pytest.CaptureFixture[str], argv: list[str]) -> tuple[int, str, str]: |
| rc = main(argv) |
| captured = capsys.readouterr() |
| return rc, captured.out, captured.err |
| |
| |
| # --------------------------------------------------------------------------- |
| # build_corpus_text |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_build_corpus_text_empty(): |
| assert build_corpus_text([]) == "" |
| |
| |
| def test_build_corpus_text_single_item(): |
| result = build_corpus_text([{"number": 42, "title": "A bug", "body": "Details here"}]) |
| assert "#42 | 'A bug'" in result |
| assert "Details here" in result |
| |
| |
| def test_build_corpus_text_multiple_items(): |
| corpus = [ |
| {"number": 1, "title": "First", "body": "Body one"}, |
| {"number": 2, "title": "Second", "body": "Body two"}, |
| ] |
| result = build_corpus_text(corpus) |
| assert "#1 | 'First'" in result |
| assert "#2 | 'Second'" in result |
| # Items separated by blank lines |
| assert result.count("\n\n") >= 1 |
| |
| |
| def test_build_corpus_text_repr_escapes_quotes(): |
| result = build_corpus_text([{"number": 1, "title": 'She said "hi"', "body": "x"}]) |
| # title is repr'd so quotes are escaped |
| assert "'She said" in result or '"She said' in result |
| |
| |
| def test_build_corpus_text_passes_full_body_without_truncation_claim(): |
| # Regression: the label used to say "first 300 chars" while passing the |
| # full body — misleading the model into under-reading long trackers and |
| # dropping credit/thread lines that fall past char 300. |
| long_body = "start " + ("x" * 600) + " REPORTER_CREDIT_AT_END" |
| result = build_corpus_text([{"number": 7, "title": "T", "body": long_body}]) |
| assert "REPORTER_CREDIT_AT_END" in result # full body present |
| assert "300 chars" not in result # no false truncation claim |
| |
| |
| # --------------------------------------------------------------------------- |
| # build_roster_text |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_build_roster_text_empty(): |
| assert build_roster_text({}) == "(none)" |
| |
| |
| def test_build_roster_text_single_entry(): |
| result = build_roster_text({"99": "user@example.com"}) |
| assert result == "#99: user@example.com" |
| |
| |
| def test_build_roster_text_multiple_entries(): |
| result = build_roster_text({"1": "a@b.com", "2": "c@d.com"}) |
| lines = result.splitlines() |
| assert len(lines) == 2 |
| assert "#1: a@b.com" in lines |
| assert "#2: c@d.com" in lines |
| |
| |
| # --------------------------------------------------------------------------- |
| # find_repo_root |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_find_repo_root_from_child_directory(tmp_path: Path): |
| _make_repo(tmp_path) |
| child = tmp_path / "a" / "b" / "c" |
| child.mkdir(parents=True) |
| assert find_repo_root(child) == tmp_path |
| |
| |
| def test_find_repo_root_from_repo_root_itself(tmp_path: Path): |
| _make_repo(tmp_path) |
| assert find_repo_root(tmp_path) == tmp_path |
| |
| |
| def test_find_repo_root_raises_when_no_git(tmp_path: Path): |
| child = tmp_path / "orphan" |
| child.mkdir() |
| with pytest.raises(RuntimeError, match=r"\.git"): |
| find_repo_root(child) |
| |
| |
| # --------------------------------------------------------------------------- |
| # extract_skill_section |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_extract_skill_section_returns_until_next_same_level_heading(tmp_path: Path): |
| skill_md = tmp_path / "SKILL.md" |
| skill_md.write_text( |
| textwrap.dedent("""\ |
| ## Step 1 |
| |
| Content for step 1. |
| |
| ## Step 2 |
| |
| Content for step 2. |
| """) |
| ) |
| result = extract_skill_section(skill_md, "## Step 1") |
| assert "Content for step 1" in result |
| assert "Step 2" not in result |
| |
| |
| def test_extract_skill_section_stops_at_higher_level_heading(tmp_path: Path): |
| skill_md = tmp_path / "SKILL.md" |
| skill_md.write_text( |
| textwrap.dedent("""\ |
| ### Sub-step A |
| |
| Content A. |
| |
| ## Parent heading |
| |
| Other content. |
| """) |
| ) |
| result = extract_skill_section(skill_md, "### Sub-step A") |
| assert "Content A" in result |
| assert "Parent heading" not in result |
| |
| |
| def test_extract_skill_section_returns_rest_of_file_when_last_heading(tmp_path: Path): |
| skill_md = tmp_path / "SKILL.md" |
| skill_md.write_text( |
| textwrap.dedent("""\ |
| ## Only Section |
| |
| Everything here belongs to this section. |
| No more headings after this. |
| """) |
| ) |
| result = extract_skill_section(skill_md, "## Only Section") |
| assert "Everything here" in result |
| assert "No more headings" in result |
| |
| |
| def test_extract_skill_section_ignores_heading_inside_code_fence(tmp_path: Path): |
| skill_md = tmp_path / "SKILL.md" |
| skill_md.write_text( |
| textwrap.dedent("""\ |
| ## Real Section |
| |
| Some intro. |
| |
| ``` |
| ## This looks like a heading but is in a fence |
| code here |
| ``` |
| |
| More real content. |
| |
| ## Next Section |
| |
| Should not appear. |
| """) |
| ) |
| result = extract_skill_section(skill_md, "## Real Section") |
| assert "More real content" in result |
| assert "Next Section" not in result |
| |
| |
| def test_extract_skill_section_raises_on_missing_heading(tmp_path: Path): |
| skill_md = tmp_path / "SKILL.md" |
| skill_md.write_text("## Existing\n\nContent.\n") |
| with pytest.raises(ValueError, match="not found"): |
| extract_skill_section(skill_md, "## Missing Heading") |
| |
| |
| def test_extract_skill_section_raises_on_invalid_heading_format(tmp_path: Path): |
| skill_md = tmp_path / "SKILL.md" |
| skill_md.write_text("## A\n\nContent.\n") |
| with pytest.raises(ValueError, match="does not look like a Markdown heading"): |
| extract_skill_section(skill_md, "Not a heading") |
| |
| |
| def test_extract_skill_section_includes_heading_line_itself(tmp_path: Path): |
| skill_md = tmp_path / "SKILL.md" |
| skill_md.write_text("## My Section\n\nBody.\n") |
| result = extract_skill_section(skill_md, "## My Section") |
| assert result.startswith("## My Section") |
| |
| |
| # --------------------------------------------------------------------------- |
| # load_step_config |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_load_step_config_uses_step_config_json(tmp_path: Path): |
| repo_root = _make_repo(tmp_path) |
| skill_md = repo_root / "skills" / "my-skill" / "SKILL.md" |
| skill_md.parent.mkdir(parents=True) |
| skill_md.write_text("## Target Step\n\nPrompt content here.\n\n## Other Step\n\nNot this.\n") |
| |
| fixtures_dir = _make_fixtures_dir( |
| repo_root / "step-dir", |
| step_config={"skill_md": "skills/my-skill/SKILL.md", "step_heading": "## Target Step"}, |
| ) |
| system_prompt, _user_prompt_template = load_step_config(fixtures_dir) |
| assert "Prompt content here" in system_prompt |
| assert "Other Step" not in system_prompt |
| |
| |
| def test_load_step_config_appends_output_spec(tmp_path: Path): |
| repo_root = _make_repo(tmp_path) |
| skill_md = repo_root / "SKILL.md" |
| skill_md.write_text("## Step\n\nBase prompt.\n") |
| |
| fixtures_dir = _make_fixtures_dir( |
| repo_root / "step-dir", |
| step_config={"skill_md": "SKILL.md", "step_heading": "## Step"}, |
| output_spec="Return JSON only.", |
| ) |
| system_prompt, _ = load_step_config(fixtures_dir) |
| assert "Base prompt" in system_prompt |
| assert "Return JSON only" in system_prompt |
| |
| |
| def test_load_step_config_falls_back_to_system_prompt_md(tmp_path: Path): |
| fixtures_dir = _make_fixtures_dir( |
| tmp_path / "step-dir", |
| system_prompt="You are a helpful assistant.", |
| ) |
| system_prompt, _ = load_step_config(fixtures_dir) |
| assert "You are a helpful assistant" in system_prompt |
| |
| |
| def test_load_step_config_uses_custom_user_prompt_template(tmp_path: Path): |
| fixtures_dir = _make_fixtures_dir( |
| tmp_path / "step-dir", |
| system_prompt="System.", |
| user_prompt_template="Custom: {report}", |
| ) |
| _, user_prompt_template = load_step_config(fixtures_dir) |
| assert user_prompt_template == "Custom: {report}" |
| |
| |
| def test_load_step_config_uses_default_user_prompt_template_when_absent(tmp_path: Path): |
| fixtures_dir = _make_fixtures_dir( |
| tmp_path / "step-dir", |
| system_prompt="System.", |
| ) |
| _, user_prompt_template = load_step_config(fixtures_dir) |
| assert "{corpus}" in user_prompt_template |
| assert "{roster}" in user_prompt_template |
| assert "{report}" in user_prompt_template |
| |
| |
| def test_load_step_config_raises_when_neither_config_present(tmp_path: Path): |
| fixtures_dir = tmp_path / "empty-fixtures" |
| fixtures_dir.mkdir() |
| with pytest.raises(FileNotFoundError): |
| load_step_config(fixtures_dir) |
| |
| |
| # --------------------------------------------------------------------------- |
| # load_case |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_load_case_loads_report_and_expected(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| case_dir = _make_case(fixtures_dir, "case-1", report="The report.", expected={"verdict": "duplicate"}) |
| |
| corpus, roster, report, expected = load_case(case_dir) |
| assert report == "The report." |
| assert expected == {"verdict": "duplicate"} |
| assert corpus == [] |
| assert roster == {} |
| |
| |
| def test_load_case_loads_optional_corpus(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| corpus_data = [{"number": 1, "title": "T", "body": "B"}] |
| (fixtures_dir / "corpus.json").write_text(json.dumps(corpus_data)) |
| case_dir = _make_case(fixtures_dir, "case-1") |
| |
| corpus, _, _, _ = load_case(case_dir) |
| assert corpus == corpus_data |
| |
| |
| def test_load_case_loads_optional_roster(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| roster_data = {"42": "reporter@example.com"} |
| (fixtures_dir / "reporter-roster.json").write_text(json.dumps(roster_data)) |
| case_dir = _make_case(fixtures_dir, "case-1") |
| |
| _, roster, _, _ = load_case(case_dir) |
| assert roster == roster_data |
| |
| |
| def test_load_case_tags_missing_meta_returns_empty_set(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| case_dir = _make_case(fixtures_dir, "case-1") |
| assert load_case_tags(case_dir) == set() |
| |
| |
| def test_load_case_tags_reads_case_meta(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| case_dir = _make_case(fixtures_dir, "case-1") |
| (case_dir / "case-meta.json").write_text(json.dumps({"tags": ["llama", "smoke"]})) |
| assert load_case_tags(case_dir) == {"llama", "smoke"} |
| |
| |
| def test_load_case_tags_rejects_non_string_tags(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| case_dir = _make_case(fixtures_dir, "case-1") |
| (case_dir / "case-meta.json").write_text(json.dumps({"tags": ["llama", 3]})) |
| with pytest.raises(ValueError, match="tags"): |
| load_case_tags(case_dir) |
| |
| |
| # --------------------------------------------------------------------------- |
| # find_cases |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_find_cases_single_case_dir(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| case_dir = _make_case(fixtures_dir, "case-1") |
| |
| # Pass the case directory directly |
| results = find_cases(case_dir) |
| assert len(results) == 1 |
| assert results[0] == (case_dir, fixtures_dir) |
| |
| |
| def test_find_cases_fixtures_dir_with_multiple_cases(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| case1 = _make_case(fixtures_dir, "case-1") |
| case2 = _make_case(fixtures_dir, "case-2") |
| |
| results = find_cases(fixtures_dir) |
| assert len(results) == 2 |
| assert (case1, fixtures_dir) in results |
| assert (case2, fixtures_dir) in results |
| |
| |
| def test_find_cases_recursive_skill_dir(tmp_path: Path): |
| step1_fixtures = tmp_path / "step-1" / "fixtures" |
| step2_fixtures = tmp_path / "step-2" / "fixtures" |
| c1 = _make_case(step1_fixtures, "case-1") |
| c2 = _make_case(step2_fixtures, "case-1") |
| |
| results = find_cases(tmp_path) |
| assert len(results) == 2 |
| assert (c1, step1_fixtures) in results |
| assert (c2, step2_fixtures) in results |
| |
| |
| def test_find_cases_returns_empty_for_no_cases(tmp_path: Path): |
| empty_dir = tmp_path / "nothing" |
| empty_dir.mkdir() |
| assert find_cases(empty_dir) == [] |
| |
| |
| def test_find_cases_deduplicates_nested_fixtures(tmp_path: Path): |
| # A fixtures dir that itself contains another fixtures dir — the inner |
| # one should not be double-counted. |
| outer_fixtures = tmp_path / "step-1" / "fixtures" |
| _make_case(outer_fixtures, "case-1") |
| inner_fixtures = outer_fixtures / "nested" / "fixtures" |
| _make_case(inner_fixtures, "case-inner") |
| |
| results = find_cases(tmp_path) |
| fixtures_dirs = [f for _, f in results] |
| assert fixtures_dirs.count(outer_fixtures) == 1 |
| |
| |
| # --------------------------------------------------------------------------- |
| # main (CLI) |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_main_exits_1_when_no_cases_found(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| empty = tmp_path / "empty" |
| empty.mkdir() |
| rc, _, stderr = _run_main(capsys, [str(empty)]) |
| assert rc == 1 |
| assert "No eval cases found" in stderr |
| |
| |
| def test_main_prints_case_header_and_expected(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| repo_root = _make_repo(tmp_path) |
| skill_md = repo_root / "SKILL.md" |
| skill_md.write_text("## My Step\n\nDo the thing.\n") |
| |
| fixtures_dir = _make_fixtures_dir( |
| repo_root / "step-dir", |
| step_config={"skill_md": "SKILL.md", "step_heading": "## My Step"}, |
| ) |
| _make_case(fixtures_dir, "case-1", expected={"result": "pass"}) |
| |
| rc, stdout, _ = _run_main(capsys, [str(fixtures_dir)]) |
| assert rc == 0 |
| assert "CASE:" in stdout |
| assert '"result": "pass"' in stdout |
| |
| |
| def test_main_quiet_suppresses_prompts(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| repo_root = _make_repo(tmp_path) |
| skill_md = repo_root / "SKILL.md" |
| skill_md.write_text("## Step\n\nSecret system prompt.\n") |
| |
| fixtures_dir = _make_fixtures_dir( |
| repo_root / "step-dir", |
| step_config={"skill_md": "SKILL.md", "step_heading": "## Step"}, |
| ) |
| _make_case(fixtures_dir, "case-1") |
| |
| rc, stdout, _ = _run_main(capsys, [str(fixtures_dir), "--quiet"]) |
| assert rc == 0 |
| assert "Secret system prompt" not in stdout |
| assert "CASE:" in stdout |
| assert "EXPECTED" in stdout |
| |
| |
| def test_main_prints_system_and_user_prompt_without_quiet(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| repo_root = _make_repo(tmp_path) |
| skill_md = repo_root / "SKILL.md" |
| skill_md.write_text("## Step\n\nSystem content here.\n") |
| |
| fixtures_dir = _make_fixtures_dir( |
| repo_root / "step-dir", |
| step_config={"skill_md": "SKILL.md", "step_heading": "## Step"}, |
| ) |
| _make_case(fixtures_dir, "case-1", report="The incoming report.") |
| |
| rc, stdout, _ = _run_main(capsys, [str(fixtures_dir)]) |
| assert rc == 0 |
| assert "SYSTEM PROMPT" in stdout |
| assert "System content here" in stdout |
| assert "USER PROMPT" in stdout |
| assert "The incoming report" in stdout |
| |
| |
| def test_main_caches_step_config_across_cases(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """Step config should be loaded once per fixtures dir even with multiple cases.""" |
| repo_root = _make_repo(tmp_path) |
| skill_md = repo_root / "SKILL.md" |
| skill_md.write_text("## Step\n\nPrompt.\n") |
| |
| fixtures_dir = _make_fixtures_dir( |
| repo_root / "step-dir", |
| step_config={"skill_md": "SKILL.md", "step_heading": "## Step"}, |
| ) |
| _make_case(fixtures_dir, "case-1") |
| _make_case(fixtures_dir, "case-2") |
| |
| rc, stdout, _ = _run_main(capsys, [str(fixtures_dir)]) |
| assert rc == 0 |
| # Both cases should appear |
| assert stdout.count("CASE:") == 2 |
| |
| |
| def test_main_bad_user_prompt_template_raises(tmp_path: Path): |
| """A malformed user-prompt-template.md with unknown slots raises an error.""" |
| repo_root = _make_repo(tmp_path) |
| skill_md = repo_root / "SKILL.md" |
| skill_md.write_text("## Step\n\nPrompt.\n") |
| |
| fixtures_dir = _make_fixtures_dir( |
| repo_root / "step-dir", |
| step_config={"skill_md": "SKILL.md", "step_heading": "## Step"}, |
| user_prompt_template="Hello {unknown_slot}", |
| ) |
| _make_case(fixtures_dir, "case-1") |
| |
| with pytest.raises(KeyError): |
| main([str(fixtures_dir)]) |
| |
| |
| # --------------------------------------------------------------------------- |
| # is_structural_expected |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_is_structural_expected_plain_kvs(): |
| assert is_structural_expected({"class": "BUG", "confidence": "high"}) is False |
| |
| |
| def test_is_structural_expected_has_flag(): |
| assert is_structural_expected({"has_security_model_quote": True}) is True |
| |
| |
| def test_is_structural_expected_mention_list(): |
| assert is_structural_expected({"mention_handles": ["@alice"]}) is True |
| |
| |
| def test_is_structural_expected_non_dict(): |
| assert is_structural_expected(["a", "b"]) is False # type: ignore[arg-type] |
| |
| |
| def test_is_structural_expected_empty_dict(): |
| assert is_structural_expected({}) is False |
| |
| |
| # --------------------------------------------------------------------------- |
| # extract_json_from_output |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_extract_json_whole_output(): |
| value, err = extract_json_from_output('{"verdict": "ok"}') |
| assert err is None |
| assert value == {"verdict": "ok"} |
| |
| |
| def test_extract_json_whole_output_with_whitespace(): |
| value, err = extract_json_from_output('\n {"verdict": "ok"}\n\n') |
| assert err is None |
| assert value == {"verdict": "ok"} |
| |
| |
| def test_extract_json_fenced_block(): |
| text = 'Here is the output:\n\n```json\n{"verdict": "ok"}\n```\n\nThank you.' |
| value, err = extract_json_from_output(text) |
| assert err is None |
| assert value == {"verdict": "ok"} |
| |
| |
| def test_extract_json_bare_fenced_block(): |
| text = 'Result:\n```\n{"verdict": "ok"}\n```' |
| value, err = extract_json_from_output(text) |
| assert err is None |
| assert value == {"verdict": "ok"} |
| |
| |
| def test_extract_json_largest_brace_block(): |
| text = 'I think the answer is {"verdict": "ok"} based on the data.' |
| value, err = extract_json_from_output(text) |
| assert err is None |
| assert value == {"verdict": "ok"} |
| |
| |
| def test_extract_json_picks_largest_brace_block(): |
| text = 'Small: {"a": 1}. Larger and correct: {"verdict": "ok", "rationale": "longer text here"}.' |
| value, err = extract_json_from_output(text) |
| assert err is None |
| assert value == {"verdict": "ok", "rationale": "longer text here"} |
| |
| |
| def test_extract_json_array_top_level(): |
| value, err = extract_json_from_output("[1, 2, 3]") |
| assert err is None |
| assert value == [1, 2, 3] |
| |
| |
| def test_extract_json_no_json_returns_error(): |
| value, err = extract_json_from_output("Just prose, no JSON anywhere.") |
| assert value is None |
| assert err is not None and "no JSON" in err |
| |
| |
| def test_extract_json_empty_output_returns_error(): |
| value, err = extract_json_from_output("") |
| assert value is None |
| assert err is not None |
| |
| |
| def test_extract_json_malformed_braces_returns_error(): |
| value, err = extract_json_from_output('{"verdict": missing-quotes}') |
| assert value is None |
| assert err is not None |
| |
| |
| # --------------------------------------------------------------------------- |
| # compare_outputs |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_compare_outputs_equal_dicts(): |
| ok, diff = compare_outputs({"a": 1, "b": 2}, {"a": 1, "b": 2}) |
| assert ok is True |
| assert diff == "" |
| |
| |
| def test_compare_outputs_dict_key_order_irrelevant(): |
| ok, _ = compare_outputs({"b": 2, "a": 1}, {"a": 1, "b": 2}) |
| assert ok is True |
| |
| |
| def test_compare_outputs_unequal_dicts_show_diff(): |
| ok, diff = compare_outputs({"class": "BUG"}, {"class": "INVALID"}) |
| assert ok is False |
| assert "BUG" in diff |
| assert "INVALID" in diff |
| |
| |
| def test_compare_outputs_unequal_marks_added_and_removed(): |
| ok, diff = compare_outputs({"a": 1}, {"a": 1, "b": 2}) |
| assert ok is False |
| assert "+" in diff or "-" in diff |
| |
| |
| # --------------------------------------------------------------------------- |
| # --cli mode integration |
| # --------------------------------------------------------------------------- |
| |
| |
| def _make_cli_case(tmp_path: Path, *, expected: dict, report: str = "the report") -> tuple[Path, Path]: |
| """Build a minimal fixtures dir + one case dir; return (fixtures_dir, case_dir).""" |
| repo_root = _make_repo(tmp_path) |
| skill_md = repo_root / "SKILL.md" |
| skill_md.write_text("## Step\n\nSystem instructions.\n") |
| fixtures_dir = _make_fixtures_dir( |
| repo_root / "step-dir", |
| step_config={"skill_md": "SKILL.md", "step_heading": "## Step"}, |
| user_prompt_template="Report: {report}\n", |
| ) |
| case_dir = _make_case(fixtures_dir, "case-1", report=report, expected=expected) |
| return fixtures_dir, case_dir |
| |
| |
| def test_cli_mode_pass_with_echo(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """A CLI that echoes the expected JSON should PASS.""" |
| expected = {"verdict": "ok"} |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected=expected) |
| rc, stdout, _ = _run_main( |
| capsys, |
| ["--cli", f"echo '{json.dumps(expected)}'", str(fixtures_dir)], |
| ) |
| assert rc == 0 |
| assert "PASS" in stdout |
| assert "1 passed" in stdout |
| |
| |
| def test_cli_mode_fail_with_wrong_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """A CLI that returns the wrong JSON should FAIL and exit non-zero.""" |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"verdict": "ok"}) |
| rc, stdout, _ = _run_main( |
| capsys, |
| ["--cli", 'echo \'{"verdict": "wrong"}\'', str(fixtures_dir)], |
| ) |
| assert rc == 1 |
| assert "FAIL" in stdout |
| assert "1 failed" in stdout |
| |
| |
| def test_cli_mode_fail_with_wrong_jsons(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """A CLI that returns the wrong JSONS should FAIL with multiple failures and exit non-zero.""" |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"verdict": "ok"}) |
| _make_case(fixtures_dir, "case-2", report="another report", expected={"verdict": "ok"}) |
| rc, stdout, _ = _run_main( |
| capsys, |
| ["--cli", 'echo \'{"verdict": "wrong"}\'', str(fixtures_dir)], |
| ) |
| assert rc == 1 |
| assert "FAIL" in stdout |
| assert ( |
| "2 failed" in stdout |
| ) # asserts that behaviour doesn't changes and outputs exactly 2 failures instead of stopping at the first one, which is tested in the next test case |
| |
| |
| def test_cli_model_with_fail_fast_and_wrong_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """With --fail-fast, the runner should stop at the first failure and not run further cases.""" |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"verdict": "ok"}) |
| # Add a second case that would FAIL if it ran, but should be skipped due to fail-fast. |
| _make_case(fixtures_dir, "case-2", report="another report", expected={"verdict": "ok"}) |
| rc, stdout, _ = _run_main( |
| capsys, |
| ["--cli", 'echo \'{"verdict": "wrong"}\'', "--fail-fast", str(fixtures_dir)], |
| ) |
| assert rc == 1 |
| assert "FAIL" in stdout |
| assert "1 failed" in stdout |
| assert "CASE: case-2" not in stdout # second case should not run at all |
| |
| |
| def test_cli_model_with_fail_fast_and_error_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """In timeout being negative raise internal error when run_cli is called and --fail-fast is used.""" |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"verdict": "wrong"}) |
| _make_case(fixtures_dir, "case-2", report="another report", expected={"verdict": "ok"}) |
| rc, stdout, _ = _run_main( |
| capsys, |
| [ |
| "--cli", |
| 'echo \'{"verdict": "wrong"}\'', |
| "--timeout", |
| "-1", # force an error (timeout) instead of a fail, to check that fail-fast also applies to errors |
| "--exact", |
| "--fail-fast", |
| str(fixtures_dir), |
| ], |
| ) |
| assert rc == 1 |
| assert "ERROR" in stdout |
| assert "1 errored" in stdout |
| assert "CASE: case-2" not in stdout # second case should not run at all |
| |
| |
| def test_cli_mode_manual_skips_structural(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """Structural expected.json (has_* / mention_*) is reported MANUAL, not auto-compared.""" |
| fixtures_dir, _ = _make_cli_case( |
| tmp_path, expected={"has_security_model_quote": True, "mention_handles": []} |
| ) |
| rc, stdout, _ = _run_main( |
| capsys, |
| # CLI would return junk; runner should not even invoke it for MANUAL cases. |
| # (`false` rather than `exit 1` because the runner uses shell=False; |
| # `exit` is a shell builtin and would not be found as a binary.) |
| ["--cli", "false", str(fixtures_dir)], |
| ) |
| assert rc == 0 |
| assert "MANUAL" in stdout |
| assert "1 manual" in stdout |
| |
| |
| def test_cli_mode_non_json_under_exact_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """In --exact mode, prose with no JSON returns ERROR and exits non-zero.""" |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"verdict": "ok"}) |
| rc, stdout, _ = _run_main( |
| capsys, |
| [ |
| "--cli", |
| "echo 'just prose, no JSON here'", |
| "--exact", |
| str(fixtures_dir), |
| ], |
| ) |
| assert rc == 1 |
| assert "ERROR" in stdout |
| assert "1 errored" in stdout |
| |
| |
| def test_cli_mode_non_json_wraps_and_passes_under_field_aware( |
| tmp_path: Path, capsys: pytest.CaptureFixture[str] |
| ): |
| """Default field-aware mode wraps prose as {"raw_output": ...} so the |
| intersection-only comparator can proceed. With expected.json declaring |
| no raw_output key, the case passes.""" |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"verdict": "ok"}) |
| rc, stdout, _ = _run_main( |
| capsys, |
| [ |
| "--cli", |
| "echo 'just prose, no JSON here'", |
| "--grader-cli", |
| _GRADER_YES, # not actually invoked; no overlapping keys |
| str(fixtures_dir), |
| ], |
| ) |
| assert rc == 0 |
| assert "PASS" in stdout |
| assert "1 passed" in stdout |
| |
| |
| def test_cli_mode_non_json_wrap_can_assert_on_raw_output(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """Suite authors who want to gate on the prose can declare raw_output |
| in expected.json; the wrapped actual carries the model's prose and a |
| mismatch is a real decision-level failure.""" |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"raw_output": "this exact prose\n"}) |
| rc, stdout, _ = _run_main( |
| capsys, |
| [ |
| "--cli", |
| "echo 'different prose'", |
| "--grader-cli", |
| _GRADER_YES, |
| str(fixtures_dir), |
| ], |
| ) |
| assert rc == 1 |
| assert "FAIL" in stdout |
| assert "raw_output" in stdout |
| |
| |
| def test_cli_mode_non_zero_exit_under_exact_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """In --exact mode, a non-zero CLI exit still returns ERROR and exits non-zero.""" |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"verdict": "ok"}) |
| rc, stdout, _ = _run_main(capsys, ["--cli", "false", "--exact", str(fixtures_dir)]) |
| assert rc == 1 |
| assert "ERROR" in stdout |
| |
| |
| def test_cli_mode_non_zero_exit_wraps_under_field_aware(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """In the default field-aware mode, a non-zero CLI exit is wrapped as |
| raw_output (+ stderr + exit_code) and the intersection-only comparator |
| decides whether the case passes.""" |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"verdict": "ok"}) |
| rc, stdout, _ = _run_main( |
| capsys, |
| [ |
| "--cli", |
| "false", |
| "--grader-cli", |
| _GRADER_YES, |
| str(fixtures_dir), |
| ], |
| ) |
| assert rc == 0 |
| assert "PASS" in stdout |
| |
| |
| def test_cli_mode_extracts_json_from_fenced_response(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """The runner should find JSON inside a ```json fence in the CLI's stdout.""" |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"verdict": "ok"}) |
| # printf interprets escapes so we get a real fenced block on stdout. |
| rc, stdout, _ = _run_main( |
| capsys, |
| [ |
| "--cli", |
| 'printf \'Sure!\\n\\n```json\\n{"verdict": "ok"}\\n```\\n\'', |
| str(fixtures_dir), |
| ], |
| ) |
| assert rc == 0 |
| assert "PASS" in stdout |
| |
| |
| def test_cli_mode_summary_counts(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """Multiple cases should be summarised correctly.""" |
| expected = {"verdict": "ok"} |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected=expected) |
| # Add a second case that will FAIL by reusing the same fixtures dir. |
| _make_case(fixtures_dir, "case-2-fail", report="x", expected={"verdict": "different"}) |
| rc, stdout, _ = _run_main( |
| capsys, |
| ["--cli", f"echo '{json.dumps(expected)}'", str(fixtures_dir)], |
| ) |
| assert rc == 1 # because one case FAILs |
| assert "1 passed" in stdout |
| assert "1 failed" in stdout |
| |
| |
| def test_tag_filter_runs_only_matching_cases(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """--tag should restrict discovered cases before invoking the CLI.""" |
| expected = {"verdict": "ok"} |
| fixtures_dir, case_dir = _make_cli_case(tmp_path, expected=expected) |
| _make_case(fixtures_dir, "case-2-untagged", report="x", expected={"verdict": "different"}) |
| (case_dir / "case-meta.json").write_text(json.dumps({"tags": ["llama"]})) |
| |
| rc, stdout, _ = _run_main( |
| capsys, |
| ["--tag", "llama", "--cli", f"echo '{json.dumps(expected)}'", str(fixtures_dir)], |
| ) |
| assert rc == 0 |
| assert "1 passed" in stdout |
| assert "case-2-untagged" not in stdout |
| |
| |
| def test_list_tags_prints_counts(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """--list-tags prints distinct tags with per-tag case counts.""" |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| case1 = _make_case(fixtures_dir, "case-1") |
| case2 = _make_case(fixtures_dir, "case-2") |
| (case1 / "case-meta.json").write_text(json.dumps({"tags": ["llama"]})) |
| (case2 / "case-meta.json").write_text(json.dumps({"tags": ["qwen", "llama"]})) |
| |
| rc, stdout, stderr = _run_main(capsys, [str(fixtures_dir), "--list-tags"]) |
| assert rc == 0 |
| assert stderr == "" |
| assert stdout.strip().splitlines() == ["llama 2", "qwen 1"] |
| |
| |
| def test_list_tags_no_tags_found(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """--list-tags exits 0 with an informational line when no tags exist.""" |
| fixtures_dir = tmp_path / "fixtures" |
| _make_case(fixtures_dir, "case-1") |
| |
| rc, stdout, stderr = _run_main(capsys, [str(fixtures_dir), "--list-tags"]) |
| assert rc == 0 |
| assert stderr == "" |
| assert stdout.strip() == "no tags found" |
| |
| |
| def test_collect_tag_counts(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| case1 = _make_case(fixtures_dir, "case-1") |
| case2 = _make_case(fixtures_dir, "case-2") |
| (case1 / "case-meta.json").write_text(json.dumps({"tags": ["alpha"]})) |
| (case2 / "case-meta.json").write_text(json.dumps({"tags": ["alpha", "beta"]})) |
| |
| counts = collect_tag_counts(find_cases(fixtures_dir)) |
| assert counts == {"alpha": 2, "beta": 1} |
| |
| |
| # --------------------------------------------------------------------------- |
| # load_grading_schema |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_load_grading_schema_defaults_when_no_file(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| assert load_grading_schema(fixtures_dir) == set(DEFAULT_PROSE_FIELDS) |
| |
| |
| def test_load_grading_schema_override_replaces_default(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| (fixtures_dir / "grading-schema.json").write_text(json.dumps({"prose_fields": ["why"]})) |
| assert load_grading_schema(fixtures_dir) == {"why"} |
| |
| |
| def test_load_grading_schema_empty_list_disables_grader(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| (fixtures_dir / "grading-schema.json").write_text(json.dumps({"prose_fields": []})) |
| assert load_grading_schema(fixtures_dir) == set() |
| |
| |
| def test_load_grading_schema_rejects_non_string_entries(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| (fixtures_dir / "grading-schema.json").write_text(json.dumps({"prose_fields": ["why", 7]})) |
| with pytest.raises(ValueError, match="prose_fields"): |
| load_grading_schema(fixtures_dir) |
| |
| |
| def test_load_grading_schema_missing_key_falls_back_to_default(tmp_path: Path): |
| fixtures_dir = tmp_path / "fixtures" |
| fixtures_dir.mkdir() |
| (fixtures_dir / "grading-schema.json").write_text(json.dumps({"unrelated": True})) |
| assert load_grading_schema(fixtures_dir) == set(DEFAULT_PROSE_FIELDS) |
| |
| |
| # --------------------------------------------------------------------------- |
| # grade_prose_field (single-field helper retained for callers that want |
| # per-field grading; the main runner path uses the batched grader below.) |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_grade_prose_field_short_circuits_on_exact_equality(): |
| # Identical values should pass without invoking any CLI. |
| ok, note = grade_prose_field("$.reason", "boom", "boom", grader_cli="false", timeout=5) |
| assert ok is True |
| assert note == "" |
| |
| |
| def test_grade_prose_field_grader_says_match(): |
| grader = 'echo \'{"match": true, "reason": "same meaning"}\'' |
| ok, note = grade_prose_field("$.reason", "the build failed", "build broke", grader_cli=grader, timeout=5) |
| assert ok is True |
| assert note == "" |
| |
| |
| def test_grade_prose_field_grader_says_no(): |
| grader = 'echo \'{"match": false, "reason": "different conclusion"}\'' |
| ok, note = grade_prose_field( |
| "$.reason", "the build failed", "the build passed", grader_cli=grader, timeout=5 |
| ) |
| assert ok is False |
| assert "$.reason" in note |
| assert "different conclusion" in note |
| |
| |
| def test_grade_prose_field_grader_returns_garbage(): |
| ok, note = grade_prose_field("$.reason", "x", "y", grader_cli="echo 'not json at all'", timeout=5) |
| assert ok is False |
| assert "$.reason" in note |
| |
| |
| def test_grade_prose_field_grader_non_zero_exit(): |
| ok, note = grade_prose_field("$.reason", "x", "y", grader_cli="false", timeout=5) |
| assert ok is False |
| assert "$.reason" in note |
| |
| |
| # --------------------------------------------------------------------------- |
| # collect_diffs (pure walker; no grader calls) |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_collect_diffs_no_diff_when_equal(): |
| d, p = collect_diffs({"verdict": "BUG"}, {"verdict": "BUG"}, prose_fields=set()) |
| assert d == [] |
| assert p == [] |
| |
| |
| def test_collect_diffs_decision_scalar_mismatch_is_decision_only(): |
| d, p = collect_diffs({"verdict": "X"}, {"verdict": "Y"}, prose_fields={"reason"}) |
| assert any("verdict" in m for m in d) |
| assert p == [] |
| |
| |
| def test_collect_diffs_prose_mismatch_yields_pair(): |
| d, p = collect_diffs( |
| {"verdict": "BUG", "reason": "wording A"}, |
| {"verdict": "BUG", "reason": "wording B"}, |
| prose_fields={"reason"}, |
| ) |
| assert d == [] |
| assert len(p) == 1 |
| path, exp, act = p[0] |
| assert path == "$.reason" |
| assert exp == "wording B" |
| assert act == "wording A" |
| |
| |
| def test_collect_diffs_nested_list_of_prose_fields_yields_multiple_pairs(): |
| actual = {"items": [{"reason": "a"}, {"reason": "c"}]} |
| expected = {"items": [{"reason": "b"}, {"reason": "d"}]} |
| d, p = collect_diffs(actual, expected, prose_fields={"reason"}) |
| assert d == [] |
| assert [pair[0] for pair in p] == ["$.items[0].reason", "$.items[1].reason"] |
| |
| |
| def test_collect_diffs_missing_key_in_actual_is_skipped(): |
| """Expected declares 'b'; actual omits it. Skipped, not failed — |
| expected.json is a description of values where the model speaks, not a |
| required-keys schema.""" |
| d, p = collect_diffs({"a": 1}, {"a": 1, "b": 2}, prose_fields=set()) |
| assert d == [] |
| assert p == [] |
| |
| |
| def test_collect_diffs_extra_keys_in_actual_are_ignored(): |
| """Only the intersection is asserted; extras in actual pass.""" |
| d, p = collect_diffs({"a": 1, "extra": "anything"}, {"a": 1}, prose_fields=set()) |
| assert d == [] |
| assert p == [] |
| |
| |
| def test_collect_diffs_intersection_value_mismatch_still_fails(): |
| """Keys present in both that differ in value still fail.""" |
| d, _ = collect_diffs({"a": 2, "extra": "x"}, {"a": 1, "b": 2}, prose_fields=set()) |
| assert any("a" in m for m in d) |
| |
| |
| def test_collect_diffs_empty_actual_passes_against_any_expected(): |
| """Document the trade-off: a model returning {} matches any expected. |
| Suite authors should keep expected.json focused on the keys that |
| actually carry the eval's signal.""" |
| d, p = collect_diffs({}, {"a": 1, "b": 2, "c": [1, 2]}, prose_fields=set()) |
| assert d == [] |
| assert p == [] |
| |
| |
| def test_collect_diffs_length_mismatch_is_decision(): |
| d, _ = collect_diffs({"items": [1, 2]}, {"items": [1, 2, 3]}, prose_fields=set()) |
| assert any("length mismatch" in m for m in d) |
| |
| |
| def test_collect_diffs_equal_prose_does_not_emit_pair(): |
| d, p = collect_diffs({"reason": "same"}, {"reason": "same"}, prose_fields={"reason"}) |
| assert d == [] |
| assert p == [] |
| |
| |
| # --------------------------------------------------------------------------- |
| # batch_grade_prose_fields |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_batch_grade_empty_pairs_makes_no_grader_call(tmp_path: Path): |
| counter = tmp_path / "calls" |
| result = batch_grade_prose_fields([], _grader_count_cli(counter), timeout=5) |
| assert result == {} |
| assert _count_grader_calls(counter) == 0 |
| |
| |
| def test_batch_grade_single_pair_one_call(tmp_path: Path): |
| counter = tmp_path / "calls" |
| pairs = [("$.reason", "expected", "actual")] |
| result = batch_grade_prose_fields(pairs, _grader_count_cli(counter), timeout=5) |
| assert _count_grader_calls(counter) == 1 |
| assert result["$.reason"] == (True, "") |
| |
| |
| def test_batch_grade_many_pairs_one_call(tmp_path: Path): |
| """Headline guarantee: N prose mismatches → 1 grader call.""" |
| counter = tmp_path / "calls" |
| pairs = [ |
| ("$.a", "x", "y"), |
| ("$.b", "x", "y"), |
| ("$.c.d", "x", "y"), |
| ("$.list[0].reason", "x", "y"), |
| ] |
| result = batch_grade_prose_fields(pairs, _grader_count_cli(counter), timeout=5) |
| assert _count_grader_calls(counter) == 1 |
| for path, _, _ in pairs: |
| assert result[path] == (True, "") |
| |
| |
| def test_batch_grade_grader_says_no(): |
| pairs = [("$.reason", "expected", "actual")] |
| result = batch_grade_prose_fields(pairs, _GRADER_NO, timeout=5) |
| ok, note = result["$.reason"] |
| assert ok is False |
| assert "differs" in note |
| |
| |
| def test_batch_grade_grader_failure_marks_all_fail(): |
| pairs = [("$.a", "x", "y"), ("$.b", "x", "y")] |
| result = batch_grade_prose_fields(pairs, "false", timeout=5) |
| for path, _, _ in pairs: |
| ok, _ = result[path] |
| assert ok is False |
| |
| |
| # --------------------------------------------------------------------------- |
| # compare_with_grader (uses the batched grader path) |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_compare_with_grader_passes_when_decision_fields_match_and_prose_judged_match(): |
| actual = {"verdict": "BUG", "reason": "the system crashes on a null record"} |
| expected = {"verdict": "BUG", "reason": "crashes on null input"} |
| ok, msgs = compare_with_grader( |
| actual, |
| expected, |
| prose_fields={"reason"}, |
| grader_cli=_GRADER_YES, |
| timeout=5, |
| ) |
| assert ok is True |
| assert msgs == [] |
| |
| |
| def test_compare_with_grader_skips_grader_when_decision_field_differs(tmp_path: Path): |
| """Decision-field failure must not invoke the grader.""" |
| counter = tmp_path / "calls" |
| actual = {"verdict": "INVALID", "reason": "wording A"} |
| expected = {"verdict": "BUG", "reason": "wording B"} |
| ok, msgs = compare_with_grader( |
| actual, |
| expected, |
| prose_fields={"reason"}, |
| grader_cli=_grader_count_cli(counter), |
| timeout=5, |
| ) |
| assert ok is False |
| assert _count_grader_calls(counter) == 0 |
| assert any("verdict" in m for m in msgs) |
| |
| |
| def test_compare_with_grader_multiple_prose_mismatches_one_call(tmp_path: Path): |
| counter = tmp_path / "calls" |
| actual = { |
| "verdict": "BUG", |
| "reason": "a", |
| "follow_up": [{"reason": "c"}, {"reason": "e"}], |
| } |
| expected = { |
| "verdict": "BUG", |
| "reason": "b", |
| "follow_up": [{"reason": "d"}, {"reason": "f"}], |
| } |
| ok, msgs = compare_with_grader( |
| actual, |
| expected, |
| prose_fields={"reason"}, |
| grader_cli=_grader_count_cli(counter), |
| timeout=5, |
| ) |
| assert ok is True, msgs |
| assert _count_grader_calls(counter) == 1 |
| |
| |
| def test_compare_with_grader_fails_when_grader_says_no(): |
| actual = {"verdict": "BUG", "reason": "crashes on overflow"} |
| expected = {"verdict": "BUG", "reason": "null pointer on init"} |
| ok, msgs = compare_with_grader( |
| actual, |
| expected, |
| prose_fields={"reason"}, |
| grader_cli=_GRADER_NO, |
| timeout=5, |
| ) |
| assert ok is False |
| assert any("reason" in m for m in msgs) |
| |
| |
| def test_compare_with_grader_handles_nested_list_of_dicts(): |
| actual = { |
| "overall": "fail", |
| "follow_up": [ |
| {"skill": "install", "reason": "missing hook script"}, |
| {"skill": "update", "reason": "claude-code below the min_version floor"}, |
| ], |
| } |
| expected = { |
| "overall": "fail", |
| "follow_up": [ |
| {"skill": "install", "reason": "hooks/scripts not installed"}, |
| {"skill": "update", "reason": "claude-code is under the min_version floor"}, |
| ], |
| } |
| ok, msgs = compare_with_grader( |
| actual, |
| expected, |
| prose_fields={"reason"}, |
| grader_cli=_GRADER_YES, |
| timeout=5, |
| ) |
| assert ok is True |
| assert msgs == [] |
| |
| |
| def test_compare_with_grader_no_prose_diff_no_grader_call(tmp_path: Path): |
| counter = tmp_path / "calls" |
| ok, _ = compare_with_grader( |
| {"verdict": "BUG", "reason": "same"}, |
| {"verdict": "BUG", "reason": "same"}, |
| prose_fields={"reason"}, |
| grader_cli=_grader_count_cli(counter), |
| timeout=5, |
| ) |
| assert ok is True |
| assert _count_grader_calls(counter) == 0 |
| |
| |
| # --------------------------------------------------------------------------- |
| # --grader-cli end-to-end |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_cli_grader_mode_passes_on_wording_difference(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """Same verdict, different prose in `reason` — grader-cli mode should PASS.""" |
| expected = {"verdict": "BUG", "reason": "crashes on null input"} |
| actual = {"verdict": "BUG", "reason": "null pointer on first call"} |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected=expected) |
| rc, stdout, _ = _run_main( |
| capsys, |
| [ |
| "--cli", |
| f"echo '{json.dumps(actual)}'", |
| "--grader-cli", |
| _GRADER_YES, |
| str(fixtures_dir), |
| ], |
| ) |
| assert rc == 0, stdout |
| assert "PASS" in stdout |
| assert "1 passed" in stdout |
| |
| |
| def test_cli_grader_mode_fails_on_decision_field_difference( |
| tmp_path: Path, capsys: pytest.CaptureFixture[str] |
| ): |
| """Decision field (verdict) differs — must FAIL even if grader would say YES.""" |
| expected = {"verdict": "BUG", "reason": "same"} |
| actual = {"verdict": "INVALID", "reason": "same"} |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected=expected) |
| rc, stdout, _ = _run_main( |
| capsys, |
| [ |
| "--cli", |
| f"echo '{json.dumps(actual)}'", |
| "--grader-cli", |
| _GRADER_YES, |
| str(fixtures_dir), |
| ], |
| ) |
| assert rc == 1 |
| assert "FAIL" in stdout |
| assert "verdict" in stdout |
| |
| |
| def test_cli_grader_mode_fails_when_grader_rejects_prose(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """Decision match, but grader says prose differs.""" |
| expected = {"verdict": "BUG", "reason": "crashes on null input"} |
| actual = {"verdict": "BUG", "reason": "totally unrelated text"} |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected=expected) |
| rc, stdout, _ = _run_main( |
| capsys, |
| [ |
| "--cli", |
| f"echo '{json.dumps(actual)}'", |
| "--grader-cli", |
| _GRADER_NO, |
| str(fixtures_dir), |
| ], |
| ) |
| assert rc == 1 |
| assert "FAIL" in stdout |
| assert "reason" in stdout |
| |
| |
| def test_cli_grader_mode_respects_grading_schema_override(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """grading-schema.json with prose_fields=[] forces exact compare on `reason`.""" |
| expected = {"verdict": "BUG", "reason": "crashes on null input"} |
| actual = {"verdict": "BUG", "reason": "null pointer on first call"} |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected=expected) |
| (fixtures_dir / "grading-schema.json").write_text(json.dumps({"prose_fields": []})) |
| rc, stdout, _ = _run_main( |
| capsys, |
| [ |
| "--cli", |
| f"echo '{json.dumps(actual)}'", |
| "--grader-cli", |
| _GRADER_YES, # would say YES, but reason should be graded exact now |
| str(fixtures_dir), |
| ], |
| ) |
| assert rc == 1 |
| assert "FAIL" in stdout |
| assert "reason" in stdout |
| |
| |
| def test_grader_cli_requires_cli_flag(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"verdict": "ok"}) |
| with pytest.raises(SystemExit): |
| main(["--grader-cli", _GRADER_YES, str(fixtures_dir)]) |
| err = capsys.readouterr().err |
| assert "require --cli" in err |
| |
| |
| def test_exact_requires_cli_flag(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected={"verdict": "ok"}) |
| with pytest.raises(SystemExit): |
| main(["--exact", str(fixtures_dir)]) |
| err = capsys.readouterr().err |
| assert "require --cli" in err |
| |
| |
| def test_default_grader_constant_is_haiku(): |
| # Defending the documented default so a future rename doesn't silently |
| # change cost characteristics for users. |
| assert "haiku" in DEFAULT_GRADER_CLI |
| |
| |
| def test_exact_mode_falls_back_to_verbatim_comparison(tmp_path: Path, capsys: pytest.CaptureFixture[str]): |
| """With --exact, a wording-only diff on a prose field should FAIL.""" |
| expected = {"verdict": "BUG", "reason": "null input crash"} |
| actual = {"verdict": "BUG", "reason": "different wording"} |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected=expected) |
| rc, stdout, _ = _run_main( |
| capsys, |
| [ |
| "--cli", |
| f"echo '{json.dumps(actual)}'", |
| "--exact", |
| str(fixtures_dir), |
| ], |
| ) |
| assert rc == 1 |
| assert "FAIL" in stdout |
| |
| |
| def test_default_grader_not_invoked_when_decision_field_differs( |
| tmp_path: Path, capsys: pytest.CaptureFixture[str] |
| ): |
| """If only decision fields differ, the grader is never called, so the |
| default (claude -p --model haiku) does not need to exist on PATH.""" |
| expected = {"verdict": "BUG"} |
| actual = {"verdict": "INVALID"} |
| fixtures_dir, _ = _make_cli_case(tmp_path, expected=expected) |
| # Do NOT pass --grader-cli or --exact: rely on the default grader being |
| # un-invoked. If it were invoked, the test would error (claude not on PATH). |
| rc, stdout, _ = _run_main( |
| capsys, |
| ["--cli", f"echo '{json.dumps(actual)}'", str(fixtures_dir)], |
| ) |
| assert rc == 1 |
| assert "FAIL" in stdout |
| assert "verdict" in stdout |
| |
| |
| # --------------------------------------------------------------------------- |
| # run_cli — the shell=False / shlex.split invocation contract |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_run_cli_returns_stdout_and_zero_rc(): |
| stdout, _stderr, rc = run_cli("python3 -c \"print('hi')\"", "", timeout=10) |
| assert rc == 0 |
| assert stdout.strip() == "hi" |
| |
| |
| def test_run_cli_passes_prompt_on_stdin(): |
| # `cat` echoes whatever arrives on stdin; the prompt must reach it. |
| stdout, _stderr, rc = run_cli("cat", "hello-from-stdin", timeout=10) |
| assert rc == 0 |
| assert stdout == "hello-from-stdin" |
| |
| |
| def test_run_cli_reports_non_zero_exit(): |
| _stdout, _stderr, rc = run_cli('python3 -c "import sys; sys.exit(3)"', "", timeout=10) |
| assert rc == 3 |
| |
| |
| def test_run_cli_bare_env_prefix_is_not_a_command(): |
| # Regression guard for the _grader_count_cli fix: because run_cli uses |
| # shell=False with shlex.split, a leading `VAR=value` is tokenised as a |
| # literal argv[0] binary name, which does not exist -> OSError. The env |
| # assignment is NOT honoured by a shell. This is why the test helper must |
| # wrap the inner command in `bash -c`. |
| with pytest.raises(OSError): |
| run_cli("FOO=bar python3 -c \"import os; print(os.environ['FOO'])\"", "", timeout=10) |
| |
| |
| def test_run_cli_bash_c_honours_env_prefix(): |
| # The fix: wrapping in `bash -c` lets the env assignment take effect. |
| inner = "FOO=bar python3 -c \"import os; print(os.environ['FOO'])\"" |
| stdout, _stderr, rc = run_cli(f"bash -c {shlex.quote(inner)}", "", timeout=10) |
| assert rc == 0 |
| assert stdout.strip() == "bar" |
| |
| |
| # --------------------------------------------------------------------------- |
| # Structural assertions: load_assertions |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_load_assertions_absent_returns_empty(tmp_path: Path): |
| assert load_assertions(tmp_path) == {} |
| |
| |
| def test_load_assertions_reads_specs(tmp_path: Path): |
| (tmp_path / "assertions.json").write_text( |
| json.dumps({"has_x": {"field": "body", "type": "contains", "substring": "x"}}) |
| ) |
| specs = load_assertions(tmp_path) |
| assert specs["has_x"]["type"] == "contains" |
| |
| |
| def test_load_assertions_rejects_unknown_type(tmp_path: Path): |
| (tmp_path / "assertions.json").write_text(json.dumps({"has_x": {"type": "bogus"}})) |
| with pytest.raises(ValueError, match="invalid type"): |
| load_assertions(tmp_path) |
| |
| |
| def test_load_assertions_rejects_non_object_spec(tmp_path: Path): |
| (tmp_path / "assertions.json").write_text(json.dumps({"has_x": "nope"})) |
| with pytest.raises(ValueError, match="must be an object"): |
| load_assertions(tmp_path) |
| |
| |
| # --------------------------------------------------------------------------- |
| # Structural assertions: evaluate_deterministic_assertion |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_assert_regex_match_and_flags(): |
| spec = {"field": "body", "type": "regex", "pattern": "download page.*https?://", "flags": "is"} |
| holds, _ = evaluate_deterministic_assertion(spec, {"body": "Download Page\n https://x"}) |
| assert holds is True |
| |
| |
| def test_assert_regex_no_match(): |
| spec = {"field": "body", "type": "regex", "pattern": "KEYS"} |
| holds, _ = evaluate_deterministic_assertion(spec, {"body": "no link here"}) |
| assert holds is False |
| |
| |
| def test_assert_contains_case_insensitive(): |
| spec = {"field": "body", "type": "contains", "substring": "APACHE.ORG", "flags": "i"} |
| holds, _ = evaluate_deterministic_assertion(spec, {"body": "from your @apache.org address"}) |
| assert holds is True |
| |
| |
| def test_assert_contains_all_reports_missing(): |
| spec = {"field": "body", "type": "contains_all", "substrings": ["a", "z"]} |
| holds, note = evaluate_deterministic_assertion(spec, {"body": "a only"}) |
| assert holds is False |
| assert "z" in note |
| |
| |
| def test_assert_empty_true_for_empty_list_and_missing(): |
| spec = {"field": "scope_violations", "type": "empty"} |
| assert evaluate_deterministic_assertion(spec, {"scope_violations": []})[0] is True |
| assert evaluate_deterministic_assertion(spec, {})[0] is True |
| assert evaluate_deterministic_assertion(spec, {"scope_violations": ["x"]})[0] is False |
| |
| |
| def test_assert_field_true(): |
| spec = {"field": "proposed", "type": "field_true"} |
| assert evaluate_deterministic_assertion(spec, {"proposed": True})[0] is True |
| assert evaluate_deterministic_assertion(spec, {"proposed": False})[0] is False |
| assert evaluate_deterministic_assertion(spec, {})[0] is False |
| |
| |
| def test_assert_max_length(): |
| spec = {"field": "pairs", "type": "max_length", "max": 20} |
| assert evaluate_deterministic_assertion(spec, {"pairs": list(range(20))})[0] is True |
| assert evaluate_deterministic_assertion(spec, {"pairs": list(range(19))})[0] is True |
| holds, note = evaluate_deterministic_assertion(spec, {"pairs": list(range(21))}) |
| assert holds is False |
| assert "len=21" in note |
| # absent field is not satisfied; a non-int 'max' is a loud spec error |
| assert evaluate_deterministic_assertion(spec, {})[0] is False |
| assert ( |
| evaluate_deterministic_assertion({"field": "pairs", "type": "max_length"}, {"pairs": []})[0] is None |
| ) |
| assert ( |
| evaluate_deterministic_assertion( |
| {"field": "pairs", "type": "max_length", "max": True}, {"pairs": []} |
| )[0] |
| is None |
| ) |
| |
| |
| def test_assert_missing_field_for_text_predicate_is_false(): |
| spec = {"field": "body", "type": "contains", "substring": "x"} |
| holds, note = evaluate_deterministic_assertion(spec, {}) |
| assert holds is False |
| assert "not present" in note |
| |
| |
| def test_assert_missing_pattern_is_spec_error(): |
| spec = {"field": "body", "type": "regex"} |
| holds, note = evaluate_deterministic_assertion(spec, {"body": "x"}) |
| assert holds is None |
| assert "pattern" in note |
| |
| |
| def test_assert_negate_inverts_regex_result(): |
| # negate asserts the *absence* of a match: pattern missing -> True, |
| # pattern present -> False (so a real leak is still caught). |
| spec = {"field": "cmds", "type": "regex", "pattern": "--passphrase", "negate": True} |
| assert evaluate_deterministic_assertion(spec, {"cmds": "gpg --detach-sign x"})[0] is True |
| assert evaluate_deterministic_assertion(spec, {"cmds": "gpg --passphrase p x"})[0] is False |
| |
| |
| def test_assert_negate_passes_spec_error_through(): |
| # A spec/usage error (None) must not be flipped to a bogus True/False. |
| spec = {"field": "body", "type": "regex", "negate": True} |
| holds, note = evaluate_deterministic_assertion(spec, {"body": "x"}) |
| assert holds is None |
| assert "pattern" in note |
| |
| |
| # --------------------------------------------------------------------------- |
| # Structural assertions: batch_judge_assertions |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_batch_judge_empty_makes_no_call(): |
| assert batch_judge_assertions({}, {"a": 1}, "false", 10) == {} |
| |
| |
| def test_batch_judge_yes(): |
| specs = {"has_flag": {"type": "judge", "rubric": "is it flagged"}} |
| grades = batch_judge_assertions(specs, {"body": "x"}, _JUDGE_YES, 10) |
| assert grades["has_flag"][0] is True |
| |
| |
| def test_batch_judge_grader_error_returns_none(): |
| specs = {"has_flag": {"type": "judge", "rubric": "is it flagged"}} |
| holds, note = batch_judge_assertions(specs, {"body": "x"}, "false", 10)["has_flag"] |
| assert holds is None |
| assert "exited" in note |
| |
| |
| # --------------------------------------------------------------------------- |
| # Structural assertions: compare_structural |
| # --------------------------------------------------------------------------- |
| |
| |
| def _assertions(deterministic_only: bool = False) -> dict: |
| specs = { |
| "has_keys_link": {"field": "body", "type": "regex", "pattern": r"https?://\S*KEYS", "flags": "i"}, |
| "has_skip_note": {"field": "body", "type": "regex", "pattern": "skip-promote-wait", "flags": "i"}, |
| } |
| if not deterministic_only: |
| specs["has_injection_flagged"] = {"type": "judge", "rubric": "flagged?"} |
| return specs |
| |
| |
| def test_compare_structural_pass_mixed(): |
| expected = {"backend": "announce-list", "has_keys_link": True, "has_skip_note": False} |
| actual = {"backend": "announce-list", "body": "Keys: https://dist.apache.org/KEYS"} |
| ok, notes = compare_structural( |
| actual, |
| expected, |
| _assertions(deterministic_only=True), |
| prose_fields=set(), |
| grader_cli=_GRADER_YES, |
| exact=False, |
| grader_timeout=10, |
| ) |
| assert ok, notes |
| |
| |
| def test_compare_structural_fails_on_decision_field(): |
| expected = {"backend": "announce-list", "has_keys_link": True} |
| actual = {"backend": "github-release-notes", "body": "https://x/KEYS"} |
| ok, notes = compare_structural( |
| actual, |
| expected, |
| _assertions(deterministic_only=True), |
| prose_fields=set(), |
| grader_cli=_GRADER_YES, |
| exact=False, |
| grader_timeout=10, |
| ) |
| assert not ok |
| assert any("backend" in n for n in notes) |
| |
| |
| def test_compare_structural_fails_on_assertion_mismatch(): |
| expected = {"has_skip_note": False} |
| actual = {"body": "[SKIP-PROMOTE-WAIT: overridden]"} |
| ok, notes = compare_structural( |
| actual, |
| expected, |
| _assertions(deterministic_only=True), |
| prose_fields=set(), |
| grader_cli=_GRADER_YES, |
| exact=False, |
| grader_timeout=10, |
| ) |
| assert not ok |
| assert any("has_skip_note" in n for n in notes) |
| |
| |
| def test_compare_structural_missing_assertion_fails_loudly(): |
| expected = {"has_undeclared": True} |
| ok, notes = compare_structural( |
| {"body": "x"}, |
| expected, |
| {}, |
| prose_fields=set(), |
| grader_cli=_GRADER_YES, |
| exact=False, |
| grader_timeout=10, |
| ) |
| assert not ok |
| assert any("no assertion defined" in n for n in notes) |
| |
| |
| def test_compare_structural_judge_disagreement_fails(): |
| expected = {"has_injection_flagged": True} |
| ok, notes = compare_structural( |
| {"injection_summary": "ignored injection"}, |
| expected, |
| _assertions(), |
| prose_fields=set(), |
| grader_cli=_JUDGE_NO, |
| exact=False, |
| grader_timeout=10, |
| ) |
| assert not ok |
| assert any("has_injection_flagged" in n for n in notes) |