Files
marketplace/tests/test_checkbox_merge.py
T
naudachu d4c43464e5 feat: merge checkbox state on pull instead of overwriting it
A tick was lost in both directions: pull wrote the server body as-is,
push sent the local body as-is, last writer won. Tick it in the web UI
and the first `push.py --update` dropped it; tick it locally and the
first pull dropped it.

The usual answer is drift tracking and a three-way merge, which this
repo rejected on purpose. It is not needed. A tick is monotone — an item
only travels `[ ]` -> `[x]` — so unioning the two sides is a set union,
not conflict resolution. One rule for one line type replaces the whole
mechanism, and the store stays "not a mirror".

`map.merge_checkbox_state` is pure and does the work; `from_api` takes
the local body as an optional argument; `pull.py` hands it the copy
already on disk. Checkbox parsing is imported from `skills/issue`
(`checkboxes` / `set_checkbox`), never redefined here — the domain layer
is untouched.

The same item text more than once is read as a set: one ticked local
item ticks every server line with that text. Pairing duplicates up by
order is the alternative, and it can still drop a tick — which is the
bug being fixed.

The price is documented, not hidden: unticking is not monotone, so a box
unticked in the web UI comes back on the next pull. Untick locally, then
push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:52:11 +05:00

408 lines
19 KiB
Python

#!/usr/bin/env python3
"""
Checkbox state survives a pull; everything else in the body does not.
Two levels, on purpose. `map.merge_checkbox_state` is pure, so most of the rule
is pinned down with plain strings and no store anywhere. The pull tests then
prove the rule is actually wired into the write path, with the transport
stubbed at the one seam `test_push_dependencies.py` uses — `_gitea.api`, the
single function that shells out to `tea`. Nothing here touches a network, and
no test may ever be made to.
`skills/*/scripts/` are not packages; they go on sys.path by hand.
"""
import contextlib
import io
import os
import re
import shutil
import sys
import tempfile
import unittest
import urllib.parse
from unittest import mock
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
os.path.join(_ROOT, "skills", "issue", "scripts")):
if _p not in sys.path:
sys.path.insert(0, _p)
import _gitea # noqa: E402
import issue # noqa: E402
import map as gmap # noqa: E402
import pull # noqa: E402
REPO = "claude-skills/tea"
BASE = "repos/%s" % REPO
def body(*criteria, **kw):
"""A body in the canonical shape, with the given `## Acceptance criteria`."""
summary = kw.get("summary", "Прозаическое описание.")
return ("## Summary\n%s\n\n## Spec\nnone\n\n## Acceptance criteria\n%s\n"
% (summary, "\n".join(criteria))).strip()
# --------------------------------------------------------------------------
# the rule itself — pure, no store, no tracker
# --------------------------------------------------------------------------
class MergeCheckboxStateTest(unittest.TestCase):
"""`[x]` wins from whichever side has it, for a matching item text."""
def test_a_local_tick_survives_the_overwrite(self):
got = gmap.merge_checkbox_state(body("- [ ] первое", "- [ ] второе"),
body("- [x] первое", "- [ ] второе"))
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
def test_a_remote_tick_is_kept(self):
got = gmap.merge_checkbox_state(body("- [x] первое", "- [ ] второе"),
body("- [ ] первое", "- [ ] второе"))
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
def test_both_sides_ticked_is_still_ticked(self):
one = body("- [x] первое")
self.assertEqual(gmap.merge_checkbox_state(one, one), one)
def test_the_union_is_taken_item_by_item(self):
got = gmap.merge_checkbox_state(
body("- [x] первое", "- [ ] второе", "- [ ] третье"),
body("- [ ] первое", "- [x] второе", "- [ ] третье"))
self.assertEqual(got, body("- [x] первое", "- [x] второе", "- [ ] третье"))
def test_an_item_the_local_copy_does_not_have_comes_from_the_server(self):
"""Including its state — both states, in both directions."""
got = gmap.merge_checkbox_state(
body("- [x] новое сверху", "- [ ] новое снизу"),
body("- [x] что-то совсем другое"))
self.assertEqual(got, body("- [x] новое сверху", "- [ ] новое снизу"))
def test_prose_is_not_merged(self):
got = gmap.merge_checkbox_state(
body("- [ ] пункт", summary="Новый текст с сервера."),
body("- [x] пункт", summary="Старый локальный текст."))
self.assertIn("Новый текст с сервера.", got)
self.assertNotIn("Старый локальный текст.", got)
self.assertIn("- [x] пункт", got)
def test_a_heading_the_local_copy_added_is_gone(self):
remote = body("- [x] пункт")
got = gmap.merge_checkbox_state(remote, remote + "\n\n## Notes\nмои заметки\n")
self.assertEqual(got, remote)
def test_no_local_copy_returns_the_server_body_untouched(self):
remote = body("- [ ] пункт")
for local in (None, "", " \n"):
self.assertIs(gmap.merge_checkbox_state(remote, local), remote,
"local_body=%r rewrote the body" % local)
def test_nothing_ticked_locally_returns_the_same_object(self):
remote = body("- [ ] пункт")
self.assertIs(gmap.merge_checkbox_state(remote, body("- [ ] пункт")), remote)
def test_no_matching_item_returns_the_same_object(self):
remote = body("- [ ] пункт")
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] другой")), remote)
def test_a_body_with_no_checkboxes_at_all(self):
remote = "## Summary\nодна проза\n"
self.assertIs(gmap.merge_checkbox_state(remote, "- [x] пункт"), remote)
self.assertEqual(gmap.merge_checkbox_state("- [ ] пункт", remote), "- [ ] пункт")
def test_exactly_one_character_changes(self):
"""Ticking a box must not produce a diff wider than the state."""
remote = body("- [ ] пункт", "- [ ] второй")
got = gmap.merge_checkbox_state(remote, body("- [x] пункт", "- [ ] второй"))
diff = [i for i, (a, b) in enumerate(zip(remote, got)) if a != b]
self.assertEqual(len(remote), len(got))
self.assertEqual(len(diff), 1)
self.assertEqual((remote[diff[0]], got[diff[0]]), (" ", "x"))
def test_a_rewrapped_item_keeps_its_tick(self):
"""`Checkbox.text` joins continuation lines with one space, which is
the whole reason matching survives a reflow."""
remote = body("- [ ] длинный пункт, который сервер\n"
" перенёс на две строки")
got = gmap.merge_checkbox_state(
remote, body("- [x] длинный пункт, который сервер перенёс на две строки"))
self.assertIn("- [x] длинный пункт", got)
def test_a_reworded_item_does_not_keep_its_tick(self):
"""Different text is a different item. The tick stays with the wording
it was put on — this is a match, not a guess."""
got = gmap.merge_checkbox_state(body("- [ ] пункт про sqlc"),
body("- [x] пункт про SQLC"))
self.assertEqual(got, body("- [ ] пункт про sqlc"))
def test_the_marker_style_does_not_have_to_match(self):
"""`-`, `*` and `1.` are all checkbox markers to the domain parser, so
the item is the same item however the two sides chose to render it."""
got = gmap.merge_checkbox_state(body("1. [ ] пункт"), body("* [x] пункт"))
self.assertEqual(got, body("1. [x] пункт"))
def test_a_moved_item_keeps_its_tick(self):
"""Matching is on text alone; the section is not part of the key. An
item promoted out of `## Acceptance criteria` is the same item."""
remote = "## Issues\n- [ ] пункт\n"
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
self.assertEqual(got, "## Issues\n- [x] пункт\n")
def test_duplicate_text_is_read_as_a_set(self):
"""The documented reading: one ticked local item ticks every remote
line with that text. Pairing duplicates by order is the alternative,
and it is the one that can still drop a tick."""
got = gmap.merge_checkbox_state(body("- [ ] пункт", "- [ ] пункт"),
body("- [ ] пункт", "- [x] пункт"))
self.assertEqual(got, body("- [x] пункт", "- [x] пункт"))
def test_duplicate_text_never_loses_the_second_tick(self):
"""Two local lines, one remote: order-pairing would drop this tick."""
got = gmap.merge_checkbox_state(body("- [ ] пункт"),
body("- [ ] пункт", "- [x] пункт"))
self.assertEqual(got, body("- [x] пункт"))
def test_an_example_inside_a_code_fence_is_not_ticked(self):
"""The domain parser skips fences whole, and so does the merge: a
`- [ ]` in a fence is markup being shown, not a box anyone may tick."""
remote = "## Spec\n```md\n- [ ] пункт\n```\n\n## Acceptance criteria\n- [ ] пункт\n"
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
self.assertEqual(got, remote.replace("## Acceptance criteria\n- [ ] пункт",
"## Acceptance criteria\n- [x] пункт"))
self.assertIn("```md\n- [ ] пункт\n```", got)
def test_an_existing_capital_X_is_left_alone(self):
remote = body("- [X] пункт")
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] пункт")), remote)
def test_a_capital_X_locally_still_counts_as_ticked(self):
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [X] пункт"))
self.assertEqual(got, body("- [x] пункт"))
def test_unticking_in_the_web_does_not_survive(self):
"""The accepted price, pinned so nobody 'fixes' it by accident:
unticking is not monotone, so a box unticked upstream comes back.
Untick locally and push."""
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
self.assertEqual(got, body("- [x] пункт"))
def test_parsing_is_the_domain_layer_s(self):
"""The acceptance criterion, asserted rather than eyeballed: the rule
calls into skills/issue and defines no checkbox syntax of its own."""
with mock.patch.object(issue, "checkboxes", wraps=issue.checkboxes) as cb, \
mock.patch.object(issue, "set_checkbox", wraps=issue.set_checkbox) as sc:
gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
self.assertTrue(cb.called)
self.assertTrue(sc.called)
def test_no_checkbox_markup_is_spelled_out_in_the_sync_layer(self):
"""The same criterion from the other side: the bracket markup itself
appears nowhere under skills/sync/scripts. Knowing what `[ ]` looks
like is the domain's job, and there is only one copy of it."""
sync = os.path.join(_ROOT, "skills", "sync", "scripts")
for name in sorted(f for f in os.listdir(sync) if f.endswith(".py")):
with open(os.path.join(sync, name)) as f:
code = f.read().split('"""')[0::2] # docstrings dropped
for chunk in code:
for markup in ("[ xX]", "[xX]", "- [ ]", "- [x]"):
self.assertNotIn(markup, chunk,
"%s spells out %r" % (name, markup))
class FromApiTest(unittest.TestCase):
"""The seam between the rule and the translation."""
PAYLOAD = {"number": 42, "title": "T", "html_url": "u",
"body": body("- [ ] пункт")}
def test_local_body_is_optional_and_defaults_to_no_merge(self):
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO)
self.assertEqual(iss.body, body("- [ ] пункт"))
def test_local_body_contributes_its_ticks(self):
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO,
local_body=body("- [x] пункт"))
self.assertEqual(iss.body, body("- [x] пункт"))
def test_an_empty_remote_body_does_not_crash(self):
iss, _ = gmap.from_api({"number": 42, "title": "T", "body": None},
"an-issue", REPO, local_body=body("- [x] пункт"))
self.assertEqual(iss.body, "")
# --------------------------------------------------------------------------
# pull.py — the rule wired into the write path
# --------------------------------------------------------------------------
class FakeGitea(object):
"""A `tea api` that answers issues from memory and remembers the calls."""
def __init__(self):
self.calls = []
self.issues = {}
def add(self, number, title, text, **kw):
p = {"number": number, "title": title, "body": text, "state": "open",
"comments": 0, "html_url": "https://git.example/%s/issues/%d" % (REPO, number),
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
"updated_at": "2026-08-10T00:00:00Z"}
p.update(kw)
self.issues[number] = p
return p
def api(self, login, endpoint, method="GET", payload=None, **kw):
self.calls.append((method, endpoint))
path, _, query = endpoint.partition("?")
params = dict(urllib.parse.parse_qsl(query))
m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path)
if m and method == "GET":
return self.issues.get(int(m.group(1)))
if path == "%s/issues" % BASE and method == "GET":
if int(params.get("page", 1)) > 1:
return []
return [self.issues[n] for n in sorted(self.issues)]
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
class PullTestCase(unittest.TestCase):
"""A temp store and a fake transport."""
def setUp(self):
self.root = tempfile.mkdtemp(prefix="tea-store-")
self.fake = FakeGitea()
for p in (mock.patch.object(_gitea, "api", self.fake.api),
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
p.start()
self.addCleanup(p.stop)
self.addCleanup(shutil.rmtree, self.root, True)
def write_local(self, id, text, number=42):
issue.save(self.root, issue.Issue(
id=id, title="An issue", body=text, labels=["type/task"],
origin="gitea", extra={"gitea": "%s#%d" % (REPO, number),
"url": "https://git.example/x", "synced": "old"}))
def run_pull(self, *argv):
out, err = io.StringIO(), io.StringIO()
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
with mock.patch.object(sys, "argv", args), \
contextlib.redirect_stdout(out), \
contextlib.redirect_stderr(err):
pull.main()
return out.getvalue(), err.getvalue()
def stored_body(self, id):
return issue.load(self.root, id).body
def raw(self, id):
"""The file on disk, byte for byte — metadata included."""
with open(issue.path_of(self.root, id)) as f:
return f.read()
class PullMergesTicksTest(PullTestCase):
def test_a_tick_made_locally_survives_the_pull(self):
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
def test_a_tick_made_in_the_web_lands_locally(self):
self.write_local("an-issue", body("- [ ] первое", "- [ ] второе"))
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
def test_the_rest_of_the_body_is_still_overwritten(self):
self.write_local("an-issue", body("- [x] первое",
summary="Локальная правка прозы."))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] новое с сервера",
summary="Серверная проза."))
self.run_pull("42")
got = self.stored_body("an-issue")
self.assertIn("Серверная проза.", got)
self.assertNotIn("Локальная правка прозы.", got)
self.assertIn("- [x] первое", got)
self.assertIn("- [ ] новое с сервера", got)
def test_an_item_the_server_added_arrives_ticked_if_the_server_ticked_it(self):
self.write_local("an-issue", body("- [ ] первое"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [x] новое с сервера"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [ ] первое", "- [x] новое с сервера"))
def test_filter_mode_merges_too(self):
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "An issue", body("- [ ] первое"))
self.run_pull("--label", "type/task")
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
def test_a_retitled_issue_keeps_its_slug_and_its_ticks(self):
"""The merge hangs off the local id, which is resolved from the number
— a title change must not orphan the ticks."""
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "Completely different title", body("- [ ] первое"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
self.assertEqual(issue.load(self.root, "an-issue").title,
"Completely different title")
class PullIntoAnEmptyStoreTest(PullTestCase):
def test_no_local_file_writes_the_server_body_unchanged(self):
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
def test_a_store_that_does_not_exist_yet_is_created_and_not_merged(self):
shutil.rmtree(self.root)
self.fake.add(42, "An issue", body("- [ ] первое"))
_out, err = self.run_pull("42")
self.assertIn("created store", err)
self.assertEqual(self.stored_body("an-issue"), body("- [ ] первое"))
class CachedIsUnchangedTest(PullTestCase):
def test_a_skipped_issue_is_neither_read_nor_merged(self):
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
before = self.raw("an-issue")
with mock.patch.object(gmap, "merge_checkbox_state") as merge:
out, _ = self.run_pull("42", "--cached")
merge.assert_not_called()
self.assertIn("(cached)", out)
self.assertEqual(self.raw("an-issue"), before)
def test_without_cached_the_same_issue_is_merged(self):
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
class RoundTripTest(PullTestCase):
"""Pull twice with no change in between: the second is a no-op."""
def test_a_repeat_pull_does_not_churn_the_file(self):
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
self.run_pull("42")
first = self.raw("an-issue")
self.run_pull("42")
self.assertEqual(self.raw("an-issue"), first)
if __name__ == "__main__":
unittest.main()