test_knowtation_policies.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
27 days ago
| 1 | """Tests for Phase 2.3 — Knowtation Harmony merge policies. |
| 2 | |
| 3 | Test tiers covered |
| 4 | ------------------ |
| 5 | 1. Unit — HARMONY_POLICIES: exactly 6 policies, unique names, valid strategies, |
| 6 | correct priority ordering, required fields present. |
| 7 | 2. Unit — Per-policy fixture: each policy row resolves the expected strategy |
| 8 | for a matching path+dimension pair. |
| 9 | 3. Unit — to_attribute_rules(): returns 6 rules sorted by priority descending. |
| 10 | 4. Unit — resolve_harmony_strategy(): first-match semantics, dimension=frontmatter |
| 11 | vs dimension=*, default catch-all. |
| 12 | 5. Integration — write_harmony_museattributes(): file written, valid TOML, |
| 13 | all 6 policies present; overwrite=False prevents re-write; overwrite=True |
| 14 | replaces. |
| 15 | 6. Integration — synthetic merge scenario per policy: strategy resolved matches |
| 16 | expectation for representative path+dimension pairs. |
| 17 | """ |
| 18 | |
| 19 | from __future__ import annotations |
| 20 | |
| 21 | import pathlib |
| 22 | import textwrap |
| 23 | |
| 24 | import pytest |
| 25 | |
| 26 | try: |
| 27 | import tomllib |
| 28 | except ImportError: |
| 29 | import tomli as tomllib # type: ignore[no-reattr] |
| 30 | |
| 31 | from muse.core.attributes import VALID_STRATEGIES |
| 32 | from muse.plugins.knowtation.policies import ( |
| 33 | HARMONY_POLICIES, |
| 34 | HarmonyPolicy, |
| 35 | resolve_harmony_strategy, |
| 36 | to_attribute_rules, |
| 37 | write_harmony_museattributes, |
| 38 | ) |
| 39 | |
| 40 | |
| 41 | # ============================================================================ |
| 42 | # TestHarmonyPoliciesTable |
| 43 | # ============================================================================ |
| 44 | |
| 45 | |
| 46 | class TestHarmonyPoliciesTable: |
| 47 | def test_exactly_six_policies(self) -> None: |
| 48 | assert len(HARMONY_POLICIES) == 6 |
| 49 | |
| 50 | def test_all_names_unique(self) -> None: |
| 51 | names = [p.name for p in HARMONY_POLICIES] |
| 52 | assert len(names) == len(set(names)), f"Duplicate policy names: {names}" |
| 53 | |
| 54 | def test_all_strategies_valid(self) -> None: |
| 55 | for p in HARMONY_POLICIES: |
| 56 | assert p.strategy in VALID_STRATEGIES, ( |
| 57 | f"Policy '{p.name}' uses invalid strategy '{p.strategy}'" |
| 58 | ) |
| 59 | |
| 60 | def test_priorities_non_negative(self) -> None: |
| 61 | for p in HARMONY_POLICIES: |
| 62 | assert p.priority >= 0, f"Policy '{p.name}' has negative priority" |
| 63 | |
| 64 | def test_priorities_all_different(self) -> None: |
| 65 | priorities = [p.priority for p in HARMONY_POLICIES] |
| 66 | assert len(priorities) == len(set(priorities)), ( |
| 67 | f"Duplicate priorities: {priorities}" |
| 68 | ) |
| 69 | |
| 70 | def test_all_have_comments(self) -> None: |
| 71 | for p in HARMONY_POLICIES: |
| 72 | assert p.comment, f"Policy '{p.name}' has no comment" |
| 73 | |
| 74 | def test_all_have_path_pattern(self) -> None: |
| 75 | for p in HARMONY_POLICIES: |
| 76 | assert p.path_pattern, f"Policy '{p.name}' has empty path_pattern" |
| 77 | |
| 78 | def test_all_have_dimension(self) -> None: |
| 79 | for p in HARMONY_POLICIES: |
| 80 | assert p.dimension, f"Policy '{p.name}' has empty dimension" |
| 81 | |
| 82 | def test_default_policy_has_lowest_priority(self) -> None: |
| 83 | default = next((p for p in HARMONY_POLICIES if p.name == "default"), None) |
| 84 | assert default is not None, "'default' policy not found" |
| 85 | min_priority = min(p.priority for p in HARMONY_POLICIES) |
| 86 | assert default.priority == min_priority |
| 87 | |
| 88 | def test_archive_policy_has_highest_priority(self) -> None: |
| 89 | archive = next((p for p in HARMONY_POLICIES if p.name == "archive"), None) |
| 90 | assert archive is not None, "'archive' policy not found" |
| 91 | max_priority = max(p.priority for p in HARMONY_POLICIES) |
| 92 | assert archive.priority == max_priority |
| 93 | |
| 94 | def test_harmony_policy_is_frozen(self) -> None: |
| 95 | import dataclasses |
| 96 | |
| 97 | p = HARMONY_POLICIES[0] |
| 98 | with pytest.raises((AttributeError, TypeError, dataclasses.FrozenInstanceError)): |
| 99 | p.strategy = "hacked" # type: ignore[misc] |
| 100 | |
| 101 | |
| 102 | # ============================================================================ |
| 103 | # TestPerPolicyFixtures (one fixture per policy row) |
| 104 | # ============================================================================ |
| 105 | |
| 106 | |
| 107 | class TestPerPolicyFixtures: |
| 108 | """Each test verifies that a canonical path+dimension pair resolves to the |
| 109 | expected strategy via resolve_harmony_strategy().""" |
| 110 | |
| 111 | def test_archive_policy(self) -> None: |
| 112 | assert resolve_harmony_strategy("archive/old-note.md", "*") == "ours" |
| 113 | |
| 114 | def test_archive_policy_nested(self) -> None: |
| 115 | assert resolve_harmony_strategy("archive/2024/jan/note.md", "*") == "ours" |
| 116 | |
| 117 | def test_inbox_policy(self) -> None: |
| 118 | strategy = resolve_harmony_strategy("inbox/capture.md", "*") |
| 119 | assert strategy == "prefer-newer-date" |
| 120 | |
| 121 | def test_inbox_policy_nested(self) -> None: |
| 122 | assert ( |
| 123 | resolve_harmony_strategy("inbox/subdir/item.md", "*") |
| 124 | == "prefer-newer-date" |
| 125 | ) |
| 126 | |
| 127 | def test_capture_policy(self) -> None: |
| 128 | assert ( |
| 129 | resolve_harmony_strategy("captures/pdf-import.md", "*") |
| 130 | == "prefer-newer-date" |
| 131 | ) |
| 132 | |
| 133 | def test_tags_policy_frontmatter_dimension(self) -> None: |
| 134 | strategy = resolve_harmony_strategy("projects/born-free/note.md", "frontmatter") |
| 135 | assert strategy == "union-sorted" |
| 136 | |
| 137 | def test_tags_policy_any_md_file(self) -> None: |
| 138 | strategy = resolve_harmony_strategy("areas/health/daily.md", "frontmatter") |
| 139 | assert strategy == "union-sorted" |
| 140 | |
| 141 | def test_project_policy_sections_dimension(self) -> None: |
| 142 | # The tags rule (frontmatter dim) doesn't match dimension="sections"; |
| 143 | # so the project rule (knowtation-3way, priority 20) fires first. |
| 144 | strategy = resolve_harmony_strategy("projects/born-free/analysis.md", "sections") |
| 145 | assert strategy == "knowtation-3way" |
| 146 | |
| 147 | def test_project_policy_star_dimension_tags_wins(self) -> None: |
| 148 | # When dimension="*", the **/*.md tags rule (priority 30) matches before |
| 149 | # the projects/** rule (priority 20) because 30 > 20. |
| 150 | strategy = resolve_harmony_strategy("projects/born-free/analysis.md", "*") |
| 151 | assert strategy == "union-sorted" |
| 152 | |
| 153 | def test_project_policy_nested_path(self) -> None: |
| 154 | # projects/born-free/inbox/note.md — "inbox/**" only matches paths that |
| 155 | # start with "inbox/", not nested. Tags rule (priority 30) matches first. |
| 156 | strategy = resolve_harmony_strategy("projects/born-free/inbox/note.md", "*") |
| 157 | assert strategy == "union-sorted" |
| 158 | |
| 159 | def test_default_policy_catch_all_non_md(self) -> None: |
| 160 | # .txt files don't match "**/*.md", so default (auto) fires. |
| 161 | assert resolve_harmony_strategy("uncategorized/random.txt", "*") == "auto" |
| 162 | |
| 163 | def test_default_policy_unknown_dimension_non_md(self) -> None: |
| 164 | assert resolve_harmony_strategy("something/entirely/different.json", "notes") == "auto" |
| 165 | |
| 166 | def test_archive_beats_tags_for_frontmatter(self) -> None: |
| 167 | # archive (priority 50) beats tags rule (priority 30) on any dimension. |
| 168 | assert resolve_harmony_strategy("archive/note.md", "frontmatter") == "ours" |
| 169 | |
| 170 | def test_archive_beats_tags_for_sections(self) -> None: |
| 171 | assert resolve_harmony_strategy("archive/note.md", "sections") == "ours" |
| 172 | |
| 173 | |
| 174 | # ============================================================================ |
| 175 | # TestToAttributeRules |
| 176 | # ============================================================================ |
| 177 | |
| 178 | |
| 179 | class TestToAttributeRules: |
| 180 | def test_returns_six_rules(self) -> None: |
| 181 | rules = to_attribute_rules() |
| 182 | assert len(rules) == 6 |
| 183 | |
| 184 | def test_sorted_by_priority_descending(self) -> None: |
| 185 | rules = to_attribute_rules() |
| 186 | priorities = [r.priority for r in rules] |
| 187 | assert priorities == sorted(priorities, reverse=True) |
| 188 | |
| 189 | def test_all_strategies_valid(self) -> None: |
| 190 | for rule in to_attribute_rules(): |
| 191 | assert rule.strategy in VALID_STRATEGIES |
| 192 | |
| 193 | def test_highest_priority_rule_first(self) -> None: |
| 194 | rules = to_attribute_rules() |
| 195 | max_p = max(p.priority for p in HARMONY_POLICIES) |
| 196 | assert rules[0].priority == max_p |
| 197 | |
| 198 | def test_lowest_priority_rule_last(self) -> None: |
| 199 | rules = to_attribute_rules() |
| 200 | min_p = min(p.priority for p in HARMONY_POLICIES) |
| 201 | assert rules[-1].priority == min_p |
| 202 | |
| 203 | |
| 204 | # ============================================================================ |
| 205 | # TestResolveHarmonyStrategy |
| 206 | # ============================================================================ |
| 207 | |
| 208 | |
| 209 | class TestResolveHarmonyStrategy: |
| 210 | def test_archive_star_dimension(self) -> None: |
| 211 | # archive rule (priority 50, dimension=*) → ours |
| 212 | strategy = resolve_harmony_strategy("archive/note.md", "notes") |
| 213 | assert strategy == "ours" |
| 214 | |
| 215 | def test_frontmatter_dimension_matched(self) -> None: |
| 216 | strategy = resolve_harmony_strategy("areas/health.md", "frontmatter") |
| 217 | assert strategy == "union-sorted" |
| 218 | |
| 219 | def test_notes_dimension_falls_to_project(self) -> None: |
| 220 | # The tags rule has dimension=frontmatter; doesn't match "notes" |
| 221 | # → project rule (knowtation-3way) fires. |
| 222 | strategy = resolve_harmony_strategy("projects/x/note.md", "notes") |
| 223 | assert strategy == "knowtation-3way" |
| 224 | |
| 225 | def test_default_returns_auto_for_non_md(self) -> None: |
| 226 | strategy = resolve_harmony_strategy("random_path.toml", "config") |
| 227 | assert strategy == "auto" |
| 228 | |
| 229 | def test_returns_string(self) -> None: |
| 230 | assert isinstance(resolve_harmony_strategy("*", "*"), str) |
| 231 | |
| 232 | |
| 233 | # ============================================================================ |
| 234 | # TestWriteHarmonyMuseattributes |
| 235 | # ============================================================================ |
| 236 | |
| 237 | |
| 238 | class TestWriteHarmonyMuseattributes: |
| 239 | def test_creates_file(self, tmp_path: pathlib.Path) -> None: |
| 240 | write_harmony_museattributes(tmp_path) |
| 241 | assert (tmp_path / ".museattributes").exists() |
| 242 | |
| 243 | def test_written_file_is_valid_toml(self, tmp_path: pathlib.Path) -> None: |
| 244 | write_harmony_museattributes(tmp_path) |
| 245 | content = (tmp_path / ".museattributes").read_text(encoding="utf-8") |
| 246 | parsed = tomllib.loads(content) |
| 247 | assert isinstance(parsed, dict) |
| 248 | |
| 249 | def test_written_file_has_six_rules(self, tmp_path: pathlib.Path) -> None: |
| 250 | write_harmony_museattributes(tmp_path) |
| 251 | content = (tmp_path / ".museattributes").read_text(encoding="utf-8") |
| 252 | parsed = tomllib.loads(content) |
| 253 | assert len(parsed.get("rules", [])) == 6 |
| 254 | |
| 255 | def test_written_file_meta_domain_is_knowtation(self, tmp_path: pathlib.Path) -> None: |
| 256 | write_harmony_museattributes(tmp_path) |
| 257 | content = (tmp_path / ".museattributes").read_text(encoding="utf-8") |
| 258 | parsed = tomllib.loads(content) |
| 259 | assert parsed.get("meta", {}).get("domain") == "knowtation" |
| 260 | |
| 261 | def test_overwrite_false_raises_on_existing(self, tmp_path: pathlib.Path) -> None: |
| 262 | write_harmony_museattributes(tmp_path) |
| 263 | with pytest.raises(FileExistsError): |
| 264 | write_harmony_museattributes(tmp_path, overwrite=False) |
| 265 | |
| 266 | def test_overwrite_true_replaces_file(self, tmp_path: pathlib.Path) -> None: |
| 267 | write_harmony_museattributes(tmp_path) |
| 268 | write_harmony_museattributes(tmp_path, overwrite=True) |
| 269 | content = (tmp_path / ".museattributes").read_text(encoding="utf-8") |
| 270 | parsed = tomllib.loads(content) |
| 271 | assert len(parsed.get("rules", [])) == 6 |
| 272 | |
| 273 | def test_all_policy_strategies_in_written_file(self, tmp_path: pathlib.Path) -> None: |
| 274 | write_harmony_museattributes(tmp_path) |
| 275 | content = (tmp_path / ".museattributes").read_text(encoding="utf-8") |
| 276 | for policy in HARMONY_POLICIES: |
| 277 | assert policy.strategy in content, ( |
| 278 | f"Strategy '{policy.strategy}' missing from written file" |
| 279 | ) |
| 280 | |
| 281 | def test_written_file_loadable_by_load_attributes(self, tmp_path: pathlib.Path) -> None: |
| 282 | from muse.core.attributes import load_attributes |
| 283 | |
| 284 | write_harmony_museattributes(tmp_path) |
| 285 | rules = load_attributes(tmp_path, domain="knowtation") |
| 286 | assert len(rules) == 6 |
| 287 | |
| 288 | def test_written_file_resolves_archive_correctly(self, tmp_path: pathlib.Path) -> None: |
| 289 | from muse.core.attributes import load_attributes, resolve_strategy |
| 290 | |
| 291 | write_harmony_museattributes(tmp_path) |
| 292 | rules = load_attributes(tmp_path, domain="knowtation") |
| 293 | assert resolve_strategy(rules, "archive/note.md") == "ours" |
| 294 | |
| 295 | def test_written_file_resolves_inbox_correctly(self, tmp_path: pathlib.Path) -> None: |
| 296 | from muse.core.attributes import load_attributes, resolve_strategy |
| 297 | |
| 298 | write_harmony_museattributes(tmp_path) |
| 299 | rules = load_attributes(tmp_path, domain="knowtation") |
| 300 | assert resolve_strategy(rules, "inbox/note.md") == "prefer-newer-date" |
| 301 | |
| 302 | |
| 303 | # ============================================================================ |
| 304 | # TestSyntheticMergeScenarios (integration — per policy) |
| 305 | # ============================================================================ |
| 306 | |
| 307 | |
| 308 | class TestSyntheticMergeScenarios: |
| 309 | """Simulate what the merger would see by checking resolved strategies.""" |
| 310 | |
| 311 | def _rules(self) -> list: |
| 312 | return to_attribute_rules() |
| 313 | |
| 314 | def test_scenario_archive_note_conflict(self) -> None: |
| 315 | from muse.core.attributes import resolve_strategy |
| 316 | |
| 317 | strategy = resolve_strategy(self._rules(), "archive/2024/jan/note.md", "*") |
| 318 | assert strategy == "ours" |
| 319 | |
| 320 | def test_scenario_inbox_telegram_capture(self) -> None: |
| 321 | from muse.core.attributes import resolve_strategy |
| 322 | |
| 323 | strategy = resolve_strategy(self._rules(), "inbox/telegram-2025-06-01.md", "*") |
| 324 | assert strategy == "prefer-newer-date" |
| 325 | |
| 326 | def test_scenario_project_collaboration_note_sections(self) -> None: |
| 327 | from muse.core.attributes import resolve_strategy |
| 328 | |
| 329 | # Sections dimension: tags rule (frontmatter) doesn't match → project rule |
| 330 | strategy = resolve_strategy(self._rules(), "projects/born-free/meeting-2025.md", "sections") |
| 331 | assert strategy == "knowtation-3way" |
| 332 | |
| 333 | def test_scenario_frontmatter_tag_update(self) -> None: |
| 334 | from muse.core.attributes import resolve_strategy |
| 335 | |
| 336 | strategy = resolve_strategy( |
| 337 | self._rules(), "projects/born-free/analysis.md", "frontmatter" |
| 338 | ) |
| 339 | # frontmatter dimension → union-sorted (tags rule, priority 30) |
| 340 | assert strategy == "union-sorted" |
| 341 | |
| 342 | def test_scenario_pdf_import(self) -> None: |
| 343 | from muse.core.attributes import resolve_strategy |
| 344 | |
| 345 | strategy = resolve_strategy(self._rules(), "captures/imported-pdf.md", "*") |
| 346 | assert strategy == "prefer-newer-date" |
| 347 | |
| 348 | def test_scenario_uncategorised_md_note_star_dim(self) -> None: |
| 349 | from muse.core.attributes import resolve_strategy |
| 350 | |
| 351 | # .md file with dimension="*": tags rule (priority 30) matches **/*.md |
| 352 | strategy = resolve_strategy(self._rules(), "ideas/random-thought.md", "*") |
| 353 | assert strategy == "union-sorted" |
| 354 | |
| 355 | def test_scenario_uncategorised_non_md_default(self) -> None: |
| 356 | from muse.core.attributes import resolve_strategy |
| 357 | |
| 358 | # Non-.md file: only the default rule (*) matches |
| 359 | strategy = resolve_strategy(self._rules(), "ideas/sketch.txt", "*") |
| 360 | assert strategy == "auto" |
| 361 | |
| 362 | def test_scenario_archive_beats_everything(self) -> None: |
| 363 | from muse.core.attributes import resolve_strategy |
| 364 | |
| 365 | strategy = resolve_strategy( |
| 366 | self._rules(), "archive/projects/old-capture.md", "frontmatter" |
| 367 | ) |
| 368 | assert strategy == "ours" |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
27 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
27 days ago