
import importlib
from java.lang import Throwable, RuntimeException

"""
testRunner
Ignition module containing functions and classes necessary to run tests across modules in an Ignition project.
Associated tests and example tests are prefixed by 'check_'.
In Gateway Settings, ensure that the project containing this module is designated as the scripting project.

Sample script console usage:
results = testRunner.run()

This single module is grafted together from other disparate ones.
The order of functions listed is
    interface functions (.run())
    tests prefixed by 'check_'
    TestSuite class
	looseleaf functions

Original Author: Evan McKee

Created Date: 06/16/2025
"""

def run():
	"""Run all tests.
	"""
	TS = TestSuite(prefix='check_',
		modulePaths=[
			'testRunner',
			### Your module paths here ###
		],
		ignore=[
			'testRunner.check_TestSuite_getResults',  # Causes recursion error if testRunner is a module
			### Your function paths to disinclude here ###
		])
	
	return TS.getResults()

def check_TestSuite_getResults():
	
	TS = TestSuite(modulePaths=['testRunner'], ignore=['testRunner.check_TestSuite_getResults'])
	r = TS.getResults()['toPrint']
	expected = '''3 of 3 tests passed.
100.0% pass rate.'''
	assert r == expected, str(r)

def check_TestSuite_runFunctions():

	functionList = [{'modulePath': 'testRunner', 'functionName': 'iAlwaysPass'}]

	# Function which passes
	modulePath = functionList[0]['modulePath']

	TS = TestSuite()
	r = TS.runFunctions(modulePath, functionList)['errors']
	assert r == [], 'Working case failed. ' + str(r)
	
	# Function which fails
	functionList = [{'modulePath': 'testRunner', 'functionName': 'iAlwaysFail'}]
	
	r = TS.runFunctions(modulePath, functionList)['errors']
	assert len(r) == 1, 'Failure case failed. ' + str(r)
	
	# Function which breaks (errors out)
	functionList = [{'modulePath': 'testRunner', 'functionName': 'iAlwaysBreak'}]
	
	r = TS.runFunctions(modulePath, functionList)['errors']
	assert len(r) == 1, 'Exception case ' + str(r)
	
	return None

def check_TestSuite_thisModuleExists():
	
	modulePath = 'testRunner'
	
	TS = TestSuite()
	r = TS.thisModuleExists(modulePath)
	assert r is True, str(r)
	
def check_TestSuite_getFunctionNamesFromModulePath():
	
	modulePath = 'testRunner'
	
	TS = TestSuite()
	r = TS.getFunctionNamesFromModulePath(modulePath)
	assert 'check_TestSuite_getFunctionNamesFromModulePath' in r, str(r)

class TestSuite:
	
	def __init__(self,
		prefix='check_',  # Will run every test function with this prefix
		modulePaths=[ 
			# Module paths to include ex. 'myPackage.myModule'
			],
		ignore=[
			# Function paths to ignore ex. 'myPackage.myModule.myFunction'
			],
		includePassageList=False,  # Capture results from every function, not just ones that passed
		printResults=True,  # Print results to script console
		):
		
		self.prefix = prefix
		self.modulePaths = modulePaths
		self.ignore = ignore
		self.includePassageList = includePassageList
		self.printResults = printResults
		
	def getResults(self):
		"""Runs all test functions across modules prepended by the designated prefix to
		verify they work.
		
		Original Author: Evan McKee
		
		Created Date: 01/21/2025
		
		Returns:
			results (dict) {
				'passRatio' (str): numPassed/totalFunctions,
				'passRate' (float(0.-100.)): numPassed/totalFunctions (percentage),
				'errors' (list of strings): Fail messages,
				'toPrint' (str): console output,
				'passageList' (list of dicts): [
						{
							'name' (str): Name of function with path
							'pass' (bool): Whether or not it passed
						}
					]
				}
		"""
	
		errors = []  # List of string errors during testing
		passageList = []  # List of dicts; Every test function along with whether it passed
		totalTests = 0
		
		self.modulePaths = sorted(list(set(self.modulePaths)))
	
		# Collect every function as {'module': modulePath, 'functionName': functionName}
		fxnList = []
		
		for modulePath in self.modulePaths:
			functionNames = self.getFunctionNamesFromModulePath(modulePath)
			for functionName in functionNames:
				if '.'.join([modulePath, functionName]) not in self.ignore:
					fxnList.append({'modulePath': modulePath, 'functionName': functionName})

		# Run all functions
		for modulePath in self.modulePaths:
			functionNames = self.getFunctionNamesFromModulePath(modulePath)
			prefixFunctions = [k for k in fxnList if k['modulePath'] == modulePath and k['functionName'].startswith(self.prefix)]
			newResults = self.runFunctions(modulePath, prefixFunctions)
			errors.extend(newResults['errors'])
			passageList.extend(newResults['passageList'])
			totalTests += len(prefixFunctions)
		
		# Calculate pass rate
		passRatio = '{}/{}'.format(totalTests - len(errors), totalTests)
		passRate = round(100 * (totalTests - len(errors)) / float(totalTests), 2) if totalTests != 0 else 0.
		
		# Fill printable text
		toPrint = '{} of {} tests passed.'.format(passRatio.split('/')[0], passRatio.split('/')[1])
		toPrint += '\n{}% pass rate.'.format(passRate)
		if errors:
			toPrint += '\nErrors:'
			for s in errors:
				toPrint += '\n  ' + s
		if self.includePassageList:
			toPrint += '\nAll tests:'
			for d in passageList:
				if d['pass'] is False:
					toPrint += '\n  {} {}'.format('f', d['name'])
				else:
					toPrint += '\n  {} {}'.format('p', d['name'])
		
		dOut = {
			'passRate': passRate,
			'passRatio': passRatio,
			'errors': errors,
			'toPrint': toPrint,
			'passageList': passageList,
			}
		
		if self.printResults:
			print toPrint

		return dOut				

	def runFunctions(self, modulePath, fxnList, passValue=None):
		"""Runs functions iteratively collecting run failures.
	
		Original Author: Evan McKee
	
		Created Date: 01/21/2025
		Args:
			modulePath (string): Qualified path to module
			fxnList (list of function dicts to execute as {'modulePath': modulePath, 'functionName': functionName})
			passValue (any): Represents "good" result
		Returns:
			dict {
				errors (list of strings): Fail messages
				passageList (list of dicts as {
					name (str): Name of function
					pass (bool): Whether it passed
					})
				}
		"""
		
		errors = []
		passageList = []
		
		for fxn in fxnList:
			functionName = fxn['functionName']

			# Run test
			try:
				original = getattr(importlib.import_module(modulePath), functionName)
				returnValue = original()
				
				# On fail
				if returnValue != passValue:
					errors.append('{}.{} failed. Got returnValue {} not {}.'.format(modulePath, functionName, returnValue, passValue))
					passageList.append({'name': '{}.{}'.format(modulePath, functionName), 'pass': False})
				
				# On pass
				else:
					passageList.append({'name': '{}.{}'.format(modulePath, functionName), 'pass': True})
			
			# Python error
			except Exception as E:
				errors.append('{}.{} failed. Got Python error {}'.format(modulePath, functionName, str(E)))
				passageList.append({'name': '{}.{}'.format(modulePath, functionName), 'pass': False})
			
			# java error
			except Throwable as E:
				errors.append('{}.{} failed. Got java error {}'.format(modulePath, functionName, str(E)))
				passageList.append({'name': '{}.{}'.format(modulePath, functionName), 'pass': False})
			
		return {
			'errors': errors,
			'passageList': passageList,
			}

	def thisModuleExists(self, modulePath):
		"""Returns True if modulePath exists, otherwise False.
	
		Original Author: Evan McKee
		
		Created Date: 01/21/2025
		Args:
			modulePath (str): Path to module (ex: "myPackage.myScript")
		"""
		try:
			__import__(modulePath)
			return True
		except:
			return False
			
	def getFunctionNamesFromModulePath(self, modulePath):
		"""Scans a module for all function names (not including class functions).
	
		Original Author: Evan McKee
		
		Created Date: 01/21/2025
		Args:
			modulePath (str): Full path of module (ex. myPackage.myScript)
		Returns:
			list of function names as strings (not full paths).
		"""
		functionNames = []
		if self.thisModuleExists(modulePath):
			module = importlib.import_module(modulePath)
			for attributeName in dir(module):
				attribute = getattr(module, attributeName)
				if str(type(attribute)) == "<type 'function'>":
					functionNames.append(attributeName)
		return functionNames

def iAlwaysPass():
	"""Function used in testing which always passes.
	"""
	assert True
	
def iAlwaysFail():
	"""Function used in testing which always fails in assertion.
	"""
	assert 1 == 0, '1 != 0.'
	
def iAlwaysBreak():
	"""Function used in testing which always throws a Python error.
	"""
	_ = 1/0