mirror of
https://github.com/GAM-team/GAM.git
synced 2026-07-04 21:01:36 +00:00
Phase 4b: Unit Testing
This commit is contained in:
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
@@ -539,6 +539,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
"$PYTHON" -m pip install ..[yubikey]
|
"$PYTHON" -m pip install ..[yubikey]
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: |
|
||||||
|
"$PYTHON" -m pip install pytest
|
||||||
|
"$PYTHON" -m pytest ../tests/ -v
|
||||||
|
|
||||||
- name: Install PyInstaller
|
- name: Install PyInstaller
|
||||||
if: matrix.goal == 'build'
|
if: matrix.goal == 'build'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
63
tests/conftest.py
Normal file
63
tests/conftest.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""Pytest configuration and shared fixtures for GAM tests.
|
||||||
|
|
||||||
|
Path setup: GAM's modules use bare 'from util.X import ...' which requires the
|
||||||
|
gam package directory on sys.path. We add it in a fixture (not at module level)
|
||||||
|
to avoid shadowing Python's stdlib 'cmd' module during pytest configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _gam_path_and_globals():
|
||||||
|
"""Add GAM's package dir to sys.path and initialize minimal global state.
|
||||||
|
|
||||||
|
Done in a fixture rather than at module level to avoid polluting sys.path
|
||||||
|
during pytest's own import of stdlib modules (e.g. cmd, which would collide
|
||||||
|
with gam/cmd/).
|
||||||
|
"""
|
||||||
|
gam_pkg_dir = os.path.join(os.path.dirname(__file__), '..', 'src', 'gam')
|
||||||
|
gam_pkg_dir = os.path.normpath(gam_pkg_dir)
|
||||||
|
|
||||||
|
# Temporarily prepend — will be cleaned up after test
|
||||||
|
inserted = False
|
||||||
|
if gam_pkg_dir not in sys.path:
|
||||||
|
sys.path.insert(0, gam_pkg_dir)
|
||||||
|
inserted = True
|
||||||
|
|
||||||
|
# Also need src/ on path for 'gam' package imports
|
||||||
|
src_dir = os.path.join(os.path.dirname(__file__), '..', 'src')
|
||||||
|
src_dir = os.path.normpath(src_dir)
|
||||||
|
src_inserted = False
|
||||||
|
if src_dir not in sys.path:
|
||||||
|
sys.path.insert(0, src_dir)
|
||||||
|
src_inserted = True
|
||||||
|
|
||||||
|
from gamlib import glcfg as GC
|
||||||
|
from gamlib import glglobals as GM
|
||||||
|
|
||||||
|
# Ensure Values dict exists with minimal defaults
|
||||||
|
if not GC.Values:
|
||||||
|
GC.Values = {}
|
||||||
|
GC.Values.setdefault(GC.DOMAIN, 'example.com')
|
||||||
|
GC.Values.setdefault(GC.CUSTOMER_ID, 'C00000000')
|
||||||
|
GC.Values.setdefault(GC.TIMEZONE, 'UTC')
|
||||||
|
GC.Values.setdefault(GC.SHOW_COUNTS_MIN, 0)
|
||||||
|
|
||||||
|
# Ensure Globals dict exists
|
||||||
|
if not GM.Globals:
|
||||||
|
GM.Globals = {}
|
||||||
|
GM.Globals.setdefault(GM.STDOUT, None)
|
||||||
|
GM.Globals.setdefault(GM.STDERR, None)
|
||||||
|
GM.Globals.setdefault(GM.SYSEXITRC, 0)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Clean up sys.path to avoid side effects between test files
|
||||||
|
if inserted and gam_pkg_dir in sys.path:
|
||||||
|
sys.path.remove(gam_pkg_dir)
|
||||||
|
if src_inserted and src_dir in sys.path:
|
||||||
|
sys.path.remove(src_dir)
|
||||||
181
tests/test_args.py
Normal file
181
tests/test_args.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""Unit tests for gam.util.args — argument parsing and formatting functions."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import arrow
|
||||||
|
|
||||||
|
|
||||||
|
class TestCourseScopes:
|
||||||
|
"""Test course ID and alias scope manipulation."""
|
||||||
|
|
||||||
|
def test_add_course_id_scope_plain(self):
|
||||||
|
from gam.util.args import addCourseIdScope
|
||||||
|
assert addCourseIdScope('12345') == '12345' # numeric, no prefix
|
||||||
|
|
||||||
|
def test_add_course_id_scope_name(self):
|
||||||
|
from gam.util.args import addCourseIdScope
|
||||||
|
assert addCourseIdScope('my-course') == 'd:my-course'
|
||||||
|
|
||||||
|
def test_add_course_id_scope_already_prefixed(self):
|
||||||
|
from gam.util.args import addCourseIdScope
|
||||||
|
assert addCourseIdScope('d:my-course') == 'd:my-course'
|
||||||
|
assert addCourseIdScope('p:my-course') == 'p:my-course'
|
||||||
|
|
||||||
|
def test_remove_course_id_scope(self):
|
||||||
|
from gam.util.args import removeCourseIdScope
|
||||||
|
assert removeCourseIdScope('d:my-course') == 'my-course'
|
||||||
|
|
||||||
|
def test_remove_course_id_scope_no_prefix(self):
|
||||||
|
from gam.util.args import removeCourseIdScope
|
||||||
|
assert removeCourseIdScope('12345') == '12345'
|
||||||
|
|
||||||
|
def test_add_course_alias_scope(self):
|
||||||
|
from gam.util.args import addCourseAliasScope
|
||||||
|
assert addCourseAliasScope('my-alias') == 'd:my-alias'
|
||||||
|
|
||||||
|
def test_add_course_alias_scope_already_prefixed(self):
|
||||||
|
from gam.util.args import addCourseAliasScope
|
||||||
|
assert addCourseAliasScope('d:my-alias') == 'd:my-alias'
|
||||||
|
assert addCourseAliasScope('p:my-alias') == 'p:my-alias'
|
||||||
|
|
||||||
|
def test_remove_course_alias_scope(self):
|
||||||
|
from gam.util.args import removeCourseAliasScope
|
||||||
|
assert removeCourseAliasScope('d:my-alias') == 'my-alias'
|
||||||
|
|
||||||
|
def test_remove_course_alias_scope_no_prefix(self):
|
||||||
|
from gam.util.args import removeCourseAliasScope
|
||||||
|
assert removeCourseAliasScope('my-alias') == 'my-alias'
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailAddressParsing:
|
||||||
|
"""Test email address parsing and normalization."""
|
||||||
|
|
||||||
|
def test_get_email_domain(self):
|
||||||
|
from gam.util.args import getEmailAddressDomain
|
||||||
|
assert getEmailAddressDomain('user@domain.com') == 'domain.com'
|
||||||
|
|
||||||
|
def test_get_email_domain_no_at(self):
|
||||||
|
from gam.util.args import getEmailAddressDomain
|
||||||
|
# Falls back to GC.Values[GC.DOMAIN] which is 'example.com' in conftest
|
||||||
|
assert getEmailAddressDomain('justuser') == 'example.com'
|
||||||
|
|
||||||
|
def test_get_email_domain_case(self):
|
||||||
|
from gam.util.args import getEmailAddressDomain
|
||||||
|
assert getEmailAddressDomain('user@DOMAIN.COM') == 'domain.com'
|
||||||
|
|
||||||
|
def test_get_email_username(self):
|
||||||
|
from gam.util.args import getEmailAddressUsername
|
||||||
|
assert getEmailAddressUsername('user@domain.com') == 'user'
|
||||||
|
|
||||||
|
def test_get_email_username_no_at(self):
|
||||||
|
from gam.util.args import getEmailAddressUsername
|
||||||
|
assert getEmailAddressUsername('justuser') == 'justuser'
|
||||||
|
|
||||||
|
def test_split_email(self):
|
||||||
|
from gam.util.args import splitEmailAddress
|
||||||
|
assert splitEmailAddress('User@Domain.COM') == ('user', 'domain.com')
|
||||||
|
|
||||||
|
def test_split_email_no_at(self):
|
||||||
|
from gam.util.args import splitEmailAddress
|
||||||
|
assert splitEmailAddress('JustUser') == ('justuser', 'example.com')
|
||||||
|
|
||||||
|
def test_normalize_uid(self):
|
||||||
|
from gam.util.args import normalizeEmailAddressOrUID
|
||||||
|
# uid: prefix should be stripped
|
||||||
|
assert normalizeEmailAddressOrUID('uid:12345') == '12345'
|
||||||
|
|
||||||
|
def test_normalize_email_lowercased(self):
|
||||||
|
from gam.util.args import normalizeEmailAddressOrUID
|
||||||
|
result = normalizeEmailAddressOrUID('User@Domain.COM')
|
||||||
|
assert result == 'user@domain.com'
|
||||||
|
|
||||||
|
def test_normalize_email_no_lower(self):
|
||||||
|
from gam.util.args import normalizeEmailAddressOrUID
|
||||||
|
result = normalizeEmailAddressOrUID('User@Domain.COM', noLower=True)
|
||||||
|
assert result == 'User@Domain.COM'
|
||||||
|
|
||||||
|
def test_normalize_bare_username_gets_domain(self):
|
||||||
|
from gam.util.args import normalizeEmailAddressOrUID
|
||||||
|
result = normalizeEmailAddressOrUID('admin')
|
||||||
|
assert result == 'admin@example.com'
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatting:
|
||||||
|
"""Test formatting helper functions."""
|
||||||
|
|
||||||
|
def test_format_milliseconds(self):
|
||||||
|
from gam.util.args import formatMilliSeconds
|
||||||
|
assert formatMilliSeconds(0) == '00:00:00'
|
||||||
|
assert formatMilliSeconds(1000) == '00:00:01'
|
||||||
|
assert formatMilliSeconds(61000) == '00:01:01'
|
||||||
|
assert formatMilliSeconds(3661000) == '01:01:01'
|
||||||
|
|
||||||
|
def test_format_http_error(self):
|
||||||
|
from gam.util.args import formatHTTPError
|
||||||
|
result = formatHTTPError(404, 'Not Found', 'Resource missing')
|
||||||
|
assert result == '404: Not Found - Resource missing'
|
||||||
|
|
||||||
|
def test_format_max_message_bytes_raw(self):
|
||||||
|
from gam.util.args import formatMaxMessageBytes
|
||||||
|
assert formatMaxMessageBytes(500, 1024, 1048576) == 500
|
||||||
|
|
||||||
|
def test_format_max_message_bytes_kilobytes(self):
|
||||||
|
from gam.util.args import formatMaxMessageBytes
|
||||||
|
assert formatMaxMessageBytes(2048, 1024, 1048576) == '2K'
|
||||||
|
|
||||||
|
def test_format_max_message_bytes_megabytes(self):
|
||||||
|
from gam.util.args import formatMaxMessageBytes
|
||||||
|
assert formatMaxMessageBytes(5242880, 1024, 1048576) == '5M'
|
||||||
|
|
||||||
|
def test_format_file_size_zero(self):
|
||||||
|
from gam.util.args import formatFileSize
|
||||||
|
assert formatFileSize(0) == '0 KB'
|
||||||
|
|
||||||
|
def test_format_file_size_small(self):
|
||||||
|
from gam.util.args import formatFileSize
|
||||||
|
assert formatFileSize(500) == '1 KB'
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimestamps:
|
||||||
|
"""Test timestamp formatting."""
|
||||||
|
|
||||||
|
def test_iso_format_timestamp(self):
|
||||||
|
from gam.util.output import ISOformatTimeStamp
|
||||||
|
ts = arrow.get('2024-01-15T10:30:00+00:00')
|
||||||
|
result = ISOformatTimeStamp(ts)
|
||||||
|
assert '2024-01-15' in result
|
||||||
|
assert '10:30:00' in result
|
||||||
|
|
||||||
|
def test_current_iso_format_returns_string(self):
|
||||||
|
from gam.util.output import currentISOformatTimeStamp
|
||||||
|
result = currentISOformatTimeStamp()
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert 'T' in result # ISO format contains T separator
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeltaParsing:
|
||||||
|
"""Test time delta parsing functions."""
|
||||||
|
|
||||||
|
def test_get_delta_invalid_returns_none(self):
|
||||||
|
from gam.util.args import getDelta, DELTA_DATE_PATTERN
|
||||||
|
result = getDelta('not-a-delta', DELTA_DATE_PATTERN)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_delta_date(self):
|
||||||
|
"""Requires GM.DATETIME_NOW to be initialized."""
|
||||||
|
import arrow as _arrow
|
||||||
|
from gamlib import glglobals as GM
|
||||||
|
GM.Globals[GM.DATETIME_NOW] = _arrow.now()
|
||||||
|
|
||||||
|
from gam.util.args import getDeltaDate
|
||||||
|
result = getDeltaDate('-30d')
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_get_delta_time(self):
|
||||||
|
"""Requires GM.DATETIME_NOW to be initialized."""
|
||||||
|
import arrow as _arrow
|
||||||
|
from gamlib import glglobals as GM
|
||||||
|
GM.Globals[GM.DATETIME_NOW] = _arrow.now()
|
||||||
|
|
||||||
|
from gam.util.args import getDeltaTime
|
||||||
|
result = getDeltaTime('-1h')
|
||||||
|
assert result is not None
|
||||||
62
tests/test_imports.py
Normal file
62
tests/test_imports.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Structural tests — verify all modules import without errors."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllModulesImport:
|
||||||
|
"""Every module in gam/ should import without errors."""
|
||||||
|
|
||||||
|
def test_gam_package_imports(self):
|
||||||
|
"""The top-level gam package should import without circular import errors."""
|
||||||
|
import gam
|
||||||
|
assert hasattr(gam, 'ProcessGAMCommand')
|
||||||
|
|
||||||
|
def test_util_modules_import(self):
|
||||||
|
"""All util/ modules should import individually without errors."""
|
||||||
|
util_dir = os.path.join(os.path.dirname(__file__), '..', 'src', 'gam', 'util')
|
||||||
|
util_dir = os.path.normpath(util_dir)
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
for f in sorted(os.listdir(util_dir)):
|
||||||
|
if not f.endswith('.py') or f == '__init__.py':
|
||||||
|
continue
|
||||||
|
mod_name = f'gam.util.{f[:-3]}'
|
||||||
|
try:
|
||||||
|
importlib.import_module(mod_name)
|
||||||
|
except Exception as e:
|
||||||
|
failures.append(f'{mod_name}: {e}')
|
||||||
|
|
||||||
|
assert not failures, "Failed to import:\n" + "\n".join(failures)
|
||||||
|
|
||||||
|
def test_no_local_imports_in_util(self):
|
||||||
|
"""util/ modules should not have function-scope imports from other util/ modules.
|
||||||
|
|
||||||
|
The one known exception is api.py:getSaUser (documented api<->uid cycle).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
util_dir = os.path.join(os.path.dirname(__file__), '..', 'src', 'gam', 'util')
|
||||||
|
util_dir = os.path.normpath(util_dir)
|
||||||
|
|
||||||
|
KNOWN_EXCEPTIONS = {
|
||||||
|
('api.py', 'from util.uid import convertUIDtoEmailAddress'),
|
||||||
|
}
|
||||||
|
|
||||||
|
violations = []
|
||||||
|
for f in sorted(os.listdir(util_dir)):
|
||||||
|
if not f.endswith('.py') or f == '__init__.py':
|
||||||
|
continue
|
||||||
|
with open(os.path.join(util_dir, f)) as fh:
|
||||||
|
for i, line in enumerate(fh, 1):
|
||||||
|
if re.match(r'^\s+from (util|gam\.util)\.\w+ import', line):
|
||||||
|
text = line.strip().split('#')[0].strip()
|
||||||
|
if (f, text) not in KNOWN_EXCEPTIONS:
|
||||||
|
violations.append(f'{f}:{i}: {line.strip()}')
|
||||||
|
|
||||||
|
assert not violations, (
|
||||||
|
f"Found {len(violations)} unexpected local import(s) in util/:\n"
|
||||||
|
+ "\n".join(violations)
|
||||||
|
)
|
||||||
99
tests/test_output.py
Normal file
99
tests/test_output.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Unit tests for gam.util.output — output formatting and utility functions."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripControlChars:
|
||||||
|
"""Test control character stripping."""
|
||||||
|
|
||||||
|
def test_strips_null(self):
|
||||||
|
from gam.util.output import _stripControlCharsFromName
|
||||||
|
assert _stripControlCharsFromName('hello\x00world') == 'helloworld'
|
||||||
|
|
||||||
|
def test_strips_cr(self):
|
||||||
|
from gam.util.output import _stripControlCharsFromName
|
||||||
|
assert _stripControlCharsFromName('hello\rworld') == 'helloworld'
|
||||||
|
|
||||||
|
def test_strips_nl(self):
|
||||||
|
from gam.util.output import _stripControlCharsFromName
|
||||||
|
assert _stripControlCharsFromName('hello\nworld') == 'helloworld'
|
||||||
|
|
||||||
|
def test_strips_multiple(self):
|
||||||
|
from gam.util.output import _stripControlCharsFromName
|
||||||
|
assert _stripControlCharsFromName('\x00he\rll\no') == 'hello'
|
||||||
|
|
||||||
|
def test_clean_string_unchanged(self):
|
||||||
|
from gam.util.output import _stripControlCharsFromName
|
||||||
|
assert _stripControlCharsFromName('hello world') == 'hello world'
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
from gam.util.output import _stripControlCharsFromName
|
||||||
|
assert _stripControlCharsFromName('') == ''
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrentCount:
|
||||||
|
"""Test count display formatting."""
|
||||||
|
|
||||||
|
def test_current_count_with_items(self):
|
||||||
|
from gam.util.output import currentCount
|
||||||
|
result = currentCount(3, 10)
|
||||||
|
assert result == ' (3/10)'
|
||||||
|
|
||||||
|
def test_current_count_nl(self):
|
||||||
|
from gam.util.output import currentCountNL
|
||||||
|
result = currentCountNL(3, 10)
|
||||||
|
assert result == ' (3/10)\n'
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatKeyValueList:
|
||||||
|
"""Test key-value list formatting.
|
||||||
|
|
||||||
|
formatKeyValueList processes pairs: [key1, val1, key2, val2, ...]
|
||||||
|
Each key gets ': ' and its value appended.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_single_pair(self):
|
||||||
|
from gam.util.output import formatKeyValueList
|
||||||
|
result = formatKeyValueList('', ['Name', 'Alice'], '')
|
||||||
|
assert result == 'Name: Alice'
|
||||||
|
|
||||||
|
def test_multiple_pairs(self):
|
||||||
|
from gam.util.output import formatKeyValueList
|
||||||
|
result = formatKeyValueList('', ['Name', 'Alice', 'Age', 30], '')
|
||||||
|
assert 'Name: Alice' in result
|
||||||
|
assert 'Age: 30' in result
|
||||||
|
|
||||||
|
def test_with_prefix_suffix(self):
|
||||||
|
from gam.util.output import formatKeyValueList
|
||||||
|
result = formatKeyValueList(' ', ['key', 'val'], '\n')
|
||||||
|
assert result == ' key: val\n'
|
||||||
|
|
||||||
|
def test_none_value(self):
|
||||||
|
from gam.util.output import formatKeyValueList
|
||||||
|
result = formatKeyValueList('', ['key', None], '')
|
||||||
|
assert result == 'key:'
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
from gam.util.output import formatKeyValueList
|
||||||
|
result = formatKeyValueList('pre', [], 'suf')
|
||||||
|
assert result == 'presuf'
|
||||||
|
|
||||||
|
|
||||||
|
class TestColoredText:
|
||||||
|
"""Test colored text creation."""
|
||||||
|
|
||||||
|
def test_create_colored_text_returns_string(self):
|
||||||
|
from gam.util.output import createColoredText
|
||||||
|
result = createColoredText('hello', 'red')
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert 'hello' in result
|
||||||
|
|
||||||
|
def test_create_red_text(self):
|
||||||
|
from gam.util.output import createRedText
|
||||||
|
result = createRedText('error')
|
||||||
|
assert 'error' in result
|
||||||
|
|
||||||
|
def test_create_green_text(self):
|
||||||
|
from gam.util.output import createGreenText
|
||||||
|
result = createGreenText('success')
|
||||||
|
assert 'success' in result
|
||||||
Reference in New Issue
Block a user