b33e2a79c6
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s
Four defects from the 2026-07-25 review of the recall (#227) and merge (#231) milestones. The theme: a snippet could be recorded but not fully corrected, and the agent and web surfaces had drifted apart. - #2076 language was mis-derived from the first caller tag, so a snippet created with tags and no language read that tag back as its language — corrupting the tag set and the code fence on the next update. Only the FIRST tag can carry the language, since compose_tags emits [language, "snippet", *caller]. - #2077 MCP update_snippet mapped "" to "unchanged", so no field could ever be cleared and no snippet detached from its project. Now an omitted field is left alone, an empty string clears, and project_id follows the -1 = detach convention. A service-level UNSET sentinel keeps None available as the clear. - #2078 surface parity: adds delete_snippet (MCP had none, so a wrong snippet could not be retired by the agent that recorded it), locations on MCP create and update, system_ids through the REST routes and the editor, and the near-duplicate gate on REST create with a "record it anyway" escape. - #2079 project scoping: list_snippets takes project_id through the service, the MCP tool and the REST route, defaulting to every project — reaching across projects is the point when the helper you need was written elsewhere. Sharing the list across owners is deliberately NOT in here: query_knowledge is shared with the Knowledge browse surface, so widening it changes behaviour well beyond snippets. Left open on #2079 for a scope decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
174 lines
7.0 KiB
Python
174 lines
7.0 KiB
Python
"""Unit tests for the snippet serialize/parse helpers (pure functions, no DB)."""
|
|
from scribe.services import snippets as s
|
|
|
|
|
|
def test_compose_title_with_and_without_usage():
|
|
assert s.compose_title("debounce", "rate-limit a callback") == "debounce — rate-limit a callback"
|
|
assert s.compose_title(" debounce ", "") == "debounce"
|
|
assert s.compose_title("debounce") == "debounce"
|
|
|
|
|
|
def test_compose_tags_lowercases_language_and_dedups():
|
|
assert s.compose_tags("Python", ["util", "python"]) == ["python", "snippet", "util"]
|
|
assert s.compose_tags("", None) == ["snippet"]
|
|
assert s.compose_tags("vue", ["snippet"]) == ["vue", "snippet"]
|
|
|
|
|
|
def test_compose_body_includes_fields_and_fence():
|
|
body = s.compose_body(
|
|
code="return 1", language="python", signature="f() -> int",
|
|
when_to_use="always", repo="scribe", path="a.py", symbol="f",
|
|
)
|
|
assert "**When to use:** always" in body
|
|
assert "**Signature:** `f() -> int`" in body
|
|
assert "`scribe` · `a.py` · `f`" in body
|
|
assert "```python\nreturn 1\n```" in body
|
|
assert body.rstrip().endswith("```")
|
|
|
|
|
|
def test_compose_body_bare_code_only():
|
|
body = s.compose_body(code="x = 1")
|
|
assert body.strip() == "```\nx = 1\n```"
|
|
|
|
|
|
def test_parse_round_trips_a_composed_snippet():
|
|
title = s.compose_title("useDebouncedRef", "debounce a reactive ref")
|
|
body = s.compose_body(
|
|
code="const x = 1", language="ts", signature="useDebouncedRef(v, ms)",
|
|
when_to_use="debounce a reactive ref", repo="scribe",
|
|
path="frontend/src/composables/x.ts", symbol="useDebouncedRef",
|
|
)
|
|
got = s.parse_snippet_fields(title, body, ["ts", "snippet"])
|
|
assert got["name"] == "useDebouncedRef"
|
|
assert got["when_to_use"] == "debounce a reactive ref"
|
|
assert got["signature"] == "useDebouncedRef(v, ms)"
|
|
assert got["language"] == "ts"
|
|
assert got["code"] == "const x = 1"
|
|
assert got["repo"] == "scribe"
|
|
assert got["path"] == "frontend/src/composables/x.ts"
|
|
assert got["symbol"] == "useDebouncedRef"
|
|
|
|
|
|
def test_parse_is_tolerant_of_plain_body():
|
|
got = s.parse_snippet_fields("just a name", "no structure here", None)
|
|
assert got["name"] == "just a name"
|
|
assert got["when_to_use"] == ""
|
|
assert got["signature"] == ""
|
|
assert got["code"] == "" # never raises on an unstructured body
|
|
|
|
|
|
def test_parse_falls_back_to_tag_for_language():
|
|
got = s.parse_snippet_fields("n — u", "```\ncode\n```", ["ruby", "snippet"])
|
|
assert got["language"] == "ruby"
|
|
|
|
|
|
def test_parse_does_not_promote_a_caller_tag_to_language():
|
|
# compose_tags puts the language FIRST, so a leading "snippet" marker means
|
|
# no language was recorded — the tags after it are the caller's own and must
|
|
# not be mistaken for one (which would also corrupt the code fence on the
|
|
# next update).
|
|
tags = s.compose_tags("", ["auth"])
|
|
assert tags == ["snippet", "auth"]
|
|
got = s.parse_snippet_fields("n — u", s.compose_body(code="x = 1"), tags)
|
|
assert got["language"] == ""
|
|
|
|
|
|
def test_caller_tag_survives_an_update_round_trip_without_a_language():
|
|
# Regression: the tag used to be read back as the language, then dropped
|
|
# from the extra-tag set on re-compose — so it silently disappeared.
|
|
tags = s.compose_tags("", ["auth"])
|
|
fields = s.parse_snippet_fields("n — u", s.compose_body(code="x = 1"), tags)
|
|
extra = [t for t in tags if t not in (s.SNIPPET_TAG, fields["language"])]
|
|
assert s.compose_tags(fields["language"], extra) == ["snippet", "auth"]
|
|
|
|
|
|
def test_compose_body_multi_location_renders_bullet_list():
|
|
body = s.compose_body(
|
|
code="x = 1",
|
|
locations=[
|
|
{"repo": "scribe", "path": "a.py", "symbol": "f"},
|
|
{"repo": "web", "path": "b.ts", "symbol": "g"},
|
|
],
|
|
)
|
|
assert "**Locations:**" in body
|
|
assert "- `scribe` · `a.py` · `f`" in body
|
|
assert "- `web` · `b.ts` · `g`" in body
|
|
assert "**Location:**" not in body.replace("**Locations:**", "")
|
|
|
|
|
|
def test_compose_body_single_location_via_list_uses_singular_label():
|
|
body = s.compose_body(
|
|
code="x = 1", locations=[{"repo": "scribe", "path": "a.py", "symbol": "f"}],
|
|
)
|
|
assert "**Location:** `scribe` · `a.py` · `f`" in body
|
|
assert "**Locations:**" not in body
|
|
|
|
|
|
def test_normalize_locations_drops_empty_and_dedups():
|
|
got = s._normalize_locations([
|
|
{"repo": "scribe", "path": "a.py", "symbol": "f"},
|
|
{"repo": "", "path": "", "symbol": ""}, # dropped (empty)
|
|
{"repo": "scribe", "path": "a.py", "symbol": "f"}, # dropped (dup)
|
|
{"repo": "web", "path": "", "symbol": ""},
|
|
])
|
|
assert got == [
|
|
{"repo": "scribe", "path": "a.py", "symbol": "f"},
|
|
{"repo": "web", "path": "", "symbol": ""},
|
|
]
|
|
|
|
|
|
def test_parse_round_trips_multi_location():
|
|
body = s.compose_body(
|
|
code="const x = 1", language="ts",
|
|
locations=[
|
|
{"repo": "scribe", "path": "a.ts", "symbol": "f"},
|
|
{"repo": "web", "path": "b.ts", "symbol": "g"},
|
|
],
|
|
)
|
|
got = s.parse_snippet_fields("n — u", body, ["ts", "snippet"])
|
|
assert got["locations"] == [
|
|
{"repo": "scribe", "path": "a.ts", "symbol": "f"},
|
|
{"repo": "web", "path": "b.ts", "symbol": "g"},
|
|
]
|
|
# repo/path/symbol mirror the first location for back-compat.
|
|
assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.ts", "f")
|
|
|
|
|
|
def test_parse_legacy_single_location_line_still_works():
|
|
# A body written by the pre-multi-location serializer.
|
|
body = "**Location:** `scribe` · `a.py` · `f`\n\n```py\nx = 1\n```\n"
|
|
got = s.parse_snippet_fields("n — u", body, None)
|
|
assert got["locations"] == [{"repo": "scribe", "path": "a.py", "symbol": "f"}]
|
|
assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.py", "f")
|
|
|
|
|
|
def test_merge_snippet_fields_unions_locations_and_tags():
|
|
target_fields = {"language": "py", "locations": [{"repo": "a", "path": "a.py", "symbol": "f"}]}
|
|
src1 = ({"language": "py", "locations": [{"repo": "b", "path": "b.py", "symbol": "g"}]},
|
|
["py", "snippet", "helper"])
|
|
src2 = ({"language": "py", "locations": [{"repo": "a", "path": "a.py", "symbol": "f"}]}, # dup loc
|
|
["snippet"])
|
|
locs, extra = s.merge_snippet_fields(target_fields, ["py", "snippet", "core"], [src1, src2])
|
|
# target location first, then unique source locations; dup dropped.
|
|
assert locs == [
|
|
{"repo": "a", "path": "a.py", "symbol": "f"},
|
|
{"repo": "b", "path": "b.py", "symbol": "g"},
|
|
]
|
|
# extra tags unioned, language + "snippet" markers excluded.
|
|
assert extra == ["core", "helper"]
|
|
|
|
|
|
def test_snippet_to_dict_includes_parsed_fields():
|
|
class FakeNote:
|
|
title = "debounce — rate-limit"
|
|
body = "```js\ncode\n```\n"
|
|
tags = ["js", "snippet"]
|
|
|
|
def to_dict(self):
|
|
return {"id": 1, "title": self.title, "note_type": "snippet", "tags": self.tags}
|
|
|
|
data = s.snippet_to_dict(FakeNote())
|
|
assert data["snippet"]["name"] == "debounce"
|
|
assert data["snippet"]["language"] == "js"
|
|
assert data["snippet"]["code"] == "code"
|