Files
FabledGrimoire/tests/test_integration_shape_move.py
bvandeusenandClaude Opus 5.5 979efe6662
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 31s
feat(6243): move_path carries a moved path's snippets with its shapes
Snippet locations are keyed by path too, so a snippet recorded under a
moved directory reads `missing` at its next check. The tool is now
move_path(project_id, from_path, to_path). It moves the ledger rows as
before, and re-paths the project's snippet locations under from_path
through update_snippet, so each body and its indexed mirror stay one
record. A snippet the caller can only read is listed in
snippets_read_only. The tool refuses only when nothing at all is recorded
under from_path. The path test is the one snippets already uses
(_path_touches), and the plugin version is minted for the skill edit.

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

150 lines
5.7 KiB
Python

"""Real-Postgres integration tests for moving a directory's shape judgments.
Ledger rows are keyed by path. A directory moved in the tree has to carry its
rows to the new path, or the next sync vanishes every judgment and lists the
same shapes again, unjudged.
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from grimoire.models import async_session
from grimoire.models.code_shape import CodeShape, CodeShapeEvent
from grimoire.models.project import Project
from grimoire.services.shape_ledger import classify_shapes, move_shapes, sync_repo_shapes
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
REPO = "git.example.com/alice/widget"
OLD_TREE = [
("src/old/app.py", "sym", "make_app"),
("src/old/sub/util.py", "sym", "helper"),
("src/older/keep.py", "sym", "untouched"),
]
NEW_TREE = [
("src/new/app.py", "sym", "make_app"),
("src/new/sub/util.py", "sym", "helper"),
("src/older/keep.py", "sym", "untouched"),
]
@pytest_asyncio.fixture
async def seeded():
"""A project whose ledger was synced before the move, one shape judged."""
async with async_session() as s:
owner = await ensure_user(s, "shape_move_owner")
other = await ensure_user(s, "shape_move_other")
project = Project(user_id=owner.id, title="Moved directory")
s.add(project)
await s.flush()
ids = {"owner": owner.id, "other": other.id, "pid": project.id}
await s.commit()
await sync_repo_shapes(ids["pid"], REPO, OLD_TREE, seen_marker="c1")
await classify_shapes(ids["owner"], ids["pid"], [
{"path": "src/old/sub/util.py", "symbol": "helper", "status": "exempt",
"reason": "one caller"},
])
return ids
async def _ledger(pid: int) -> dict[tuple[str, str], tuple[str, bool]]:
async with async_session() as s:
rows = await s.execute(select(CodeShape).where(CodeShape.project_id == pid))
return {
(r.path, r.symbol): (r.status, r.vanished_at is None)
for r in rows.scalars()
}
@pytest.mark.integration
async def test_the_judgments_move_and_the_next_sync_finds_them(seeded):
pid = seeded["pid"]
result = await move_shapes(seeded["owner"], pid, "src/old/", "src/new")
assert result == {"moved": 2, "replaced": 0, "kept": []}
await sync_repo_shapes(pid, REPO, NEW_TREE, seen_marker="c2")
assert await _ledger(pid) == {
("src/new/app.py", "make_app"): ("unclassified", True),
("src/new/sub/util.py", "helper"): ("exempt", True),
("src/older/keep.py", "untouched"): ("unclassified", True),
}
async with async_session() as s:
moved = (await s.execute(
select(CodeShapeEvent.path).where(
CodeShapeEvent.project_id == pid, CodeShapeEvent.event == "moved",
)
)).scalars().all()
assert sorted(moved) == ["src/old/app.py", "src/old/sub/util.py"]
@pytest.mark.integration
async def test_after_the_sync_got_there_first_the_judgment_replaces_the_fresh_row(seeded):
pid = seeded["pid"]
await sync_repo_shapes(pid, REPO, NEW_TREE, seen_marker="c2")
result = await move_shapes(seeded["owner"], pid, "src/old", "src/new")
assert result == {"moved": 2, "replaced": 2, "kept": []}
assert await _ledger(pid) == {
("src/new/app.py", "make_app"): ("unclassified", True),
("src/new/sub/util.py", "helper"): ("exempt", True),
("src/older/keep.py", "untouched"): ("unclassified", True),
}
@pytest.mark.integration
async def test_a_judgment_already_at_the_new_path_stands(seeded):
pid = seeded["pid"]
await sync_repo_shapes(pid, REPO, OLD_TREE + NEW_TREE, seen_marker="c2")
await classify_shapes(seeded["owner"], pid, [
{"path": "src/new/sub/util.py", "symbol": "helper", "status": "exempt",
"reason": "judged again after the move"},
])
result = await move_shapes(seeded["owner"], pid, "src/old", "src/new")
assert result["moved"] == 1 and result["replaced"] == 1
assert result["kept"] == [
{"path": "src/old/sub/util.py", "symbol": "helper", "kind": "sym"},
]
ledger = await _ledger(pid)
assert ledger[("src/new/sub/util.py", "helper")] == ("exempt", True)
assert ("src/old/sub/util.py", "helper") in ledger
@pytest.mark.integration
async def test_overlapping_and_unwritable_moves_are_refused(seeded):
owner, other, pid = seeded["owner"], seeded["other"], seeded["pid"]
with pytest.raises(ValueError, match="overlap"):
await move_shapes(owner, pid, "src/old", "src/old/inner")
assert await move_shapes(owner, pid, "src/gone", "src/new") == {
"moved": 0, "replaced": 0, "kept": [],
}
with pytest.raises(ValueError, match="no write access"):
await move_shapes(other, pid, "src/old", "src/new")
assert ("src/old/app.py", "make_app") in await _ledger(pid)
@pytest.mark.integration
async def test_the_snippets_recorded_there_move_with_it(seeded):
from grimoire.services import snippets as snippets_svc
owner, pid = seeded["owner"], seeded["pid"]
note = await snippets_svc.create_snippet(
owner, name="move_helper", code="def helper():\n pass\n",
language="python", project_id=pid, locations=[
{"repo": "Widget", "path": "src/old/sub/util.py", "symbol": "helper"},
{"repo": "Widget", "path": "src/older/keep.py", "symbol": "untouched"},
],
)
result = await snippets_svc.move_locations(owner, pid, "src/old", "src/new")
assert result == {"moved": [int(note.id)], "read_only": []}
moved = await snippets_svc.get_snippet(owner, int(note.id))
assert [loc["path"] for loc in snippets_svc.snippet_fields(moved)["locations"]] == [
"src/new/sub/util.py", "src/older/keep.py",
]