201 lines
6.0 KiB
Python
201 lines
6.0 KiB
Python
import asyncio
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from services.rpg_plot import (
|
|
prune_beats_for_done_quests,
|
|
process_arc_beats,
|
|
should_advance_arc_keywords,
|
|
pop_matching_beats,
|
|
dice_outcome_to_beat_trigger,
|
|
active_quest_titles_to_close,
|
|
can_fire_beat,
|
|
record_beat_fired,
|
|
complete_quest_for_fired_beat,
|
|
)
|
|
|
|
|
|
def test_prune_removes_beat_when_quest_done():
|
|
arc = {
|
|
"beats": [
|
|
{"id": "b2", "title": "Highway Howl", "trigger": "event_driven:travel", "choices": []},
|
|
]
|
|
}
|
|
quests = [{"title": "Highway Howl", "status": "done"}]
|
|
arc, removed = prune_beats_for_done_quests(arc, quests)
|
|
assert removed[0]["title"] == "Highway Howl"
|
|
assert arc["beats"] == []
|
|
|
|
|
|
def test_stuck_recovery_fires_when_no_active_quests_and_cooldown_ok():
|
|
arc = {
|
|
"beats": [
|
|
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": [{"id": "a", "label": "A"}]},
|
|
],
|
|
"meta": {"last_beat_fired_at_user_turn": 1},
|
|
}
|
|
quests = [{"title": "Old", "status": "done"}]
|
|
|
|
async def run():
|
|
return await process_arc_beats(arc, quests, "hello", user_turn=5)
|
|
|
|
arc2, fired, pruned, mode, extras = asyncio.run(run())
|
|
assert mode == "stuck_recovery"
|
|
assert fired[0]["title"] == "New Beat"
|
|
assert arc2["beats"] == []
|
|
assert extras.get("cooldown_skipped") is False
|
|
|
|
|
|
def test_stuck_recovery_skipped_when_cooldown_active():
|
|
arc = {
|
|
"beats": [
|
|
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": []},
|
|
],
|
|
"meta": {"last_beat_fired_at_user_turn": 4},
|
|
}
|
|
quests = []
|
|
|
|
async def run():
|
|
return await process_arc_beats(arc, quests, "hello", user_turn=5)
|
|
|
|
arc2, fired, _, mode, extras = asyncio.run(run())
|
|
assert mode == ""
|
|
assert fired == []
|
|
assert extras.get("cooldown_skipped") is True
|
|
assert len(arc2["beats"]) == 1
|
|
|
|
|
|
def test_stuck_recovery_skipped_after_reconcile_closed_quests():
|
|
arc = {
|
|
"beats": [
|
|
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": []},
|
|
],
|
|
}
|
|
quests = []
|
|
|
|
async def run():
|
|
return await process_arc_beats(
|
|
arc, quests, "hello", user_turn=10, reconcile_closed_count=2
|
|
)
|
|
|
|
_, fired, _, mode, _ = asyncio.run(run())
|
|
assert mode == ""
|
|
assert fired == []
|
|
|
|
|
|
def test_dice_outcome_maps_to_after_fail():
|
|
assert dice_outcome_to_beat_trigger("failure") == "event_driven:after_fail"
|
|
assert dice_outcome_to_beat_trigger("critical failure") == "event_driven:after_fail"
|
|
assert dice_outcome_to_beat_trigger("success") == "event_driven:after_success"
|
|
|
|
|
|
def test_after_fail_beat_fires_on_dice_failure_only_with_needs_check():
|
|
arc = {
|
|
"beats": [
|
|
{
|
|
"id": "b_fail",
|
|
"title": "Dust Yourself Off",
|
|
"trigger": "event_driven:after_fail",
|
|
"injection": "The stumble leaves you both shaken.",
|
|
"choices": [{"id": "a", "label": "Try again"}],
|
|
},
|
|
]
|
|
}
|
|
|
|
async def run(needs_check: bool):
|
|
return await process_arc_beats(
|
|
arc, [], "продолжаем", last_dice_outcome="failure", needs_check=needs_check, user_turn=3
|
|
)
|
|
|
|
arc_ok, fired, _, mode, _ = asyncio.run(run(True))
|
|
assert mode == "after_dice"
|
|
assert fired[0]["id"] == "b_fail"
|
|
assert arc_ok["beats"] == []
|
|
|
|
arc2 = {
|
|
"beats": [
|
|
{
|
|
"id": "b_fail",
|
|
"title": "Dust Yourself Off",
|
|
"trigger": "event_driven:after_fail",
|
|
"choices": [],
|
|
},
|
|
]
|
|
}
|
|
|
|
async def run_no_check():
|
|
with patch(
|
|
"services.rpg_plot.classify_plot_beat",
|
|
new_callable=AsyncMock,
|
|
return_value=None,
|
|
):
|
|
return await process_arc_beats(
|
|
arc2,
|
|
[],
|
|
"продолжаем",
|
|
last_dice_outcome="failure",
|
|
needs_check=False,
|
|
user_turn=3,
|
|
allow_stuck_recovery=False,
|
|
)
|
|
|
|
_, fired2, _, mode2, _ = asyncio.run(run_no_check())
|
|
assert mode2 == ""
|
|
assert fired2 == []
|
|
|
|
|
|
def test_orphan_active_quests_closed_when_not_in_arc():
|
|
arc = {
|
|
"beats": [
|
|
{
|
|
"id": "b_new_3",
|
|
"title": "Defensive Magic",
|
|
"trigger": "event_driven:after_fail",
|
|
"choices": [],
|
|
}
|
|
]
|
|
}
|
|
quests = [
|
|
{"title": "Floral Whispers", "status": "active"},
|
|
{"title": "Gift from the Glade", "status": "active"},
|
|
{"title": "Defensive Magic", "status": "active"},
|
|
{"title": "Old Done", "status": "done"},
|
|
]
|
|
closed = active_quest_titles_to_close(arc, quests)
|
|
assert set(closed) == {"Floral Whispers", "Gift from the Glade"}
|
|
|
|
|
|
def test_can_fire_beat_respects_min_gap():
|
|
arc = {"meta": {"last_beat_fired_at_user_turn": 5}}
|
|
assert can_fire_beat(arc, 6, min_gap=2) is False
|
|
assert can_fire_beat(arc, 7, min_gap=2) is True
|
|
|
|
|
|
def test_record_beat_fired_updates_meta():
|
|
arc = {}
|
|
record_beat_fired(arc, {"id": "b1"}, 8)
|
|
assert arc["meta"]["last_beat_fired_at_user_turn"] == 8
|
|
assert arc["meta"]["last_beat_id"] == "b1"
|
|
|
|
|
|
def test_complete_quest_for_fired_beat():
|
|
beat = {"id": "b1", "title": "Pancake Talk", "injection": "..."}
|
|
|
|
async def run():
|
|
with patch("services.memory.upsert_quest", new_callable=AsyncMock) as mock:
|
|
await complete_quest_for_fired_beat("sess-1", beat)
|
|
mock.assert_called_once_with("sess-1", "Pancake Talk", "done")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_keyword_fallback_travel():
|
|
arc = {
|
|
"beats": [
|
|
{"id": "b2", "title": "Highway Howl", "trigger": "event_driven:travel", "choices": []},
|
|
]
|
|
}
|
|
trig = should_advance_arc_keywords("едем на стадион")
|
|
assert trig == "event_driven:travel"
|
|
arc, fired = pop_matching_beats(arc, trig, max_beats=1)
|
|
assert fired[0]["title"] == "Highway Howl"
|