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

1"""Learning test registry for little-loops. 

2 

3Provides CRUD operations for learning test records stored as YAML-frontmatter 

4markdown files under .ll/learning-tests/<slug>.md. 

5 

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""" 

10 

11from __future__ import annotations 

12 

13import re 

14from dataclasses import dataclass 

15from pathlib import Path 

16from typing import Any, Literal 

17 

18import yaml 

19 

20from little_loops.frontmatter import update_frontmatter 

21from little_loops.issue_parser import slugify 

22 

23_DEFAULT_BASE_DIR = Path(".ll") / "learning-tests" 

24 

25 

26@dataclass 

27class Assertion: 

28 """A single claim tested against an API or library.""" 

29 

30 claim: str 

31 result: Literal["pass", "fail", "untested"] 

32 

33 def to_dict(self) -> dict[str, str]: 

34 return {"claim": self.claim, "result": self.result} 

35 

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 ) 

42 

43 

44@dataclass 

45class LearnTestRecord: 

46 """A learning test record capturing what is known about an API or library.""" 

47 

48 target: str 

49 date: str 

50 status: Literal["proven", "refuted", "stale"] 

51 assertions: list[Assertion] 

52 raw_output_path: str | None 

53 

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 } 

62 

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 ) 

72 

73 

74def _resolve_base(base_dir: Path | None) -> Path: 

75 return base_dir if base_dir is not None else Path.cwd() / _DEFAULT_BASE_DIR 

76 

77 

78def _slug_path(target_slug: str, base_dir: Path) -> Path: 

79 return base_dir / f"{target_slug}.md" 

80 

81 

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 {} 

88 

89 

90def write_record(record: LearnTestRecord, *, base_dir: Path | None = None) -> Path: 

91 """Write a LearnTestRecord to .ll/learning-tests/<slug>.md. 

92 

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 

103 

104 

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) 

115 

116 

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 

128 

129 

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) 

138 

139 

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)