Files
FabledGrimoire/tests/test_family_models.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

113 lines
4.7 KiB
Python

"""Family canon's schema — the parts that need no database.
The constraints themselves, against real Postgres, are in
tests/test_integration_family_canon.py. These pin that the migration and the
model agree on every whitelist (a value one side accepts and the
other refuses only fails at INSERT time, which CI's unit lane never reaches),
and that the seed ships nothing a stranger's install would find foreign.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from grimoire.models import family
ROOT = Path(__file__).resolve().parents[1]
def _migration():
path = ROOT / "alembic" / "versions" / "0120_family_canon.py"
spec = importlib.util.spec_from_file_location("m0120", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_the_migration_checks_and_the_model_agree():
m = _migration()
assert tuple(m._MEMBERSHIP) == family.MEMBERSHIP_STATES
assert tuple(m._IDEA_STATUSES) == family.IDEA_STATUSES
assert tuple(m._ADOPTION_STATUSES) == family.ADOPTION_STATUSES
assert tuple(m._REASONED) == family.REASONED_STATUSES
assert tuple(m._ACTIONS) == family.DECISION_ACTIONS
assert tuple(m._DECIDERS) == family.DECIDERS
def test_the_departures_that_need_a_reason_are_adoption_states():
assert set(family.REASONED_STATUSES) <= set(family.ADOPTION_STATUSES)
def test_the_seed_is_generic_and_unique():
seed = _migration()._SEED
slugs = [slug for _, slug, _, _ in seed]
assert len(slugs) == len(set(slugs)), "a duplicate slug breaks the live-unique index"
for name, slug, description, markers in seed:
assert name and slug and description
assert slug == slug.lower() and " " not in slug
# A list, possibly empty — never None. Detection reads it directly.
assert isinstance(markers, list)
def test_the_column_defaults_match_the_first_state_of_each_lifecycle():
"""A row created with no state given starts where its lifecycle starts.
An idea is a candidate until promoted; an answer is unassessed until
someone assesses it — never silently `adopted`."""
assert family.FamilyIdea.__table__.c.status.server_default.arg == "candidate"
assert family.FamilyAdoption.__table__.c.status.server_default.arg == "unassessed"
assert family.ProjectPlatform.__table__.c.state.server_default.arg == "declared"
def test_a_canon_ideas_scope_lives_on_the_idea_and_nowhere_else():
"""One scope source. A rule topic is linked FROM the idea and carries no
platforms of its own, so the two cannot disagree about who a standard
reaches — two sources of truth."""
from grimoire.models.rulebook import RulebookTopic
assert "topic_id" in family.FamilyIdea.__table__.c
assert not {c.name for c in RulebookTopic.__table__.c} & {"platform_id", "platform_ids"}
# --- a decision read as precedent ------------------------------------------------
def _assessment():
reason = "the APK is built in CI and signed with the one release key"
return family.FamilyDecision(
id=7, idea_id=3, project_id=5, action="assess", reason=reason,
before={"status": "unassessed", "reason": "", "canon_version": None},
after={"status": "adopted", "reason": reason, "canon_version": 2},
evidence={"evidence": ["ci.yml::android-release"], "source": "shapes"},
precedent_ids=[4, 6], decided_via="agent", user_id=1,
)
def test_a_brief_carries_the_reason_once():
brief = _assessment().to_brief()
assert brief["reason"].startswith("the APK is built")
assert brief["before"] == {"status": "unassessed", "canon_version": None}
assert brief["after"] == {"status": "adopted", "canon_version": 2}
# Nothing in it says the reason a second time.
assert repr(brief).count("the APK is built") == 1
def test_a_brief_keeps_the_cited_evidence_and_drops_the_rest():
promotion = family.FamilyDecision(
id=8, idea_id=3, project_id=None, action="promote", reason="proven twice",
before=None,
after={"status": "canon", "applies_when": "an app that ships its own APK",
"canon_version": 1, "platforms": ["android-app"]},
evidence={"criteria": {"proven": "a long paragraph"}, "evidence": ["#12"],
"ledger_rows_opened": 3},
)
brief = promotion.to_brief()
assert brief["evidence"] == ["#12"]
assert brief["before"] is None
assert brief["after"] == {"status": "canon", "canon_version": 1,
"platforms": ["android-app"]}
def test_the_full_row_is_still_the_audit_shape():
full = _assessment().to_dict()
assert full["after"]["reason"] == full["reason"]
assert full["evidence"]["source"] == "shapes" and full["precedent_ids"] == [4, 6]