CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 15s
Docstrings, section headers and assertion messages drop the record number, milestone or step that introduced the behaviour, and incident accounts are restated as the present-tense failure shape. A dead pointer to a design doc that is not in the repo is removed. Renamed to the property they check: test_the_old_shape_would_be_caught -> test_a_rank_then_scope_query_is_caught test_a_ledger_repeat_no_longer_produces_that_case_at_all -> test_a_ledger_repeat_is_counted_not_withheld test_the_header_no_longer_promises_once_per_session -> test_the_header_explains_the_seen_marker test_the_portal_case_no_longer_stamps_one_canon_onto_a_whole_file -> test_one_canon_is_not_stamped_onto_a_whole_file test_the_repos_own_prose_no_longer_defines_with -> test_the_repos_own_prose_does_not_define_with test_the_original_regression_create_note_with_content -> test_the_real_server_refuses_content_on_create_note Fixture and example ids stay: "note 5" in a supersession chain, "rule 156" in an illustrative question, `#2275` as the hex-versus-citation case, `#3244` as the example of a bare number, fixed dates in date-filter tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
5.5 KiB
Python
151 lines
5.5 KiB
Python
"""The moment catalog — the vocabulary rules mount on.
|
|
|
|
What this pins is what a mount depends on: every name is well-formed and
|
|
resolvable, an unknown name is refused rather than stored, the catalog speaks
|
|
for any kind of work rather than only software, and both doors — the MCP tool
|
|
and the REST read — hand out the same catalog.
|
|
"""
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from scribe.services import moments
|
|
from tests.helpers import FakeMCP, dev_only_hits
|
|
|
|
_NAME = re.compile(r"^[a-z]+\.[a-z]+$")
|
|
|
|
|
|
def test_the_catalog_is_not_empty():
|
|
"""The sweeps below are vacuous over an empty catalog."""
|
|
assert len(moments.MOMENTS) >= 10
|
|
|
|
|
|
def test_every_moment_is_keyed_by_its_own_well_formed_name():
|
|
for key, m in moments.MOMENTS.items():
|
|
assert key == m.name, f"{key!r} is filed under a name it does not carry"
|
|
assert _NAME.match(key), f"{key!r} is not <area>.<verb>"
|
|
|
|
|
|
def test_every_moment_says_what_it_means_and_how_it_is_reached():
|
|
for m in moments.MOMENTS.values():
|
|
assert m.means.strip() and m.reached_by.strip(), m.name
|
|
assert "\n" not in m.means, f"{m.name}: `means` is one line"
|
|
|
|
|
|
def test_the_skill_family_is_not_a_catalog_entry():
|
|
"""A family is a shape, not a moment — no rule mounts on `skill.<name>`
|
|
literally, and a catalog key starting with the prefix would shadow it."""
|
|
assert not any(k.startswith(moments.SKILL_PREFIX) for k in moments.MOMENTS)
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["means", "reached_by"])
|
|
def test_the_catalog_assumes_no_particular_domain(field):
|
|
found = {
|
|
m.name: hits
|
|
for m in [*moments.MOMENTS.values(), moments.SKILL_FAMILY]
|
|
if (hits := dev_only_hits(getattr(m, field)))
|
|
}
|
|
assert not found, f"software-only vocabulary in `{field}`: {found}"
|
|
|
|
|
|
def test_the_guard_can_fail():
|
|
assert dev_only_hits("after the commit lands")
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(moments.MOMENTS))
|
|
def test_every_catalog_moment_is_mountable(name):
|
|
assert moments.require_moment(name) == name
|
|
|
|
|
|
@pytest.mark.parametrize("raw,clean", [
|
|
(" Work.Finish ", "work.finish"),
|
|
("skill.verification", "skill.verification"),
|
|
("skill.scribe:writing-plans", "skill.scribe:writing-plans"),
|
|
("skill.scribe-proc-release_notes", "skill.scribe-proc-release_notes"),
|
|
])
|
|
def test_a_name_is_normalised_before_it_is_judged(raw, clean):
|
|
assert moments.require_moment(raw) == clean
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [
|
|
"work.finished", "finish", "", "skill.", "skill.two words", "work.",
|
|
])
|
|
def test_an_unknown_moment_is_refused_with_the_catalog(bad):
|
|
"""A typo stored as a mount never fires — the silent failure moments end."""
|
|
with pytest.raises(ValueError) as exc:
|
|
moments.require_moment(bad)
|
|
assert "work.finish" in str(exc.value)
|
|
assert moments.SKILL_FAMILY.name in str(exc.value)
|
|
|
|
|
|
def test_the_catalog_payload_carries_every_moment_and_the_family():
|
|
data = moments.catalog()
|
|
assert [m["name"] for m in data["moments"]] == list(moments.MOMENTS)
|
|
assert data["total"] == len(moments.MOMENTS)
|
|
assert data["families"][0]["prefix"] == moments.SKILL_PREFIX
|
|
|
|
|
|
async def test_the_tool_returns_the_catalog_with_this_installs_actions():
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from scribe.mcp.tools import moments as tool
|
|
|
|
by_moment = {"actions": {"work.change": []}, "removed_defaults": []}
|
|
with patch.object(tool, "current_user_id", lambda: 7), \
|
|
patch.object(tool.actions_svc, "actions_by_moment",
|
|
AsyncMock(return_value=by_moment)) as read:
|
|
out = await tool.list_moments()
|
|
read.assert_awaited_once_with(7)
|
|
assert out["moments"] == moments.catalog()["moments"]
|
|
assert out["actions"] == by_moment["actions"]
|
|
assert out["removed_defaults"] == []
|
|
|
|
|
|
def test_the_tools_are_registered_and_classified():
|
|
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
|
|
from scribe.mcp.tools import moments as tool
|
|
|
|
mcp = FakeMCP()
|
|
tool.register(mcp)
|
|
assert mcp.names == [
|
|
"list_moments", "list_reply_shapes", "map_action", "unmap_action",
|
|
"rules_to_mount", "propose_rule_moments", "rule_moment_proposals",
|
|
"judge_rule_moments", "rule_misfired",
|
|
]
|
|
assert {"list_moments", "list_reply_shapes",
|
|
"rules_to_mount", "rule_moment_proposals"} <= _READ_ONLY_TOOLS
|
|
# A confirm mounts a rule, so a read key must not reach it.
|
|
assert {"map_action", "unmap_action",
|
|
"propose_rule_moments", "judge_rule_moments", "rule_misfired"} <= _WRITE_TOOLS
|
|
|
|
|
|
def test_both_doors_read_one_catalog():
|
|
"""Door parity: the tool and the routes call the same services, so the
|
|
session and the Settings view cannot name different moments or disagree
|
|
about what a mapping does."""
|
|
from scribe.mcp.tools import moments as tool
|
|
from scribe.routes import retrieval as routes
|
|
from scribe.services import moment_actions
|
|
|
|
assert tool.moments_svc is moments
|
|
assert routes.moments_svc is moments
|
|
assert tool.actions_svc is moment_actions
|
|
assert routes.moment_actions_svc is moment_actions
|
|
|
|
|
|
def test_a_rules_moments_are_normalised_and_deduplicated():
|
|
assert moments.require_moments([" Work.Finish", "reply.report", "work.finish"]) == [
|
|
"work.finish", "reply.report",
|
|
]
|
|
assert moments.require_moments("work.finish") == ["work.finish"]
|
|
assert moments.require_moments([]) == []
|
|
|
|
|
|
def test_absent_moments_mean_leave_them_alone():
|
|
assert moments.require_moments(None) is None
|
|
|
|
|
|
def test_one_unknown_moment_refuses_the_whole_set():
|
|
with pytest.raises(ValueError, match="work.finished"):
|
|
moments.require_moments(["work.finish", "work.finished"])
|