This commit is contained in:
Mikan
2026-06-20 19:13:05 +03:00
parent 32575e217e
commit 8514c63ec6
193 changed files with 22105 additions and 11660 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Test package for AI-RPG backend."""

25
tests/conftest.py Normal file
View File

@@ -0,0 +1,25 @@
"""Pytest config + fixtures."""
from __future__ import annotations
import os
# Use test env vars before importing anything app-related
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://airpg:airpg@localhost:5432/airpg_test")
os.environ.setdefault("QDRANT_URL", "http://localhost:6333")
os.environ.setdefault("LLM_API_URL", "") # forces MockLlmClient
os.environ.setdefault("SECRET_KEY", "test-secret-key-32-bytes-long-required")
os.environ.setdefault("DEBUG", "true")
import pytest
def pytest_collection_modifyitems(config, items):
"""Auto-mark async tests with pytest.mark.asyncio."""
for item in items:
if "asyncio" in item.keywords:
continue
# If the test function is a coroutine function, auto-add the asyncio marker
import inspect
if inspect.iscoroutinefunction(item.function):
item.add_marker(pytest.mark.asyncio)

View File

@@ -0,0 +1 @@
"""Integration tests package."""

1
tests/unit/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Unit tests package."""

View 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)

View 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"

View 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}"

View 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

View 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

View 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)

View 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

View 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"

View 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