2
0
Эх сурвалжийг харах

Fbx test improvements for linux (#13270)

* Linux fixes for FBX tests:
-Standardized some types across Linux and Windows in the dbgsg files so the output will be the same on both platforms.
-If the only differences between dbgsg files is hashes, then its downgraded to a warning. This is temporary, until we get product output differences across platforms fixed.
-Added json output for dbgsg info to make it easier to write other automated tests, where json parsing is preferred.

Signed-off-by: AMZN-stankowi <[email protected]>

* XML hashes are ignored now

Signed-off-by: AMZN-stankowi <[email protected]>

* Updated tests for new json dbgsg files

Signed-off-by: AMZN-stankowi <[email protected]>

* Truncated floating point values for now (with a warning) because Linux and Windows are not generating the same values for the same scene files.
Fixed a casing problem for a file name.

Signed-off-by: AMZN-stankowi <[email protected]>

* Fixing casing on another file failing on Linux due to case

Signed-off-by: AMZN-stankowi <[email protected]>

* Fixed another Linux casing issue: All paths in the cache are lowercase, this was using an upercase folder in the cache.

Signed-off-by: AMZN-stankowi <[email protected]>

* Another Linux casing fix

Signed-off-by: AMZN-stankowi <[email protected]>

* Fixed incorrect case for cache folder in some situations on Linux

Signed-off-by: AMZN-stankowi <[email protected]>

* Got rid of function level imports

Signed-off-by: AMZN-stankowi <[email protected]>

* Added namespace before size_t

Signed-off-by: AMZN-stankowi <[email protected]>

* Improved logic that extracts differences between lists

Signed-off-by: AMZN-stankowi <[email protected]>

* Fixed doc string for return value to match actual return value

Signed-off-by: AMZN-stankowi <[email protected]>

* Added tests for pipeline_utils list comparisons

Signed-off-by: AMZN-stankowi <[email protected]>

* Pull request feedback:
Used pformat to print lists instead of manually unrolling lists.
Broke compare_scene_debug_file into multiple functions.
Changed some variables to ALL CAPS to show the intent that they are constant.
A lot more comments.

Signed-off-by: AMZN-stankowi <[email protected]>

Signed-off-by: AMZN-stankowi <[email protected]>
AMZN-stankowi 2 жил өмнө
parent
commit
3435961dd7

+ 0 - 0
AutomatedTesting/Gem/PythonTests/assetpipeline/fbx_tests/assets/Motion/SceneDebug/Jack_Idle_Aim_ZUp.dbgsg → AutomatedTesting/Gem/PythonTests/assetpipeline/fbx_tests/assets/Motion/SceneDebug/jack_idle_aim_zup.dbgsg


+ 0 - 0
AutomatedTesting/Gem/PythonTests/assetpipeline/fbx_tests/assets/cubewithline/scenedebug/cubewithline.dbgsg → AutomatedTesting/Gem/PythonTests/assetpipeline/fbx_tests/assets/cubewithline/SceneDebug/cubewithline.dbgsg


+ 0 - 0
AutomatedTesting/Gem/PythonTests/assetpipeline/fbx_tests/assets/cubewithline/scenedebug/cubewithline.dbgsg.xml → AutomatedTesting/Gem/PythonTests/assetpipeline/fbx_tests/assets/cubewithline/SceneDebug/cubewithline.dbgsg.xml


+ 261 - 22
AutomatedTesting/Gem/PythonTests/assetpipeline/fbx_tests/fbx_tests.py

@@ -9,9 +9,12 @@ import binascii
 from dataclasses import dataclass
 import logging
 import os
+from pprint import pformat
 import pytest
+import re
+import shutil
 from typing import List
-import distutils.dir_util
+import warnings
 
 # Import LyTestTools
 import ly_test_tools.builtin.helpers as helpers
@@ -34,6 +37,11 @@ logger = logging.getLogger(__name__)
 # Helper: variables we will use for parameter values in the test:
 targetProjects = ["AutomatedTesting"]
 
+# Helper: Gets a case correct version of the cache folder
+def get_cache_folder(asset_processor):
+    # Make sure the folder being checked is fully lowercase.
+    # Leave the "c" in Cache uppercase.
+    return re.sub("ache[/\\\\](.*)", lambda m: m.group().lower(), asset_processor.project_test_cache_folder())
 
 @pytest.fixture
 def local_resources(request, workspace, ap_setup_fixture):
@@ -77,6 +85,10 @@ blackbox_fbx_tests = [
                                     product_name='onemeshonematerial/onemeshonematerial.dbgsg',
                                     sub_id=1918494907,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='onemeshonematerial/onemeshonematerial.dbgsg.json',
+                                    sub_id=1785487734,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='onemeshonematerial/onemeshonematerial.dbgsg.xml',
                                     sub_id=556355570, asset_type=b'51f376140d774f369ac67ed70a0ac868'),
@@ -122,6 +134,10 @@ blackbox_fbx_tests = [
                                     product_name='softnaminglod/lodtest.dbgsg',
                                     sub_id=-632012261,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='softnaminglod/lodtest.dbgsg.json',
+                                    sub_id=1220784361,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='softnaminglod/lodtest.dbgsg.xml',
                                     sub_id=-2036095434,
@@ -169,6 +185,10 @@ blackbox_fbx_tests = [
                                     product_name='softnamingphysics/physicstest.dbgsg',
                                     sub_id=-740411732,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='softnamingphysics/physicstest.dbgsg.json',
+                                    sub_id=515116686,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='softnamingphysics/physicstest.dbgsg.xml',
                                     sub_id=330338417,
@@ -217,6 +237,10 @@ blackbox_fbx_tests = [
                                     product_name='twomeshonematerial/multiple_mesh_one_material.dbgsg',
                                     sub_id=2077268018,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='twomeshonematerial/multiple_mesh_one_material.dbgsg.json',
+                                    sub_id=2005967149,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='twomeshonematerial/multiple_mesh_one_material.dbgsg.xml',
                                     sub_id=1321067730,
@@ -256,6 +280,10 @@ blackbox_fbx_tests = [
                                     product_name='twomeshlinkedmaterials/multiple_mesh_linked_materials.dbgsg',
                                     sub_id=-1898461950,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='twomeshlinkedmaterials/multiple_mesh_linked_materials.dbgsg.json',
+                                    sub_id=-920599604,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='twomeshlinkedmaterials/multiple_mesh_linked_materials.dbgsg.xml',
                                     sub_id=-772341513,
@@ -303,6 +331,10 @@ blackbox_fbx_tests = [
                                     product_name='onemeshmultiplematerials/single_mesh_multiple_materials.dbgsg',
                                     sub_id=-262822238,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='onemeshmultiplematerials/single_mesh_multiple_materials.dbgsg.json',
+                                    sub_id=1655098364,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='onemeshmultiplematerials/single_mesh_multiple_materials.dbgsg.xml',
                                     sub_id=1462358160,
@@ -349,6 +381,10 @@ blackbox_fbx_tests = [
                                     product_name='vertexcolor/vertexcolor.dbgsg',
                                     sub_id=-1543877170,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='vertexcolor/vertexcolor.dbgsg.json',
+                                    sub_id=-879818679,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='vertexcolor/vertexcolor.dbgsg.xml',
                                     sub_id=1743516586,
@@ -375,7 +411,7 @@ blackbox_fbx_tests = [
         BlackboxAssetTest(
             test_name="MotionTest_RunAP_SuccessWithMatchingProducts",
             asset_folder="Motion",
-            scene_debug_file="Jack_Idle_Aim_ZUp.dbgsg",
+            scene_debug_file="jack_idle_aim_zup.dbgsg",
             assets=[
                 asset_db_utils.DBSourceAsset(
                     source_file_name="Jack_Idle_Aim_ZUp.fbx",
@@ -395,6 +431,10 @@ blackbox_fbx_tests = [
                                     product_name='motion/jack_idle_aim_zup.dbgsg',
                                     sub_id=-517610290,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='motion/jack_idle_aim_zup.dbgsg.json',
+                                    sub_id=-728903306,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='motion/jack_idle_aim_zup.dbgsg.xml',
                                     sub_id=-817863914,
@@ -445,6 +485,10 @@ blackbox_fbx_tests = [
                                     product_name='shaderball/shaderball.dbgsg',
                                     sub_id=-1607815784,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='shaderball/shaderball.dbgsg.json',
+                                    sub_id=-67222749,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='shaderball/shaderball.dbgsg.xml',
                                     sub_id=-1153118555,
@@ -489,6 +533,10 @@ blackbox_fbx_tests = [
                                     product_name='cubewithline/cubewithline.dbgsg',
                                     sub_id=1173066699,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='cubewithline/cubewithline.dbgsg.json',
+                                    sub_id=-1293505439,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='cubewithline/cubewithline.dbgsg.xml',
                                     sub_id=1357518515,
@@ -537,6 +585,10 @@ blackbox_fbx_tests = [
                                     product_name='morphtargetonematerial/morphtargetonematerial.dbgsg',
                                     sub_id=1414413688,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='morphtargetonematerial/morphtargetonematerial.dbgsg.json',
+                                    sub_id=1407432457,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='morphtargetonematerial/morphtargetonematerial.dbgsg.xml',
                                     sub_id=1435013070,
@@ -589,6 +641,10 @@ blackbox_fbx_tests = [
                                     product_name='morphtargettwomaterials/morphtargettwomaterials.dbgsg',
                                     sub_id=594741318,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='morphtargettwomaterials/morphtargettwomaterials.dbgsg.json',
+                                    sub_id=862170373,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='morphtargettwomaterials/morphtargettwomaterials.dbgsg.xml',
                                     sub_id=-990870494,
@@ -642,6 +698,10 @@ blackbox_fbx_special_tests = [
                                     product_name='twomeshtwomaterial/multiple_mesh_multiple_material.dbgsg',
                                     sub_id=896980093,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='twomeshtwomaterial/multiple_mesh_multiple_material.dbgsg.json',
+                                    sub_id=-1300898491,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='twomeshtwomaterial/multiple_mesh_multiple_material.dbgsg.xml',
                                     sub_id=-1556988544,
@@ -671,6 +731,10 @@ blackbox_fbx_special_tests = [
                                     product_name='twomeshtwomaterial/multiple_mesh_multiple_material.dbgsg',
                                     sub_id=896980093,
                                     asset_type=b'07f289d14dc74c4094b40a53bbcb9f0b'),
+                                asset_db_utils.DBProduct(
+                                    product_name='twomeshtwomaterial/multiple_mesh_multiple_material.dbgsg.json',
+                                    sub_id=-1300898491,
+                                    asset_type=b'4342b27e0e1449c3b3b9bcdb9a5fca23'),
                                 asset_db_utils.DBProduct(
                                     product_name='twomeshtwomaterial/multiple_mesh_multiple_material.dbgsg.xml',
                                     sub_id=-1556988544,
@@ -746,9 +810,147 @@ class TestsFBX_AllPlatforms(object):
                     product.product_name = job.platform + "/" \
                                            + product.product_name
 
+
+    @staticmethod
+    def trim_floating_point_values_from_same_length_lists(diff_actual: List[str], diff_expected: List[str],
+            actual_file_path:str, expected_file_path:str) -> (List[str], List[str]):
+        # Linux and non-Linux platforms generate slightly different values for floating points.
+        # Long term, it will be important to stabilize the output of product assets, because this difference
+        # will cause problems: If an Android asset is generated from a Linux versus Windows machine, for example,
+        # it will be different when it's not expected to be different.
+        # In the short term, it's not something addressed yet, so instead this function will
+        # truncate any floating point values to be short enough to be stable. It will then emit a warning, to help keep track of this issue.
+        
+        # Get the initial list lengths, so they can be compared to the list lengths later to see if any differences were
+        # removed due to floating point value drift.
+        initial_diff_actual_len = len(diff_actual)
+        initial_diff_expected_len = len(diff_expected)
+
+        # This function requires the two lists to be equal length.
+        assert initial_diff_actual_len == initial_diff_expected_len, "Scene mismatch - different line counts"
+
+        # Floating point values between Linux and Windows aren't consistent yet. For now, trim these values for comparison.
+        # Store the trimmed values and compare the un-trimmed values separately, emitting warnings.
+        # Trim decimals from the lists to be compared, if any where found, re-compare and generate new lists.
+        DECIMAL_DIGITS_TO_PRESERVE = 3
+        floating_point_regex = re.compile(f"(.*?-?[0-9]+\\.[0-9]{{{DECIMAL_DIGITS_TO_PRESERVE},{DECIMAL_DIGITS_TO_PRESERVE}}})[0-9]+(.*)")
+        for index, diff_actual_line in enumerate(diff_actual):
+            # Loop, because there may be multiple floats on the same line.
+            while True:
+                match_result = floating_point_regex.match(diff_actual[index])
+                if not match_result:
+                    break
+                diff_actual[index] = f"{match_result.group(1)}{match_result.group(2)}"
+            # diff_actual and diff_expected have the same line count, so they can both be checked here
+            while True:
+                match_result = floating_point_regex.match(diff_expected[index])
+                if not match_result:
+                    break
+                diff_expected[index] = f"{match_result.group(1)}{match_result.group(2)}"
+                
+        # Re-run the diff now that floating point values have been truncated.
+        diff_actual, diff_expected = utils.get_differences_between_lists(diff_actual, diff_expected)
+        
+        # If both lists are now empty, then the only differences between the two scene files were floating point drift.
+        if (diff_actual == None and diff_expected == None) or (len(diff_actual) == 0 and len(diff_actual) == 0):
+            warnings.warn(f"Floating point drift detected between {expected_file_path} and {actual_file_path}.")
+            return diff_actual, diff_expected
+            
+        # Something has gone wrong if the lists are now somehow different lengths after the comparison.
+        assert len(diff_actual) == len(diff_expected), "Scene mismatch - different line counts after truncation"
+
+        # If some entries were removed from both lists but not all, then there was some floating point drift causing
+        # differences to appear between the scene files. Provide a warning on that so it can be tracked, then
+        # continue on to the next set of list comparisons.
+        if len(diff_actual) != initial_diff_actual_len or len(diff_expected) != initial_diff_expected_len:
+            warnings.warn(f"Floating point drift detected between {expected_file_path} and {actual_file_path}.")
+
+        return diff_actual, diff_expected
+
+        
+    @staticmethod
+    def scan_scene_debug_xml_file_for_issues(diff_actual: List[str], diff_expected: List[str],
+            actual_hashes_to_skip: List[str], expected_hashes_to_skip: List[str]) -> (List[str], List[str]):
+        # Given the differences between the newly generated XML file versus the last known good, and the lists of hashes that were
+        # skipped in the non-XML debug scene graph comparison, check if the differences in the XML file are the same as the hashes
+        # that were skipped in the non-XML file.
+        # Hashes are generated differently on Linux than other platforms right now. Long term this is a problem, it will mean that
+        # product assets generated on Linux are different than other platforms. Short term, this is a known issue. This automated
+        # test handles this by emitting warnings when this occurs.
+
+        # If the difference count doesn't match the non-XML file, then it's not just hash mis-matches in the XML file, and the test has failed.
+        assert len(expected_hashes_to_skip) == len(diff_expected), "Scene mismatch"
+        assert len(actual_hashes_to_skip) == len(diff_actual), "Scene mismatch"                
+
+        # This test did a simple line by line comparison, and didn't actually load the XML data into a graph to compare.
+        # Which means that the relevant info for this field to make it clear that it is a hash and not another number is not immediately available.
+        # So instead, extract the number and compare it to the known list of hashes.
+        # If this regex fails or the number isn't in the hash list, then it means this is a non-hash difference and should cause a test failure.
+        # Otherwise, if it's just a hash difference, it can be a warning for now, while the information being hashed is not stable across platforms.
+        xml_number_regex = re.compile('.*<Class name="AZ::u64" field="m_data" value="([0-9]*)" type="{D6597933-47CD-4FC8-B911-63F3E2B0993A}"\\/>')
+
+        for list_entry in diff_actual:
+            match_result = xml_number_regex.match(list_entry)
+            assert match_result, "Scene mismatch"
+            data_value = match_result.group(1)
+            # This value doesn't match the list of known hash differences, so mark this test as failed.
+            assert (data_value in actual_hashes_to_skip), "Scene mismatch"
+                
+        for list_entry in diff_expected:
+            match_result = xml_number_regex.match(list_entry)
+            assert match_result, "Scene mismatch"
+            data_value = match_result.group(1)
+            # This value doesn't match the list of known hash differences, so mark this test as failed.
+            assert (data_value in expected_hashes_to_skip), "Scene mismatch"
+        return expected_hashes_to_skip, actual_hashes_to_skip
+
+
+    @staticmethod
+    def scan_scene_debug_scene_graph_file_differences_for_issues(diff_actual: List[str], diff_expected: List[str],
+            actual_file_path:str, expected_file_path:str) -> (List[str], List[str]):
+        # Given the set of differences between two debug scene graph files, check for any known issues and emit warnings.
+        # For unknown issues, fail the test. This primarily checks for hashes that are different.
+        # Right now, hash generation is sometimes different on Linux from other platforms, and the test assets were generated on Windows,
+        # so the hashes may be different when run on Linux. Also, it's been a pain point to need to re-generate debug scene graphs
+        # when small changes occur in the scenes. This layer of data changing hasn't been causing issues yet, and is caught by other
+        # automated tests focused on the specific set of data. This automated test is to verify that the basic structure of the scene
+        # is the same with each run.
+        diff_actual_hashes_removed = []
+        diff_expected_hashes_removed = []
+
+        hash_regex = re.compile("(.*Hash: )([0-9]*)")
+
+        actual_hashes = []
+        expected_hashes = []
+
+        for list_entry in diff_actual:
+            match_result = hash_regex.match(list_entry)
+            assert match_result, "Scene mismatch"
+            diff_actual_hashes_removed.append(match_result.group(1))
+            actual_hashes.append(match_result.group(2))
+                
+        for list_entry in diff_expected:
+            match_result = hash_regex.match(list_entry)
+            assert match_result, "Scene mismatch"
+            diff_expected_hashes_removed.append(match_result.group(1))
+            expected_hashes.append(match_result.group(2))
+
+        hashes_removed_diffs_identical = utils.compare_lists(diff_actual_hashes_removed, diff_expected_hashes_removed)
+
+        # If, after removing all of the hash values, the lists are now identical, emit a warning.
+        if hashes_removed_diffs_identical == True:
+            warnings.warn(f"Hash values no longer match for debug scene graph between files {expected_file_path} and {actual_file_path}")
+
+        return expected_hashes, actual_hashes
+
+
     @staticmethod
-    def compare_scene_debug_file(asset_processor, expected_file_path, actual_file_path):
-        debug_graph_path = os.path.join(asset_processor.project_test_cache_folder(), actual_file_path)
+    def compare_scene_debug_file(asset_processor, expected_file_path: str, actual_file_path: str,
+            expected_hashes_to_skip: List[str] = None, actual_hashes_to_skip: List[str] = None):
+        # Given the paths to the debug scene graph generated by re-processing the test scene file and the path to the
+        # last known good debug scene graph for that file, load both debug scene graphs into memory and scan them for differences.
+        # Warns on known issues, and fails on unknown issues.
+        debug_graph_path = os.path.join(get_cache_folder(asset_processor), actual_file_path)
         expected_debug_graph_path = os.path.join(asset_processor.project_test_source_folder(), "SceneDebug", expected_file_path)
 
         logger.info(f"Parsing scene graph: {debug_graph_path}")
@@ -758,13 +960,33 @@ class TestsFBX_AllPlatforms(object):
         logger.info(f"Parsing scene graph: {expected_debug_graph_path}")
         with open(expected_debug_graph_path, "r") as scene_file:
             expected_lines = scene_file.readlines()
-        assert utils.compare_lists(actual_lines, expected_lines), "Scene mismatch"
+        diff_actual, diff_expected = utils.get_differences_between_lists(actual_lines, expected_lines)
+        if diff_actual == None and diff_expected == None:
+            return None, None
+
+        # There are some differences that are currently considered warnings.
+        # Long term these should become errors, but right now product assets on Linux and non-Linux
+        # are showing differences in hashes.
+        # Linux and non-Linux platforms are also generating different floating point values.
+        diff_actual, diff_expected = TestsFBX_AllPlatforms.trim_floating_point_values_from_same_length_lists(diff_actual, diff_expected, actual_file_path, expected_file_path)
+
+        # If this is the XML debug file, then it will be difficult to verify if a line is a hash line or another integer.
+        # However, because XML files are always compared after standard dbgsg files, the hashes from that initial comparison can be used here to check.
+        is_xml_dbgsg = os.path.splitext(expected_file_path)[-1].lower() == ".xml"        
+
+        if is_xml_dbgsg:
+            return TestsFBX_AllPlatforms.scan_scene_debug_xml_file_for_issues(diff_actual, diff_expected, actual_hashes_to_skip, expected_hashes_to_skip)
+        else:
+            return TestsFBX_AllPlatforms.scan_scene_debug_scene_graph_file_differences_for_issues(diff_actual, diff_expected, actual_file_path, expected_file_path)
+
 
     # Helper to run Asset Processor with debug output enabled and Atom output disabled
     @staticmethod
     def run_ap_debug_skip_atom_output(asset_processor):
-        result = asset_processor.batch_process(extra_params=["--debugOutput",
+        result, output = asset_processor.batch_process(capture_output=True, extra_params=["--debugOutput",
                                                              "--regset=\"/O3DE/SceneAPI/AssetImporter/SkipAtomOutput=true\""])
+        # If the test fails, it's helpful to have the output from asset processor in the logs, to track the failure down.
+        logger.info(f"Asset Processor Output: {pformat(output)}")
         assert result, "Asset Processor Failed"
 
     def run_fbx_test(self, workspace, ap_setup_fixture, asset_processor,
@@ -805,8 +1027,15 @@ class TestsFBX_AllPlatforms(object):
                 for expected_product in expected_job.products:
                     expected_product_list.append(expected_product.product_name)
 
+        cache_folder = get_cache_folder(asset_processor)
         missing_assets, _ = utils.compare_assets_with_cache(expected_product_list,
-                                                            asset_processor.project_test_cache_folder())
+                                                            cache_folder)
+
+        # If the test is going to fail, print information to help track down the cause of failure.
+        if missing_assets:
+            logger.info(f"The following assets were missing from cache: {pformat(missing_assets)}")
+            in_cache = os.listdir(cache_folder)
+            logger.info(f"The cache {cache_folder} contains this content: {pformat(in_cache)}")
 
         assert not missing_assets, \
             f'The following assets were expected to be in, but not found in cache: {str(missing_assets)}'
@@ -820,12 +1049,14 @@ class TestsFBX_AllPlatforms(object):
         if blackbox_params.scene_debug_file:
             scene_debug_file = blackbox_params.override_scene_debug_file if overrideAsset \
                 else blackbox_params.scene_debug_file
-            self.compare_scene_debug_file(asset_processor, scene_debug_file, blackbox_params.scene_debug_file)
+            expected_hashes_to_skip, actual_hashes_to_skip = self.compare_scene_debug_file(asset_processor, scene_debug_file, blackbox_params.scene_debug_file)
 
             # Run again for the .dbgsg.xml file
             self.compare_scene_debug_file(asset_processor,
                                           scene_debug_file + ".xml",
-                                          blackbox_params.scene_debug_file + ".xml")
+                                          blackbox_params.scene_debug_file + ".xml",
+                                          expected_hashes_to_skip = expected_hashes_to_skip,
+                                          actual_hashes_to_skip = actual_hashes_to_skip)
 
         # Check that each given source asset resulted in the expected jobs and products.
         self.populate_asset_info(workspace, project, assetsToValidate)
@@ -852,9 +1083,10 @@ class TestsFBX_AllPlatforms(object):
             5. Run asset processor
             6. Validate that Asset Processor generates the expected output
         """
-
         # Copying test assets to project folder
-        asset_processor.prepare_test_environment(ap_setup_fixture["tests_dir"], "ModifiedFBXFile_ConsistentProductOutput")
+        TEST_FOLDER_NAME = "ModifiedFBXFile_ConsistentProductOutput"
+
+        asset_processor.prepare_test_environment(ap_setup_fixture["tests_dir"], TEST_FOLDER_NAME)
         # Run AP against the FBX file and the .assetinfo file
         self.run_ap_debug_skip_atom_output(asset_processor)
 
@@ -863,14 +1095,19 @@ class TestsFBX_AllPlatforms(object):
         assert os.path.exists(scene_debug_expected), "Expected scene file missing in SceneDebug/modifiedfbxfile.dbgsg - Check test assets"
 
         # Set path to actual dbgsg output, obtained when running AP
-        scene_debug_actual = os.path.join(asset_processor.temp_project_cache(asset_platform=ASSET_PROCESSOR_PLATFORM_MAP[workspace.asset_processor_platform]), "ModifiedFBXFile_ConsistentProductOutput","modifiedfbxfile.dbgsg")
+        scene_debug_actual = os.path.join(
+            asset_processor.temp_project_cache(asset_platform=ASSET_PROCESSOR_PLATFORM_MAP[workspace.asset_processor_platform]),
+            TEST_FOLDER_NAME.lower(),
+            "modifiedfbxfile.dbgsg")
+
         assert os.path.exists(scene_debug_actual)
 
         # Compare the dbgsg files to ensure expected outputs
-        self.compare_scene_debug_file(asset_processor, scene_debug_expected, scene_debug_actual)
+        expected_hashes_to_skip, actual_hashes_to_skip = self.compare_scene_debug_file(asset_processor, scene_debug_expected, scene_debug_actual)
 
         # Run again for the .dbgsg.xml files
-        self.compare_scene_debug_file(asset_processor, scene_debug_expected + ".xml", scene_debug_actual + ".xml")
+        self.compare_scene_debug_file(asset_processor, scene_debug_expected + ".xml", scene_debug_actual + ".xml",
+            expected_hashes_to_skip=expected_hashes_to_skip, actual_hashes_to_skip=actual_hashes_to_skip)
 
         # Remove the files to be replaced from the source test asset folder
         filestoremove = [
@@ -883,11 +1120,10 @@ class TestsFBX_AllPlatforms(object):
             assert not os.path.exists(file), f"File failed to be removed: {file}"
 
         # Add the replacement FBX and expected dbgsg files into the test project
-        source = os.path.join(ap_setup_fixture["tests_dir"], "Assets",
+        source = os.path.join(ap_setup_fixture["tests_dir"], "assets",
                               "Override_ModifiedFBXFile_ConsistentProductOutput")
         destination = asset_processor.project_test_source_folder()
-        # Note: Replace distutils for shutil.copytree("src", "dst", dirs_exist_ok=True) once updated to Python 3.8
-        distutils.dir_util.copy_tree(source, destination)
+        shutil.copytree(source, destination, dirs_exist_ok=True)
         assert os.path.exists(scene_debug_expected), \
             "Expected scene file missing in SceneDebug/modifiedfbxfile.dbgsg - Check test assets"
 
@@ -895,10 +1131,11 @@ class TestsFBX_AllPlatforms(object):
         self.run_ap_debug_skip_atom_output(asset_processor)
 
         # Compare the new .dbgsg files with their expected outputs
-        self.compare_scene_debug_file(asset_processor, scene_debug_expected, scene_debug_actual)
+        expected_hashes_to_skip, actual_hashes_to_skip = self.compare_scene_debug_file(asset_processor, scene_debug_expected, scene_debug_actual)
 
         # Run again for the .dbgsg.xml file
-        self.compare_scene_debug_file(asset_processor, scene_debug_expected + ".xml", scene_debug_actual + ".xml")
+        self.compare_scene_debug_file(asset_processor, scene_debug_expected + ".xml", scene_debug_actual + ".xml",
+            expected_hashes_to_skip=expected_hashes_to_skip, actual_hashes_to_skip=actual_hashes_to_skip)
 
     def test_FBX_MixedCaseFileExtension_OutputSucceeds(self, workspace, ap_setup_fixture, asset_processor):
         """
@@ -941,7 +1178,7 @@ class TestsFBX_AllPlatforms(object):
                 ]
 
             missing_assets, _ = utils.compare_assets_with_cache(expectedassets,
-                                                                asset_processor.project_test_cache_folder())
+                                                                get_cache_folder(asset_processor))
 
             assert not missing_assets, \
                 f'The following assets were expected to be in when processing {extension}, but not found in cache: ' \
@@ -958,9 +1195,11 @@ class TestsFBX_AllPlatforms(object):
                                               "onemeshonematerial", "onemeshonematerial.dbgsg")
             assert os.path.exists(scene_debug_actual), f"Scene debug output missing after running AP on {extension}."
 
-            self.compare_scene_debug_file(asset_processor, scene_debug_expected, scene_debug_actual)
+            expected_hashes_to_skip, actual_hashes_to_skip = self.compare_scene_debug_file(asset_processor, scene_debug_expected, scene_debug_actual)
 
             # Run again for the .dbgsg.xml file
             self.compare_scene_debug_file(asset_processor,
                                           scene_debug_expected + ".xml",
-                                          scene_debug_actual + ".xml")
+                                          scene_debug_actual + ".xml",
+                                          expected_hashes_to_skip = expected_hashes_to_skip,
+                                          actual_hashes_to_skip = actual_hashes_to_skip)

+ 44 - 6
Code/Tools/SceneAPI/SceneCore/Utilities/DebugOutput.cpp

@@ -10,6 +10,7 @@
 #include <AzCore/std/optional.h>
 
 #include <AzCore/IO/SystemFile.h>
+#include <AzCore/Serialization/Json/JsonUtils.h>
 #include <AzCore/Serialization/Utils.h>
 #include <AzFramework/StringFunc/StringFunc.h>
 #include <SceneAPI/SceneCore/Containers/Scene.h>
@@ -21,6 +22,8 @@
 
 namespace AZ::SceneAPI::Utilities
 {
+    bool SaveToJson(const AZStd::string& fileName, const DebugSceneGraph& graph);
+
     void DebugNode::Reflect(AZ::ReflectContext* context)
     {
         AZ::SerializeContext* serialize = azrtti_cast<AZ::SerializeContext*>(context);
@@ -82,16 +85,18 @@ namespace AZ::SceneAPI::Utilities
 
     void DebugOutput::Write(const char* name, uint64_t data)
     {
-        m_output += AZStd::string::format("\t%s: %" PRIu64 "\n", name, data);
+        AZ::u64 multiplatformSafeData(static_cast<AZ::u64>(data));
+        m_output += AZStd::string::format("\t%s: %llu\n", name, multiplatformSafeData);
 
-        AddToNode(name, data);
+        AddToNode(name, multiplatformSafeData);
     }
 
     void DebugOutput::Write(const char* name, int64_t data)
     {
-        m_output += AZStd::string::format("\t%s: %" PRId64 "\n", name, data);
+        AZ::s64 multiplatformSafeData(static_cast<AZ::s64>(data));
+        m_output += AZStd::string::format("\t%s: %lld\n", name, multiplatformSafeData);
 
-        AddToNode(name, data);
+        AddToNode(name, multiplatformSafeData);
     }
 
     void DebugOutput::Write(const char* name, const DataTypes::MatrixType& data)
@@ -241,7 +246,12 @@ namespace AZ::SceneAPI::Utilities
             }
             dbgFile.Close();
 
+            // XML is useful because it stores more information than JSON with the serializer, so some automation is better suited to use XML.
             Utils::SaveObjectToFile((debugSceneFile + ".xml").c_str(), DataStream::StreamType::ST_XML, &debugSceneGraph);
+            // JSON is useful because it can be quicker and easier to parse than XML, and more structured than the human readable dbgsg file.
+            AZStd::string jsonFileName(debugSceneFile + ".json");
+            SaveToJson(jsonFileName, debugSceneGraph);
+
 
             static const AZ::Data::AssetType dbgSceneGraphAssetType("{07F289D1-4DC7-4C40-94B4-0A53BBCB9F0B}");
             productList.AddProduct(productName, AZ::Uuid::CreateName(productName.c_str()), dbgSceneGraphAssetType,
@@ -251,19 +261,47 @@ namespace AZ::SceneAPI::Utilities
             productList.AddProduct(
                 (productName + ".xml"), AZ::Uuid::CreateName((productName + ".xml").c_str()), dbgSceneGraphXmlAssetType,
                 AZStd::nullopt, AZStd::nullopt);
+            
+            static const AZ::Data::AssetType dbgSceneGraphJsonAssetType("{4342B27E-0E14-49C3-B3B9-BCDB9A5FCA23}");
+            productList.AddProduct(
+                jsonFileName, AZ::Uuid::CreateName((productName + ".json").c_str()), dbgSceneGraphJsonAssetType,
+                AZStd::nullopt, AZStd::nullopt);
 
             // save out debug text for the Scene Manifest
             AZStd::string productNameDebugManifest { debugSceneFile };
             AzFramework::StringFunc::Path::ReplaceExtension(productNameDebugManifest, "assetinfo.dbg");
             scene->GetManifest().SaveToFile(productNameDebugManifest.c_str());
 
-            static const AZ::Data::AssetType dbgSceneManifstAssetType("{48A78BE7-B3F2-44B8-8AA6-F0607E9A75A5}");
+            static const AZ::Data::AssetType dbgSceneManifestAssetType("{48A78BE7-B3F2-44B8-8AA6-F0607E9A75A5}");
             productList.AddProduct(
                 productNameDebugManifest,
                 AZ::Uuid::CreateName((productName + ".assetinfo.dbg").c_str()),
-                dbgSceneManifstAssetType,
+                dbgSceneManifestAssetType,
                 AZStd::nullopt,
                 AZStd::nullopt);
         }
     }
+
+    bool SaveToJson(const AZStd::string& fileName, const DebugSceneGraph& graph)
+    {
+        AZ::JsonSerializerSettings settings;
+        rapidjson::Document jsonDocument;
+        auto jsonResult = JsonSerialization::Store(jsonDocument, jsonDocument.GetAllocator(), graph, settings);
+        if (jsonResult.GetProcessing() == AZ::JsonSerializationResult::Processing::Halted)
+        {
+            AZ_Error("Scene Debug Output", false,
+                AZStd::string::format(
+                    "JSON serialization of file %.*s failed: %.*s", AZ_STRING_ARG(fileName), AZ_STRING_ARG(jsonResult.ToString(""))).c_str());
+            return false;
+        }
+
+        auto jsonSaveResult = AZ::JsonSerializationUtils::WriteJsonFile(jsonDocument, fileName);
+
+        AZ_Error(
+            "Scene Debug Output",
+            jsonSaveResult.IsSuccess(),
+            AZStd::string::format("Saving JSON to file %.*s failed: %.*s", AZ_STRING_ARG(fileName), AZ_STRING_ARG(jsonSaveResult.GetError())).c_str());
+
+        return jsonSaveResult.IsSuccess();
+    }
 }

+ 14 - 12
Code/Tools/SceneAPI/SceneCore/Utilities/DebugOutput.inl

@@ -10,30 +10,32 @@
 
 namespace AZ::SceneAPI::Utilities
 {
-    template <typename T>
+    template<typename T>
     void DebugOutput::Write(const char* name, const AZStd::vector<T>& data)
     {
-        size_t hash = AZStd::hash_range(data.begin(), data.end());
-        m_output += AZStd::string::format("\t%s: Count %zu. Hash: %zu\n", name, data.size(), hash);
+        AZStd::size_t hash = AZStd::hash_range(data.begin(), data.end());
+        AZ::u64 dataSize(static_cast<AZ::u64>(data.size()));
+        m_output += AZStd::string::format("\t%s: Count %llu. Hash: %zu\n", name, dataSize, hash);
 
-        AddToNode(AZStd::string::format("%s - Count", name).c_str(), data.size());
-        AddToNode(AZStd::string::format("%s - Hash", name).c_str(), hash);
+        AddToNode(AZStd::string::format("%s - Count", name).c_str(), dataSize);
+        AddToNode(AZStd::string::format("%s - Hash", name).c_str(), static_cast<AZ::u64>(hash));
     }
 
-    template <typename T>
+    template<typename T>
     void DebugOutput::Write(const char* name, const AZStd::vector<AZStd::vector<T>>& data)
     {
-        size_t hash = 0;
+        AZStd::size_t hash = 0;
+        AZ::u64 dataSize(static_cast<AZ::u64>(data.size()));
 
         for (auto&& vector : data)
         {
             AZStd::hash_combine(hash, AZStd::hash_range(vector.begin(), vector.end()));
         }
 
-        m_output += AZStd::string::format("\t%s: Count %zu. Hash: %zu\n", name, data.size(), hash);
+        m_output += AZStd::string::format("\t%s: Count %llu. Hash: %zu\n", name, dataSize, hash);
 
-        AddToNode(AZStd::string::format("%s - Count", name).c_str(), data.size());
-        AddToNode(AZStd::string::format("%s - Hash", name).c_str(), hash);
+        AddToNode(AZStd::string::format("%s - Count", name).c_str(), dataSize);
+        AddToNode(AZStd::string::format("%s - Hash", name).c_str(), static_cast<AZ::u64>(hash));
     }
-    
-}
+
+} // namespace AZ::SceneAPI::Utilities

+ 27 - 18
Tools/LyTestTools/ly_test_tools/o3de/pipeline_utils.py

@@ -352,31 +352,40 @@ def get_relative_file_paths(start_dir: str, ignore_list: Optional[List[str]] = N
                 all_files.append(os.path.relpath(full_path, start_dir))
     return all_files
 
+def get_differences_between_lists(first: List[str], second: List[str]) -> (List[str], List[str]):
+    """
+        Returns two lists that contain unique entries in lists, missing from the other list.
+    """
+    first_set = set(first)
+    second_set = set(second)
+    diff_first = [x for x in first_set if x not in second]
+    diff_second = [x for x in second_set if x not in first]
+    
+    if diff_first or diff_second:
+        # Print a simple header if there are differences, to make it easier to follow log output on build machines.
+        logger.info("Differences were found comparing the given lists.")
+
+    # Log difference between actual and expected (if any). Easier for troubleshooting
+    if diff_first:
+        logger.info("The following entries were actually found but not expected:")
+        for list_entry in diff_first:
+            logger.info("   " + list_entry)
+    if diff_second:
+        logger.info("The following entries were expected to be found but were actually not:")
+        for list_entry in diff_second:
+            logger.info("   " + list_entry)
+
+    return diff_first, diff_second
+    
 
 def compare_lists(actual: List[str], expected: List[str]) -> bool:
     """Compares the two lists of strings. Returns false and prints any discrepancies if present."""
 
     # Find difference between expected and actual
-    diff = {"actual": [], "expected": []}
-    for asset in actual:
-        if asset not in expected:
-            diff["actual"].append(asset)
-    for asset in expected:
-        if asset not in actual:
-            diff["expected"].append(asset)
-
-    # Log difference between actual and expected (if any). Easier for troubleshooting
-    if diff["actual"]:
-        logger.info("The following assets were actually found but not expected:")
-        for asset in diff["actual"]:
-            logger.info("   " + asset)
-    if diff["expected"]:
-        logger.info("The following assets were expected to be found but were actually not:")
-        for asset in diff["expected"]:
-            logger.info("   " + asset)
+    diff_actual, diff_expected = get_differences_between_lists(actual, expected)
 
     # True ONLY IF both diffs are empty
-    return not diff["actual"] and not diff["expected"]
+    return not diff_actual and not diff_expected
 
 
 def delete_MoveOutput_folders(search_path: List[str] or str) -> None:

+ 90 - 0
Tools/LyTestTools/tests/unit/test_pipeline_utils.py

@@ -0,0 +1,90 @@
+"""
+Copyright (c) Contributors to the Open 3D Engine Project.
+For complete copyright and license terms please see the LICENSE at the root of this distribution.
+
+SPDX-License-Identifier: Apache-2.0 OR MIT
+"""
+
+import pytest
+import functools
+import ly_test_tools.o3de.pipeline_utils as pipeline_utils
+
+pytestmark = pytest.mark.SUITE_smoke
+
+
+def lists_are_equal(first_list, second_list):
+    first_list.sort()
+    second_list.sort()
+    return functools.reduce(lambda x, y : x and y, map(lambda p, q: p == q, first_list, second_list))
+
+
+class TestPipelineUtils(object):
+    def test_ListDiff_EmptyLists_ReturnsEmptyLists(self):
+        first_list = []
+        second_list = []
+        diff_first, diff_second = pipeline_utils.get_differences_between_lists(first_list, second_list)
+        assert len(diff_first) == 0
+        assert len(diff_first) == len(diff_second)
+        
+
+    def test_ListDiff_SecondListNotEmpty_ReturnsContentsOfFirstList(self):
+        first_list = ["SomeEntry"]
+        second_list = []
+        diff_first, diff_second = pipeline_utils.get_differences_between_lists(first_list, second_list)
+        assert len(diff_first) == 1
+        assert len(diff_second) == 0
+        assert lists_are_equal(diff_first, first_list)
+        
+
+    def test_ListDiff_IdenticalListsSameOrder_ReturnsEmptyLists(self):
+        first_list = ["SomeEntry", "AnotherEntry"]
+        second_list = ["SomeEntry", "AnotherEntry"]
+        diff_first, diff_second = pipeline_utils.get_differences_between_lists(first_list, second_list)
+        assert len(diff_first) == 0
+        assert len(diff_first) == len(diff_second)
+        
+
+    def test_ListDiff_IdenticalListsDifferentOrder_ReturnsEmptyLists(self):
+        first_list = ["OutOfOrderEntry", "SomeEntry", "AnotherEntry"]
+        second_list = ["SomeEntry", "AnotherEntry", "OutOfOrderEntry"]
+        diff_first, diff_second = pipeline_utils.get_differences_between_lists(first_list, second_list)
+        assert len(diff_first) == 0
+        assert len(diff_first) == len(diff_second)
+        
+
+    def test_ListDiff_DifferentLists_ReturnsDifferences(self):
+        first_list = [ "ListOneUniqueEntry", "OutOfOrderEntry", "SomeEntry", "AnotherEntry"]
+        second_list = ["SomeEntry", "AnotherEntry", "ListTwoUniqueEntry", "OutOfOrderEntry", "SecondUniqueEntry"]
+        diff_first, diff_second = pipeline_utils.get_differences_between_lists(first_list, second_list)
+        assert lists_are_equal(diff_first, ["ListOneUniqueEntry"])
+        assert lists_are_equal(diff_second, ["ListTwoUniqueEntry", "SecondUniqueEntry"])
+
+
+    def test_ListCompare_EmptyLists_ReturnsTrue(self):
+        first_list = []
+        second_list = []
+        assert pipeline_utils.compare_lists(first_list, second_list) == True
+        
+
+    def test_ListCompare_SecondListNotEmpty_ReturnsFalse(self):
+        first_list = ["SomeEntry"]
+        second_list = []
+        assert pipeline_utils.compare_lists(first_list, second_list) == False
+        
+
+    def test_ListCompare_IdenticalListsSameOrder_ReturnsTrue(self):
+        first_list = ["SomeEntry", "AnotherEntry"]
+        second_list = ["SomeEntry", "AnotherEntry"]
+        assert pipeline_utils.compare_lists(first_list, second_list) == True
+        
+
+    def test_ListCompare_IdenticalListsDifferentOrder_ReturnsTrue(self):
+        first_list = ["OutOfOrderEntry", "SomeEntry", "AnotherEntry"]
+        second_list = ["SomeEntry", "AnotherEntry", "OutOfOrderEntry"]
+        assert pipeline_utils.compare_lists(first_list, second_list) == True
+        
+
+    def test_ListCompare_DifferentLists_ReturnsFalse(self):
+        first_list = [ "ListOneUniqueEntry", "OutOfOrderEntry", "SomeEntry", "AnotherEntry"]
+        second_list = ["SomeEntry", "AnotherEntry", "ListTwoUniqueEntry", "OutOfOrderEntry", "SecondUniqueEntry"]
+        assert pipeline_utils.compare_lists(first_list, second_list) == False