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
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>
105 lines
4.6 KiB
Python
105 lines
4.6 KiB
Python
"""Moment proposals and judgments — what a mock can see.
|
||
|
||
The behaviour against real tables is tests/test_integration_rule_moment_judgments.py.
|
||
These pin the pieces that need no database: that the migration's CHECKs and
|
||
the model agree, which moments count as evidence, and that the line
|
||
the signal hands a session names the tool that answers it.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
from grimoire.models.rule_moment_judgment import JUDGMENT_STATES, NO_MOMENT, SOURCES
|
||
from grimoire.services import moments as moments_svc
|
||
from grimoire.services import rule_moment_judgments as svc
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
|
||
|
||
def _migration():
|
||
path = ROOT / "alembic" / "versions" / "0118_rule_moment_judgments.py"
|
||
spec = importlib.util.spec_from_file_location("m0118", 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._STATES) == tuple(JUDGMENT_STATES)
|
||
assert tuple(m._SOURCES) == tuple(SOURCES)
|
||
|
||
|
||
def test_verdicts_name_real_states():
|
||
assert set(svc.VERDICTS.values()) <= set(JUDGMENT_STATES)
|
||
|
||
|
||
def test_no_moment_is_not_a_mountable_name():
|
||
""""" is the "none fits" answer; if it ever became mountable, a pass's
|
||
"no moment" would read as a mount on nothing."""
|
||
assert not moments_svc.is_moment(NO_MOMENT)
|
||
|
||
|
||
def test_the_ubiquitous_moments_are_not_evidence():
|
||
assert svc.signal_moments(["work.run", "work.verify", "work.change", "work.verify",
|
||
"not.a.moment", "skill.brainstorming"]) == [
|
||
"work.verify", "skill.brainstorming",
|
||
]
|
||
# Every skipped name is a real moment — a typo here would skip nothing.
|
||
assert all(moments_svc.is_moment(m) for m in svc.SIGNAL_SKIP)
|
||
|
||
|
||
def test_the_proposal_line_names_the_tool_that_answers_it():
|
||
rule = SimpleNamespace(id=11, title="Definition of done", kind="rule")
|
||
line = svc._proposal_line(rule, "work.verify", {"situations": ["a", "b", "c"]})
|
||
assert line.startswith("> Rule #11")
|
||
assert "`work.verify`" in line and "3 distinct sessions" in line
|
||
assert '"rule_id": 11' in line and '"moment": "work.verify"' in line
|
||
assert "judge_rule_moments" in line and '"reject"' in line
|
||
|
||
|
||
# ── The misfire ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_a_day_is_one_situation_and_days_are_distinct():
|
||
"""The day arm keys verbatim: tokenised, "10" and "05" fall under the
|
||
minimum token length and every day of a year would be one situation."""
|
||
a = datetime(2026, 10, 5, 1, tzinfo=timezone.utc)
|
||
assert svc.misfire_situation(a) == svc.misfire_situation(a + timedelta(hours=20))
|
||
assert svc.misfire_situation(a) != svc.misfire_situation(a + timedelta(days=1))
|
||
assert svc.misfire_situation(a) != svc.misfire_situation(a + timedelta(days=31))
|
||
|
||
|
||
def test_a_misfire_counts_reasons_and_actions():
|
||
now = datetime(2026, 10, 5, tzinfo=timezone.utc)
|
||
mf = None
|
||
for i in range(svc.MISFIRE_REASONS + 2):
|
||
mf = svc.add_misfire(mf, svc.misfire_situation(now + timedelta(days=i)), 7, now,
|
||
why=f"reason {i}", reached_by="git push" if i % 2 else "make ship")
|
||
assert len(mf["situations"]) == svc.MISFIRE_REASONS + 2
|
||
assert [r["why"] for r in mf["reasons"]][-1] == f"reason {svc.MISFIRE_REASONS + 1}"
|
||
assert len(mf["reasons"]) == svc.MISFIRE_REASONS
|
||
assert mf["reached_by"] == {"make ship": 4, "git push": 3}
|
||
assert mf["projects"] == [7]
|
||
|
||
|
||
def test_the_unmount_line_offers_both_fixes():
|
||
rule = SimpleNamespace(id=10, title="CI verifies", kind="rule")
|
||
mf = {"situations": ["a", "b", "c"], "reached_by": {"actions_run_read": 3}}
|
||
line = svc._unmount_line(rule, "work.verify", mf)
|
||
assert line.startswith("> Rule #10") and "3 distinct days" in line
|
||
assert "`actions_run_read` ×3" in line
|
||
assert '"verdict": "reject"' in line and "unmap_action" in line and '"confirm"' in line
|
||
|
||
|
||
def test_a_held_reply_names_the_misfire_call_only_for_a_mount():
|
||
from grimoire.services.moment_delivery import reply_hold_reason
|
||
|
||
mounted = {"rule_id": 11, "title": "Definition of done", "moment": "reply.report", "trigger": ""}
|
||
scored = {"rule_id": 12, "title": "Other", "moment": "", "score": 0.81, "trigger": ""}
|
||
assert 'rule_misfired(11, "reply.report", why)' in reply_hold_reason([mounted])
|
||
assert "rule_misfired" not in reply_hold_reason([scored])
|