Add New Basic Tests

This commit is contained in:
David Sangrey committed 2026-01-25 16:03:13 -05:00
1 parent ccf7fbc164
commit 9b95ca5ca2
19 files changed
+1710 -147

No files matched your search

+13 -11
View File
@@ -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
+53
View File
@@ -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
+79 -56
View File
@@ -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().
+91 -40
View File
@@ -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
+90 -40
View File
@@ -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
+90
View File
@@ -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)
+43
View File
@@ -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()
+187
View File
@@ -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()
+131
View File
@@ -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] == ""
+99
View File
@@ -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)
+85
View File
@@ -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)
+113
View File
@@ -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
+39
View File
@@ -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"}'
+105
View File
@@ -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()
+146
View File
@@ -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"
+47
View File
@@ -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"
+84
View File
@@ -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"
+60
View File
@@ -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
)
+155
View File
@@ -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