diff --git a/addons/base/controllers/main.py b/addons/base/controllers/main.py index 4b223bdeb9e..6c91a3b1633 100644 --- a/addons/base/controllers/main.py +++ b/addons/base/controllers/main.py @@ -118,29 +118,39 @@ class Menu(openerpweb.Controller): @openerpweb.jsonrequest def load(self, req): - m = req.session.model('ir.ui.menu') + return {'data': self.do_load(req)} + + def do_load(self, req): + """ Loads all menu items (all applications and their sub-menus). + + :param req: A request object, with an OpenERP session attribute + :type req: < session -> OpenERPSession > + :return: the menu root + :rtype: dict('children': menu_nodes) + """ + Menus = req.session.model('ir.ui.menu') # menus are loaded fully unlike a regular tree view, cause there are # less than 512 items - menu_ids = m.search([]) - menu_items = m.read(menu_ids, ['name', 'sequence', 'parent_id']) + menu_ids = Menus.search([]) + menu_items = Menus.read(menu_ids, ['name', 'sequence', 'parent_id']) menu_root = {'id': False, 'name': 'root', 'parent_id': [-1, '']} menu_items.append(menu_root) + # make a tree using parent_id - for i in menu_items: - i['children'] = [] - d = dict([(i["id"], i) for i in menu_items]) - for i in menu_items: - if not i['parent_id']: - pid = False - else: - pid = i['parent_id'][0] - if pid in d: - d[pid]['children'].append(i) - # sort by sequence a tree using parent_id - for i in menu_items: - i['children'].sort(key=lambda x:x["sequence"]) + menu_items_map = dict((menu_item["id"], menu_item) for menu_item in menu_items) + for menu_item in menu_items: + if not menu_item['parent_id']: continue + parent = menu_item['parent_id'][0] + if parent in menu_items_map: + menu_items_map[parent].setdefault( + 'children', []).append(menu_item) - return {'data': menu_root} + # sort by sequence a tree using parent_id + for menu_item in menu_items: + menu_item.setdefault('children', []).sort( + key=lambda x:x["sequence"]) + + return menu_root @openerpweb.jsonrequest def action(self, req, menu_id): diff --git a/addons/base/tests/__init__.py b/addons/base/tests/__init__.py new file mode 100644 index 00000000000..40a96afc6ff --- /dev/null +++ b/addons/base/tests/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/addons/base/tests/test_menu.py b/addons/base/tests/test_menu.py new file mode 100644 index 00000000000..1d1e49b6c97 --- /dev/null +++ b/addons/base/tests/test_menu.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +import mock +import unittest2 +import base.controllers.main +import openerpweb.openerpweb + +class Placeholder(object): + def __init__(self, **kwargs): + for k, v in kwargs.iteritems(): + setattr(self, k, v) + +class LoadTest(unittest2.TestCase): + def setUp(self): + self.menu = base.controllers.main.Menu() + self.menus_mock = mock.Mock() + self.request = Placeholder( + session=openerpweb.openerpweb.OpenERPSession( + model_factory=lambda _session, _name: self.menus_mock)) + + def tearDown(self): + del self.request + del self.menus_mock + del self.menu + + def test_empty(self): + self.menus_mock.search = mock.Mock(return_value=[]) + self.menus_mock.read = mock.Mock(return_value=[]) + + root = self.menu.do_load(self.request) + + self.menus_mock.search.assert_called_with([]) + self.menus_mock.read.assert_called_with( + [], ['name', 'sequence', 'parent_id']) + + self.assertListEqual( + root['children'], + []) + + def test_applications_sort(self): + self.menus_mock.search = mock.Mock(return_value=[1, 2, 3]) + self.menus_mock.read = mock.Mock(return_value=[ + {'id': 2, 'sequence': 3, 'parent_id': [False, '']}, + {'id': 3, 'sequence': 2, 'parent_id': [False, '']}, + {'id': 1, 'sequence': 1, 'parent_id': [False, '']}, + ]) + + root = self.menu.do_load(self.request) + + self.menus_mock.read.assert_called_with( + [1, 2, 3], ['name', 'sequence', 'parent_id']) + + self.assertEqual( + root['children'], + [{ + 'id': 1, 'sequence': 1, + 'parent_id': [False, ''], 'children': [] + }, { + 'id': 3, 'sequence': 2, + 'parent_id': [False, ''], 'children': [] + }, { + 'id': 2, 'sequence': 3, + 'parent_id': [False, ''], 'children': [] + }]) + + def test_deep(self): + self.menus_mock.search = mock.Mock(return_value=[1, 2, 3, 4]) + self.menus_mock.read = mock.Mock(return_value=[ + {'id': 1, 'sequence': 1, 'parent_id': [False, '']}, + {'id': 2, 'sequence': 2, 'parent_id': [1, '']}, + {'id': 3, 'sequence': 1, 'parent_id': [2, '']}, + {'id': 4, 'sequence': 2, 'parent_id': [2, '']}, + ]) + + root = self.menu.do_load(self.request) + + self.assertEqual( + root['children'], + [{ + 'id': 1, + 'sequence': 1, + 'parent_id': [False, ''], + 'children': [{ + 'id': 2, + 'sequence': 2, + 'parent_id': [1, ''], + 'children': [{ + 'id': 3, + 'sequence': 1, + 'parent_id': [2, ''], + 'children': [] + }, { + 'id': 4, + 'sequence': 2, + 'parent_id': [2, ''], + 'children': [] + }] + }] + }] + ) diff --git a/doc/source/addon-structure.txt b/doc/source/addon-structure.txt new file mode 100644 index 00000000000..8e2824bc950 --- /dev/null +++ b/doc/source/addon-structure.txt @@ -0,0 +1,9 @@ + + +-- __openerp__.py + +-- controllers/ + +-- static/ + +-- openerp/ + +-- css/ + +-- img/ + +-- js/ + +-- tests/ diff --git a/doc/source/addons.rst b/doc/source/addons.rst index ad46dd2f6fe..976054bc47e 100644 --- a/doc/source/addons.rst +++ b/doc/source/addons.rst @@ -1,7 +1,91 @@ Developing OpenERP Web Addons ============================= -* Structure of an addon +Structure +--------- + +.. literalinclude:: addon-structure.txt + +``__openerp__.py`` + The addon's descriptor, contains the following information: + + ``name: str`` + The addon name, in plain, readable english + ``version: str`` + The addon version, following `Semantic Versioning`_ rules + ``depends: [str]`` + A list of addons this addon needs to work correctly. ``base`` is + an implied dependency if the list is empty. + ``css: [str]`` + An ordered list of CSS files this addon provides and needs. The + file paths are relative to the addon's root. Because the Web + Client *may* perform concatenations and other various + optimizations on CSS files, the order is important. + ``js: [str]`` + An ordered list of Javascript files this addon provides and needs + (including dependencies files). As with CSS files, the order is + important as the Web Client *may* perform contatenations and + minimizations of files. + ``active: bool`` + Whether this addon should be enabled by default any time it is + found, or whether it will be enabled through other means (on a + by-need or by-installation basis for instance). + +``controllers/`` + All of the Python controllers and JSON-RPC endpoints. + +``static/`` + The static files directory, may be served via a separate web server. + + The third-party dependencies should be bundled in it (each in their + own directory). + +``static/openerp/`` + Sub-tree for all the addon's own static files. + +``static/openerp/{css,js,img}`` + Location for (respectively) the addon's static CSS files, its JS + files and its various image resources. + +``tests/`` + The directories in which all tests for the addon are located. + +.. _addons-testing: + +Testing +------- + +Python +++++++ + +OpenERP Web uses unittest2_ for its testing needs. We selected +unittest2 rather than unittest_ for the following reasons: + +* autodiscovery_ (similar to nose, via the ``unit2`` + CLI utility) and `pluggable test discovery`_. + +* `new and improved assertions`_ (with improvements in type-specific + inequality reportings) including `pluggable custom types equality + assertions`_ + +* neveral new APIs, most notably `assertRaises context manager`_, + `cleanup function registration`_, `test skipping`_ and `class- and + module-level setup and teardown`_ + +* finally, unittest2 is a backport of Python 3's unittest. We might as + well get used to it. + +To run tests on addons (from the root directory of OpenERP Web) is as +simple as typing ``PYTHONPATH=. unit2 discover -s addons`` [#]_. To +test an addon which does not live in the ``addons`` directory, simply +replace ``addons`` by the directory in which your own addon lives. + +.. note:: unittest2 is entirely compatible with nose_ (or the + other way around). If you want to use nose as your test + runner (due to its addons for instance) you can simply install it + and run ``nosetests addons`` instead of the ``unit2`` command, + the result should be exactly the same. + * Addons lifecycle (loading, execution, events, ...) * Python-side @@ -16,3 +100,54 @@ Developing OpenERP Web Addons * Javascript public APIs * QWeb templates description? * OpenERP Web modules (from OpenERP modules) + +.. [#] the ``-s`` parameter tells ``unit2`` to start trying to + find tests in the provided directory (here we're testing + addons). However a side-effect of that is to set the + ``PYTHONPATH`` there as well, so it will fail to find (and + import) ``openerpweb``. + + The ``-t`` parameter lets us set the ``PYTHONPATH`` + independently, but it doesn't accept multiple values and here + we really want to have both ``.`` and ``addons`` on the + ``PYTHONPATH``. + + The solution is to set the ``PYTHONPATH`` to ``.`` on start, + and the ``start-directory`` to ``addons``. This results in a + correct ``PYTHONPATH`` within ``unit2``. + +.. _unittest: + http://docs.python.org/library/unittest.html + +.. _unittest2: + http://www.voidspace.org.uk/python/articles/unittest2.shtml + +.. _autodiscovery: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#test-discovery + +.. _pluggable test discovery: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#load-tests + +.. _new and improved assertions: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#new-assert-methods + +.. _pluggable custom types equality assertions: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#add-new-type-specific-functions + +.. _assertRaises context manager: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#assertraises + +.. _cleanup function registration: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#cleanup-functions-with-addcleanup + +.. _test skipping: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#test-skipping + +.. _class- and module-level setup and teardown: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#class-and-module-level-fixtures + +.. _Semantic Versioning: + http://semver.org/ + +.. _nose: + http://somethingaboutorange.com/mrl/projects/nose/1.0.0/ diff --git a/doc/source/development.rst b/doc/source/development.rst index aebf57c1086..a2be0ec2eef 100644 --- a/doc/source/development.rst +++ b/doc/source/development.rst @@ -6,4 +6,25 @@ Contributing to OpenERP Web * QWeb code documentation/description * Documentation of the OpenERP APIs and choices taken based on that? * Style guide and coding conventions (PEP8? More) -* Test frameworks for Python and JS? +* Test frameworks in JS? + +Testing +------- + +Python +++++++ + +Testing for the OpenERP Web core is similar to :ref:`testing addons +`: the tests live in ``openerpweb.tests``, unittest2_ +is the testing framework and tests can be run via either unittest2 +(``unit2 discover``) or via nose_ (``nosetests``). + +Tests for the OpenERP Web core can also be run using ``setup.py +test``. + + +.. _unittest2: + http://www.voidspace.org.uk/python/articles/unittest2.shtml + +.. _nose: + http://somethingaboutorange.com/mrl/projects/nose/1.0.0/ diff --git a/openerpweb/openerpweb.py b/openerpweb/openerpweb.py index 404616273fb..f966501f72c 100644 --- a/openerpweb/openerpweb.py +++ b/openerpweb/openerpweb.py @@ -39,13 +39,15 @@ class OpenERPModel(object): class OpenERPSession(object): - def __init__(self, server='127.0.0.1', port=8069): + def __init__(self, server='127.0.0.1', port=8069, + model_factory=OpenERPModel): self._server = server self._port = port self._db = False self._uid = False self._login = False self._password = False + self.model_factory = model_factory def proxy(self, service): s = xmlrpctimeout.TimeoutServerProxy('http://%s:%s/xmlrpc/%s' % (self._server, self._port, service), timeout=5) @@ -69,7 +71,7 @@ class OpenERPSession(object): return r def model(self, model): - return OpenERPModel(self, model) + return self.model_factory(self, model) #---------------------------------------------------------- # OpenERP Web RequestHandler diff --git a/openerpweb/tests/__init__.py b/openerpweb/tests/__init__.py new file mode 100644 index 00000000000..40a96afc6ff --- /dev/null +++ b/openerpweb/tests/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/openerpweb/tests/test_model.py b/openerpweb/tests/test_model.py new file mode 100644 index 00000000000..1d0d058b092 --- /dev/null +++ b/openerpweb/tests/test_model.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +import mock +import unittest2 +import openerpweb.openerpweb + +class OpenERPModelTest(unittest2.TestCase): + def test_rpc_call(self): + session = mock.Mock(['execute']) + Model = openerpweb.openerpweb.OpenERPModel( + session, 'a.b') + + Model.search([('field', 'op', 'value')], {'key': 'value'}) + session.execute.assert_called_once_with( + 'a.b', 'search', [('field', 'op', 'value')], {'key': 'value'}) + + session.execute.reset_mock() + + Model.read([42]) + session.execute.assert_called_once_with( + 'a.b', 'read', [42]) diff --git a/setup.py b/setup.py index 439fb77bcfd..da0dd56a294 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,11 @@ setup( "simplejson >= 2.0.9", "python-dateutil >= 1.4.1", ], + tests_require=[ + 'unittest2', + 'mock', + ], + test_suite = 'unittest2.collector', zip_safe=False, packages=[ 'addons',