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

105 lines
4.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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])