Coverage for little_loops / learning_tests.py: 97%
75 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
1"""Learning test registry for little-loops.
3Provides CRUD operations for learning test records stored as YAML-frontmatter
4markdown files under .ll/learning-tests/<slug>.md.
6Reading uses yaml.safe_load on the raw frontmatter block (rather than the
7hand-rolled parse_frontmatter) because the assertions field is a block
8sequence of dicts, which parse_frontmatter cannot deserialize.
9"""
11from __future__ import annotations
13import re
14from dataclasses import dataclass
15from pathlib import Path
16from typing import Any, Literal
18import yaml
20from little_loops.frontmatter import update_frontmatter
21from little_loops.issue_parser import slugify
23_DEFAULT_BASE_DIR = Path(".ll") / "learning-tests"
26@dataclass
27class Assertion:
28 """A single claim tested against an API or library."""
30 claim: str
31 result: Literal["pass", "fail", "untested"]
33 def to_dict(self) -> dict[str, str]:
34 return {"claim": self.claim, "result": self.result}
36 @classmethod
37 def from_dict(cls, data: dict[str, Any]) -> Assertion:
38 return cls(
39 claim=data["claim"],
40 result=data.get("result", "untested"),
41 )
44@dataclass
45class LearnTestRecord:
46 """A learning test record capturing what is known about an API or library."""
48 target: str
49 date: str
50 status: Literal["proven", "refuted", "stale"]
51 assertions: list[Assertion]
52 raw_output_path: str | None
54 def to_dict(self) -> dict[str, Any]:
55 return {
56 "target": self.target,
57 "date": self.date,
58 "status": self.status,
59 "assertions": [a.to_dict() for a in self.assertions],
60 "raw_output_path": self.raw_output_path,
61 }
63 @classmethod
64 def from_dict(cls, data: dict[str, Any]) -> LearnTestRecord:
65 return cls(
66 target=data["target"],
67 date=data["date"],
68 status=data.get("status", "proven"),
69 assertions=[Assertion.from_dict(a) for a in (data.get("assertions") or [])],
70 raw_output_path=data.get("raw_output_path"),
71 )
74def _resolve_base(base_dir: Path | None) -> Path:
75 return base_dir if base_dir is not None else Path.cwd() / _DEFAULT_BASE_DIR
78def _slug_path(target_slug: str, base_dir: Path) -> Path:
79 return base_dir / f"{target_slug}.md"
82def _read_frontmatter_yaml(content: str) -> dict[str, Any] | None:
83 """Extract and parse the YAML frontmatter block using yaml.safe_load."""
84 fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
85 if not fm_match:
86 return None
87 return yaml.safe_load(fm_match.group(1)) or {}
90def write_record(record: LearnTestRecord, *, base_dir: Path | None = None) -> Path:
91 """Write a LearnTestRecord to .ll/learning-tests/<slug>.md.
93 Overwrites any existing file for the same target slug.
94 Returns the path of the written file.
95 """
96 base = _resolve_base(base_dir)
97 base.mkdir(parents=True, exist_ok=True)
98 slug = slugify(record.target)
99 path = _slug_path(slug, base)
100 fm_text = yaml.dump(record.to_dict(), default_flow_style=False, sort_keys=False).strip()
101 path.write_text(f"---\n{fm_text}\n---\n")
102 return path
105def read_record(target_slug: str, *, base_dir: Path | None = None) -> LearnTestRecord | None:
106 """Read a LearnTestRecord by slug. Returns None if not found."""
107 base = _resolve_base(base_dir)
108 path = _slug_path(target_slug, base)
109 if not path.exists():
110 return None
111 data = _read_frontmatter_yaml(path.read_text())
112 if data is None:
113 return None
114 return LearnTestRecord.from_dict(data)
117def list_records(*, base_dir: Path | None = None) -> list[LearnTestRecord]:
118 """Return all LearnTestRecord objects from the registry directory."""
119 base = _resolve_base(base_dir)
120 if not base.exists():
121 return []
122 records = []
123 for md_file in sorted(base.glob("*.md")):
124 data = _read_frontmatter_yaml(md_file.read_text())
125 if data is not None:
126 records.append(LearnTestRecord.from_dict(data))
127 return records
130def mark_stale(target_slug: str, *, base_dir: Path | None = None) -> None:
131 """Set status to 'stale' on an existing record, preserving all other fields."""
132 base = _resolve_base(base_dir)
133 path = _slug_path(target_slug, base)
134 if not path.exists():
135 return
136 updated = update_frontmatter(path.read_text(), {"status": "stale"})
137 path.write_text(updated)
140def check_learning_test(target: str, *, base_dir: Path | None = None) -> LearnTestRecord | None:
141 """Look up a record by target name (slugified). Returns None if not found."""
142 return read_record(slugify(target), base_dir=base_dir)