artifacts-dashboard/backend/tests/test_crafting_strategy.py
Paweł Orzech 75313b83c0
Add multi-user workflows/pipelines and error tracking
Add multi-user automation features and per-user error tracking.

- Database migrations: add workflow_configs/workflow_runs (004), app_errors (005), pipeline_configs/pipeline_runs (006), and add user_token_hash to app_errors (007).
- Backend: introduce per-request token handling (X-API-Token) via app.api.deps and update many API routes (auth, automations, bank, characters, dashboard, events, exchange, logs) to use user-scoped Artifacts client and character scoping. Auth endpoints no longer store tokens server-side (validate-only); clear is a no-op on server.
- New Errors API and services: endpoint to list, filter, resolve, and report errors scoped to the requesting user; add error models, schemas, middleware/error handler and error_service for recording/hashing tokens.
- Pipelines & Workflows: add API routers, models, schemas and engine modules (pipeline/worker/coordinator, workflow runner/conditions) and action_executor updates to support workflow/pipeline execution.
- Logs: logs endpoint now prefers fetching recent action logs from the game API (with fallback to local DB), supports paging and filtering, and scopes results to the user.
- Frontend: add pipeline/workflow builders, lists, progress components and hooks (use-errors, use-pipelines, use-workflows), sentry client config, and updates to API client/constants/types.
- Misc: add middleware error handler, various engine strategy tweaks, tests adjusted.

Overall this change enables per-user API tokens, scopes DB queries to each user, introduces pipelines/workflows runtime support, and centralizes application error tracking.
2026-03-01 23:02:34 +01:00

328 lines
12 KiB
Python

"""Tests for the CraftingStrategy state machine."""
import pytest
from app.engine.strategies.base import ActionType
from app.engine.strategies.crafting import CraftingStrategy
from app.schemas.game import CraftItem, CraftSchema, EffectSchema, InventorySlot, ItemSchema
def _make_craftable_item(
code: str = "iron_sword",
skill: str = "weaponcrafting",
level: int = 5,
materials: list[tuple[str, int]] | None = None,
) -> ItemSchema:
"""Helper to build an ItemSchema with a crafting recipe."""
if materials is None:
materials = [("iron_ore", 5), ("wood", 2)]
return ItemSchema(
name=code.replace("_", " ").title(),
code=code,
level=level,
type="weapon",
craft=CraftSchema(
skill=skill,
level=level,
items=[CraftItem(code=c, quantity=q) for c, q in materials],
),
)
class TestCraftingStrategyInitialization:
"""Tests for CraftingStrategy creation and recipe resolution."""
def test_initial_state(self, pathfinder_with_maps):
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
assert strategy.get_state() == "check_materials"
def test_recipe_resolved_from_items_data(self, pathfinder_with_maps):
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
item = _make_craftable_item()
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
assert strategy._recipe_resolved is True
assert len(strategy._recipe) == 2
assert strategy._craft_skill == "weaponcrafting"
def test_recipe_not_resolved_without_data(self, pathfinder_with_maps):
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
assert strategy._recipe_resolved is False
def test_set_items_data_resolves_recipe(self, pathfinder_with_maps):
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
item = _make_craftable_item()
strategy.set_items_data([item])
assert strategy._recipe_resolved is True
def test_set_items_data_no_double_resolve(self, pathfinder_with_maps):
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
item = _make_craftable_item()
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
# Calling again should not re-resolve
strategy._craft_skill = "overwritten"
strategy.set_items_data([item])
assert strategy._craft_skill == "overwritten"
class TestCraftingStrategyIdleWithoutRecipe:
"""Tests for behavior when recipe is not resolved."""
@pytest.mark.asyncio
async def test_idle_when_recipe_not_resolved(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
char = make_character(x=0, y=0)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.IDLE
class TestCraftingStrategyMaterials:
"""Tests for material checking and withdrawal."""
@pytest.mark.asyncio
async def test_move_to_bank_when_materials_missing(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
char = make_character(x=0, y=0, inventory=[])
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.MOVE
assert plan.params == {"x": 10, "y": 0}
@pytest.mark.asyncio
async def test_withdraw_materials_at_bank(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
char = make_character(x=10, y=0, inventory=[])
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.WITHDRAW_ITEM
assert plan.params["code"] == "iron_ore"
assert plan.params["quantity"] == 3
@pytest.mark.asyncio
async def test_partial_materials_withdraw_remaining(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 5)])
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
# Character already has 2 iron_ore
char = make_character(
x=10, y=0,
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=2)],
)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.WITHDRAW_ITEM
assert plan.params["code"] == "iron_ore"
assert plan.params["quantity"] == 3 # Need 5, have 2, withdraw 3
class TestCraftingStrategyCrafting:
"""Tests for the crafting execution flow."""
@pytest.mark.asyncio
async def test_move_to_workshop_with_all_materials(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
char = make_character(
x=0, y=0,
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=3)],
)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.MOVE
assert plan.params == {"x": 5, "y": 5}
@pytest.mark.asyncio
async def test_craft_when_at_workshop_with_materials(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
char = make_character(
x=5, y=5,
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=3)],
)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.CRAFT
assert plan.params["code"] == "iron_sword"
@pytest.mark.asyncio
async def test_complete_after_crafting_quantity(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy(
{"item_code": "iron_sword", "quantity": 1},
pf,
items_data=[item],
)
# Simulate having crafted enough
strategy._crafted_count = 1
char = make_character(x=5, y=5)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.COMPLETE
class TestCraftingStrategyRecycle:
"""Tests for recycle_excess behavior."""
@pytest.mark.asyncio
async def test_recycle_after_craft(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy(
{"item_code": "iron_sword", "quantity": 10, "recycle_excess": True},
pf,
items_data=[item],
)
# Simulate: at workshop, just crafted, now checking result
strategy._state = strategy._state.__class__("check_result")
char = make_character(
x=5, y=5,
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=1)],
)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.RECYCLE
assert plan.params["code"] == "iron_sword"
class TestCraftingStrategyDeposit:
"""Tests for deposit behavior after crafting."""
@pytest.mark.asyncio
async def test_deposit_after_completing_all_crafts(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy(
{"item_code": "iron_sword", "quantity": 1},
pf,
items_data=[item],
)
# Simulate: just crafted the last item
strategy._state = strategy._state.__class__("check_result")
strategy._crafted_count = 0 # Will be incremented in handler
char = make_character(
x=5, y=5,
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=1)],
)
plan = await strategy.next_action(char)
# After crafting 1/1, should move to bank to deposit
assert plan.action_type == ActionType.MOVE
assert plan.params == {"x": 10, "y": 0}
@pytest.mark.asyncio
async def test_deposit_items_at_bank(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy(
{"item_code": "iron_sword", "quantity": 5}, # quantity > crafted
pf,
items_data=[item],
)
strategy._state = strategy._state.__class__("deposit")
strategy._crafted_count = 2 # Still more to craft
char = make_character(
x=10, y=0,
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=2)],
)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.DEPOSIT_ITEM
assert plan.params["code"] == "iron_sword"
@pytest.mark.asyncio
async def test_complete_after_all_deposited(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy(
{"item_code": "iron_sword", "quantity": 1},
pf,
items_data=[item],
)
strategy._state = strategy._state.__class__("deposit")
strategy._crafted_count = 1 # Already crafted target quantity
char = make_character(
x=10, y=0,
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=1)],
)
plan = await strategy.next_action(char)
# With crafted_count >= quantity, the top-level check returns COMPLETE
assert plan.action_type == ActionType.COMPLETE
class TestCraftingStrategyNoLocations:
"""Tests for missing map tiles."""
@pytest.mark.asyncio
async def test_idle_when_no_bank(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
char = make_character(x=0, y=0, inventory=[])
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.IDLE
@pytest.mark.asyncio
async def test_idle_when_no_workshop(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
char = make_character(
x=0, y=0,
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=3)],
)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.IDLE