Files
FabledGrimoire/tests/test_snippet_notes.py
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

155 lines
6.0 KiB
Python

"""A snippet's notes — where the explanation goes.
A snippet has name, code, signature, locations and `when_to_use`. Without a
place for prose, what a session learned about a snippet goes into
`when_to_use` — the trigger, joined onto the title of every chunk the snippet
is embedded as — so a multi-paragraph write-up there blurs the one situation
it is ranked on.
The rules pinned here, each with a way to rot silently:
- Notes live in the BODY, after the code, under `## Notes`, and read back
from it. A `## Notes` line inside the code is code, not the section.
- A snippet with no notes composes the body it always did, byte for byte —
no re-embed for the corpus that has none.
- Every path that rebuilds the body CARRIES the notes. `update_snippet`,
merge and un-merge all compose the body from scratch, so a field one of
them forgets is erased by any edit to something else.
- `trigger_advice` speaks for a write-up and stays quiet for a situation.
"""
import pytest
import pytest_asyncio
from tests.helpers import ensure_user
from grimoire.services import snippets as s
CODE = "def helper():\n return 1"
NOTES = "Shaped this way because the caller owns the session.\n\nSuperseded #12."
# --- unit: the body convention -----------------------------------------------
def test_notes_follow_the_code_under_their_heading():
body = s.compose_body(code=CODE, language="python", when_to_use="a helper", notes=NOTES)
code_at = body.index("```python")
notes_at = body.index(s.NOTES_HEADING)
assert code_at < notes_at
assert body.rstrip().endswith("Superseded #12.")
def test_notes_round_trip_through_the_body():
body = s.compose_body(code=CODE, language="python", notes=NOTES)
got = s.parse_snippet_fields("helper", body, ["python", "snippet"])
assert got["notes"] == NOTES
assert got["code"] == CODE
def test_a_snippet_without_notes_composes_the_body_it_always_did():
"""No trailing section, no extra blank line: the corpus that has no notes
must embed exactly as before."""
body = s.compose_body(code=CODE, language="python", when_to_use="a helper")
assert body == "**When to use:** a helper\n\n```python\n" + CODE + "\n```\n"
assert s.parse_snippet_fields("helper", body)["notes"] == ""
def test_a_notes_heading_inside_the_code_is_code():
md = "# Title\n\n## Notes\n\nthis is markdown being recorded"
body = s.compose_body(code=md, language="markdown")
got = s.parse_snippet_fields("md_template", body)
assert got["notes"] == ""
assert got["code"] == md
def test_notes_after_code_that_itself_contains_the_heading():
md = "## Notes\ninside"
body = s.compose_body(code=md, language="markdown", notes="the real notes")
got = s.parse_snippet_fields("md_template", body)
assert got["notes"] == "the real notes"
assert got["code"] == md
def test_long_notes_are_chunked_apart_from_the_code():
"""The heading is the chunker's split point, so a long explanation gets
vectors of its own instead of averaging into the code's."""
from grimoire.services.embeddings import chunk_document
long_notes = "\n\n".join(["An explanatory paragraph about the history. " * 12] * 4)
body = s.compose_body(code=CODE, language="python", when_to_use="a helper",
notes=long_notes)
chunks = chunk_document("helper — a helper", body)
assert len(chunks) > 1
assert "```python" in chunks[0]
assert "```python" not in chunks[-1]
# --- unit: the advice ---------------------------------------------------------
def test_a_situation_draws_no_advice():
assert s.trigger_advice("Debouncing a reactive input before it fetches.") is None
assert s.trigger_advice("") is None
assert s.trigger_advice(None) is None
def test_a_long_trigger_is_advised_toward_notes():
advice = s.trigger_advice("x" * (s.TRIGGER_ADVISE_CHARS + 1))
assert advice and "notes" in advice
assert f"{s.TRIGGER_ADVISE_CHARS + 1} characters" in advice
def test_a_headed_or_multi_paragraph_trigger_is_advised_even_when_short():
assert "headings" in s.trigger_advice("When adding a record.\n\n## Why\nbecause")
assert "paragraphs" in s.trigger_advice("When adding a record.\n\nAlso, history.")
# --- integration: every body rebuild carries the notes ------------------------
@pytest_asyncio.fixture
async def user_id(_dispose_engine):
from grimoire.models import async_session
async with async_session() as session:
uid = (await ensure_user(session, "snippet_notes_itest")).id
await session.commit()
return uid
async def _fields(uid, note_id):
return s.snippet_fields(await s.get_snippet(uid, note_id))
@pytest.mark.integration
async def test_an_edit_to_another_field_keeps_the_notes(user_id):
note = await s.create_snippet(
user_id, name="notes_keep", code=CODE, language="python",
when_to_use="a helper", notes=NOTES,
)
assert (await _fields(user_id, note.id))["notes"] == NOTES
await s.update_snippet(user_id, note.id, when_to_use="a sharper situation")
got = await _fields(user_id, note.id)
assert got["notes"] == NOTES
assert got["when_to_use"] == "a sharper situation"
# And an empty string clears them, like every other field.
await s.update_snippet(user_id, note.id, notes="")
assert (await _fields(user_id, note.id))["notes"] == ""
@pytest.mark.integration
async def test_a_merge_keeps_the_survivors_notes(user_id):
survivor = await s.create_snippet(
user_id, name="notes_merge_a", code=CODE, language="python",
repo="R", path="a.py", symbol="helper", notes=NOTES,
)
source = await s.create_snippet(
user_id, name="notes_merge_b", code=CODE + " # variant", language="python",
repo="R", path="b.py", symbol="helper",
)
await s.merge_snippets(user_id, survivor.id, [source.id])
assert (await _fields(user_id, survivor.id))["notes"] == NOTES
await s.unmerge_snippet(user_id, survivor.id, source.id)
assert (await _fields(user_id, survivor.id))["notes"] == NOTES