CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Failing after 15s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m15s
CI & Build / Python tests (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 30s
Ledger rows are keyed by path, so after a directory moves the next sync marks every judged row vanished and lists the same shapes again, unjudged. move_shapes(project_id, from_path, to_path) re-keys the rows under one path to the other, in every repo of the project, keeping each row's judgment, uses edges and history and adding a `moved` event that records the path it left. A row already at the target is replaced when nobody judged it; a judged one stands, and the old row is reported in `kept`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
4.8 KiB
Python
127 lines
4.8 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_empty_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")
|
|
with pytest.raises(ValueError, match="no shapes under"):
|
|
await move_shapes(owner, pid, "src/gone", "src/new")
|
|
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)
|