From 0c0ed009fd711e001d600d47bfa1db7d7fb3d23b Mon Sep 17 00:00:00 2001 From: mike Date: Mon, 16 Nov 2009 01:04:44 +0000 Subject: Merge branch 'master' of git://git.sugarlabs.org/tutorius/mainline --- diff --git a/tests/enginetests.py b/tests/enginetests.py new file mode 100644 index 0000000..30d68de --- /dev/null +++ b/tests/enginetests.py @@ -0,0 +1,116 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Erick Lavoie +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +Engine Tests + + + +Usage of actions and event filters is tested, but not the concrete actions +and event filters. Those are in their separate test module + +""" + +import unittest + +from sugar.tutorius.tutorial import Tutorial +from sugar.tutorius.engine import TutorialRunner +from sugar.tutorius.filters import EventFilter + +from actiontests import CountAction + +class MockProbeMgr(object): + def __init__(self): + self.action = None + self.event = None + self.cB = None + + def doCB(self): + self.cB(self.event) + + currentActivity = property(fget=lambda s:s, fset=lambda s, v: v) + + def install(self, action, block=False): + self.action = action + + def update(self, action, newaction, block=False): + self.action = newaction + + def uninstall(self, action, block=False): + self.action = None + + def subscribe(self, event, callback): + self.event = event + self.cB = callback + self.event.install_handlers(callback) + return str(event) + + def unsubscribe(self, address): + self.event = None + +class MockEvent(EventFilter): + pass + + + +class TutorialRunnerTest(unittest.TestCase): + """ + This class needs to test the TutorialRunner + """ + def setUp(self): + self.pM = MockProbeMgr() + + + def tearDown(self): + self.pM = None + + # Basic interface cases + def testOneStateTutorial(self): + tutorial = Tutorial("TutorialRunner") + state_name = tutorial.add_state() + tutorial.update_transition(Tutorial.INITIAL_TRANSITION_NAME, + None, state_name) + event = MockEvent() + tutorial.add_transition(state_name, (event, Tutorial.END)) + + runner = TutorialRunner(tutorial, self.pM) + runner.start() + + assert runner._state == state_name, "Current state is: %s"%runner._state + assert self.pM.action == None + assert self.pM.event == event + + event.do_callback() + assert runner._state == Tutorial.END, "Current state is: %s"%runner._state + assert self.pM.action == None, "Current action is %s"%str(self.pM.action) + assert self.pM.event == None, "Current event is %s"%str(self.pM.event) + + + + # Limit cases + def testEmptyTutorial(self): + tutorial = Tutorial("TutorialRunner") + runner = TutorialRunner(tutorial, self.pM) + runner.start() + + assert runner._state == Tutorial.END, "Current state is: %s"%runner._state + assert self.pM.action == None + assert self.pM.event == None + + # Error cases + +if __name__ == "__main__": + unittest.main() diff --git a/tests/probetests.py b/tests/probetests.py index e1a587b..59072e5 100644 --- a/tests/probetests.py +++ b/tests/probetests.py @@ -47,7 +47,7 @@ class MockAddon(Action): i = TIntProperty(0) s = TStringProperty("test") - def do(self): + def do(self, **kwargs): global message_box message_box = (self.i, self.s) @@ -66,15 +66,20 @@ class MockAddon(Action): fake_addon_cache["MockAddon"] = MockAddon class MockActivity(object): - pass + def get_bundle_id(self): + return "localhost.unittest.ProbeTest" + + def get_id(self): + return "unique_id_1" + class MockProbeProxy(object): _MockProxyCache = {} - def __new__(cls, activityName): + def __new__(cls, activityName, unique_id): #For testing, use only one instance per activityName return cls._MockProxyCache.setdefault(activityName, super(MockProbeProxy, cls).__new__(cls)) - def __init__(self, activityName): + def __init__(self, activityName, unique_id): """ Constructor @param activityName unique activity id. Must be a valid dbus bus name. @@ -153,6 +158,12 @@ class MockSessionBus(object): old_SessionBus = dbus.SessionBus +class MockServiceProxy(object): + def register_probe(self, process_name, unique_id): + pass + def unregister_probe(self, unique_id): + pass + ########################################################################### # Begin Test Cases ########################################################################### @@ -170,7 +181,7 @@ class ProbeTest(unittest.TestCase): #Setup the activity and probe self.activity = MockActivity() - self.probe = TProbe("localhost.unittest.ProbeTest", self.activity) + self.probe = TProbe(self.activity, MockServiceProxy()) #Override the eventOccured on the Probe... self.old_eO = self.probe.eventOccured @@ -287,67 +298,49 @@ class ProbeManagerTest(unittest.TestCase): MockProbeProxy._MockProxyCache = {} self.probeManager = ProbeManager(proxy_class=MockProbeProxy) - def test_attach(self): - #ErrorCase: Set currentActivity to unattached activity - #Attempt to set to a non existing activity - try: - self.probeManager.currentActivity = "act1" - assert False, "Exception expected" - except RuntimeError, e: - pass - - #Attach an activity - self.probeManager.attach("act1") - - #Should have been created - assert "act1" in MockProbeProxy._MockProxyCache.keys(), "Proxy not created" - - #ErrorCase: Attach multiple times to same activity - #Try to attach again - self.assertRaises(RuntimeWarning, self.probeManager.attach, "act1") - - #Set current activity should work - self.probeManager.currentActivity = "act1" - - #TODO Fill in the alive/notalive behavior at creation time once - # it is fixed in the ProbeManager - - def test_detach(self): - #attach an activity - self.probeManager.attach("act1") - self.probeManager.currentActivity = "act1" - act1 = MockProbeProxy("act1") - - #Now we detach - self.probeManager.detach("act1") - assert act1.MockAlive == False, "ProbeProxy should have been detached" - assert self.probeManager.currentActivity is None, "Current activity should be None" - - #Attempt to detach again, should do nothing - #ErrorCase: detach already detached (currently silent fail) - self.probeManager.detach("act1") - - #Now, attach 2 activities - self.probeManager.attach("act2") - self.probeManager.attach("act3") - act2 = MockProbeProxy("act2") - act3 = MockProbeProxy("act3") - - self.probeManager.currentActivity = "act2" - - assert act2.MockAlive and act3.MockAlive, "Both ProbeProxy instances should be alive" + def test_register_probe(self): + assert len(self.probeManager.get_registered_probes_list()) == 0 + + self.probeManager.register_probe("act1", "unique_id_1") + assert len(self.probeManager.get_registered_probes_list()) == 1 + assert len(self.probeManager.get_registered_probes_list("act1")) == 1 + assert self.probeManager.get_registered_probes_list()[0][0] == "unique_id_1" + + self.probeManager.register_probe("act2","unique_id_2") + assert len(self.probeManager.get_registered_probes_list()) == 2 + assert len(self.probeManager.get_registered_probes_list("act1")) == 1 + assert self.probeManager.get_registered_probes_list("act1")[0][0] == "unique_id_1" + assert len(self.probeManager.get_registered_probes_list("act2")) == 1 + assert self.probeManager.get_registered_probes_list("act2")[0][0] == "unique_id_2" + + def test_register_multiple_probes(self): + assert len(self.probeManager.get_registered_probes_list()) == 0 + + self.probeManager.register_probe("act1", "unique_id_1") + self.probeManager.register_probe("act1","unique_id_2") + assert len(self.probeManager.get_registered_probes_list()) == 2 + assert len(self.probeManager.get_registered_probes_list("act1")) == 2 + assert self.probeManager.get_registered_probes_list("act1")[0][0] == "unique_id_1" + assert self.probeManager.get_registered_probes_list("act1")[1][0] == "unique_id_2" + + def test_unregister_probe(self): + assert len(self.probeManager.get_registered_probes_list()) == 0 + self.probeManager.register_probe("act1", "unique_id_1") + self.probeManager.register_probe("act1","unique_id_2") + + self.probeManager.unregister_probe("unique_id_1") + assert len(self.probeManager.get_registered_probes_list("act1")) == 1 + assert self.probeManager.get_registered_probes_list("act1")[0][0] == "unique_id_2" - #Detach the not active activity - self.probeManager.detach("act3") - #Check the statuses - assert act2.MockAlive and not act3.MockAlive, "Only act2 should be alive" - assert self.probeManager.currentActivity == "act2", "act2 should not have failed" + self.probeManager.unregister_probe("unique_id_2") + assert len(self.probeManager.get_registered_probes_list("act1")) == 0 + assert self.probeManager.get_registered_probes_list("act1") == [] def test_actions(self): - self.probeManager.attach("act1") - self.probeManager.attach("act2") - act1 = MockProbeProxy("act1") - act2 = MockProbeProxy("act2") + self.probeManager.register_probe("act1", "unique_id_1") + self.probeManager.register_probe("act2", "unique_id_2") + act1 = self.probeManager.get_registered_probes_list("act1")[0][1] + act2 = self.probeManager.get_registered_probes_list("act2")[0][1] ad1 = MockAddon() #ErrorCase: install, update, uninstall without currentActivity @@ -376,10 +369,10 @@ class ProbeManagerTest(unittest.TestCase): assert act1.MockAction is None, "Action should be uninstalled" def test_events(self): - self.probeManager.attach("act1") - self.probeManager.attach("act2") - act1 = MockProbeProxy("act1") - act2 = MockProbeProxy("act2") + self.probeManager.register_probe("act1", "unique_id_1") + self.probeManager.register_probe("act2", "unique_id_2") + act1 = self.probeManager.get_registered_probes_list("act1")[0][1] + act2 = self.probeManager.get_registered_probes_list("act2")[0][1] ad1 = MockAddon() ad2 = MockAddon() @@ -405,12 +398,13 @@ class ProbeManagerTest(unittest.TestCase): assert act1.MockEventAddr == "SomeAddress", "Unsubscribe should have been called" assert act2.MockEventAddr is None, "Unsubscribe should not have been called" + class ProbeProxyTest(unittest.TestCase): def setUp(self): dbus.SessionBus = MockSessionBus - self.mockObj = MockProxyObject("unittest.TestCase", "/tutorius/Probe") - self.probeProxy = ProbeProxy("unittest.TestCase") + self.mockObj = MockProxyObject("unittest.TestCase", "/tutorius/Probe/unique_id_1") + self.probeProxy = ProbeProxy("unittest.TestCase", "unique_id_1") def tearDown(self): dbus.SessionBus = old_SessionBus diff --git a/tests/skip b/tests/skip index 3868383..028ecaf 100644 --- a/tests/skip +++ b/tests/skip @@ -2,3 +2,4 @@ utils.py run-tests.py overlaytests.py viewer.py +coretests.py diff --git a/tests/storetests.py b/tests/storetests.py index 0c36973..3f1b73c 100644 --- a/tests/storetests.py +++ b/tests/storetests.py @@ -31,13 +31,11 @@ class StoreProxyTest(unittest.TestCase): def tearDown(self): pass - @catch_unimplemented def test_get_categories(self): categories = self.store.get_categories() assert isinstance(categories, list), "categories should be a list" - @catch_unimplemented def test_get_tutorials(self): self.store.get_tutorials() @@ -46,17 +44,14 @@ class StoreProxyTest(unittest.TestCase): assert isinstance(version_dict, dict) - @catch_unimplemented def test_download_tutorial(self): tutorial = self.store.download_tutorial(g_other_id) assert tutorial is not None - @catch_unimplemented def test_login(self): assert self.store.login("benoit.tremblay1@gmail.com", "tutorius12") - @catch_unimplemented def test_register_new_user(self): random_num = str(random.randint(0, 999999999)) user_info = { @@ -69,29 +64,24 @@ class StoreProxyTest(unittest.TestCase): class StoreProxyLoginTest(unittest.TestCase): - @catch_unimplemented def setUp(self): self.store = StoreProxy("http://bobthebuilder.mine.nu/tutorius/en-US/tutorius") self.store.login("nobody@mozilla.org", "tutorius12") - @catch_unimplemented def tearDown(self): session_id = self.store.get_session_id() if session_id is not None: self.store.close_session() - @catch_unimplemented def test_get_session_id(self): session_id = self.store.get_session_id() assert session_id is not None - @catch_unimplemented def test_rate(self): assert self.store.rate(5, g_tutorial_id) - @catch_unimplemented def test_publish(self): # TODO : We need to send in a real tutorial loaded from # the Vault @@ -108,7 +98,6 @@ class StoreProxyLoginTest(unittest.TestCase): } assert self.store.publish('This should be a real tutorial...', tutorial_info) != -1 - @catch_unimplemented def test_unpublish(self): assert self.store.unpublish(g_tutorial_id) @@ -118,7 +107,6 @@ class StoreProxyLoginTest(unittest.TestCase): def test_republish(self): assert self.store.publish(None, None, g_tutorial_id) - @catch_unimplemented def test_update_published_tutorial(self): # TODO : Run these tests with files from the Vault #self.store.publish([g_tutorial_id, 'Fake tutorial']) diff --git a/tutorius/TProbe.py b/tutorius/TProbe.py index dbab86a..0c79690 100644 --- a/tutorius/TProbe.py +++ b/tutorius/TProbe.py @@ -38,45 +38,65 @@ class TProbe(dbus.service.Object): a DBUS Interface. """ - def __init__(self, activity_name, activity): + def __init__(self, activity, service_proxy=None): """ Create and register a TProbe for an activity. - @param activity_name unique activity_id @param activity activity reference, must be a gtk container + @param service_proxy A Service proxy object to do the registering """ - LOGGER.debug("TProbe :: Creating TProbe for %s (%d)", activity_name, os.getpid()) - LOGGER.debug("TProbe :: Current gobject context: %s", str(gobject.main_context_default())) - LOGGER.debug("TProbe :: Current gobject depth: %s", str(gobject.main_depth())) # Moving the ObjectStore assignment here, in the meantime # the reference to the activity shouldn't be share as a # global variable but passed by the Probe to the objects # that requires it self._activity = activity + + if service_proxy == None: + from .service import ServiceProxy + + self._service_proxy = service_proxy or ServiceProxy() ObjectStore().activity = activity - self._activity_name = activity_name + self._activity_name = activity.get_bundle_id() + self._unique_id = activity.get_id() + + LOGGER.debug("TProbe :: Creating TProbe for %s (%d)", self._activity_name, os.getpid()) + LOGGER.debug("TProbe :: Current gobject context: %s", str(gobject.main_context_default())) + LOGGER.debug("TProbe :: Current gobject depth: %s", str(gobject.main_depth())) self._session_bus = dbus.SessionBus() # Giving a new name because _name is already used by dbus - self._name2 = dbus.service.BusName(activity_name, self._session_bus) - dbus.service.Object.__init__(self, self._session_bus, "/tutorius/Probe") + self._name2 = dbus.service.BusName(self._activity_name, self._session_bus) + dbus.service.Object.__init__(self, self._session_bus, "/tutorius/Probe/"+str(self._unique_id)) # Add the dictionary we will use to store which actions and events # are known self._installedActions = {} self._subscribedEvents = {} + LOGGER.debug("TProbe :: registering '%s' with unique_id '%s'", self._activity_name, activity.get_id()) + self._service_proxy.register_probe(self._activity_name, self._unique_id) + + + def start(self): """ Optional method to call if the probe is not inserted into an existing activity. Starts a gobject mainloop """ mainloop = gobject.MainLoop() - print "Starting Probe for " + self._activity_name mainloop.run() + def stop(self): + """ + Clean up the probe when finished. Should be called just + before a process ends + """ + from .service import ServiceProxy + LOGGER.debug("TProbe :: unregistering '%s' with unique_id '%s'", self._activity_name, self._unique_id) + ServiceProxy().unregister_probe(self._unique_id) + @dbus.service.method("org.tutorius.ProbeInterface", in_signature='s', out_signature='') def registered(self, service): @@ -234,16 +254,17 @@ class ProbeProxy: It provides an object interface to the TProbe, which requires pickled strings, across a DBus communication. """ - def __init__(self, activityName): + def __init__(self, activityName, unique_id): """ Constructor - @param activityName unique activity id. Must be a valid dbus bus name. + @param activityName generic activity name. Must be a valid dbus bus name. + @param unique_id unique id specific to an instance of an activity """ LOGGER.debug("ProbeProxy :: Creating ProbeProxy for %s (%d)", activityName, os.getpid()) LOGGER.debug("ProbeProxy :: Current gobject context: %s", str(gobject.main_context_default())) LOGGER.debug("ProbeProxy :: Current gobject depth: %s", str(gobject.main_depth())) bus = dbus.SessionBus() - self._object = bus.get_object(activityName, "/tutorius/Probe") + self._object = bus.get_object(activityName, "/tutorius/Probe/"+str(unique_id)) self._probe = dbus.Interface(self._object, "org.tutorius.ProbeInterface") self._actions = {} @@ -397,7 +418,7 @@ class ProbeProxy: return_cb=save_args(self.__clear_event, address), block=block) else: - LOGGER.debug("ProbeProxy :: unsubsribe address %s failed : not registered", address) + LOGGER.debug("ProbeProxy :: unsubscribe address %s failed : not registered", address) def detach(self, block=False): """ @@ -418,16 +439,22 @@ class ProbeManager(object): For now, it only handles one at a time, though. Actually it doesn't do much at all. But it keeps your encapsulation happy """ + _LOGGER = logging.getLogger("sugar.tutorius.ProbeManager") + def __init__(self, proxy_class=ProbeProxy): """Constructor @param proxy_class Class to use for creating Proxies to activities. The class should support the same interface as ProbeProxy. Exists to make this class unit-testable by replacing the Proxy with a mock """ + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + self._ProxyClass = proxy_class self._probes = {} self._current_activity = None + ProbeManager._LOGGER.debug("__init__()") + def setCurrentActivity(self, activity_id): if not activity_id in self._probes: raise RuntimeError("Activity not attached") @@ -437,23 +464,6 @@ class ProbeManager(object): return self._current_activity currentActivity = property(fget=getCurrentActivity, fset=setCurrentActivity) - def attach(self, activity_id): - if activity_id in self._probes: - raise RuntimeWarning("Activity already attached") - - self._probes[activity_id] = self._ProxyClass(activity_id) - #TODO what do we do with this? Raise something? - if self._probes[activity_id].isAlive(): - print "Alive!" - else: - print "FAil!" - - def detach(self, activity_id): - if activity_id in self._probes: - probe = self._probes.pop(activity_id) - probe.detach() - if self._current_activity == activity_id: - self._current_activity = None def install(self, action, block=False): """ @@ -463,7 +473,7 @@ class ProbeManager(object): @return None """ if self.currentActivity: - return self._probes[self.currentActivity].install(action, block) + return self._first_proxy(self.currentActivity).install(action, block) else: raise RuntimeWarning("No activity attached") @@ -476,7 +486,7 @@ class ProbeManager(object): @return None """ if self.currentActivity: - return self._probes[self.currentActivity].update(action, newaction, block) + return self._first_proxy(self.currentActivity).update(action, newaction, block) else: raise RuntimeWarning("No activity attached") @@ -487,7 +497,7 @@ class ProbeManager(object): @param block Force a synchroneous dbus call if True """ if self.currentActivity: - return self._probes[self.currentActivity].uninstall(action, block) + return self._first_proxy(self.currentActivity).uninstall(action, block) else: raise RuntimeWarning("No activity attached") @@ -499,7 +509,7 @@ class ProbeManager(object): @return address identifier used for unsubscribing """ if self.currentActivity: - return self._probes[self.currentActivity].subscribe(event, callback) + return self._first_proxy(self.currentActivity).subscribe(event, callback) else: raise RuntimeWarning("No activity attached") @@ -510,7 +520,67 @@ class ProbeManager(object): @return None """ if self.currentActivity: - return self._probes[self.currentActivity].unsubscribe(address) + return self._first_proxy(self.currentActivity).unsubscribe(address) else: raise RuntimeWarning("No activity attached") + def register_probe(self, process_name, unique_id): + """ Adds a probe to the known probes, to be used by a tutorial. + + A generic name for a process (like an Activity) is passed + so that the execution of a tutorial will use that generic + name. However, a unique id is also passed to differentiate + between many instances of the same process. + + @param process_name The generic name of a process + @param unique_id The unique identification associated to this + process + """ + ProbeManager._LOGGER.debug("register_probe(%s,%s)", process_name, unique_id) + if process_name not in self._probes: + self._probes[process_name] = [(unique_id,self._ProxyClass(process_name, unique_id))] + else: + self._probes[process_name].append((unique_id,self._ProxyClass(process_name, unique_id))) + + + def unregister_probe(self, unique_id): + """ Remove a probe from the known probes. + + @param unique_id The unique identification associated to this + process + """ + ProbeManager._LOGGER.debug("unregister_probe(%s)", unique_id) + for process_name, proxies in self._probes.items(): + for id, proxy in proxies: + if unique_id == id: + proxy.detach() + proxies.remove((id,proxy)) + if len(proxies) == 0: + self._probes.pop(process_name) + + def get_registered_probes_list(self, process_name=None): + if process_name == None: + probe_list = [] + for probes in self._probes.itervalues(): + probe_list.extend(probes) + return probe_list + else: + if process_name in self._probes: + return self._probes[process_name] + else: + return [] + + + + def _first_proxy(self, process_name): + """ + Returns the oldest probe connected under the process_name + @param process_name The generic process name under which the probe + is connected + """ + if process_name in self._probes: + return self._probes[process_name][0][1] + else: + raise RuntimeWarning("No activity attached under '%s'", process_name) + + diff --git a/tutorius/creator.py b/tutorius/creator.py index c477056..f59f320 100644 --- a/tutorius/creator.py +++ b/tutorius/creator.py @@ -26,14 +26,15 @@ import gtk.glade import gobject from gettext import gettext as T +import uuid import os -from sugar.graphics import icon -import copy +from sugar.graphics import icon, style from . import overlayer, gtkutils, actions, vault, properties, addon from . import filters from .services import ObjectStore -from .core import Tutorial, FiniteStateMachine, State +from .core import State +from .tutorial import Tutorial from . import viewer class Creator(object): @@ -50,10 +51,11 @@ class Creator(object): """ self._activity = activity if not tutorial: - self._tutorial = FiniteStateMachine('Untitled') - self._state = State(name='INIT') - self._tutorial.add_state(self._state) - self._state_counter = 1 + self._tutorial = Tutorial('Untitled') + self._state = self._tutorial.add_state() + self._tutorial.update_transition( + transition_name=self._tutorial.INITIAL_TRANSITION_NAME, + new_state=self._state) else: self._tutorial = tutorial # TODO load existing tutorial; unused yet @@ -110,30 +112,17 @@ class Creator(object): """ Removes the first instance of specified action from the tutorial. - @param action: the action object to remove from the tutorial + @param action: the action name @returns: True if successful, otherwise False. """ - state = self._tutorial.get_state_by_name("INIT") - - while True: - state_actions = state.get_action_list() - for fsm_action in state_actions: - if fsm_action is action: - state.clear_actions() - if state is self._state: - fsm_action.exit_editmode() - state_actions.remove(fsm_action) - self.set_insertion_point(state.name) - for keep_action in state_actions: - state.add_action(keep_action) - return True - - ev_list = state.get_event_filter_list() - if ev_list: - state = self._tutorial.get_state_by_name(ev_list[0][1]) - continue - + action_obj = self._tutorial.get_action_dict(self._state)\ + .get(action, None) + if not action_obj: return False + action_obj.exit_editmode() + self._tutorial.delete_action(action) + self._overview.win.queue_draw() + return True def delete_state(self): """ @@ -143,49 +132,25 @@ class Creator(object): @returns: True if successful, otherwise False. """ - if not self._state.get_event_filter_list(): + if self._state in (self._tutorial.INIT, self._tutorial.END): # last state cannot be removed return False - state = self._tutorial.get_state_by_name("INIT") - ev_list = state.get_event_filter_list() - if state is self._state: - next_state = self._tutorial.get_state_by_name(ev_list[0][1]) - self.set_insertion_point(next_state.name) - self._tutorial.remove_state(state.name) - self._tutorial.remove_state(next_state.name) - next_state.name = "INIT" - self._tutorial.add_state(next_state) - return True - - # loop to repair links from deleted state - while ev_list: - next_state = self._tutorial.get_state_by_name(ev_list[0][1]) - if next_state is self._state: - # the tutorial will flush the event filters. We'll need to - # clear and re-add them. - self._tutorial.remove_state(self._state.name) - state.clear_event_filters() - self._update_next_state(state, ev_list[0][0], next_state.get_event_filter_list()[0][1]) - for ev, next_state in ev_list: - state.add_event_filter(ev, next_state) - - self.set_insertion_point(ev_list[0][1]) - return True - - state = next_state - ev_list = state.get_event_filter_list() - return False + remove_state = self._state + next_state = self._tutorial\ + .get_following_states_dict(remove_state).keys()[0] + self.set_insertion_point(next_state) + return bool(self._tutorial.delete_state(remove_state)) def get_insertion_point(self): - return self._state.name + return self._state def set_insertion_point(self, state_name): - for action in self._state.get_action_list(): + for action in self._tutorial.get_action_dict(self._state).values(): action.exit_editmode() - self._state = self._tutorial.get_state_by_name(state_name) - self._overview.win.queue_draw() - state_actions = self._state.get_action_list() + + self._state = state_name + state_actions = self._tutorial.get_action_dict(self._state).values() for action in state_actions: action.enter_editmode() action._drag._eventbox.connect_after( @@ -196,6 +161,8 @@ class Creator(object): else: self._propedit.action = None + self._overview.win.queue_draw() + def _evfilt_cb(self, menuitem, event): """ @@ -249,7 +216,7 @@ class Creator(object): action_type = self._propedit.actions_list[path][ToolBox.ICON_NAME] action = addon.create(action_type) action.enter_editmode() - self._state.add_action(action) + self._tutorial.add_action(self._state, action) # FIXME: replace following with event catching action._drag._eventbox.connect_after( "button-release-event", self._action_refresh_cb, action) @@ -283,31 +250,24 @@ class Creator(object): else: raise NotImplementedError() - event_filters = self._state.get_event_filter_list() + event_filters = self._tutorial.get_transition_dict(self._state) + + # if not at the end of tutorial if event_filters: - # linearize tutorial by inserting state - new_state = State(name=str(self._state_counter)) - self._state_counter += 1 - self._state.clear_event_filters() - for evt_filt, next_state in event_filters: - new_state.add_event_filter(evt_filt, next_state) - self._update_next_state(self._state, event, new_state.name) - next_state = new_state.name - # blocks are shifted, full redraw is necessary - self._overview.win.queue_draw() + old_transition = event_filters.keys()[0] + new_state = self._tutorial.add_state(event_filters[old_transition]) + self._tutorial.update_transition(transition_name=old_transition, + new_state=new_state) + else: # append empty state only if edit inserting at end of linearized # tutorial. - self._update_next_state(self._state, event, str(self._state_counter)) - next_state = str(self._state_counter) - new_state = State(name=str(self._state_counter)) - self._state_counter += 1 + new_state = self._tutorial.add_state() + self._tutorial.add_transition(self._state, (event, new_state)) - self._state.add_event_filter(event, next_state) - self._tutorial.add_state(new_state) self._overview.win.queue_draw() - self.set_insertion_point(new_state.name) + self.set_insertion_point(new_state) def _action_refresh_cb(self, widget, evt, action): """ @@ -324,24 +284,27 @@ class Creator(object): self._overview.win.queue_draw() - def _cleanup_cb(self, *args): + def _cleanup_cb(self, *args, **kwargs): """ Quit editing and cleanup interface artifacts. + + @param force: force quitting without saving. """ # undo actions so they don't persist through step editing - for action in self._state.get_action_list(): + for action in self._tutorial.get_action_dict(self._state).values(): action.exit_editmode() - dialog = gtk.MessageDialog( - parent=self._activity, - flags=gtk.DIALOG_MODAL, - type=gtk.MESSAGE_QUESTION, - buttons=gtk.BUTTONS_YES_NO, - message_format=T('Do you want to save before stopping edition?')) - do_save = dialog.run() - dialog.destroy() - if do_save == gtk.RESPONSE_YES: - self.save() + if kwargs.get(force, False): + dialog = gtk.MessageDialog( + parent=self._activity, + flags=gtk.DIALOG_MODAL, + type=gtk.MESSAGE_QUESTION, + buttons=gtk.BUTTONS_YES_NO, + message_format=T('Do you want to save before stopping edition?')) + do_save = dialog.run() + dialog.destroy() + if do_save == gtk.RESPONSE_YES: + self.save() # remove UI remains self._hlmask.covered = None @@ -354,20 +317,24 @@ class Creator(object): del self._activity._creator def save(self, widget=None): - if not self.tuto: - dlg = TextInputDialog(self._activity, + if not self._guid: + self._guid = str(uuid.uuid1()) + dlg = TextInputDialog(parent=self._overview.win, text=T("Enter a tutorial title."), field=T("Title")) tutorialName = "" while not tutorialName: tutorialName = dlg.pop() dlg.destroy() - - # prepare tutorial for serialization - self.tuto = Tutorial(tutorialName, self._tutorial) - bundle = vault.TutorialBundler(self._guid) - self._guid = bundle.Guid - bundle.write_metadata_file(self.tuto) - bundle.write_fsm(self._tutorial) + self._metadata = { + vault.INI_GUID_PROPERTY: self._guid, + vault.INI_NAME_PROPERTY: tutorialName, + vault.INI_VERSION_PROPERTY: '1', + 'activities':{os.environ['SUGAR_BUNDLE_NAME']: + os.environ['SUGAR_BUNDLE_VERSION'] + }, + } + + vault.Vault.saveTutorial(self._tutorial, self._metadata) def launch(*args, **kwargs): diff --git a/tutorius/engine.py b/tutorius/engine.py index e77a018..c945e49 100644 --- a/tutorius/engine.py +++ b/tutorius/engine.py @@ -4,17 +4,130 @@ from jarabe.model import shell from sugar.bundle.activitybundle import ActivityBundle from .vault import Vault +from .TProbe import ProbeManager +from .dbustools import save_args +from .tutorial import Tutorial, AutomaticTransitionEvent + + +class TutorialRunner(object): + """ + Driver for the execution of one tutorial + """ + def __init__(self, tutorial, probeManager): + """Constructor + @param tutorial Tutorial to execute + @param probeManager probeManager to use + """ + self._tutorial = tutorial + self._pM = probeManager + + #State + self._state = None + self._sEvents = set() #Subscribed Events + + #Cached objects + self._actions = {} + + #Temp FIX until event/actions have an activity id + self._activity_id = None + + #Temp FIX until event, actions have an activity id + def setCurrentActivity(self): + self._pM.currentActivity = self._activity_id + + def start(self): + self.setCurrentActivity() #Temp Hack until activity in events/actions + self.enterState(self._tutorial.INIT) + + def stop(self): + self.setCurrentActivity() #Temp Hack until activity in events/actions + self.enterState(self._tutorial.END) + self._teardownState() + self._state = None + + def _handleEvent(self, next_state, event): + #FIXME sanity check, log event that was not installed and ignore + self.enterState(next_state) + + def _teardownState(self): + if self._state is None: + #No state, no teardown + return + + #Clear the current actions + for action in self._actions.values(): + self._pM.uninstall(action) + self._actions = {} + + #Clear the EventFilters + for event in self._sEvents: + self._pM.unsubscribe(event) + self._sEvents.clear() + + def _setupState(self): + if self._state is None: + raise RuntimeError("Attempting to setupState without a state") + + # Handle the automatic event + state_name = self._state + + self._actions = self._tutorial.get_action_dict(self._state) + transitions = self._tutorial.get_transition_dict(self._state) + + for (event, next_state) in transitions.values(): + if isinstance(event, AutomaticTransitionEvent): + state_name = next_state + break + + self._sEvents.add(self._pM.subscribe(event, save_args(self._handleEvent, next_state))) + + for action in self._actions.values(): + self._pM.install(action) + + return state_name + + def enterState(self, state_name): + """ + Starting from the state_name, the runner execute states until + no automatic transition are found and will wait for an external + event to occur. + + When entering the state, actions and events from the previous + state are respectively uninstalled and unsubscribed and actions + and events from the state_name will be installed and subscribed. + + @param state_name The name of the state to enter in + """ + self.setCurrentActivity() #Temp Hack until activity in events/actions + + # Recursive base case + if state_name == self._state: + #Nothing to do + return + + self._teardownState() + self._state = state_name + + # Recursively call the enterState in case there was an automatic + # transition in the state definition + self.enterState(self._setupState()) + + + class Engine: """ Driver for the execution of tutorials """ - def __init__(self): - # FIXME Probe management should be in the probe manager + def __init__(self, probeManager=None): + """Constructor + @param probeManager (optional) ProbeManager instance to use + """ dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) #FIXME shell.get_model() will only be useful in the shell process self._shell = shell.get_model() + self._probeManager = probeManager or ProbeManager() self._tutorial = None def launch(self, tutorialID): @@ -22,25 +135,33 @@ class Engine: @param tutorialID unique tutorial identifier used to retrieve it from the disk """ if self._tutorial: - self._tutorial.detach() - self._tutorial = None + self.stop() + + self._tutorial = TutorialRunner(Vault.loadTutorial(tutorialID), self._probeManager) #Get the active activity from the shell activity = self._shell.get_active_activity() - self._tutorial = Vault.loadTutorial(tutorialID) - #TProbes automatically use the bundle id, available from the ActivityBundle bundle = ActivityBundle(activity.get_bundle_path()) - self._tutorial.attach(bundle.get_bundle_id()) - def stop(self): + self._tutorial._activity_id = bundle.get_bundle_id() #HACK until we have activity id's in action/events + + self._tutorial.start() + + def stop(self, tutorialID=None): """ Stop the current tutorial """ - self._tutorial.detach() + if tutorialID is None: + logging.warning( + "stop() without a tutorialID will become deprecated") + self._tutorial.stop() self._tutorial = None - def pause(self): + def pause(self, tutorialID=None): """ Interrupt the current tutorial and save its state in the journal """ + if tutorialID is None: + logging.warning( \ + "pause() without a tutorialID will become deprecated") raise NotImplementedError("Unable to store tutorial state") diff --git a/tutorius/service.py b/tutorius/service.py index eb246a1..11a94a5 100644 --- a/tutorius/service.py +++ b/tutorius/service.py @@ -2,6 +2,9 @@ import dbus from .engine import Engine from .dbustools import remote_call +from .TProbe import ProbeManager +import logging +LOGGER = logging.getLogger("sugar.tutorius.service") _DBUS_SERVICE = "org.tutorius.Service" _DBUS_PATH = "/org/tutorius/Service" @@ -19,11 +22,13 @@ class Service(dbus.service.Object): self._engine = None + self._probeMgr = ProbeManager() + def start(self): """ Start the service itself """ # For the moment there is nothing to do - pass + LOGGER.debug("Service.start()") @dbus.service.method(_DBUS_SERVICE_IFACE, @@ -33,7 +38,7 @@ class Service(dbus.service.Object): @param tutorialID unique tutorial identifier used to retrieve it from the disk """ if self._engine == None: - self._engine = Engine() + self._engine = Engine(self._probeMgr) self._engine.launch(tutorialID) @dbus.service.method(_DBUS_SERVICE_IFACE, @@ -50,6 +55,35 @@ class Service(dbus.service.Object): """ self._engine.pause() + @dbus.service.method(_DBUS_SERVICE_IFACE, + in_signature="ss", out_signature="") + def register_probe(self, process_name, unique_id): + """ Adds a probe to the known probes, to be used by a tutorial. + + A generic name for a process (like an Activity) is passed + so that the execution of a tutorial will use that generic + name. However, a unique id is also passed to differentiate + between many instances of the same process. + + @param process_name The generic name of a process + @param unique_id The unique identification associated to this + process + """ + LOGGER.debug("Service.register_probe(%s,%s)", process_name, unique_id) + self._probeMgr.register_probe(process_name, unique_id) + + @dbus.service.method(_DBUS_SERVICE_IFACE, + in_signature="s", out_signature="") + def unregister_probe(self, unique_id): + """ Remove a probe from the known probes. + + @param process_name The generic name of a process + @param unique_id The unique identification associated to this + process + """ + LOGGER.debug("Service.unregister_probe(%s)", unique_id) + self._probeMgr.unregister_probe(unique_id) + class ServiceProxy: """ Proxy to connect to the Service object, abstracting the DBus interface""" @@ -74,6 +108,33 @@ class ServiceProxy: """ remote_call(self._service.pause, (), block=False) + def register_probe(self, process_name, unique_id): + """ Adds a probe to the known probes, to be used by a tutorial. + + A generic name for a process (like an Activity) is passed + so that the execution of a tutorial will use that generic + name. However, a unique id is also passed to differentiate + between many instances of the same process. + + @param process_name The generic name of a process + @param unique_id The unique identification associated to this + process + """ + remote_call(self._service.register_probe, (process_name,unique_id), block=False) + + def unregister_probe(self, unique_id): + """ Remove a probe from the known probes. + + @param process_name The generic name of a process + @param unique_id The unique identification associated to this + process + """ + # We make it synchronous because otherwise on closing, + # activities kill the dbus session bus too fast for the + # asynchronous call to be completed + self._service.unregister_probe(unique_id) + + if __name__ == "__main__": import dbus.mainloop.glib import gobject diff --git a/tutorius/viewer.py b/tutorius/viewer.py index 272558e..56428e1 100644 --- a/tutorius/viewer.py +++ b/tutorius/viewer.py @@ -18,9 +18,7 @@ This module renders a widget containing a graphical representation of a tutorial and acts as a creator proxy as it has some editing functionality. """ -import sys - -import gtk, gtk.gdk +import gtk import cairo from math import pi as PI PI2 = PI/2 @@ -30,7 +28,6 @@ import rsvg from sugar.bundle import activitybundle from sugar.tutorius import addon from sugar.graphics import icon -from sugar.tutorius.filters import EventFilter from sugar.tutorius.actions import Action import os @@ -66,7 +63,7 @@ class Viewer(object): self.alloc = None self.click_pos = None self.drag_pos = None - self.selection = [] + self.selection = set() self.win = gtk.Window(gtk.WINDOW_TOPLEVEL) self.win.set_size_request(400, 200) @@ -81,7 +78,7 @@ class Viewer(object): canvas = gtk.DrawingArea() vbox.add_with_viewport(canvas) canvas.set_app_paintable(True) - canvas.connect_after("expose-event", self.on_viewer_expose, tutorial._states) + canvas.connect_after("expose-event", self.on_viewer_expose, tutorial) canvas.add_events(gtk.gdk.BUTTON_PRESS_MASK \ |gtk.gdk.BUTTON_MOTION_MASK \ |gtk.gdk.BUTTON_RELEASE_MASK \ @@ -99,10 +96,13 @@ class Viewer(object): canvas.set_size_request(2048, 180) # FIXME def destroy(self): + """ + Destroy ui resources associated with this object. + """ self.win.destroy() - def _paint_state(self, ctx, states): + def _paint_state(self, ctx, tutorial): """ Paints a tutorius fsm state in a cairo context. Final context state will be shifted by the size of the graphics. @@ -111,17 +111,13 @@ class Viewer(object): block_max_height = self.alloc.height new_insert_point = None - cur_state = 'INIT' + state_name = tutorial.INIT # FIXME: get app when we have a model that supports it cur_app = 'Calculate' app_start = ctx.get_matrix() - try: - state = states[cur_state] - except KeyError: - state = None - while state: + while state_name: new_app = 'Calculate' if new_app != cur_app: ctx.save() @@ -132,16 +128,17 @@ class Viewer(object): ctx.translate(BLOCK_PADDING, 0) cur_app = new_app - action_list = state.get_action_list() + action_list = tutorial.get_action_dict(state_name).items() if action_list: - local_height = (block_max_height - BLOCK_PADDING)/len(action_list) - BLOCK_PADDING + local_height = (block_max_height - BLOCK_PADDING)\ + / len(action_list) - BLOCK_PADDING ctx.save() - for action in action_list: + for action_name, action in action_list: origin = tuple(ctx.get_matrix())[-2:] if self.click_pos and \ self.click_pos[0]-BLOCK_WIDTHorigin[0]: - self.selection.append(action) + self.selection.add((action_name, action)) self.render_action(ctx, block_width, local_height, action) ctx.translate(0, local_height+BLOCK_PADDING) @@ -150,7 +147,7 @@ class Viewer(object): # insertion cursor painting made from two opposed triangles # joined by a line. - if state.name == self._creator.get_insertion_point(): + if state_name == self._creator.get_insertion_point(): ctx.save() bp2 = BLOCK_PADDING/2 ctx.move_to(-bp2, 0) @@ -170,36 +167,34 @@ class Viewer(object): ctx.restore() - event_list = state.get_event_filter_list() + event_list = tutorial.get_transition_dict(state_name).items() if event_list: - local_height = (block_max_height - BLOCK_PADDING)/len(event_list) - BLOCK_PADDING + local_height = (block_max_height - BLOCK_PADDING)\ + /len(event_list) - BLOCK_PADDING ctx.save() - for event, next_state in event_list: + for transition_name, transition in event_list: origin = tuple(ctx.get_matrix())[-2:] if self.click_pos and \ self.click_pos[0]-BLOCK_WIDTHorigin[0]: - self.selection.append(event) - self.render_event(ctx, block_width, local_height, event) + self.selection.add((transition_name, transition)) + self.render_event(ctx, block_width, local_height, + event=transition[0]) ctx.translate(0, local_height+BLOCK_PADDING) ctx.restore() ctx.translate(BLOCK_WIDTH, 0) - # FIXME point to next state in state, as it would highlight - # the "happy path". - cur_state = event_list[0][1] - if (not new_insert_point) and self.click_pos: origin = tuple(ctx.get_matrix())[-2:] if self.click_pos[0]