Files
FabledScribe/tests/test_services_snippets.py
T
bvandeusen 85625de394
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 1m10s
feat(scribe): merge_snippets — unify found one-off snippets into one canonical
Step 2 of the snippet-merge milestone (#231). The dedup gate only PREVENTS
new near-duplicates; merge is the CURE for the ones already scattered.

- services/snippets.py: merge_snippets(user_id, target_id, source_ids) —
  keep the target's scalar fields (name/when_to_use/signature/language/
  code), union the sources' locations + extra tags onto it (so the survivor
  carries every call site as a location), trash the sources (recoverable),
  re-embed the survivor. Pure merge_snippet_fields() factored out for unit
  testing. Returns (survivor_note, merged_ids).
- mcp/tools/snippets.py: merge_snippets(target_id, source_ids) tool (5th),
  and a create_snippet dedup-path nudge toward merge over a forced copy.
- routes/snippets.py: POST /api/snippets/<id>/merge {source_ids} — share-
  aware (can_write target + every source, rule #78) with a same-owner guard
  (cross-owner merge is out of scope).
- plugin reusing-code skill + MCP _INSTRUCTIONS: point found-duplicates at
  merge as the cure (rule #119 surfaces, not a Scribe rule). plugin.json
  0.1.13 -> 0.1.14 in the same change (the #1040 marketplace-ship lesson).
- Tests: pure merge-helper union/dedup; MCP tool (requires a source,
  survivor+merged_ids, not-found); route handler + 5-tool registration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 17:11:47 -04:00

154 lines
6.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_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"