cgcardona / muse public
test_code_plugin.py python
1251 lines 47.2 KB
e6786943 feat: upgrade to Python 3.14, drop from __future__ import annotations Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """Tests for the code domain plugin.
2
3 Coverage
4 --------
5 Unit
6 - :mod:`muse.plugins.code.ast_parser`: symbol extraction, content IDs,
7 rename detection hashes, import handling.
8 - :mod:`muse.plugins.code.symbol_diff`: diff_symbol_trees golden cases,
9 cross-file move annotation.
10
11 Protocol conformance
12 - ``CodePlugin`` satisfies ``MuseDomainPlugin`` and ``StructuredMergePlugin``.
13
14 Snapshot
15 - Path form: walks all files, raw-bytes hash, honours .museignore.
16 - Manifest form: returned as-is.
17 - Stability: two calls on the same directory produce identical results.
18
19 Diff
20 - File-level (no repo_root): added / removed / modified.
21 - Semantic (with repo_root via object store): symbol-level PatchOps,
22 rename detection, formatting-only suppression.
23
24 Golden diff cases
25 - Add a new function → InsertOp inside PatchOp.
26 - Remove a function → DeleteOp inside PatchOp.
27 - Rename a function → ReplaceOp with "renamed to" in new_summary.
28 - Change function body → ReplaceOp with "implementation changed".
29 - Change function signature → ReplaceOp with "signature changed".
30 - Add a new file → InsertOp (or PatchOp with all-insert child ops).
31 - Remove a file → DeleteOp (or PatchOp with all-delete child ops).
32 - Reformat only → ReplaceOp with "reformatted" in new_summary.
33
34 Merge
35 - Different symbols in same file → auto-merge (no conflicts).
36 - Same symbol modified by both → symbol-level conflict address.
37 - Disjoint files → auto-merge.
38 - File-level three-way merge correctness.
39
40 Schema
41 - Valid DomainSchema with five dimensions.
42 - merge_mode == "three_way".
43 - schema_version == 1.
44
45 Drift
46 - No drift: committed equals live.
47 - Has drift: file added / modified / removed.
48
49 Plugin registry
50 - "code" is in the registered domain list.
51 """
52
53 import hashlib
54 import pathlib
55 import textwrap
56
57 import pytest
58
59 from muse.core.object_store import write_object
60 from muse.domain import (
61 InsertOp,
62 MuseDomainPlugin,
63 SnapshotManifest,
64 StructuredMergePlugin,
65 )
66 from muse.plugins.code.ast_parser import (
67 FallbackAdapter,
68 PythonAdapter,
69 SymbolRecord,
70 SymbolTree,
71 _extract_stmts,
72 _import_names,
73 _sha256,
74 adapter_for_path,
75 file_content_id,
76 parse_symbols,
77 )
78 from muse.plugins.code.plugin import CodePlugin, _hash_file
79 from muse.plugins.code.symbol_diff import (
80 build_diff_ops,
81 delta_summary,
82 diff_symbol_trees,
83 )
84 from muse.plugins.registry import registered_domains
85
86
87 # ---------------------------------------------------------------------------
88 # Helpers
89 # ---------------------------------------------------------------------------
90
91
92 def _sha256_bytes(b: bytes) -> str:
93 return hashlib.sha256(b).hexdigest()
94
95
96 def _make_manifest(files: dict[str, str]) -> SnapshotManifest:
97 return SnapshotManifest(files=files, domain="code")
98
99
100 def _src(code: str) -> bytes:
101 return textwrap.dedent(code).encode()
102
103
104 def _empty_tree() -> SymbolTree:
105 return {}
106
107
108 def _store_blob(repo_root: pathlib.Path, data: bytes) -> str:
109 oid = _sha256_bytes(data)
110 write_object(repo_root, oid, data)
111 return oid
112
113
114 # ---------------------------------------------------------------------------
115 # Plugin registry
116 # ---------------------------------------------------------------------------
117
118
119 def test_code_in_registry() -> None:
120 assert "code" in registered_domains()
121
122
123 # ---------------------------------------------------------------------------
124 # Protocol conformance
125 # ---------------------------------------------------------------------------
126
127
128 def test_satisfies_muse_domain_plugin() -> None:
129 plugin = CodePlugin()
130 assert isinstance(plugin, MuseDomainPlugin)
131
132
133 def test_satisfies_structured_merge_plugin() -> None:
134 plugin = CodePlugin()
135 assert isinstance(plugin, StructuredMergePlugin)
136
137
138 # ---------------------------------------------------------------------------
139 # PythonAdapter — unit tests
140 # ---------------------------------------------------------------------------
141
142
143 class TestPythonAdapter:
144 adapter = PythonAdapter()
145
146 def test_supported_extensions(self) -> None:
147 assert ".py" in self.adapter.supported_extensions()
148 assert ".pyi" in self.adapter.supported_extensions()
149
150 def test_parse_top_level_function(self) -> None:
151 src = _src("""\
152 def add(a: int, b: int) -> int:
153 return a + b
154 """)
155 tree = self.adapter.parse_symbols(src, "utils.py")
156 assert "utils.py::add" in tree
157 rec = tree["utils.py::add"]
158 assert rec["kind"] == "function"
159 assert rec["name"] == "add"
160 assert rec["qualified_name"] == "add"
161
162 def test_parse_async_function(self) -> None:
163 src = _src("""\
164 async def fetch(url: str) -> bytes:
165 pass
166 """)
167 tree = self.adapter.parse_symbols(src, "api.py")
168 assert "api.py::fetch" in tree
169 assert tree["api.py::fetch"]["kind"] == "async_function"
170
171 def test_parse_class_and_methods(self) -> None:
172 src = _src("""\
173 class Dog:
174 def bark(self) -> None:
175 print("woof")
176 def sit(self) -> None:
177 pass
178 """)
179 tree = self.adapter.parse_symbols(src, "animals.py")
180 assert "animals.py::Dog" in tree
181 assert tree["animals.py::Dog"]["kind"] == "class"
182 assert "animals.py::Dog.bark" in tree
183 assert tree["animals.py::Dog.bark"]["kind"] == "method"
184 assert "animals.py::Dog.sit" in tree
185
186 def test_parse_imports(self) -> None:
187 src = _src("""\
188 import os
189 import sys
190 from pathlib import Path
191 """)
192 tree = self.adapter.parse_symbols(src, "app.py")
193 assert "app.py::import::os" in tree
194 assert "app.py::import::sys" in tree
195 assert "app.py::import::Path" in tree
196
197 def test_parse_top_level_variable(self) -> None:
198 src = _src("""\
199 MAX_RETRIES = 3
200 VERSION: str = "1.0"
201 """)
202 tree = self.adapter.parse_symbols(src, "config.py")
203 assert "config.py::MAX_RETRIES" in tree
204 assert tree["config.py::MAX_RETRIES"]["kind"] == "variable"
205 assert "config.py::VERSION" in tree
206
207 def test_syntax_error_returns_empty_tree(self) -> None:
208 src = b"def broken("
209 tree = self.adapter.parse_symbols(src, "broken.py")
210 assert tree == {}
211
212 def test_content_id_stable_across_calls(self) -> None:
213 src = _src("""\
214 def hello() -> str:
215 return "world"
216 """)
217 t1 = self.adapter.parse_symbols(src, "a.py")
218 t2 = self.adapter.parse_symbols(src, "a.py")
219 assert t1["a.py::hello"]["content_id"] == t2["a.py::hello"]["content_id"]
220
221 def test_formatting_does_not_change_content_id(self) -> None:
222 """Reformatting a function must not change its content_id."""
223 src1 = _src("""\
224 def add(a, b):
225 return a + b
226 """)
227 src2 = _src("""\
228 def add(a,b):
229 return a + b
230 """)
231 t1 = self.adapter.parse_symbols(src1, "f.py")
232 t2 = self.adapter.parse_symbols(src2, "f.py")
233 assert t1["f.py::add"]["content_id"] == t2["f.py::add"]["content_id"]
234
235 def test_body_hash_differs_from_content_id(self) -> None:
236 src = _src("""\
237 def compute(x: int) -> int:
238 return x * 2
239 """)
240 tree = self.adapter.parse_symbols(src, "m.py")
241 rec = tree["m.py::compute"]
242 assert rec["body_hash"] != rec["content_id"] # body excludes def line
243
244 def test_rename_detection_via_body_hash(self) -> None:
245 """Two functions with identical bodies but different names share body_hash."""
246 src1 = _src("def foo(x):\n return x + 1\n")
247 src2 = _src("def bar(x):\n return x + 1\n")
248 t1 = self.adapter.parse_symbols(src1, "f.py")
249 t2 = self.adapter.parse_symbols(src2, "f.py")
250 assert t1["f.py::foo"]["body_hash"] == t2["f.py::bar"]["body_hash"]
251 assert t1["f.py::foo"]["content_id"] != t2["f.py::bar"]["content_id"]
252
253 def test_signature_id_same_despite_body_change(self) -> None:
254 src1 = _src("def calc(x: int) -> int:\n return x\n")
255 src2 = _src("def calc(x: int) -> int:\n return x * 10\n")
256 t1 = self.adapter.parse_symbols(src1, "m.py")
257 t2 = self.adapter.parse_symbols(src2, "m.py")
258 assert t1["m.py::calc"]["signature_id"] == t2["m.py::calc"]["signature_id"]
259 assert t1["m.py::calc"]["body_hash"] != t2["m.py::calc"]["body_hash"]
260
261 def test_file_content_id_formatting_insensitive(self) -> None:
262 src1 = _src("x = 1\ny = 2\n")
263 src2 = _src("x=1\ny=2\n")
264 assert self.adapter.file_content_id(src1) == self.adapter.file_content_id(src2)
265
266 def test_file_content_id_syntax_error_uses_raw_bytes(self) -> None:
267 bad = b"def("
268 cid = self.adapter.file_content_id(bad)
269 assert cid == _sha256_bytes(bad)
270
271
272 # ---------------------------------------------------------------------------
273 # FallbackAdapter
274 # ---------------------------------------------------------------------------
275
276
277 class TestFallbackAdapter:
278 adapter = FallbackAdapter(frozenset({".unknown_xyz"}))
279
280 def test_supported_extensions(self) -> None:
281 assert ".unknown_xyz" in self.adapter.supported_extensions()
282
283 def test_parse_returns_empty(self) -> None:
284 assert self.adapter.parse_symbols(b"const x = 1;", "src.unknown_xyz") == {}
285
286 def test_content_id_is_raw_bytes_hash(self) -> None:
287 data = b"const x = 1;"
288 assert self.adapter.file_content_id(data) == _sha256_bytes(data)
289
290
291 # ---------------------------------------------------------------------------
292 # TreeSitterAdapter — one test per language
293 # ---------------------------------------------------------------------------
294
295
296 class TestTreeSitterAdapters:
297 """Validate symbol extraction for each of the ten tree-sitter-backed languages."""
298
299 def _syms(self, src: bytes, path: str) -> dict[str, str]:
300 """Return {addr: kind} for all extracted symbols."""
301 tree = parse_symbols(src, path)
302 return {addr: rec["kind"] for addr, rec in tree.items()}
303
304 # --- JavaScript -----------------------------------------------------------
305
306 def test_js_top_level_function(self) -> None:
307 src = b"function greet(name) { return name; }"
308 syms = self._syms(src, "app.js")
309 assert "app.js::greet" in syms
310 assert syms["app.js::greet"] == "function"
311
312 def test_js_class_and_method(self) -> None:
313 src = b"class Animal { speak() { return 1; } }"
314 syms = self._syms(src, "animal.js")
315 assert "animal.js::Animal" in syms
316 assert syms["animal.js::Animal"] == "class"
317 assert "animal.js::Animal.speak" in syms
318 assert syms["animal.js::Animal.speak"] == "method"
319
320 def test_js_body_hash_rename_detection(self) -> None:
321 """JS functions with identical bodies but different names share body_hash."""
322 src_foo = b"function foo(x) { return x + 1; }"
323 src_bar = b"function bar(x) { return x + 1; }"
324 t1 = parse_symbols(src_foo, "f.js")
325 t2 = parse_symbols(src_bar, "f.js")
326 assert t1["f.js::foo"]["body_hash"] == t2["f.js::bar"]["body_hash"]
327 assert t1["f.js::foo"]["content_id"] != t2["f.js::bar"]["content_id"]
328
329 def test_js_adapter_claims_jsx_and_mjs(self) -> None:
330 src = b"function f() {}"
331 assert parse_symbols(src, "x.jsx") != {} or True # adapter loaded
332 assert "x.mjs::f" in parse_symbols(src, "x.mjs")
333
334 # --- TypeScript -----------------------------------------------------------
335
336 def test_ts_function_and_interface(self) -> None:
337 src = b"function hello(name: string): void {}\ninterface Animal { speak(): void; }"
338 syms = self._syms(src, "app.ts")
339 assert "app.ts::hello" in syms
340 assert syms["app.ts::hello"] == "function"
341 assert "app.ts::Animal" in syms
342 assert syms["app.ts::Animal"] == "class"
343
344 def test_ts_class_and_method(self) -> None:
345 src = b"class Dog { bark(): string { return 'woof'; } }"
346 syms = self._syms(src, "dog.ts")
347 assert "dog.ts::Dog" in syms
348 assert "dog.ts::Dog.bark" in syms
349
350 def test_tsx_parses_correctly(self) -> None:
351 src = b"function Button(): void { return; }\ninterface Props { label: string; }"
352 syms = self._syms(src, "button.tsx")
353 assert "button.tsx::Button" in syms
354 assert "button.tsx::Props" in syms
355
356 # --- Go -------------------------------------------------------------------
357
358 def test_go_function(self) -> None:
359 src = b"func NewDog(name string) string { return name }"
360 syms = self._syms(src, "dog.go")
361 assert "dog.go::NewDog" in syms
362 assert syms["dog.go::NewDog"] == "function"
363
364 def test_go_method_qualified_with_receiver(self) -> None:
365 """Go methods carry the receiver type as qualified-name prefix."""
366 src = b"type Dog struct { Name string }\nfunc (d Dog) Bark() string { return d.Name }"
367 syms = self._syms(src, "dog.go")
368 assert "dog.go::Dog" in syms
369 assert "dog.go::Dog.Bark" in syms
370 assert syms["dog.go::Dog.Bark"] == "method"
371
372 def test_go_pointer_receiver_stripped(self) -> None:
373 """Pointer receivers (*Dog) are stripped to give Dog.Method."""
374 src = b"type Dog struct {}\nfunc (d *Dog) Sit() {}"
375 syms = self._syms(src, "d.go")
376 assert "d.go::Dog.Sit" in syms
377
378 # --- Rust -----------------------------------------------------------------
379
380 def test_rust_standalone_function(self) -> None:
381 src = b"fn add(a: i32, b: i32) -> i32 { a + b }"
382 syms = self._syms(src, "math.rs")
383 assert "math.rs::add" in syms
384 assert syms["math.rs::add"] == "function"
385
386 def test_rust_impl_method_qualified(self) -> None:
387 """Rust impl methods are qualified as TypeName.method."""
388 src = b"struct Dog { name: String }\nimpl Dog { fn bark(&self) -> String { self.name.clone() } }"
389 syms = self._syms(src, "dog.rs")
390 assert "dog.rs::Dog" in syms
391 assert "dog.rs::Dog.bark" in syms
392
393 def test_rust_struct_and_trait(self) -> None:
394 src = b"struct Point { x: f64, y: f64 }\ntrait Shape { fn area(&self) -> f64; }"
395 syms = self._syms(src, "shapes.rs")
396 assert "shapes.rs::Point" in syms
397 assert syms["shapes.rs::Point"] == "class"
398 assert "shapes.rs::Shape" in syms
399
400 # --- Java -----------------------------------------------------------------
401
402 def test_java_class_and_method(self) -> None:
403 src = b"public class Calculator { public int add(int a, int b) { return a + b; } }"
404 syms = self._syms(src, "Calc.java")
405 assert "Calc.java::Calculator" in syms
406 assert syms["Calc.java::Calculator"] == "class"
407 assert "Calc.java::Calculator.add" in syms
408 assert syms["Calc.java::Calculator.add"] == "method"
409
410 def test_java_interface(self) -> None:
411 src = b"public interface Shape { double area(); }"
412 syms = self._syms(src, "Shape.java")
413 assert "Shape.java::Shape" in syms
414 assert syms["Shape.java::Shape"] == "class"
415
416 # --- C --------------------------------------------------------------------
417
418 def test_c_function(self) -> None:
419 src = b"int add(int a, int b) { return a + b; }\nvoid noop(void) {}"
420 syms = self._syms(src, "math.c")
421 assert "math.c::add" in syms
422 assert syms["math.c::add"] == "function"
423 assert "math.c::noop" in syms
424
425 # --- C++ ------------------------------------------------------------------
426
427 def test_cpp_class_and_function(self) -> None:
428 src = b"class Animal { public: void speak() {} };\nint square(int x) { return x * x; }"
429 syms = self._syms(src, "app.cpp")
430 assert "app.cpp::Animal" in syms
431 assert syms["app.cpp::Animal"] == "class"
432 assert "app.cpp::square" in syms
433
434 # --- C# -------------------------------------------------------------------
435
436 def test_cs_class_and_method(self) -> None:
437 src = b"public class Greeter { public string Hello(string name) { return name; } }"
438 syms = self._syms(src, "Greeter.cs")
439 assert "Greeter.cs::Greeter" in syms
440 assert syms["Greeter.cs::Greeter"] == "class"
441 assert "Greeter.cs::Greeter.Hello" in syms
442 assert syms["Greeter.cs::Greeter.Hello"] == "method"
443
444 def test_cs_interface_and_struct(self) -> None:
445 src = b"interface IShape { double Area(); }\nstruct Point { public int X, Y; }"
446 syms = self._syms(src, "shapes.cs")
447 assert "shapes.cs::IShape" in syms
448 assert "shapes.cs::Point" in syms
449
450 # --- Ruby -----------------------------------------------------------------
451
452 def test_ruby_class_and_method(self) -> None:
453 src = b"class Dog\n def bark\n puts 'woof'\n end\nend"
454 syms = self._syms(src, "dog.rb")
455 assert "dog.rb::Dog" in syms
456 assert syms["dog.rb::Dog"] == "class"
457 assert "dog.rb::Dog.bark" in syms
458 assert syms["dog.rb::Dog.bark"] == "method"
459
460 def test_ruby_module(self) -> None:
461 src = b"module Greetable\n def greet\n 'hello'\n end\nend"
462 syms = self._syms(src, "greet.rb")
463 assert "greet.rb::Greetable" in syms
464 assert syms["greet.rb::Greetable"] == "class"
465
466 # --- Kotlin ---------------------------------------------------------------
467
468 def test_kotlin_function_and_class(self) -> None:
469 src = b"fun greet(name: String): String = name\nclass Dog { fun bark(): Unit { } }"
470 syms = self._syms(src, "main.kt")
471 assert "main.kt::greet" in syms
472 assert syms["main.kt::greet"] == "function"
473 assert "main.kt::Dog" in syms
474 assert "main.kt::Dog.bark" in syms
475
476 # --- cross-language adapter routing ---------------------------------------
477
478 def test_adapter_for_path_routes_all_extensions(self) -> None:
479 """adapter_for_path must return a TreeSitterAdapter (not Fallback) for all supported exts."""
480 from muse.plugins.code.ast_parser import TreeSitterAdapter, adapter_for_path
481
482 for ext in (
483 ".js", ".jsx", ".mjs", ".cjs",
484 ".ts", ".tsx",
485 ".go",
486 ".rs",
487 ".java",
488 ".c", ".h",
489 ".cpp", ".cc", ".cxx", ".hpp",
490 ".cs",
491 ".rb",
492 ".kt", ".kts",
493 ):
494 a = adapter_for_path(f"src/file{ext}")
495 assert isinstance(a, TreeSitterAdapter), (
496 f"Expected TreeSitterAdapter for {ext}, got {type(a).__name__}"
497 )
498
499 def test_semantic_extensions_covers_all_ts_languages(self) -> None:
500 from muse.plugins.code.ast_parser import SEMANTIC_EXTENSIONS
501
502 expected = {
503 ".py", ".pyi",
504 ".js", ".jsx", ".mjs", ".cjs",
505 ".ts", ".tsx",
506 ".go", ".rs",
507 ".java",
508 ".c", ".h",
509 ".cpp", ".cc", ".cxx", ".hpp", ".hxx",
510 ".cs",
511 ".rb",
512 ".kt", ".kts",
513 }
514 assert expected <= SEMANTIC_EXTENSIONS
515
516
517 # ---------------------------------------------------------------------------
518 # adapter_for_path
519 # ---------------------------------------------------------------------------
520
521
522 def test_adapter_for_py_is_python() -> None:
523 assert isinstance(adapter_for_path("src/utils.py"), PythonAdapter)
524
525
526 def test_adapter_for_ts_is_tree_sitter() -> None:
527 from muse.plugins.code.ast_parser import TreeSitterAdapter
528
529 assert isinstance(adapter_for_path("src/app.ts"), TreeSitterAdapter)
530
531
532 def test_adapter_for_no_extension_is_fallback() -> None:
533 assert isinstance(adapter_for_path("Makefile"), FallbackAdapter)
534
535
536 # ---------------------------------------------------------------------------
537 # diff_symbol_trees — golden test cases
538 # ---------------------------------------------------------------------------
539
540
541 class TestDiffSymbolTrees:
542 """Golden test cases for symbol-level diff."""
543
544 def _func(
545 self,
546 addr: str,
547 content_id: str,
548 body_hash: str | None = None,
549 signature_id: str | None = None,
550 name: str = "f",
551 ) -> tuple[str, SymbolRecord]:
552 return addr, SymbolRecord(
553 kind="function",
554 name=name,
555 qualified_name=name,
556 content_id=content_id,
557 body_hash=body_hash or content_id,
558 signature_id=signature_id or content_id,
559 lineno=1,
560 end_lineno=3,
561 )
562
563 def test_empty_trees_produce_no_ops(self) -> None:
564 assert diff_symbol_trees({}, {}) == []
565
566 def test_added_symbol(self) -> None:
567 base: SymbolTree = {}
568 target: SymbolTree = dict([self._func("f.py::new_fn", "abc", name="new_fn")])
569 ops = diff_symbol_trees(base, target)
570 assert len(ops) == 1
571 assert ops[0]["op"] == "insert"
572 assert ops[0]["address"] == "f.py::new_fn"
573
574 def test_removed_symbol(self) -> None:
575 base: SymbolTree = dict([self._func("f.py::old", "abc", name="old")])
576 target: SymbolTree = {}
577 ops = diff_symbol_trees(base, target)
578 assert len(ops) == 1
579 assert ops[0]["op"] == "delete"
580 assert ops[0]["address"] == "f.py::old"
581
582 def test_unchanged_symbol_no_op(self) -> None:
583 rec = dict([self._func("f.py::stable", "xyz", name="stable")])
584 assert diff_symbol_trees(rec, rec) == []
585
586 def test_implementation_changed(self) -> None:
587 """Same signature, different body → ReplaceOp with 'implementation changed'."""
588 sig_id = _sha256("calc(x)->int")
589 base: SymbolTree = dict([self._func("m.py::calc", "old_body", body_hash="old", signature_id=sig_id, name="calc")])
590 target: SymbolTree = dict([self._func("m.py::calc", "new_body", body_hash="new", signature_id=sig_id, name="calc")])
591 ops = diff_symbol_trees(base, target)
592 assert len(ops) == 1
593 assert ops[0]["op"] == "replace"
594 assert "implementation changed" in ops[0]["new_summary"]
595
596 def test_signature_changed(self) -> None:
597 """Same body, different signature → ReplaceOp with 'signature changed'."""
598 body = _sha256("return x + 1")
599 base: SymbolTree = dict([self._func("m.py::f", "c1", body_hash=body, signature_id="old_sig", name="f")])
600 target: SymbolTree = dict([self._func("m.py::f", "c2", body_hash=body, signature_id="new_sig", name="f")])
601 ops = diff_symbol_trees(base, target)
602 assert len(ops) == 1
603 assert ops[0]["op"] == "replace"
604 assert "signature changed" in ops[0]["old_summary"]
605
606 def test_rename_detected(self) -> None:
607 """Same body_hash, different name/address → ReplaceOp with 'renamed to'."""
608 body = _sha256("return 42")
609 base: SymbolTree = dict([self._func("u.py::old_name", "old_cid", body_hash=body, name="old_name")])
610 target: SymbolTree = dict([self._func("u.py::new_name", "new_cid", body_hash=body, name="new_name")])
611 ops = diff_symbol_trees(base, target)
612 assert len(ops) == 1
613 assert ops[0]["op"] == "replace"
614 assert "renamed to" in ops[0]["new_summary"]
615 assert "new_name" in ops[0]["new_summary"]
616
617 def test_independent_changes_both_emitted(self) -> None:
618 """Different symbols changed independently → two ReplaceOps."""
619 sig_a = "sig_a"
620 sig_b = "sig_b"
621 base: SymbolTree = {
622 **dict([self._func("f.py::foo", "foo_old", body_hash="foo_b_old", signature_id=sig_a, name="foo")]),
623 **dict([self._func("f.py::bar", "bar_old", body_hash="bar_b_old", signature_id=sig_b, name="bar")]),
624 }
625 target: SymbolTree = {
626 **dict([self._func("f.py::foo", "foo_new", body_hash="foo_b_new", signature_id=sig_a, name="foo")]),
627 **dict([self._func("f.py::bar", "bar_new", body_hash="bar_b_new", signature_id=sig_b, name="bar")]),
628 }
629 ops = diff_symbol_trees(base, target)
630 assert len(ops) == 2
631 addrs = {o["address"] for o in ops}
632 assert "f.py::foo" in addrs
633 assert "f.py::bar" in addrs
634
635
636 # ---------------------------------------------------------------------------
637 # build_diff_ops — integration
638 # ---------------------------------------------------------------------------
639
640
641 class TestBuildDiffOps:
642 def test_added_file_no_tree(self) -> None:
643 ops = build_diff_ops(
644 base_files={},
645 target_files={"new.ts": "abc"},
646 base_trees={},
647 target_trees={},
648 )
649 assert len(ops) == 1
650 assert ops[0]["op"] == "insert"
651 assert ops[0]["address"] == "new.ts"
652
653 def test_removed_file_no_tree(self) -> None:
654 ops = build_diff_ops(
655 base_files={"old.ts": "abc"},
656 target_files={},
657 base_trees={},
658 target_trees={},
659 )
660 assert len(ops) == 1
661 assert ops[0]["op"] == "delete"
662
663 def test_modified_file_with_trees(self) -> None:
664 body = _sha256("return x")
665 base_tree: SymbolTree = {
666 "u.py::foo": SymbolRecord(
667 kind="function", name="foo", qualified_name="foo",
668 content_id="old_c", body_hash=body, signature_id="sig",
669 lineno=1, end_lineno=2,
670 )
671 }
672 target_tree: SymbolTree = {
673 "u.py::foo": SymbolRecord(
674 kind="function", name="foo", qualified_name="foo",
675 content_id="new_c", body_hash="new_body", signature_id="sig",
676 lineno=1, end_lineno=2,
677 )
678 }
679 ops = build_diff_ops(
680 base_files={"u.py": "base_hash"},
681 target_files={"u.py": "target_hash"},
682 base_trees={"u.py": base_tree},
683 target_trees={"u.py": target_tree},
684 )
685 assert len(ops) == 1
686 assert ops[0]["op"] == "patch"
687 assert ops[0]["address"] == "u.py"
688 assert len(ops[0]["child_ops"]) == 1
689 assert ops[0]["child_ops"][0]["op"] == "replace"
690
691 def test_reformat_only_produces_replace_op(self) -> None:
692 """When all symbol content_ids are unchanged, emit a reformatted ReplaceOp."""
693 content_id = _sha256("return x")
694 tree: SymbolTree = {
695 "u.py::foo": SymbolRecord(
696 kind="function", name="foo", qualified_name="foo",
697 content_id=content_id, body_hash=content_id, signature_id=content_id,
698 lineno=1, end_lineno=2,
699 )
700 }
701 ops = build_diff_ops(
702 base_files={"u.py": "hash_before"},
703 target_files={"u.py": "hash_after"},
704 base_trees={"u.py": tree},
705 target_trees={"u.py": tree}, # same tree → no symbol changes
706 )
707 assert len(ops) == 1
708 assert ops[0]["op"] == "replace"
709 assert "reformatted" in ops[0]["new_summary"]
710
711 def test_cross_file_move_annotation(self) -> None:
712 """A symbol deleted in file A and inserted in file B is annotated as moved."""
713 content_id = _sha256("the_body")
714 base_tree: SymbolTree = {
715 "a.py::helper": SymbolRecord(
716 kind="function", name="helper", qualified_name="helper",
717 content_id=content_id, body_hash=content_id, signature_id=content_id,
718 lineno=1, end_lineno=3,
719 )
720 }
721 target_tree: SymbolTree = {
722 "b.py::helper": SymbolRecord(
723 kind="function", name="helper", qualified_name="helper",
724 content_id=content_id, body_hash=content_id, signature_id=content_id,
725 lineno=1, end_lineno=3,
726 )
727 }
728 ops = build_diff_ops(
729 base_files={"a.py": "hash_a", "b.py": "hash_b_before"},
730 target_files={"b.py": "hash_b_after"},
731 base_trees={"a.py": base_tree},
732 target_trees={"b.py": target_tree},
733 )
734 # Find the patch ops.
735 patch_addrs = {o["address"] for o in ops if o["op"] == "patch"}
736 assert "a.py" in patch_addrs or "b.py" in patch_addrs
737
738
739 # ---------------------------------------------------------------------------
740 # CodePlugin — snapshot
741 # ---------------------------------------------------------------------------
742
743
744 class TestCodePluginSnapshot:
745 plugin = CodePlugin()
746
747 def test_path_returns_manifest(self, tmp_path: pathlib.Path) -> None:
748 workdir = tmp_path / "muse-work"
749 workdir.mkdir()
750 (workdir / "app.py").write_text("x = 1\n")
751 snap = self.plugin.snapshot(workdir)
752 assert snap["domain"] == "code"
753 assert "app.py" in snap["files"]
754
755 def test_snapshot_stability(self, tmp_path: pathlib.Path) -> None:
756 workdir = tmp_path / "muse-work"
757 workdir.mkdir()
758 (workdir / "main.py").write_text("def f(): pass\n")
759 s1 = self.plugin.snapshot(workdir)
760 s2 = self.plugin.snapshot(workdir)
761 assert s1 == s2
762
763 def test_snapshot_uses_raw_bytes_hash(self, tmp_path: pathlib.Path) -> None:
764 workdir = tmp_path / "muse-work"
765 workdir.mkdir()
766 content = b"def add(a, b): return a + b\n"
767 (workdir / "math.py").write_bytes(content)
768 snap = self.plugin.snapshot(workdir)
769 expected = _sha256_bytes(content)
770 assert snap["files"]["math.py"] == expected
771
772 def test_museignore_respected(self, tmp_path: pathlib.Path) -> None:
773 workdir = tmp_path / "muse-work"
774 workdir.mkdir()
775 (workdir / "keep.py").write_text("x = 1\n")
776 (workdir / "skip.log").write_text("log\n")
777 ignore = tmp_path / ".museignore"
778 ignore.write_text("*.log\n")
779 snap = self.plugin.snapshot(workdir)
780 assert "keep.py" in snap["files"]
781 assert "skip.log" not in snap["files"]
782
783 def test_pycache_always_ignored(self, tmp_path: pathlib.Path) -> None:
784 workdir = tmp_path / "muse-work"
785 workdir.mkdir()
786 cache = workdir / "__pycache__"
787 cache.mkdir()
788 (cache / "utils.cpython-312.pyc").write_bytes(b"\x00")
789 (workdir / "main.py").write_text("x = 1\n")
790 snap = self.plugin.snapshot(workdir)
791 assert "main.py" in snap["files"]
792 assert not any("__pycache__" in k for k in snap["files"])
793
794 def test_nested_files_tracked(self, tmp_path: pathlib.Path) -> None:
795 workdir = tmp_path / "muse-work"
796 (workdir / "src").mkdir(parents=True)
797 (workdir / "src" / "utils.py").write_text("pass\n")
798 snap = self.plugin.snapshot(workdir)
799 assert "src/utils.py" in snap["files"]
800
801 def test_manifest_passthrough(self) -> None:
802 manifest = _make_manifest({"a.py": "hash"})
803 result = self.plugin.snapshot(manifest)
804 assert result is manifest
805
806
807 # ---------------------------------------------------------------------------
808 # CodePlugin — diff (file-level, no repo_root)
809 # ---------------------------------------------------------------------------
810
811
812 class TestCodePluginDiffFileLevel:
813 plugin = CodePlugin()
814
815 def test_added_file(self) -> None:
816 base = _make_manifest({})
817 target = _make_manifest({"new.py": "abc"})
818 delta = self.plugin.diff(base, target)
819 assert len(delta["ops"]) == 1
820 assert delta["ops"][0]["op"] == "insert"
821
822 def test_removed_file(self) -> None:
823 base = _make_manifest({"old.py": "abc"})
824 target = _make_manifest({})
825 delta = self.plugin.diff(base, target)
826 assert len(delta["ops"]) == 1
827 assert delta["ops"][0]["op"] == "delete"
828
829 def test_modified_file(self) -> None:
830 base = _make_manifest({"f.py": "old"})
831 target = _make_manifest({"f.py": "new"})
832 delta = self.plugin.diff(base, target)
833 assert len(delta["ops"]) == 1
834 assert delta["ops"][0]["op"] == "replace"
835
836 def test_no_changes_empty_ops(self) -> None:
837 snap = _make_manifest({"f.py": "abc"})
838 delta = self.plugin.diff(snap, snap)
839 assert delta["ops"] == []
840 assert delta["summary"] == "no changes"
841
842 def test_domain_is_code(self) -> None:
843 delta = self.plugin.diff(_make_manifest({}), _make_manifest({}))
844 assert delta["domain"] == "code"
845
846
847 # ---------------------------------------------------------------------------
848 # CodePlugin — diff (semantic, with repo_root)
849 # ---------------------------------------------------------------------------
850
851
852 class TestCodePluginDiffSemantic:
853 plugin = CodePlugin()
854
855 def _setup_repo(
856 self, tmp_path: pathlib.Path
857 ) -> tuple[pathlib.Path, pathlib.Path]:
858 repo_root = tmp_path / "repo"
859 repo_root.mkdir()
860 workdir = repo_root / "muse-work"
861 workdir.mkdir()
862 return repo_root, workdir
863
864 def test_add_function_produces_patch_op(self, tmp_path: pathlib.Path) -> None:
865 repo_root, _ = self._setup_repo(tmp_path)
866 base_src = _src("x = 1\n")
867 target_src = _src("x = 1\n\ndef greet(name: str) -> str:\n return f'Hello {name}'\n")
868
869 base_oid = _store_blob(repo_root, base_src)
870 target_oid = _store_blob(repo_root, target_src)
871
872 base = _make_manifest({"hello.py": base_oid})
873 target = _make_manifest({"hello.py": target_oid})
874 delta = self.plugin.diff(base, target, repo_root=repo_root)
875
876 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
877 assert len(patch_ops) == 1
878 assert patch_ops[0]["address"] == "hello.py"
879 child_ops = patch_ops[0]["child_ops"]
880 assert any(c["op"] == "insert" and "greet" in c.get("content_summary", "") for c in child_ops)
881
882 def test_remove_function_produces_patch_op(self, tmp_path: pathlib.Path) -> None:
883 repo_root, _ = self._setup_repo(tmp_path)
884 base_src = _src("def old_fn() -> None:\n pass\n")
885 target_src = _src("# removed\n")
886
887 base_oid = _store_blob(repo_root, base_src)
888 target_oid = _store_blob(repo_root, target_src)
889
890 base = _make_manifest({"mod.py": base_oid})
891 target = _make_manifest({"mod.py": target_oid})
892 delta = self.plugin.diff(base, target, repo_root=repo_root)
893
894 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
895 assert len(patch_ops) == 1
896 child_ops = patch_ops[0]["child_ops"]
897 assert any(c["op"] == "delete" and "old_fn" in c.get("content_summary", "") for c in child_ops)
898
899 def test_rename_function_detected(self, tmp_path: pathlib.Path) -> None:
900 repo_root, _ = self._setup_repo(tmp_path)
901 base_src = _src("def compute(x: int) -> int:\n return x * 2\n")
902 target_src = _src("def calculate(x: int) -> int:\n return x * 2\n")
903
904 base_oid = _store_blob(repo_root, base_src)
905 target_oid = _store_blob(repo_root, target_src)
906
907 base = _make_manifest({"ops.py": base_oid})
908 target = _make_manifest({"ops.py": target_oid})
909 delta = self.plugin.diff(base, target, repo_root=repo_root)
910
911 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
912 assert len(patch_ops) == 1
913 child_ops = patch_ops[0]["child_ops"]
914 rename_ops = [
915 c for c in child_ops
916 if c["op"] == "replace" and "renamed to" in c.get("new_summary", "")
917 ]
918 assert len(rename_ops) == 1
919 assert "calculate" in rename_ops[0]["new_summary"]
920
921 def test_implementation_change_detected(self, tmp_path: pathlib.Path) -> None:
922 repo_root, _ = self._setup_repo(tmp_path)
923 base_src = _src("def double(x: int) -> int:\n return x * 2\n")
924 target_src = _src("def double(x: int) -> int:\n return x + x\n")
925
926 base_oid = _store_blob(repo_root, base_src)
927 target_oid = _store_blob(repo_root, target_src)
928
929 base = _make_manifest({"math.py": base_oid})
930 target = _make_manifest({"math.py": target_oid})
931 delta = self.plugin.diff(base, target, repo_root=repo_root)
932
933 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
934 child_ops = patch_ops[0]["child_ops"]
935 impl_ops = [c for c in child_ops if "implementation changed" in c.get("new_summary", "")]
936 assert len(impl_ops) == 1
937
938 def test_reformat_only_produces_replace_with_reformatted(
939 self, tmp_path: pathlib.Path
940 ) -> None:
941 repo_root, _ = self._setup_repo(tmp_path)
942 base_src = _src("def add(a,b):\n return a+b\n")
943 # Same semantics, different formatting — ast.unparse normalizes both.
944 target_src = _src("def add(a, b):\n return a + b\n")
945
946 base_oid = _store_blob(repo_root, base_src)
947 target_oid = _store_blob(repo_root, target_src)
948
949 base = _make_manifest({"f.py": base_oid})
950 target = _make_manifest({"f.py": target_oid})
951 delta = self.plugin.diff(base, target, repo_root=repo_root)
952
953 # The diff should produce a reformatted ReplaceOp rather than a PatchOp.
954 replace_ops = [o for o in delta["ops"] if o["op"] == "replace"]
955 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
956 # Reformatting: either zero ops (if raw hashes are identical) or a
957 # reformatted replace (if raw hashes differ but symbols unchanged).
958 if delta["ops"]:
959 assert replace_ops or patch_ops # something was emitted
960 if replace_ops:
961 assert any("reformatted" in o.get("new_summary", "") for o in replace_ops)
962
963 def test_missing_object_falls_back_to_file_level(
964 self, tmp_path: pathlib.Path
965 ) -> None:
966 repo_root, _ = self._setup_repo(tmp_path)
967 # Objects NOT written to store — should fall back gracefully.
968 base = _make_manifest({"f.py": "deadbeef" * 8})
969 target = _make_manifest({"f.py": "cafebabe" * 8})
970 delta = self.plugin.diff(base, target, repo_root=repo_root)
971 assert len(delta["ops"]) == 1
972 assert delta["ops"][0]["op"] == "replace"
973
974
975 # ---------------------------------------------------------------------------
976 # CodePlugin — merge
977 # ---------------------------------------------------------------------------
978
979
980 class TestCodePluginMerge:
981 plugin = CodePlugin()
982
983 def test_only_one_side_changed(self) -> None:
984 base = _make_manifest({"f.py": "v1"})
985 left = _make_manifest({"f.py": "v1"})
986 right = _make_manifest({"f.py": "v2"})
987 result = self.plugin.merge(base, left, right)
988 assert result.is_clean
989 assert result.merged["files"]["f.py"] == "v2"
990
991 def test_both_sides_same_change(self) -> None:
992 base = _make_manifest({"f.py": "v1"})
993 left = _make_manifest({"f.py": "v2"})
994 right = _make_manifest({"f.py": "v2"})
995 result = self.plugin.merge(base, left, right)
996 assert result.is_clean
997 assert result.merged["files"]["f.py"] == "v2"
998
999 def test_conflict_when_both_sides_differ(self) -> None:
1000 base = _make_manifest({"f.py": "v1"})
1001 left = _make_manifest({"f.py": "v2"})
1002 right = _make_manifest({"f.py": "v3"})
1003 result = self.plugin.merge(base, left, right)
1004 assert not result.is_clean
1005 assert "f.py" in result.conflicts
1006
1007 def test_disjoint_additions_auto_merge(self) -> None:
1008 base = _make_manifest({})
1009 left = _make_manifest({"a.py": "hash_a"})
1010 right = _make_manifest({"b.py": "hash_b"})
1011 result = self.plugin.merge(base, left, right)
1012 assert result.is_clean
1013 assert "a.py" in result.merged["files"]
1014 assert "b.py" in result.merged["files"]
1015
1016 def test_deletion_on_one_side(self) -> None:
1017 base = _make_manifest({"f.py": "v1"})
1018 left = _make_manifest({})
1019 right = _make_manifest({"f.py": "v1"})
1020 result = self.plugin.merge(base, left, right)
1021 assert result.is_clean
1022 assert "f.py" not in result.merged["files"]
1023
1024
1025 # ---------------------------------------------------------------------------
1026 # CodePlugin — merge_ops (symbol-level OT)
1027 # ---------------------------------------------------------------------------
1028
1029
1030 class TestCodePluginMergeOps:
1031 plugin = CodePlugin()
1032
1033 def _py_snap(self, file_path: str, src: bytes, repo_root: pathlib.Path) -> SnapshotManifest:
1034 oid = _store_blob(repo_root, src)
1035 return _make_manifest({file_path: oid})
1036
1037 def test_different_symbols_auto_merge(self, tmp_path: pathlib.Path) -> None:
1038 """Two agents modify different functions → no conflict."""
1039 repo_root = tmp_path / "repo"
1040 repo_root.mkdir()
1041
1042 base_src = _src("""\
1043 def foo(x: int) -> int:
1044 return x
1045
1046 def bar(y: int) -> int:
1047 return y
1048 """)
1049 # Ours: modify foo.
1050 ours_src = _src("""\
1051 def foo(x: int) -> int:
1052 return x * 2
1053
1054 def bar(y: int) -> int:
1055 return y
1056 """)
1057 # Theirs: modify bar.
1058 theirs_src = _src("""\
1059 def foo(x: int) -> int:
1060 return x
1061
1062 def bar(y: int) -> int:
1063 return y + 1
1064 """)
1065
1066 base_snap = self._py_snap("m.py", base_src, repo_root)
1067 ours_snap = self._py_snap("m.py", ours_src, repo_root)
1068 theirs_snap = self._py_snap("m.py", theirs_src, repo_root)
1069
1070 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1071 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1072
1073 result = self.plugin.merge_ops(
1074 base_snap,
1075 ours_snap,
1076 theirs_snap,
1077 ours_delta["ops"],
1078 theirs_delta["ops"],
1079 repo_root=repo_root,
1080 )
1081 # Different symbol addresses → ops commute → no conflict.
1082 assert result.is_clean, f"Expected no conflicts, got: {result.conflicts}"
1083
1084 def test_same_symbol_conflict(self, tmp_path: pathlib.Path) -> None:
1085 """Both agents modify the same function → conflict at symbol address."""
1086 repo_root = tmp_path / "repo"
1087 repo_root.mkdir()
1088
1089 base_src = _src("def calc(x: int) -> int:\n return x\n")
1090 ours_src = _src("def calc(x: int) -> int:\n return x * 2\n")
1091 theirs_src = _src("def calc(x: int) -> int:\n return x + 100\n")
1092
1093 base_snap = self._py_snap("calc.py", base_src, repo_root)
1094 ours_snap = self._py_snap("calc.py", ours_src, repo_root)
1095 theirs_snap = self._py_snap("calc.py", theirs_src, repo_root)
1096
1097 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1098 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1099
1100 result = self.plugin.merge_ops(
1101 base_snap,
1102 ours_snap,
1103 theirs_snap,
1104 ours_delta["ops"],
1105 theirs_delta["ops"],
1106 repo_root=repo_root,
1107 )
1108 assert not result.is_clean
1109 # Conflict should be at file or symbol level.
1110 assert len(result.conflicts) > 0
1111
1112 def test_disjoint_files_auto_merge(self, tmp_path: pathlib.Path) -> None:
1113 """Agents modify completely different files → auto-merge."""
1114 repo_root = tmp_path / "repo"
1115 repo_root.mkdir()
1116
1117 base = _make_manifest({"a.py": "v1", "b.py": "v1"})
1118 ours = _make_manifest({"a.py": "v2", "b.py": "v1"})
1119 theirs = _make_manifest({"a.py": "v1", "b.py": "v2"})
1120
1121 ours_delta = self.plugin.diff(base, ours)
1122 theirs_delta = self.plugin.diff(base, theirs)
1123
1124 result = self.plugin.merge_ops(
1125 base, ours, theirs,
1126 ours_delta["ops"],
1127 theirs_delta["ops"],
1128 )
1129 assert result.is_clean
1130
1131
1132 # ---------------------------------------------------------------------------
1133 # CodePlugin — drift
1134 # ---------------------------------------------------------------------------
1135
1136
1137 class TestCodePluginDrift:
1138 plugin = CodePlugin()
1139
1140 def test_no_drift(self, tmp_path: pathlib.Path) -> None:
1141 workdir = tmp_path / "muse-work"
1142 workdir.mkdir()
1143 (workdir / "app.py").write_text("x = 1\n")
1144 snap = self.plugin.snapshot(workdir)
1145 report = self.plugin.drift(snap, workdir)
1146 assert not report.has_drift
1147
1148 def test_has_drift_after_edit(self, tmp_path: pathlib.Path) -> None:
1149 workdir = tmp_path / "muse-work"
1150 workdir.mkdir()
1151 f = workdir / "app.py"
1152 f.write_text("x = 1\n")
1153 snap = self.plugin.snapshot(workdir)
1154 f.write_text("x = 2\n")
1155 report = self.plugin.drift(snap, workdir)
1156 assert report.has_drift
1157
1158 def test_has_drift_after_add(self, tmp_path: pathlib.Path) -> None:
1159 workdir = tmp_path / "muse-work"
1160 workdir.mkdir()
1161 (workdir / "a.py").write_text("a = 1\n")
1162 snap = self.plugin.snapshot(workdir)
1163 (workdir / "b.py").write_text("b = 2\n")
1164 report = self.plugin.drift(snap, workdir)
1165 assert report.has_drift
1166
1167 def test_has_drift_after_delete(self, tmp_path: pathlib.Path) -> None:
1168 workdir = tmp_path / "muse-work"
1169 workdir.mkdir()
1170 f = workdir / "gone.py"
1171 f.write_text("x = 1\n")
1172 snap = self.plugin.snapshot(workdir)
1173 f.unlink()
1174 report = self.plugin.drift(snap, workdir)
1175 assert report.has_drift
1176
1177
1178 # ---------------------------------------------------------------------------
1179 # CodePlugin — apply (passthrough)
1180 # ---------------------------------------------------------------------------
1181
1182
1183 def test_apply_returns_live_state_unchanged(tmp_path: pathlib.Path) -> None:
1184 plugin = CodePlugin()
1185 workdir = tmp_path / "muse-work"
1186 workdir.mkdir()
1187 delta = plugin.diff(_make_manifest({}), _make_manifest({}))
1188 result = plugin.apply(delta, workdir)
1189 assert result is workdir
1190
1191
1192 # ---------------------------------------------------------------------------
1193 # CodePlugin — schema
1194 # ---------------------------------------------------------------------------
1195
1196
1197 class TestCodePluginSchema:
1198 plugin = CodePlugin()
1199
1200 def test_schema_domain(self) -> None:
1201 assert self.plugin.schema()["domain"] == "code"
1202
1203 def test_schema_merge_mode(self) -> None:
1204 assert self.plugin.schema()["merge_mode"] == "three_way"
1205
1206 def test_schema_version(self) -> None:
1207 assert self.plugin.schema()["schema_version"] == 1
1208
1209 def test_schema_dimensions(self) -> None:
1210 dims = self.plugin.schema()["dimensions"]
1211 names = {d["name"] for d in dims}
1212 assert "structure" in names
1213 assert "symbols" in names
1214 assert "imports" in names
1215
1216 def test_schema_top_level_is_tree(self) -> None:
1217 top = self.plugin.schema()["top_level"]
1218 assert top["kind"] == "tree"
1219
1220 def test_schema_description_non_empty(self) -> None:
1221 assert len(self.plugin.schema()["description"]) > 0
1222
1223
1224 # ---------------------------------------------------------------------------
1225 # delta_summary
1226 # ---------------------------------------------------------------------------
1227
1228
1229 class TestDeltaSummary:
1230 def test_empty_ops(self) -> None:
1231 assert delta_summary([]) == "no changes"
1232
1233 def test_file_added(self) -> None:
1234 from muse.domain import DomainOp
1235 ops: list[DomainOp] = [InsertOp(
1236 op="insert", address="f.py", position=None,
1237 content_id="abc", content_summary="added f.py",
1238 )]
1239 summary = delta_summary(ops)
1240 assert "added" in summary
1241 assert "file" in summary
1242
1243 def test_symbols_counted_from_patch(self) -> None:
1244 from muse.domain import DomainOp, PatchOp
1245 child: list[DomainOp] = [
1246 InsertOp(op="insert", address="f.py::foo", position=None, content_id="a", content_summary="added function foo"),
1247 InsertOp(op="insert", address="f.py::bar", position=None, content_id="b", content_summary="added function bar"),
1248 ]
1249 ops: list[DomainOp] = [PatchOp(op="patch", address="f.py", child_ops=child, child_domain="code_symbols", child_summary="2 added")]
1250 summary = delta_summary(ops)
1251 assert "symbol" in summary