#
#
#  Copyright (c) 2014, 2015, 2016, 2017 Psyop Media Company, LLC
#  See license.txt
#
#

import sys
import unittest


def get_all_unit_tests():
    """ Returns the list of unit tests (to run in any context)"""
    return [CSVParsing, CryptoHashing]


def get_all_nuke_tests():
    """ Returns the list of Nuke integration tests"""
    return [CSVParsingNuke, CryptomatteNodePasting, CryptomatteNukeTests]


#############################################
# Env. variables
#############################################
"""
The supplied Cryptomatte sample images are required for testing.  The path to the sample_images 
directory can be defined using the environment variable below. Otherwise, they are searched for 
relative to this file.
"""
SAMPLES_IMAGES_DIR_ENVIRON = "CRYPTOMATTE_TESTING_SAMPLES"
global CRYPTOMATTETEST_SKIP_CLEANUP_ON_FAILURE
CRYPTOMATTETEST_SKIP_CLEANUP_ON_FAILURE = False


def set_skip_cleanup_on_failure(enabled):
    assert type(enabled) is bool
    global CRYPTOMATTETEST_SKIP_CLEANUP_ON_FAILURE
    CRYPTOMATTETEST_SKIP_CLEANUP_ON_FAILURE = enabled


def reset_skip_cleanup_on_failure():
    global CRYPTOMATTETEST_SKIP_CLEANUP_ON_FAILURE
    CRYPTOMATTETEST_SKIP_CLEANUP_ON_FAILURE = False


#############################################
# Unit tests
#############################################


class CSVParsing(unittest.TestCase):
    csv_str = ("""str, "str with space", "single 'quotes'", """
               '"with_a,_comma", "with comma, and \\"quotes\\"", <123.45>, '
               '" space_in_front", "space_at_end ", "has_escape\\\\chars", '
               '"cyrillic \xd1\x80\xd0\xb0\xd0\xb2\xd0\xbd\xd0\xb8\xd0\xbd\xd0\xb0"')
    name_list = [
        "str", "str with space", "single 'quotes'", "with_a,_comma", 'with comma, and "quotes"',
        "<123.45>", " space_in_front", "space_at_end ", "has_escape\\chars",
        "cyrillic \xd1\x80\xd0\xb0\xd0\xb2\xd0\xbd\xd0\xb8\xd0\xbd\xd0\xb0"
    ]

    def test_csv_round_trip(self):
        import cryptomatte_utilities as cu
        """Ensures the round trip is correct for CSV encoding and decoding. """

        def check_results(encoded, decoded):
            self.assertEqual(encoded, self.csv_str,
                             "Round trip to str failed: %s != %s" % (self.csv_str, encoded))
            self.assertEqual(self.name_list, decoded,
                             "Round trip to list failed: %s != %s" % (self.name_list, decoded))

        # start from csv
        decoded = cu._decode_csv(self.csv_str)
        encoded = cu._encode_csv(decoded)
        check_results(encoded, decoded)

        # start from list
        encoded = cu._encode_csv(self.name_list)
        decoded = cu._decode_csv(encoded)
        check_results(encoded, decoded)


class CryptoHashing(unittest.TestCase):
    mm3hash_float_values = {
        "hello": 6.0705627102400005616e-17,
        "cube": -4.08461912519e+15,
        "sphere": 2.79018604383e+15,
        "plane": 3.66557617593e-11,
        # utf-8 bytes for "plane" in Bulgarian
        "\xd1\x80\xd0\xb0\xd0\xb2\xd0\xbd\xd0\xb8\xd0\xbd\xd0\xb0": -1.3192631212399999468e-25,
        # utf-8 bytes for "girl" in German
        "m\xc3\xa4dchen": 6.2361298211599995797e+25,
    }

    def test_mm3hash_float(self):
        import cryptomatte_utilities as cu
        for name, hashvalue in self.mm3hash_float_values.iteritems():
            msg = "%s hash does not line up: %s %s" % (name, hashvalue, cu.mm3hash_float(name))
            self.assertEqual(cu.mm3hash_float(name), cu.single_precision(hashvalue), msg)


#############################################
# Nuke tests
#############################################

global g_cancel_nuke_testing
g_cancel_nuke_testing = False


class CSVParsingNuke(unittest.TestCase):
    """
    Nuke removes escaped characters when you use getValue() on a string knob. 
    Therefore, testing the round trip through the gizmo is slightly complicated. 
    """

    def setUp(self):
        import nuke
        self.gizmo = nuke.nodes.Cryptomatte()

    def tearDown(self):
        import nuke
        nuke.delete(self.gizmo)

    def round_trip_through_gizmo(self, csv, msg):
        import cryptomatte_utilities as cu
        # start from csv
        self.gizmo.knob("matteList").setValue(csv.replace("\\", "\\\\"))

        for i in range(3):
            matte_set = cu.get_mattelist_as_set(self.gizmo)
            self.gizmo.knob("matteList").setValue("")
            cu.set_mattelist_from_set(self.gizmo, matte_set)
        result_csv = self.gizmo.knob("matteList").getValue()
        correct_set = set(cu._decode_csv(csv))
        result_set = set(cu._decode_csv(result_csv))
        self.assertEqual(correct_set, result_set, "%s: Came out as: %s" % (msg, result_csv))

    def test_csv_through_gizmo(self):
        self.round_trip_through_gizmo('"name containing a \\"quote\\"  "', "Round trip failed")

    def test_big_csv_through_gizmo(self):
        self.round_trip_through_gizmo(CSVParsing.csv_str, "Round trip failed")


class CryptomatteNodePasting(unittest.TestCase):
    """
    This one takes some explaining. 

    In Nuke 8, 9, 10, it's been discovered that when copy and pasting certain nodes,
    or when opening certain scripts the inputchanged knob change callback breaks the script. 

    What actually happens is the call to metadata() breaks it. 

    The only reliable way to notice that it's unsafe we've found is calling node.screenHeight(), 
    and if zero, stopping. 

    see: https://github.com/Psyop/Cryptomatte/issues/18

    This test tests for it. 
    """

    expected_error_prefix =  "On paste, node lost connection to"

    def test_paste_with_channelmerge(self):
        """Tests this bug has been fixed."""
        import nuke
        pastable = """
            set cut_paste_input [stack 0]
            version 10.0 v4
            push $cut_paste_input
            TimeOffset {
             name {prefix}_TimeOffset7
             label "\[value time_offset]"
             selected true
             xpos 13246
             ypos 6816
            }
            set Ndd6dafc0 [stack 0]
            push $Ndd6dafc0
            add_layer {crypto_object crypto_object.red crypto_object.green crypto_object.blue crypto_object.alpha}
            add_layer {crypto_object00 crypto_object00.red crypto_object00.green crypto_object00.blue crypto_object00.alpha}
            add_layer {crypto_object01 crypto_object01.red crypto_object01.green crypto_object01.blue crypto_object01.alpha}
            add_layer {crypto_object02 crypto_object02.red crypto_object02.green crypto_object02.blue crypto_object02.alpha}
            Cryptomatte {
             name {prefix}_Cryptomatte6
             selected true
             xpos 13150
             ypos 6876
             matteList ""
             cryptoLayer crypto_object
             expression a
             keyedName "ID value not in manifest."
             previewChannel crypto_object
             in00 crypto_object00
             in01 crypto_object01
             in02 crypto_object02
             in03 none
             in04 none
             in05 none
             in06 none
             in07 none
            }
            ChannelMerge {
             inputs 2
             operation in
             name {prefix}_ChannelMerge7
             selected true
             xpos 13246
             ypos 6941
            }
        """

        prefix = "test_paste_with_channelmerge_"
        pasted_node_names = [(prefix + x)
                             for x in ("_TimeOffset7", "_Cryptomatte6", "_ChannelMerge7")]
        self.assertFalse(
            any([nuke.toNode(x) for x in pasted_node_names]), 
            "Nodes already exist at the start of testing, were not pasted. ")
        try:
            # deselect all
            for node in nuke.selectedNodes():
                node['selected'].setValue(False)

            nuke.tcl(pastable.replace("{prefix}", prefix))
            pasted_nodes = [nuke.toNode(x) for x in pasted_node_names]
            ch_merge_node = pasted_nodes[-1]
            self.assertTrue(ch_merge_node.input(0), "%s input 0." % self.expected_error_prefix)
            self.assertTrue(ch_merge_node.input(1), "%s input 1." % self.expected_error_prefix)
        except:
            raise
        finally:
            for node in pasted_nodes:
                if node:
                    nuke.delete(node)

    def test_bug_still_exists(self):
        """Tests this bug still exists. We don't want to be running the fix if we don't have to. """
        import nuke

        if nuke.NUKE_VERSION_MAJOR == 7:  # but is known not to exist in version 7.
            return

        def test_callback(node=None, knob=None):
            node.metadata()

        callback = lambda: test_callback(nuke.thisNode())
        nuke.addKnobChanged(callback, nodeClass='Cryptomatte')

        try:
            self.test_paste_with_channelmerge()
            exception = None
        except Exception, e:
            exception = e
        finally:
            nuke.removeKnobChanged(callback, nodeClass='Cryptomatte')

        if exception:
            self.assertTrue(
                type(exception) is AssertionError,
                "Bug check failure was not assertion error. %s " % exception)
            self.assertTrue(
                self.expected_error_prefix in str(exception),
                "Assertion did not contain expected prefix (wrong assertion failed, probably). %s" %
                exception)
        else:
            self.fail("Pasting bug does not reproduce in Nuke Version %s" % nuke.NUKE_VERSION_MAJOR)


class CryptomatteNukeTests(unittest.TestCase):
    """ 
    Many tests are combined into one big class because setupClass takes significant time,
    and many tests in the same class is the lesser evil. 
    """

    @classmethod
    def setUpClass(cls):
        import nuke
        import os
        default_path = os.path.normpath(os.path.join(__file__, "../", "../", "sample_images"))
        sample_images = os.environ.get(SAMPLES_IMAGES_DIR_ENVIRON, "") or default_path
        obj_path = os.path.join(sample_images, "bunny_CryptoObject.exr").replace("\\", "/")
        asset_path = os.path.join(sample_images, "bunny_CryptoAsset.exr").replace("\\", "/")
        material_path = os.path.join(sample_images, "bunny_CryptoMaterial.exr").replace("\\", "/")
        sidecar_path = os.path.join(sample_images, "sidecar_manifest",
                                    "bunny_CryptoObject.exr").replace("\\", "/")
        for file_path in [obj_path, asset_path, material_path, sidecar_path]:
            if not os.path.isfile(file_path):
                raise IOError(
                    ("Could not find: %s. Sample image dir can be defined env variable, %s") %
                    (file_path, SAMPLES_IMAGES_DIR_ENVIRON))

        cls.read_obj = nuke.nodes.Read(file=obj_path)
        cls.read_asset = nuke.nodes.Read(file=asset_path)
        cls.read_material = nuke.nodes.Read(file=material_path)
        cls.read_sidecar = nuke.nodes.Read(file=sidecar_path)

        cls.constant = nuke.nodes.Constant(color=0.5)
        cls.set_canceled(False)

    @classmethod
    def tearDownClass(cls):
        import nuke
        if cls.canceled():
            return
        nuke.delete(cls.read_obj)
        nuke.delete(cls.read_asset)
        nuke.delete(cls.read_material)
        nuke.delete(cls.read_sidecar)
        nuke.delete(cls.constant)

    @classmethod
    def set_canceled(cls, canceled):
        global g_cancel_nuke_testing
        g_cancel_nuke_testing = canceled

    @classmethod
    def canceled(cls):
        global g_cancel_nuke_testing
        return g_cancel_nuke_testing

    def skip_cleanup(self):
        global CRYPTOMATTETEST_SKIP_CLEANUP_ON_FAILURE
        test_ok = (sys.exc_info() == (None, None, None) or sys.exc_info()[0] is unittest.SkipTest)
        if CRYPTOMATTETEST_SKIP_CLEANUP_ON_FAILURE and not test_ok:
            self.set_canceled(True)  # ensure that teardownClass does not run
            return True
        else:
            return False

    def setUp(self):
        import nuke
        import cryptomatte_utilities as cu
        if self.canceled():
            self.skipTest("Remaining tests canceled.")
            return
            # raise RuntimeError("Remaining tests canceled.")
        if not hasattr(self, "read_asset"):
            # this happens pre-python 2.7, in Nuke 7.
            # We can still create everything and run tests in Nuke 7,
            # They'll just scatter some nodes about.
            self.setUpClass()
        cu.reset_manifest_cache()
        self._remove_later = []
        self.gizmo = self.tempNode("Cryptomatte", inputs=[self.read_asset])
        self.merge = self.tempNode(
            "Merge", inputs=[self.read_obj, self.read_asset], also_merge="all")
        self.copyMetadata = self.tempNode("CopyMetaData", inputs=[self.merge, self.read_asset])
        self.read_obj_dot = self.tempNode("Dot", inputs=[self.read_obj])

    def tearDown(self):
        import nuke
        import cryptomatte_utilities as cu
        if not self.canceled():
            if self.constant.sample("red", 0, 0) != 0.5:
                self.set_canceled(True)
                self.fail(("After running '%s', can no longer sample. "
                           "No remaining tests can run.") % (self.id()))
        if not self.canceled() and not self.skip_cleanup():
            for node in self._remove_later:
                nuke.delete(node)

        cu.reset_manifest_cache()


    def tempNode(self, nodeType, **kwargs):
        """
        Creates a temporary nuke node that will be removed in teardown, 
        after the test_ method. 
        """
        import nuke
        func = getattr(nuke.nodes, nodeType)
        node = func(**kwargs)
        self._remove_later.append(node)
        return node

    def delete_nodes_after_test(self, nodes):
        self._remove_later += nodes

    #############################################
    # Keying constants (for cryptoAsset example)
    #############################################

    heroflower_expr = (
        "((uCryptoAsset00.red == 2.07262543558e+26) ? uCryptoAsset00.green : 0.0) + "
        "((uCryptoAsset00.blue == 2.07262543558e+26) ? uCryptoAsset00.alpha : 0.0) + "
        "((uCryptoAsset01.red == 2.07262543558e+26) ? uCryptoAsset01.green : 0.0) + "
        "((uCryptoAsset01.blue == 2.07262543558e+26) ? uCryptoAsset01.alpha : 0.0) + "
        "((uCryptoAsset02.red == 2.07262543558e+26) ? uCryptoAsset02.green : 0.0) + "
        "((uCryptoAsset02.blue == 2.07262543558e+26) ? uCryptoAsset02.alpha : 0.0) + 0")

    black_pkr = ("add", (700.0, 700.0))
    floweredge_pkr = ("add", (884.0, 662.0))
    bunny_pkr = ("add", (769.0, 429.0))
    set_pkr = ("add", (490.0, 250.0))  # on this pixel, extracted alpha == 1.0
    bunnyflower_pkr = ("add", (842.0, 441.0))
    rm_black_pkr = ("remove", black_pkr[1])
    rm_floweredge_pkr = ("remove", floweredge_pkr[1])
    rm_bunny_pkr = ("remove", bunny_pkr[1])
    rm_set_pkr = ("remove", set_pkr[1])
    rm_bunnyflower_pkr = ("remove", bunnyflower_pkr[1])

    #############################################
    # Utils - Keying and sampling
    #############################################

    def key_on_image(self, *args):
        self.key_on_gizmo(self.gizmo, *args)

    def key_on_gizmo(self, gizmo, *args):

        def pickerCoords(coord):
            return [0.0, 0.0, 0.0, 0.0, coord[0], coord[1], 0.0, 0.0]

        for action, coordinates in args:
            if action == "add":
                gizmo.knob("pickerAdd").setValue(pickerCoords(coordinates))
            elif action == "remove":
                gizmo.knob("pickerRemove").setValue(pickerCoords(coordinates))

    def _sample_gizmo_assert(self, pkr, msg, inverse, **kwargs):
        for channel, value in kwargs.iteritems():
            sample = self.gizmo.sample(channel, pkr[1][0], pkr[1][1])
            msg_resolved = "%s: (%s) %s vs %s" % (msg, channel, sample, value)
            if inverse:
                self.assertNotEqual(sample, value, msg_resolved)
            else:
                self.assertEqual(sample, value, msg_resolved)

    def assertSampleEqual(self, pkr, msg, **kwargs):
        self._sample_gizmo_assert(pkr, msg, False, **kwargs)

    def assertSampleNotEqual(self, pkr, msg, **kwargs):
        self._sample_gizmo_assert(pkr, msg, True, **kwargs)

    def assertMatteList(self, value, msg):
        ml = self.gizmo.knob("matteList").getValue()
        self.assertEqual(ml, value, '%s. ("%s" vs "%s")' % (msg, value, ml))

    def hash_channel(self, node, pkr, channel, num_scanlines=8, precision=None):
        """
        Returns a hash from the values a channel, tested in a number of 
        horizontal scanlines across the images. A picker tuple can be 
        provided to ensure the values on that scanline are checked. 
        If channel=depth is provided, and it finds only "depth.Z", 
        it will assume that was intended. "
        """
        import hashlib
        if channel not in ["red", "green", "blue", "alpha"] and channel not in node.channels():
            raise RuntimeError("Incorrect channel specified. %s" % channel)

        anything = False
        m = hashlib.md5()
        width, height = node.width(), node.height()
        if pkr:
            y = pkr[1][1]
            for x in xrange(width):
                sample = node.sample(channel, x, y)
                if precision:
                    sample = round(sample, precision)
                anything = anything or bool(sample)
                m.update(str(sample))
        for y_index in xrange(num_scanlines):
            y = (float(y_index) + 0.5) * height / (num_scanlines)
            for x in xrange(width):
                sample = node.sample(channel, x, y)
                anything = anything or bool(sample)
                if precision:
                    sample = round(sample, precision)
                m.update(str(sample))
        return m.hexdigest() if anything else 0

    def hash_channel_layer(self, node, layer, channel, num_scanlines=16):
        """Hashes some scanlines of a layer other than rgba, by shuffling first. """
        shuf = self.tempNode("Shuffle", inputs=[node])
        shuf.knob("in").setValue(layer)
        return self.hash_channel(shuf, None, channel, num_scanlines=num_scanlines)

    #############################################
    # Manifest tests
    #############################################

    def _create_bogus_asset_manifest(self):
        bad_md = '{set exr/cryptomatte/d593dd7/manifest "\{broken\}"}'
        return self.tempNode(
            "ModifyMetaData", inputs=[self.read_asset], 
            metadata=bad_md, name="ModifyMetaData_rename")

    def _create_missing_asset_manifest(self):
        bad_md = '{remove exr/cryptomatte/d593dd7/manifest ""}'
        return self.tempNode(
            "ModifyMetaData", inputs=[self.read_asset], 
            metadata=bad_md, name="ModifyMetaData_remove")

    def test_manifests(self):
        # Embedded and sidecar
        import cryptomatte_utilities as cu
        for read in [self.read_obj, self.read_asset, self.read_material, self.read_sidecar]:
            cinfo = cu.CryptomatteInfo(read)
            mismatches, collisions = cinfo.test_manifest(quiet=True)
            self.assertTrue(cinfo.parse_manifest(),
                            "%s manifest not loaded. " % read.knob("file").getValue())
            self.assertEqual(mismatches, [], "%s manifest mismatch" % read.knob("file").getValue())

    def test_load_manifests_lazy(self):
        import cryptomatte_utilities as cu
        gizmo = self.tempNode("Cryptomatte", inputs=[self.read_asset])
        cinfo = cu.CryptomatteInfo(gizmo)
        self.assertNotIn("manifest", cinfo.cryptomattes[cinfo.selection])

    def test_load_manifests_nonlazy(self):
        import cryptomatte_utilities as cu
        gizmo = self.tempNode("Cryptomatte", inputs=[self.read_asset])
        cinfo = cu.CryptomatteInfo(gizmo, True)
        self.assertIn("manifest", cinfo.cryptomattes[cinfo.selection])

    def test_layer_bogus_manifest(self):
        import cryptomatte_utilities as cu

        def test_manifest_and_keying(input_node):
            cu.reset_manifest_cache()
            cinfo = cu.CryptomatteInfo(input_node)
            self.gizmo.knob("matteList").setValue("")
            self.gizmo.setInput(0, input_node)

            self.assertFalse(cinfo.parse_manifest(), "Bogus manifest still loaded. ")

            cu.update_cryptomatte_gizmo(self.gizmo, True)  # tests that this doesn't raise.
            self.assertEqual(
                self.gizmo.knob("cryptoLayer").value(), "uCryptoAsset", "Layer selection not set.")

            self.key_on_image(self.bunny_pkr)
            self.assertMatteList("<3.36000126251e-27>", "Could not key with bogus manifest.")

        bogus_asset = self._create_bogus_asset_manifest()
        test_manifest_and_keying(bogus_asset)

        missing_manifest = self._create_missing_asset_manifest()
        test_manifest_and_keying(missing_manifest)


    #############################################
    # Layer Selection
    #############################################

    def test_layer_selection(self, node=None):
        gizmo = node if node else self.gizmo
        # layer selection set up properly in the first place
        self.assertEqual(
            gizmo.knob("cryptoLayer").value(), "uCryptoAsset", "Layer selection not set.")

        # switching inputs switches layer selections
        gizmo.setInput(0, self.read_obj)
        self.assertEqual(
            gizmo.knob("cryptoLayer").value(), "uCryptoObject",
            "Input change did not switch layers")
        gizmo.setInput(0, self.read_asset)
        self.assertEqual(
            gizmo.knob("cryptoLayer").value(), "uCryptoAsset", "Input change did not switch layers")

        # switching inputs to a multi-cryptomatte stream does not switch layer selections
        gizmo.setInput(0, self.copyMetadata)
        self.assertEqual(
            gizmo.knob("cryptoLayer").value(), "uCryptoAsset",
            "Input change to multi should not have switched layers")

        gizmo.setInput(0, self.read_obj)
        gizmo.setInput(0, self.copyMetadata)
        self.assertEqual(
            gizmo.knob("cryptoLayer").value(), "uCryptoObject",
            "Input change to multi should not have switch layers")

    def test_layer_selection_after_keying(self):
        """ Tests all layer selection options are still available after keying.  
        """
        layers = ["uCryptoAsset", "uCryptoObject"]
        choice_knob = self.gizmo.knob("cryptoLayerChoice")
        self.gizmo.setInput(0, self.copyMetadata) # set to multi

        for layer in layers:
            choice_knob.setValue(layer)
            self.assertEqual(self.gizmo.knob("cryptoLayer").value(), layer)
            self.assertEqual(set(choice_knob.values()), set(layers))
            self.key_on_gizmo(self.gizmo, self.triangle_pkr, self.set_pkr)
            self.assertEqual(set(choice_knob.values()), set(layers))

        new_gizmo = self.tempNode(
            "Cryptomatte", cryptoLayer="uCryptoAsset", 
            inputs=[self.copyMetadata], stopAutoUpdate=True)
        self.assertEqual(set(new_gizmo.knob("cryptoLayerChoice").values()), set(layers))

    def test_layer_lock(self, node=None):
        gizmo = node if node else self.gizmo
        # locking layer selection stops the switching
        gizmo.setInput(0, self.read_asset)
        gizmo.knob("cryptoLayerLock").setValue(True)
        gizmo.setInput(0, self.read_obj_dot)
        self.assertEqual(
            gizmo.knob("cryptoLayer").value(), "uCryptoAsset",
            "cryptoLayerLock did not keep things from changing.")
        gizmo.knob("cryptoLayerLock").setValue(False)
        self.assertEqual(
            gizmo.knob("cryptoLayer").value(), "uCryptoObject",
            "Disabling cryptoLayerLock did not set gizmo back to uCryptoObject.")

    def _setup_test_layer_forced_update_func(self, gizmo):
        gizmo.setInput(0, self.read_obj_dot)
        self.read_obj_dot.setInput(0, self.read_asset)
        if (gizmo.knob("cryptoLayer").value() != "uCryptoObject"):
            raise RuntimeError("Upstream changes now trigger updates, test is invalid %s " %
                               gizmo.knob("cryptoLayer").value())

    def test_layer_forced_update_btn(self, node=None):
        gizmo = node if node else self.gizmo
        self._setup_test_layer_forced_update_func(gizmo)

        gizmo.knob("forceUpdate").execute()
        self.assertEqual(
            gizmo.knob("cryptoLayer").value(), "uCryptoAsset",
            "Update button did not update gizmo. %s" % (gizmo.knob("cryptoLayer").value()))

    def test_layer_forced_update_func(self, node=None):
        import cryptomatte_utilities as cu
        gizmo = node if node else self.gizmo
        self._setup_test_layer_forced_update_func(gizmo)

        cu.update_cryptomatte_gizmo(gizmo, True)
        self.assertEqual(
            gizmo.knob("cryptoLayer").value(), "uCryptoAsset",
            "Update function should have updated from upstream changes. %s" %
            (gizmo.knob("cryptoLayer").value()))

    #############################################
    # Keying
    #############################################

    def test_keying_nothing(self):
        self.key_on_image(self.black_pkr)
        self.assertMatteList("", "Something selected on black. ")

    def _test_keying_partial_black(self, msg=""):
        # used as setup for other tests
        self.key_on_image(self.floweredge_pkr)
        self.assertMatteList("heroflower", msg or "Hero flower not selected on partial pixels. ")
        self.assertEqual(
            self.gizmo.knob("expression").getValue(), self.heroflower_expr, msg or
            "Hero flower expression was wrong. ")

    def test_keying_partial_black(self):
        self._test_keying_partial_black()

    def test_keying_manual(self):
        self.gizmo.knob("matteList").setValue("heroflower")
        self.assertEqual(
            self.gizmo.knob("expression").getValue(), self.heroflower_expr,
            "Expression did not update on setting matte list. ")

    def test_keying_with_removechannels(self):
        self.gizmo.knob("RemoveChannels").setValue(True)
        self.key_on_image(self.bunny_pkr)
        self.assertMatteList("bunny", "Could not key on image after Remove Channels was enabled.")

    def test_keying_blank_matteList(self):
        self._test_keying_partial_black()
        self.gizmo.knob("matteList").setValue("")
        self.assertEqual(
            self.gizmo.knob("expression").getValue(), "",
            "Expression did not update on blanking matte list. ")

    def test_keying_clear(self):
        import cryptomatte_utilities as cu
        self._test_keying_partial_black()
        cu.clear_cryptomatte_gizmo(self.gizmo)
        self.assertMatteList("", "Clear() failed. ")
        self.assertEqual(self.gizmo.knob("expression").getValue(), "", "Clear() failed. ")

    def test_clear_button(self):
        import cryptomatte_utilities as cu
        self._test_keying_partial_black()
        self.gizmo.knob("clear").execute()
        self.assertMatteList("", "Clear button failed. ")
        self.assertEqual(self.gizmo.knob("expression").getValue(), "", "Clear button failed. ")

    def test_keying_multiselect(self):
        import cryptomatte_utilities as cu

        # Multiselect over partial black
        self.key_on_image(self.floweredge_pkr, self.floweredge_pkr, self.floweredge_pkr)
        self.assertMatteList("heroflower",
                             "Multiselect on edge didn't select only 'heroflower': (%s)" %
                             self.gizmo.knob("matteList").getValue())

        # Multiselect over partial pixels
        cu.clear_cryptomatte_gizmo(self.gizmo)
        self.key_on_image(self.bunnyflower_pkr, self.black_pkr, self.bunnyflower_pkr)
        self.assertMatteList("bunny, heroflower", "Same pixel multiple selection was wrong: (%s)" %
                             self.gizmo.knob("matteList").getValue())

        # Add set to selection
        self.key_on_image(self.set_pkr)
        self.assertMatteList(
            "bunny, heroflower, set",
            "Multi selection was wrong: (%s)" % self.gizmo.knob("matteList").getValue())

        # Remove bunny and flower
        self.key_on_image(self.rm_bunny_pkr, self.rm_black_pkr, self.rm_floweredge_pkr)
        self.assertMatteList(
            "set", "Bunny and flower not removed: (%s)" % self.gizmo.knob("matteList").getValue())

    def test_keying_single_selection(self):
        # Single selection
        self.gizmo.knob("matteList").setValue("bunny, heroflower, set")
        self.gizmo.knob("singleSelection").setValue(True)
        self.assertMatteList("bunny, heroflower, set",
                             "Single selection knob changed without selection changed matte list.")
        self.key_on_image(self.set_pkr)
        self.assertMatteList(
            "set",
            "Single selection seems to have failed: (%s)" % self.gizmo.knob("matteList").getValue())

        for _ in range(5):
            self.key_on_image(self.black_pkr, self.bunnyflower_pkr)
            self.assertMatteList("bunny", "Single selection may be flickering on partials")

    def test_keying_stop_auto_update(self):
        self.gizmo.knob("stopAutoUpdate").setValue(True)
        self.gizmo.knob("expression").setValue(self.heroflower_expr)
        self.key_on_image(self.bunny_pkr)
        self.key_on_image(self.rm_set_pkr)
        self.key_on_image(self.rm_black_pkr)
        self.gizmo.knob("matteList").setValue("hello")
        self.assertEqual(
            self.gizmo.knob("expression").getValue(), self.heroflower_expr,
            "Stop auto update did not work. ")

    def test_keying_without_preview_channels(self):
        """ 
        Test that the gizmo can be set up and used properly without 
        the preview channels being available. 
        """
        self.gizmo.setInput(0, self.read_material)  # switch layer selection
        remove = self.tempNode("Remove", inputs=[self.read_asset], channels="uCryptoAsset")
        self.gizmo.setInput(0, remove)
        self._test_keying_partial_black()
        self.gizmo.knob("forceUpdate").execute()

    def test_keying_without_prefix(self):
        """ 
        If the image is loaded without the exr/ prefix, things should keep working. 
        """
        exception = None
        try:
            self.read_asset.knob("noprefix").setValue(True)
            self.gizmo.knob("forceUpdate").execute()
            self._test_keying_partial_black("Keying failed once read-node prefix was disabled.")
        except Exception, e:
            exception = e

        self.read_asset.knob("noprefix").setValue(False)
        if exception:
            raise exception

    #############################################
    # Output checking
    #############################################

    def test_output_preview(self):
        self.key_on_image(self.bunny_pkr)
        msg = "Selection did not light up properly. %s, %s"
        self.assertSampleEqual(
            self.bunny_pkr, "Preview image did not light up", red=1.0, green=1.0, alpha=1.0)
        self.assertSampleNotEqual(self.set_pkr, "Set pixels should be dark.", red=1.0, green=1.0)
        self.assertSampleEqual(self.set_pkr, "Set pixels should be unselected.", alpha=0.0)

    def test_output_preview_disabled(self):
        # stops lighting up after disabled, but alpha still correct
        self.key_on_image(self.bunny_pkr)
        self.gizmo.knob("previewEnabled").setValue(False)
        self.assertSampleEqual(
            self.bunny_pkr,
            "Preview image bunny pixels wrong when disabled",
            red=0.0,
            green=0.0,
            alpha=1.0)
        self.assertSampleEqual(
            self.set_pkr,
            "Preview image set pixels wrong when disabled",
            red=0.0,
            green=0.0,
            alpha=0.0)
        self.gizmo.knob("previewEnabled").setValue(True)

    def test_output_preview_multi(self):
        # add an item, make sure it lights up too
        self.key_on_image(self.bunny_pkr, self.set_pkr)
        self.assertSampleEqual(
            self.bunny_pkr, "Bunny pixels are wrong.", red=1.0, green=1.0, alpha=1.0)
        self.assertSampleEqual(self.set_pkr, "Set pixels are wrong.", red=1.0, green=1.0, alpha=1.0)

    #############################################
    # Matte list manipulations
    #############################################

    def _clear_manifest_cache(self):
        import cryptomatte_utilities as cu
        cu.g_cryptomatte_manf_from_names = {}
        cu.g_cryptomatte_manf_from_IDs = {}

    def test_matte_list_numeric(self):
        """
        Tests what happens with matte lists if you have a name matte list, but pick without a 
        manifest, and vice version. It should be smart enough to not create anything redundant. 
        """
        import nuke
        numeric_mlist_bunny = "<3.36000126251e-27>"
        numeric_mlist_set = "<7.36562399642e+18>"
        numeric_mlist_both = "<3.36000126251e-27>, <7.36562399642e+18>"
        name_mlist_bunny = "bunny"

        mod_md_node = self.tempNode(
            "ModifyMetaData",
            inputs=[self.read_asset],
            metadata='{set exr/cryptomatte/d593dd7/manifest "\{\}"}')

        self.gizmo.setInput(0, mod_md_node)
        self._clear_manifest_cache()

        self.key_on_image(self.bunny_pkr)
        self.assertMatteList(numeric_mlist_bunny, "Numeric mattelist incorrect.")

        # test adding redundant item numerically to a named selection
        self.gizmo.knob("matteList").setValue(name_mlist_bunny)
        self.key_on_image(self.bunny_pkr)
        self.assertMatteList("bunny", "Redundant matte list generated.")

        # test that removing named item by number works
        self.key_on_image(self.rm_bunny_pkr)
        self.assertMatteList("", "Removal of name by number failed.")

        # test that adding and removing numeric items works from a named list
        self.gizmo.knob("matteList").setValue("bunny, heroflower")
        self.key_on_image(self.set_pkr, self.rm_bunny_pkr)
        self.assertMatteList("<7.36562399642e+18>, heroflower", "Removal of name by number failed.")
        self.key_on_image(self.bunny_pkr)
        self.assertMatteList("<3.36000126251e-27>, <7.36562399642e+18>, heroflower",
                             "Adding number to a name list failed.")

    def test_matte_list_name_modifications(self):
        self.gizmo.knob("matteList").setValue(
            "<3.36000126251e-27>, <7.36562399642e+18>, heroflower")
        self.key_on_image(self.rm_bunny_pkr, self.rm_set_pkr, self.rm_floweredge_pkr,
                          self.rm_floweredge_pkr)
        self.assertMatteList("", "Could not remove numbers by name.")
        self.key_on_image(self.bunny_pkr, self.set_pkr)
        self.assertMatteList("bunny, set", "Could not re-add by picking.")

    #############################################
    # Decryptomatte
    #############################################

    def test_decrypto_basic(self):
        """ Tests both basic decryptomatte, as well as ensuring hash_channel() is
        returning different hashes for different values. 
        """
        import cryptomatte_utilities as cu

        self.key_on_image(self.bunny_pkr)
        wrong_hash = self.hash_channel(self.gizmo, self.bunny_pkr, "green")
        correct_hash = self.hash_channel(self.gizmo, self.bunny_pkr, "alpha")

        new_nodes = cu._decryptomatte(self.gizmo)
        self.delete_nodes_after_test(new_nodes)
        decryptomatted_hash = self.hash_channel(new_nodes[-1], self.bunny_pkr, "alpha")
        self.assertNotEqual(decryptomatted_hash, wrong_hash,
                            "Wrong hash is same as right hash, scanlines may be broken.")
        self.assertEqual(decryptomatted_hash, correct_hash,
                         "Decryptomatte caused a different alpha from Cryptomatte.")

    def test_decrypto_in_group(self):
        """ Tests basic decryptomatte in a group. 
        """
        # Put a cryptomatte with some values on it in a group
        import nuke
        self.key_on_image(self.bunny_pkr)
        gizmo_name = self.gizmo.fullName()
        self.gizmo['selected'].setValue(True)
        group = nuke.collapseToGroup(show=False)
        for node in nuke.selectedNodes():
            node['selected'].setValue(False)

        # Clean up of the group will remove the gizmo as well
        self.delete_nodes_after_test([group])
        self._remove_later = [x for x in self._remove_later if x != self.gizmo]
        self.gizmo = None
        
        # press the button
        grouped_gizmo = nuke.toNode("%s.%s" % (group.fullName(), gizmo_name))
        self.assertFalse(grouped_gizmo.knob("disable").value())
        grouped_gizmo.knob("decryptomatte").execute()
        self.assertTrue(grouped_gizmo.knob("disable").value())

    def test_decrypto_custom_channel(self):
        import cryptomatte_utilities as cu
        custom_layer = "uCryptoAsset"  # guaranteed to already exist.
        custom_layer_subchannel = "%s.red" % custom_layer

        self.key_on_image(self.set_pkr)
        self.gizmo.knob("matteOutput").setValue(custom_layer)
        alpha_hash = self.hash_channel(self.gizmo, self.set_pkr, "alpha")
        correct_hash = self.hash_channel(self.gizmo, self.set_pkr, custom_layer_subchannel)

        new_nodes = cu._decryptomatte(self.gizmo)
        self.delete_nodes_after_test(new_nodes)
        decryptomatte_hash = self.hash_channel(new_nodes[-1], self.set_pkr, custom_layer_subchannel)

        self.assertNotEqual(correct_hash, alpha_hash, "Custom channel is the same as the alpha.")
        self.assertEqual(correct_hash, decryptomatte_hash,
                         "Decryptomatte in a custom channel mismatch.")

    def test_decrypto_matteonly_unpremul(self):
        import cryptomatte_utilities as cu
        custom_layer = "uCryptoAsset"  # guaranteed to already exist
        prec = 5

        # self.gizmo will have alpha for unpremult.
        self.key_on_image(self.set_pkr, self.bunny_pkr)
        new_gizmo = self.tempNode(
            "Cryptomatte",
            inputs=[self.gizmo],
            matteList="bunny",
            matteOnly=True,
            matteOutput=custom_layer)
        no_unpremult_hash = self.hash_channel(new_gizmo, self.set_pkr, "alpha", precision=prec)
        new_gizmo.knob("unpremultiply").setValue(True)

        channels = ["red", "green", "alpha", "%s.red" % custom_layer]
        gizmo_hashes = [self.hash_channel(new_gizmo, self.set_pkr, ch, precision=prec)
                        for ch in channels]
        new_nodes = cu._decryptomatte(new_gizmo)
        self.delete_nodes_after_test(new_nodes)
        decrypto_hashes = [self.hash_channel(new_nodes[-1], self.set_pkr, ch, precision=prec)
                           for ch in channels]

        self.assertNotEqual(no_unpremult_hash, gizmo_hashes[0],
                            "Unpremult didn't seem to change anything.")
        for ch, g_hash, dc_hash in zip(channels, gizmo_hashes, decrypto_hashes):
            self.assertEqual(g_hash, dc_hash,
                             "Difference between decryptomatte (%s) and regular (%s) in %s"
                              % (new_nodes[-1].name(), new_gizmo.name(), ch))
        for i, channel in enumerate(channels[:-1]):
            msg = "Matte-only difference between %s and %s" % (channels[i], channels[i + 1])
            self.assertEqual(decrypto_hashes[i], decrypto_hashes[i + 1], msg)

    def test_decrypto_rmchannels_customlayer(self):
        self._test_decrypto_rmchannels("uCryptoAsset")

    def test_decrypto_rmchannels_alpha(self):
        self._test_decrypto_rmchannels("alpha")

    def _test_decrypto_rmchannels(self, output_layer="alpha"):
        import cryptomatte_utilities as cu
        orig_channels = set(x for x in self.gizmo.channels())
        self.gizmo.knob("RemoveChannels").setValue(True)
        self.gizmo.knob("matteOutput").setValue(output_layer)
        channels_removed = set(x for x in self.gizmo.channels())

        new_nodes = cu._decryptomatte(self.gizmo)
        self.delete_nodes_after_test(new_nodes)

        channels_removed_decrypto = set(x for x in new_nodes[-1].channels())
        channels_removed_decrypto |= set(['rgba.red', 'rgba.green', 'rgba.blue', 'rgba.alpha'])

        self.assertNotEqual(orig_channels, channels_removed, 
                            "Removing channels did nothing (%s)" % output_layer)
        self.assertEqual(channels_removed, channels_removed_decrypto,
                         "Channels not removed properly after decrypto. (%s)" % output_layer)

    #############################################
    # Gizmo integrity
    #############################################

    def test_gizmo_version(self, node=None):
        import cryptomatte_utilities as cu
        def test_version(gizmo):
            gizmo_version = gizmo.knob("cryptomatteVersion").value()
            self.assertEqual(
                gizmo_version, cu.__version__,
                "%s version not same as Python version. "
                        "(%s, %s)" % (gizmo.Class(), cu.__version__, gizmo_version))

        test_version(self.gizmo)
        test_version(self.tempNode("Encryptomatte"))

    def test_crypto_channel_knobs_type(self, node=None):
        import cryptomatte_utilities as cu
        for channel in cu.GIZMO_CHANNEL_KNOBS:
            self.assertTrue(
                self.gizmo.knob(channel).Class() in set(["Channel_Knob", "ChannelMask_Knob"]),
                "Input knob was not a channel knob, which causes failed renders "
                "due to expression errors on load. (%s)" % self.gizmo.knob(channel).Class())

    def test_encrypt_channel_knobs_type(self, node=None):
        import cryptomatte_utilities as cu
        encrypt = self.tempNode("Encryptomatte")
        for channel in cu.GIZMO_REMOVE_CHANNEL_KNOBS + cu.GIZMO_ADD_CHANNEL_KNOBS:
            self.assertTrue(
                encrypt.knob(channel).Class() in set(["Channel_Knob", "ChannelMask_Knob"]),
                "Input knob was not a channel knob, which causes failed renders "
                "due to expression errors on load. (%s)" % encrypt.knob(channel).Class())

    #############################################
    # Encryptomatte
    #############################################
    # todo(jfriedman): test stop_auto_update, auto filling in "matte name"

    triangle_coords = [
        [916.0, 512.0],
        [839.0, 416.0],
        [828.0, 676.0],
    ]

    triangle_pkr = ("add", (840.0, 615.0))

    def _setup_rotomask(self):
        """Which partially covers the flower."""
        import nuke.rotopaint as rp
        self.gizmo.setInput(0, self.read_asset)
        rotomask = self.tempNode("Roto", inputs=[self.read_asset])
        shape = rp.Shape(rotomask['curves'])
        shape.getAttributes().set("fx", 20)
        shape.getAttributes().set("fy", 20)
        for pos in self.triangle_coords:
            shape.append(pos)
        rotomask['curves'].rootLayer.append(shape)
        return rotomask

    def test_encrypt_layerselection(self):
        encryptomatte = self.tempNode("Encryptomatte", inputs=[self.gizmo])
        self.test_layer_selection(encryptomatte)

    def test_encrypt_layer_lock(self):
        encryptomatte = self.tempNode("Encryptomatte", inputs=[self.gizmo])
        self.test_layer_lock(encryptomatte)

    def test_encrypt_layer_forced_update_btn(self):
        encryptomatte = self.tempNode("Encryptomatte", inputs=[self.gizmo])
        self.test_layer_forced_update_btn(encryptomatte)

    def test_encrypt_matte_name_autofill2(self):
        encryptomatte = self.tempNode("Encryptomatte", inputs=[self.read_asset])
        self.assertEqual(
            encryptomatte.knob("matteName").getValue(), "",
            "Encryptomatte got a matte name not properly blank to start. ")
        encryptomatte.setInput(1, self.constant)
        self.assertEqual(
            encryptomatte.knob("matteName").getValue(),
            self.constant.name(), "Encryptomatte matte name was not set automatically.")
        encryptomatte.setInput(1, self.gizmo)
        self.assertEqual(
            encryptomatte.knob("matteName").getValue(),
            self.constant.name(), ("Encryptomatte matte should not have been "
                                   "set automatically if it was already connected. "))

        preconnected_encrypto = self.tempNode(
            "Encryptomatte", inputs=[self.read_asset, self.constant])
        self.assertEqual(
            encryptomatte.knob("matteName").getValue(),
            self.constant.name(), "Encryptomatte matte name was not set automatically on creation.")

    def test_encrypt_bogus_inputs(self):
        """ Tests that when setting up layers, entering the name before pressing "setup layers"
        doesn't spew python errors but fails gracefully. 
        """
        import cryptomatte_utilities as cu
        encryptomatte = self.tempNode("Encryptomatte", matteName="triangle")
        try:
            encryptomatte.knob("cryptoLayer").setValue("customCrypto")
            cu.encryptomatte_knob_changed_event(encryptomatte, encryptomatte.knob("cryptoLayer"))
            encryptomatte.knob("setupLayers").setValue(True)
            cu.encryptomatte_knob_changed_event(encryptomatte, encryptomatte.knob("setupLayers"))
        except Exception, e:
            self.fail("Invalid crypto layer name raises error: %s" % e)

    def test_encrypt_setup_layers_numbers(self):
        """ Tests that when setting up layers, entering the name before pressing "setup layers"
        doesn't spew python errors but fails gracefully. 
        """
        encryptomatte = self.tempNode("Encryptomatte", matteName="triangle")
        encryptomatte.knob("setupLayers").setValue(True)
        encryptomatte.knob("cryptoLayer").setValue("customCrypto")
        customLayers = [
            "customCrypto00", "customCrypto01", "customCrypto02", "customCrypto03",
            "customCrypto04", "customCrypto05", "customCrypto06", "customCrypto07",
            "customCrypto08", "customCrypto09"
        ]

        encryptomatte.knob("cryptoLayers").setValue(3)
        channels = set(encryptomatte.channels())
        for layer in customLayers[:3]:
            self.assertTrue("%s.red" % layer in channels, "%s not in channels" % layer)
        for layer in customLayers[3:]:
            self.assertFalse("%s.red" % layer in channels, "%s in channels" % layer)

        encryptomatte.knob("cryptoLayers").setValue(6)
        channels = encryptomatte.channels()
        for layer in customLayers[:6]:
            self.assertTrue("%s.red" % layer in channels, "%s not in channels" % layer)
        for layer in customLayers[6:]:
            self.assertFalse("%s.red" % layer in channels, "%s in channels" % layer)

    def test_encrypt_roundtrip_setup(self):
        import cryptomatte_utilities as cu

        keysurf_hash = self.hash_channel(self.gizmo, None, "blue", num_scanlines=16)
        roto_hash = self.hash_channel(self._setup_rotomask(), None, "alpha", num_scanlines=16)

        id0_hash = self.hash_channel_layer(self.read_asset, "uCryptoAsset00", "red")
        encryptomatte = self.tempNode(
            "Encryptomatte", inputs=[self.read_asset, self._setup_rotomask()], matteName="triangle")
        second_cryptomatte = self.tempNode(
            "Cryptomatte", matteList="triangle")
        second_cryptomatte.setInput(0, encryptomatte)
        self.assertEqual(encryptomatte.knob("cryptoLayer").getValue(), "uCryptoAsset", "Layer was not set")

        new_id0_hash = self.hash_channel_layer(encryptomatte, "uCryptoAsset00", "red")
        self.assertNotEqual(id0_hash, new_id0_hash, "ID channel did not change. ")

        decrypto_hash = self.hash_channel(second_cryptomatte, None, "alpha", num_scanlines=16)
        mod_keysurf_hash = self.hash_channel(second_cryptomatte, None, "alpha", num_scanlines=16)
        
        self.assertEqual(roto_hash, decrypto_hash, ("Alpha did not survive round trip through "
                                                    "Encryptomatte and then Cryptomatte. "))
        self.assertNotEqual(keysurf_hash, mod_keysurf_hash, "preview image did not change. ")
        cinfo = cu.CryptomatteInfo(second_cryptomatte)
        names_to_IDs = cinfo.parse_manifest()
        self.assertTrue("set" in names_to_IDs, "Manifest doesn't contain original manifest")
        self.assertTrue("triangle" in names_to_IDs, "Manifest doesn't contain new members")

    def test_encrypt_roundtrip_keyable(self):        
        encryptomatte = self.tempNode(
            "Encryptomatte", inputs=[self.read_asset, self._setup_rotomask()], matteName="triangle")

        self.gizmo.setInput(0, encryptomatte)
        self.key_on_image(self.set_pkr, self.triangle_pkr)

        mlist = self.gizmo.knob("matteList").getValue()
        self.assertEqual(mlist, "set, triangle",
                         "Encrypto-modified manifest not properly keyable. {0}".format(mlist))

        self.assertTrue("set" in mlist, "Couldn't properly key 'set' (Pre-existing)")
        self.assertTrue("triangle" in mlist, "Couldn't properly key 'triangle' (New Member)")

    def test_encrypt_roundtrip_without_prefix(self):
        self.read_asset.knob("noprefix").setValue(True)
        exception = None
        try:
            self.test_encrypt_roundtrip_setup()
        except:
            raise
        finally:
            self.read_asset.knob("noprefix").setValue(False)

    def test_encrypt_bogus_manifest(self):
        import cryptomatte_utilities as cu
        mod_md_node = self._create_bogus_asset_manifest()
        encryptomatte = self.tempNode(
            "Encryptomatte", inputs=[mod_md_node, self._setup_rotomask()], matteName="triangle")
        self.gizmo.setInput(0, encryptomatte)
        self.key_on_image(self.triangle_pkr)
        self.gizmo.knob("forceUpdate").execute()
        self.assertMatteList("<1.54567320652e-21>", "Did not update after keying. ")

        def broken_test_parts():
            """ this should work, but doesn't for some reason. It produces the right result 
            as seen in failfast, but breaks tests. The encryptomatte tests are generally too 
            brittle and this is one example. """
            self.assertSampleEqual(
                self.triangle_pkr, "Encryptomatte result not keyable after bogus manifest", alpha=1.0)
        # broken_test_parts()

    def test_encrypt_merge_operations(self):
        import cryptomatte_utilities as cu

        encry_over = self.tempNode(
            "Encryptomatte",
            inputs=[self.read_asset, self._setup_rotomask()],
            matteName="triangle",
            mergeOperation="over")

        id0_hash = self.hash_channel_layer(encry_over, "uCryptoAsset00", "red")

        # use a new rotomask every time, less bug prone in Nuke. 
        encry_under = self.tempNode(
            "Encryptomatte",
            inputs=[self.read_asset, self._setup_rotomask()],
            matteName="triangle",
            mergeOperation="under")
        id0_mod_hash = self.hash_channel_layer(encry_under, "uCryptoAsset00", "red")

        self.assertNotEqual(id0_hash, id0_mod_hash, "Under mode did not change ID 0. ")

    def test_encrypt_fresh_roundtrip(self):
        constant2k = self.tempNode("Constant", format="square_2K")
        empty_hash = self.hash_channel(constant2k, None, "alpha", num_scanlines=16)

        roto1k = self._setup_rotomask()
        roto1k.setInput(0, constant2k)
        roto_hash = self.hash_channel(roto1k, None, "alpha", num_scanlines=16)

        if empty_hash == roto_hash:
            raise RuntimeError("Roto matte did not change alpha, test is invalid. (%s)" % roto_hash)

        constant2k = self.tempNode("Constant", format="square_1K")

        encryptomatte = self.tempNode(
            "Encryptomatte", inputs=[constant2k, roto1k], matteName="triangle")
        encryptomatte.knob("cryptoLayer").setValue("customCrypto")
        encryptomatte.knob("setupLayers").setValue(True)

        self.gizmo.setInput(0, encryptomatte)
        self.key_on_image(self.triangle_pkr)
        self.assertMatteList("triangle", "Encryptomatte did not produce a keyable triangle")

        def broken_test_parts():
            """ this should work, but doesn't for some reason. It produces the right result 
            as seen in failfast, but breaks tests. The encryptomatte tests are generally too 
            brittle and this is one example. """

            self.gizmo.knob("forceUpdate").execute() # needed for some reason. 
            merge = self.tempNode("Merge", inputs=[constant2k, roto1k])
            roto_hash_720 = self.hash_channel(merge, None, "alpha", num_scanlines=16)
            decrypto_hash = self.hash_channel(self.gizmo, None, "alpha", num_scanlines=16)
            self.assertEqual(
                roto_hash_720, decrypto_hash, 
                ("Alpha did not survive round trip through "
                "Encryptomatte (%s) and then Cryptomatte (%s). ") % (roto_hash_720, decrypto_hash))
        # broken_test_parts()


    def test_encrypt_manifest(self):
        """Gets it into a weird state where it has a manifest but no cryptomatte."""
        import cryptomatte_utilities as cu
        encryptomatte = self.tempNode(
            "Encryptomatte", inputs=[self.gizmo, self.constant], matteName="test")
        cu.encryptomatte_knob_changed_event(encryptomatte, encryptomatte.knob("matteName"))
        encryptomatte.knob("setupLayers").setValue(True)
        encryptomatte.knob("cryptoLayer").setValue("customCrypto")
        encryptomatte.knob("setupLayers").setValue(False)
        cu.encryptomatte_knob_changed_event(encryptomatte, encryptomatte.knob("matteName"))

    def test_encrypt_fresh_keyable(self):
        """Tests fresh Encryptomatte setup where there is no input constant."""
        import cryptomatte_utilities as cu
        encryptomatte = self.tempNode(
            "Encryptomatte", inputs=[None, self._setup_rotomask()], matteName="triangle", 
            setupLayers=True, cryptoLayer="custom_crypto")
        self.gizmo.setInput(0, encryptomatte)
        self.key_on_image(self.triangle_pkr)
        self.assertMatteList("triangle", "Encryptomatte did not produce a keyable triangle")

    def test_channel_identification(self):
        """Makes sure multiple Cryptomattes can have sub-channels identified properly, 
        even if one's name starts with the others. """
        import cryptomatte_utilities as cu
        lyr_a_name, lyr_b_name = "cryLyr", "cryLyrSecond"
        lyr_a_result = ['cryLyr00', 'cryLyr01', 'cryLyr02']
        lyr_b_result = ['cryLyrSecond00', 'cryLyrSecond01', 'cryLyrSecond02']

        encrypt_a = self.tempNode("Encryptomatte", setupLayers=True, cryptoLayer=lyr_a_name)
        encrypt_b = self.tempNode("Encryptomatte", setupLayers=True, cryptoLayer=lyr_b_name)

        encrypt_b.setInput(0, encrypt_a)
        self.gizmo.setInput(0, encrypt_b)
        cinfo = cu.CryptomatteInfo(self.gizmo)

        identified_a = cinfo._identify_channels(lyr_a_name)
        identified_b = cinfo._identify_channels(lyr_b_name)

        self.assertEqual(lyr_a_result, identified_a)
        self.assertEqual(lyr_b_result, identified_b)

        cinfo.set_selection(lyr_a_name)
        self.assertEqual(cinfo.get_channels(), identified_a)

        cinfo.set_selection(lyr_b_name)
        self.assertEqual(cinfo.get_channels(), identified_b)

    #############################################
    # Blender names ("with . in names)
    #############################################

    nuke_unfriendly_channel_names = [
        ("custom.crypto", "custom_crypto"),
        ("amp&rsand", "amp_rsand"),
        ("per.od", "per_od"),
        (".per.od", "_per_od"),
        ("numlast_123", "numlast_123"),
        ("123_numfirst", "_123_numfirst"),
        ("123_numfirst.", "_123_numfirst_"),
        ("num_123_middle", "num_123_middle"),
    ]

    def test_blendery_names_in_metadata(self):
        """Tests that names with nuke-unfriendly layer names are mangled property."""
        encryptomatte = self.tempNode(
            "Encryptomatte", inputs=[None, self._setup_rotomask()], matteName="triangle", 
            setupLayers=True)
        for bad_name, corrected_name in self.nuke_unfriendly_channel_names:
            encryptomatte.knob("cryptoLayer").setValue(bad_name)

            name_key = ""
            metadata = encryptomatte.metadata()
            for key in metadata:
                if key.startswith("cryptomatte/") and key.endswith("/name"):
                    name_key = key
                    break
            self.assertEqual(metadata[name_key], corrected_name)

            modify_md = self.tempNode(
                "ModifyMetaData", inputs=[encryptomatte], 
                metadata='{set %s "%s"}' % (name_key, bad_name))
            self.gizmo.setInput(0, modify_md)

            # For some reason, on first runs after opening a fresh nuke (12.0v3)
            # this does not always update on its own. 
            encryptomatte.knob("forceUpdate").execute()
            self.gizmo.knob("forceUpdate").execute()

            self.assertEqual(
                self.gizmo.knob("cryptoLayer").getValue(), corrected_name,
                "Period should be removed from name and it should key.")
            self.key_on_image(self.triangle_pkr)
            self.assertMatteList("triangle", "Did not produce a keyable triangle")

        self.test_bad_names_in_nuke_layers()

    def test_blendery_names_encryptomatte(self):
        """Tests that names with nuke-unfriendly layer names are mangled property by Encryptomatte."""

        encryptomatte = self.tempNode(
            "Encryptomatte", inputs=[None, self._setup_rotomask()], matteName="triangle", 
            setupLayers=True)
        for bad_name, corrected_name in self.nuke_unfriendly_channel_names:
            encryptomatte.knob("cryptoLayer").setValue(bad_name)
            self.assertEqual(
                corrected_name, encryptomatte.knob("cryptoLayer").getValue(),
                "Period should be removed from Encryptomatte name.")
            self.gizmo.setInput(0, encryptomatte)

            # For some reason, on first runs after opening a fresh nuke (12.0v3)
            # this does not always update on its own. 
            encryptomatte.knob("forceUpdate").execute()
            self.gizmo.knob("forceUpdate").execute()

            self.assertEqual(
                self.gizmo.knob("cryptoLayer").getValue(), corrected_name,
                "Period should be removed from name and it should key.")
            self.key_on_image(self.triangle_pkr)
            self.assertMatteList("triangle", "Did not produce a keyable triangle")

        self.test_bad_names_in_nuke_layers()

    def test_bad_names_in_nuke_layers(self):
        import nuke
        import cryptomatte_utilities as cu
        for bad_name, corrected_name in self.nuke_unfriendly_channel_names:
            if bad_name != cu._legal_nuke_layer_name(bad_name):
                self.assertNotIn(
                    bad_name + "00", nuke.layers(), 
                    "Bad layer (%s) got into nuke layers. Restarting Nuke is required to test this again." % bad_name)

    def test_file_open_doesnt_update_nodes(self):
        """
        Note: This script behaves differently if the xpos and ypos are on the Cryptomatte node,
        vs if they aren't. They need to be here for this to work. 

        There does not appear to be a difference in loading a file vs pasting
        as of Nuke 12v3. 
        """
        import nuke

        prefix = "test_file_open_doesnt_update_nodes"
        asset_file = self.read_asset.knob("file").getValue()

        loadable = """
            Read {
             inputs 0
             file_type exr
             file %s
             name %s_Read
             selected true
             xpos 498
             ypos -17
            }
            add_layer {uCryptoAsset00 uCryptoAsset00.red uCryptoAsset00.green uCryptoAsset00.blue uCryptoAsset00.alpha}
            add_layer {uCryptoAsset01 uCryptoAsset01.red uCryptoAsset01.green uCryptoAsset01.blue uCryptoAsset01.alpha}
            add_layer {uCryptoAsset02 uCryptoAsset02.red uCryptoAsset02.green uCryptoAsset02.blue uCryptoAsset02.alpha}
            Cryptomatte {
             name %s_Cryptomatte
             selected true
             xpos 395
             ypos 33
             matteOutput alpha
             matteOutput alpha
             matteList "set, triangle"
             cryptoLayer uCryptoMaterial
             expression "((uCryptoMaterial00.red == 7.36562399642e+18 || uCryptoMaterial00.red == 1.54567320652e-21) ? uCryptoMaterial00.green : 0.0) + ((uCryptoMaterial00.blue == 7.36562399642e+18 || uCryptoMaterial00.blue == 1.54567320652e-21) ? uCryptoMaterial00.alpha : 0.0) + ((uCryptoMaterial01.red == 7.36562399642e+18 || uCryptoMaterial01.red == 1.54567320652e-21) ? uCryptoMaterial01.green : 0.0) + ((uCryptoMaterial01.blue == 7.36562399642e+18 || uCryptoMaterial01.blue == 1.54567320652e-21) ? uCryptoMaterial01.alpha : 0.0) + ((uCryptoMaterial02.red == 7.36562399642e+18 || uCryptoMaterial02.red == 1.54567320652e-21) ? uCryptoMaterial02.green : 0.0) + ((uCryptoMaterial02.blue == 7.36562399642e+18 || uCryptoMaterial02.blue == 1.54567320652e-21) ? uCryptoMaterial02.alpha : 0.0) + 0"
             in00 uCryptoMaterial00
             in01 uCryptoMaterial01
             in02 uCryptoMaterial02
            }
        """ % (asset_file, prefix, prefix)

        pasted_node_names = [(prefix + x)
                             for x in ("_Read", "_Cryptomatte")]
        self.assertFalse(
            any([nuke.toNode(x) for x in pasted_node_names]), 
            "Nodes already exist at the start of testing, were not pasted. ")

        try:
            for node in nuke.selectedNodes():
                node['selected'].setValue(False)
            nuke.scriptReadText(loadable)
            loaded_nodes = [nuke.toNode(x) for x in pasted_node_names]
            read, cryptomatte = loaded_nodes

            msg = "Loaded file was changed from the state it was loaded. "
            self.assertEqual(cryptomatte.knob("cryptoLayer").getValue(), "uCryptoMaterial")
            self.assertEqual(cryptomatte.knob("in00").value(), "uCryptoMaterial00")
            self.assertEqual(cryptomatte.knob("in01").value(), "uCryptoMaterial01")
            self.assertEqual(cryptomatte.knob("in02").value(), "uCryptoMaterial02")
            self.assertEqual(cryptomatte.knob("matteList").getValue(), "set, triangle")
            self.assertIn("uCryptoMaterial00.red", cryptomatte.knob("expression").getValue())
        except:
            raise
        finally:
            if not self.skip_cleanup():
                for node in loaded_nodes:
                    nuke.delete(node)


#############################################
# Ad hoc test running
#############################################


def run_unit_tests(test_filter="", failfast=False):
    """ Utility function for manually running tests unit tests.
    Returns unittest results if there are failures, otherwise None """

    return run_tests(get_all_unit_tests(), test_filter=test_filter, failfast=failfast)


def run_nuke_tests(test_filter="", failfast=False):
    """ Utility function for manually running tests inside Nuke
    Returns unittest results if there are failures, otherwise None """

    return run_tests(
        get_all_unit_tests() + get_all_nuke_tests(), test_filter=test_filter, failfast=failfast)


def run_tests(test_cases, test_filter="", failfast=False):
    """ Utility function for manually running tests. 
    Returns results if there are failures, otherwise None 

    Args:
        test_filter will be matched fnmatch style (* wildcards) to either the name of the TestCase 
        class or test method. 

        failfast stop after a failure, and skip cleanup of the nodes that were created. 
    """
    import nuke
    import fnmatch
    import platform
    import cryptomatte_utilities as cu

    def find_test_method(traceback):
        """ Finds first "test*" function in traceback called. """
        import re
        match = re.search('", line \d+, in (test[a-z_0-9]+)', traceback)
        return match.group(1) if match else ""

    suite = unittest.TestSuite()
    for case in test_cases:
        suite.addTests(unittest.TestLoader().loadTestsFromTestCase(case))

    if test_filter:
        filtered_suite = unittest.TestSuite()
        for test in suite:
            if any(fnmatch.fnmatch(x, test_filter) for x in test.id().split(".")):
                filtered_suite.addTest(test)
        if not any(True for _ in filtered_suite):
            raise RuntimeError("Filter %s selected no tests. " % test_filter)
        suite = filtered_suite

    set_skip_cleanup_on_failure(failfast)

    pv = sys.version_info
    if "%s.%s" % (pv[0], pv[1]) == "2.6":
        runner = unittest.TextTestRunner(verbosity=2) # nuke 7 again..
    else:
        runner = unittest.TextTestRunner(verbosity=2, failfast=failfast)
    result = runner.run(suite)

    reset_skip_cleanup_on_failure()

    print "---------"
    print 'Cryptomatte %s, Nuke %s, %s' % (cu.__version__, 
                                           nuke.NUKE_VERSION_STRING, 
                                           platform.platform())
    print "---------"
    for test_instance, traceback in result.failures:
        print "Failed: %s.%s" % (type(test_instance).__name__, find_test_method(traceback))
        print
        print traceback
        print "---------"
    for test_instance, traceback in result.errors:
        print "Error: %s.%s" % (type(test_instance).__name__, find_test_method(traceback))
        print
        print traceback
        print "---------"

    if result.failures or result.errors:
        print "TESTING FAILED: %s failed, %s errors. (%s test cases.)" % (len(result.failures),
                                                                          len(result.errors),
                                                                          suite.countTestCases())
        return result
    else:
        print "Testing passed: %s failed, %s errors. (%s test cases.)" % (len(result.failures),
                                                                          len(result.errors),
                                                                          suite.countTestCases())
        return None