"""Guessed record ids are refused; `{{ref:N}}` placeholders resolve (#4016). The defect: a session predicts the ids its next creates will get and writes them into a body before the records exist; any other session's create takes those numbers. These pin the recogniser — what counts as a `#N`, where a guess can sit relative to the highest id — and the placeholder rewrite the batch create uses instead of guessing. """ from unittest.mock import AsyncMock, patch import pytest from scribe.services import record_refs from scribe.services.record_refs import ( GUESS_WINDOW, cited_ids, placeholder_keys, resolve_placeholders, ) @pytest.mark.parametrize(("text", "expected"), [ ("see #12 and (#34)", {12, 34}), ("#7 at the start", {7}), ("an entity { is not a reference", set()), ("a fragment page#12 is not one", set()), ("a colour #123abc is not one", set()), ("path/#9 is not one", set()), ("", set()), ]) def test_cited_ids_reads_standalone_hash_numbers_only(text, expected): assert cited_ids(text) == expected def test_cited_ids_ignores_none_among_texts(): assert cited_ids(None, "#5", None) == {5} async def test_a_number_just_above_the_highest_id_is_a_guess(): with patch.object(record_refs, "_max_note_id", AsyncMock(return_value=100)): assert await record_refs.guessed_ids("next is #101, then #102") == [101, 102] async def test_existing_ids_and_far_numbers_are_not_guesses(): """Below the max is a real record; far above it is not a Scribe id at all (a PR, a forge issue) — neither may be refused.""" far = 100 + GUESS_WINDOW + 1 with patch.object(record_refs, "_max_note_id", AsyncMock(return_value=100)): assert await record_refs.guessed_ids(f"#1 #100 #{far}") == [] async def test_the_window_edge_is_still_a_guess(): edge = 100 + GUESS_WINDOW with patch.object(record_refs, "_max_note_id", AsyncMock(return_value=100)): assert await record_refs.guessed_ids(f"#{edge}") == [edge] async def test_text_without_references_never_queries(): """Most writes cite nothing; they must not pay a query for it.""" probe = AsyncMock(return_value=100) with patch.object(record_refs, "_max_note_id", probe): await record_refs.refuse_guessed_ids("no numbers here", None, "") probe.assert_not_awaited() async def test_refusal_names_the_guess_and_the_fix(): with patch.object(record_refs, "_max_note_id", AsyncMock(return_value=100)): with pytest.raises(ValueError) as exc: await record_refs.refuse_guessed_ids("steps #101 and #103") message = str(exc.value) assert "#101, #103" in message assert "create_records" in message and "{{ref:N}}" in message def test_placeholder_keys_tolerates_spacing(): assert placeholder_keys("{{ref:1}} {{ ref: 2 }} {{ref:milestone}}") == {"1", "2", "milestone"} def test_resolve_placeholders_rewrites_every_occurrence(): refs = {"1": '#40 "first"', "milestone": 'milestone 9 "plan"'} out = resolve_placeholders("{{ref:1}} then {{ref:1}} in {{ref:milestone}}", refs) assert out == '#40 "first" then #40 "first" in milestone 9 "plan"' def test_resolve_placeholders_passes_empty_through(): assert resolve_placeholders(None, {}) is None assert resolve_placeholders("", {}) == "" def test_an_unknown_key_is_a_caller_bug_not_a_silent_leftover(): with pytest.raises(KeyError): resolve_placeholders("{{ref:3}}", {"1": "#1"})