"""The lesson → rule link at its doors (milestone 440 step 1, #4630). The link's own behaviour — states, cascade, ACL — is tested against Postgres in tests/test_integration_lesson_rule_links.py. These pin the wiring a mock CAN see: that a bad rule id stops a lesson create before anything is written, that the doors pass the set through with the semantics their docstrings promise, that the judge tool is registered, that backup skips a link it cannot map, and that the migration and the model agree about the states. """ from __future__ import annotations import importlib.util from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from scribe.mcp._context import _user_id_ctx from scribe.mcp.tools import lessons as lesson_tools from scribe.models.lesson_rule_link import LINK_STATES from scribe.services import backup from scribe.services import lesson_rules as links_svc from scribe.services import lessons as lessons_svc TRIGGER = "a CI run is still in_progress far past its usual time" def _stub_note(**kw): base = dict( id=41, title="t", body="b", tags=[], project_id=None, note_type="lesson", data={}, arose_from_id=None, created_at=None, updated_at=None, ) base.update(kw) return SimpleNamespace(**base) def _create_patches(created): return ( patch.object(lessons_svc, "create_lesson", created), patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()), ) @pytest.mark.asyncio async def test_an_unreadable_rule_stops_the_lesson_before_it_is_written(): """All-or-nothing at the door: the rule ids are validated BEFORE the lesson exists, so a bad id cannot leave a lesson saved and unlinked.""" _user_id_ctx.set(7) created = AsyncMock(return_value=_stub_note()) p1, p2, p3 = _create_patches(created) with p1, p2, p3, patch.object( links_svc, "require_rules", AsyncMock(side_effect=ValueError("rule(s) [9] not found")), ): with pytest.raises(ValueError): await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER, rule_ids=[9]) created.assert_not_awaited() @pytest.mark.asyncio async def test_create_links_the_named_rules_to_the_new_lesson(): _user_id_ctx.set(7) created = AsyncMock(return_value=_stub_note(id=41)) linked = AsyncMock() p1, p2, p3 = _create_patches(created) with p1, p2, p3, \ patch.object(links_svc, "require_rules", AsyncMock(return_value=[5, 6])), \ patch.object(links_svc, "set_lesson_rules", linked): await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER, rule_ids=[5, 6]) linked.assert_awaited_once() assert linked.await_args.args[1:] == (41, [5, 6]) @pytest.mark.asyncio async def test_create_without_rules_writes_no_link(): _user_id_ctx.set(7) linked = AsyncMock() p1, p2, p3 = _create_patches(AsyncMock(return_value=_stub_note())) with p1, p2, p3, patch.object(links_svc, "set_lesson_rules", linked): await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER) linked.assert_not_awaited() @pytest.mark.parametrize("rule_ids, expect_call", [(None, False), ([], True), ([5], True)]) @pytest.mark.asyncio async def test_update_leaves_links_alone_on_none_and_replaces_on_a_list(rule_ids, expect_call): """None is "unchanged"; a list — including [] — is the full new set, which the service turns into confirmations and rejections.""" _user_id_ctx.set(7) linked = AsyncMock() with patch.object(lessons_svc, "update_lesson", AsyncMock(return_value=_stub_note())), \ patch.object(links_svc, "require_rules", AsyncMock(side_effect=lambda uid, ids: list(ids))), \ patch.object(links_svc, "set_lesson_rules", linked): await lesson_tools.update_lesson(lesson_id=41, rule_ids=rule_ids) assert linked.await_count == (1 if expect_call else 0) if expect_call: assert linked.await_args.args[1:] == (41, rule_ids) def test_the_judge_tool_is_registered(): names = [] fake = SimpleNamespace(tool=lambda name: (lambda fn: names.append(name) or fn)) lesson_tools.register(fake) assert "judge_lesson_link" in names def test_verdicts_name_real_states(): assert set(links_svc.VERDICTS.values()) <= set(LINK_STATES) def test_the_migration_check_and_the_model_agree_on_the_states(): """Rule 36's drift, guarded: the CHECK is written from the migration's tuple and the code from the model's, so they must be the same tuple.""" path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0111_lesson_rule_links.py" spec = importlib.util.spec_from_file_location("m0111", path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) assert tuple(module._STATES) == tuple(LINK_STATES) @pytest.mark.parametrize("lesson_mapped, rule_mapped", [(False, True), (True, False)]) def test_backup_skips_a_link_whose_end_did_not_restore(lesson_mapped, rule_mapped): maps = backup._Maps() if lesson_mapped: maps.notes[10] = 110 if rule_mapped: maps.rules[20] = 120 row = {"lesson_id": 10, "rule_id": 20, "state": "confirmed"} assert backup._build_lesson_rule_link(row, maps) is None def test_backup_keeps_an_unjudged_link_unjudged(): """A suggested link was never judged; restoring it with the restore's time in judged_at would say it was.""" maps = backup._Maps() maps.notes[10] = 110 maps.rules[20] = 120 built = backup._build_lesson_rule_link( {"lesson_id": 10, "rule_id": 20, "state": "suggested", "judged_at": None}, maps, ) assert (built.lesson_id, built.rule_id, built.state) == (110, 120, "suggested") assert built.judged_at is None # ── #4631: judged when written ─────────────────────────────────────────────── @pytest.mark.asyncio async def test_an_unanswered_lesson_comes_back_with_rules_to_judge_against(): """Neither answer given: the create offers the rules the lesson resembles, searched with its claim and trigger and scoped to its project.""" _user_id_ctx.set(7) offered = [{"id": 5, "title": "Read the log", "kind": "rule", "when_to_apply": "a run overran", "score": 0.61}] search = AsyncMock(return_value=offered) p1, p2, p3 = _create_patches(AsyncMock(return_value=_stub_note())) with p1, p2, p3, patch.object(links_svc, "rule_candidates", search): out = await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER, project_id=2) assert search.await_args.args == (7, "x", TRIGGER, 2) assert out["rule_candidates"] == offered assert out["rule_judgment"] == links_svc.UNJUDGED assert "no_rule" in out["rule_hint"] and "rule_ids" in out["rule_hint"] @pytest.mark.asyncio async def test_a_search_that_could_not_run_offers_no_list_rather_than_an_empty_one(): """None means unavailable; an empty list would claim nothing resembles it.""" _user_id_ctx.set(7) p1, p2, p3 = _create_patches(AsyncMock(return_value=_stub_note())) with p1, p2, p3, patch.object(links_svc, "rule_candidates", AsyncMock(return_value=None)): out = await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER) assert "rule_candidates" not in out assert out["rule_judgment"] == links_svc.UNJUDGED @pytest.mark.asyncio async def test_no_rule_on_create_records_the_answer_and_offers_nothing(): _user_id_ctx.set(7) answered = AsyncMock() search = AsyncMock(return_value=[]) p1, p2, p3 = _create_patches(AsyncMock(return_value=_stub_note(id=41))) with p1, p2, p3, patch.object(links_svc, "set_no_rule", answered), \ patch.object(links_svc, "rule_candidates", search): out = await lesson_tools.create_lesson( what="x", when_to_apply=TRIGGER, no_rule="a one-off of this CI host", ) assert answered.await_args.args[1:] == (41, "a one-off of this CI host") search.assert_not_awaited() assert "rule_candidates" not in out @pytest.mark.asyncio async def test_rules_and_no_rule_together_write_nothing(): _user_id_ctx.set(7) created = AsyncMock(return_value=_stub_note()) p1, p2, p3 = _create_patches(created) with p1, p2, p3, patch.object(links_svc, "require_rules", AsyncMock(return_value=[5])): with pytest.raises(ValueError, match="no_rule"): await lesson_tools.create_lesson( what="x", when_to_apply=TRIGGER, rule_ids=[5], no_rule="none fits", ) created.assert_not_awaited() @pytest.mark.asyncio async def test_update_records_no_rule_and_refuses_it_beside_named_rules(): _user_id_ctx.set(7) answered = AsyncMock() updated = AsyncMock(return_value=_stub_note()) with patch.object(lessons_svc, "update_lesson", updated), \ patch.object(links_svc, "set_no_rule", answered): await lesson_tools.update_lesson(lesson_id=41, no_rule="stands alone") assert answered.await_args.args[1:] == (41, "stands alone") updated.reset_mock() with patch.object(lessons_svc, "update_lesson", updated), \ patch.object(links_svc, "require_rules", AsyncMock(return_value=[5])): with pytest.raises(ValueError): await lesson_tools.update_lesson(lesson_id=41, rule_ids=[5], no_rule="none") updated.assert_not_awaited() def test_an_empty_rule_list_beside_no_rule_is_one_answer_not_two(): """`rule_ids=[]` clears; it does not name a rule, so it cannot contradict.""" links_svc.require_one_answer([], "none fits") links_svc.require_one_answer(None, "none fits") links_svc.require_one_answer([5], "") with pytest.raises(ValueError): links_svc.require_one_answer([5], "none fits") @pytest.mark.asyncio async def test_list_unjudged_is_a_listing_and_routes_to_its_own_query(): _user_id_ctx.set(7) listed = AsyncMock(return_value=([], 0)) with patch.object(links_svc, "list_unjudged", listed), \ patch("scribe.mcp.tools.lessons.access_svc.label_shared_items", AsyncMock(return_value=[])), \ patch("scribe.mcp.tools.lessons.attach_usage", AsyncMock()): out = await lesson_tools.list_lessons(unjudged=True, tag="ci", project_id=2) assert out == {"lessons": [], "total": 0} assert listed.await_args.kwargs["tag"] == "ci" assert listed.await_args.kwargs["project_id"] == 2 with pytest.raises(ValueError): await lesson_tools.list_lessons(unjudged=True, q="log") def test_backup_skips_a_no_rule_answer_whose_lesson_did_not_restore(): maps = backup._Maps() assert backup._build_lesson_no_rule({"lesson_id": 10, "why": "alone"}, maps) is None maps.notes[10] = 110 built = backup._build_lesson_no_rule({"lesson_id": 10, "why": "alone"}, maps) assert (built.lesson_id, built.why) == (110, "alone") # An answer with no reason is not an answer; it restores as unjudged. assert backup._build_lesson_no_rule({"lesson_id": 10, "why": " "}, maps) is None def test_the_no_rule_migration_follows_the_link_migration(): path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0112_lesson_no_rule.py" spec = importlib.util.spec_from_file_location("m0112", path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) assert (module.revision, module.down_revision) == ("0112", "0111")