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
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>
150 lines
5.7 KiB
Python
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",
|
|
]
|