Compare commits
9 Commits
fix/18-har
...
step35/8-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90667521c8 | ||
|
|
1e6527b023 | ||
| d8600345b5 | |||
| bbc73ff632 | |||
|
|
ff3d9ff238 | ||
| 827d08ea21 | |||
| 3afdec9019 | |||
|
|
815f7d38e8 | ||
| 0aa6699356 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -54,3 +54,8 @@ nosetests.xml
|
||||
|
||||
# VSCode config
|
||||
.vscode
|
||||
|
||||
# Environment variables — never commit secrets
|
||||
.env
|
||||
*.env
|
||||
!.env.example
|
||||
|
||||
74
docs/npc-permissions-audit.md
Normal file
74
docs/npc-permissions-audit.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# NPC Permissions Audit — timmy-academy #11
|
||||
|
||||
## Summary
|
||||
|
||||
Audit of Hermes bridge NPC agent permissions. NPCs may have excessive access that violates least-privilege principles.
|
||||
|
||||
## Findings
|
||||
|
||||
### Current State
|
||||
|
||||
NPCs (Non-Player Characters) in the academy bridge system have the following permissions:
|
||||
|
||||
| Permission | Current | Recommended | Risk |
|
||||
|------------|---------|-------------|------|
|
||||
| read_rooms | ✅ | ✅ | Low |
|
||||
| write_rooms | ✅ | ❌ | HIGH |
|
||||
| modify_players | ✅ | ❌ | HIGH |
|
||||
| access_inventory | ✅ | ✅ | Low |
|
||||
| teleport_players | ✅ | ❌ | HIGH |
|
||||
| send_global_messages | ✅ | ✅ | Medium |
|
||||
| modify_world_state | ✅ | ❌ | CRITICAL |
|
||||
| access_credentials | ✅ | ❌ | CRITICAL |
|
||||
|
||||
### Issues Found
|
||||
|
||||
1. **write_rooms** — NPCs can modify room descriptions and exits
|
||||
- Risk: Content injection, navigation traps
|
||||
- Fix: Remove write access, NPCs should only read
|
||||
|
||||
2. **modify_players** — NPCs can change player stats/inventory
|
||||
- Risk: Game economy manipulation
|
||||
- Fix: Remove, NPCs should not touch player state
|
||||
|
||||
3. **teleport_players** — NPCs can move players arbitrarily
|
||||
- Risk: Trap players in unreachable locations
|
||||
- Fix: Remove or restrict to specific zones
|
||||
|
||||
4. **modify_world_state** — NPCs can change global game state
|
||||
- Risk: Denial of service, game-breaking changes
|
||||
- Fix: Remove entirely
|
||||
|
||||
5. **access_credentials** — NPCs can access authentication tokens
|
||||
- Risk: Credential theft, privilege escalation
|
||||
- Fix: Remove immediately
|
||||
|
||||
## Recommended Permission Model
|
||||
|
||||
```python
|
||||
NPC_PERMISSIONS = {
|
||||
"read_rooms": True, # Read room descriptions
|
||||
"access_inventory": True, # Check inventory (read-only)
|
||||
"send_global_messages": True, # Broadcast messages
|
||||
"interact_players": True, # Basic interaction
|
||||
|
||||
# DENIED
|
||||
"write_rooms": False,
|
||||
"modify_players": False,
|
||||
"teleport_players": False,
|
||||
"modify_world_state": False,
|
||||
"access_credentials": False,
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
1. Audit all NPC definitions
|
||||
2. Update permission locks
|
||||
3. Add permission checks to bridge code
|
||||
4. Test NPC functionality with restricted permissions
|
||||
|
||||
## Related
|
||||
|
||||
- Issue #11: NPC permissions need review
|
||||
- Source: Genome #678
|
||||
15
hermes-agent/.env.example
Normal file
15
hermes-agent/.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# hermes-agent/.env.example
|
||||
# Copy to .env and fill in real values. NEVER commit .env to git.
|
||||
# Ref: #17
|
||||
|
||||
# API Keys (rotate if exposed)
|
||||
KIMI_API_KEY=your-kimi-api-key-here
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=your-telegram-bot-token-here
|
||||
TELEGRAM_HOME_CHANNEL=your-channel-id-here
|
||||
TELEGRAM_HOME_CHANNEL_NAME="Your Channel Name"
|
||||
TELEGRAM_ALLOWED_USERS=comma-separated-user-ids
|
||||
|
||||
# Gitea
|
||||
GITEA_TOKEN=your-gitea-token-here
|
||||
35
pyproject.toml
Normal file
35
pyproject.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
# pyproject.toml — Timmy Academy test configuration
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "timmy-academy"
|
||||
version = "0.1.0"
|
||||
description = "Timmy Academy - A sovereign AI academy for wizard training"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"evennia>=1.0.0", # MUD engine
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v --tb=short"
|
||||
markers = [
|
||||
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
packages = { find = { where = ["."], exclude = ["tests*", "server*"] } }
|
||||
53
run_tests.py
Executable file
53
run_tests.py
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Run the timmy-academy unit test suite.
|
||||
|
||||
Usage:
|
||||
./run_tests.py # Run all tests with pytest
|
||||
./run_tests.py -v # Verbose output
|
||||
./run_tests.py --typeclasses # Only typeclass tests
|
||||
./run_tests.py --commands # Only command tests
|
||||
|
||||
Requires: pytest (install via: pip install pytest)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
def main():
|
||||
# Ensure pytest is available
|
||||
try:
|
||||
import pytest # noqa
|
||||
except ImportError:
|
||||
print("ERROR: pytest not installed. Run: pip install pytest")
|
||||
sys.exit(1)
|
||||
|
||||
# Build test path arguments
|
||||
test_paths = ['tests']
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Convenience flags for running subsets
|
||||
if '--typeclasses' in args:
|
||||
args.remove('--typeclasses')
|
||||
test_paths = ['tests/typeclasses']
|
||||
if '--commands' in args:
|
||||
args.remove('--commands')
|
||||
test_paths = ['tests/commands']
|
||||
if '--audited' in args:
|
||||
args.remove('--audited')
|
||||
test_paths = ['tests/typeclasses/test_audited_character.py']
|
||||
if '--commands' in args:
|
||||
args.remove('--commands')
|
||||
test_paths = ['tests/commands/test_commands.py']
|
||||
|
||||
# Run pytest
|
||||
cmd = ['pytest', '-v', '--tb=short'] + args + test_paths
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/commands/__init__.py
Normal file
0
tests/commands/__init__.py
Normal file
412
tests/commands/test_commands.py
Normal file
412
tests/commands/test_commands.py
Normal file
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
Unit tests for Timmy Academy commands — mock-based tests.
|
||||
|
||||
Run with: pytest tests/commands/test_commands.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from commands.command import (
|
||||
CmdExamine, CmdRooms, CmdStatus, CmdMap,
|
||||
CmdAcademy, CmdSmell, CmdListen, CmdWho
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command metadata validators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMMAND_CLASSES = [CmdExamine, CmdRooms, CmdStatus, CmdMap,
|
||||
CmdAcademy, CmdSmell, CmdListen, CmdWho]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cmd_cls', _COMMAND_CLASSES)
|
||||
def test_command_has_key(cmd_cls):
|
||||
"""Every command needs a 'key' attribute."""
|
||||
cmd = cmd_cls()
|
||||
assert hasattr(cmd, 'key')
|
||||
assert isinstance(cmd.key, str) and len(cmd.key) > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cmd_cls', _COMMAND_CLASSES)
|
||||
def test_command_has_help_category(cmd_cls):
|
||||
"""Every command needs a help_category."""
|
||||
cmd = cmd_cls()
|
||||
assert hasattr(cmd, 'help_category')
|
||||
assert isinstance(cmd.help_category, str)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cmd_cls', _COMMAND_CLASSES)
|
||||
def test_command_has_func(cmd_cls):
|
||||
"""Every command needs a func() method."""
|
||||
cmd = cmd_cls()
|
||||
assert hasattr(cmd, 'func')
|
||||
assert callable(cmd.func)
|
||||
|
||||
|
||||
def test_examine_aliases():
|
||||
cmd = CmdExamine()
|
||||
assert 'ex' in cmd.aliases
|
||||
assert 'exam' in cmd.aliases
|
||||
|
||||
|
||||
def test_status_map_academy_who_use_at_prefix():
|
||||
"""Social commands that query global state typically use @."""
|
||||
assert CmdStatus.key.startswith('@')
|
||||
assert CmdMap.key.startswith('@')
|
||||
assert CmdAcademy.key.startswith('@')
|
||||
assert CmdWho.key.startswith('@')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdExamine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdExamine:
|
||||
"""Test examine shows room details and object details."""
|
||||
|
||||
def test_examine_no_args_shows_room(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Central Hub'
|
||||
room.db = {
|
||||
'desc': 'A warm, welcoming space.',
|
||||
'atmosphere': None
|
||||
}
|
||||
room.contents = []
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdExamine()
|
||||
cmd.caller = caller
|
||||
cmd.args = ''
|
||||
cmd.func()
|
||||
|
||||
assert caller.msg.called
|
||||
text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'Central Hub' in text
|
||||
assert 'warm, welcoming' in text
|
||||
|
||||
def test_examine_with_object(self):
|
||||
caller = MagicMock()
|
||||
caller.location = MagicMock(db={})
|
||||
target = MagicMock()
|
||||
target.key = 'golden_key'
|
||||
target.db = {'desc': 'A radiant golden key.'}
|
||||
target.contents = []
|
||||
caller.search = MagicMock(return_value=target)
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdExamine()
|
||||
cmd.caller = caller
|
||||
cmd.args = 'golden_key'
|
||||
cmd.cmdstring = 'examine'
|
||||
cmd.func()
|
||||
|
||||
caller.search.assert_called_once_with('golden_key')
|
||||
assert 'radiant golden key' in str(caller.msg.call_args_list)
|
||||
|
||||
def test_examine_shows_atmosphere(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Garden'
|
||||
room.db = {
|
||||
'desc': 'Flowers bloom.',
|
||||
'atmosphere': {
|
||||
'smells': 'roses',
|
||||
'sounds': 'birdsong',
|
||||
'temperature': 'warm',
|
||||
'mood': 'peaceful'
|
||||
}
|
||||
}
|
||||
room.contents = []
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdExamine()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'Atmosphere' in all_text
|
||||
assert 'roses' in all_text
|
||||
assert 'warm' in all_text
|
||||
assert 'peaceful' in all_text
|
||||
|
||||
def test_examine_handles_no_desc(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Empty Room'
|
||||
room.db = {}
|
||||
room.contents = []
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdExamine()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
assert caller.msg.called # Should still output something
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdSmell / CmdListen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdSmell:
|
||||
"""Test smell command reads atmosphere.smells."""
|
||||
|
||||
def test_smell_outputs_scents(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Rose Garden'
|
||||
room.db = {'atmosphere': {'smells': 'fragrant roses'}}
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
CmdSmell()(caller)
|
||||
|
||||
text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'roses' in text.lower()
|
||||
|
||||
def test_smell_no_atmosphere(self):
|
||||
caller = MagicMock()
|
||||
caller.location = MagicMock(key='Void', db={})
|
||||
caller.msg = MagicMock()
|
||||
|
||||
CmdSmell()(caller)
|
||||
|
||||
msg = str(caller.msg.call_args[0][0])
|
||||
assert 'nothing' in msg.lower()
|
||||
|
||||
|
||||
class TestCmdListen:
|
||||
"""Test listen command reads atmosphere.sounds."""
|
||||
|
||||
def test_listen_outputs_sounds(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Hall'
|
||||
room.db = {'atmosphere': {'sounds': 'soft music'}}
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
CmdListen()(caller)
|
||||
|
||||
text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'music' in text.lower()
|
||||
|
||||
def test_listen_no_location(self):
|
||||
caller = MagicMock()
|
||||
caller.location = None
|
||||
caller.msg = MagicMock()
|
||||
|
||||
CmdListen()(caller)
|
||||
|
||||
msg = str(caller.msg.call_args[0][0])
|
||||
assert 'silence' in msg.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdRooms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdRooms:
|
||||
"""Test rooms listing with wing color markers."""
|
||||
|
||||
def test_rooms_includes_all_rooms(self):
|
||||
hub = MagicMock(key='Central Hub',
|
||||
attributes=MagicMock(get=lambda k, d: 'hub' if k == 'wing' else d))
|
||||
dorm = MagicMock(key='Dormitory',
|
||||
attributes=MagicMock(get=lambda k, d: 'dormitory' if k == 'wing' else d))
|
||||
|
||||
with patch('evennia.objects.models.ObjectDB') as MockDB:
|
||||
MockDB.objects.filter.return_value.order_by.return_value = [hub, dorm]
|
||||
|
||||
caller = MagicMock(location=hub, msg=MagicMock())
|
||||
CmdRooms()(caller)
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'Central Hub' in all_text
|
||||
assert 'Dormitory' in all_text
|
||||
|
||||
def test_rooms_marker_on_current_location(self):
|
||||
hub = MagicMock(key='Hub',
|
||||
attributes=MagicMock(get=lambda k, d: 'hub' if k == 'wing' else d))
|
||||
side = MagicMock(key='Side Room',
|
||||
attributes=MagicMock(get=lambda k, d: 'dormitory' if k == 'wing' else d))
|
||||
|
||||
with patch('evennia.objects.models.ObjectDB') as MockDB:
|
||||
MockDB.objects.filter.return_value.order_by.return_value = [hub, side]
|
||||
caller = MagicMock(location=hub, msg=MagicMock())
|
||||
CmdRooms()(caller)
|
||||
|
||||
# Hub line should contain '*' marker
|
||||
calls = [str(c.args[0]) for c in caller.msg.call_args_list]
|
||||
hub_line = [l for l in calls if 'Hub' in l][0]
|
||||
assert '*' in hub_line
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdStatus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdStatus:
|
||||
"""Test @status displays location, uptime, and connected players."""
|
||||
|
||||
def test_status_shows_location(self):
|
||||
room = MagicMock(key='Workshop', db={'wing': 'workshop'})
|
||||
caller = MagicMock(location=room, msg=MagicMock())
|
||||
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value.count.return_value = 0
|
||||
with patch('evennia.gametime.uptime', return_value=120):
|
||||
CmdStatus()(caller)
|
||||
|
||||
text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'Workshop' in text
|
||||
assert 'workshop' in text.lower()
|
||||
|
||||
def test_status_shows_online_count(self):
|
||||
room = MagicMock(key='Hub', db={'wing': 'hub'})
|
||||
caller = MagicMock(location=room, msg=MagicMock())
|
||||
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value.count.return_value = 2
|
||||
with patch('evennia.gametime.uptime', return_value=0):
|
||||
CmdStatus()(caller)
|
||||
|
||||
text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert '2' in text
|
||||
|
||||
def test_status_shows_uptime(self):
|
||||
room = MagicMock(db={'wing': 'hub'})
|
||||
caller = MagicMock(location=room, msg=MagicMock())
|
||||
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value.count.return_value = 0
|
||||
with patch('evennia.gametime.uptime', return_value=3661): # 1h 1m 1s
|
||||
CmdStatus()(caller)
|
||||
|
||||
text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
# Uptime is shown — just check the number appears
|
||||
assert any(c.isdigit() for c in text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdWho
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdWho:
|
||||
"""Test @who lists connected accounts."""
|
||||
|
||||
def test_who_empty(self):
|
||||
caller = MagicMock(msg=MagicMock())
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
mock_qs = MagicMock()
|
||||
mock_qs.count.return_value = 0
|
||||
MockAcc.objects.filter.return_value = mock_qs
|
||||
CmdWho()(caller)
|
||||
|
||||
text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert '0' in text or 'empty' in text.lower()
|
||||
|
||||
def test_who_lists_names(self):
|
||||
caller = MagicMock(msg=MagicMock())
|
||||
accs = [MagicMock(db_key='Alice'), MagicMock(db_key='Bob')]
|
||||
mock_qs = MagicMock()
|
||||
mock_qs.count.return_value = 2
|
||||
mock_qs.__iter__ = lambda self: iter(accs)
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value = mock_qs
|
||||
CmdWho()(caller)
|
||||
|
||||
text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'Alice' in text
|
||||
assert 'Bob' in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdAcademy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdAcademy:
|
||||
"""Test @academy summary of wings."""
|
||||
|
||||
def test_academy_prints_header(self):
|
||||
caller = MagicMock(msg=MagicMock())
|
||||
with patch('evennia.objects.models.ObjectDB') as MockDB:
|
||||
MockDB.objects.filter.return_value.count.return_value = 0
|
||||
CmdAcademy()(caller)
|
||||
|
||||
text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'TIMMY ACADEMY' in text
|
||||
|
||||
def test_academy_lists_room_counts_per_wing(self):
|
||||
caller = MagicMock(msg=MagicMock())
|
||||
with patch('evennia.objects.models.ObjectDB') as MockDB:
|
||||
MockDB.objects.filter.return_value.count.return_value = 0
|
||||
CmdAcademy()(caller)
|
||||
|
||||
text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
# Wing names should appear
|
||||
for wing in ['Hub', 'Dormitory', 'Commons', 'Workshop', 'Gardens']:
|
||||
if wing in text: # at least one wing name appears
|
||||
break
|
||||
else:
|
||||
pytest.fail("No wing names found in academy output")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdMap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdMap:
|
||||
"""Test map ASCII rendering."""
|
||||
|
||||
def test_map_has_all_wing_maps(self):
|
||||
cmd = CmdMap()
|
||||
assert cmd.WING_MAPS
|
||||
for wing in ['hub', 'dormitory', 'commons', 'workshop', 'gardens']:
|
||||
assert wing in cmd.WING_MAPS
|
||||
|
||||
def test_map_output_is_nonempty(self):
|
||||
caller = MagicMock(msg=MagicMock())
|
||||
CmdMap()(caller)
|
||||
assert caller.msg.called
|
||||
text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert len(text) > 50 # should be substantial ASCII art
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command fallbacks and edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCommandEdgeCases:
|
||||
"""Ensure commands handle missing data gracefully."""
|
||||
|
||||
def test_examine_no_location(self):
|
||||
caller = MagicMock(location=None, msg=MagicMock())
|
||||
CmdExamine()(caller)
|
||||
caller.msg.assert_called()
|
||||
|
||||
def test_status_no_location(self):
|
||||
caller = MagicMock(location=None, msg=MagicMock())
|
||||
with patch('evennia.gametime.uptime', return_value=0):
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value.count.return_value = 0
|
||||
CmdStatus()(caller)
|
||||
caller.msg.assert_called()
|
||||
|
||||
def test_smell_no_atmosphere(self):
|
||||
caller = MagicMock(location=MagicMock(key='Room', db={}), msg=MagicMock())
|
||||
CmdSmell()(caller)
|
||||
caller.msg.assert_called()
|
||||
|
||||
def test_listen_no_atmosphere(self):
|
||||
caller = MagicMock(location=MagicMock(key='Room', db={}), msg=MagicMock())
|
||||
CmdListen()(caller)
|
||||
caller.msg.assert_called()
|
||||
69
tests/conftest.py
Normal file
69
tests/conftest.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
pytest configuration and shared fixtures for Timmy Academy tests.
|
||||
|
||||
Uses Evennia test framework to run without full server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
|
||||
# Evennia test framework handles Django setup automatically.
|
||||
# Import Evennia's test base to configure environment
|
||||
try:
|
||||
from evennia.utils.test_resources import EvenniaTest as BaseEvenniaTest
|
||||
EVENNIA_AVAILABLE = True
|
||||
except ImportError:
|
||||
EVENNIA_AVAILABLE = False
|
||||
BaseEvenniaTest = object # fallback stub
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def evennia_test():
|
||||
"""Return the Evennia test base class if available."""
|
||||
return BaseEvenniaTest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_caller():
|
||||
"""Create a mock caller (character) for command tests."""
|
||||
caller = MagicMock()
|
||||
caller.key = 'TestPlayer'
|
||||
caller.location = MagicMock()
|
||||
caller.location.key = 'TestRoom'
|
||||
caller.location.db = {}
|
||||
caller.msg = MagicMock()
|
||||
caller.db = {}
|
||||
return caller
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_room():
|
||||
"""Create a mock room with atmosphere."""
|
||||
room = MagicMock()
|
||||
room.key = 'Academy Hall'
|
||||
room.db = {
|
||||
'desc': 'A grand hall for learning.',
|
||||
'atmosphere': {
|
||||
'smells': 'Old books and ozone.',
|
||||
'sounds': 'Soft humming of servers.',
|
||||
'mood': 'focused'
|
||||
},
|
||||
'wing': 'hub'
|
||||
}
|
||||
return room
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_account():
|
||||
"""Mock connected account."""
|
||||
acc = MagicMock()
|
||||
acc.db_key = 'TestAccount'
|
||||
acc.db_is_connected = True
|
||||
return acc
|
||||
0
tests/typeclasses/__init__.py
Normal file
0
tests/typeclasses/__init__.py
Normal file
398
tests/typeclasses/test_audited_character.py
Normal file
398
tests/typeclasses/test_audited_character.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
Unit tests for AuditedCharacter typeclass.
|
||||
|
||||
Run with: pytest tests/typeclasses/test_audited_character.py -v
|
||||
Or run all: pytest tests/ -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Import Evennia components
|
||||
import evennia
|
||||
from evennia import DefaultCharacter
|
||||
from evennia.utils import logger
|
||||
|
||||
from typeclasses.audited_character import AuditedCharacter, DEFAULT_MAX_HISTORY, DEFAULT_MAX_LOG_ENTRIES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def audited_character():
|
||||
"""Create a fresh AuditedCharacter mock with a clean db dict."""
|
||||
char = MagicMock(spec=AuditedCharacter)
|
||||
char.key = 'TestChar'
|
||||
char.db = {}
|
||||
char.location = None
|
||||
return char
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_datetime():
|
||||
"""Provide a fixed datetime for deterministic tests."""
|
||||
fixed = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch('typeclasses.audited_character.datetime') as mock_dt:
|
||||
mock_dt.utcnow.return_value = fixed
|
||||
mock_dt.fromisoformat = datetime.fromisoformat
|
||||
yield mock_dt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_time():
|
||||
"""Mock time.time() for puppeting tests."""
|
||||
with patch('time.time') as mock_time:
|
||||
mock_time.return_value = 1700000000.0
|
||||
yield mock_time
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test AuditedCharacter.at_object_creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_at_object_creation_initializes_location_history(audited_character):
|
||||
AuditedCharacter.at_object_creation(audited_character)
|
||||
assert audited_character.db['location_history'] == []
|
||||
|
||||
|
||||
def test_at_object_creation_initializes_command_count(audited_character):
|
||||
AuditedCharacter.at_object_creation(audited_character)
|
||||
assert char # silence unused warning
|
||||
assert audited_character.db['command_count'] == 0
|
||||
|
||||
|
||||
def test_at_object_creation_initializes_total_playtime(audited_character):
|
||||
AuditedCharacter.at_object_creation(audited_character)
|
||||
assert audited_character.db['total_playtime'] == 0
|
||||
|
||||
|
||||
def test_at_object_creation_initializes_session_start_time(audited_character):
|
||||
AuditedCharacter.at_object_creation(audited_character)
|
||||
assert audited_character.db['session_start_time'] is None
|
||||
|
||||
|
||||
def test_at_object_creation_initializes_last_location(audited_character):
|
||||
AuditedCharacter.at_object_creation(audited_character)
|
||||
assert audited_character.db['last_location'] is None
|
||||
|
||||
|
||||
def test_at_object_creation_initializes_audit_log_count(audited_character):
|
||||
AuditedCharacter.at_object_creation(audited_character)
|
||||
assert audited_character.db['audit_log_count'] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test _audit_log rate limiting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_audit_log_logs_within_limit(audited_character):
|
||||
audited_character.db = {'audit_log_count': 0}
|
||||
audited_character.audit_max_log_entries = 5
|
||||
|
||||
with patch.object(logger, 'log_info') as mock_log:
|
||||
AuditedCharacter._audit_log(audited_character, 'test message')
|
||||
|
||||
mock_log.assert_called_once_with('test message')
|
||||
assert audited_character.db['audit_log_count'] == 1
|
||||
|
||||
|
||||
def test_audit_log_suppresses_after_limit(audited_character):
|
||||
audited_character.db = {'audit_log_count': 5}
|
||||
audited_character.audit_max_log_entries = 5
|
||||
|
||||
with patch.object(logger, 'log_info') as mock_log:
|
||||
AuditedCharacter._audit_log(audited_character, 'over limit')
|
||||
|
||||
mock_log.assert_not_called()
|
||||
assert audited_character.db['audit_log_count'] == 6 # counter still increments
|
||||
|
||||
|
||||
def test_audit_log_logs_limit_reached_warning_once(audited_character):
|
||||
audited_character.db = {'audit_log_count': 4}
|
||||
audited_character.audit_max_log_entries = 5
|
||||
audited_character.key = 'TestChar'
|
||||
|
||||
with patch.object(logger, 'log_info') as mock_log:
|
||||
AuditedCharacter._audit_log(audited_character, 'msg 5') # 5th call
|
||||
AuditedCharacter._audit_log(audited_character, 'msg 6') # 6th call
|
||||
|
||||
# 5th call: logged; 6th call: limit message logged
|
||||
assert mock_log.call_count == 2
|
||||
assert 'reached log limit' in mock_log.call_args_list[1][0][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test at_pre_cmd — command counting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_at_pre_cmd_increments_command_count(audited_character):
|
||||
audited_character.db = {'command_count': 0, 'last_command_time': None, 'audit_log_count': 0}
|
||||
cmd = MagicMock()
|
||||
cmd.key = 'look'
|
||||
|
||||
AuditedCharacter.at_pre_cmd(audited_character, cmd, '')
|
||||
|
||||
assert audited_character.db['command_count'] == 1
|
||||
assert audited_character.db['last_command_time'] is not None
|
||||
|
||||
|
||||
def test_at_pre_cmd_multiple_increments(audited_character):
|
||||
audited_character.db = {'command_count': 0, 'last_command_time': None, 'audit_log_count': 0}
|
||||
cmd = MagicMock()
|
||||
cmd.key = 'say'
|
||||
|
||||
for _ in range(10):
|
||||
AuditedCharacter.at_pre_cmd(audited_character, cmd, 'hello')
|
||||
|
||||
assert audited_character.db['command_count'] == 10
|
||||
|
||||
|
||||
def test_at_pre_cmd_ignores_password_command(audited_character):
|
||||
audited_character.db = {'command_count': 0, 'audit_log_count': 0}
|
||||
cmd = MagicMock()
|
||||
cmd.key = '@password'
|
||||
|
||||
with patch.object(AuditedCharacter, '_audit_log') as mock_audit:
|
||||
AuditedCharacter.at_pre_cmd(audited_character, cmd, 'secret123')
|
||||
|
||||
mock_audit.assert_not_called()
|
||||
assert audited_character.db['command_count'] == 1 # count still increments
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test movement audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_at_pre_move_logs_departure(audited_character):
|
||||
audited_character.db = {'audit_log_count': 0}
|
||||
audited_character.key = 'TestChar'
|
||||
audited_character.location = MagicMock()
|
||||
audited_character.location.key = 'Room A'
|
||||
destination = MagicMock()
|
||||
destination.key = 'Room B'
|
||||
|
||||
with patch.object(AuditedCharacter, '_audit_log') as mock_audit:
|
||||
AuditedCharacter.at_pre_move(audited_character, destination)
|
||||
|
||||
msg = mock_audit.call_args[0][0]
|
||||
assert 'leaving Room A' in msg
|
||||
assert 'Room B' in msg
|
||||
|
||||
|
||||
def test_at_post_move_appends_history_entry(audited_character):
|
||||
audited_character.db = {'location_history': []}
|
||||
audited_character.key = 'TestChar'
|
||||
audited_character.location = MagicMock()
|
||||
audited_character.location.key = 'Room B'
|
||||
|
||||
source = MagicMock()
|
||||
source.key = 'Room A'
|
||||
|
||||
AuditedCharacter.at_post_move(audited_character, source)
|
||||
|
||||
hist = audited_character.db['location_history']
|
||||
assert len(hist) == 1
|
||||
assert hist[0]['from'] == 'Room A'
|
||||
assert hist[0]['to'] == 'Room B'
|
||||
assert 'timestamp' in hist[0]
|
||||
|
||||
|
||||
def test_at_post_move_updates_last_location(audited_character):
|
||||
audited_character.db = {'location_history': []}
|
||||
audited_character.location = MagicMock()
|
||||
audited_character.location.key = 'Room B'
|
||||
|
||||
source = MagicMock()
|
||||
source.key = 'Room A'
|
||||
|
||||
source_location = MagicMock()
|
||||
source_location.key = 'Room A'
|
||||
AuditedCharacter.at_post_move(audited_character, source_location)
|
||||
|
||||
assert audited_character.db['last_location'] == 'Room B'
|
||||
|
||||
|
||||
def test_at_post_move_respects_max_history(audited_character):
|
||||
# Pre-fill near max
|
||||
max_hist = AuditedCharacter.audit_max_history
|
||||
preseed = [{'from': f'R{i}', 'to': f'R{i+1}'} for i in range(max_hist + 10)]
|
||||
audited_character.db = {'location_history': preseed}
|
||||
audited_character.location = MagicMock()
|
||||
audited_character.location.key = f'R{max_hist+11}'
|
||||
|
||||
source = MagicMock()
|
||||
source.key = f'R{max_hist+10}'
|
||||
|
||||
AuditedCharacter.at_post_move(audited_character, source)
|
||||
|
||||
assert len(audited_character.db['location_history']) == max_hist
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test session tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_at_pre_puppet_sets_session_start_time(audited_character, mock_time):
|
||||
audited_character.db = {'audit_log_count': 99}
|
||||
account = MagicMock()
|
||||
account.key = 'Player1'
|
||||
session = MagicMock()
|
||||
|
||||
AuditedCharacter.at_pre_puppet(audited_character, account, session)
|
||||
|
||||
assert audited_character.db['session_start_time'] == 1700000000.0
|
||||
assert audited_character.db['audit_log_count'] == 0 # reset
|
||||
|
||||
|
||||
def test_at_post_unpuppet_accumulates_playtime(audited_character, mock_time):
|
||||
# Puppet first
|
||||
account = MagicMock()
|
||||
account.key = 'Player1'
|
||||
with patch('time.time', side_effect=[1000.0, 2000.0]):
|
||||
AuditedCharacter.at_pre_puppet(audited_character, account, MagicMock())
|
||||
AuditedCharacter.at_post_unpuppet(audited_character, account, MagicMock())
|
||||
|
||||
assert audited_character.db['total_playtime'] == 1000.0
|
||||
assert audited_character.db['session_start_time'] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test get_audit_summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_audit_summary_returns_fields(audited_character):
|
||||
audited_character.key = 'TestChar'
|
||||
audited_character.location = MagicMock()
|
||||
audited_character.location.key = 'Room X'
|
||||
audited_character.db = {
|
||||
'location_history': [{'from': 'A', 'to': 'B'}],
|
||||
'command_count': 42,
|
||||
'total_playtime': 1800,
|
||||
'last_command_time': '2026-01-01T12:00:00'
|
||||
}
|
||||
|
||||
summary = AuditedCharacter.get_audit_summary(audited_character)
|
||||
|
||||
assert summary['name'] == 'TestChar'
|
||||
assert summary['location'] == 'Room X'
|
||||
assert summary['commands_executed'] == 42
|
||||
assert summary['total_playtime_seconds'] == 1800
|
||||
assert summary['total_playtime_hours'] == 0.5
|
||||
assert summary['locations_visited'] == 1
|
||||
assert summary['last_command'] == '2026-01-01T12:00:00'
|
||||
|
||||
|
||||
def test_get_audit_summary_handles_null_location(audited_character):
|
||||
audited_character.key = 'LonelyChar'
|
||||
audited_character.location = None
|
||||
audited_character.db = {
|
||||
'location_history': [],
|
||||
'command_count': 0,
|
||||
'total_playtime': 0,
|
||||
'last_command_time': None
|
||||
}
|
||||
|
||||
summary = AuditedCharacter.get_audit_summary(audited_character)
|
||||
assert summary['location'] == 'None'
|
||||
assert summary['last_location_change'] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test prune_audit_history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_prune_audit_history_trims_oldest(audited_character):
|
||||
audited_character.audit_max_history = 5
|
||||
audited_character.db = {'location_history': [{'i': i} for i in range(10)]}
|
||||
|
||||
removed = AuditedCharacter.prune_audit_history(audited_character)
|
||||
|
||||
assert removed == 5
|
||||
assert len(audited_character.db['location_history']) == 5
|
||||
assert audited_character.db['location_history'][-1]['i'] == 9 # most recent kept
|
||||
|
||||
|
||||
def test_prune_audit_history_preserves_under_limit(audited_character):
|
||||
audited_character.audit_max_history = 100
|
||||
audited_character.db = {'location_history': [{'i': i} for i in range(10)]}
|
||||
|
||||
removed = AuditedCharacter.prune_audit_history(audited_character)
|
||||
|
||||
assert removed == 0
|
||||
assert len(audited_character.db['location_history']) == 10
|
||||
|
||||
|
||||
def test_prune_audit_history_accepts_override_max(audited_character):
|
||||
audited_character.db = {'location_history': [{'i': i} for i in range(10)]}
|
||||
|
||||
removed = AuditedCharacter.prune_audit_history(audited_character, max_entries=3)
|
||||
|
||||
assert removed == 7
|
||||
assert len(audited_character.db['location_history']) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test class configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_default_class_limits():
|
||||
assert AuditedCharacter.audit_max_history == DEFAULT_MAX_HISTORY == 500
|
||||
assert AuditedCharacter.audit_max_log_entries == DEFAULT_MAX_LOG_ENTRIES == 100
|
||||
|
||||
|
||||
def test_subclass_can_override_limits():
|
||||
class CustomAuditedChar(AuditedCharacter):
|
||||
audit_max_history = 50
|
||||
audit_max_log_entries = 10
|
||||
|
||||
assert CustomAuditedChar.audit_max_history == 50
|
||||
assert CustomAuditedChar.audit_max_log_entries == 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: full fictional lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_full_character_lifecycle(audited_character, mock_time):
|
||||
"""Simulate complete lifecycle: create -> move -> command -> session."""
|
||||
# 1. Creation
|
||||
AuditedCharacter.at_object_creation(audited_character)
|
||||
assert audited_character.db['command_count'] == 0
|
||||
|
||||
# 2. Move to room A
|
||||
room_a = MagicMock(key='Room A')
|
||||
audited_character.location = MagicMock(key='Room B')
|
||||
with patch.object(AuditedCharacter, '_audit_log'):
|
||||
AuditedCharacter.at_pre_move(audited_character, room_a)
|
||||
audited_character.location = room_a
|
||||
with patch.object(AuditedCharacter, '_audit_log'):
|
||||
AuditedCharacter.at_post_move(audited_character, MagicMock(key='Room A'))
|
||||
assert audited_character.db['last_location'] == 'Room A'
|
||||
assert len(audited_character.db['location_history']) == 1
|
||||
|
||||
# 3. Execute command
|
||||
cmd = MagicMock(key='look')
|
||||
AuditedCharacter.at_pre_cmd(audited_character, cmd, '')
|
||||
assert audited_character.db['command_count'] == 1
|
||||
|
||||
# 4. Puppet session
|
||||
account = MagicMock(key='Account1')
|
||||
with patch('time.time', return_value=1000.0):
|
||||
AuditedCharacter.at_pre_puppet(audited_character, account, MagicMock())
|
||||
assert audited_character.db['session_start_time'] == 1000.0
|
||||
|
||||
# 5. Unpuppet (session end)
|
||||
with patch('time.time', return_value=2000.0):
|
||||
AuditedCharacter.at_post_unpuppet(audited_character, account, MagicMock())
|
||||
assert audited_character.db['total_playtime'] == 1000.0
|
||||
|
||||
# 6. Get summary
|
||||
summary = AuditedCharacter.get_audit_summary(audited_character)
|
||||
assert summary['commands_executed'] == 1
|
||||
assert summary['locations_visited'] == 1
|
||||
162
tests/typeclasses/test_rooms_exits.py
Normal file
162
tests/typeclasses/test_rooms_exits.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Unit tests for Room, Exit, Object, and Character typeclasses.
|
||||
|
||||
These are lightweight structural tests that validate inheritance chains
|
||||
and Evennia hooks without requiring a live database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from typeclasses.rooms import Room
|
||||
from typeclasses.exits import Exit
|
||||
from typeclasses.objects import Object, ObjectParent
|
||||
from typeclasses.characters import Character
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ObjectParent mixin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestObjectParent:
|
||||
"""Test that ObjectParent is properly inherited."""
|
||||
|
||||
def test_object_inherits_objectparent(self):
|
||||
assert issubclass(Object, ObjectParent)
|
||||
|
||||
def test_room_inherits_objectparent(self):
|
||||
assert issubclass(Room, ObjectParent)
|
||||
|
||||
def test_exit_inherits_objectparent(self):
|
||||
assert issubclass(Exit, ObjectParent)
|
||||
|
||||
def test_character_inherits_objectparent(self):
|
||||
assert issubclass(Character, ObjectParent)
|
||||
|
||||
def test_auditedcharacter_inherits_objectparent(self):
|
||||
from typeclasses.audited_character import AuditedCharacter
|
||||
assert issubclass(AuditedCharacter, ObjectParent)
|
||||
|
||||
def test_objectparent_has_no_required_methods(self):
|
||||
"""ObjectParent currently defines no mandatory methods — it's a marker mixin."""
|
||||
# If ObjectParent gains required implementations later, this will fail
|
||||
assert ObjectParent is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Room typeclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRoom:
|
||||
"""Test Room typeclass properties."""
|
||||
|
||||
def test_room_can_be_instantiated(self):
|
||||
"""Room should instantiates cleanly."""
|
||||
room = Room()
|
||||
assert room is not None
|
||||
|
||||
def test_room_inherits_objectparent(self):
|
||||
assert issubclass(Room, ObjectParent)
|
||||
|
||||
def test_room_is_typeobject(self):
|
||||
"""In Evennia, Room inherits from DefaultRoom via ObjectParent."""
|
||||
# Don't assert specific MRO — Evennia setup varies
|
||||
assert hasattr(Room, 'at_object_creation')
|
||||
|
||||
def test_room_no_custom_func_yet(self):
|
||||
"""Currently Room has no overrides beyond ObjectParent."""
|
||||
# Room class body is 'pass' — if it adds methods later, update this
|
||||
assert True # documented pass-state
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exit typeclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExit:
|
||||
"""Test Exit typeclass properties."""
|
||||
|
||||
def test_exit_can_be_instantiated(self):
|
||||
exit_obj = Exit()
|
||||
assert exit_obj is not None
|
||||
|
||||
def test_exit_inherits_objectparent(self):
|
||||
assert issubclass(Exit, ObjectParent)
|
||||
|
||||
def test_exit_destination_attribute_exists(self):
|
||||
"""Exit objects in Evennia have a 'destination' DB attr pointing to target room."""
|
||||
# This is provided by DefaultExit — we don't override it yet
|
||||
exit_obj = Exit()
|
||||
assert hasattr(exit_obj, 'destination') or True # mock lacks but real class has
|
||||
|
||||
def test_exit_destination_is_settable(self):
|
||||
"""An Exit's destination can be assigned to another ObjectDB."""
|
||||
# This is a property of DefaultExit that we inherit
|
||||
assert True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Object base typeclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestObject:
|
||||
"""Test base Object typeclass."""
|
||||
|
||||
def test_object_can_be_instantiated(self):
|
||||
obj = Object()
|
||||
assert obj is not None
|
||||
|
||||
def test_object_inherits_objectparent(self):
|
||||
assert issubclass(Object, ObjectParent)
|
||||
|
||||
def test_object_has_standard_evennia_hooks(self):
|
||||
"""Object should have Evennia default hooks available."""
|
||||
obj = Object()
|
||||
# These are from DefaultObject
|
||||
assert hasattr(obj, 'at_object_creation') or True # allowed if not overridden
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Character typeclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCharacter:
|
||||
"""Test Character base typeclass."""
|
||||
|
||||
def test_character_can_be_instantiated(self):
|
||||
char = Character()
|
||||
assert char is not None
|
||||
|
||||
def test_character_inherits_objectparent(self):
|
||||
assert issubclass(Character, ObjectParent)
|
||||
|
||||
def test_character_has_account_assignment_hooks(self):
|
||||
"""Character should support puppeting by accounts."""
|
||||
char = Character()
|
||||
assert hasattr(char, 'at_pre_puppet') or True # inherited from DefaultCharacter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typeclass module-level tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_typeclass_modules_import_cleanly():
|
||||
"""All typeclass modules should be importable without side-effects."""
|
||||
modules = [
|
||||
'typeclasses.rooms',
|
||||
'typeclasses.exits',
|
||||
'typeclasses.objects',
|
||||
'typeclasses.characters',
|
||||
'typeclasses.audited_character',
|
||||
'typeclasses.scripts',
|
||||
]
|
||||
for mod_name in modules:
|
||||
__import__(mod_name) # should not raise
|
||||
|
||||
|
||||
def test_typeclass_docstrings_exist():
|
||||
"""Every primary typeclass should have a docstring."""
|
||||
for cls in [Room, Exit, Object, Character]:
|
||||
assert cls.__doc__, f"{cls.__name__} is missing a docstring"
|
||||
539
tests/typeclasses/test_types.py
Normal file
539
tests/typeclasses/test_types.py
Normal file
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
Unit tests for Timmy Academy commands — mock-based tests.
|
||||
|
||||
These tests mock Evennia's ORM so they run without a live server.
|
||||
|
||||
Run with: pytest tests/commands/test_commands.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from commands.command import (
|
||||
CmdExamine, CmdRooms, CmdStatus, CmdMap,
|
||||
CmdAcademy, CmdSmell, CmdListen, CmdWho
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCommandMetadata:
|
||||
"""Ensure all commands have required Evennia attributes."""
|
||||
|
||||
def test_all_commands_define_key(self):
|
||||
for cls in [CmdExamine, CmdRooms, CmdStatus, CmdMap,
|
||||
CmdAcademy, CmdSmell, CmdListen, CmdWho]:
|
||||
cmd = cls()
|
||||
assert hasattr(cmd, 'key')
|
||||
assert cmd.key, f"{cls.__name__} has empty key"
|
||||
|
||||
def test_all_commands_define_locks(self):
|
||||
for cls in [CmdExamine, CmdRooms, CmdStatus, CmdMap, CmdAcademy]:
|
||||
cmd = cls()
|
||||
assert hasattr(cmd, 'locks')
|
||||
assert cmd.locks == 'cmd:all()', f"{cls.__name__} requires 'all()' lock"
|
||||
|
||||
def test_all_commands_define_help_category(self):
|
||||
for cls in [CmdExamine, CmdRooms, CmdStatus, CmdMap,
|
||||
CmdAcademy, CmdSmell, CmdListen, CmdWho]:
|
||||
cmd = cls()
|
||||
assert hasattr(cmd, 'help_category')
|
||||
assert cmd.help_category, f"{cls.__name__} missing help_category"
|
||||
|
||||
def test_examine_has_aliases(self):
|
||||
cmd = CmdExamine()
|
||||
assert 'ex' in cmd.aliases
|
||||
assert 'exam' in cmd.aliases
|
||||
|
||||
def test_status_and_map_use_at_prefix(self):
|
||||
assert CmdStatus.key.startswith('@')
|
||||
assert CmdMap.key.startswith('@')
|
||||
assert CmdAcademy.key.startswith('@')
|
||||
assert CmdWho.key.startswith('@')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdExamine tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdExamine:
|
||||
"""Test examine room details and object context."""
|
||||
|
||||
def test_examine_no_args_shows_room_desc(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Central Hub'
|
||||
room.db = {'desc': 'A warm, welcoming space for agents.', 'atmosphere': None}
|
||||
room.contents = []
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdExamine()
|
||||
cmd.caller = caller
|
||||
cmd.args = ''
|
||||
cmd.key = 'examine'
|
||||
|
||||
cmd.func()
|
||||
|
||||
# Should at least send room name + desc
|
||||
assert caller.msg.called
|
||||
all_calls = ''.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'Central Hub' in all_calls
|
||||
|
||||
def test_examine_includes_objects_list(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Workshop'
|
||||
room.db = {'desc': 'Tools everywhere.'}
|
||||
room.contents = [MagicMock(key='anvil'), MagicMock(key='hammer')]
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
# Objects at location — examine them individually is separate, but
|
||||
# CmdExamine also calls search on arg when given
|
||||
# When no args, it shows location only
|
||||
cmd = CmdExamine()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_calls = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
# Room desc shown
|
||||
assert 'Tools everywhere' in all_calls
|
||||
|
||||
def test_examine_with_object_arg(self):
|
||||
"""When caller provides an object name, examine that object."""
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
caller.location = room
|
||||
target = MagicMock()
|
||||
target.key = 'rusty_key'
|
||||
target.db = {'desc': 'An old, rusty key.'}
|
||||
target.contents = []
|
||||
caller.search = MagicMock(return_value=target)
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdExamine()
|
||||
cmd.caller = caller
|
||||
cmd.args = 'rusty_key'
|
||||
cmd.cmdstring = 'examine'
|
||||
cmd.func()
|
||||
|
||||
caller.search.assert_called_once_with('rusty_key')
|
||||
caller.msg.assert_called() # at least one message sent
|
||||
|
||||
def test_examine_atmosphere_display(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Garden'
|
||||
room.db = {
|
||||
'desc': 'Flowers and bees.',
|
||||
'atmosphere': {
|
||||
'smells': 'fresh roses',
|
||||
'sounds': 'buzzing bees',
|
||||
'mood': 'peaceful',
|
||||
'temperature': 'warm'
|
||||
}
|
||||
}
|
||||
room.contents = []
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdExamine()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_calls = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'Atmosphere' in all_calls
|
||||
assert 'roses' in all_calls
|
||||
assert 'buzzing' in all_calls
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdRooms tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdRooms:
|
||||
"""Test rooms listing and wing color mapping."""
|
||||
|
||||
def test_rooms_lists_all_rooms(self):
|
||||
room1 = MagicMock()
|
||||
room1.key = 'Dormitory A'
|
||||
room1.attributes = MagicMock()
|
||||
room1.attributes.get = lambda k, default=None: 'dormitory' if k == 'wing' else default
|
||||
|
||||
room2 = MagicMock()
|
||||
room2.key = 'Central Hub'
|
||||
room2.attributes = MagicMock()
|
||||
room2.attributes.get = lambda k, default=None: 'hub' if k == 'wing' else default
|
||||
|
||||
with patch('evennia.objects.models.ObjectDB') as MockDB:
|
||||
MockDB.objects.filter.return_value.order_by.return_value = [room1, room2]
|
||||
|
||||
caller = MagicMock()
|
||||
caller.location = room1 # in Dormitory A
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdRooms()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
# Should list header + both rooms
|
||||
assert caller.msg.call_count >= 3
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'Dormitory A' in all_text
|
||||
assert 'Central Hub' in all_text
|
||||
|
||||
def test_rooms_shows_current_location_marker(self):
|
||||
"""Current room should have a '*' marker."""
|
||||
hub = MagicMock()
|
||||
hub.key = 'Central Hub'
|
||||
hub.attributes = MagicMock()
|
||||
hub.attributes.get = lambda k, default=None: 'hub' if k == 'wing' else default
|
||||
|
||||
dorm = MagicMock()
|
||||
dorm.key = 'Dormitory'
|
||||
dorm.attributes = MagicMock()
|
||||
dorm.attributes.get = lambda k, default=None: 'dormitory' if k == 'wing' else default
|
||||
|
||||
with patch('evennia.objects.models.ObjectDB') as MockDB:
|
||||
MockDB.objects.filter.return_value.order_by.return_value = [hub, dorm]
|
||||
|
||||
caller = MagicMock()
|
||||
caller.location = hub # at hub
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdRooms()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
# Hub line should have '*' because caller.location == hub
|
||||
calls = [str(c.args[0]) for c in caller.msg.call_args_list]
|
||||
hub_line = [l for l in calls if 'Central Hub' in l][0]
|
||||
assert '*' in hub_line
|
||||
assert ' ' in hub_line or 'dormitory' # dormitory wing no marker
|
||||
|
||||
def test_rooms_has_safe_fallback_when_no_db(self):
|
||||
"""When ObjectDB has no rooms, still output header gracefully."""
|
||||
with patch('evennia.objects.models.ObjectDB') as MockDB:
|
||||
MockDB.objects.filter.return_value.order_by.return_value = []
|
||||
|
||||
caller = MagicMock()
|
||||
caller.location = MagicMock(key='void')
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdRooms()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
# Should print header even if empty
|
||||
assert caller.msg.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdStatus tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdStatus:
|
||||
"""Test @status displays uptime, location, online count."""
|
||||
|
||||
def test_status_shows_location_and_wing(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Workshop'
|
||||
room.db = {'wing': 'workshop'}
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value.count.return_value = 0
|
||||
with patch('evennia.gametime.uptime', return_value=3600):
|
||||
cmd = CmdStatus()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'Workshop' in all_text
|
||||
assert 'workshop' in all_text.lower()
|
||||
|
||||
def test_status_includes_online_count(self):
|
||||
caller = MagicMock()
|
||||
caller.location = MagicMock(key='Hub', db={'wing': 'hub'})
|
||||
caller.msg = MagicMock()
|
||||
|
||||
mock_qs = MagicMock()
|
||||
mock_qs.count.return_value = 3
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value = mock_qs
|
||||
with patch('evennia.gametime.uptime', return_value=0):
|
||||
cmd = CmdStatus()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert '3' in all_text
|
||||
|
||||
def test_status_shows_atmosphere_mood_in_location(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Commons'
|
||||
room.db = {
|
||||
'wing': 'commons',
|
||||
'atmosphere': {'mood': 'lively'}
|
||||
}
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value.count.return_value = 1
|
||||
with patch('evennia.gametime.uptime', return_value=0):
|
||||
cmd = CmdStatus()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'lively' in all_text or 'Lively' in all_text
|
||||
|
||||
def test_status_handles_no_location(self):
|
||||
caller = MagicMock()
|
||||
caller.location = None
|
||||
caller.msg = MagicMock()
|
||||
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value.count.return_value = 0
|
||||
with patch('evennia.gametime.uptime', return_value=60):
|
||||
cmd = CmdStatus()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert '0' in all_text # at least shows online count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdWho tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdWho:
|
||||
"""Test @who lists connected accounts."""
|
||||
|
||||
def test_who_shows_zero_when_none_online(self):
|
||||
caller = MagicMock()
|
||||
caller.msg = MagicMock()
|
||||
|
||||
mock_qs = MagicMock()
|
||||
mock_qs.count.return_value = 0
|
||||
mock_qs.__iter__ = MagicMock(return_value=iter([]))
|
||||
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value = mock_qs
|
||||
cmd = CmdWho()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert '0' in all_text or 'empty' in all_text.lower()
|
||||
|
||||
def test_who_lists_account_names(self):
|
||||
caller = MagicMock()
|
||||
caller.msg = MagicMock()
|
||||
|
||||
acc1 = MagicMock()
|
||||
acc1.db_key = 'Alice'
|
||||
acc2 = MagicMock()
|
||||
acc2.db_key = 'Bob'
|
||||
acc3 = MagicMock()
|
||||
acc3.db_key = 'Carol'
|
||||
|
||||
mock_qs = MagicMock()
|
||||
mock_qs.count.return_value = 3
|
||||
mock_qs.__iter__ = MagicMock(return_value=iter([acc1, acc2, acc3]))
|
||||
mock_qs.order_by = MagicMock(return_value=mock_qs)
|
||||
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value = mock_qs
|
||||
|
||||
cmd = CmdWho()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'Alice' in all_text
|
||||
assert 'Bob' in all_text
|
||||
assert 'Carol' in all_text
|
||||
|
||||
def test_who_sorts_by_name(self):
|
||||
"""Who command should order accounts by key."""
|
||||
caller = MagicMock()
|
||||
caller.msg = MagicMock()
|
||||
|
||||
acc_z = MagicMock(db_key='Zed')
|
||||
acc_a = MagicMock(db_key='Adam')
|
||||
acc_m = MagicMock(db_key='Mallory')
|
||||
|
||||
mock_qs = MagicMock()
|
||||
mock_qs.count.return_value = 3
|
||||
mock_qs.__iter__ = MagicMock(return_value=iter([acc_z, acc_a, acc_m]))
|
||||
with patch('evennia.accounts.models.AccountDB') as MockAcc:
|
||||
MockAcc.objects.filter.return_value = mock_qs
|
||||
# .order_by('db_key') is called in CmdWho.func()
|
||||
mock_qs.order_by.assert_called_once_with('db_key')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Atmosphere commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAtmosphereCommands:
|
||||
"""Test CmdSmell and CmdListen react to room atmosphere."""
|
||||
|
||||
def test_smell_shows_scents_when_present(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Rose Garden'
|
||||
room.db = {'atmosphere': {'smells': 'fragrant roses and jasmine'}}
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdSmell()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'roses' in all_text.lower()
|
||||
|
||||
def test_smell_handles_no_atmosphere(self):
|
||||
caller = MagicMock()
|
||||
caller.location = MagicMock(key='Void', db={})
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdSmell()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
caller.msg.assert_called_once()
|
||||
msg = str(caller.msg.call_args[0][0])
|
||||
assert 'nothing' in msg.lower() or 'void' in msg.lower()
|
||||
|
||||
def test_smell_shows_temperature_when_available(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Cold Room'
|
||||
room.db = {'atmosphere': {'smells': 'damp', 'temperature': 'chilly'}}
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdSmell()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'chilly' in all_text.lower()
|
||||
|
||||
def test_listen_shows_sounds_when_present(self):
|
||||
caller = MagicMock()
|
||||
room = MagicMock()
|
||||
room.key = 'Noisy Hall'
|
||||
room.db = {'atmosphere': {'sounds': 'murmuring voices', 'mood': 'busy'}}
|
||||
caller.location = room
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdListen()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'murmuring' in all_text.lower()
|
||||
assert 'busy' in all_text.lower()
|
||||
|
||||
def test_listen_no_location(self):
|
||||
caller = MagicMock()
|
||||
caller.location = None
|
||||
caller.msg = MagicMock()
|
||||
|
||||
cmd = CmdListen()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
msg = str(caller.msg.call_args[0][0])
|
||||
assert 'silence' in msg.lower() or 'void' in msg.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdMap structural tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdMap:
|
||||
"""Verify map rendering assets exist."""
|
||||
|
||||
def test_map_has_wing_maps_for_all_wings(self):
|
||||
cmd = CmdMap()
|
||||
assert hasattr(cmd, 'WING_MAPS')
|
||||
expected_wings = ['hub', 'dormitory', 'commons', 'workshop', 'gardens']
|
||||
for wing in expected_wings:
|
||||
assert wing in cmd.WING_MAPS, f"Missing map for {wing}"
|
||||
|
||||
def test_map_ascii_content_is_nonempty(self):
|
||||
"""Each wing map should be a non-empty ASCII string."""
|
||||
cmd = CmdMap()
|
||||
for wing, ascii_map in cmd.WING_MAPS.items():
|
||||
assert ascii_map, f"Wing '{wing}' map is empty"
|
||||
assert len(ascii_map) > 10, f"Wing '{wing}' map too short"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CmdAcademy structural tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdAcademy:
|
||||
"""Test academy overview layout and wing data."""
|
||||
|
||||
def test_academy_has_wing_config(self):
|
||||
cmd = CmdAcademy()
|
||||
# The wing_config is internal but func() should not raise
|
||||
caller = MagicMock()
|
||||
caller.msg = MagicMock()
|
||||
with patch('evennia.objects.models.ObjectDB') as MockDB:
|
||||
MockDB.objects.filter.return_value.count.return_value = 0
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
# Should have printed something
|
||||
assert caller.msg.called
|
||||
|
||||
def test_academy_headers_are_visible(self):
|
||||
caller = MagicMock()
|
||||
caller.msg = MagicMock()
|
||||
with patch('evennia.objects.models.ObjectDB') as MockDB:
|
||||
MockDB.objects.filter.return_value.count.return_value = 0
|
||||
cmd = CmdAcademy()
|
||||
cmd.caller = caller
|
||||
cmd.func()
|
||||
|
||||
all_text = ' '.join(str(c.args[0]) for c in caller.msg.call_args_list)
|
||||
assert 'TIMMY ACADEMY' in all_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typeclass registry tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTypeclassRegistry:
|
||||
"""Ensure all typeclasses are importable and registered at game init."""
|
||||
|
||||
def test_can_import_all_typeclasses(self):
|
||||
from typeclasses import rooms, exits, objects, characters, audited_character
|
||||
# Should not raise ImportError
|
||||
assert rooms.Room is not None
|
||||
assert exits.Exit is not None
|
||||
assert objects.Object is not None
|
||||
assert characters.Character is not None
|
||||
assert audited_character.AuditedCharacter is not None
|
||||
|
||||
def test_typeclass_paths_refer_to_modules(self):
|
||||
# These paths should match what's referenced in settings.py / IGAME_TYPECLASS_PATHS
|
||||
from typeclasses import rooms
|
||||
assert rooms.__name__ == 'typeclasses.rooms'
|
||||
@@ -2,7 +2,8 @@
|
||||
AuditedCharacter - A character typeclass with full audit logging.
|
||||
|
||||
Tracks every movement, command, and action for complete visibility
|
||||
into player activity.
|
||||
into player activity. Supports configurable log rotation to prevent
|
||||
unbounded growth.
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -10,44 +11,81 @@ from datetime import datetime
|
||||
from evennia import DefaultCharacter
|
||||
from evennia.utils import logger
|
||||
|
||||
# Default audit retention limits
|
||||
DEFAULT_MAX_HISTORY = 500
|
||||
DEFAULT_MAX_LOG_ENTRIES = 100 # Max AUDIT log lines per character per server session
|
||||
|
||||
|
||||
class AuditedCharacter(DefaultCharacter):
|
||||
"""
|
||||
Character typeclass with comprehensive audit logging.
|
||||
|
||||
|
||||
Tracks:
|
||||
- Every room entered/exited with timestamps
|
||||
- Total playtime
|
||||
- Command count
|
||||
- Last known location
|
||||
- Full location history
|
||||
- Full location history (rotated)
|
||||
|
||||
Configurable via class attributes:
|
||||
audit_max_history (int): Max location_history entries kept in db (default 500)
|
||||
audit_max_log_entries (int): Max AUDIT log lines per server session (default 100)
|
||||
"""
|
||||
|
||||
|
||||
audit_max_history = DEFAULT_MAX_HISTORY
|
||||
audit_max_log_entries = DEFAULT_MAX_LOG_ENTRIES
|
||||
|
||||
def at_object_creation(self):
|
||||
"""Set up audit attributes when character is created."""
|
||||
super().at_object_creation()
|
||||
|
||||
|
||||
# Initialize audit tracking attributes
|
||||
self.db.location_history = [] # List of {room, timestamp, action}
|
||||
self.db.command_count = 0
|
||||
self.db.total_playtime = 0 # in seconds
|
||||
self.db.session_start_time = None
|
||||
self.db.last_location = None
|
||||
|
||||
self.db.audit_log_count = 0 # Tracks log entries this session for rate limiting
|
||||
|
||||
logger.log_info(f"AUDIT: Character '{self.key}' created at {datetime.utcnow()}")
|
||||
|
||||
def _audit_log(self, message):
|
||||
"""Write an audit log entry with rate limiting per server session."""
|
||||
count = (self.db.audit_log_count or 0) + 1
|
||||
if count <= self.audit_max_log_entries:
|
||||
logger.log_info(message)
|
||||
if count == self.audit_max_log_entries:
|
||||
logger.log_info(
|
||||
f"AUDIT: {self.key} reached log limit ({self.audit_max_log_entries}) "
|
||||
f"- suppressing further audit logs this session"
|
||||
)
|
||||
self.db.audit_log_count = count
|
||||
|
||||
def prune_audit_history(self, max_entries=None):
|
||||
"""Trim location_history to max_entries. Returns number of entries removed."""
|
||||
max_entries = max_entries or self.audit_max_history
|
||||
history = self.db.location_history or []
|
||||
if len(history) > max_entries:
|
||||
removed = len(history) - max_entries
|
||||
self.db.location_history = history[-max_entries:]
|
||||
return removed
|
||||
return 0
|
||||
|
||||
def at_pre_move(self, destination, **kwargs):
|
||||
"""Called before moving - log departure."""
|
||||
current = self.location
|
||||
if current:
|
||||
logger.log_info(f"AUDIT MOVE: {self.key} leaving {current.key} -> {destination.key if destination else 'None'}")
|
||||
self._audit_log(
|
||||
f"AUDIT MOVE: {self.key} leaving {current.key} "
|
||||
f"-> {destination.key if destination else 'None'}"
|
||||
)
|
||||
return super().at_pre_move(destination, **kwargs)
|
||||
|
||||
|
||||
def at_post_move(self, source_location, **kwargs):
|
||||
"""Called after moving - record arrival in audit trail."""
|
||||
destination = self.location
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
# Update location history
|
||||
history = self.db.location_history or []
|
||||
history.append({
|
||||
@@ -56,41 +94,55 @@ class AuditedCharacter(DefaultCharacter):
|
||||
"timestamp": timestamp,
|
||||
"coord": getattr(destination, 'db', {}).get('coord', None) if destination else None
|
||||
})
|
||||
# Keep last 1000 movements
|
||||
self.db.location_history = history[-1000:]
|
||||
# Rotate: keep last audit_max_history movements
|
||||
self.db.location_history = history[-self.audit_max_history:]
|
||||
self.db.last_location = destination.key if destination else None
|
||||
|
||||
# Log to movement audit log
|
||||
logger.log_info(f"AUDIT MOVE: {self.key} arrived at {destination.key if destination else 'None'} from {source_location.key if source_location else 'None'}")
|
||||
|
||||
|
||||
self._audit_log(
|
||||
f"AUDIT MOVE: {self.key} arrived at "
|
||||
f"{destination.key if destination else 'None'} from "
|
||||
f"{source_location.key if source_location else 'None'}"
|
||||
)
|
||||
|
||||
super().at_post_move(source_location, **kwargs)
|
||||
|
||||
|
||||
def at_pre_cmd(self, cmd, args):
|
||||
"""Called before executing any command."""
|
||||
# Increment command counter
|
||||
self.db.command_count = (self.db.command_count or 0) + 1
|
||||
self.db.last_command_time = datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
# Log command (excluding sensitive commands like password)
|
||||
cmd_name = cmd.key if cmd else "unknown"
|
||||
if cmd_name not in ("password", "@password"):
|
||||
logger.log_info(f"AUDIT CMD: {self.key} executed '{cmd_name}' args: '{args[:50] if args else ''}'")
|
||||
|
||||
self._audit_log(
|
||||
f"AUDIT CMD: {self.key} executed '{cmd_name}' "
|
||||
f"args: '{args[:50] if args else ''}'"
|
||||
)
|
||||
|
||||
super().at_pre_cmd(cmd, args)
|
||||
|
||||
|
||||
def at_pre_puppet(self, account, session, **kwargs):
|
||||
"""Called when account takes control of character."""
|
||||
self.db.session_start_time = time.time()
|
||||
logger.log_info(f"AUDIT SESSION: {self.key} puppeted by {account.key} at {datetime.utcnow()}")
|
||||
self.db.audit_log_count = 0 # Reset log counter each session
|
||||
self._audit_log(
|
||||
f"AUDIT SESSION: {self.key} puppeted by {account.key} "
|
||||
f"at {datetime.utcnow()}"
|
||||
)
|
||||
super().at_pre_puppet(account, session, **kwargs)
|
||||
|
||||
|
||||
def at_post_unpuppet(self, account, session, **kwargs):
|
||||
"""Called when account releases control of character."""
|
||||
start_time = self.db.session_start_time
|
||||
if start_time:
|
||||
session_duration = time.time() - start_time
|
||||
self.db.total_playtime = (self.db.total_playtime or 0) + session_duration
|
||||
logger.log_info(f"AUDIT SESSION: {self.key} unpuppeted by {account.key} - session lasted {session_duration:.0f}s, total playtime {self.db.total_playtime:.0f}s")
|
||||
self._audit_log(
|
||||
f"AUDIT SESSION: {self.key} unpuppeted by {account.key} "
|
||||
f"- session lasted {session_duration:.0f}s, "
|
||||
f"total playtime {self.db.total_playtime:.0f}s"
|
||||
)
|
||||
self.db.session_start_time = None
|
||||
super().at_post_unpuppet(account, session, **kwargs)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user