feat(family): platforms declared at inception and detected from bound repos (milestone 463 step 2, #4988)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s
A project's platforms decide which family ideas reach it. This step makes membership answerable from every door: - services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one. - coverage: the archive scan now carries every path, and the refresh runs detection fail-open. - inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected. - MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms. - REST: /api/platforms (admin writes) and /api/projects/<id>/platforms. - UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""Real-Postgres checks for platform membership (milestone 463 step 2).
|
||||
|
||||
The one invariant: detection only ever ADDS, and only where nobody has
|
||||
answered. A refresh that overwrote a "no" would put a project back into a
|
||||
family it was taken out of; one that overwrote a "yes" would erase who said
|
||||
it. Each is shown here against the real writer, beside the person-facing
|
||||
writes it must defer to.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.family import Platform
|
||||
from scribe.models.project import Project
|
||||
from scribe.services import inception as inception_svc
|
||||
from scribe.services import platforms as platforms_svc
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
OWNER = "platforms_owner"
|
||||
|
||||
|
||||
async def _id(slug: str) -> int:
|
||||
async with async_session() as s:
|
||||
return await s.scalar(
|
||||
select(Platform.id).where(Platform.slug == slug, Platform.deleted_at.is_(None))
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seeded():
|
||||
"""An owner, an outsider and a fresh project. Each test gets its own
|
||||
project, so answers never leak between tests through a shared row."""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, OWNER)
|
||||
outsider = await ensure_user(s, "platforms_outsider")
|
||||
project = Project(user_id=owner.id, title="an app with a server")
|
||||
s.add(project)
|
||||
await s.flush()
|
||||
ids = {"owner": owner.id, "outsider": outsider.id, "pid": project.id}
|
||||
await s.commit()
|
||||
return ids
|
||||
|
||||
|
||||
def _states(rows: list[dict]) -> dict[str, str]:
|
||||
return {r["slug"]: r["state"] for r in rows}
|
||||
|
||||
|
||||
async def test_detection_adds_only_where_nobody_has_answered(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
await platforms_svc.set_project_platforms(owner, pid, {
|
||||
"go": "declared", "rust": "rejected",
|
||||
})
|
||||
added = await platforms_svc.detect_for_project(pid, [
|
||||
"server/go.mod", "core/Cargo.toml", "app/src/main/AndroidManifest.xml",
|
||||
])
|
||||
# Only android was unanswered; go and rust keep the person's answers.
|
||||
assert added == [await _id("android-app")]
|
||||
states = _states(await platforms_svc.project_platforms(owner, pid))
|
||||
assert states == {"go": "declared", "rust": "rejected", "android-app": "detected"}
|
||||
|
||||
|
||||
async def test_detection_never_removes_a_platform_whose_marker_is_gone(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
await platforms_svc.detect_for_project(pid, ["server/go.mod"])
|
||||
await platforms_svc.detect_for_project(pid, ["README.md"])
|
||||
assert _states(await platforms_svc.project_platforms(owner, pid)) == {"go": "detected"}
|
||||
|
||||
|
||||
async def test_a_withdrawn_answer_lets_detection_decide_again(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
await platforms_svc.set_project_platforms(owner, pid, {"go": "rejected"})
|
||||
assert await platforms_svc.detect_for_project(pid, ["go.mod"]) == []
|
||||
await platforms_svc.set_project_platforms(owner, pid, {"go": None})
|
||||
assert await platforms_svc.detect_for_project(pid, ["go.mod"]) == [await _id("go")]
|
||||
|
||||
|
||||
async def test_a_bad_update_writes_nothing(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
with pytest.raises(ValueError, match="unknown platform"):
|
||||
await platforms_svc.set_project_platforms(owner, pid, {
|
||||
"go": "declared", "not-a-platform": "declared",
|
||||
})
|
||||
assert await platforms_svc.project_platforms(owner, pid) == []
|
||||
with pytest.raises(ValueError, match="no write access"):
|
||||
await platforms_svc.set_project_platforms(seeded["outsider"], pid, {"go": "declared"})
|
||||
assert await platforms_svc.project_platforms(seeded["outsider"], pid) is None
|
||||
|
||||
|
||||
async def test_inception_records_slugs_and_rejects_what_it_leaves_out(seeded):
|
||||
"""The inception list is the WHOLE answer. Detection found Android and
|
||||
Go; the person says the project is Go and Python — so Android becomes a
|
||||
recorded "no", and the next refresh cannot bring it back."""
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
await platforms_svc.detect_for_project(pid, ["app/AndroidManifest.xml", "go.mod"])
|
||||
defaults = await inception_svc.current_defaults(owner, pid)
|
||||
assert {"android-app", "go"} <= {p["slug"] for p in defaults["project_platforms"]}
|
||||
|
||||
out = await inception_svc.decide(owner, pid, via="ui", choices={
|
||||
"seed_systems": False, "platforms": ["python", "go"],
|
||||
})
|
||||
assert _states(out["effects"]["platforms"]) == {
|
||||
"android-app": "rejected", "go": "declared", "python": "declared",
|
||||
}
|
||||
async with async_session() as s:
|
||||
project = await s.get(Project, pid)
|
||||
assert project.inception["choices"]["platforms"] == ["go", "python"]
|
||||
assert await platforms_svc.detect_for_project(pid, ["app/AndroidManifest.xml"]) == []
|
||||
|
||||
|
||||
async def test_an_unknown_inception_platform_applies_nothing(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
with pytest.raises(ValueError, match="unknown platform"):
|
||||
await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||
"seed_systems": True, "platforms": ["go", "cobol-mainframe"],
|
||||
})
|
||||
assert await platforms_svc.project_platforms(owner, pid) == []
|
||||
|
||||
|
||||
async def test_the_catalog_is_admin_written_and_duplicate_gated():
|
||||
async with async_session() as s:
|
||||
admin = await ensure_user(s, "platforms_admin", role="admin")
|
||||
user = await ensure_user(s, OWNER)
|
||||
await s.commit()
|
||||
admin_id, user_id = admin.id, user.id
|
||||
assert await platforms_svc.create_platform(user_id, "Kotlin Multiplatform") is None
|
||||
# "Android App" reduces to the seeded android-app: the existing one comes
|
||||
# back rather than a second spelling of it.
|
||||
dup = await platforms_svc.create_platform(admin_id, "Android App")
|
||||
assert dup["duplicate"] and dup["existing_id"] == await _id("android-app")
|
||||
with pytest.raises(ValueError, match="repo-relative"):
|
||||
await platforms_svc.create_platform(admin_id, "Odd", markers=["/abs"])
|
||||
Reference in New Issue
Block a user