"""世谱匹配 / 预算 / 注入分组单元测试。""" from __future__ import annotations from app.modules.lorebook import ( LorebookBook, LorebookEntry, MatchedEntry, build_injections, match_entries, select_within_budget, ) def _entry(eid: str, **kw) -> LorebookEntry: return LorebookEntry(id=eid, **kw) def _book(entries: list[LorebookEntry], **kw) -> LorebookBook: return LorebookBook(id="lorebook_x", entries=entries, **kw) def test_keyword_match() -> None: book = _book([_entry("1", keys=["猫"], content="她喜欢猫。")]) assert [m.entry.id for m in match_entries(book, "今天看到一只猫")] == ["1"] def test_no_match_when_keyword_absent() -> None: book = _book([_entry("1", keys=["猫"], content="她喜欢猫。")]) assert match_entries(book, "今天天气不错") == [] def test_case_insensitive_by_default() -> None: book = _book([_entry("1", keys=["cat"], content="cat info")]) assert match_entries(book, "I have a CAT") != [] def test_case_sensitive() -> None: book = _book([_entry("1", keys=["Cat"], content="x", case_sensitive=True)]) assert match_entries(book, "a cat here") == [] assert [m.entry.id for m in match_entries(book, "a Cat here")] == ["1"] def test_constant_always_matched() -> None: book = _book([_entry("1", keys=["不会命中"], content="恒定设定", constant=True)]) matched = match_entries(book, "无关文本") assert [m.entry.id for m in matched] == ["1"] assert matched[0].reason == "constant" def test_disabled_entry_skipped() -> None: book = _book([_entry("1", keys=["猫"], content="x", enabled=False)]) assert match_entries(book, "一只猫") == [] def test_selective_requires_both_keys() -> None: book = _book( [ _entry( "1", keys=["猫"], secondary_keys=["黑"], content="黑猫设定", selective=True, ) ] ) assert match_entries(book, "看到一只猫") == [] assert [m.entry.id for m in match_entries(book, "看到一只黑猫")] == ["1"] def test_regex_match() -> None: book = _book([_entry("1", keys=[r"\b\d{4}\b"], content="年份", use_regex=True)]) assert [m.entry.id for m in match_entries(book, "生于 1999 年")] == ["1"] assert match_entries(book, "生于 99 年") == [] def test_recursive_scanning() -> None: # 条目 1 命中关键词,其 content 含触发条目 2 的关键词 book = _book( [ _entry("1", keys=["学校"], content="她在学校认识小明。"), _entry("2", keys=["小明"], content="小明是同班同学。"), ], recursive_scanning=True, ) matched = match_entries(book, "今天去了学校") ids = {m.entry.id for m in matched} assert ids == {"1", "2"} assert any(m.reason == "recursive" for m in matched if m.entry.id == "2") def test_budget_keeps_lower_priority() -> None: book = _book( [ _entry("a", keys=["k"], content="aaaa", priority=10), _entry("b", keys=["k"], content="bbbb", priority=1), ], token_budget=5, ) matched = match_entries(book, "k") kept = select_within_budget(matched, token_budget=5) # 仅 b(cost=4)能进预算;a(cost=4,但 priority 低)在 b 之后超预算 assert [m.entry.id for m in kept] == ["b"] def test_budget_none_keeps_all_sorted_by_order() -> None: book = _book( [ _entry("a", keys=["k"], content="x", priority=5, insertion_order=20), _entry("b", keys=["k"], content="y", priority=1, insertion_order=10), ] ) kept = select_within_budget(match_entries(book, "k")) assert [m.entry.id for m in kept] == ["b", "a"] def test_build_injections_groups_by_position() -> None: matched = [ MatchedEntry(entry=_entry("a", keys=["k"], content="前文", position="before_char")), MatchedEntry(entry=_entry("b", keys=["k"], content="后文1", position="after_char")), MatchedEntry(entry=_entry("c", keys=["k"], content="后文2", position="after_char")), ] inj = build_injections(matched) assert inj == {"before_char": "前文", "after_char": "后文1\n后文2"} def test_unknown_position_falls_back_to_after_char() -> None: matched = [MatchedEntry(entry=_entry("a", keys=["k"], content="x", position="weird"))] assert build_injections(matched) == {"after_char": "x"}