* refactor(scripts): add project_contract.py single source of truth * fix(spec-status): word-boundary regex for stack matching (uv!=uvicorn) * feat(project-status): frontend stack detection + no_db support * fix(template): cookiecutter use_db=no writes no_db=true marker * fix(ci): ruff SIM300/PLC0415/W292/E501 in project_contract tests --------- Co-authored-by: opencode-agent <agent@opencode.local>
140 lines
5.1 KiB
Python
140 lines
5.1 KiB
Python
"""Tests for ``.opencode/scripts/project_contract.py`` — single source of
|
||
truth for project types, stacks, structure, frontend markers, no_db.
|
||
|
||
The contract module is imported via ``importlib.util`` (no ``sys.path``
|
||
mutation), mirroring how ``spec-status.py`` and ``project-status.py`` load it.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
SCRIPT_PATH = (
|
||
Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "project_contract.py"
|
||
)
|
||
_spec = importlib.util.spec_from_file_location("project_contract", SCRIPT_PATH)
|
||
pc = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
|
||
_spec.loader.exec_module(pc) # type: ignore[union-attr]
|
||
|
||
|
||
# ── ProjectType enum ────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_project_type_enum_values():
|
||
"""ProjectType has exactly 7 members including MCP_SERVER and UNKNOWN."""
|
||
members = {t.name for t in pc.ProjectType}
|
||
assert members == {
|
||
"FULLSTACK",
|
||
"BACKEND",
|
||
"CLI",
|
||
"BOT",
|
||
"WORKER",
|
||
"MCP_SERVER",
|
||
"UNKNOWN",
|
||
}, f"unexpected members: {members}"
|
||
# Values are lowercase strings.
|
||
values = {t.value for t in pc.ProjectType}
|
||
assert values == {
|
||
"fullstack",
|
||
"backend",
|
||
"cli",
|
||
"bot",
|
||
"worker",
|
||
"mcp-server",
|
||
"unknown",
|
||
}
|
||
|
||
|
||
def test_valid_types_excludes_unknown():
|
||
"""VALID_TYPES excludes ``"unknown"`` (used by spec-status PROJECT_TYPE)."""
|
||
assert "unknown" not in pc.VALID_TYPES
|
||
assert "mcp-server" in pc.VALID_TYPES
|
||
assert {
|
||
"fullstack",
|
||
"backend",
|
||
"cli",
|
||
"bot",
|
||
"worker",
|
||
"mcp-server",
|
||
} == pc.VALID_TYPES
|
||
|
||
|
||
def test_stack_required_keys_match_valid_types():
|
||
"""STACK_REQUIRED keys == VALID_TYPES (all 6 types including mcp-server)."""
|
||
assert set(pc.STACK_REQUIRED.keys()) == pc.VALID_TYPES
|
||
|
||
|
||
def test_stack_required_uv_in_all_types():
|
||
"""``uv`` stays in ALL stacks (build-tool, mentioned in stack.md)."""
|
||
for ptype, required in pc.STACK_REQUIRED.items():
|
||
assert "uv" in required, f"missing 'uv' in {ptype} stack: {required}"
|
||
|
||
|
||
def test_structure_expected_keys_subset():
|
||
"""STRUCTURE_EXPECTED keys ⊆ VALID_TYPES ∪ {"unknown"}.
|
||
|
||
BACKEND is resolved dynamically (not in dict); mcp-server TBD out of scope.
|
||
"""
|
||
keys = set(pc.STRUCTURE_EXPECTED.keys())
|
||
valid_plus_unknown = pc.VALID_TYPES | {"unknown"}
|
||
assert keys.issubset(valid_plus_unknown), f"unexpected keys: {keys - valid_plus_unknown}"
|
||
# Backend NOT in dict (resolved dynamically via _expected_backend_paths)
|
||
assert "backend" not in keys
|
||
# mcp-server NOT in dict (out of scope)
|
||
assert "mcp-server" not in keys
|
||
|
||
|
||
def test_no_db_supported_subset():
|
||
"""NO_DB_SUPPORTED ⊆ VALID_TYPES (backend + fullstack only)."""
|
||
assert pc.NO_DB_SUPPORTED.issubset(pc.VALID_TYPES)
|
||
assert {"backend", "fullstack"} == pc.NO_DB_SUPPORTED
|
||
|
||
|
||
def test_frontend_stack_markers_keys():
|
||
"""FRONTEND_STACK_MARKERS has the expected keys with correct marker lists."""
|
||
assert set(pc.FRONTEND_STACK_MARKERS.keys()) == {
|
||
"fullstack_package_deps",
|
||
"fullstack_files",
|
||
}
|
||
assert pc.FRONTEND_STACK_MARKERS["fullstack_package_deps"] == [
|
||
"tailwindcss",
|
||
"bits-ui",
|
||
]
|
||
assert pc.FRONTEND_STACK_MARKERS["fullstack_files"] == [
|
||
"frontend/components.json",
|
||
"frontend/tsconfig.json",
|
||
]
|
||
|
||
|
||
# ── re-export sanity (backward compat) ─────────────────────────────────────
|
||
|
||
|
||
def test_spec_status_reexports_match_contract():
|
||
"""``spec-status.py`` re-exports VALID_TYPES / STACK_REQUIRED from contract."""
|
||
spec_status_path = (
|
||
Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "spec-status.py"
|
||
)
|
||
ss_spec = importlib.util.spec_from_file_location("spec_status", spec_status_path)
|
||
ss = importlib.util.module_from_spec(ss_spec) # type: ignore[arg-type]
|
||
sys.modules["spec_status"] = ss
|
||
ss_spec.loader.exec_module(ss) # type: ignore[union-attr]
|
||
assert ss.VALID_TYPES is pc.VALID_TYPES or ss.VALID_TYPES == pc.VALID_TYPES
|
||
assert ss.STACK_REQUIRED == pc.STACK_REQUIRED
|
||
|
||
|
||
def test_project_status_reexports_match_contract():
|
||
"""``project-status.py`` re-exports ProjectType / STRUCTURE_EXPECTED."""
|
||
project_status_path = (
|
||
Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "project-status.py"
|
||
)
|
||
ps_spec = importlib.util.spec_from_file_location("project_status", project_status_path)
|
||
ps = importlib.util.module_from_spec(ps_spec) # type: ignore[arg-type]
|
||
sys.modules["project_status"] = ps
|
||
ps_spec.loader.exec_module(ps) # type: ignore[union-attr]
|
||
# Note: ProjectType is loaded twice (once here, once inside project-status
|
||
# via importlib.util with a different module name) so identity differs,
|
||
# but the enum members compare equal by value.
|
||
assert list(ps.ProjectType) == list(pc.ProjectType)
|
||
assert ps.STRUCTURE_EXPECTED == pc.STRUCTURE_EXPECTED
|