feat(engine): persistenza enigmi, inventario strutturato, HP mostri
test / test (push) Has been cancelled
test / test (push) Has been cancelled
Fase 2 del remediation plan: - set_puzzle_state / is_puzzle_solved per enigmi area - update_inventory su character_items con add/remove/equip/list - combat() legge HP da combat_state e persiste danno - dm_cycle inietta campaign_id nei tool che ne hanno bisogno - helpers db: dedup inventario, remove/equip per nome Fase 4 del remediation plan: - test integrazione dm_cycle con LLM mockato (5 test) - test WebSocket handler con mock (4 test) - CI minimo .gitea/workflows/test.yml
This commit is contained in:
1 parent
cb45172717
commit
6b048dc0cc
7 files changed
+386
-18
No files matched your search
@@ -0,0 +1,22 @@
|
||||
name: test
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
pip install pytest pytest-asyncio httpx
|
||||
- name: Run tests
|
||||
run: |
|
||||
PYTHONPATH=$(pwd)/src python3 -m pytest tests/ -v
|
||||
- name: Verify DB init
|
||||
run: |
|
||||
PYTHONPATH=$(pwd)/src python3 -c "from src.engine import db; db.init_db()"
|
||||
+67
-8
@@ -415,7 +415,7 @@ def get_log(campaign_id: int, limit: int = 20) -> list[dict]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def set_area_state(campaign_id: int, area_id: str, visited: bool = True, state: dict = {}) -> None:
|
||||
def set_area_state(campaign_id: int, area_id: str, visited: bool = True, state: dict | None = None) -> None:
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"""INSERT INTO adventure_state (campaign_id, area_id, visited, state)
|
||||
@@ -423,11 +423,41 @@ def set_area_state(campaign_id: int, area_id: str, visited: bool = True, state:
|
||||
ON CONFLICT(campaign_id, area_id) DO UPDATE SET
|
||||
visited = excluded.visited,
|
||||
state = excluded.state""",
|
||||
(campaign_id, area_id, int(visited), json.dumps(state)),
|
||||
(campaign_id, area_id, int(visited), json.dumps(state or {})),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def set_puzzle_state(campaign_id: int, area_id: str, puzzle_id: str, solved: bool = True) -> None:
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT state FROM adventure_state WHERE campaign_id = ? AND area_id = ?",
|
||||
(campaign_id, area_id),
|
||||
).fetchone()
|
||||
state = json.loads(row["state"]) if row and row["state"] else {}
|
||||
state[puzzle_id] = "solved" if solved else "unsolved"
|
||||
conn.execute(
|
||||
"""INSERT INTO adventure_state (campaign_id, area_id, visited, state)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(campaign_id, area_id) DO UPDATE SET
|
||||
state = excluded.state""",
|
||||
(campaign_id, area_id, json.dumps(state)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def is_puzzle_solved(campaign_id: int, area_id: str, puzzle_id: str) -> bool:
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT state FROM adventure_state WHERE campaign_id = ? AND area_id = ?",
|
||||
(campaign_id, area_id),
|
||||
).fetchone()
|
||||
if not row or not row["state"]:
|
||||
return False
|
||||
state = json.loads(row["state"])
|
||||
return state.get(puzzle_id) == "solved"
|
||||
|
||||
|
||||
def get_area_state(campaign_id: int) -> list[dict]:
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
@@ -533,6 +563,17 @@ def get_combat_state(campaign_id: int) -> list[dict]:
|
||||
|
||||
def add_character_item(character_id: int, name: str, item_def_id: str = "", quantity: int = 1) -> int:
|
||||
conn = get_conn()
|
||||
existing = conn.execute(
|
||||
"SELECT id, quantity FROM character_items WHERE character_id = ? AND name = ?",
|
||||
(character_id, name),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE character_items SET quantity = quantity + ? WHERE id = ?",
|
||||
(quantity, existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return existing["id"]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO character_items (character_id, name, item_def_id, quantity) VALUES (?, ?, ?, ?)",
|
||||
(character_id, name, item_def_id, quantity),
|
||||
@@ -541,11 +582,23 @@ def add_character_item(character_id: int, name: str, item_def_id: str = "", quan
|
||||
return cur.lastrowid or 0
|
||||
|
||||
|
||||
def remove_character_item(item_id: int) -> bool:
|
||||
def remove_character_item(character_id: int, name: str, quantity: int = 1) -> bool:
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM character_items WHERE id = ?", (item_id,))
|
||||
existing = conn.execute(
|
||||
"SELECT id, quantity FROM character_items WHERE character_id = ? AND name = ?",
|
||||
(character_id, name),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
return False
|
||||
if existing["quantity"] > quantity:
|
||||
conn.execute(
|
||||
"UPDATE character_items SET quantity = quantity - ? WHERE id = ?",
|
||||
(quantity, existing["id"]),
|
||||
)
|
||||
else:
|
||||
conn.execute("DELETE FROM character_items WHERE id = ?", (existing["id"],))
|
||||
conn.commit()
|
||||
return conn.total_changes > 0
|
||||
return True
|
||||
|
||||
|
||||
def get_character_items(character_id: int) -> list[dict]:
|
||||
@@ -557,11 +610,17 @@ def get_character_items(character_id: int) -> list[dict]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def equip_character_item(item_id: int, equipped: bool = True) -> bool:
|
||||
def equip_character_item(character_id: int, name: str, equipped: bool = True) -> bool:
|
||||
conn = get_conn()
|
||||
conn.execute("UPDATE character_items SET equipped = ? WHERE id = ?", (int(equipped), item_id))
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM character_items WHERE character_id = ? AND name = ?",
|
||||
(character_id, name),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
return False
|
||||
conn.execute("UPDATE character_items SET equipped = ? WHERE id = ?", (int(equipped), existing["id"]))
|
||||
conn.commit()
|
||||
return conn.total_changes > 0
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -39,6 +39,10 @@ def dm_cycle(campaign_id: int, player_action: str) -> dict:
|
||||
|
||||
tool_name, tool_params, narration = _llm_decision(ctx, player_action, current_area_id=ctx.current_area_id)
|
||||
|
||||
# Inietta campaign_id per strumenti che necessitano persistenza
|
||||
if tool_name == "combat":
|
||||
tool_params.setdefault("campaign_id", campaign_id)
|
||||
|
||||
result = tools.run(tool_name, **tool_params)
|
||||
|
||||
mechanic_msg = result.get("message", "")
|
||||
|
||||
@@ -16,13 +16,14 @@ _KNOWN_TOOLS: dict[str, dict[str, str]] = {
|
||||
"damage_die": "dado danno (default d8)",
|
||||
"damage_bonus": "bonus danno (default 0)",
|
||||
"mighty_deed_die": "dado deed guerriero (es. d3)",
|
||||
"monster_id": "id mostro (per PF persistenti)",
|
||||
"armor_type": "none/light/medium/heavy",
|
||||
},
|
||||
"ability_check": {"character_id": "id pg", "dc": "CD prova", "modifier": "bonus/penalità"},
|
||||
"saving_throw": {"character_id": "id pg", "save_type": "Tempra|Riflessi|Volontà", "dc": "CD", "modifier": "bonus/penalità"},
|
||||
"movement": {"distance_m": "metri", "terrain": "normale|difficile|molto difficile"},
|
||||
"spellcasting": {"character_id": "id pg", "spell_name": "nome incantesimo", "spell_check": "valore tiro"},
|
||||
"update_inventory": {"character_id": "id pg", "item": "nome oggetto", "action": "add|remove"},
|
||||
"update_inventory": {"character_id": "id pg", "item": "nome oggetto", "action": "add|remove|equip|unequip|list", "quantity": "quantità (default 1)"},
|
||||
"award_xp": {"character_id": "id pg", "xp": "quantità PE"},
|
||||
"dm_override": {"command": "narrate|set|force", "params": "parametri opzionali"},
|
||||
}
|
||||
|
||||
+67
-9
@@ -62,6 +62,8 @@ def combat(
|
||||
mighty_deed_die: str = "",
|
||||
luck_mod: int = 0,
|
||||
armor_type: str = "none",
|
||||
monster_id: str = "",
|
||||
campaign_id: int = 0,
|
||||
) -> ToolResult:
|
||||
char = storage.get_character(character_id)
|
||||
if not char:
|
||||
@@ -71,7 +73,6 @@ def combat(
|
||||
atk_roll = dice.roll("d20", modifier=attack_bonus)
|
||||
raw_total = atk_roll["total"]
|
||||
|
||||
|
||||
deed_roll = None
|
||||
if mighty_deed_die:
|
||||
deed_roll = dice.roll(mighty_deed_die)
|
||||
@@ -81,6 +82,17 @@ def combat(
|
||||
result_parts = []
|
||||
crit_res = None
|
||||
|
||||
# HP persistenti: carica mostro da combat_state se disponibile
|
||||
monster_name = ""
|
||||
monster_hp_before = None
|
||||
if monster_id and campaign_id:
|
||||
state = storage.get_combat_state(campaign_id)
|
||||
monster_state = next((m for m in state if m["monster_id"] == monster_id), None)
|
||||
if monster_state:
|
||||
monster_name = monster_state["monster_name"]
|
||||
monster_hp_before = monster_state["current_hp"]
|
||||
result_parts.append(f"{monster_name}: PF {monster_state['current_hp']}/{monster_state['max_hp']}")
|
||||
|
||||
if atk_roll["fumble"]:
|
||||
fumble_die_name = storage.get_fumble_die(armor_type) or "d4"
|
||||
fumble_roll = dice.roll(fumble_die_name)
|
||||
@@ -101,6 +113,10 @@ def combat(
|
||||
+ (crit_res["result"] if crit_res else "Nessun risultato trovato.")
|
||||
)
|
||||
|
||||
# Calcolo danno e persistenza
|
||||
total_dmg = 0
|
||||
new_hp = None
|
||||
monster_defeated = False
|
||||
if hit and not atk_roll["fumble"]:
|
||||
dmg = dice.roll(damage_die, modifier=damage_bonus)
|
||||
crit_dmg_bonus = 0
|
||||
@@ -108,6 +124,15 @@ def combat(
|
||||
crit_dmg_bonus = _parse_extra_damage(crit_res.get("result", ""))
|
||||
total_dmg = dmg["total"] + crit_dmg_bonus
|
||||
result_parts.append(f"Colpito! Danni: {dmg['results']} + {crit_dmg_bonus} extra = {total_dmg}")
|
||||
|
||||
if monster_id and campaign_id and monster_hp_before is not None:
|
||||
hp_result = storage.update_combat_hp(campaign_id, monster_id, total_dmg)
|
||||
if hp_result:
|
||||
new_hp = hp_result["new_hp"]
|
||||
monster_defeated = hp_result["status"] == "defeated"
|
||||
result_parts.append(f"PF {monster_name}: {monster_hp_before} → {new_hp}")
|
||||
if monster_defeated:
|
||||
result_parts.append(f"{monster_name} sconfitto!")
|
||||
elif not atk_roll["fumble"]:
|
||||
result_parts.append("Mancato!")
|
||||
|
||||
@@ -125,6 +150,9 @@ def combat(
|
||||
"crit": atk_roll["crit"],
|
||||
"fumble": atk_roll["fumble"],
|
||||
"deed": deed_roll["total"] if deed_roll else None,
|
||||
"monster_hp_before": monster_hp_before,
|
||||
"monster_hp_after": new_hp,
|
||||
"monster_defeated": monster_defeated,
|
||||
"message": f"Iniziativa: {init_roll['total']} | Attacco d20+{attack_bonus}: {atk_roll['results'][0]}+{attack_bonus}={atk_roll['total']} vs CA {target_ac} | {'COLPI TO!' if hit else 'Mancato.'}{deed_msg} | " + " | ".join(result_parts),
|
||||
}
|
||||
|
||||
@@ -273,25 +301,55 @@ def spellcasting(character_id: int, spell_name: str, spell_check: int, dc: int =
|
||||
|
||||
|
||||
@tool
|
||||
def update_inventory(character_id: int, item: str, action: str = "add") -> ToolResult:
|
||||
def update_inventory(character_id: int, item: str = "", action: str = "add") -> ToolResult:
|
||||
char = storage.get_character(character_id)
|
||||
if not char:
|
||||
return {"success": False, "tool": "update-inventory", "message": "Personaggio non trovato."}
|
||||
inv = json.loads(char.get("equipment", "[]"))
|
||||
|
||||
if action == "add":
|
||||
inv.append(item)
|
||||
if not item:
|
||||
return {"success": False, "tool": "update-inventory", "message": "Specificare un oggetto da aggiungere."}
|
||||
storage.add_character_item(character_id, item)
|
||||
elif action == "remove":
|
||||
inv = [i for i in inv if i != item]
|
||||
if not item:
|
||||
return {"success": False, "tool": "update-inventory", "message": "Specificare un oggetto da rimuovere."}
|
||||
if not storage.remove_character_item(character_id, item):
|
||||
return {"success": False, "tool": "update-inventory", "message": f"Oggetto '{item}' non trovato."}
|
||||
elif action == "equip":
|
||||
if not item:
|
||||
return {"success": False, "tool": "update-inventory", "message": "Specificare un oggetto da equipaggiare."}
|
||||
if not storage.equip_character_item(character_id, item, True):
|
||||
return {"success": False, "tool": "update-inventory", "message": f"Oggetto '{item}' non trovato."}
|
||||
elif action == "unequip":
|
||||
if not item:
|
||||
return {"success": False, "tool": "update-inventory", "message": "Specificare un oggetto da rimuovere."}
|
||||
if not storage.equip_character_item(character_id, item, False):
|
||||
return {"success": False, "tool": "update-inventory", "message": f"Oggetto '{item}' non trovato."}
|
||||
elif action == "list":
|
||||
items = storage.get_character_items(character_id)
|
||||
names = [f"{i['name']} x{i['quantity']}{' (eq.)' if i['equipped'] else ''}" for i in items]
|
||||
return {
|
||||
"success": True,
|
||||
"tool": "update-inventory",
|
||||
"action": action,
|
||||
"inventory": [dict(i) for i in items],
|
||||
"message": f"Inventario: {len(items)} oggetti — " + (", ".join(names) if names else "vuoto."),
|
||||
}
|
||||
else:
|
||||
return {"success": False, "tool": "update-inventory", "message": f"Azione '{action}' non valida. Usa 'add' o 'remove'."}
|
||||
storage.update_character(character_id, equipment=json.dumps(inv))
|
||||
return {"success": False, "tool": "update-inventory", "message": f"Azione '{action}' non valida. Usa: add, remove, equip, unequip, list."}
|
||||
|
||||
# Sincronizza campo equipment legacy per backward compat
|
||||
items = storage.get_character_items(character_id)
|
||||
names = [i["name"] for i in items if i.get("quantity", 1) > 0]
|
||||
storage.update_character(character_id, equipment=json.dumps(names))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"tool": "update-inventory",
|
||||
"action": action,
|
||||
"item": item,
|
||||
"inventory": inv,
|
||||
"message": f"Inventario: {action} '{item}' → {len(inv)} oggetti.",
|
||||
"inventory": [dict(i) for i in storage.get_character_items(character_id)],
|
||||
"message": f"Inventario: {action} '{item}' → aggiornato.",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Integration test: dm_cycle con LLM mockato."""
|
||||
import json
|
||||
import sys
|
||||
sys.path.insert(0, "src")
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.engine import db, dm
|
||||
|
||||
MOCK_TOOL_RESP = json.dumps({
|
||||
"tool": "roll_dice",
|
||||
"params": {"die": "d20", "count": 1},
|
||||
"narration": "Tendi l'orecchio nel buio...",
|
||||
})
|
||||
|
||||
MOCK_COMBAT_RESP = json.dumps({
|
||||
"tool": "combat",
|
||||
"params": {
|
||||
"character_id": 1,
|
||||
"target_ac": 12,
|
||||
"attack_bonus": 2,
|
||||
"damage_die": "d8",
|
||||
},
|
||||
"narration": "Affronti il guardiano con la spada sguainata!",
|
||||
})
|
||||
|
||||
|
||||
def _setup_campaign():
|
||||
conn = db.init_db()
|
||||
camp_id = db.create_campaign("Test Integrazione", "Avventura")
|
||||
char_id = db.create_character(camp_id, "Eroe", "guerriero", "soldato")
|
||||
db.update_character(
|
||||
char_id,
|
||||
level=1, str=15, agi=11, sta=14,
|
||||
per=9, int=8, lck=10,
|
||||
hp_current=12, hp_max=12, ac=15,
|
||||
armor_type="light",
|
||||
)
|
||||
db.seed_adventure_state(camp_id)
|
||||
return camp_id, char_id
|
||||
|
||||
|
||||
@patch("src.engine.dm.llm_ask", return_value=MOCK_TOOL_RESP)
|
||||
def test_dm_cycle_returns_tool_params_narration(mock_ask):
|
||||
camp_id, _ = _setup_campaign()
|
||||
out = dm.dm_cycle(camp_id, "Provo a sentire rumori")
|
||||
|
||||
assert "tool" in out
|
||||
assert "params" in out
|
||||
assert "narration" in out
|
||||
assert out["tool"] == "roll_dice"
|
||||
assert out["params"]["die"] == "d20"
|
||||
assert "Tendi l'orecchio" in out["narration"]
|
||||
|
||||
|
||||
@patch("src.engine.dm.llm_ask", return_value=MOCK_TOOL_RESP)
|
||||
def test_dm_cycle_advances_turn(mock_ask):
|
||||
camp_id, _ = _setup_campaign()
|
||||
turn_before = db.get_campaign(camp_id)["turn"]
|
||||
out = dm.dm_cycle(camp_id, "Provo a sentire rumori")
|
||||
assert out["turn"] == turn_before + 1
|
||||
assert db.get_campaign(camp_id)["turn"] == turn_before + 1
|
||||
|
||||
|
||||
@patch("src.engine.dm.llm_ask", return_value=MOCK_TOOL_RESP)
|
||||
def test_dm_cycle_writes_log(mock_ask):
|
||||
camp_id, _ = _setup_campaign()
|
||||
out = dm.dm_cycle(camp_id, "Provo a sentire rumori")
|
||||
|
||||
log = db.get_log(camp_id, limit=1)
|
||||
assert len(log) >= 1
|
||||
assert log[0]["action"] == "Provo a sentire rumori"
|
||||
assert "Tendi l'orecchio" in log[0]["result"]
|
||||
|
||||
|
||||
@patch("src.engine.dm.llm_ask", return_value=MOCK_COMBAT_RESP)
|
||||
def test_dm_cycle_combat_injects_campaign_id(mock_ask):
|
||||
camp_id, char_id = _setup_campaign()
|
||||
# Replace character_id placeholder in mock with real ID
|
||||
resp = json.loads(MOCK_COMBAT_RESP)
|
||||
resp["params"]["character_id"] = char_id
|
||||
|
||||
with patch("src.engine.dm.llm_ask", return_value=json.dumps(resp)):
|
||||
out = dm.dm_cycle(camp_id, "Attacco il guardiano")
|
||||
assert out["tool"] == "combat"
|
||||
assert out["params"].get("campaign_id") == camp_id
|
||||
|
||||
|
||||
@patch("src.engine.dm.llm_ask", return_value="{}")
|
||||
def test_dm_cycle_fallback_on_empty_json(mock_ask):
|
||||
camp_id, _ = _setup_campaign()
|
||||
out = dm.dm_cycle(camp_id, "saluto il guardiano")
|
||||
# Fallback heuristic: should produce dm_override narrate
|
||||
assert out["tool"] in ("combat", "dm_override")
|
||||
assert out["full_output"]
|
||||
@@ -0,0 +1,129 @@
|
||||
"""WebSocket test: test handler logica via mock WS."""
|
||||
import json
|
||||
import sys
|
||||
sys.path.insert(0, "src")
|
||||
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.engine import db
|
||||
|
||||
MOCK_RESP = json.dumps({
|
||||
"tool": "roll_dice",
|
||||
"params": {"die": "d20", "count": 1},
|
||||
"narration": "Tendi l'orecchio nel buio...",
|
||||
})
|
||||
|
||||
|
||||
def _setup_campaign():
|
||||
conn = db.init_db()
|
||||
camp_id = db.create_campaign("Test WS", "Avventura")
|
||||
char_id = db.create_character(camp_id, "Eroe", "guerriero", "soldato")
|
||||
db.update_character(
|
||||
char_id,
|
||||
level=1, str=15, agi=11, sta=14,
|
||||
per=9, int=8, lck=10,
|
||||
hp_current=12, hp_max=12, ac=15,
|
||||
armor_type="light",
|
||||
)
|
||||
db.seed_adventure_state(camp_id)
|
||||
return camp_id
|
||||
|
||||
|
||||
def _make_mock_ws():
|
||||
ws = AsyncMock()
|
||||
ws.send_json = AsyncMock()
|
||||
ws.send_text = AsyncMock()
|
||||
ws.close = AsyncMock()
|
||||
ws.receive_text = AsyncMock()
|
||||
return ws
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.engine.dm.llm_ask", return_value=MOCK_RESP)
|
||||
async def test_ws_handler_game_loop(mock_ask):
|
||||
from src.web.server import game_ws
|
||||
|
||||
camp_id = _setup_campaign()
|
||||
ws = _make_mock_ws()
|
||||
ws.receive_text.side_effect = [
|
||||
json.dumps({"type": "action", "payload": "Provo ad ascoltare"}),
|
||||
Exception("WS chiuso"),
|
||||
]
|
||||
|
||||
try:
|
||||
await game_ws(ws, camp_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ws.accept.assert_awaited_once()
|
||||
|
||||
calls = [c[0][0] for c in ws.send_json.call_args_list]
|
||||
assert calls[0]["type"] == "state"
|
||||
resp = calls[1]
|
||||
assert resp["type"] == "response"
|
||||
assert resp["tool"] == "roll_dice"
|
||||
assert resp["turn"] == 1
|
||||
assert calls[2]["type"] == "state"
|
||||
assert calls[2]["campaign"]["turn"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.engine.dm.llm_ask", return_value=MOCK_RESP)
|
||||
async def test_ws_handler_unknown_campaign(mock_ask):
|
||||
from src.web.server import game_ws
|
||||
|
||||
ws = _make_mock_ws()
|
||||
await game_ws(ws, 99999)
|
||||
|
||||
ws.accept.assert_awaited_once()
|
||||
ws.send_json.assert_awaited_once_with(
|
||||
{"type": "error", "message": "Campagna non trovata"}
|
||||
)
|
||||
ws.close.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.engine.dm.llm_ask", return_value=MOCK_RESP)
|
||||
async def test_ws_handler_plain_text(mock_ask):
|
||||
from src.web.server import game_ws
|
||||
|
||||
camp_id = _setup_campaign()
|
||||
ws = _make_mock_ws()
|
||||
ws.receive_text.side_effect = [
|
||||
"Attacco il guardiano!",
|
||||
Exception("fine"),
|
||||
]
|
||||
|
||||
try:
|
||||
await game_ws(ws, camp_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
calls = [c[0][0] for c in ws.send_json.call_args_list]
|
||||
assert calls[0]["type"] == "state"
|
||||
assert calls[1]["type"] == "response"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.engine.dm.llm_ask", return_value=MOCK_RESP)
|
||||
async def test_ws_handler_error_boundary(mock_ask):
|
||||
from src.web.server import game_ws
|
||||
|
||||
camp_id = _setup_campaign()
|
||||
ws = _make_mock_ws()
|
||||
ws.receive_text.side_effect = [
|
||||
json.dumps({"type": "action", "payload": "test"}),
|
||||
Exception("fine"),
|
||||
]
|
||||
|
||||
with patch("src.web.server.dm_cycle", side_effect=[ValueError("Test errore")]):
|
||||
try:
|
||||
await game_ws(ws, camp_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
calls = [c[0][0] for c in ws.send_json.call_args_list]
|
||||
error_msgs = [c for c in calls if c.get("type") == "error"]
|
||||
assert len(error_msgs) >= 1
|
||||
Reference in new issue
Block a user