Skip to content

Commit 45114f5

Browse files
TTTPOBclaude
andcommitted
test: update and extend tests for MergeMode and auto-merge behavior
- test_enums: add TestMergeMode sanity coverage (str, json, coercion) - test_drift: update no-base tests to expect MERGED+PY_WINS_NO_BASE when cell counts match; add structural-mismatch, trailing-empty-cell, and two-cell py-wins cases - test_hooks_pair_drift: split drift_only tests into count-mismatch (deny) and content-diff (auto-merge) variants for both pre and post hooks - test_hooks_pre_commit: update TestDriftOnly and TestIncludeFilter to use count-mismatch for deny cases; add content-diff auto-merge case Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 71c4bdc commit 45114f5

4 files changed

Lines changed: 123 additions & 20 deletions

File tree

tests/test_drift.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import nbformat
77
import pytest
88

9+
from jupyter_jcli._enums import MergeMode
910
from jupyter_jcli.drift import DriftResult, check_drift, three_way_merge
1011
from jupyter_jcli.parser import Cell
1112

@@ -206,12 +207,15 @@ def test_no_git_base_drift_only_equal(self, tmp_path):
206207
assert result.status == "in_sync"
207208

208209
def test_no_git_base_drift_only_unequal(self, tmp_path):
209-
"""No git base + unequal cells -> drift_only."""
210+
"""No git base + same count but different sources -> merged (py wins)."""
210211
py, ipynb = _write_pair(tmp_path, ["x = 1"], ["x = 99"])
211212
with self._patch_git(None, None):
212213
result = check_drift(py, ipynb)
213-
assert result.status == "drift_only"
214-
assert len(result.conflict_indices) >= 1
214+
assert result.status == "merged"
215+
assert result.merge_mode == MergeMode.PY_WINS_NO_BASE
216+
assert result.ipynb_needs_update is True
217+
assert result.py_needs_update is False
218+
assert result.merged_cells[0].source == "x = 1"
215219

216220
def test_both_changed_different_cells_merged(self, tmp_path):
217221
"""Both sides changed different cells -> merged, both files need update."""
@@ -245,17 +249,43 @@ def _side_effect(path: Path) -> str | None:
245249
assert calls_by_suffix[".py"] >= 1
246250

247251
def test_py_untracked_ipynb_only_exists(self, tmp_path):
248-
"""With py untracked, any source difference is DRIFT_ONLY regardless of ipynb state."""
252+
"""With py untracked and same cell count, different sources -> py wins (MERGED)."""
249253
py, ipynb = _write_pair(tmp_path, ["x = 1"], ["x = 99"])
250-
# Only py has no HEAD; we don't even check ipynb HEAD
251254
with self._patch_git(None):
252255
result = check_drift(py, ipynb)
253-
assert result.status == "drift_only"
254-
assert len(result.conflict_indices) >= 1
256+
assert result.status == "merged"
257+
assert result.merge_mode == MergeMode.PY_WINS_NO_BASE
255258

256259
def test_py_untracked_sources_equal_is_in_sync(self, tmp_path):
257260
"""With py untracked and equal sources -> IN_SYNC."""
258261
py, ipynb = _write_pair(tmp_path, ["x = 1", "y = 2"], ["x = 1", "y = 2"])
259262
with self._patch_git(None):
260263
result = check_drift(py, ipynb)
261264
assert result.status == "in_sync"
265+
266+
def test_no_git_base_py_wins_py_canonical(self, tmp_path):
267+
"""No git base + 2 non-empty cells each, different sources -> MERGED, py wins."""
268+
py, ipynb = _write_pair(tmp_path, ["x = 1", "y = 2"], ["x = 99", "y = 88"])
269+
with self._patch_git(None):
270+
result = check_drift(py, ipynb)
271+
assert result.status == "merged"
272+
assert result.merge_mode == MergeMode.PY_WINS_NO_BASE
273+
assert result.py_needs_update is False
274+
assert result.ipynb_needs_update is True
275+
assert [c.source for c in result.merged_cells] == ["x = 1", "y = 2"]
276+
277+
def test_no_git_base_structural_mismatch_stays_drift_only(self, tmp_path):
278+
"""No git base + cell count mismatch -> DRIFT_ONLY (structural divergence)."""
279+
py, ipynb = _write_pair(tmp_path, ["x = 1", "y = 2", "z = 3"], ["x = 99"])
280+
with self._patch_git(None):
281+
result = check_drift(py, ipynb)
282+
assert result.status == "drift_only"
283+
assert len(result.conflict_indices) >= 1
284+
285+
def test_no_git_base_py_trailing_empty_cell_is_in_sync(self, tmp_path):
286+
"""No git base + py has trailing empty cell -> filtered out, still IN_SYNC."""
287+
# _write_pair writes cells as-is; empty string -> empty cell in py
288+
py, ipynb = _write_pair(tmp_path, ["x = 1", ""], ["x = 1"])
289+
with self._patch_git(None):
290+
result = check_drift(py, ipynb)
291+
assert result.status == "in_sync"

tests/test_enums.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import pytest
88

9-
from jupyter_jcli._enums import CellType, DriftStatus, OutputType, ResponseStatus
9+
from jupyter_jcli._enums import CellType, DriftStatus, MergeMode, OutputType, ResponseStatus
1010
from jupyter_jcli.drift import DriftResult
1111

1212

@@ -56,6 +56,38 @@ def test_drift_result_invalid_status_raises(self):
5656
# CellType
5757
# ---------------------------------------------------------------------------
5858

59+
class TestMergeMode:
60+
def test_members_exist(self):
61+
assert MergeMode.THREE_WAY
62+
assert MergeMode.PY_WINS_NO_BASE
63+
64+
def test_str_inheritance(self):
65+
assert MergeMode.THREE_WAY == "three_way"
66+
assert MergeMode.PY_WINS_NO_BASE == "py_wins_no_base"
67+
assert isinstance(MergeMode.THREE_WAY, str)
68+
69+
def test_json_serializable(self):
70+
assert json.dumps(MergeMode.THREE_WAY) == '"three_way"'
71+
72+
def test_invalid_raises(self):
73+
with pytest.raises(ValueError):
74+
MergeMode("bogus")
75+
76+
def test_coerce_from_string(self):
77+
assert MergeMode("three_way") is MergeMode.THREE_WAY
78+
assert MergeMode("py_wins_no_base") is MergeMode.PY_WINS_NO_BASE
79+
80+
def test_drift_result_coerces_merge_mode(self):
81+
from jupyter_jcli.drift import DriftResult
82+
r = DriftResult(status="merged", merge_mode="py_wins_no_base")
83+
assert r.merge_mode is MergeMode.PY_WINS_NO_BASE
84+
85+
def test_drift_result_defaults_to_three_way(self):
86+
from jupyter_jcli.drift import DriftResult
87+
r = DriftResult(status="in_sync")
88+
assert r.merge_mode is MergeMode.THREE_WAY
89+
90+
5991
class TestCellType:
6092
def test_members_exist(self):
6193
assert CellType.CODE

tests/test_hooks_pair_drift.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -263,18 +263,30 @@ def _git_side(path: Path) -> str | None:
263263
assert "Pre-existing conflict" in reason or "pre-existing" in reason.lower()
264264
assert "git diff" in reason
265265

266-
def test_drift_only_returns_deny(self, tmp_path):
267-
"""No git base + unequal cells -> deny."""
268-
py, ipynb = _make_pair(tmp_path, ["x = 1"], ["x = 99"])
266+
def test_drift_only_count_mismatch_returns_deny(self, tmp_path):
267+
"""No git base + cell count mismatch -> deny."""
268+
py, ipynb = _make_pair(tmp_path, ["x = 1", "y = 2"], ["x = 99"])
269269
with patch("jupyter_jcli.drift._get_git_base_text", return_value=None):
270270
code, out = _invoke({"tool_name": "Edit", "tool_input": {"file_path": str(py)}})
271271
assert code == 0
272272
assert _decision(out) == "deny"
273273
reason = _reason(out)
274-
# New message: "not yet committed" and "git log --oneline"
275274
assert "not yet committed" in reason
276275
assert "git log" in reason
277276

277+
def test_drift_only_content_diff_auto_merges(self, tmp_path):
278+
"""No git base + same count but different sources -> silent allow + ipynb rewritten."""
279+
import nbformat as nbf
280+
281+
py, ipynb = _make_pair(tmp_path, ["x = 1"], ["x = 99"])
282+
with patch("jupyter_jcli.drift._get_git_base_text", return_value=None):
283+
code, out = _invoke({"tool_name": "Edit", "tool_input": {"file_path": str(py)}})
284+
assert code == 0
285+
assert _decision(out) is None # silent allow
286+
nb = nbf.read(str(ipynb), as_version=4)
287+
non_empty = [c.source for c in nb.cells if c.source.strip()]
288+
assert non_empty == ["x = 1"]
289+
278290

279291
# ---------------------------------------------------------------------------
280292
# Fail-open on bad input / exceptions
@@ -477,9 +489,9 @@ def _git_side(path: Path) -> str | None:
477489
assert "j-cli convert" in reason
478490
assert _event_name(out) == "PostToolUse"
479491

480-
def test_drift_only_after_edit_warns(self, tmp_path):
481-
"""py has no git baseline after agent's edit -> warn with convert hint."""
482-
py, ipynb = _make_pair(tmp_path, ["x = 10"], ["x = 99"])
492+
def test_drift_only_count_mismatch_after_edit_warns(self, tmp_path):
493+
"""py has no git baseline + count mismatch after agent's edit -> warn with convert hint."""
494+
py, ipynb = _make_pair(tmp_path, ["x = 10", "y = 20"], ["x = 99"])
483495

484496
with patch("jupyter_jcli.drift._get_git_base_text", return_value=None):
485497
code, out = _invoke_post({"tool_name": "Edit", "tool_input": {"file_path": str(py)}})
@@ -491,6 +503,20 @@ def test_drift_only_after_edit_warns(self, tmp_path):
491503
assert "j-cli convert" in reason
492504
assert _event_name(out) == "PostToolUse"
493505

506+
def test_drift_only_content_diff_after_edit_syncs(self, tmp_path):
507+
"""py has no git baseline + same count, different sources -> allow + reason explains."""
508+
py, ipynb = _make_pair(tmp_path, ["x = 10"], ["x = 99"])
509+
510+
with patch("jupyter_jcli.drift._get_git_base_text", return_value=None):
511+
code, out = _invoke_post({"tool_name": "Edit", "tool_input": {"file_path": str(py)}})
512+
513+
assert code == 0
514+
assert _decision(out) == "allow"
515+
reason = _reason(out)
516+
assert "no git baseline" in reason
517+
assert "outputs preserved" in reason
518+
assert _event_name(out) == "PostToolUse"
519+
494520
def test_non_paired_file_is_silent(self, tmp_path):
495521
"""Files without a paired counterpart are silently ignored."""
496522
solo = tmp_path / "script.py"

tests/test_hooks_pre_commit.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -267,9 +267,9 @@ def test_conflict_exits_1_with_cell_index(self, git_repo, monkeypatch):
267267
class TestDriftOnly:
268268
def test_drift_only_exits_1(self, git_repo, monkeypatch):
269269
monkeypatch.chdir(git_repo)
270-
# No commit → no git base
271-
_make_py(git_repo / "nb.py", "x = 1")
272-
_make_ipynb(git_repo / "nb.ipynb", "x = 99") # different
270+
# No commit → no git base; use count mismatch to stay DRIFT_ONLY
271+
_make_py(git_repo / "nb.py", "x = 1", "y = 2")
272+
_make_ipynb(git_repo / "nb.ipynb", "x = 99") # count mismatch
273273
_git(git_repo, "git", "add", "nb.py")
274274

275275
runner = CliRunner()
@@ -280,6 +280,21 @@ def test_drift_only_exits_1(self, git_repo, monkeypatch):
280280
assert "git base" in combined or "pick a side" in combined.lower()
281281
assert "j-cli convert" in combined
282282

283+
def test_content_diff_same_count_auto_merges(self, git_repo, monkeypatch):
284+
"""No git base + same count but different content -> auto-merge, exit 0."""
285+
monkeypatch.chdir(git_repo)
286+
_make_py(git_repo / "nb.py", "x = 1")
287+
_make_ipynb(git_repo / "nb.ipynb", "x = 99") # same count, different content
288+
_git(git_repo, "git", "add", "nb.py")
289+
290+
runner = CliRunner()
291+
result = _invoke(runner)
292+
293+
assert result.exit_code == 0
294+
nb = nbformat.read(str(git_repo / "nb.ipynb"), as_version=4)
295+
non_empty = [c for c in nb.cells if c.source.strip()]
296+
assert non_empty[0].source == "x = 1"
297+
283298

284299
# ---------------------------------------------------------------------------
285300
# Two sides change different cells -> merged, both written, exit 0
@@ -323,8 +338,8 @@ def test_include_matching_processes_file(self, git_repo, monkeypatch):
323338
monkeypatch.chdir(git_repo)
324339
sub = git_repo / "nb"
325340
sub.mkdir()
326-
_make_py(sub / "script.py", "x = 1")
327-
_make_ipynb(sub / "script.ipynb", "x = 99") # drift → exit 1
341+
_make_py(sub / "script.py", "x = 1", "y = 2")
342+
_make_ipynb(sub / "script.ipynb", "x = 99") # count mismatch → drift_only → exit 1
328343
_git(git_repo, "git", "add", "nb/script.py")
329344

330345
runner = CliRunner()

0 commit comments

Comments
 (0)