Files
aprsd/tests/utils/test_utils.py
T
hemna 7a2ed79759 fix: remove dead-code Python version guard in utils/__init__.py (#270)
The guard:
    if sys.version_info.major == 3 and sys.version_info.minor >= 3:
        from collections.abc import MutableMapping
    else:
        from collections.abc import MutableMapping

has identical branches — both import from collections.abc (the correct
Python 3.3+ location).  Remove the guard; keep the bare import.

Closes #251
2026-08-28 14:23:58 -04:00

345 lines
12 KiB
Python

import os
import shutil
import tempfile
import unittest
from unittest import mock
from aprsd import utils
class TestUtils(unittest.TestCase):
"""Unit tests for utility functions in aprsd.utils."""
def test_singleton_decorator(self):
"""Test singleton() decorator — same instance on repeated calls."""
@utils.singleton
class TestClass:
def __init__(self):
self.value = 42
instance1 = TestClass()
instance2 = TestClass()
self.assertIs(instance1, instance2)
self.assertEqual(instance1.value, 42)
TestClass.reset() # clean up
def test_singleton_has_reset(self):
"""@singleton-decorated class must expose a .reset() method."""
@utils.singleton
class Resettable:
pass
self.assertTrue(callable(Resettable.reset))
Resettable.reset() # clean up
def test_singleton_reset_clears_instance(self):
"""reset() must clear the stored instance so the next call creates a new one.
Regression test for issue #240: @singleton classes stored their instance
in wrapper_singleton.instance inside a closure. There was no way to clear
it in test setUp/tearDown (unlike the __new__-based singletons which expose
_instance = None). This caused state to leak between tests.
"""
@utils.singleton
class Counter:
_count = 0
def __init__(self):
Counter._count += 1
self.id = Counter._count
a = Counter()
self.assertEqual(a.id, 1)
# Second call before reset returns same instance
b = Counter()
self.assertIs(a, b)
# After reset(), a new instance is created
Counter.reset()
c = Counter()
self.assertIsNot(a, c)
self.assertEqual(c.id, 2)
Counter.reset() # clean up
def test_singleton_instance_is_none_before_first_call(self):
"""instance attribute must be None before the class is first called."""
@utils.singleton
class Fresh:
pass
self.assertIsNone(Fresh.instance)
Fresh()
self.assertIsNotNone(Fresh.instance)
Fresh.reset()
self.assertIsNone(Fresh.instance)
def test_env(self):
"""Test env() function."""
# Test with existing environment variable
os.environ['TEST_VAR'] = 'test_value'
result = utils.env('TEST_VAR')
self.assertEqual(result, 'test_value')
# Test with non-existent variable
result = utils.env('NON_EXISTENT_VAR')
self.assertEqual(result, '')
# Test with default
result = utils.env('NON_EXISTENT_VAR2', default='default_value')
self.assertEqual(result, 'default_value')
# Cleanup
del os.environ['TEST_VAR']
def test_env_multiple_vars(self):
"""Test env() with multiple variables."""
os.environ['VAR1'] = 'value1'
result = utils.env('VAR1', 'VAR2', 'VAR3')
self.assertEqual(result, 'value1')
del os.environ['VAR1']
def test_mkdir_p(self):
"""Test mkdir_p() function."""
temp_dir = tempfile.mkdtemp()
test_path = os.path.join(temp_dir, 'test', 'nested', 'dir')
try:
utils.mkdir_p(test_path)
self.assertTrue(os.path.isdir(test_path))
# Should not raise exception if directory exists
utils.mkdir_p(test_path)
self.assertTrue(os.path.isdir(test_path))
finally:
shutil.rmtree(temp_dir)
def test_insert_str(self):
"""Test insert_str() function."""
result = utils.insert_str('hello', ' world', 5)
self.assertEqual(result, 'hello world')
result = utils.insert_str('test', 'X', 0)
self.assertEqual(result, 'Xtest')
result = utils.insert_str('test', 'X', 4)
self.assertEqual(result, 'testX')
def test_end_substr(self):
"""Test end_substr() function."""
result = utils.end_substr('hello world', 'hello')
self.assertEqual(result, 5)
result = utils.end_substr('test', 'notfound')
self.assertEqual(result, -1)
result = utils.end_substr('abc', 'abc')
self.assertEqual(result, 3)
def test_rgb_from_name(self):
"""Test rgb_from_name() function."""
rgb = utils.rgb_from_name('test')
self.assertIsInstance(rgb, tuple)
self.assertEqual(len(rgb), 3)
self.assertGreaterEqual(rgb[0], 0)
self.assertLessEqual(rgb[0], 255)
self.assertGreaterEqual(rgb[1], 0)
self.assertLessEqual(rgb[1], 255)
self.assertGreaterEqual(rgb[2], 0)
self.assertLessEqual(rgb[2], 255)
# Same name should produce same RGB
rgb1 = utils.rgb_from_name('test')
rgb2 = utils.rgb_from_name('test')
self.assertEqual(rgb1, rgb2)
def test_hextriplet(self):
"""Test hextriplet() function."""
result = utils.hextriplet((255, 0, 128))
self.assertEqual(result, '#FF0080')
result = utils.hextriplet((0, 0, 0))
self.assertEqual(result, '#000000')
result = utils.hextriplet((255, 255, 255))
self.assertEqual(result, '#FFFFFF')
def test_hex_from_name(self):
"""Test hex_from_name() function."""
hex_color = utils.hex_from_name('test')
self.assertIsInstance(hex_color, str)
self.assertTrue(hex_color.startswith('#'))
self.assertEqual(len(hex_color), 7)
# Same name should produce same hex
hex1 = utils.hex_from_name('test')
hex2 = utils.hex_from_name('test')
self.assertEqual(hex1, hex2)
def test_human_size(self):
"""Test human_size() function."""
result = utils.human_size(1024)
self.assertIn('KB', result)
result = utils.human_size(512)
self.assertIn('bytes', result)
result = utils.human_size(1024 * 1024)
self.assertIn('MB', result)
def test_strfdelta(self):
"""Test strfdelta() function."""
import datetime
delta = datetime.timedelta(hours=1, minutes=30, seconds=45)
result = utils.strfdelta(delta)
self.assertIn('01', result)
self.assertIn('30', result)
self.assertIn('45', result)
delta = datetime.timedelta(days=1, hours=2, minutes=30, seconds=15)
result = utils.strfdelta(delta)
self.assertIn('1 days', result)
def test_flatten_dict(self):
"""Test flatten_dict() function."""
nested = {'a': 1, 'b': {'c': 2, 'd': {'e': 3}}}
result = utils.flatten_dict(nested)
self.assertIn('a', result)
self.assertIn('b.c', result)
self.assertIn('b.d.e', result)
self.assertEqual(result['a'], 1)
self.assertEqual(result['b.c'], 2)
self.assertEqual(result['b.d.e'], 3)
def test_flatten_dict_custom_sep(self):
"""Test flatten_dict() with custom separator."""
nested = {'a': {'b': 1}}
result = utils.flatten_dict(nested, sep='_')
self.assertIn('a_b', result)
def test_parse_delta_str(self):
"""Test parse_delta_str() function."""
result = utils.parse_delta_str('1:30:45')
self.assertIn('hours', result)
self.assertIn('minutes', result)
self.assertIn('seconds', result)
self.assertEqual(result['hours'], 1.0)
self.assertEqual(result['minutes'], 30.0)
self.assertEqual(result['seconds'], 45.0)
result = utils.parse_delta_str('1 day, 2:30:15')
self.assertIn('days', result)
self.assertEqual(result['days'], 1.0)
def test_parse_delta_str_invalid(self):
"""Test parse_delta_str() with invalid input."""
result = utils.parse_delta_str('invalid')
self.assertEqual(result, {})
def test_calculate_initial_compass_bearing(self):
"""Test calculate_initial_compass_bearing() function."""
point_a = (40.7128, -74.0060) # New York
point_b = (34.0522, -118.2437) # Los Angeles
bearing = utils.calculate_initial_compass_bearing(point_a, point_b)
self.assertGreaterEqual(bearing, 0)
self.assertLessEqual(bearing, 360)
# Same point should have undefined bearing, but function should handle it
bearing = utils.calculate_initial_compass_bearing(point_a, point_a)
self.assertIsInstance(bearing, float)
def test_calculate_initial_compass_bearing_invalid(self):
"""Test calculate_initial_compass_bearing() with invalid input."""
with self.assertRaises(TypeError):
utils.calculate_initial_compass_bearing([1, 2], (3, 4))
def test_degrees_to_cardinal(self):
"""Test degrees_to_cardinal() function."""
self.assertEqual(utils.degrees_to_cardinal(0), 'N')
self.assertEqual(utils.degrees_to_cardinal(90), 'E')
self.assertEqual(utils.degrees_to_cardinal(180), 'S')
self.assertEqual(utils.degrees_to_cardinal(270), 'W')
self.assertEqual(utils.degrees_to_cardinal(45), 'NE')
def test_degrees_to_cardinal_full_string(self):
"""Test degrees_to_cardinal() with full_string=True."""
self.assertEqual(utils.degrees_to_cardinal(0, full_string=True), 'North')
self.assertEqual(utils.degrees_to_cardinal(90, full_string=True), 'East')
self.assertEqual(utils.degrees_to_cardinal(180, full_string=True), 'South')
self.assertEqual(utils.degrees_to_cardinal(270, full_string=True), 'West')
def test_aprs_passcode(self):
"""Test aprs_passcode() function."""
passcode = utils.aprs_passcode('N0CALL')
self.assertIsInstance(passcode, int)
self.assertGreaterEqual(passcode, 0)
self.assertLessEqual(passcode, 0x7FFF)
# Same callsign should produce same passcode
passcode1 = utils.aprs_passcode('N0CALL')
passcode2 = utils.aprs_passcode('N0CALL')
self.assertEqual(passcode1, passcode2)
# Different callsigns should produce different passcodes
passcode3 = utils.aprs_passcode('K1ABC')
self.assertNotEqual(passcode1, passcode3)
def test_aprs_passcode_with_ssid(self):
"""Test aprs_passcode() with SSID."""
passcode1 = utils.aprs_passcode('N0CALL-1')
passcode2 = utils.aprs_passcode('N0CALL')
self.assertEqual(passcode1, passcode2)
def test_load_entry_points(self):
"""Test load_entry_points() function."""
# Should not raise exception even with non-existent group
utils.load_entry_points('nonexistent.group')
@mock.patch('aprsd.utils.update_checker.UpdateChecker')
def test_check_version(self, mock_checker):
"""Test _check_version() function."""
mock_instance = mock.MagicMock()
mock_instance.check.return_value = None
mock_checker.return_value = mock_instance
level, msg = utils._check_version()
self.assertEqual(level, 0)
self.assertIn('up to date', msg)
@mock.patch('aprsd.utils.update_checker.UpdateChecker')
def test_check_version_update_available(self, mock_checker):
"""Test _check_version() when update is available."""
mock_instance = mock.MagicMock()
mock_instance.check.return_value = 'New version available'
mock_checker.return_value = mock_instance
level, msg = utils._check_version()
self.assertEqual(level, 1)
self.assertEqual(msg, 'New version available')
class TestDeadCodeRemoval(unittest.TestCase):
"""Regression tests for dead code that was removed."""
def test_mutable_mapping_importable(self):
"""MutableMapping must be importable from aprsd.utils after dead-code removal.
The version guard
if sys.version_info.major == 3 and sys.version_info.minor >= 3:
from collections.abc import MutableMapping
else:
from collections.abc import MutableMapping
had both branches do the same thing. After removing the guard the import
must still succeed on all supported Python versions.
"""
from aprsd.utils import MutableMapping as MM # noqa: N814
self.assertTrue(issubclass(dict, MM))