Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
"""Unit tests for Utils class and its methods."""

import inspect
import os
import sys
import unittest
from unittest.mock import MagicMock, patch
from pathlib import Path

# Add the parent directory to the path so we can import tir module
repo_root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(repo_root))

from tir.technologies.core.utils import Utils


class TestUtilsGetMainEntrypointFromStack(unittest.TestCase):
"""Test cases for Utils.get_main_entrypoint_from_stack method."""

def setUp(self):
"""Initialize test fixtures."""
self.utils = Utils()

def test_get_main_entrypoint_with_default_parameters(self):
"""Test method returns a string value with default parameters."""
result = self.utils.get_main_entrypoint_from_stack()
self.assertIsInstance(result, str)

def test_get_main_entrypoint_returns_fallback_when_no_match(self):
"""Test method returns fallback value when no matching frame is found."""
result = self.utils.get_main_entrypoint_from_stack(
target_modules=["nonexistent/module.py"],
fallback="custom_fallback"
)
self.assertEqual(result, "custom_fallback")

def test_get_main_entrypoint_returns_default_fallback(self):
"""Test method returns default fallback when no matching frame is found."""
result = self.utils.get_main_entrypoint_from_stack(
target_modules=["nonexistent/module.py"]
)
self.assertEqual(result, "function_name")

def test_get_main_entrypoint_ignores_specified_functions(self):
"""Test that specified functions in ignored_functions are skipped."""
# Create a set of functions to ignore
ignored_set = {"__getattribute__", "_subscribe_routes", "__init__", "<lambda>"}

result = self.utils.get_main_entrypoint_from_stack(
target_modules=["test_utils.py"],
ignored_functions=ignored_set,
fallback="test_fallback"
)

# The result should not be in the ignored set or should be fallback
if result != "test_fallback":
self.assertNotIn(result, ignored_set)

def test_get_main_entrypoint_with_custom_ignored_functions(self):
"""Test method with custom ignored functions set."""
custom_ignored = {"test_method", "setup"}
result = self.utils.get_main_entrypoint_from_stack(
target_modules=["test_utils.py"],
ignored_functions=custom_ignored,
fallback="not_found"
)

self.assertIsInstance(result, str)

def test_get_main_entrypoint_case_insensitive_matching(self):
"""Test that module matching is case-insensitive."""
# Test with uppercase module name
result1 = self.utils.get_main_entrypoint_from_stack(
target_modules=["TEST_UTILS.PY"],
fallback="not_found"
)

# Test with lowercase module name
result2 = self.utils.get_main_entrypoint_from_stack(
target_modules=["test_utils.py"],
fallback="not_found"
)

# Both should return the same result
self.assertEqual(result1, result2)

def test_get_main_entrypoint_with_path_separators(self):
"""Test that method handles both forward and backward slashes."""
# Test with forward slashes
result1 = self.utils.get_main_entrypoint_from_stack(
target_modules=["tests/test_utils.py"],
fallback="not_found"
)

# Test with backward slashes (Windows style)
result2 = self.utils.get_main_entrypoint_from_stack(
target_modules=["tests\\test_utils.py"],
fallback="not_found"
)

# Both should return the same result
self.assertEqual(result1, result2)

def test_get_main_entrypoint_multiple_target_modules(self):
"""Test method with multiple target modules."""
result = self.utils.get_main_entrypoint_from_stack(
target_modules=["nonexistent.py", "test_utils.py", "another.py"],
fallback="not_found"
)

self.assertIsInstance(result, str)

@patch('inspect.stack')
def test_get_main_entrypoint_with_mocked_stack(self, mock_stack):
"""Test method behavior with mocked call stack."""
# Create mock stack frames
mock_frame1 = MagicMock()
mock_frame1.filename = "c:\\TOTVS\\repos\\tir\\tir\\main.py"
mock_frame1.function = "test_function"

mock_frame2 = MagicMock()
mock_frame2.filename = "c:\\other\\path\\module.py"
mock_frame2.function = "other_function"

mock_stack.return_value = [mock_frame1, mock_frame2]

result = self.utils.get_main_entrypoint_from_stack(
target_modules=["tir/main.py"]
)

self.assertEqual(result, "test_function")

@patch('inspect.stack')
def test_get_main_entrypoint_skips_ignored_functions(self, mock_stack):
"""Test that method skips frames with ignored functions."""
# Create mock stack frames
mock_frame1 = MagicMock()
mock_frame1.filename = "c:\\TOTVS\\repos\\tir\\tir\\main.py"
mock_frame1.function = "__init__" # This should be ignored

mock_frame2 = MagicMock()
mock_frame2.filename = "c:\\TOTVS\\repos\\tir\\tir\\main.py"
mock_frame2.function = "user_function" # This should be returned

mock_stack.return_value = [mock_frame1, mock_frame2]

result = self.utils.get_main_entrypoint_from_stack(
target_modules=["tir/main.py"]
)

self.assertEqual(result, "user_function")

@patch('inspect.stack')
def test_get_main_entrypoint_returns_first_match(self, mock_stack):
"""Test that method returns the first matching function."""
# Create mock stack frames
mock_frame1 = MagicMock()
mock_frame1.filename = "c:\\TOTVS\\repos\\tir\\tir\\main.py"
mock_frame1.function = "first_function"

mock_frame2 = MagicMock()
mock_frame2.filename = "c:\\TOTVS\\repos\\tir\\tir\\main.py"
mock_frame2.function = "second_function"

mock_stack.return_value = [mock_frame1, mock_frame2]

result = self.utils.get_main_entrypoint_from_stack(
target_modules=["tir/main.py"]
)

# Should return the first matching function
self.assertEqual(result, "first_function")

def test_get_main_entrypoint_empty_ignored_functions(self):
"""Test method with empty ignored functions set."""
result = self.utils.get_main_entrypoint_from_stack(
target_modules=["test_utils.py"],
ignored_functions=set(),
fallback="fallback"
)

self.assertIsInstance(result, str)

def test_get_main_entrypoint_returns_string_type(self):
"""Test that method always returns a string."""
test_cases = [
{"target_modules": None},
{"target_modules": ["nonexistent.py"]},
{"fallback": "custom"},
{"ignored_functions": set()},
]

for kwargs in test_cases:
result = self.utils.get_main_entrypoint_from_stack(**kwargs)
self.assertIsInstance(result, str,
f"Expected str, got {type(result)} for kwargs: {kwargs}")

def test_utils_instantiation(self):
"""Test that Utils class can be instantiated."""
utils_instance = Utils()
self.assertIsNotNone(utils_instance)
self.assertTrue(hasattr(utils_instance, 'get_main_entrypoint_from_stack'))

def test_get_main_entrypoint_with_absolute_path_module(self):
"""Test method with absolute path in target_modules."""
abs_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "test_utils.py"))

result = self.utils.get_main_entrypoint_from_stack(
target_modules=[abs_path],
fallback="not_found"
)

self.assertIsInstance(result, str)


class TestUtilsClass(unittest.TestCase):
"""Test cases for Utils class itself."""

def test_utils_has_get_main_entrypoint_method(self):
"""Test that Utils class has the get_main_entrypoint_from_stack method."""
utils = Utils()
self.assertTrue(hasattr(utils, 'get_main_entrypoint_from_stack'))
self.assertTrue(callable(getattr(utils, 'get_main_entrypoint_from_stack')))

def test_utils_docstring_exists(self):
"""Test that Utils class has proper documentation."""
self.assertIsNotNone(Utils.__doc__)
self.assertTrue(len(Utils.__doc__) > 0)

def test_get_main_entrypoint_docstring_exists(self):
"""Test that method has proper documentation."""
method = Utils.get_main_entrypoint_from_stack
self.assertIsNotNone(method.__doc__)
self.assertTrue(len(method.__doc__) > 0)


if __name__ == '__main__':
unittest.main()
2 changes: 2 additions & 0 deletions tir/technologies/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from tir.technologies.core.config import ConfigLoader
from tir.technologies.core.language import LanguagePack
from tir.technologies.core.third_party.xpath_soup import xpath_soup
from tir.technologies.core.utils import Utils
from selenium.webdriver.firefox.options import Options as FirefoxOpt
from selenium.webdriver.firefox.service import Service as FirefoxService
from selenium.webdriver.chrome.options import Options as ChromeOpt
Expand Down Expand Up @@ -116,6 +117,7 @@ def __init__(self, config_path="", autostart=True):
self.language = LanguagePack(self.config.language) if self.config.language else ""
self.log = Log(folder=self.config.log_folder, config_path=self.config_path)
self.log.station = socket.gethostname()
self.utils = Utils()
self.test_case = []
self.last_test_case = None
self.message = ""
Expand Down
75 changes: 75 additions & 0 deletions tir/technologies/core/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import inspect
import os
from typing import Iterable, Optional, Set


class Utils:
"""Shared utility methods for TIR technologies."""

def get_main_entrypoint_from_stack(
self,
target_modules: Optional[Iterable[str]] = None,
ignored_functions: Optional[Set[str]] = None,
fallback: str = "function_name"
) -> str:
"""Return the external entrypoint function from call stack.

Retrieves the name of the first function in the call stack that matches the target
modules and is not in the ignored functions set. Useful for identifying which test
method or user function initiated a chain of internal calls.

Args:
target_modules: Iterable of module paths to search for (e.g., "tir/main.py").
Default: ("tir/main.py",). Must be relative or absolute paths.
ignored_functions: Set of function names to exclude from results
(e.g., "__init__", "<lambda>").
Default: {"__getattribute__", "_subscribe_routes", "__init__", "<lambda>"}
fallback: Value to return if no matching frame is found. Default: "function_name"

Returns:
str: The name of the matching function, or fallback value if no match found.

Examples:
>>> utils = Utils()
>>> # Get entrypoint with defaults
>>> utils.get_main_entrypoint_from_stack()
'my_test_method'

>>> # Get entrypoint from custom module
>>> utils.get_main_entrypoint_from_stack(target_modules=["custom/module.py"])
'custom_function'

>>> # Custom ignored functions
>>> utils.get_main_entrypoint_from_stack(
... ignored_functions={"__init__", "setup"}
... )
'test_something'
"""
modules = tuple(target_modules) if target_modules else ("tir/main.py",)
ignored = ignored_functions or {"__getattribute__", "_subscribe_routes", "__init__", "<lambda>"}

# Normalize all target modules to lowercase with forward slashes
normalized_modules = tuple(
os.path.normpath(module).replace("\\", "/").lower()
for module in modules
)

# Iterate through call stack frames
for stack_item in inspect.stack():
# Normalize the frame's filename for comparison
filename = os.path.normpath(stack_item.filename).replace("\\", "/").lower()

# Check if filename matches any target module (case-insensitive, path-aware)
# Use path matching to avoid false positives (e.g., avoiding "my_main.py" matching "main.py")
matches_target = any(
filename.endswith(os.path.sep.replace("\\", "/") + module) or
filename.endswith("/" + module) or
filename == module
for module in normalized_modules
)

# Return the function name if it matches and is not ignored
if matches_target and stack_item.function not in ignored:
return stack_item.function

return fallback
14 changes: 0 additions & 14 deletions tir/technologies/poui_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -2056,20 +2056,6 @@ def search_for_errors(self, check_help=True):
self.restart_counter += 1
self.log_error(message)

def get_function_from_stack(self):
"""
[Internal]

Gets the function name that called the Webapp class from the call stack.

Usage:

>>> # Calling the method:
>>> self.get_function_from_stack()
"""
stack_item = next(iter(filter(lambda x: x.filename == self.config.routine, inspect.stack())), None)
return stack_item.function if stack_item and stack_item.function else "function_name"

def create_message(self, args, message_type=enum.MessageType.CORRECT):
"""
[Internal]
Expand Down
25 changes: 8 additions & 17 deletions tir/technologies/webapp_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1985,7 +1985,10 @@ def escape_to_main_menu(self):
success = menu_screen and container_layers

logger().debug(f'Check Menu Screen: {menu_screen}')
logger().debug(f'wa-dialog layers: {container_layers}')
logger().debug(f'wa-dialog layers: {container_layers}')

if not success:
self.log_error('Home screen not found!')

# wait trasitions between screens to avoid errors in layers number
self.wait_element_timeout(term=container_term, scrap_type=enum.ScrapType.CSS_SELECTOR,
Expand Down Expand Up @@ -4264,20 +4267,6 @@ def search_for_errors(self, check_help=True):
self.restart_counter += 1
self.log_error(message)

def get_function_from_stack(self):
"""
[Internal]

Gets the function name that called the Webapp class from the call stack.

Usage:

>>> # Calling the method:
>>> self.get_function_from_stack()
"""
stack_item = next(iter(filter(lambda x: x.filename == self.config.routine, inspect.stack())), None)
return stack_item.function if stack_item and stack_item.function else "function_name"

def create_message(self, args, message_type=enum.MessageType.CORRECT):
"""
[Internal]
Expand Down Expand Up @@ -8693,8 +8682,10 @@ def log_error(self, message, new_log_line=True, skip_restart=False, restart_coun
system_info()

stack_item = self.log.get_testcase_stack()
test_number = f"{stack_item.split('_')[-1]} -" if stack_item else ""
log_message = f"{test_number} {message}"
entrypoint_function = self.utils.get_main_entrypoint_from_stack()
entrypoint_prefix = f"[{entrypoint_function}] " if entrypoint_function and entrypoint_function != "function_name" else ""
test_number = f"{stack_item.split('_')[-1]} - " if stack_item else ""
log_message = f"{entrypoint_prefix}{test_number}{message}"
self.message = log_message
self.expected = False
self.log.seconds = self.log.set_seconds(self.log.initial_time)
Expand Down
Loading