Files
FabledScribe/tests/test_inception.py
T
bvandeusenandClaude Opus 5.5 07d2542479
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
feat(family): platforms declared at inception and detected from bound repos (milestone 463 step 2, #4988)
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>
2026-10-06 09:51:28 -04:00

92 lines
4.4 KiB
Python

"""Project inception (milestone 297) — step 1: the record's shape.
The WHY a project inherits what it does lives on projects.inception. Pure
validation is pinned here; the effects are step 3's integration tests.
Rules left inception in two steps: the always-on opt-out with the tier
(milestone 394), and the rulebook subscription with subscriptions (milestone
414). A rule in a rulebook is global, so there is nothing to choose.
"""
from scribe.models.project import Project
from scribe.services.inception import (
CHOICE_KEYS, INCEPTION_VIAS, is_decided, normalize_choices, validate_inception,
)
def test_project_carries_an_inception_record_and_to_dict_shows_it():
assert "inception" in Project.__table__.c
assert Project.__table__.c.inception.nullable # NULL = undecided
p = Project(user_id=1, title="x", inception=None)
assert p.to_dict()["inception"] is None and not is_decided(p)
p.inception = {"via": "mcp", "decided_at": "2026-08-22T00:00:00+00:00", "decided_by": 1,
"choices": normalize_choices({"seed_systems": True})}
assert is_decided(p) and p.to_dict()["inception"]["via"] == "mcp"
assert INCEPTION_VIAS == ("mcp", "ui", "legacy")
def test_validate_inception_pins_the_choice_vocabulary():
assert CHOICE_KEYS == ("design_system_id", "seed_systems", "platforms")
assert validate_inception({}) is None
assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None
assert validate_inception({"design_system_id": None}) is None
assert "must be an object" in validate_inception([])
assert "unknown inception choice" in validate_inception({"repo": "x"})
# The retired exclusion key is now an UNKNOWN key rather than a typed one,
# which is the right error: a caller still passing it is asking for a
# choice that no longer exists, and silently ignoring it would let them
# believe a rulebook had been declined.
assert "unknown inception choice" in validate_inception(
{"exclude_always_on_rulebooks": [1]})
# Same for the subscription choice since milestone 414.
assert "unknown inception choice" in validate_inception({"subscribe_rulebooks": [2]})
assert "positive id or null" in validate_inception({"design_system_id": 0})
assert "positive id or null" in validate_inception({"design_system_id": True})
assert "true or false" in validate_inception({"seed_systems": "yes"})
def test_validate_inception_takes_platforms_as_slugs_or_unstated():
"""Slugs, not ids: an inception record outlives a restore, and a slug is
what both sides of a restore agree on. None is "not answered", which is
different from [] — "none of them"."""
assert validate_inception({"platforms": ["android-app", "go"]}) is None
assert validate_inception({"platforms": []}) is None
assert validate_inception({"platforms": None}) is None
assert "platform slugs" in validate_inception({"platforms": "android-app"})
assert "platform slugs" in validate_inception({"platforms": [3]})
assert "platform slugs" in validate_inception({"platforms": [" "]})
def test_normalize_choices_is_canonical_and_complete():
out = normalize_choices({"design_system_id": 4})
assert out == {"design_system_id": 4, "seed_systems": False, "platforms": None}
assert normalize_choices(None) == {
"design_system_id": None, "seed_systems": False, "platforms": None,
}
# Sorted and de-duplicated, so two equal answers record identically.
assert normalize_choices({"platforms": ["go", " android-app", "go"]})["platforms"] == [
"android-app", "go",
]
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
"""The vocabulary moved from a module constant to the global catalog table
(milestone 307): a constant cannot be a foreign key, so nothing outside a
project could reference an area. The seed and the bootstrap ask must both
read the table, or the list they show and the list they mint diverge."""
import inspect
from scribe.services import systems as systems_svc
assert not hasattr(systems_svc, "STANDARD_SYSTEMS"), (
"the constant is gone — the catalog table is the single source"
)
source = inspect.getsource(systems_svc.seed_standard_systems)
assert "list_canonical_systems" in source
assert "canonical_id=entry.id" in source, (
"a seeded System must be mapped as it is created, or a project born "
"from the standard set still needs a reconciliation pass"
)