# Copyright (c) 2002, 2003, 2004 by Intevation GmbH
# Authors:
# Bernhard Herzog <bh@intevation.de>
#
# This program is free software under the GPL (>=v2)
# Read the file COPYING coming with Thuban for details.

"""
Test the Connector class
"""

__version__ = "$Revision: 2586 $"
# $Source$
# $Id: test_connector.py 2586 2005-03-16 14:55:21Z bh $

import sys
import unittest
import traceback

import support
support.initthuban()

from Thuban.Lib.connector import Connector, Publisher, Conduit, ConnectorError

# some messages used in the tests
SIMPLE = "SIMPLE"
PARAM = "PARAM"

class SimplePublisher:

    """A version of Publisher that uses a specific connector.

    The Publisher class in Thuban.Lib.connector uses the global
    connector in the same module.
    """

    def __init__(self, connector):
        self.connector = connector

    def __del__(self):
        self.connector.RemovePublisher(self)

    def issue(self):
        """Issue a SIMPLE message without parameters"""
        self.connector.Issue(self, SIMPLE)

    def issue_arg(self):
        """Issue a PARAM message with 42 as parameter"""
        self.connector.Issue(self, PARAM, 42)


class RealPublisher(Publisher):

    """Extended version of Publisher for testing purposes.

    Publisher is not intended to be used directly. It is used as a base
    class for objects that send messages when they change. So we do just
    that here and derive from Publisher to provide some simple methods
    that issue messages.
    """

    def simple_action(self):
        """Issue a SIMPLE message without parameters"""
        self.issue(SIMPLE)

    def param_action(self):
        """Issue a PARAM message with 42 as parameter"""
        self.issue(PARAM, 42)


class Receiver:

    """Class to be used as a generic receiver of messages.

    An instance of this class has some methods that can be used as
    subscribers for messages. These messages put information about the
    messages they received into the public instance variable messages.
    See the method's doc-strings for more information.

    Furthermore, the class is instantiated with a test case object as
    parameter and the instance notifies the test case when it's being
    instantiated and deleted so that the test case can determine which
    objects weren't deleted.
    """

    def __init__(self, testcase):
        """Initialize the object for the given testcase.

        Call the testcase's expect_delete method with self as parameter.
        """
        self.testcase = testcase
        self.testcase.expect_delete(self)
        self.reset()

    def __del__(self):
        """Tell the test case that the object has been deleted"""
        self.testcase.deleted(self)

    def reset(self):
        """Clear the list of received messages"""
        self.messages = []

    def no_params(self):
        """Method for subscriptions without parameters

        Add the tuple ("no_params",) to self.messages
        """
        self.messages.append(("no_params",))

    def with_params(self, *args):
        """Method for subscriptions with parameters

        Add a tuple with the string 'params' followed by the arguments
        of this function (except for the self parameter) to
        self.messages.
        """
        self.messages.append(("params",) + args)



class DeletionTestMixin:

    """Mixin class to check for memory leaks.

    Mixin class for test that want to determine whether certain objects
    have been destroyed.

    This class maintains two lists, deleted_objects and
    expected_deletions to determine whether all objects which are
    expected to be deleted by a test are actually deleted.
    """

    def setUp(self):
        """Initialize self.deleted_objects and self.expected_deletions"""
        self.deleted_objects = []
        self.expected_deletions = []

    def expect_delete(self, obj):
        """Append the id of obj to the self.expected_deletions"""
        self.expected_deletions.append(id(obj))

    def deleted(self, obj):
        """Append the id of obj to the self.deleted_objects"""
        self.deleted_objects.append(id(obj))

    def check_deletions(self):
        """Assert equality of self.expected_deletions and self.deleted_objects

        This check simply compares the lists for equality and thus
        effectively assumes that the objects are deleted in the same
        order in which they're added to the list which if used only for
        Receiver instances is the order in which they're instantiated.
        """
        self.assertEquals(self.expected_deletions, self.deleted_objects)


class ConnectorTest(unittest.TestCase, DeletionTestMixin):

    """Test cases for the Connector class.

    These tests use the SimplePublisher class instead of the Publisher
    class in Thuban.Lib.connector because we only want to test the
    connector here.
    """

    def setUp(self):
        """Extend the inherited method to create a Connector instance.

        Bind the Connector to self.connector.
        """
        self.connector = Connector()
        DeletionTestMixin.setUp(self)

    def test_issue_simple(self):
        """Test connector issue without parameters"""
        # Make a publisher and a subscriber and connect the two
        pub = SimplePublisher(self.connector)
        rec = Receiver(self)
        self.connector.Connect(pub, SIMPLE, rec.no_params, ())

        # now the publisher should have subscribers
        self.assert_(self.connector.HasSubscribers(pub))

        # Issue a message and check whether the receiver got it
        pub.issue()
        self.assertEquals(rec.messages, [("no_params",)])
        rec.reset()

        # disconnect and check that the message doesn't get send anymore
        self.connector.Disconnect(pub, SIMPLE, rec.no_params, ())
        pub.issue()
        self.assertEquals(rec.messages, [])

        # now the publisher should have no subscribers
        self.failIf(self.connector.HasSubscribers(pub))

        # make sure that all references have been deleted
        del rec
        self.check_deletions()

    def test_issue_param(self):
        """Test connector issue with parameters"""
        pub = SimplePublisher(self.connector)
        rec = Receiver(self)
        # Three cases: 1. The parameter supplied by pub.issue_arg, 2.
        # only the parameter given when connecting, 3. both
        self.connector.Connect(pub, PARAM, rec.with_params, ())
        self.connector.Connect(pub, SIMPLE, rec.with_params, ("deliverator",))
        self.connector.Connect(pub, PARAM, rec.with_params, ("loglo",))

        pub.issue_arg()
        pub.issue()
        self.assertEquals(rec.messages, [("params", 42),
                                         ("params", 42, "loglo"),
                                         ("params", "deliverator")])

        # make sure that all references have been deleted
        self.connector.RemovePublisher(pub)
        del rec
        self.check_deletions()

    def test_cyclic_references(self):
        """Test whether connector avoids cyclic references"""
        pub = SimplePublisher(self.connector)
        rec = Receiver(self)
        self.connector.Connect(pub, SIMPLE, rec.no_params, ())

        # deleting pub and rec should be enough that the last reference
        # to rec has been dropped because the connector doesn't keep
        # references to the publishers and SimplePublisher's __del__
        # method removes all subscriptions
        del pub
        del rec
        self.check_deletions()

    def test_disconnect_in_receiver(self):
        """Test unsubscribing from a channel while receiving a message

        There was a bug in the connector implementation in the following
        situation:

         - 2 receivers for the same channel

         - the reiver called first unsubscribes itself from that channel
           in response to a message on that channel

        Now the second receiver is never called because the list of
        receivers was modified by Disconnect while the connecter was
        iterating over it.
        """
        messages = []
        def rec1(*args):
            try:
                messages.append("rec1")
                self.connector.Disconnect(None, SIMPLE, rec1, ())
            except:
                self.fail("Exception in rec1")
        def rec2(*args):
            try:
                messages.append("rec2")
                self.connector.Disconnect(None, SIMPLE, rec2, ())
            except:
                self.fail("Exception in rec1")

        self.connector.Connect(None, SIMPLE, rec1, ())
        self.connector.Connect(None, SIMPLE, rec2, ())

        self.connector.Issue(None, SIMPLE)

        self.assertEquals(messages, [("rec1"), ("rec2")])


class TestPublisher(unittest.TestCase, DeletionTestMixin):

    """Tests for the Publisher class"""

    def setUp(self):
        DeletionTestMixin.setUp(self)

    def test_issue_simple(self):
        """Test Publisher message without parameters"""
        # Make a publisher and a subscriber and connect the two
        pub = RealPublisher()
        rec = Receiver(self)
        pub.Subscribe(SIMPLE, rec.no_params)

        # Issue a message and check whether the receiver got it
        pub.simple_action()
        self.assertEquals(rec.messages, [("no_params",)])
        rec.reset()

        # disconnect and check that the message doesn't get sent anymore
        pub.Unsubscribe(SIMPLE, rec.no_params)
        pub.simple_action()
        self.assertEquals(rec.messages, [])

        # make sure that all references have been deleted
        del rec
        self.check_deletions()

    def test_issue_param(self):
        """Test Publisher message with parameters"""
        pub = RealPublisher()
        rec = Receiver(self)
        # Three cases: 1. The parameter supplied by pub.issue_arg, 2.
        # only the parameter given when connecting, 3. both
        pub.Subscribe(PARAM, rec.with_params)
        pub.Subscribe(SIMPLE, rec.with_params, "deliverator")
        pub.Subscribe(PARAM, rec.with_params, "loglo")

        pub.param_action()
        pub.simple_action()
        self.assertEquals(rec.messages, [("params", 42),
                                         ("params", 42, "loglo"),
                                         ("params", "deliverator")])

        # make sure that all references have been deleted
        pub.Destroy()
        del rec
        self.check_deletions()

    def test_cyclic_references(self):
        """Test whether Publisher avoids cyclic references"""
        pub = RealPublisher()
        rec = Receiver(self)
        pub.Subscribe(SIMPLE, rec.no_params, ())

        # deleting pub and rec should be enough that the last reference
        # to rec has been dropped because the connector doesn't keep
        # references to the publishers and SimplePublisher's __del__
        # method removes all subscriptions
        del pub
        del rec
        self.check_deletions()

    def test_unsubscribe_after_destroy(self):
        """Test that Unsubscribe() does not raise exceptions after a Destroy"""
        pub = RealPublisher()
        rec = Receiver(self)
        pub.Subscribe(SIMPLE, rec.no_params)

        # Sanity check: Issue a message and check whether the receiver
        # got it
        pub.simple_action()
        self.assertEquals(rec.messages, [("no_params",)])
        rec.reset()

        # Now the real test. Destroy the publisher and Unsubscribe the
        # receiver afterwards. The Unsubscribe should not raise an
        # exception.
        pub.Destroy()
        try:
            pub.Unsubscribe(SIMPLE, rec.no_params)
        except ConnectorError:
            self.fail("Unsubscribe after Destroy raised exception:\n"+
                      "".join(traceback.format_exception(*sys.exc_info())))


class MyConduit(Conduit):

    """Class for use in the Conduit tests

    Like publishers Conduits are not instantiated themselves they're
    always used as base classes.
    """

    def __init__(self, forward):
        self.forward = forward
        self.subscribe_forwarding(SIMPLE, self.forward)

    def set_forward(self, forward):
        # NOTE: The fact the we simply pass self.forward through to
        # unsubscribe_forwarding and subscribe_forwarding is used by the
        # test for None handling.
        self.unsubscribe_forwarding(SIMPLE, self.forward)
        self.forward = forward
        self.subscribe_forwarding(SIMPLE, self.forward)

    def action(self):
        self.delegate.param_action()


class TestConduit(unittest.TestCase, support.SubscriberMixin):

    """Tests for the Conduit class"""

    def setUp(self):
        self.publisher = RealPublisher()
        self.other_publisher = RealPublisher()
        self.clear_messages()

    def tearDown(self):
        self.clear_messages()
        self.publisher.Destroy()
        self.other_publisher.Destroy()
        self.publisher = self.other_publisher = None

    def test_forwarding(self):
        """Test conduit forwarding"""
        cond = MyConduit(self.publisher)
        cond.Subscribe(SIMPLE, self.subscribe_with_params, SIMPLE)
        self.publisher.simple_action()
        self.check_messages([(SIMPLE,)])
        self.clear_messages()

        # Set a different publisher. The message of the new publisher
        # will be forwarded through the conduit but not that of the old
        # one anymore
        cond.set_forward(self.other_publisher)
        self.other_publisher.simple_action()
        self.check_messages([(SIMPLE,)])
        self.clear_messages()

        self.publisher.simple_action()
        self.check_messages([])
        self.clear_messages()

    def test_none_handling(self):
        """Test whether (un)subscribe_forwarding work with None"""
        # All we test is whether it works without raising exceptions :)
        cond = MyConduit(None)
        cond.set_forward(None)


if __name__ == "__main__":
    support.run_tests()
