rebase
This commit is contained in:
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit tests package."""
|
||||
69
tests/unit/test_embeddings.py
Normal file
69
tests/unit/test_embeddings.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for `app.core.embeddings.HashEmbedder`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.embeddings import HashEmbedder
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hash_embedder_returns_correct_dimension():
|
||||
emb = HashEmbedder(dimension=128)
|
||||
vecs = await emb.embed(["hello world"])
|
||||
assert len(vecs) == 1
|
||||
assert len(vecs[0]) == 128
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hash_embedder_empty_text_returns_zero_vector():
|
||||
emb = HashEmbedder(dimension=64)
|
||||
vecs = await emb.embed([""])
|
||||
assert vecs[0] == [0.0] * 64
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hash_embedder_deterministic():
|
||||
emb = HashEmbedder(dimension=64)
|
||||
v1 = (await emb.embed(["the quick brown fox"]))[0]
|
||||
v2 = (await emb.embed(["the quick brown fox"]))[0]
|
||||
assert v1 == v2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hash_embedder_normalized():
|
||||
emb = HashEmbedder(dimension=64)
|
||||
v = (await emb.embed(["some text with multiple words for hashing"]))[0]
|
||||
norm = math.sqrt(sum(x * x for x in v))
|
||||
assert abs(norm - 1.0) < 1e-6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hash_embedder_similar_texts_have_overlap():
|
||||
"""Texts sharing tokens should have non-zero cosine similarity."""
|
||||
emb = HashEmbedder(dimension=256)
|
||||
v1 = (await emb.embed(["the dragon attacks the village"]))[0]
|
||||
v2 = (await emb.embed(["the dragon breathes fire"]))[0]
|
||||
v3 = (await emb.embed(["quantum mechanics equations"]))[0]
|
||||
# Cosine similarity (vectors are already normalized)
|
||||
sim_12 = sum(a * b for a, b in zip(v1, v2))
|
||||
sim_13 = sum(a * b for a, b in zip(v1, v3))
|
||||
# Shared-token texts should be more similar than disjoint ones
|
||||
assert sim_12 > sim_13
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hash_embedder_batch():
|
||||
emb = HashEmbedder(dimension=32)
|
||||
vecs = await emb.embed(["a", "b", "c"])
|
||||
assert len(vecs) == 3
|
||||
assert all(len(v) == 32 for v in vecs)
|
||||
|
||||
|
||||
def test_hash_embedder_invalid_dimension():
|
||||
with pytest.raises(ValueError):
|
||||
HashEmbedder(dimension=0)
|
||||
with pytest.raises(ValueError):
|
||||
HashEmbedder(dimension=-1)
|
||||
76
tests/unit/test_llm_mock.py
Normal file
76
tests/unit/test_llm_mock.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Tests for `app.core.llm.MockLlmClient`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.llm import LLMResponseError, MockLlmClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_client_returns_replay_response():
|
||||
client = MockLlmClient({
|
||||
"test_stage": [
|
||||
{"message": {"role": "assistant", "content": "Hello"}, "finish_reason": "stop"}
|
||||
]
|
||||
})
|
||||
resp = await client.complete(stage="test_stage", messages=[])
|
||||
assert resp["message"]["content"] == "Hello"
|
||||
assert resp["finish_reason"] == "stop"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_client_advances_on_each_call():
|
||||
client = MockLlmClient({
|
||||
"test_stage": [
|
||||
{"message": {"role": "assistant", "content": "First"}, "finish_reason": "stop"},
|
||||
{"message": {"role": "assistant", "content": "Second"}, "finish_reason": "stop"},
|
||||
]
|
||||
})
|
||||
r1 = await client.complete(stage="test_stage", messages=[])
|
||||
r2 = await client.complete(stage="test_stage", messages=[])
|
||||
assert r1["message"]["content"] == "First"
|
||||
assert r2["message"]["content"] == "Second"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_client_raises_on_exhausted_replay():
|
||||
client = MockLlmClient({
|
||||
"test_stage": [
|
||||
{"message": {"role": "assistant", "content": "Only"}, "finish_reason": "stop"}
|
||||
]
|
||||
})
|
||||
await client.complete(stage="test_stage", messages=[])
|
||||
with pytest.raises(LLMResponseError):
|
||||
await client.complete(stage="test_stage", messages=[])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_client_records_calls():
|
||||
client = MockLlmClient({
|
||||
"test_stage": [
|
||||
{"message": {"role": "assistant", "content": "X"}, "finish_reason": "stop"}
|
||||
]
|
||||
})
|
||||
await client.complete(stage="test_stage", messages=[{"role": "user", "content": "hi"}],
|
||||
tools=[{"name": "calc"}])
|
||||
assert len(client.recorded_calls) == 1
|
||||
assert client.recorded_calls[0]["stage"] == "test_stage"
|
||||
assert client.recorded_calls[0]["messages"][0]["content"] == "hi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_client_stream_yields_chunks():
|
||||
client = MockLlmClient({
|
||||
"test_stage": [
|
||||
{"message": {"role": "assistant", "content": "Hello world"}, "finish_reason": "stop"}
|
||||
]
|
||||
})
|
||||
chunks = []
|
||||
async for evt in client.stream_complete(stage="test_stage", messages=[]):
|
||||
chunks.append(evt)
|
||||
# Last chunk must have finish_reason
|
||||
assert chunks[-1]["finish_reason"] == "stop"
|
||||
# Earlier chunks have content deltas
|
||||
contents = [c["delta"].get("content", "") for c in chunks if c["delta"].get("content")]
|
||||
assert "".join(contents) == "Hello world"
|
||||
58
tests/unit/test_prompts.py
Normal file
58
tests/unit/test_prompts.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Tests for prompt registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.prompts.registry import get_prompt
|
||||
|
||||
|
||||
def test_get_prompt_returns_english_for_known_stage():
|
||||
p = get_prompt("orchestrator_phase1", "en")
|
||||
assert isinstance(p, str)
|
||||
assert len(p) > 100
|
||||
assert "Game Master" in p or "GM" in p
|
||||
|
||||
|
||||
def test_get_prompt_falls_back_to_english():
|
||||
p = get_prompt("orchestrator_phase2", "ru")
|
||||
# Russian bundle is empty — falls back to English
|
||||
assert isinstance(p, str)
|
||||
assert len(p) > 100
|
||||
|
||||
|
||||
def test_get_prompt_unknown_stage_raises():
|
||||
with pytest.raises(KeyError):
|
||||
get_prompt("does_not_exist", "en")
|
||||
|
||||
|
||||
def test_all_stages_have_english_prompt():
|
||||
stages = [
|
||||
"world_builder_schema", "world_builder_env", "world_builder_entities",
|
||||
"world_editor", "orchestrator_phase1", "orchestrator_phase2",
|
||||
"orchestrator_phase3_summary", "orchestrator_phase3_suggest",
|
||||
"intro_scene", "subagent", "summary",
|
||||
]
|
||||
for s in stages:
|
||||
p = get_prompt(s, "en")
|
||||
assert len(p) > 50, f"Stage {s} has empty prompt"
|
||||
|
||||
|
||||
def test_orchestrator_phase1_prompt_has_required_placeholders():
|
||||
p = get_prompt("orchestrator_phase1", "en")
|
||||
for placeholder in [
|
||||
"{world_name}", "{rules}", "{schemas_summary}", "{environment_json}",
|
||||
"{plot_rails_json}", "{current_time}", "{max_substeps}",
|
||||
"{player_action}", "{language}", "{recent_history}",
|
||||
]:
|
||||
assert placeholder in p, f"Missing placeholder {placeholder}"
|
||||
|
||||
|
||||
def test_orchestrator_phase2_prompt_has_required_placeholders():
|
||||
p = get_prompt("orchestrator_phase2", "en")
|
||||
for placeholder in [
|
||||
"{world_name}", "{language}", "{current_time}",
|
||||
"{player_action}", "{plan}", "{summary_json}",
|
||||
"{environment_json}", "{world_description}",
|
||||
]:
|
||||
assert placeholder in p, f"Missing placeholder {placeholder}"
|
||||
89
tests/unit/test_security.py
Normal file
89
tests/unit/test_security.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Tests for `app.core.security` — JWT and password hashing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from jose import JWTError
|
||||
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
validate_password_strength,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
def test_password_hash_and_verify():
|
||||
plain = "S3cretPass!"
|
||||
hashed = hash_password(plain)
|
||||
assert hashed != plain
|
||||
assert verify_password(plain, hashed) is True
|
||||
assert verify_password("wrong", hashed) is False
|
||||
|
||||
|
||||
def test_password_hash_is_bcrypt():
|
||||
hashed = hash_password("S3cretPass!")
|
||||
# bcrypt hashes start with $2b$ or $2a$
|
||||
assert hashed.startswith("$2")
|
||||
|
||||
|
||||
def test_validate_password_strength_strong():
|
||||
errors = validate_password_strength("GoodPass123")
|
||||
assert errors == []
|
||||
|
||||
|
||||
def test_validate_password_strength_short():
|
||||
errors = validate_password_strength("abc12")
|
||||
assert any("8 characters" in e for e in errors)
|
||||
|
||||
|
||||
def test_validate_password_strength_no_digit():
|
||||
errors = validate_password_strength("NoDigitsHere")
|
||||
assert any("digit" in e for e in errors)
|
||||
|
||||
|
||||
def test_validate_password_strength_no_letter():
|
||||
errors = validate_password_strength("12345678")
|
||||
assert any("letter" in e for e in errors)
|
||||
|
||||
|
||||
def test_validate_password_strength_blacklist():
|
||||
errors = validate_password_strength("password1")
|
||||
assert any("common" in e for e in errors)
|
||||
|
||||
|
||||
def test_create_and_decode_access_token():
|
||||
uid = uuid.uuid4()
|
||||
token = create_access_token(uid, extra_claims={"is_admin": True})
|
||||
payload = decode_token(token)
|
||||
assert payload["sub"] == str(uid)
|
||||
assert payload["type"] == "access"
|
||||
assert payload["is_admin"] is True
|
||||
assert "iat" in payload and "exp" in payload
|
||||
|
||||
|
||||
def test_create_and_decode_refresh_token():
|
||||
uid = uuid.uuid4()
|
||||
token = create_refresh_token(uid)
|
||||
payload = decode_token(token)
|
||||
assert payload["sub"] == str(uid)
|
||||
assert payload["type"] == "refresh"
|
||||
|
||||
|
||||
def test_decode_invalid_token():
|
||||
with pytest.raises(JWTError):
|
||||
decode_token("not-a-valid-jwt")
|
||||
|
||||
|
||||
def test_token_expiry_in_future():
|
||||
uid = uuid.uuid4()
|
||||
token = create_access_token(uid, expires_in_minutes=60)
|
||||
payload = decode_token(token)
|
||||
now = int(time.time())
|
||||
assert payload["exp"] > now
|
||||
assert payload["iat"] <= now
|
||||
79
tests/unit/test_sse_emitter.py
Normal file
79
tests/unit/test_sse_emitter.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Tests for `app.engine.sse.SseEmitter`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.sse import SseEmitter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emitter_yields_emitted_events():
|
||||
emitter = SseEmitter()
|
||||
await emitter.emit("tool_call", {"tool": "calc"})
|
||||
await emitter.emit("phase_end", {"phase": 1})
|
||||
await emitter.done({"result": "ok"})
|
||||
|
||||
events = []
|
||||
async for evt in emitter.stream():
|
||||
events.append(evt)
|
||||
assert len(events) == 3
|
||||
assert events[0]["event"] == "tool_call"
|
||||
assert events[1]["event"] == "phase_end"
|
||||
assert events[2]["event"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emitter_event_ids_increment():
|
||||
emitter = SseEmitter()
|
||||
await emitter.emit("a", {})
|
||||
await emitter.emit("b", {})
|
||||
events = []
|
||||
async for evt in emitter.stream():
|
||||
events.append(evt)
|
||||
# IDs are evt_1, evt_2 (sentinel None doesn't yield an event)
|
||||
assert events[0]["id"] == "evt_1"
|
||||
assert events[1]["id"] == "evt_2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emitter_error_closes_stream():
|
||||
emitter = SseEmitter()
|
||||
await emitter.error("test_error", "something went wrong", details={"k": "v"})
|
||||
events = []
|
||||
async for evt in emitter.stream():
|
||||
events.append(evt)
|
||||
assert len(events) == 1
|
||||
assert events[0]["event"] == "error"
|
||||
import json
|
||||
payload = json.loads(events[0]["data"])
|
||||
assert payload["code"] == "test_error"
|
||||
assert payload["details"] == {"k": "v"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emitter_done_with_no_result():
|
||||
emitter = SseEmitter()
|
||||
await emitter.done()
|
||||
events = []
|
||||
async for evt in emitter.stream():
|
||||
events.append(evt)
|
||||
assert len(events) == 1
|
||||
assert events[0]["event"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emitter_serializes_non_serializable_data():
|
||||
emitter = SseEmitter()
|
||||
# A set is not JSON-serializable
|
||||
await emitter.emit("test", {"data": {1, 2, 3}})
|
||||
await emitter.done()
|
||||
events = []
|
||||
async for evt in emitter.stream():
|
||||
events.append(evt)
|
||||
# The first event should still have valid JSON (with serialization fallback)
|
||||
import json
|
||||
payload = json.loads(events[0]["data"])
|
||||
assert "error" in payload or "data" in payload
|
||||
166
tests/unit/test_state_validator.py
Normal file
166
tests/unit/test_state_validator.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Tests for `app.core.state_validator`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.state_validator import apply_patch, validate_state, validate_world
|
||||
|
||||
|
||||
def _character_schema():
|
||||
return [
|
||||
{
|
||||
"name": "player",
|
||||
"type": "object",
|
||||
"required": True,
|
||||
"properties": [
|
||||
{"name": "name", "type": "string", "required": True},
|
||||
{
|
||||
"name": "stats",
|
||||
"type": "object",
|
||||
"required": True,
|
||||
"properties": [
|
||||
{"name": "health", "type": "integer", "required": True, "min": 0, "max": 100},
|
||||
{"name": "mana", "type": "integer", "required": False, "min": 0, "max": 100},
|
||||
],
|
||||
},
|
||||
{"name": "inventory", "type": "array", "required": False},
|
||||
],
|
||||
},
|
||||
{"name": "current_location", "type": "string", "required": True},
|
||||
{
|
||||
"name": "plot_rails",
|
||||
"type": "object",
|
||||
"required": True,
|
||||
"properties": [
|
||||
{"name": "hooks", "type": "array", "required": True},
|
||||
{"name": "current_goals", "type": "array", "required": True},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_validate_state_ok():
|
||||
state = {
|
||||
"player": {
|
||||
"name": "Eric",
|
||||
"stats": {"health": 100, "mana": 10},
|
||||
"inventory": [],
|
||||
},
|
||||
"current_location": "Tavern",
|
||||
"plot_rails": {"hooks": [], "current_goals": ["survive"]},
|
||||
}
|
||||
ok, errors = validate_state(state, _character_schema())
|
||||
assert ok, errors
|
||||
|
||||
|
||||
def test_validate_state_missing_required():
|
||||
state = {"player": {}, "current_location": "Tavern"}
|
||||
ok, errors = validate_state(state, _character_schema())
|
||||
assert not ok
|
||||
assert any("player" in e for e in errors) or any("plot_rails" in e for e in errors)
|
||||
|
||||
|
||||
def test_validate_state_wrong_type():
|
||||
state = {
|
||||
"player": {"name": "Eric", "stats": {"health": "not a number"}},
|
||||
"current_location": "Tavern",
|
||||
"plot_rails": {"hooks": [], "current_goals": []},
|
||||
}
|
||||
ok, errors = validate_state(state, _character_schema())
|
||||
assert not ok
|
||||
assert any("health" in e for e in errors)
|
||||
|
||||
|
||||
def test_validate_state_range_violation():
|
||||
state = {
|
||||
"player": {"name": "Eric", "stats": {"health": 200, "mana": 10}},
|
||||
"current_location": "Tavern",
|
||||
"plot_rails": {"hooks": [], "current_goals": []},
|
||||
}
|
||||
ok, errors = validate_state(state, _character_schema())
|
||||
assert not ok
|
||||
assert any("health" in e for e in errors)
|
||||
|
||||
|
||||
def test_apply_patch_set_value():
|
||||
state = {"player": {"name": "Eric"}}
|
||||
new, errors = apply_patch(state, {"player.name": "Erik"})
|
||||
assert not errors
|
||||
assert new["player"]["name"] == "Erik"
|
||||
|
||||
|
||||
def test_apply_patch_inc():
|
||||
state = {"player": {"stats": {"health": 50}}}
|
||||
new, errors = apply_patch(state, {"player.stats.health": {"op": "inc", "by": -10}})
|
||||
assert not errors
|
||||
assert new["player"]["stats"]["health"] == 40
|
||||
|
||||
|
||||
def test_apply_patch_dec():
|
||||
state = {"player": {"stats": {"mana": 30}}}
|
||||
new, errors = apply_patch(state, {"player.stats.mana": {"op": "dec", "by": 5}})
|
||||
assert not errors
|
||||
assert new["player"]["stats"]["mana"] == 25
|
||||
|
||||
|
||||
def test_apply_patch_append_to_list():
|
||||
state = {"player": {"inventory": []}}
|
||||
new, errors = apply_patch(
|
||||
state,
|
||||
{"player.inventory": {"op": "append", "value": {"item_id": "sword_01", "qty": 1}}},
|
||||
)
|
||||
assert not errors
|
||||
assert new["player"]["inventory"] == [{"item_id": "sword_01", "qty": 1}]
|
||||
|
||||
|
||||
def test_apply_patch_remove_key():
|
||||
state = {"player": {"name": "Eric", "backstory": "mysterious"}}
|
||||
new, errors = apply_patch(state, {"player.backstory": {"op": "remove"}})
|
||||
assert not errors
|
||||
assert "backstory" not in new["player"]
|
||||
|
||||
|
||||
def test_apply_patch_creates_nested_path():
|
||||
state = {"player": {}}
|
||||
new, errors = apply_patch(state, {"player.stats": {"health": 100, "mana": 10}})
|
||||
assert not errors
|
||||
assert new["player"]["stats"]["health"] == 100
|
||||
|
||||
|
||||
def test_apply_patch_does_not_mutate_input():
|
||||
state = {"player": {"stats": {"health": 100}}}
|
||||
_ = apply_patch(state, {"player.stats.health": {"op": "inc", "by": -10}})
|
||||
# Original state unchanged
|
||||
assert state["player"]["stats"]["health"] == 100
|
||||
|
||||
|
||||
def test_validate_world_ok():
|
||||
world = {
|
||||
"name": "Test world",
|
||||
"language": "en",
|
||||
"schemas": [{"type": "character", "properties": []}],
|
||||
"environment_schema": _character_schema(),
|
||||
"environment": {
|
||||
"player": {"name": "Eric", "stats": {"health": 100, "mana": 10}, "inventory": []},
|
||||
"current_location": "Tavern",
|
||||
"plot_rails": {"hooks": [], "current_goals": [], "completed_goals": []},
|
||||
},
|
||||
"plot_rails": {"hooks": [], "current_goals": [], "completed_goals": []},
|
||||
"current_time": "day_1_hour_8",
|
||||
}
|
||||
ok, errors = validate_world(world)
|
||||
assert ok, errors
|
||||
|
||||
|
||||
def test_validate_world_invalid_time():
|
||||
world = {
|
||||
"name": "X",
|
||||
"language": "en",
|
||||
"schemas": [],
|
||||
"environment_schema": [],
|
||||
"environment": {},
|
||||
"plot_rails": {"hooks": [], "current_goals": [], "completed_goals": []},
|
||||
"current_time": "invalid_time",
|
||||
}
|
||||
ok, errors = validate_world(world)
|
||||
assert not ok
|
||||
assert any("time" in e.lower() for e in errors)
|
||||
58
tests/unit/test_time_utils.py
Normal file
58
tests/unit/test_time_utils.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Tests for `app.core.time_utils`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.time_utils import GameTime, advance_time, parse_delta, time_le
|
||||
|
||||
|
||||
def test_parse_basic_time():
|
||||
gt = GameTime.parse("day_1_hour_8")
|
||||
assert gt.year == 1 and gt.day == 1 and gt.hour == 8 and gt.minute == 0
|
||||
|
||||
|
||||
def test_parse_with_minutes_and_year():
|
||||
gt = GameTime.parse("year_3_day_5_hour_14_min_30")
|
||||
assert (gt.year, gt.day, gt.hour, gt.minute) == (3, 5, 14, 30)
|
||||
|
||||
|
||||
def test_parse_round_trip():
|
||||
s = "year_2_day_10_hour_18_min_45"
|
||||
gt = GameTime.parse(s)
|
||||
assert gt.to_string() == s
|
||||
|
||||
|
||||
def test_parse_invalid_raises():
|
||||
with pytest.raises(ValueError):
|
||||
GameTime.parse("invalid")
|
||||
with pytest.raises(ValueError):
|
||||
GameTime.parse("day_0_hour_8") # day must be >= 1
|
||||
|
||||
|
||||
def test_parse_delta_basic():
|
||||
assert parse_delta("hours_2_min_30") == (0, 0, 2, 30)
|
||||
assert parse_delta("days_1") == (0, 1, 0, 0)
|
||||
assert parse_delta("min_15") == (0, 0, 0, 15)
|
||||
assert parse_delta("year_2_days_3_hours_4_min_5") == (2, 3, 4, 5)
|
||||
|
||||
|
||||
def test_advance_time_simple():
|
||||
assert advance_time("day_1_hour_8", "hours_2") == "day_1_hour_10"
|
||||
assert advance_time("day_1_hour_8", "min_30") == "day_1_hour_8_min_30"
|
||||
assert advance_time("day_1_hour_23", "hours_2") == "day_2_hour_1"
|
||||
assert advance_time("day_1_hour_8", "days_3_hours_4") == "day_4_hour_12"
|
||||
|
||||
|
||||
def test_advance_time_with_year():
|
||||
# 365 days in a year, so adding 365 days = next year
|
||||
s = advance_time("day_1_hour_0", "days_365")
|
||||
gt = GameTime.parse(s)
|
||||
assert gt.year == 2
|
||||
|
||||
|
||||
def test_time_le():
|
||||
assert time_le("day_1_hour_8", "day_1_hour_9") is True
|
||||
assert time_le("day_1_hour_9", "day_1_hour_8") is False
|
||||
assert time_le("day_1_hour_8", "day_1_hour_8") is True
|
||||
assert time_le("year_1_day_5_hour_12", "year_2_day_1_hour_0") is True
|
||||
114
tests/unit/test_tool_registry.py
Normal file
114
tests/unit/test_tool_registry.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Tests for `app.engine.tools.base.ToolRegistry` and a few game tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.tools.base import Tool, ToolContext, ToolRegistry, ToolResult
|
||||
|
||||
|
||||
class _StubTool(Tool):
|
||||
name = "stub"
|
||||
category = "game"
|
||||
stages = {"test_stage"}
|
||||
description = "Stub tool for tests"
|
||||
parameters_schema = {"type": "object", "properties": {}}
|
||||
|
||||
def __init__(self, return_ok: bool = True):
|
||||
self.return_ok = return_ok
|
||||
self.calls = []
|
||||
|
||||
async def execute(self, arguments, ctx):
|
||||
self.calls.append(arguments)
|
||||
if self.return_ok:
|
||||
return ToolResult(ok=True, data=arguments, message="ok")
|
||||
return ToolResult(ok=False, error_code="stub_error", error_message="intentional")
|
||||
|
||||
|
||||
def test_registry_register_and_get():
|
||||
reg = ToolRegistry()
|
||||
t = _StubTool()
|
||||
reg.register(t)
|
||||
assert reg.get("stub") is t
|
||||
|
||||
|
||||
def test_registry_duplicate_register_raises():
|
||||
reg = ToolRegistry()
|
||||
reg.register(_StubTool())
|
||||
with pytest.raises(ValueError):
|
||||
reg.register(_StubTool())
|
||||
|
||||
|
||||
def test_registry_list_for_stage_filters():
|
||||
reg = ToolRegistry()
|
||||
t1 = _StubTool()
|
||||
t1.stages = {"a", "b"}
|
||||
t2 = _StubTool()
|
||||
t2.name = "stub2"
|
||||
t2.stages = {"b", "c"}
|
||||
reg.register(t1)
|
||||
reg.register(t2)
|
||||
a_tools = reg.list_for_stage("a")
|
||||
assert a_tools == [t1]
|
||||
b_tools = reg.list_for_stage("b")
|
||||
assert set(b_tools) == {t1, t2}
|
||||
c_tools = reg.list_for_stage("c")
|
||||
assert c_tools == [t2]
|
||||
|
||||
|
||||
def test_registry_to_openai_format():
|
||||
reg = ToolRegistry()
|
||||
reg.register(_StubTool())
|
||||
fmt = reg.to_openai_format("test_stage")
|
||||
assert len(fmt) == 1
|
||||
assert fmt[0]["type"] == "function"
|
||||
assert fmt[0]["function"]["name"] == "stub"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_execute_unknown_tool():
|
||||
reg = ToolRegistry()
|
||||
ctx = ToolContext(db=None, world=None)
|
||||
result = await reg.execute("nonexistent", {}, ctx)
|
||||
assert not result.ok
|
||||
assert result.error_code == "unknown_tool"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_execute_dispatches():
|
||||
reg = ToolRegistry()
|
||||
t = _StubTool()
|
||||
reg.register(t)
|
||||
ctx = ToolContext(db=None, world=None)
|
||||
result = await reg.execute("stub", {"x": 1}, ctx)
|
||||
assert result.ok
|
||||
assert t.calls == [{"x": 1}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_execute_catches_exceptions():
|
||||
class _BoomTool(Tool):
|
||||
name = "boom"
|
||||
stages = {"*"}
|
||||
async def execute(self, arguments, ctx):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
reg = ToolRegistry()
|
||||
reg.register(_BoomTool())
|
||||
ctx = ToolContext(db=None, world=None)
|
||||
result = await reg.execute("boom", {}, ctx)
|
||||
assert not result.ok
|
||||
assert result.error_code == "tool_exception"
|
||||
|
||||
|
||||
def test_tool_result_to_dict_ok():
|
||||
r = ToolResult(ok=True, data={"a": 1}, message="hello")
|
||||
assert r.to_dict() == {"ok": True, "data": {"a": 1}, "message": "hello"}
|
||||
|
||||
|
||||
def test_tool_result_to_dict_error():
|
||||
r = ToolResult(ok=False, error_code="x", error_message="boom")
|
||||
d = r.to_dict()
|
||||
assert d["ok"] is False
|
||||
assert d["error"]["code"] == "x"
|
||||
assert d["error"]["message"] == "boom"
|
||||
105
tests/unit/test_tools_calc_random.py
Normal file
105
tests/unit/test_tools_calc_random.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Tests for the CalcTool and RandomChoiceTool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.tools.base import ToolContext
|
||||
from app.engine.tools.game import CalcTool, RandomChoiceTool
|
||||
|
||||
|
||||
class _FakeWorld:
|
||||
id = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calc_simple_arithmetic():
|
||||
tool = CalcTool()
|
||||
ctx = ToolContext(db=None, world=_FakeWorld())
|
||||
r = await tool.execute({"expression": "2 + 3 * 4"}, ctx)
|
||||
assert r.ok
|
||||
assert r.data["result"] == 14
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calc_with_variables():
|
||||
tool = CalcTool()
|
||||
ctx = ToolContext(db=None, world=_FakeWorld())
|
||||
r = await tool.execute({
|
||||
"expression": "max(1, player_attack - enemy_armor)",
|
||||
"variables": {"player_attack": 10, "enemy_armor": 7},
|
||||
}, ctx)
|
||||
assert r.ok
|
||||
assert r.data["result"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calc_dice_notation():
|
||||
tool = CalcTool()
|
||||
ctx = ToolContext(db=None, world=_FakeWorld())
|
||||
r = await tool.execute({"expression": "1d6"}, ctx)
|
||||
assert r.ok
|
||||
assert 1 <= r.data["result"] <= 6
|
||||
assert len(r.data["rolls"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calc_dice_with_modifier():
|
||||
tool = CalcTool()
|
||||
ctx = ToolContext(db=None, world=_FakeWorld())
|
||||
r = await tool.execute({"expression": "2d6+3"}, ctx)
|
||||
assert r.ok
|
||||
assert 5 <= r.data["result"] <= 15
|
||||
assert len(r.data["rolls"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calc_rejects_disallowed_chars():
|
||||
tool = CalcTool()
|
||||
ctx = ToolContext(db=None, world=_FakeWorld())
|
||||
r = await tool.execute({"expression": "__import__('os')"}, ctx)
|
||||
assert not r.ok
|
||||
assert r.error_code == "validation_error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calc_division_by_zero_returns_error():
|
||||
tool = CalcTool()
|
||||
ctx = ToolContext(db=None, world=_FakeWorld())
|
||||
r = await tool.execute({"expression": "1/0"}, ctx)
|
||||
assert not r.ok
|
||||
assert r.error_code == "evaluation_error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_random_choice_deterministic_with_same_step():
|
||||
"""Same world_id + step_id → same choice."""
|
||||
tool = RandomChoiceTool()
|
||||
ctx1 = ToolContext(db=None, world=_FakeWorld(), step_id="00000000-0000-0000-0000-000000000010")
|
||||
ctx2 = ToolContext(db=None, world=_FakeWorld(), step_id="00000000-0000-0000-0000-000000000010")
|
||||
r1 = await tool.execute({"options": ["a", "b", "c"]}, ctx1)
|
||||
r2 = await tool.execute({"options": ["a", "b", "c"]}, ctx2)
|
||||
assert r1.ok and r2.ok
|
||||
assert r1.data["choice"] == r2.data["choice"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_random_choice_weights():
|
||||
"""Weighted choice should always pick the heavy option when others have weight 0."""
|
||||
tool = RandomChoiceTool()
|
||||
ctx = ToolContext(db=None, world=_FakeWorld(), step_id="00000000-0000-0000-0000-000000000020")
|
||||
for _ in range(10):
|
||||
r = await tool.execute({
|
||||
"options": ["always", "never"],
|
||||
"weights": [1, 0],
|
||||
}, ctx)
|
||||
assert r.ok
|
||||
assert r.data["choice"] == "always"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_random_choice_requires_two_options():
|
||||
tool = RandomChoiceTool()
|
||||
ctx = ToolContext(db=None, world=_FakeWorld())
|
||||
r = await tool.execute({"options": ["only"]}, ctx)
|
||||
assert not r.ok
|
||||
Reference in New Issue
Block a user