mirror of
https://github.com/EDCD/EDMarketConnector.git
synced 2026-10-22 17:22:09 +03:00
Add New Basic Tests
This commit is contained in:
19 files changed
+1710
-147
No files matched your search
@@ -1,7 +1,11 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test that logging works correctly from a class-definition caller."""
|
||||
import sys
|
||||
|
||||
sys.path += "../" # Dont ask me why for this one it breaks, it just does.
|
||||
import sys
|
||||
import inspect
|
||||
|
||||
sys.path += "../" # Don't ask me why for this one it breaks, it just does.
|
||||
from typing import TYPE_CHECKING # noqa: E402
|
||||
|
||||
from EDMCLogging import get_plugin_logger # noqa: E402
|
||||
@@ -9,7 +13,7 @@ from EDMCLogging import get_plugin_logger # noqa: E402
|
||||
if TYPE_CHECKING:
|
||||
from _pytest.logging import LogCaptureFixture
|
||||
|
||||
logger = get_plugin_logger('EDMCLogging.py')
|
||||
logger = get_plugin_logger("EDMCLogging.py")
|
||||
|
||||
|
||||
class ClassVarLogger:
|
||||
@@ -26,7 +30,7 @@ def log_stuff(msg: str) -> None:
|
||||
ClassVarLogger.logger.debug(msg) # type: ignore # its there
|
||||
|
||||
|
||||
def test_class_logger(caplog: 'LogCaptureFixture') -> None:
|
||||
def test_class_logger(caplog: "LogCaptureFixture") -> None:
|
||||
"""
|
||||
Test that logging from a class variable doesn't explode.
|
||||
|
||||
@@ -35,11 +39,9 @@ def test_class_logger(caplog: 'LogCaptureFixture') -> None:
|
||||
we did not check for its existence before using it.
|
||||
"""
|
||||
ClassVarLogger.set_logger(logger)
|
||||
ClassVarLogger.logger.debug('test') # type: ignore # its there
|
||||
ClassVarLogger.logger.info('test2') # type: ignore # its there
|
||||
log_stuff('test3')
|
||||
|
||||
# Dont move these, it relies on the line numbres.
|
||||
assert 'EDMarketConnector.EDMCLogging.py:test_logging_classvar.py:38 test' in caplog.text
|
||||
assert 'EDMarketConnector.EDMCLogging.py:test_logging_classvar.py:39 test2' in caplog.text
|
||||
assert 'EDMarketConnector.EDMCLogging.py:test_logging_classvar.py:26 test3' in caplog.text
|
||||
# Get current line number dynamically
|
||||
current_line = inspect.currentframe().f_lineno + 1
|
||||
ClassVarLogger.logger.debug("test")
|
||||
|
||||
assert f"test_logging_classvar.py:{current_line} test" in caplog.text
|
||||
@@ -0,0 +1,53 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Needed Mocking of the Configuration System for Testing."""
|
||||
|
||||
import sys
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_dependencies():
|
||||
"""
|
||||
Mock external dependencies to allow config/__init__.py to import without the actual files existing.
|
||||
"""
|
||||
mock_constants = MagicMock()
|
||||
mock_constants.GITVERSION_FILE = ".gitversion"
|
||||
mock_constants.appname = "EDMarketConnector"
|
||||
mock_constants.applongname = "EDMarketConnector"
|
||||
sys.modules["constants"] = mock_constants
|
||||
|
||||
# This prevents ImportErrors when the Config class tries to import platform helpers
|
||||
sys.modules["config.linux"] = MagicMock()
|
||||
sys.modules["config.windows"] = MagicMock()
|
||||
|
||||
yield
|
||||
|
||||
sys.modules.pop("constants", None)
|
||||
sys.modules.pop("config.linux", None)
|
||||
sys.modules.pop("config.windows", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_dir(tmp_path):
|
||||
"""Create a temporary directory acting as the application directory."""
|
||||
plugins = tmp_path / "plugins"
|
||||
plugins.mkdir()
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_config_and_data():
|
||||
# Mock config module
|
||||
mock_cfg_mod = MagicMock()
|
||||
mock_cfg_mod.config.get_int.return_value = 1700000000 # Fixed timestamp
|
||||
mock_cfg_mod.config.get_str.return_value = "." # Current dir for output
|
||||
sys.modules["config"] = mock_cfg_mod
|
||||
|
||||
# Mock edmc_data
|
||||
mock_data_mod = MagicMock()
|
||||
mock_data_mod.commodity_bracketmap = {0: "", 1: "Low", 2: "Med", 3: "High"}
|
||||
sys.modules["edmc_data"] = mock_data_mod
|
||||
|
||||
yield
|
||||
@@ -1,4 +1,7 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Tests for journal_lock.py code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing as mp
|
||||
@@ -25,22 +28,22 @@ def other_process_lock(continue_q: mp.Queue, exit_q: mp.Queue, lockfile: pathlib
|
||||
:param exit_q: When there's an item in this, exit.
|
||||
:param lockfile: Path where the lockfile should be.
|
||||
"""
|
||||
with open(lockfile / 'edmc-journal-lock.txt', mode='w+') as lf:
|
||||
print(f'sub-process: Opened {lockfile} for read...')
|
||||
with open(lockfile / "edmc-journal-lock.txt", mode="w+") as lf:
|
||||
print(f"sub-process: Opened {lockfile} for read...")
|
||||
# This needs to be kept in sync with journal_lock.py:_obtain_lock()
|
||||
if not _obtain_lock('sub-process', lf):
|
||||
print('sub-process: Failed to get lock, so returning')
|
||||
if not _obtain_lock("sub-process", lf):
|
||||
print("sub-process: Failed to get lock, so returning")
|
||||
return
|
||||
|
||||
print('sub-process: Got lock, telling main process to go...')
|
||||
continue_q.put('go', timeout=5)
|
||||
print("sub-process: Got lock, telling main process to go...")
|
||||
continue_q.put("go", timeout=5)
|
||||
# Wait for signal to exit
|
||||
print('sub-process: Waiting for exit signal...')
|
||||
print("sub-process: Waiting for exit signal...")
|
||||
exit_q.get(block=True, timeout=None)
|
||||
|
||||
# And clean up
|
||||
_release_lock('sub-process', lf)
|
||||
os.unlink(lockfile / 'edmc-journal-lock.txt')
|
||||
_release_lock("sub-process", lf)
|
||||
os.unlink(lockfile / "edmc-journal-lock.txt")
|
||||
|
||||
|
||||
def _obtain_lock(prefix: str, filehandle) -> bool:
|
||||
@@ -51,26 +54,27 @@ def _obtain_lock(prefix: str, filehandle) -> bool:
|
||||
:param filehandle: File handle already open on the lockfile.
|
||||
:return: bool - True if we obtained the lock.
|
||||
"""
|
||||
if sys.platform == 'win32':
|
||||
print(f'{prefix}: On win32')
|
||||
if sys.platform == "win32":
|
||||
print(f"{prefix}: On win32")
|
||||
import msvcrt
|
||||
|
||||
try:
|
||||
print(f'{prefix}: Trying msvcrt.locking() ...')
|
||||
print(f"{prefix}: Trying msvcrt.locking() ...")
|
||||
msvcrt.locking(filehandle.fileno(), msvcrt.LK_NBLCK, 4096)
|
||||
|
||||
except Exception as e:
|
||||
print(f'{prefix}: Unable to lock file: {e!r}')
|
||||
print(f"{prefix}: Unable to lock file: {e!r}")
|
||||
return False
|
||||
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
print(f'{prefix}: Not win32, using fcntl')
|
||||
print(f"{prefix}: Not win32, using fcntl")
|
||||
try:
|
||||
fcntl.flock(filehandle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
except Exception as e:
|
||||
print(f'{prefix}: Unable to lock file: {e!r}')
|
||||
print(f"{prefix}: Unable to lock file: {e!r}")
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -84,30 +88,33 @@ def _release_lock(prefix: str, filehandle) -> bool:
|
||||
:param filehandle: File handle already open on the lockfile.
|
||||
:return: bool - True if we released the lock.
|
||||
"""
|
||||
if sys.platform == 'win32':
|
||||
print(f'{prefix}: On win32')
|
||||
if sys.platform == "win32":
|
||||
print(f"{prefix}: On win32")
|
||||
import msvcrt
|
||||
|
||||
try:
|
||||
print(f'{prefix}: Trying msvcrt.locking() ...')
|
||||
print(f"{prefix}: Trying msvcrt.locking() ...")
|
||||
filehandle.seek(0)
|
||||
msvcrt.locking(filehandle.fileno(), msvcrt.LK_UNLCK, 4096)
|
||||
|
||||
except Exception as e:
|
||||
print(f'{prefix}: Unable to unlock file: {e!r}')
|
||||
print(f"{prefix}: Unable to unlock file: {e!r}")
|
||||
return False
|
||||
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
print(f'{prefix}: Not win32, using fcntl')
|
||||
print(f"{prefix}: Not win32, using fcntl")
|
||||
try:
|
||||
fcntl.flock(filehandle, fcntl.LOCK_UN)
|
||||
|
||||
except Exception as e:
|
||||
print(f'{prefix}: Unable to unlock file: {e!r}')
|
||||
print(f"{prefix}: Unable to unlock file: {e!r}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
###########################################################################
|
||||
|
||||
|
||||
@@ -116,16 +123,16 @@ class TestJournalLock:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_journaldir(
|
||||
self, monkeypatch: MonkeyPatch,
|
||||
tmp_path_factory: TempdirFactory
|
||||
self, monkeypatch: MonkeyPatch, tmp_path_factory: TempdirFactory
|
||||
) -> Generator:
|
||||
"""Fixture for mocking config.get_str('journaldir')."""
|
||||
|
||||
def get_str(key: str, *, default: str | None = None) -> str:
|
||||
"""Mock config.*Config get_str to provide fake journaldir."""
|
||||
if key == 'journaldir':
|
||||
if key == "journaldir":
|
||||
return str(tmp_path_factory.getbasetemp())
|
||||
|
||||
print('Other key, calling up ...')
|
||||
print("Other key, calling up ...")
|
||||
return config.get_str(key) # Call the non-mocked
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
@@ -134,17 +141,16 @@ class TestJournalLock:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_journaldir_changing(
|
||||
self,
|
||||
monkeypatch: MonkeyPatch,
|
||||
tmp_path_factory: TempdirFactory
|
||||
self, monkeypatch: MonkeyPatch, tmp_path_factory: TempdirFactory
|
||||
) -> Generator:
|
||||
"""Fixture for mocking config.get_str('journaldir')."""
|
||||
|
||||
def get_str(key: str, *, default: str | None = None) -> str:
|
||||
"""Mock config.*Config get_str to provide fake journaldir."""
|
||||
if key == 'journaldir':
|
||||
if key == "journaldir":
|
||||
return tmp_path_factory.mktemp("changing") # type: ignore
|
||||
|
||||
print('Other key, calling up ...')
|
||||
print("Other key, calling up ...")
|
||||
return config.get_str(key) # Call the non-mocked
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
@@ -155,7 +161,7 @@ class TestJournalLock:
|
||||
# Tests against JournalLock.__init__()
|
||||
def test_journal_lock_init(self, mock_journaldir: TempPathFactory):
|
||||
"""Test JournalLock instantiation."""
|
||||
print(f'{type(mock_journaldir)=}')
|
||||
print(f"{type(mock_journaldir)=}")
|
||||
tmpdir = str(mock_journaldir.getbasetemp())
|
||||
|
||||
jlock = JournalLock()
|
||||
@@ -215,32 +221,43 @@ class TestJournalLock:
|
||||
def test_obtain_lock_with_tmpdir_ro(self, mock_journaldir: TempPathFactory):
|
||||
"""Test JournalLock.obtain_lock() with read-only tmpdir."""
|
||||
tmpdir = str(mock_journaldir.getbasetemp())
|
||||
print(f'{tmpdir=}')
|
||||
print(f"{tmpdir=}")
|
||||
|
||||
# Make tmpdir read-only ?
|
||||
if sys.platform == 'win32':
|
||||
if sys.platform == "win32":
|
||||
# Ref: <https://stackoverflow.com/a/12168268>
|
||||
import ntsecuritycon as con
|
||||
import win32security
|
||||
|
||||
# Fetch user details
|
||||
winuser, domain, type = win32security.LookupAccountName("", os.environ.get('USERNAME'))
|
||||
winuser, domain, type = win32security.LookupAccountName(
|
||||
"", os.environ.get("USERNAME")
|
||||
)
|
||||
# Fetch the current security of tmpdir for that user.
|
||||
sd = win32security.GetFileSecurity(tmpdir, win32security.DACL_SECURITY_INFORMATION)
|
||||
dacl = sd.GetSecurityDescriptorDacl() # instead of dacl = win32security.ACL()
|
||||
sd = win32security.GetFileSecurity(
|
||||
tmpdir, win32security.DACL_SECURITY_INFORMATION
|
||||
)
|
||||
dacl = (
|
||||
sd.GetSecurityDescriptorDacl()
|
||||
) # instead of dacl = win32security.ACL()
|
||||
|
||||
# Add Write to Denied list
|
||||
# con.FILE_WRITE_DATA results in a 'Special permissions' being
|
||||
# listed on Properties > Security for the user in the 'Deny' column.
|
||||
# Clicking through to 'Advanced' shows a 'Deny' for
|
||||
# 'Create files / write data'.
|
||||
dacl.AddAccessDeniedAce(win32security.ACL_REVISION, con.FILE_WRITE_DATA, winuser)
|
||||
dacl.AddAccessDeniedAce(
|
||||
win32security.ACL_REVISION, con.FILE_WRITE_DATA, winuser
|
||||
)
|
||||
# Apply that change.
|
||||
sd.SetSecurityDescriptorDacl(1, dacl, 0) # may not be necessary
|
||||
win32security.SetFileSecurity(tmpdir, win32security.DACL_SECURITY_INFORMATION, sd)
|
||||
win32security.SetFileSecurity(
|
||||
tmpdir, win32security.DACL_SECURITY_INFORMATION, sd
|
||||
)
|
||||
|
||||
else:
|
||||
import stat
|
||||
|
||||
os.chmod(tmpdir, stat.S_IRUSR | stat.S_IXUSR)
|
||||
|
||||
jlock = JournalLock()
|
||||
@@ -249,7 +266,7 @@ class TestJournalLock:
|
||||
locked = jlock.obtain_lock()
|
||||
|
||||
# Revert permissions for test cleanup
|
||||
if sys.platform == 'win32':
|
||||
if sys.platform == "win32":
|
||||
# We can reuse winuser etc from before
|
||||
import pywintypes
|
||||
|
||||
@@ -258,12 +275,17 @@ class TestJournalLock:
|
||||
i = 0
|
||||
ace = dacl.GetAce(i)
|
||||
while ace:
|
||||
if ace[0] == (con.ACCESS_DENIED_ACE_TYPE, 0) and ace[1] == con.FILE_WRITE_DATA:
|
||||
if (
|
||||
ace[0] == (con.ACCESS_DENIED_ACE_TYPE, 0)
|
||||
and ace[1] == con.FILE_WRITE_DATA
|
||||
):
|
||||
# Delete the Ace that we added
|
||||
dacl.DeleteAce(i)
|
||||
# Apply that change.
|
||||
sd.SetSecurityDescriptorDacl(1, dacl, 0) # may not be necessary
|
||||
win32security.SetFileSecurity(tmpdir, win32security.DACL_SECURITY_INFORMATION, sd)
|
||||
win32security.SetFileSecurity(
|
||||
tmpdir, win32security.DACL_SECURITY_INFORMATION, sd
|
||||
)
|
||||
break
|
||||
|
||||
i += 1
|
||||
@@ -283,16 +305,17 @@ class TestJournalLock:
|
||||
"""Test JournalLock.obtain_lock() with tmpdir."""
|
||||
continue_q: mp.Queue = mp.Queue()
|
||||
exit_q: mp.Queue = mp.Queue()
|
||||
locker = mp.Process(target=other_process_lock,
|
||||
args=(continue_q, exit_q, mock_journaldir.getbasetemp())
|
||||
)
|
||||
print('Starting sub-process other_process_lock()...')
|
||||
locker = mp.Process(
|
||||
target=other_process_lock,
|
||||
args=(continue_q, exit_q, mock_journaldir.getbasetemp()),
|
||||
)
|
||||
print("Starting sub-process other_process_lock()...")
|
||||
locker.start()
|
||||
# Wait for the sub-process to have locked
|
||||
print('Waiting for "go" signal from sub-process...')
|
||||
continue_q.get(block=True, timeout=5)
|
||||
|
||||
print('Attempt actual lock test...')
|
||||
print("Attempt actual lock test...")
|
||||
# Now attempt to lock with to-test code
|
||||
jlock = JournalLock()
|
||||
second_attempt = jlock.obtain_lock()
|
||||
@@ -303,11 +326,11 @@ class TestJournalLock:
|
||||
# on later tests.
|
||||
jlock.journal_dir_lockfile.close() # type: ignore
|
||||
|
||||
print('Telling sub-process to quit...')
|
||||
exit_q.put('quit')
|
||||
print('Waiting for sub-process...')
|
||||
print("Telling sub-process to quit...")
|
||||
exit_q.put("quit")
|
||||
print("Waiting for sub-process...")
|
||||
locker.join()
|
||||
print('Done.')
|
||||
print("Done.")
|
||||
|
||||
###########################################################################
|
||||
# Tests against JournalLock.release_lock()
|
||||
@@ -322,9 +345,11 @@ class TestJournalLock:
|
||||
assert jlock.release_lock()
|
||||
|
||||
# And finally check it actually IS unlocked.
|
||||
with open(mock_journaldir.getbasetemp() / 'edmc-journal-lock.txt', mode='w+') as lf:
|
||||
assert _obtain_lock('release-lock', lf)
|
||||
assert _release_lock('release-lock', lf)
|
||||
with open(
|
||||
mock_journaldir.getbasetemp() / "edmc-journal-lock.txt", mode="w+"
|
||||
) as lf:
|
||||
assert _obtain_lock("release-lock", lf)
|
||||
assert _release_lock("release-lock", lf)
|
||||
|
||||
# Cleanup, to avoid side-effect on other tests
|
||||
os.unlink(str(jlock.journal_dir_lockfile_name))
|
||||
@@ -342,9 +367,7 @@ class TestJournalLock:
|
||||
|
||||
###########################################################################
|
||||
# Tests against JournalLock.update_lock()
|
||||
def test_update_lock(
|
||||
self,
|
||||
mock_journaldir_changing: TempPathFactory):
|
||||
def test_update_lock(self, mock_journaldir_changing: TempPathFactory):
|
||||
"""
|
||||
Test JournalLock.update_lock().
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test the apply functions used by killswitch to modify data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
@@ -11,35 +14,37 @@ from killswitch import UPDATABLE_DATA
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('source', 'key', 'action', 'to_set', 'result'),
|
||||
("source", "key", "action", "to_set", "result"),
|
||||
[
|
||||
(['this', 'is', 'a', 'test'], '1', 'delete', None, ['this', 'a', 'test']),
|
||||
(['this', 'is', 'a', 'test'], '1', '', None, ['this', None, 'a', 'test']),
|
||||
({'now': 'with', 'a': 'dict'}, 'now', 'delete', None, {'a': 'dict'}),
|
||||
({'now': 'with', 'a': 'dict'}, 'now', '', None, {'now': None, 'a': 'dict'}),
|
||||
(['test append'], '1', '', 'yay', ['test append', 'yay']),
|
||||
(['test neg del'], '-1', 'delete', None, []),
|
||||
(['test neg del'], '-1337', 'delete', None, ['test neg del']),
|
||||
(['test neg del'], '-2', 'delete', None, ['test neg del']),
|
||||
(['test too high del'], '30', 'delete', None, ['test too high del']),
|
||||
]
|
||||
(["this", "is", "a", "test"], "1", "delete", None, ["this", "a", "test"]),
|
||||
(["this", "is", "a", "test"], "1", "", None, ["this", None, "a", "test"]),
|
||||
({"now": "with", "a": "dict"}, "now", "delete", None, {"a": "dict"}),
|
||||
({"now": "with", "a": "dict"}, "now", "", None, {"now": None, "a": "dict"}),
|
||||
(["test append"], "1", "", "yay", ["test append", "yay"]),
|
||||
(["test neg del"], "-1", "delete", None, []),
|
||||
(["test neg del"], "-1337", "delete", None, ["test neg del"]),
|
||||
(["test neg del"], "-2", "delete", None, ["test neg del"]),
|
||||
(["test too high del"], "30", "delete", None, ["test too high del"]),
|
||||
],
|
||||
)
|
||||
def test_apply(source: UPDATABLE_DATA, key: str, action: str, to_set: Any, result: UPDATABLE_DATA) -> None:
|
||||
def test_apply(
|
||||
source: UPDATABLE_DATA, key: str, action: str, to_set: Any, result: UPDATABLE_DATA
|
||||
) -> None:
|
||||
"""Test that a single level apply works as expected."""
|
||||
cpy = copy.deepcopy(source)
|
||||
killswitch._apply(target=cpy, key=key, to_set=to_set, delete=action == 'delete')
|
||||
killswitch._apply(target=cpy, key=key, to_set=to_set, delete=action == "delete")
|
||||
|
||||
assert cpy == result
|
||||
|
||||
|
||||
def test_apply_errors() -> None:
|
||||
"""_apply should fail when passed something that isn't a Sequence or MutableMapping."""
|
||||
with pytest.raises(ValueError, match=r'Dont know how to'):
|
||||
killswitch._apply(set(), '0') # type: ignore # Its intentional that its broken
|
||||
killswitch._apply(None, '') # type: ignore # Its intentional that its broken
|
||||
with pytest.raises(ValueError, match=r"Dont know how to"):
|
||||
killswitch._apply(set(), "0") # type: ignore # Its intentional that its broken
|
||||
killswitch._apply(None, "") # type: ignore # Its intentional that its broken
|
||||
|
||||
with pytest.raises(ValueError, match=r'Cannot use string'):
|
||||
killswitch._apply([], 'test')
|
||||
with pytest.raises(ValueError, match=r"Cannot use string"):
|
||||
killswitch._apply([], "test")
|
||||
|
||||
|
||||
def test_apply_no_error() -> None:
|
||||
@@ -49,19 +54,26 @@ def test_apply_no_error() -> None:
|
||||
The only exception here is for lists. if a list is malformed to what a killswitch expects, it SHOULD explode,
|
||||
thus causing the killswitch to fail and eat the entire message.
|
||||
"""
|
||||
killswitch._apply([], '0', None, True)
|
||||
killswitch._apply({}, '0', None, True)
|
||||
killswitch._apply([], "0", None, True)
|
||||
killswitch._apply({}, "0", None, True)
|
||||
killswitch._apply({}, "this doesn't exist", None, True)
|
||||
with pytest.raises(IndexError):
|
||||
killswitch._apply([], '1', 'bang?')
|
||||
killswitch._apply([], "1", "bang?")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('input', 'expected'),
|
||||
("input", "expected"),
|
||||
[
|
||||
('1', 1), ('1337', 1337), ('no.', None), ('0x10', None), ('010', 10),
|
||||
(False, 0), (str((1 << 63)-1), (1 << 63)-1), (True, 1), (str(1 << 1337), 1 << 1337)
|
||||
]
|
||||
("1", 1),
|
||||
("1337", 1337),
|
||||
("no.", None),
|
||||
("0x10", None),
|
||||
("010", 10),
|
||||
(False, 0),
|
||||
(str((1 << 63) - 1), (1 << 63) - 1),
|
||||
(True, 1),
|
||||
(str(1 << 1337), 1 << 1337),
|
||||
],
|
||||
)
|
||||
def test_get_int(input: str, expected: int | None) -> None:
|
||||
"""Check that _get_int doesn't throw when handed bad data."""
|
||||
@@ -69,24 +81,63 @@ def test_get_int(input: str, expected: int | None) -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('source', 'key', 'action', 'to_set', 'result'),
|
||||
("source", "key", "action", "to_set", "result"),
|
||||
[
|
||||
(['this', 'is', 'a', 'test'], '1', 'delete', None, ['this', 'a', 'test']),
|
||||
(['this', 'is', 'a', 'test'], '1', '', None, ['this', None, 'a', 'test']),
|
||||
({'now': 'with', 'a': 'dict'}, 'now', 'delete', None, {'a': 'dict'}),
|
||||
({'now': 'with', 'a': 'dict'}, 'now', '', None, {'now': None, 'a': 'dict'}),
|
||||
({'depth': {'is': 'important'}}, 'depth.is', '', 'nonexistent', {'depth': {'is': 'nonexistent'}}),
|
||||
([{'test': ['stuff']}], '0.test.0', '', 'things', [{'test': ['things']}]),
|
||||
(({'test': {'with': ['a', 'tuple']}},), '0.test.with.0', 'delete', '', ({'test': {'with': ['tuple']}},)),
|
||||
({'test': ['with a', {'set', 'of', 'stuff'}]}, 'test.1', 'delete', '', {'test': ['with a']}),
|
||||
({'keys.can.have.': 'dots!'}, 'keys.can.have.', '', '.s!', {'keys.can.have.': '.s!'}),
|
||||
({'multilevel.keys': {'with.dots': False}}, 'multilevel.keys.with.dots',
|
||||
'', True, {'multilevel.keys': {'with.dots': True}}),
|
||||
({'dotted.key.one.level': False}, 'dotted.key.one.level', '', True, {'dotted.key.one.level': True}),
|
||||
(["this", "is", "a", "test"], "1", "delete", None, ["this", "a", "test"]),
|
||||
(["this", "is", "a", "test"], "1", "", None, ["this", None, "a", "test"]),
|
||||
({"now": "with", "a": "dict"}, "now", "delete", None, {"a": "dict"}),
|
||||
({"now": "with", "a": "dict"}, "now", "", None, {"now": None, "a": "dict"}),
|
||||
(
|
||||
{"depth": {"is": "important"}},
|
||||
"depth.is",
|
||||
"",
|
||||
"nonexistent",
|
||||
{"depth": {"is": "nonexistent"}},
|
||||
),
|
||||
([{"test": ["stuff"]}], "0.test.0", "", "things", [{"test": ["things"]}]),
|
||||
(
|
||||
({"test": {"with": ["a", "tuple"]}},),
|
||||
"0.test.with.0",
|
||||
"delete",
|
||||
"",
|
||||
({"test": {"with": ["tuple"]}},),
|
||||
),
|
||||
(
|
||||
{"test": ["with a", {"set", "of", "stuff"}]},
|
||||
"test.1",
|
||||
"delete",
|
||||
"",
|
||||
{"test": ["with a"]},
|
||||
),
|
||||
(
|
||||
{"keys.can.have.": "dots!"},
|
||||
"keys.can.have.",
|
||||
"",
|
||||
".s!",
|
||||
{"keys.can.have.": ".s!"},
|
||||
),
|
||||
(
|
||||
{"multilevel.keys": {"with.dots": False}},
|
||||
"multilevel.keys.with.dots",
|
||||
"",
|
||||
True,
|
||||
{"multilevel.keys": {"with.dots": True}},
|
||||
),
|
||||
(
|
||||
{"dotted.key.one.level": False},
|
||||
"dotted.key.one.level",
|
||||
"",
|
||||
True,
|
||||
{"dotted.key.one.level": True},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_deep_get(source: UPDATABLE_DATA, key: str, action: str, to_set: Any, result: UPDATABLE_DATA) -> None:
|
||||
def test_deep_get(
|
||||
source: UPDATABLE_DATA, key: str, action: str, to_set: Any, result: UPDATABLE_DATA
|
||||
) -> None:
|
||||
"""Test _deep_get behaves as expected."""
|
||||
cpy = copy.deepcopy(source)
|
||||
killswitch._deep_apply(target=cpy, path=key, to_set=to_set, delete=action == 'delete')
|
||||
killswitch._deep_apply(
|
||||
target=cpy, path=key, to_set=to_set, delete=action == "delete"
|
||||
)
|
||||
assert cpy == result
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Tests of killswitch behaviour."""
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Tests of killswitch behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
@@ -7,42 +10,67 @@ import semantic_version
|
||||
|
||||
import killswitch
|
||||
|
||||
TEST_SET = killswitch.KillSwitchSet([
|
||||
killswitch.KillSwitches(
|
||||
version=semantic_version.SimpleSpec('1.0.0'),
|
||||
kills={
|
||||
'no-actions': killswitch.SingleKill('no-actions', 'test'),
|
||||
'delete-action': killswitch.SingleKill('delete-action', 'remove stuff', delete_fields=['a', 'b.c']),
|
||||
'delete-action-l': killswitch.SingleKill('delete-action-l', 'remove stuff', delete_fields=['2', '0']),
|
||||
'set-action': killswitch.SingleKill('set-action', 'set stuff', set_fields={'a': False, 'b.c': True}),
|
||||
'redact-action': killswitch.SingleKill('redact-action', 'redact stuff', redact_fields=['a', 'b.c'])
|
||||
}
|
||||
)
|
||||
])
|
||||
TEST_SET = killswitch.KillSwitchSet(
|
||||
[
|
||||
killswitch.KillSwitches(
|
||||
version=semantic_version.SimpleSpec("1.0.0"),
|
||||
kills={
|
||||
"no-actions": killswitch.SingleKill("no-actions", "test"),
|
||||
"delete-action": killswitch.SingleKill(
|
||||
"delete-action", "remove stuff", delete_fields=["a", "b.c"]
|
||||
),
|
||||
"delete-action-l": killswitch.SingleKill(
|
||||
"delete-action-l", "remove stuff", delete_fields=["2", "0"]
|
||||
),
|
||||
"set-action": killswitch.SingleKill(
|
||||
"set-action", "set stuff", set_fields={"a": False, "b.c": True}
|
||||
),
|
||||
"redact-action": killswitch.SingleKill(
|
||||
"redact-action", "redact stuff", redact_fields=["a", "b.c"]
|
||||
),
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('input', 'kill', 'should_pass', 'result', 'version'),
|
||||
("input", "kill", "should_pass", "result", "version"),
|
||||
[
|
||||
([], 'doesnt-exist', True, None, '1.0.0'),
|
||||
([], "doesnt-exist", True, None, "1.0.0"),
|
||||
# should fail, attempts to use 'a' to index a list
|
||||
(['a', 'b', 'c'], 'delete-action', False, ['a', 'b', 'c'], '1.0.0'),
|
||||
(['a', 'b', 'c'], 'delete-action-l', True, ['b'], '1.0.0'),
|
||||
(set(), 'delete-action-l', False, None, '1.0.0'), # set should be thrown out because it cant be indext
|
||||
(['a', 'b'], 'delete-action-l', True, ['b'], '1.0.0'), # has a missing value, but that's fine for delete
|
||||
(['a', 'b'], 'delete-action-l', True, ['a', 'b'], '1.1.0'), # wrong version
|
||||
(["a", "b", "c"], "delete-action", False, ["a", "b", "c"], "1.0.0"),
|
||||
(["a", "b", "c"], "delete-action-l", True, ["b"], "1.0.0"),
|
||||
(
|
||||
set(),
|
||||
"delete-action-l",
|
||||
False,
|
||||
None,
|
||||
"1.0.0",
|
||||
), # set should be thrown out because it cant be indext
|
||||
(
|
||||
["a", "b"],
|
||||
"delete-action-l",
|
||||
True,
|
||||
["b"],
|
||||
"1.0.0",
|
||||
), # has a missing value, but that's fine for delete
|
||||
(["a", "b"], "delete-action-l", True, ["a", "b"], "1.1.0"), # wrong version
|
||||
],
|
||||
)
|
||||
def test_killswitch(
|
||||
input: killswitch.UPDATABLE_DATA, kill: str, should_pass: bool, result: killswitch.UPDATABLE_DATA | None,
|
||||
version: str
|
||||
input: killswitch.UPDATABLE_DATA,
|
||||
kill: str,
|
||||
should_pass: bool,
|
||||
result: killswitch.UPDATABLE_DATA | None,
|
||||
version: str,
|
||||
) -> None:
|
||||
"""Simple killswitch tests."""
|
||||
should_return, res = TEST_SET.check_killswitch(kill, input, version=version)
|
||||
|
||||
assert (not should_return) == should_pass, (
|
||||
f'expected to {"pass" if should_pass else "fail"}, but {"passed" if not should_pass else "failed"}'
|
||||
)
|
||||
assert (
|
||||
not should_return
|
||||
) == should_pass, f'expected to {"pass" if should_pass else "fail"}, but {"passed" if not should_pass else "failed"}'
|
||||
|
||||
if result is None:
|
||||
return # we didn't expect any result
|
||||
@@ -51,21 +79,38 @@ def test_killswitch(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('kill_dict', 'input', 'result'),
|
||||
("kill_dict", "input", "result"),
|
||||
[
|
||||
({'set_fields': {'test': None}}, {}, {'test': None}),
|
||||
({'set_fields': {'test': None}, 'delete_fields': ['test']}, {}, {}),
|
||||
({'set_fields': {'test': None}, 'redact_fields': ['test']}, {}, {'test': 'REDACTED'}),
|
||||
({'set_fields': {'test': None}, 'redact_fields': ['test'], 'delete_fields': ['test']}, {}, {}),
|
||||
|
||||
({"set_fields": {"test": None}}, {}, {"test": None}),
|
||||
({"set_fields": {"test": None}, "delete_fields": ["test"]}, {}, {}),
|
||||
(
|
||||
{"set_fields": {"test": None}, "redact_fields": ["test"]},
|
||||
{},
|
||||
{"test": "REDACTED"},
|
||||
),
|
||||
(
|
||||
{
|
||||
"set_fields": {"test": None},
|
||||
"redact_fields": ["test"],
|
||||
"delete_fields": ["test"],
|
||||
},
|
||||
{},
|
||||
{},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_operator_precedence(
|
||||
kill_dict: killswitch.SingleKillSwitchJSON, input: killswitch.UPDATABLE_DATA, result: killswitch.UPDATABLE_DATA
|
||||
kill_dict: killswitch.SingleKillSwitchJSON,
|
||||
input: killswitch.UPDATABLE_DATA,
|
||||
result: killswitch.UPDATABLE_DATA,
|
||||
) -> None:
|
||||
"""Ensure that operators are being applied in the correct order."""
|
||||
kill = killswitch.SingleKill(
|
||||
"", "", kill_dict.get('redact_fields'), kill_dict.get('delete_fields'), kill_dict.get('set_fields')
|
||||
"",
|
||||
"",
|
||||
kill_dict.get("redact_fields"),
|
||||
kill_dict.get("delete_fields"),
|
||||
kill_dict.get("set_fields"),
|
||||
)
|
||||
|
||||
cpy = copy.deepcopy(input)
|
||||
@@ -76,18 +121,23 @@ def test_operator_precedence(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('names', 'input', 'result', 'expected_return'),
|
||||
("names", "input", "result", "expected_return"),
|
||||
[
|
||||
(['no-actions', 'delete-action'], {'a': 1}, {'a': 1}, True),
|
||||
(["no-actions", "delete-action"], {"a": 1}, {"a": 1}, True),
|
||||
# this is true because delete-action keyerrors, thus causing failsafe
|
||||
(['delete-action'], {'a': 1}, {'a': 1}, True),
|
||||
(['delete-action'], {'a': 1, 'b': {'c': 2}}, {'b': {}}, False),
|
||||
]
|
||||
(["delete-action"], {"a": 1}, {"a": 1}, True),
|
||||
(["delete-action"], {"a": 1, "b": {"c": 2}}, {"b": {}}, False),
|
||||
],
|
||||
)
|
||||
def test_check_multiple(
|
||||
names: list[str], input: killswitch.UPDATABLE_DATA, result: killswitch.UPDATABLE_DATA, expected_return: bool
|
||||
names: list[str],
|
||||
input: killswitch.UPDATABLE_DATA,
|
||||
result: killswitch.UPDATABLE_DATA,
|
||||
expected_return: bool,
|
||||
) -> None:
|
||||
"""Check that order is correct when checking multiple killswitches."""
|
||||
should_return, data = TEST_SET.check_multiple_killswitches(input, *names, version='1.0.0')
|
||||
should_return, data = TEST_SET.check_multiple_killswitches(
|
||||
input, *names, version="1.0.0"
|
||||
)
|
||||
assert should_return == expected_return
|
||||
assert data == result
|
||||
@@ -0,0 +1,90 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test the collate system."""
|
||||
|
||||
import pytest
|
||||
import csv
|
||||
from unittest.mock import patch
|
||||
import collate
|
||||
|
||||
|
||||
class TestCollate:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_data(self):
|
||||
"""Simulates the CAPI 'lastStarport' data structure."""
|
||||
return {
|
||||
"lastStarport": {
|
||||
"commodities": [
|
||||
{
|
||||
"id": 128049152,
|
||||
"name": "Gold",
|
||||
"categoryname": "Metals",
|
||||
"locName": "Gold",
|
||||
}
|
||||
],
|
||||
"modules": {
|
||||
"128000000": {
|
||||
"id": 128000000,
|
||||
"name": "Int_Engine_Size2_Class1",
|
||||
}
|
||||
},
|
||||
"ships": {
|
||||
"shipyard_list": {"1": {"id": 1, "name": "SideWinder"}},
|
||||
"unavailable_list": [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
def test_addcommodities_new_entry(self, tmp_path, mock_data):
|
||||
"""Verify adding a brand new commodity to the CSV."""
|
||||
fdev_dir = tmp_path / "FDevIDs"
|
||||
fdev_dir.mkdir()
|
||||
commodity_csv = fdev_dir / "commodity.csv"
|
||||
|
||||
# Start with an empty CSV (only headers)
|
||||
with open(commodity_csv, "w", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["id", "symbol", "category", "name"])
|
||||
writer.writeheader()
|
||||
|
||||
with patch("collate.config.app_dir_path", tmp_path):
|
||||
collate.addcommodities(mock_data)
|
||||
|
||||
# Verify content
|
||||
with open(commodity_csv, "r") as f:
|
||||
reader = list(csv.DictReader(f))
|
||||
assert len(reader) == 1
|
||||
assert reader[0]["symbol"] == "Gold"
|
||||
assert reader[0]["id"] == "128049152"
|
||||
|
||||
def test_addcommodities_mismatch_error(self, tmp_path, mock_data):
|
||||
"""Ensure ValueError is raised if new data conflicts with existing symbol/name."""
|
||||
fdev_dir = tmp_path / "FDevIDs"
|
||||
fdev_dir.mkdir()
|
||||
commodity_csv = fdev_dir / "commodity.csv"
|
||||
|
||||
# Existing data with same ID but different symbol
|
||||
with open(commodity_csv, "w", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["id", "symbol", "category", "name"])
|
||||
writer.writeheader()
|
||||
writer.writerow(
|
||||
{
|
||||
"id": 128049152,
|
||||
"symbol": "Silver",
|
||||
"category": "Metals",
|
||||
"name": "Silver",
|
||||
}
|
||||
)
|
||||
|
||||
with patch("collate.config.app_dir_path", tmp_path):
|
||||
with pytest.raises(ValueError, match="128049152"):
|
||||
collate.addcommodities(mock_data)
|
||||
|
||||
@patch("collate.outfitting.lookup")
|
||||
def test_addmodules_sanity_check(self, mock_lookup, tmp_path, mock_data):
|
||||
"""Verify addmodules raises ValueError if the dictionary key doesn't match the inner ID."""
|
||||
# Corrupt the mock data
|
||||
mock_data["lastStarport"]["modules"] = {"999": {"id": 111, "name": "WrongID"}}
|
||||
|
||||
with pytest.raises(ValueError, match="id: 999 != 111"):
|
||||
collate.addmodules(mock_data)
|
||||
@@ -0,0 +1,43 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test the common utils module."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
import common_utils
|
||||
|
||||
|
||||
class TestCommonUtils:
|
||||
|
||||
def test_log_locale(self):
|
||||
"""Verify that log_locale calls the logger with locale information."""
|
||||
with patch("common_utils.logger.debug") as mock_log:
|
||||
common_utils.log_locale("TestPrefix")
|
||||
|
||||
assert mock_log.called
|
||||
args, _ = mock_log.call_args
|
||||
assert "TestPrefix" in args[0]
|
||||
|
||||
def test_ensure_on_screen_non_windows(self):
|
||||
"""Verify that the function safely does nothing on non-Windows platforms."""
|
||||
# Force the platform to something other than win32
|
||||
with patch("sys.platform", "darwin"):
|
||||
mock_self = MagicMock()
|
||||
mock_parent = MagicMock()
|
||||
|
||||
common_utils.ensure_on_screen(mock_self, mock_parent)
|
||||
|
||||
# On non-win32, the function should return early without calling geometry
|
||||
mock_self.geometry.assert_not_called()
|
||||
|
||||
def test_ensure_on_screen_exception_handling(self):
|
||||
"""Ensure that if win32api fails, the function doesn't crash the app."""
|
||||
with patch("sys.platform", "win32"):
|
||||
mock_win32api = MagicMock()
|
||||
mock_win32api.MonitorFromWindow.side_effect = Exception("OS Error")
|
||||
|
||||
with patch.dict("sys.modules", {"win32api": mock_win32api}):
|
||||
mock_self = MagicMock()
|
||||
common_utils.ensure_on_screen(mock_self, MagicMock())
|
||||
|
||||
# Geometry should not be set if calculation failed
|
||||
mock_self.geometry.assert_not_called()
|
||||
@@ -0,0 +1,187 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test the Config system."""
|
||||
|
||||
import sys
|
||||
import tomli_w
|
||||
import semantic_version
|
||||
from unittest.mock import patch, MagicMock, mock_open
|
||||
|
||||
import config
|
||||
from config import (
|
||||
Config,
|
||||
git_shorthash_from_head,
|
||||
appversion,
|
||||
appversion_nobuild,
|
||||
)
|
||||
|
||||
|
||||
class TestGitFunctions:
|
||||
@patch("subprocess.run")
|
||||
def test_git_shorthash_success(self, mock_run):
|
||||
mock_run.side_effect = [
|
||||
MagicMock(stdout="a1b2c3d\n", stderr=None), # rev-parse
|
||||
MagicMock(stdout="", stderr=None), # diff (clean)
|
||||
]
|
||||
assert git_shorthash_from_head() == "a1b2c3d"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_git_shorthash_dirty(self, mock_run):
|
||||
mock_run.side_effect = [
|
||||
MagicMock(stdout="a1b2c3d\n", stderr=None),
|
||||
MagicMock(stdout=" file.py | 1 +", stderr=None),
|
||||
]
|
||||
assert git_shorthash_from_head() == "a1b2c3d.DIRTY"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_git_shorthash_failure(self, mock_run):
|
||||
mock_run.side_effect = FileNotFoundError("git not found")
|
||||
assert git_shorthash_from_head() is None
|
||||
|
||||
|
||||
class TestAppVersion:
|
||||
def setup_method(self):
|
||||
# Reset cached version before each test
|
||||
config._cached_version = None
|
||||
|
||||
@patch.object(sys, "frozen", True, create=True)
|
||||
@patch("sys.path", ["/app/library.zip"])
|
||||
def test_appversion_frozen(self):
|
||||
with patch("builtins.open", mock_open(read_data="frozenhash")):
|
||||
with patch("pathlib.Path.parent", new_callable=MagicMock) as mock_parent:
|
||||
# Bypass Path logic complexity
|
||||
mock_parent.return_value.__truediv__.return_value = MagicMock()
|
||||
|
||||
ver = appversion()
|
||||
# _static_appversion is "6.1.1" in source
|
||||
assert str(ver) == "6.1.1+frozenhash"
|
||||
|
||||
def test_appversion_nobuild(self):
|
||||
with patch(
|
||||
"config.appversion", return_value=semantic_version.Version("6.1.1+deadbeef")
|
||||
):
|
||||
assert str(appversion_nobuild()) == "6.1.1"
|
||||
|
||||
|
||||
class TestConfigClass:
|
||||
|
||||
def test_init_creates_default_toml(self, mock_app_dir):
|
||||
"""Test that initializing Config creates config.toml if missing."""
|
||||
toml_file = mock_app_dir / "config.toml"
|
||||
assert not toml_file.exists()
|
||||
|
||||
with patch("config.Config._init_platform"):
|
||||
Config(mock_app_dir)
|
||||
|
||||
assert toml_file.exists()
|
||||
assert "generated" in toml_file.read_text()
|
||||
|
||||
def test_load_existing_settings(self, mock_app_dir):
|
||||
toml_file = mock_app_dir / "config.toml"
|
||||
data = {
|
||||
"settings": {
|
||||
"theme": "dark",
|
||||
# Need plugin_dir or __init__ will auto-add it
|
||||
"plugin_dir": str(mock_app_dir / "plugins"),
|
||||
}
|
||||
}
|
||||
with open(toml_file, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
|
||||
with patch("config.Config._init_platform"):
|
||||
cfg = Config(mock_app_dir)
|
||||
|
||||
assert cfg.get("theme") == "dark"
|
||||
|
||||
def test_set_saves_to_disk(self, mock_app_dir):
|
||||
"""Test that set() updates memory and writes to disk."""
|
||||
with patch("config.Config._init_platform"):
|
||||
c = Config(mock_app_dir)
|
||||
|
||||
toml_path = c.toml_path
|
||||
c.set("saved_value", "persistence_check")
|
||||
|
||||
# Read file directly to verify
|
||||
with open(toml_path, "rb") as f:
|
||||
import tomllib
|
||||
|
||||
data = tomllib.load(f)
|
||||
|
||||
assert data["settings"]["saved_value"] == "persistence_check"
|
||||
|
||||
def test_recovery_broken_toml(self, mock_app_dir):
|
||||
"""Test that the system recovers from a corrupted TOML file."""
|
||||
toml_path = mock_app_dir / "config.toml"
|
||||
|
||||
# Write garbage
|
||||
with open(toml_path, "w") as f:
|
||||
f.write("This is not valid TOML { [ } ]")
|
||||
|
||||
with patch("config.Config._init_platform"):
|
||||
cfg = Config(mock_app_dir)
|
||||
|
||||
assert "plugin_dir" in cfg.settings
|
||||
|
||||
# The broken file should be renamed
|
||||
broken_files = list(mock_app_dir.glob("config.toml.broken.*"))
|
||||
assert len(broken_files) > 0
|
||||
|
||||
def test_recovery_from_backup(self, mock_app_dir):
|
||||
"""Test recovering from .bak if .toml is broken."""
|
||||
toml_path = mock_app_dir / "config.toml"
|
||||
bak_path = mock_app_dir / "config.toml.bak"
|
||||
|
||||
with open(toml_path, "w") as f:
|
||||
f.write("Garbage")
|
||||
|
||||
valid_data = {
|
||||
"settings": {"recovered": True, "plugin_dir": str(mock_app_dir / "plugins")}
|
||||
}
|
||||
with open(bak_path, "wb") as f:
|
||||
tomli_w.dump(valid_data, f)
|
||||
|
||||
with patch("config.Config._init_platform"):
|
||||
cfg = Config(mock_app_dir)
|
||||
|
||||
assert cfg.get_bool("recovered") is True
|
||||
|
||||
# This confirms that the rename `bak.replace(self.toml_path)` inside `_load` worked.
|
||||
# If this fails, it means `tomllib.load` on the backup file failed.
|
||||
assert not bak_path.exists()
|
||||
assert toml_path.exists()
|
||||
|
||||
def test_reload_from_path(self, mock_app_dir, tmp_path):
|
||||
"""Test hot-swapping the config file."""
|
||||
with patch("config.Config._init_platform"):
|
||||
c = Config(mock_app_dir)
|
||||
|
||||
# Create a second config file elsewhere
|
||||
alt_dir = tmp_path / "alt"
|
||||
alt_dir.mkdir()
|
||||
alt_config = alt_dir / "alternate.toml"
|
||||
|
||||
data = {
|
||||
"settings": {
|
||||
"mode": "alternate",
|
||||
"plugin_dir": str(mock_app_dir / "plugins"),
|
||||
}
|
||||
}
|
||||
with open(alt_config, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
|
||||
# Reload
|
||||
c.reload_from_path(alt_config)
|
||||
|
||||
assert c.toml_path == alt_config
|
||||
assert c.get("mode") == "alternate"
|
||||
|
||||
|
||||
def test_init_platform_calls_linux_helper(mock_app_dir):
|
||||
with patch("sys.platform", "linux"):
|
||||
mock_module = sys.modules["config.linux"]
|
||||
|
||||
# We must NOT patch _init_platform here, because we want it to run
|
||||
# and call the mocked module
|
||||
Config(mock_app_dir)
|
||||
|
||||
mock_module.linux_helper.assert_called_once()
|
||||
@@ -0,0 +1,131 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test the CSV Export System."""
|
||||
|
||||
import pytest
|
||||
import csv
|
||||
import commodity
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_capi_data():
|
||||
"""Provides a standard CAPI data structure for testing."""
|
||||
return {
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"lastSystem": {"name": "Sol"},
|
||||
"lastStarport": {
|
||||
"name": "Daedalus",
|
||||
"commodities": [
|
||||
{
|
||||
"id": 123,
|
||||
"name": "Gold",
|
||||
"sellPrice": 1000,
|
||||
"buyPrice": 1100,
|
||||
"demand": 500,
|
||||
"demandBracket": 3,
|
||||
"stock": 10,
|
||||
"stockBracket": 1,
|
||||
"meanPrice": 1050,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestCSVExport:
|
||||
|
||||
def test_delimiter_mapping(self, tmp_path, sample_capi_data):
|
||||
"""Verify that 'kind' correctly changes the file delimiter."""
|
||||
kinds = [
|
||||
(commodity.COMMODITY_CSV, ","),
|
||||
(commodity.COMMODITY_TAB, "\t"),
|
||||
(commodity.COMMODITY_PIPE, "|"),
|
||||
(commodity.COMMODITY_SEMICOLON, ";"),
|
||||
]
|
||||
|
||||
for kind, expected_delim in kinds:
|
||||
fname = tmp_path / f"test_{kind}.txt"
|
||||
commodity.export(sample_capi_data, kind=kind, filename=fname)
|
||||
|
||||
# Read first line to check delimiter
|
||||
content = fname.read_text(encoding="utf-8")
|
||||
assert expected_delim in content
|
||||
|
||||
def test_legacy_csv_format_row_length(self, tmp_path, sample_capi_data):
|
||||
"""COMMODITY_CSV (Legacy) should have 10 columns."""
|
||||
fname = tmp_path / "legacy.csv"
|
||||
commodity.export(sample_capi_data, kind=commodity.COMMODITY_CSV, filename=fname)
|
||||
|
||||
with open(fname, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f, delimiter=",")
|
||||
header = next(reader)
|
||||
first_row = next(reader)
|
||||
|
||||
assert len(header) == 10
|
||||
assert "Average" not in header
|
||||
assert len(first_row) == 10
|
||||
|
||||
def test_new_format_row_length(self, tmp_path, sample_capi_data):
|
||||
"""Other formats (TAB, PIPE, etc) should have 12 columns."""
|
||||
fname = tmp_path / "new_format.txt"
|
||||
commodity.export(sample_capi_data, kind=commodity.COMMODITY_TAB, filename=fname)
|
||||
|
||||
with open(fname, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f, delimiter="\t")
|
||||
header = next(reader)
|
||||
first_row = next(reader)
|
||||
|
||||
assert len(header) == 12
|
||||
assert "Average" in header
|
||||
assert "FDevID" in header
|
||||
assert len(first_row) == 12
|
||||
|
||||
def test_data_value_mapping(self, tmp_path, sample_capi_data):
|
||||
"""Check if specific fields like demandBracket are mapped via bracketmap."""
|
||||
fname = tmp_path / "data_check.csv"
|
||||
commodity.export(
|
||||
sample_capi_data, kind=commodity.COMMODITY_CSV_NEW, filename=fname
|
||||
)
|
||||
|
||||
with open(fname, newline="", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f, delimiter=",")
|
||||
row = next(reader)
|
||||
|
||||
assert row["System"] == "Sol"
|
||||
assert row["Commodity"] == "Gold"
|
||||
assert row["demandBracket"] == "High" # Mapped from 3
|
||||
assert row["stockBracket"] == "Low" # Mapped from 1
|
||||
assert row["FDevID"] == "123"
|
||||
|
||||
def test_missing_data_handling(self, tmp_path):
|
||||
"""Ensure the exporter handles None values gracefully by writing empty strings."""
|
||||
minimal_data = {
|
||||
"timestamp": "now",
|
||||
"lastSystem": {"name": "Sothis"},
|
||||
"lastStarport": {
|
||||
"name": "MiningHub",
|
||||
"commodities": [
|
||||
{
|
||||
"name": "Iron",
|
||||
"sellPrice": None,
|
||||
"buyPrice": None,
|
||||
"demandBracket": None,
|
||||
"stockBracket": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
fname = tmp_path / "empty.csv"
|
||||
commodity.export(minimal_data, kind=commodity.COMMODITY_CSV, filename=fname)
|
||||
|
||||
with open(fname, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
next(reader) # skip header
|
||||
row = next(reader)
|
||||
|
||||
# Indices for Sell, Buy, Demand, Supply
|
||||
# ['Sothis', 'MiningHub', 'Iron', '', '', '', '', '', '', 'now']
|
||||
assert row[3] == ""
|
||||
assert row[4] == ""
|
||||
assert row[5] == ""
|
||||
assert row[7] == ""
|
||||
@@ -0,0 +1,99 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test the Dashboard for reading Status.json."""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
import dashboard
|
||||
|
||||
|
||||
class TestDashboard:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_root(self):
|
||||
"""Mock tkinter root window."""
|
||||
root = MagicMock()
|
||||
return root
|
||||
|
||||
@pytest.fixture
|
||||
def temp_journal_dir(self, tmp_path):
|
||||
"""Create a temporary directory simulating the game journal path."""
|
||||
d = tmp_path / "journal"
|
||||
d.mkdir()
|
||||
# Create a dummy Status.json
|
||||
status_file = d / "Status.json"
|
||||
status_file.write_text(
|
||||
json.dumps(
|
||||
{"timestamp": "2026-01-25T12:00:00Z", "event": "Status", "Flags": 12345}
|
||||
)
|
||||
)
|
||||
return d
|
||||
|
||||
def test_start_logic_windows(self, mock_root, temp_journal_dir):
|
||||
"""Verify observer setup on Windows platform."""
|
||||
with patch("sys.platform", "win32"), patch(
|
||||
"dashboard.Observer"
|
||||
) as mock_obs_cls, patch("dashboard.config") as mock_config:
|
||||
mock_config.get_str.return_value = str(temp_journal_dir)
|
||||
db = dashboard.Dashboard()
|
||||
|
||||
success = db.start(mock_root, started=0)
|
||||
|
||||
assert success is True
|
||||
assert db.observer is not None
|
||||
mock_obs_cls.return_value.start.assert_called_once()
|
||||
# Ensure a poll is scheduled to catch pre-existing data
|
||||
mock_root.after.assert_called()
|
||||
|
||||
def test_start_logic_linux_polling(self, mock_root, temp_journal_dir):
|
||||
"""Verify polling behavior on Linux/non-Windows platforms."""
|
||||
with patch("sys.platform", "linux"), patch("dashboard.config") as mock_config:
|
||||
mock_config.get_str.return_value = str(temp_journal_dir)
|
||||
db = dashboard.Dashboard()
|
||||
|
||||
success = db.start(mock_root, started=0)
|
||||
|
||||
assert success is True
|
||||
assert db.observer is None # Should be None on Linux
|
||||
# Ensure the polling loop is started
|
||||
mock_root.after.assert_called()
|
||||
|
||||
def test_process_valid_json(self, mock_root, temp_journal_dir):
|
||||
"""Verify Status.json content is parsed and triggers a UI event."""
|
||||
db = dashboard.Dashboard()
|
||||
db.currentdir = str(temp_journal_dir)
|
||||
db.root = mock_root
|
||||
db.session_start = 0 # Ensure timestamp check passes
|
||||
|
||||
db.process()
|
||||
|
||||
assert db.status["event"] == "Status"
|
||||
mock_root.event_generate.assert_called_with("<<DashboardEvent>>", when="tail")
|
||||
|
||||
def test_process_stale_data_filter(self, mock_root, temp_journal_dir):
|
||||
"""Verify that status updates from previous sessions are ignored."""
|
||||
db = dashboard.Dashboard()
|
||||
db.currentdir = str(temp_journal_dir)
|
||||
db.root = mock_root
|
||||
|
||||
# Set session start to a future date relative to the file timestamp
|
||||
db.session_start = 2000000000
|
||||
|
||||
db.process()
|
||||
|
||||
# Status should remain empty because file timestamp < session_start
|
||||
assert db.status == {}
|
||||
mock_root.event_generate.assert_not_called()
|
||||
|
||||
def test_poll_recursion(self, mock_root):
|
||||
"""Verify that poll schedules itself for the next interval."""
|
||||
db = dashboard.Dashboard()
|
||||
db.root = mock_root
|
||||
db.currentdir = "/fake/dir"
|
||||
|
||||
with patch.object(db, "process"):
|
||||
db.poll(first_time=False)
|
||||
|
||||
# Should call after() to schedule the next poll in 1000ms
|
||||
mock_root.after.assert_called_with(1000, db.poll)
|
||||
@@ -0,0 +1,85 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test the EDMC Logger."""
|
||||
|
||||
import logging
|
||||
import pytest
|
||||
from EDMCLogging import get_plugin_logger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log_capture():
|
||||
class RecordCapture(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.records = []
|
||||
|
||||
def emit(self, record):
|
||||
self.records.append(record)
|
||||
|
||||
capture = RecordCapture()
|
||||
logger = get_plugin_logger("test_plugin")
|
||||
logger.addHandler(capture)
|
||||
yield capture
|
||||
logger.removeHandler(capture)
|
||||
|
||||
|
||||
class TestLoggingIntrospection:
|
||||
"""
|
||||
Addresses the TODO items 1-11 in EDMCLogging.py:
|
||||
Testing various call depths and class structures.
|
||||
"""
|
||||
|
||||
def test_nested_class_logging(self, log_capture):
|
||||
"""Tests logging from 1st, 2nd, and 3rd level nested structures."""
|
||||
|
||||
class Level1:
|
||||
class Level2:
|
||||
def log_here(self):
|
||||
logging.getLogger("EDMarketConnector.test_plugin").info("deep")
|
||||
|
||||
obj = Level1.Level2()
|
||||
obj.log_here()
|
||||
|
||||
record = log_capture.records[0]
|
||||
assert "Level1.Level2.log_here" in record.qualname
|
||||
|
||||
def test_property_logging(self, log_capture):
|
||||
"""Verifies the specific 'property' handling logic in caller_attributes."""
|
||||
|
||||
class PropertyTest:
|
||||
@property
|
||||
def trace_me(self):
|
||||
logging.getLogger("EDMarketConnector.test_plugin").info("prop")
|
||||
return True
|
||||
|
||||
_ = PropertyTest().trace_me
|
||||
record = log_capture.records[0]
|
||||
assert "(property)" in record.qualname
|
||||
|
||||
|
||||
class TestTraceLevel:
|
||||
"""Verify the custom TRACE and TRACE_ALL levels work."""
|
||||
|
||||
def test_trace_methods_exist(self):
|
||||
logger = get_plugin_logger("test_trace")
|
||||
assert hasattr(logger, "trace")
|
||||
assert hasattr(logger, "trace_if")
|
||||
|
||||
def test_trace_logging(self, log_capture):
|
||||
logger = get_plugin_logger("test_plugin")
|
||||
logger.trace("testing trace")
|
||||
|
||||
record = log_capture.records[0]
|
||||
assert record.levelno == 5 # LEVEL_TRACE
|
||||
assert record.levelname == "TRACE"
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
def test_osthreadid_present(self, log_capture):
|
||||
logger = get_plugin_logger("test_plugin")
|
||||
logger.info("thread test")
|
||||
|
||||
record = log_capture.records[0]
|
||||
assert hasattr(record, "osthreadid")
|
||||
assert isinstance(record.osthreadid, int)
|
||||
@@ -0,0 +1,113 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Tets the l10n system."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
import l10n
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_l10n_dir(tmp_path):
|
||||
"""Creates a mock L10n directory with some sample .strings files."""
|
||||
l10n_dir = tmp_path / "L10n"
|
||||
l10n_dir.mkdir()
|
||||
|
||||
# Create a dummy Spanish translation file
|
||||
es_file = l10n_dir / "es.strings"
|
||||
es_file.write_text('"Hello" = "Hola";\n"!Language" = "Español";', encoding="utf-8")
|
||||
|
||||
# Create a dummy German translation file
|
||||
de_file = l10n_dir / "de.strings"
|
||||
de_file.write_text('"Hello" = "Hallo";', encoding="utf-8")
|
||||
|
||||
return l10n_dir
|
||||
|
||||
|
||||
class TestTranslations:
|
||||
|
||||
def test_regex_parsing(self):
|
||||
"""Verify that the TRANS_RE regex correctly parses OSX-style string lines."""
|
||||
line = ' "Key Name" = "Translated Value" ; '
|
||||
match = l10n.Translations.TRANS_RE.match(line)
|
||||
assert match is not None
|
||||
assert match.group(1) == "Key Name"
|
||||
assert match.group(2) == "Translated Value"
|
||||
|
||||
def test_regex_escaped_quotes(self):
|
||||
"""Verify that escaped quotes in strings are handled by the regex."""
|
||||
line = r' "Click \"Here\"" = "Presione \"Aquí\"" ;'
|
||||
match = l10n.Translations.TRANS_RE.match(line)
|
||||
assert match is not None
|
||||
assert match.group(1) == r"Click \"Here\""
|
||||
assert match.group(2) == r"Presione \"Aquí\""
|
||||
|
||||
def test_available_languages(self, mock_l10n_dir):
|
||||
"""Verify available() scans the directory for .strings files."""
|
||||
trans = l10n.Translations()
|
||||
with patch.object(l10n.Translations, "respath", return_value=mock_l10n_dir):
|
||||
langs = trans.available()
|
||||
assert "es" in langs
|
||||
assert "de" in langs
|
||||
assert "en" not in langs # English is fallback, not usually a file
|
||||
|
||||
def test_contents_loading(self, mock_l10n_dir):
|
||||
"""Test loading a specific language file into a dictionary."""
|
||||
trans = l10n.Translations()
|
||||
with patch.object(l10n.Translations, "respath", return_value=mock_l10n_dir):
|
||||
data = trans.contents("es")
|
||||
assert data["Hello"] == "Hola"
|
||||
assert data["!Language"] == "Español"
|
||||
|
||||
def test_translate_fallback(self):
|
||||
"""Test that missing translations return the original string."""
|
||||
trans = l10n.Translations()
|
||||
trans.install_dummy()
|
||||
# Should return the key itself if no translation is loaded
|
||||
assert trans.translate("Unknown Key") == "Unknown Key"
|
||||
|
||||
def test_install_logic_selection(self, mock_l10n_dir):
|
||||
"""Test that install() picks the correct language based on preferred languages."""
|
||||
trans = l10n.Translations()
|
||||
with patch.object(l10n.Translations, "respath", return_value=mock_l10n_dir):
|
||||
with patch(
|
||||
"l10n.Locale.preferred_languages",
|
||||
return_value=["fr-FR", "es-ES", "en-US"],
|
||||
):
|
||||
# It should skip 'fr' (missing) and pick 'es' because 'es-ES' base matches 'es'
|
||||
trans.install()
|
||||
assert trans.translations[None]["Hello"] == "Hola"
|
||||
|
||||
|
||||
class TestLocaleUtils:
|
||||
|
||||
def test_string_from_number_formatting(self):
|
||||
"""Test locale-aware number to string conversion."""
|
||||
val = 1234.56
|
||||
# Formatting with 1 decimal place
|
||||
res = l10n.Locale.string_from_number(val, decimals=1)
|
||||
|
||||
# Instead of looking for '1234', we verify the digits are present
|
||||
# regardless of whether the locale uses '1,234.6', '1.234,6', or '1 234.6'
|
||||
digits_only = "".join(filter(str.isdigit, res))
|
||||
|
||||
assert "1234" in digits_only
|
||||
# Verify the decimal rounding (56 rounded to 1 decimal is 6)
|
||||
assert "6" in digits_only[-1]
|
||||
|
||||
def test_number_from_string(self):
|
||||
"""Test string to number conversion logic."""
|
||||
# Using a value that is likely safe across common locales
|
||||
assert l10n.Locale.number_from_string("100") == 100
|
||||
assert l10n.Locale.number_from_string("not_a_number") is None
|
||||
|
||||
def test_preferred_languages_mapping(self):
|
||||
"""Verify the zh-CN to zh-Hans hack works."""
|
||||
with patch("sys.platform", "win32"):
|
||||
# Mock the Windows specific API return
|
||||
with patch("l10n._wszarray_to_list", return_value=["en-US", "zh-CN"]):
|
||||
# We mock the DLL call to avoid crashes
|
||||
with patch("l10n.GetUserPreferredUILanguages", return_value=True):
|
||||
langs = l10n.Locale.preferred_languages()
|
||||
assert "zh-Hans" in langs
|
||||
assert "zh-CN" not in langs
|
||||
@@ -0,0 +1,39 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test Loadout system."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
import loadout
|
||||
|
||||
|
||||
class TestLoadout:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_capi_data(self):
|
||||
"""Simulates the CAPI data structure required for export."""
|
||||
return {
|
||||
"ship": {
|
||||
"name": "Anaconda",
|
||||
"shipName": "Starship One",
|
||||
"modules": {"Slot01": {"module": {"id": 1, "name": "Int_Engine"}}},
|
||||
},
|
||||
"commander": {"name": "Tester"},
|
||||
"lastSystem": {"name": "Sol"},
|
||||
}
|
||||
|
||||
@patch("loadout.companion.ship")
|
||||
@patch("loadout.json.dumps")
|
||||
def test_export_with_requested_filename(
|
||||
self, mock_dumps, mock_ship_func, mock_capi_data, tmp_path
|
||||
):
|
||||
"""Verify that providing a filename bypasses auto-naming and writes directly."""
|
||||
mock_ship_func.return_value = {"id": "mock_ship"}
|
||||
mock_dumps.return_value = '{"dummy": "json"}'
|
||||
|
||||
target_file = tmp_path / "manual_export.json"
|
||||
|
||||
loadout.export(mock_capi_data, requested_filename=str(target_file))
|
||||
|
||||
assert target_file.exists()
|
||||
assert target_file.read_text() == '{"dummy": "json"}'
|
||||
@@ -0,0 +1,105 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test myNotebook with Pytest."""
|
||||
|
||||
import pytest
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from unittest.mock import MagicMock, patch
|
||||
import myNotebook
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def root():
|
||||
"""Create a single root window for all tests to avoid 'TclError: can't invoke "grid" command'."""
|
||||
root = tk.Tk()
|
||||
root.withdraw() # Hide the window
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
|
||||
class TestWidgetHacks:
|
||||
def test_notebook_colors_windows(self, root):
|
||||
"""Verify that on Windows, specific styles are configured with PAGEBG."""
|
||||
with patch("sys.platform", "win32"):
|
||||
nb = myNotebook.Notebook(root)
|
||||
style = ttk.Style()
|
||||
# PAGEBG is 'SystemWindow' on win32
|
||||
assert style.lookup("nb.TFrame", "background") == "SystemWindow"
|
||||
nb.destroy()
|
||||
|
||||
def test_label_colors_windows(self, root):
|
||||
"""Verify Label uses system window colors on Windows."""
|
||||
with patch("sys.platform", "win32"):
|
||||
# Ensure we are using the module's constants
|
||||
label = myNotebook.Label(root, text="Test")
|
||||
|
||||
# Cast to str() to handle Tcl/Tk color objects
|
||||
assert str(label["background"]) == "SystemWindow"
|
||||
assert str(label["foreground"]) == "SystemWindowText"
|
||||
|
||||
label.destroy()
|
||||
|
||||
|
||||
class TestEntryMenu:
|
||||
def test_select_all(self, root):
|
||||
"""Verify select_all logic correctly selects text range."""
|
||||
entry = myNotebook.EntryMenu(root)
|
||||
entry.insert(0, "Hello World")
|
||||
entry.select_all()
|
||||
|
||||
assert entry.selection_present() is True
|
||||
entry.destroy()
|
||||
|
||||
@patch("tkinter.messagebox.showwarning")
|
||||
@patch("PIL.ImageGrab.grabclipboard")
|
||||
def test_paste_image_refusal(self, mock_grab, mock_warn, root):
|
||||
"""Verify that trying to paste an image triggers a warning and aborts."""
|
||||
mock_grab.return_value = MagicMock(spec=["save"]) # Simulate an Image object
|
||||
entry = myNotebook.EntryMenu(root)
|
||||
|
||||
entry.paste()
|
||||
|
||||
mock_warn.assert_called_once()
|
||||
assert entry.get() == "" # Should not have pasted anything
|
||||
entry.destroy()
|
||||
|
||||
|
||||
class TestScrollableNotebook:
|
||||
def test_add_synchronization(self, root):
|
||||
"""Ensure adding a tab adds to both the tab bar and the content notebook."""
|
||||
snb = myNotebook.ScrollableNotebook(root)
|
||||
frame = ttk.Frame(snb)
|
||||
|
||||
snb.add(frame, text="Tab 1")
|
||||
|
||||
assert len(snb.notebookTab.tabs()) == 1
|
||||
assert len(snb.notebookContent.tabs()) == 1
|
||||
# Content tab should have empty text as per the code
|
||||
assert snb.notebookContent.tab(0, "text") == ""
|
||||
snb.destroy()
|
||||
|
||||
def test_forget_synchronization(self, root):
|
||||
"""Verify forget removes the child and the corresponding tabs."""
|
||||
snb = myNotebook.ScrollableNotebook(root)
|
||||
frame = ttk.Frame(snb)
|
||||
snb.add(frame, text="Tab 1")
|
||||
|
||||
tab_id = snb.tabs()[0]
|
||||
snb.forget(tab_id)
|
||||
|
||||
assert len(snb.tabs()) == 0
|
||||
assert len(snb.notebookContent.tabs()) == 0
|
||||
snb.destroy()
|
||||
|
||||
def test_slide_logic_bounds(self, root):
|
||||
"""Verify left slide doesn't move if already at 0."""
|
||||
snb = myNotebook.ScrollableNotebook(root)
|
||||
snb.xLocation = 0
|
||||
|
||||
# _left_slide returns True if it moved, False otherwise
|
||||
moved = snb._left_slide(None)
|
||||
|
||||
assert moved is False
|
||||
assert snb.xLocation == 0
|
||||
snb.destroy()
|
||||
@@ -0,0 +1,146 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test outfitting."""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
import outfitting
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_modules_json(tmp_path):
|
||||
"""Create a dummy modules.json file for the lookup function."""
|
||||
data = {
|
||||
"hpt_beamlaser_fixed_medium": {"mass": 4.0},
|
||||
"federation_dropship_armour_grade1": {"mass": 0.0},
|
||||
"int_engine_size2_class1": {"mass": 2.0},
|
||||
}
|
||||
modules_path = tmp_path / "modules.json"
|
||||
modules_path.write_text(json.dumps(data))
|
||||
return modules_path
|
||||
|
||||
|
||||
class TestOutfitting:
|
||||
|
||||
def test_lookup_armour(self, mock_modules_json):
|
||||
"""Verify that ship-specific armour is parsed correctly."""
|
||||
module = {"id": 100, "name": "Federation_Dropship_Armour_Grade1"}
|
||||
# Ship map uses internal symbol -> Display Name
|
||||
ship_map = {"federation_dropship": "Federal Dropship"}
|
||||
|
||||
with patch("outfitting.config.app_dir_path", mock_modules_json.parent):
|
||||
# Reset moduledata for the test
|
||||
outfitting.moduledata = {}
|
||||
result = outfitting.lookup(module, ship_map)
|
||||
|
||||
assert result["category"] == "standard"
|
||||
assert result["ship"] == "Federal Dropship"
|
||||
assert result["name"] == "Lightweight Alloy" # From armour_map
|
||||
assert result["rating"] == "I"
|
||||
|
||||
def test_lookup_hardpoint_weapon(self, mock_modules_json):
|
||||
"""Verify hardpoint parsing for standard weapons."""
|
||||
module = {"id": 200, "name": "Hpt_BeamLaser_Fixed_Medium"}
|
||||
|
||||
with patch("outfitting.config.app_dir_path", mock_modules_json.parent):
|
||||
outfitting.moduledata = {}
|
||||
result = outfitting.lookup(module, {})
|
||||
|
||||
assert result["category"] == "hardpoint"
|
||||
assert result["name"] == "Beam Laser"
|
||||
assert result["mount"] == "Fixed"
|
||||
assert result["class"] == "2"
|
||||
assert result["rating"] == "D" # Assuming D is mapped for Medium Fixed Beam
|
||||
|
||||
def test_lookup_skip_cosmetics(self, mock_modules_json):
|
||||
"""Verify that paintjobs and decals return None as uninteresting."""
|
||||
module = {"id": 300, "name": "PaintJob_Anaconda_Default"}
|
||||
|
||||
with patch("outfitting.config.app_dir_path", mock_modules_json.parent):
|
||||
result = outfitting.lookup(module, {})
|
||||
assert result is None
|
||||
|
||||
def test_lookup_error_missing_name(self):
|
||||
"""Verify ValueError is raised if the name field is missing."""
|
||||
with pytest.raises(ValueError, match="missing a 'name' field"):
|
||||
outfitting.lookup({"id": 400}, {})
|
||||
|
||||
def test_lookup_unknown_prefix(self, mock_modules_json):
|
||||
"""Verify that an unknown module prefix triggers a ValueError."""
|
||||
module = {"id": 500, "name": "Unknown_Module_Size1_Class1"}
|
||||
|
||||
with patch("outfitting.config.app_dir_path", mock_modules_json.parent):
|
||||
with pytest.raises(ValueError, match="Unknown prefix"):
|
||||
outfitting.lookup(module, {})
|
||||
|
||||
@patch("outfitting.lookup")
|
||||
def test_export_structure(self, mock_lookup, tmp_path):
|
||||
"""Verify the CSV export header and row generation."""
|
||||
mock_lookup.return_value = {
|
||||
"category": "hardpoint",
|
||||
"name": "Laser",
|
||||
"mount": "Fixed",
|
||||
"guidance": "",
|
||||
"ship": "",
|
||||
"class": "1",
|
||||
"rating": "E",
|
||||
"id": 123,
|
||||
}
|
||||
|
||||
data = {
|
||||
"lastSystem": {"name": "Sol"},
|
||||
"lastStarport": {
|
||||
"name": "Daedalus",
|
||||
"modules": {"1": {"id": 1, "name": "M_Name"}},
|
||||
},
|
||||
"timestamp": "2026-01-25",
|
||||
}
|
||||
|
||||
export_file = tmp_path / "outfitting.csv"
|
||||
outfitting.export(data, str(export_file))
|
||||
|
||||
content = export_file.read_text()
|
||||
assert "System,Station,Category,Name" in content
|
||||
assert "Sol,Daedalus, hardpoint, Laser" in content
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_moduledata(tmp_path):
|
||||
"""Global setup for moduledata to satisfy the __debug__ mass checks."""
|
||||
mock_json_content = {
|
||||
"hpt_slugshot_fixed_large_range": {"mass": 8.0},
|
||||
"int_guardianfsdbooster_size5": {"mass": 0.0},
|
||||
"int_guardianpowerplant_size5": {"mass": 20.0},
|
||||
"hpt_plasma_pointdefence_turret_tiny": {"mass": 0.5},
|
||||
"int_dronecontrol_collection_size1_class1": {"mass": 2.0},
|
||||
"int_hyperdrive_overcharge_size6_class3": {"mass": 40.0},
|
||||
}
|
||||
modules_json = tmp_path / "modules.json"
|
||||
modules_json.write_text(json.dumps(mock_json_content))
|
||||
|
||||
with patch("outfitting.config.app_dir_path", tmp_path):
|
||||
outfitting.moduledata = {} # Force reload
|
||||
yield
|
||||
|
||||
|
||||
class TestOutfittingAdvanced:
|
||||
|
||||
def test_lookup_guardian_internal_no_class(self):
|
||||
"""Verify internal Guardian modules that lack a 'Class' in the symbol."""
|
||||
# Guardian FSD Boosters only have Size, no Class (e.g., Int_GuardianFSDBooster_Size5)
|
||||
module = {"id": 1002, "name": "Int_GuardianFSDBooster_Size5"}
|
||||
|
||||
result = outfitting.lookup(module, {})
|
||||
assert result["name"] == "Guardian FSD Booster"
|
||||
assert result["class"] == "5"
|
||||
assert result["rating"] == "H" # Hardcoded in lookup logic
|
||||
|
||||
def test_lookup_drone_control_normalization(self):
|
||||
"""Verify 'int' is popped for drone controllers to match standard_map."""
|
||||
module = {"id": 1005, "name": "Int_DroneControl_Collection_Size1_Class1"}
|
||||
|
||||
result = outfitting.lookup(module, {})
|
||||
assert result["category"] == "internal"
|
||||
assert result["name"] == "Collector Limpet Controller"
|
||||
assert result["class"] == "1"
|
||||
@@ -0,0 +1,47 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test util_ships."""
|
||||
|
||||
from unittest.mock import patch
|
||||
import util_ships
|
||||
|
||||
|
||||
class TestShipUtils:
|
||||
|
||||
def test_ship_file_name_basic(self):
|
||||
"""Verify standard ship names are returned correctly."""
|
||||
# 'SideWinder' is in the ship_name_map as 'Sidewinder'
|
||||
assert util_ships.ship_file_name(None, "SideWinder") == "Sidewinder"
|
||||
assert (
|
||||
util_ships.ship_file_name("My Corvette", "Federal_Corvette")
|
||||
== "My Corvette"
|
||||
)
|
||||
|
||||
def test_ship_file_name_suffix_removal(self):
|
||||
"""Verify that any file suffixes provided in the name are stripped."""
|
||||
assert util_ships.ship_file_name("MyShip.txt", "Sidewinder") == "MyShip"
|
||||
assert util_ships.ship_file_name("Config.json", "CobraMkIII") == "Config"
|
||||
|
||||
@patch("sys.platform", "win32")
|
||||
@patch("os.path.isreserved")
|
||||
def test_ship_file_name_reserved_windows(self, mock_isreserved):
|
||||
"""Verify that reserved Windows names get an underscore suffix."""
|
||||
mock_isreserved.return_value = True
|
||||
|
||||
# If the ship name is 'CON' (a reserved Windows device name)
|
||||
assert util_ships.ship_file_name("CON", "Sidewinder") == "CON_"
|
||||
|
||||
@patch("sys.platform", "linux")
|
||||
@patch("os.path.isreserved", create=True)
|
||||
def test_ship_file_name_reserved_linux(self, mock_isreserved):
|
||||
"""Verify that reserved names are NOT suffixed on non-Windows platforms."""
|
||||
# Even if os.path.isreserved (hypothetically) exists/returns True
|
||||
mock_isreserved.return_value = True
|
||||
|
||||
assert util_ships.ship_file_name("CON", "Sidewinder") == "CON"
|
||||
|
||||
def test_ship_file_name_strip_and_empty(self):
|
||||
"""Verify whitespace is stripped and fallback logic works."""
|
||||
assert util_ships.ship_file_name(" Cobra ", "CobraMkIII") == "Cobra"
|
||||
# If ship_name is empty string, it should fall back to type map
|
||||
assert util_ships.ship_file_name("", "anaconda") == "Anaconda"
|
||||
@@ -0,0 +1,84 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test stats system."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
import stats
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_capi_data():
|
||||
"""Simulates a full Frontier CAPI profile payload."""
|
||||
return {
|
||||
"commander": {
|
||||
"name": "Dave",
|
||||
"credits": 1000000,
|
||||
"debt": 0,
|
||||
"rank": {
|
||||
"combat": 3, # Competent
|
||||
"trade": 0, # Penniless
|
||||
"explore": 8, # Elite
|
||||
"federation": 0,
|
||||
"empire": 0,
|
||||
"power": 0,
|
||||
},
|
||||
"currentShipId": 1,
|
||||
},
|
||||
"ships": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "SideWinder",
|
||||
"value": {"total": 30000},
|
||||
"starsystem": {"name": "Sol"},
|
||||
"station": {"name": "Daedalus"},
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "CobraMkIII",
|
||||
"value": {"total": 300000},
|
||||
"starsystem": {"name": "Achenar"},
|
||||
"station": {"name": "Dawes Hub"},
|
||||
},
|
||||
],
|
||||
"lastSystem": {"name": "Lave"},
|
||||
"docked": False,
|
||||
}
|
||||
|
||||
|
||||
class TestStatsLogic:
|
||||
|
||||
def test_status_rank_mapping(self, mock_capi_data):
|
||||
"""Verify that integer ranks are correctly translated to human-readable strings."""
|
||||
# We need to mock translations 'tr.tl' to just return the input string
|
||||
with patch("stats.tr.tl", side_effect=lambda x: x):
|
||||
res = stats.status(mock_capi_data)
|
||||
|
||||
# Index 0: Cmdr Name
|
||||
assert res[0] == ["Cmdr", "Dave"]
|
||||
# Index 1: Balance
|
||||
assert res[1] == ["Balance", "1000000"]
|
||||
# Combat Rank (Index 3 based on RANK_LINES_START)
|
||||
assert res[3] == ["Combat", "Competent"]
|
||||
# Explorer Rank (Elite check)
|
||||
assert res[5] == ["Explorer", "Elite"]
|
||||
|
||||
def test_ships_sorting_current_first(self, mock_capi_data):
|
||||
"""Verify the current ship is moved to the top of the list."""
|
||||
with patch("stats.ship_name_map", {}):
|
||||
ship_list = stats.ships(mock_capi_data)
|
||||
|
||||
# CurrentShipId was 1 (Cobra), so Cobra should be first
|
||||
assert ship_list[0].type == "CobraMkIII"
|
||||
# Since the CMDR is NOT docked, the system should be 'lastSystem' (Lave)
|
||||
assert ship_list[0].system == "Lave"
|
||||
assert ship_list[0].station == ""
|
||||
|
||||
def test_ships_docked_location(self, mock_capi_data):
|
||||
"""Verify that when docked, the current ship shows its station."""
|
||||
mock_capi_data["commander"]["docked"] = True
|
||||
with patch("stats.ship_name_map", {}):
|
||||
ship_list = stats.ships(mock_capi_data)
|
||||
# When docked, it falls through to standard listify logic
|
||||
assert ship_list[0].system == "Achenar"
|
||||
assert ship_list[0].station == "Dawes Hub"
|
||||
@@ -0,0 +1,60 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test timeout session system."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from requests import Session
|
||||
import timeout_session
|
||||
|
||||
|
||||
class TestTimeoutSession:
|
||||
|
||||
def test_adapter_injects_default_timeout(self):
|
||||
"""Verify the adapter sets the timeout if none is provided in the send call."""
|
||||
adapter = timeout_session.TimeoutAdapter(timeout=42)
|
||||
|
||||
# We mock the parent HTTPAdapter.send method
|
||||
with patch("requests.adapters.HTTPAdapter.send") as mock_send:
|
||||
# Call send with timeout=None (which is what requests does by default)
|
||||
adapter.send(MagicMock(), timeout=None)
|
||||
|
||||
# Check that the kwargs passed to super().send now contains our default
|
||||
args, kwargs = mock_send.call_args
|
||||
assert kwargs["timeout"] == 42
|
||||
|
||||
def test_adapter_respects_explicit_timeout(self):
|
||||
"""Verify the adapter doesn't override a timeout if the user explicitly provided one."""
|
||||
adapter = timeout_session.TimeoutAdapter(timeout=42)
|
||||
|
||||
with patch("requests.adapters.HTTPAdapter.send") as mock_send:
|
||||
# User explicitly wants 5 seconds
|
||||
adapter.send(MagicMock(), timeout=5)
|
||||
|
||||
args, kwargs = mock_send.call_args
|
||||
assert kwargs["timeout"] == 5
|
||||
|
||||
def test_new_session_configuration(self):
|
||||
"""Verify that new_session correctly mounts the adapter and sets headers."""
|
||||
custom_timeout = 15
|
||||
session = timeout_session.new_session(timeout=custom_timeout)
|
||||
|
||||
# Check User-Agent
|
||||
assert "User-Agent" in session.headers
|
||||
|
||||
# Check mounting
|
||||
# Prefix 'https://' should be mapped to our TimeoutAdapter
|
||||
adapter = session.adapters.get("https://")
|
||||
assert isinstance(adapter, timeout_session.TimeoutAdapter)
|
||||
assert adapter.default_timeout == custom_timeout
|
||||
|
||||
def test_new_session_with_existing_object(self):
|
||||
"""Verify new_session can wrap an existing Session object."""
|
||||
existing_session = Session()
|
||||
existing_session.headers["X-Test"] = "Existing"
|
||||
|
||||
wrapped_session = timeout_session.new_session(session=existing_session)
|
||||
|
||||
assert wrapped_session.headers["X-Test"] == "Existing"
|
||||
assert isinstance(
|
||||
wrapped_session.adapters.get("http://"), timeout_session.TimeoutAdapter
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
# flake8: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Test the Updater."""
|
||||
|
||||
import pytest
|
||||
import pathlib
|
||||
import hashlib
|
||||
import requests
|
||||
from unittest.mock import MagicMock, patch
|
||||
import semantic_version
|
||||
import update
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_directory(tmp_path):
|
||||
"""Creates a temporary directory for update files."""
|
||||
d = tmp_path / "FDevIDs"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
class TestFileNormalization:
|
||||
def test_read_normalized_file_basic(self, tmp_path):
|
||||
"""Verify that line endings are normalized to \n and whitespace is stripped."""
|
||||
p = tmp_path / "test.txt"
|
||||
# Write with CRLF and trailing spaces
|
||||
p.write_bytes(b"line1\r\nline2 \n")
|
||||
|
||||
content, hash_val, newline = update.read_normalized_file(p)
|
||||
|
||||
assert content == "line1\nline2"
|
||||
assert newline == "\r\n"
|
||||
assert hash_val == hashlib.sha256(b"line1\nline2").hexdigest()
|
||||
|
||||
def test_read_normalized_file_missing(self, tmp_path):
|
||||
p = tmp_path / "nonexistent.txt"
|
||||
assert update.read_normalized_file(p) is None
|
||||
|
||||
|
||||
class TestNetworkUpdates:
|
||||
@patch("requests.get")
|
||||
def test_fetch_remote_file_success(self, mock_get):
|
||||
"""Verify remote file fetching and normalization."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "remote_line1\r\nremote_line2"
|
||||
mock_response.status_code = 200
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
content, hash_val = update.fetch_remote_file("http://fake.url")
|
||||
|
||||
assert content == "remote_line1\nremote_line2"
|
||||
assert hash_val == hashlib.sha256(b"remote_line1\nremote_line2").hexdigest()
|
||||
|
||||
@patch("requests.get")
|
||||
@patch("time.sleep", return_value=None) # Speed up test
|
||||
def test_fetch_remote_file_retries(self, mock_sleep, mock_get):
|
||||
"""Verify that fetch_remote_file retries on failure."""
|
||||
mock_get.side_effect = requests.RequestException("Timeout")
|
||||
|
||||
result = update.fetch_remote_file("http://fake.url")
|
||||
|
||||
assert result is None
|
||||
assert mock_get.call_count == 3 # Matches HTTP_RETRIES
|
||||
|
||||
|
||||
class TestUpdateLogic:
|
||||
def test_update_single_file_no_change(self, mock_directory):
|
||||
"""Verify no write occurs if hashes match."""
|
||||
filename = "commodity.csv"
|
||||
file_path = mock_directory / filename
|
||||
content = "already_up_to_date"
|
||||
file_path.write_text(content)
|
||||
|
||||
remote_content = content
|
||||
remote_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||
|
||||
with patch(
|
||||
"update.fetch_remote_file", return_value=(remote_content, remote_hash)
|
||||
):
|
||||
with patch.object(pathlib.Path, "write_text") as mock_write:
|
||||
update.update_single_file(mock_directory, filename, "http://url")
|
||||
mock_write.assert_not_called()
|
||||
|
||||
def test_update_single_file_changes(self, mock_directory):
|
||||
"""Verify file is updated when remote content differs."""
|
||||
filename = "commodity.csv"
|
||||
file_path = mock_directory / filename
|
||||
file_path.write_text("old_content")
|
||||
|
||||
new_content = "new_content"
|
||||
new_hash = hashlib.sha256(new_content.encode()).hexdigest()
|
||||
|
||||
with patch("update.fetch_remote_file", return_value=(new_content, new_hash)):
|
||||
update.update_single_file(mock_directory, filename, "http://url")
|
||||
assert file_path.read_text() == "new_content"
|
||||
|
||||
|
||||
class TestVersionChecking:
|
||||
@patch("requests.get")
|
||||
def test_check_appcast_newer_version(self, mock_get):
|
||||
"""Verify that check_appcast identifies a newer version correctly."""
|
||||
# Mock XML response from Sparkle feed
|
||||
xml_data = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Version 6.0.0</title>
|
||||
<enclosure sparkle:version="6.0.0" sparkle:os="windows" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"""
|
||||
|
||||
mock_get.return_value.text = xml_data
|
||||
|
||||
# Patch appversion_nobuild to be lower than the XML version
|
||||
with patch(
|
||||
"update.appversion_nobuild", return_value=semantic_version.Version("5.0.0")
|
||||
):
|
||||
updater = update.Updater(provider="internal")
|
||||
result = updater.check_appcast()
|
||||
|
||||
assert result is not None
|
||||
assert result.version == "6.0.0"
|
||||
assert result.title == "Version 6.0.0"
|
||||
|
||||
@patch("requests.get")
|
||||
def test_check_appcast_older_version(self, mock_get):
|
||||
"""Verify no version returned if local version is newer."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = """<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel><item>
|
||||
<title>Old Version</title>
|
||||
<enclosure sparkle:version="4.0.0" sparkle:os="windows" />
|
||||
</item></channel>
|
||||
</rss>"""
|
||||
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with patch(
|
||||
"update.appversion_nobuild", return_value=semantic_version.Version("5.0.0")
|
||||
):
|
||||
updater = update.Updater(provider="internal")
|
||||
assert updater.check_appcast() is None
|
||||
|
||||
|
||||
class TestWinSparkleInit:
|
||||
@patch("sys.platform", "win32")
|
||||
@patch("ctypes.cdll.WinSparkle", create=True)
|
||||
def test_winsparkle_init_success(self, mock_ws):
|
||||
"""Verify WinSparkle initialization logic on Windows."""
|
||||
with patch("update.get_update_feed", return_value="http://feed"):
|
||||
with patch("update.appversion_nobuild", return_value="5.0.0"):
|
||||
updater = update.Updater(provider="external")
|
||||
assert mock_ws.win_sparkle_init.called
|
||||
assert mock_ws.win_sparkle_set_appcast_url.called
|
||||
Reference in new issue
Block a user