Skip to content

Commit 4baef5a

Browse files
committed
added test cases for configuration and some commands
1 parent 6381808 commit 4baef5a

File tree

5 files changed

+381
-7
lines changed

5 files changed

+381
-7
lines changed

.github/workflows/ci.yml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: CI
22

33
on:
44
push:
5-
branches: [ main ]
5+
branches: [ main, FeatDeathMatch ]
66
pull_request:
77
branches: [ main ]
88

@@ -14,15 +14,16 @@ jobs:
1414
- name: Set up Python
1515
uses: actions/setup-python@v4
1616
with:
17-
python-version: '3.11'
17+
python-version: '3.12'
1818
- name: Install dependencies
1919
run: |
20-
python -m pip install --upgrade pip
21-
pip install -r requirements.txt
20+
python -m venv .venv
21+
.venv/bin/python -m pip install --upgrade pip
22+
.venv/bin/python -m pip install -r requirements.txt
2223
- name: Run tests
2324
run: |
24-
python -m unittest discover -v
25+
.venv/bin/python -m pytest -q
2526
- name: Lint (optional)
2627
run: |
27-
pip install flake8
28-
flake8 --max-line-length=120
28+
.venv/bin/python -m pip install flake8
29+
.venv/bin/python -m flake8 --max-line-length=120

tests/conftest.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import types
2+
import sys
3+
from unittest.mock import MagicMock, AsyncMock
4+
import pytest
5+
6+
7+
# Inject top-level fake modules used by the application so tests can import command modules
8+
def _inject_fake_modules():
9+
# Fake config.settings
10+
fake_settings = types.ModuleType('config.settings')
11+
fake_settings.HEADERS = {"User-Agent": "test-agent"}
12+
fake_settings.CACHE_TTL = 30
13+
sys.modules['config.settings'] = fake_settings
14+
15+
# yaml (used by logger)
16+
sys.modules['yaml'] = types.ModuleType('yaml')
17+
18+
# Minimal fake discord module and subpackages
19+
discord_mod = types.ModuleType('discord')
20+
discord_ext = types.ModuleType('discord.ext')
21+
discord_ext_commands = types.ModuleType('discord.ext.commands')
22+
discord_app = types.ModuleType('discord.app_commands')
23+
discord_ui = types.ModuleType('discord.ui')
24+
25+
# Provide minimal classes/attributes used by the code under test
26+
class FakeView:
27+
pass
28+
29+
class FakeEmbed:
30+
def __init__(self, title=None, color=None):
31+
self.title = title
32+
self.color = color
33+
self.fields = []
34+
def add_field(self, name, value, inline=False):
35+
self.fields.append((name, value, inline))
36+
def set_author(self, name=None, icon_url=None):
37+
self.author = (name, icon_url)
38+
39+
class FakeColor:
40+
@staticmethod
41+
def green():
42+
return 0
43+
44+
discord_ui.View = FakeView
45+
discord_mod.Embed = FakeEmbed
46+
discord_mod.Color = FakeColor
47+
48+
# Minimal SelectOption and select decorator used in the command class definition
49+
class SelectOption:
50+
def __init__(self, label=None, value=None):
51+
self.label = label
52+
self.value = value
53+
54+
def select_decorator(*d_args, **d_kwargs):
55+
def _decorator(func):
56+
return func
57+
return _decorator
58+
59+
discord_ui.SelectOption = SelectOption
60+
discord_ui.select = select_decorator
61+
# Expose SelectOption at top-level discord module as well
62+
discord_mod.SelectOption = SelectOption
63+
# Minimal Interaction class used as type hint/reference in command definitions
64+
class Interaction:
65+
pass
66+
discord_mod.Interaction = Interaction
67+
68+
# minimal nested modules
69+
discord_mod.ext = discord_ext
70+
discord_ext.commands = discord_ext_commands
71+
discord_mod.app_commands = discord_app
72+
73+
# Minimal commands.Cog base class
74+
class Cog:
75+
def __init__(self, *args, **kwargs):
76+
pass
77+
discord_ext_commands.Cog = Cog
78+
79+
# Minimal app_commands decorators and Choice class
80+
def _passthrough_decorator(*d_args, **d_kwargs):
81+
# Support both @decorator and @decorator(...)
82+
if len(d_args) == 1 and callable(d_args[0]) and not d_kwargs:
83+
return d_args[0]
84+
def _inner(func):
85+
return func
86+
return _inner
87+
88+
discord_app.command = _passthrough_decorator
89+
discord_app.describe = _passthrough_decorator
90+
discord_app.choices = lambda **k: _passthrough_decorator
91+
92+
class AppChoice:
93+
def __init__(self, name=None, value=None):
94+
self.name = name
95+
self.value = value
96+
@classmethod
97+
def __class_getitem__(cls, item):
98+
return cls
99+
100+
discord_app.Choice = AppChoice
101+
102+
# expose ui submodule on discord_mod
103+
discord_mod.ui = discord_ui
104+
105+
sys.modules['discord'] = discord_mod
106+
sys.modules['discord.ext'] = discord_ext
107+
sys.modules['discord.ext.commands'] = discord_ext_commands
108+
sys.modules['discord.app_commands'] = discord_app
109+
sys.modules['discord.ui'] = discord_ui
110+
111+
# Fake aiohttp and aiosqlite modules to avoid import errors
112+
aiohttp_mod = types.ModuleType('aiohttp')
113+
class FakeClientSession:
114+
def __init__(self, *a, **k):
115+
pass
116+
async def get(self, *a, **k):
117+
class Resp:
118+
async def json(self):
119+
return {}
120+
async def __aenter__(self):
121+
return self
122+
async def __aexit__(self, exc_type, exc, tb):
123+
return False
124+
return Resp()
125+
async def close(self):
126+
return
127+
aiohttp_mod.ClientSession = FakeClientSession
128+
sys.modules['aiohttp'] = aiohttp_mod
129+
sys.modules['aiosqlite'] = types.ModuleType('aiosqlite')
130+
131+
132+
_inject_fake_modules()
133+
134+
135+
136+
@pytest.fixture
137+
def sample_prices():
138+
return {
139+
"100": {"high": 10, "avgHighPrice": 9},
140+
"101": {"high": 100, "avgHighPrice": 95}
141+
}
142+
143+
144+
@pytest.fixture
145+
def sample_herbs(monkeypatch):
146+
# Provide a minimal herbs mapping and monkeypatch data.items.herbs if needed
147+
herbs = {"Guam": {"seed_id": 100, "herb_id": 101, "lowCTS": 100}}
148+
monkeypatch.setitem(sys.modules, 'data.items', types.ModuleType('data.items'))
149+
sys.modules['data.items'].herbs = herbs
150+
return herbs
151+
152+
153+
@pytest.fixture
154+
def fake_user():
155+
user = MagicMock()
156+
user.id = 12345
157+
user.mention = '@tester'
158+
user.display_name = 'Tester'
159+
# Simple object to mimic display_avatar with url attribute
160+
avatar = types.SimpleNamespace(url='http://avatar.url')
161+
user.display_avatar = avatar
162+
return user
163+
164+
165+
@pytest.fixture
166+
def fake_interaction(fake_user):
167+
inter = MagicMock()
168+
inter.user = fake_user
169+
# response.send_message and followup.send are coroutines in real discord.
170+
# Use AsyncMock so tests can await them and still use assert_called()/assert_awaited()
171+
inter.response = MagicMock()
172+
inter.response.send_message = AsyncMock()
173+
inter.response.defer = AsyncMock()
174+
inter.followup = MagicMock()
175+
inter.followup.send = AsyncMock()
176+
# allow setting .data for select callback tests
177+
inter.data = {}
178+
return inter
179+
180+
181+
@pytest.fixture
182+
def async_fetch_latest_prices(monkeypatch, sample_prices):
183+
async def _fake(session=None):
184+
return sample_prices
185+
monkeypatch.setattr('bot.commands.herb_profit.fetch_latest_prices', _fake)
186+
monkeypatch.setattr('bot.commands.herb_profit.fetch_1h_prices', _fake)
187+
return _fake
188+
189+
190+
@pytest.fixture
191+
def stub_calculate_custom_profit(monkeypatch):
192+
def _stub(prices, herbs, *args, **kwargs):
193+
return [{"Herb": "Guam", "Seed Price": 10, "Grimy Herb Price": 100, "Profit per Run": 90}]
194+
monkeypatch.setattr('bot.commands.herb_profit.calculate_custom_profit', _stub)
195+
return _stub

tests/test_calculations_core.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import pytest
2+
3+
from bot.utils import calculations
4+
5+
6+
def sample_prices():
7+
return {
8+
"100": {"high": 10, "avgHighPrice": 9},
9+
"101": {"high": 100, "avgHighPrice": 95}
10+
}
11+
12+
13+
def sample_herbs():
14+
return {
15+
"Guam": {"seed_id": 100, "herb_id": 101, "lowCTS": 100}
16+
}
17+
18+
19+
def test_calculate_custom_profit_basic():
20+
prices = sample_prices()
21+
herbs = sample_herbs()
22+
results = calculations.calculate_custom_profit(prices, herbs, farming_level=50, patches=1,
23+
weiss=False, trollheim=False, hosidius=False, fortis=False,
24+
compost='None', kandarin_diary='None', kourend=False,
25+
magic_secateurs=False, farming_cape=False, bottomless_bucket=False,
26+
attas=False, price_key='high')
27+
assert isinstance(results, list)
28+
assert len(results) == 1
29+
row = results[0]
30+
assert row['Herb'] == 'Guam'
31+
assert 'Profit per Run' in row
32+
33+
34+
def test_calculate_custom_profit_missing_prices():
35+
prices = {"100": {"high": 10, "avgHighPrice": 9}} # missing herb id 101
36+
herbs = sample_herbs()
37+
results = calculations.calculate_custom_profit(prices, herbs, farming_level=50, patches=1,
38+
weiss=False, trollheim=False, hosidius=False, fortis=False,
39+
compost='None', kandarin_diary='None', kourend=False,
40+
magic_secateurs=False, farming_cape=False, bottomless_bucket=False,
41+
attas=False, price_key='high')
42+
# Should skip herbs with missing price entries
43+
assert results == []
44+

tests/test_commands_herb.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import pytest
2+
import asyncio
3+
4+
from bot.commands.herb_profit import HerbProfit, FormatSelectView
5+
6+
7+
@pytest.mark.asyncio
8+
async def test_herb_profit_builds_view_and_prompts(async_fetch_latest_prices, fake_interaction, sample_herbs):
9+
# Create a fake bot with an http_session attribute
10+
class FakeBot:
11+
http_session = None
12+
13+
bot = FakeBot()
14+
cog = HerbProfit(bot)
15+
16+
# Prepare choices and compost/price_type simulated args
17+
class FakeChoice:
18+
def __init__(self, value):
19+
self.value = value
20+
21+
farming_level = 50
22+
patches = 1
23+
weiss = False
24+
trollheim = False
25+
hosidius = False
26+
fortis = False
27+
kandarin_diary = 'None'
28+
kourend = False
29+
magic_secateurs = False
30+
farming_cape = False
31+
bottomless_bucket = False
32+
attas = False
33+
compost = FakeChoice('None')
34+
price_type = FakeChoice('latest')
35+
36+
# Call the command
37+
await cog.herb_profit(
38+
fake_interaction,
39+
farming_level,
40+
patches,
41+
weiss,
42+
trollheim,
43+
hosidius,
44+
fortis,
45+
kandarin_diary,
46+
kourend,
47+
magic_secateurs,
48+
farming_cape,
49+
bottomless_bucket,
50+
attas,
51+
compost,
52+
price_type
53+
)
54+
55+
# The command should prompt with a view via interaction.response.send_message
56+
fake_interaction.response.send_message.assert_called()
57+
called_args, called_kwargs = fake_interaction.response.send_message.call_args
58+
assert 'view' in called_kwargs
59+
view = called_kwargs['view']
60+
assert isinstance(view, FormatSelectView)
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_formatselectview_markdown_sends_table(stub_calculate_custom_profit, fake_interaction, sample_prices, sample_herbs):
65+
# Build the view with the fake interaction and sample data
66+
view = FormatSelectView(bot=None, interaction=fake_interaction, farming_level=50, patches=1,
67+
weiss=False, trollheim=False, hosidius=False, fortis=False,
68+
kandarin_diary='None', kourend=False, magic_secateurs=False,
69+
farming_cape=False, bottomless_bucket=False, attas=False,
70+
compost='None', prices=sample_prices, price_key='high', price_type='latest')
71+
72+
# Simulate a user selecting markdown
73+
fake_interaction.data = {"values": ["markdown"]}
74+
75+
# Call the select callback
76+
await view.select_callback(fake_interaction, select=None)
77+
78+
# The followup send should be called with content containing the table header
79+
fake_interaction.followup.send.assert_called()
80+
args, kwargs = fake_interaction.followup.send.call_args
81+
# content is passed as a keyword argument by the implementation
82+
content = kwargs.get('content') if kwargs.get('content') is not None else (args[0] if args else '')
83+
# Ensure the message includes the mention and the table header
84+
assert fake_interaction.user.mention in content
85+
assert 'Herb' in content

0 commit comments

Comments
 (0)