tesserakit-tests 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ out/
8
+ .DS_Store
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: tesserakit-tests
3
+ Version: 0.4.0
4
+ Summary: Tests job pack for Tessera: audit a Python test suite for hygiene (no-assert, skipped, xfail).
5
+ Project-URL: Homepage, https://github.com/ShaileshRawat1403/tessera
6
+ Project-URL: Repository, https://github.com/ShaileshRawat1403/tessera
7
+ Project-URL: Issues, https://github.com/ShaileshRawat1403/tessera/issues
8
+ Author: Shailesh Rawat
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: pydantic>=2.7
15
+ Requires-Dist: rich>=13.7
16
+ Requires-Dist: tesserakit-core>=0.1.0
17
+ Requires-Dist: typer>=0.12
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # tesserakit-tests
23
+
24
+ Audit a Python test suite for hygiene problems.
25
+
26
+ `tessera-tests` parses test files with `ast` (never imports or runs them), inventories the test functions and methods, and surfaces the tests that aren't really protecting anything: tests with no assertions, and tests that are skipped or expected to fail.
27
+
28
+ ## Audit
29
+
30
+ ```bash
31
+ tessera tests audit --input . --output ./out/tests_pack
32
+ ```
33
+
34
+ Test discovery follows pytest/unittest conventions: files named `test_*.py` / `*_test.py` or under a `tests/` directory; functions named `test*`; methods named `test*` inside `Test*` classes.
35
+
36
+ Artifacts written:
37
+
38
+ ```text
39
+ tests.jsonl one TestCase per test (asserts, skip/xfail/param flags)
40
+ index.md the test inventory
41
+ validation_report.md hygiene findings
42
+ coverage_report.md counts (skipped/xfail/parametrized/no-assert) + per-file
43
+ not_running.md skipped + xfail tests (present but not protecting anything)
44
+ ```
45
+
46
+ ## What it detects
47
+
48
+ - **Assertions**: `assert` statements, `self.assert*` calls, and `pytest.raises`/`warns` blocks.
49
+ - **Markers**: `@pytest.mark.skip` / `skipif`, `xfail`, `parametrize` (matched on the decorator name).
50
+
51
+ ## Findings
52
+
53
+ - `no_assertion_test` (warning) — a test with zero assertions that isn't skipped/xfail
54
+ - `skipped_test` (info) — a skipped test
55
+ - `xfail_test` (info) — an expected-failure test
56
+ - `parse_error`, `no_tests_found`
@@ -0,0 +1,35 @@
1
+ # tesserakit-tests
2
+
3
+ Audit a Python test suite for hygiene problems.
4
+
5
+ `tessera-tests` parses test files with `ast` (never imports or runs them), inventories the test functions and methods, and surfaces the tests that aren't really protecting anything: tests with no assertions, and tests that are skipped or expected to fail.
6
+
7
+ ## Audit
8
+
9
+ ```bash
10
+ tessera tests audit --input . --output ./out/tests_pack
11
+ ```
12
+
13
+ Test discovery follows pytest/unittest conventions: files named `test_*.py` / `*_test.py` or under a `tests/` directory; functions named `test*`; methods named `test*` inside `Test*` classes.
14
+
15
+ Artifacts written:
16
+
17
+ ```text
18
+ tests.jsonl one TestCase per test (asserts, skip/xfail/param flags)
19
+ index.md the test inventory
20
+ validation_report.md hygiene findings
21
+ coverage_report.md counts (skipped/xfail/parametrized/no-assert) + per-file
22
+ not_running.md skipped + xfail tests (present but not protecting anything)
23
+ ```
24
+
25
+ ## What it detects
26
+
27
+ - **Assertions**: `assert` statements, `self.assert*` calls, and `pytest.raises`/`warns` blocks.
28
+ - **Markers**: `@pytest.mark.skip` / `skipif`, `xfail`, `parametrize` (matched on the decorator name).
29
+
30
+ ## Findings
31
+
32
+ - `no_assertion_test` (warning) — a test with zero assertions that isn't skipped/xfail
33
+ - `skipped_test` (info) — a skipped test
34
+ - `xfail_test` (info) — an expected-failure test
35
+ - `parse_error`, `no_tests_found`
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tesserakit-tests"
7
+ version = "0.4.0"
8
+ description = "Tests job pack for Tessera: audit a Python test suite for hygiene (no-assert, skipped, xfail)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Shailesh Rawat" }]
12
+ dependencies = [
13
+ "tesserakit-core>=0.1.0",
14
+ "typer>=0.12",
15
+ "rich>=13.7",
16
+ "pydantic>=2.7",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Environment :: Console",
21
+ "Intended Audience :: Developers",
22
+ "Programming Language :: Python :: 3",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/ShaileshRawat1403/tessera"
27
+ Repository = "https://github.com/ShaileshRawat1403/tessera"
28
+ Issues = "https://github.com/ShaileshRawat1403/tessera/issues"
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=8.0"]
32
+
33
+ [project.entry-points."tessera.commands"]
34
+ tests = "tessera_tests.cli:register"
35
+
36
+ [project.entry-points."tessera.jobpacks"]
37
+ tests = "tessera_tests.pack:create_pack"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/tessera_tests"]
@@ -0,0 +1,3 @@
1
+ """Tessera tests pack."""
2
+
3
+ __version__ = "0.3.1"
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from tessera_core.models import RunContext
10
+
11
+ from tessera_tests.pack import TestsPack
12
+
13
+ console = Console()
14
+ tests_app = typer.Typer(help="Audit a Python test suite for hygiene (no-assert, skipped, xfail).")
15
+
16
+
17
+ @tests_app.command("audit")
18
+ def audit_cmd(
19
+ input: Path = typer.Option(Path("."), "--input", "-i", exists=True, readable=True, help="Project directory."),
20
+ output: Path = typer.Option(Path("tests_pack"), "--output", "-o", help="Output directory."),
21
+ ) -> None:
22
+ """Discover tests and report hygiene issues (no assertions, skipped, xfail)."""
23
+ ctx = RunContext(job_name="tests", output_dir=output)
24
+ pack = TestsPack()
25
+ artifacts = pack.run(input_path=input, ctx=ctx, options={})
26
+
27
+ table = Table(title="Tests Pack Created")
28
+ table.add_column("Artifact")
29
+ table.add_column("Path")
30
+ table.add_column("Kind")
31
+ for art in artifacts:
32
+ table.add_row(art.name, str(art.path), art.kind)
33
+ console.print(table)
34
+
35
+ summary = Table(title="Run Summary")
36
+ summary.add_column("Metric")
37
+ summary.add_column("Value")
38
+ summary.add_row("run_id", ctx.run_id)
39
+ summary.add_row("tests", str(ctx.metadata.get("record_count", 0)))
40
+ summary.add_row("findings", str(ctx.metadata.get("finding_count", 0)))
41
+ console.print(summary)
42
+
43
+
44
+ def register(root_app: typer.Typer) -> None:
45
+ root_app.add_typer(tests_app, name="tests")
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from tessera_core.artifacts import write_jsonl, write_markdown
8
+ from tessera_core.models import Artifact, RunContext, ValidationFinding
9
+
10
+ from tessera_tests.loader import load_test_records
11
+ from tessera_tests.schema import TestCase
12
+ from tessera_tests.validator import validate_test_records
13
+
14
+
15
+ def load_records(input_path: Path, options: dict[str, Any]) -> list[TestCase]:
16
+ return load_test_records(input_path, options)
17
+
18
+
19
+ def validate_records(cases: list[TestCase], options: dict[str, Any]) -> list[ValidationFinding]:
20
+ return validate_test_records(cases, options)
21
+
22
+
23
+ def write_artifacts(cases: list[TestCase], ctx: RunContext, options: dict[str, Any]) -> list[Artifact]:
24
+ ctx.output_dir.mkdir(parents=True, exist_ok=True)
25
+ findings: list[ValidationFinding] = ctx.metadata.get("findings") or validate_records(cases, options)
26
+
27
+ tests_jsonl = ctx.output_dir / "tests.jsonl"
28
+ index_md = ctx.output_dir / "index.md"
29
+ validation_md = ctx.output_dir / "validation_report.md"
30
+ coverage_md = ctx.output_dir / "coverage_report.md"
31
+ not_running_md = ctx.output_dir / "not_running.md"
32
+
33
+ write_jsonl(tests_jsonl, [c.model_dump() for c in cases])
34
+ write_markdown(index_md, _render_index(cases, options))
35
+ write_markdown(validation_md, _render_validation(cases, findings))
36
+ write_markdown(coverage_md, _render_coverage(cases))
37
+ write_markdown(not_running_md, _render_not_running(cases))
38
+
39
+ return [
40
+ Artifact(name="tests.jsonl", path=tests_jsonl, kind="jsonl"),
41
+ Artifact(name="index.md", path=index_md, kind="markdown"),
42
+ Artifact(name="validation_report.md", path=validation_md, kind="markdown"),
43
+ Artifact(name="coverage_report.md", path=coverage_md, kind="markdown"),
44
+ Artifact(name="not_running.md", path=not_running_md, kind="markdown"),
45
+ ]
46
+
47
+
48
+ def _render_index(cases: list[TestCase], options: dict[str, Any]) -> str:
49
+ lines = ["# Test Inventory", ""]
50
+ lines.append(f"- Test files: {options.get('_file_count', 0)}")
51
+ lines.append(f"- Tests: {len(cases)}")
52
+ no_assert = sum(1 for c in cases if not c.has_assert and not c.is_skipped and not c.is_xfail)
53
+ lines.append(f"- Tests with no assertions: {no_assert}")
54
+ lines.append("")
55
+ if not cases:
56
+ lines.append("_No tests found._")
57
+ return "\n".join(lines) + "\n"
58
+ lines.append("| Test | Asserts | Skipped | Xfail | Param | Location |")
59
+ lines.append("|---|---:|:--:|:--:|:--:|---|")
60
+ for c in cases:
61
+ lines.append(
62
+ f"| `{c.qualname}` | {c.assertion_count} "
63
+ f"| {'yes' if c.is_skipped else '-'} | {'yes' if c.is_xfail else '-'} "
64
+ f"| {'yes' if c.is_parametrized else '-'} | `{c.file}:{c.lineno}` |"
65
+ )
66
+ return "\n".join(lines) + "\n"
67
+
68
+
69
+ def _render_validation(cases: list[TestCase], findings: list[ValidationFinding]) -> str:
70
+ lines = ["# Validation Report", ""]
71
+ lines.append(f"- Tests: {len(cases)}")
72
+ lines.append(f"- Findings: {len(findings)}")
73
+ lines.append("")
74
+ by_sev = Counter(f.severity for f in findings)
75
+ lines.append("## Severity Breakdown")
76
+ lines.append("")
77
+ for sev in ("error", "warning", "info"):
78
+ lines.append(f"- {sev}: {by_sev.get(sev, 0)}")
79
+ lines.append("")
80
+ if findings:
81
+ lines.append("## Findings")
82
+ lines.append("")
83
+ for f in findings[:300]:
84
+ lines.append(f"- **{f.severity.upper()}** `{f.code}`: {f.message}")
85
+ return "\n".join(lines)
86
+
87
+
88
+ def _render_coverage(cases: list[TestCase]) -> str:
89
+ lines = ["# Coverage Report", ""]
90
+ lines.append(f"- Tests: {len(cases)}")
91
+ if not cases:
92
+ return "\n".join(lines) + "\n"
93
+ skipped = sum(1 for c in cases if c.is_skipped)
94
+ xfail = sum(1 for c in cases if c.is_xfail)
95
+ param = sum(1 for c in cases if c.is_parametrized)
96
+ no_assert = sum(1 for c in cases if not c.has_assert and not c.is_skipped and not c.is_xfail)
97
+ lines.append(f"- Skipped: {skipped}")
98
+ lines.append(f"- Xfail: {xfail}")
99
+ lines.append(f"- Parametrized: {param}")
100
+ lines.append(f"- No assertions: {no_assert}")
101
+ lines.append("")
102
+ by_file = Counter(c.file for c in cases)
103
+ lines.append("## Tests per file")
104
+ lines.append("")
105
+ for path, n in by_file.most_common():
106
+ lines.append(f"- `{path}`: {n}")
107
+ return "\n".join(lines) + "\n"
108
+
109
+
110
+ def _render_not_running(cases: list[TestCase]) -> str:
111
+ inactive = [c for c in cases if c.is_skipped or c.is_xfail]
112
+ lines = ["# Tests Not Effectively Running", ""]
113
+ lines.append("Skipped and xfail tests — present but not protecting anything right now.")
114
+ lines.append("")
115
+ if not inactive:
116
+ lines.append("_All discovered tests are active._")
117
+ return "\n".join(lines) + "\n"
118
+ for c in inactive:
119
+ tag = "skip" if c.is_skipped else "xfail"
120
+ lines.append(f"- **{tag}** `{c.qualname}` ({c.file}:{c.lineno})")
121
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from tessera_tests.scan import discover_test_files, extract_tests
7
+ from tessera_tests.schema import TestCase
8
+
9
+
10
+ def load_test_records(input_path: Path, options: dict[str, Any]) -> list[TestCase]:
11
+ root = input_path if input_path.is_dir() else input_path.parent
12
+ cases: list[TestCase] = []
13
+ parse_errors: list[str] = []
14
+ files = discover_test_files(root)
15
+ for f in files:
16
+ found, err = extract_tests(root, f)
17
+ cases.extend(found)
18
+ if err:
19
+ parse_errors.append(err)
20
+ options["_parse_errors"] = parse_errors
21
+ options["_file_count"] = len(files)
22
+ options["_root"] = str(root)
23
+ return cases
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from tessera_core.jobpack import JobPack
7
+ from tessera_core.models import Artifact, RunContext, ValidationFinding
8
+
9
+ from tessera_tests.compiler import load_records, validate_records, write_artifacts
10
+
11
+
12
+ class TestsPack(JobPack):
13
+ name = "tests"
14
+ version = "0.3.1"
15
+
16
+ def normalize(self, input_path: Path, options: dict[str, Any]) -> list[Any]:
17
+ return load_records(input_path, options)
18
+
19
+ def validate(
20
+ self,
21
+ records: list[Any],
22
+ options: dict[str, Any],
23
+ ) -> list[ValidationFinding]:
24
+ return validate_records(records, options)
25
+
26
+ def generate(
27
+ self,
28
+ records: list[Any],
29
+ ctx: RunContext,
30
+ options: dict[str, Any],
31
+ ) -> list[Artifact]:
32
+ return write_artifacts(records, ctx, options)
33
+
34
+
35
+ def create_pack() -> TestsPack:
36
+ return TestsPack()
@@ -0,0 +1,97 @@
1
+ """Discover tests and their hygiene facts via ast (no imports, no execution)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+
8
+ from tessera_tests.schema import TestCase
9
+
10
+ _IGNORE = {
11
+ ".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache",
12
+ "dist", "build", ".tox", "target",
13
+ }
14
+
15
+
16
+ def is_test_file(rel: Path) -> bool:
17
+ n = rel.name
18
+ if not n.endswith(".py"):
19
+ return False
20
+ if n.startswith("test_") or n.endswith("_test.py"):
21
+ return True
22
+ parts = [p.lower() for p in rel.parts[:-1]]
23
+ return any(p in ("test", "tests") for p in parts) and n != "__init__.py"
24
+
25
+
26
+ def discover_test_files(root: Path) -> list[Path]:
27
+ out: list[Path] = []
28
+ for p in sorted(root.rglob("*.py")):
29
+ rel = p.relative_to(root)
30
+ if any(part in _IGNORE for part in rel.parts):
31
+ continue
32
+ if is_test_file(rel):
33
+ out.append(p)
34
+ return out
35
+
36
+
37
+ def _decorator_str(node: ast.expr) -> str:
38
+ if isinstance(node, ast.Call):
39
+ return _decorator_str(node.func)
40
+ if isinstance(node, ast.Attribute):
41
+ return f"{_decorator_str(node.value)}.{node.attr}"
42
+ if isinstance(node, ast.Name):
43
+ return node.id
44
+ return ""
45
+
46
+
47
+ def _count_assertions(fn: ast.AST) -> int:
48
+ count = 0
49
+ for node in ast.walk(fn):
50
+ if isinstance(node, ast.Assert):
51
+ count += 1
52
+ elif isinstance(node, ast.Call):
53
+ target = node.func
54
+ if isinstance(target, ast.Attribute):
55
+ attr = target.attr.lower()
56
+ if attr.startswith("assert"):
57
+ count += 1
58
+ elif attr in ("raises", "warns", "deprecated_call"):
59
+ count += 1
60
+ return count
61
+
62
+
63
+ def _make_case(fn: ast.AST, rel: str, kind: str, prefix: str) -> TestCase:
64
+ decorators = [d for d in (_decorator_str(x) for x in fn.decorator_list) if d]
65
+ lowered = " ".join(decorators).lower()
66
+ n = _count_assertions(fn)
67
+ return TestCase(
68
+ name=fn.name,
69
+ qualname=f"{prefix}{fn.name}",
70
+ kind=kind,
71
+ file=rel,
72
+ lineno=fn.lineno,
73
+ decorators=decorators,
74
+ is_skipped="skip" in lowered,
75
+ is_xfail="xfail" in lowered,
76
+ is_parametrized="parametrize" in lowered,
77
+ assertion_count=n,
78
+ has_assert=n > 0,
79
+ )
80
+
81
+
82
+ def extract_tests(root: Path, path: Path) -> tuple[list[TestCase], str | None]:
83
+ rel = path.relative_to(root).as_posix()
84
+ try:
85
+ tree = ast.parse(path.read_text(encoding="utf-8"))
86
+ except (OSError, UnicodeDecodeError, SyntaxError) as exc:
87
+ return [], f"{rel}: {exc}"
88
+
89
+ cases: list[TestCase] = []
90
+ for node in tree.body:
91
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test"):
92
+ cases.append(_make_case(node, rel, "function", ""))
93
+ elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
94
+ for sub in node.body:
95
+ if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)) and sub.name.startswith("test"):
96
+ cases.append(_make_case(sub, rel, "method", f"{node.name}."))
97
+ return cases, None
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class TestCase(BaseModel):
9
+ """A discovered test. Serialized to ``tests.jsonl``."""
10
+
11
+ # Tell pytest not to collect this domain model as a test class.
12
+ __test__ = False
13
+
14
+ name: str
15
+ qualname: str = ""
16
+ kind: str = "function" # function / method
17
+ file: str = ""
18
+ lineno: int = 0
19
+ decorators: list[str] = Field(default_factory=list)
20
+ is_skipped: bool = False
21
+ is_xfail: bool = False
22
+ is_parametrized: bool = False
23
+ assertion_count: int = 0
24
+ has_assert: bool = False
25
+ metadata: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from tessera_core.models import ValidationFinding
6
+
7
+ from tessera_tests.schema import TestCase
8
+
9
+
10
+ def validate_test_records(cases: list[TestCase], options: dict[str, Any]) -> list[ValidationFinding]:
11
+ findings: list[ValidationFinding] = []
12
+
13
+ for err in options.get("_parse_errors", []):
14
+ findings.append(ValidationFinding(severity="error", code="parse_error",
15
+ message=f"could not parse: {err}", field=None))
16
+
17
+ if not cases:
18
+ if not options.get("_parse_errors"):
19
+ findings.append(ValidationFinding(severity="info", code="no_tests_found",
20
+ message="no test functions discovered", field=None))
21
+ return findings
22
+
23
+ for c in cases:
24
+ loc = f"{c.file}:{c.lineno}"
25
+
26
+ def f(severity: str, code: str, message: str) -> ValidationFinding:
27
+ return ValidationFinding(severity=severity, code=code, message=message,
28
+ field="tests", metadata={"file": c.file, "lineno": c.lineno, "name": c.qualname})
29
+
30
+ if not c.has_assert and not c.is_skipped and not c.is_xfail:
31
+ findings.append(f("warning", "no_assertion_test",
32
+ f"{loc}: test `{c.qualname}` has no assertions"))
33
+ if c.is_skipped:
34
+ findings.append(f("info", "skipped_test", f"{loc}: test `{c.qualname}` is skipped"))
35
+ if c.is_xfail:
36
+ findings.append(f("info", "xfail_test", f"{loc}: test `{c.qualname}` is expected to fail"))
37
+
38
+ return findings
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from tessera_core.models import RunContext
7
+
8
+ from tessera_tests.pack import TestsPack
9
+ from tessera_tests.scan import extract_tests, is_test_file
10
+ from tessera_tests.schema import TestCase
11
+
12
+ REPO_ROOT = Path(__file__).resolve().parents[3]
13
+ SAMPLE = REPO_ROOT / "examples" / "tests"
14
+ SAMPLE_FILE = SAMPLE / "sample" / "test_sample.py"
15
+
16
+
17
+ # ---------- discovery / extraction ----------
18
+
19
+
20
+ def test_is_test_file():
21
+ assert is_test_file(Path("tests/test_x.py"))
22
+ assert is_test_file(Path("foo_test.py"))
23
+ assert not is_test_file(Path("src/app.py"))
24
+
25
+
26
+ def test_extract_flags():
27
+ cases, err = extract_tests(SAMPLE, SAMPLE_FILE)
28
+ assert err is None
29
+ by = {c.qualname: c for c in cases}
30
+
31
+ assert by["test_passes_with_assert"].has_assert
32
+ assert by["test_no_assertions"].has_assert is False
33
+ assert by["test_skipped"].is_skipped
34
+ assert by["test_expected_fail"].is_xfail
35
+ assert by["test_parametrized"].is_parametrized and by["test_parametrized"].has_assert
36
+ assert by["TestThing.test_method_with_assert"].kind == "method"
37
+ assert by["TestThing.test_method_with_assert"].has_assert # self.assertEqual
38
+
39
+
40
+ # ---------- end-to-end ----------
41
+
42
+
43
+ def _run(tmp_path: Path):
44
+ out = tmp_path / "tests_pack"
45
+ ctx = RunContext(job_name="tests", output_dir=out)
46
+ TestsPack().run(input_path=SAMPLE, ctx=ctx, options={})
47
+ return out, ctx
48
+
49
+
50
+ def test_findings(tmp_path: Path):
51
+ _, ctx = _run(tmp_path)
52
+ codes = {f.code for f in ctx.metadata["findings"]}
53
+ assert "no_assertion_test" in codes # test_no_assertions
54
+ assert "skipped_test" in codes
55
+ assert "xfail_test" in codes
56
+
57
+
58
+ def test_no_assertion_excludes_skipped(tmp_path: Path):
59
+ """A skipped test with no real assertion must not be double-flagged as no_assertion."""
60
+ _, ctx = _run(tmp_path)
61
+ no_assert = [f for f in ctx.metadata["findings"] if f.code == "no_assertion_test"]
62
+ flagged = {f.metadata.get("name") for f in no_assert}
63
+ assert "test_skipped" not in flagged
64
+ assert "test_no_assertions" in flagged
65
+
66
+
67
+ def test_artifacts_and_not_running(tmp_path: Path):
68
+ out, _ = _run(tmp_path)
69
+ names = {p.name for p in out.iterdir()}
70
+ assert {
71
+ "tests.jsonl", "index.md", "validation_report.md",
72
+ "coverage_report.md", "not_running.md",
73
+ } <= names
74
+ not_running = (out / "not_running.md").read_text()
75
+ assert "test_skipped" in not_running
76
+ assert "test_expected_fail" in not_running
77
+
78
+
79
+ def test_round_trip(tmp_path: Path):
80
+ out, _ = _run(tmp_path)
81
+ for line in (out / "tests.jsonl").read_text().splitlines():
82
+ c = TestCase.model_validate_json(line)
83
+ assert c.name