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