[Checkins] SVN: Products.CMFDefault/trunk/Products/CMFDefault/browser/ Test modified but is incomplete anyway. Proper unit tests required.

Charlie Clark charlie at begeistert.org
Mon Jan 12 16:22:51 EST 2009


Log message for revision 94710:
  Test modified but is incomplete anyway. Proper unit tests required.
  I did not understand all the existing checks and validators clearly. I hope I have not broken too much with my interpretation.
  Batching is not yet implemented.

Changed:
  U   Products.CMFDefault/trunk/Products/CMFDefault/browser/configure.zcml
  A   Products.CMFDefault/trunk/Products/CMFDefault/browser/new_folder.py
  A   Products.CMFDefault/trunk/Products/CMFDefault/browser/templates/contents.pt
  A   Products.CMFDefault/trunk/Products/CMFDefault/browser/tests/folder_utest.txt
  A   Products.CMFDefault/trunk/Products/CMFDefault/browser/tests/test_folder.py

-=-
Modified: Products.CMFDefault/trunk/Products/CMFDefault/browser/configure.zcml
===================================================================
--- Products.CMFDefault/trunk/Products/CMFDefault/browser/configure.zcml	2009-01-12 20:30:51 UTC (rev 94709)
+++ Products.CMFDefault/trunk/Products/CMFDefault/browser/configure.zcml	2009-01-12 21:22:51 UTC (rev 94710)
@@ -31,7 +31,21 @@
 
   <adapter factory=".metadata.MinimalMetadataSchemaAdapter"/>
 
+  <utility
+      component=".new_folder.contents_delta_vocabulary"
+      name="cmf.contents delta vocabulary"
+      provides="zope.schema.interfaces.IVocabularyFactory"
+      />
+      
   <browser:page
+      for="Products.CMFCore.interfaces.IFolderish"
+      layer="..interfaces.ICMFDefaultSkin"
+      name="contents.html"
+      class=".new_folder.ContentsView"
+      permission="cmf.ListFolderContents"
+      />
+
+  <browser:page
       for="Products.CMFCore.interfaces.IMutableMinimalDublinCore"
       layer="..interfaces.ICMFDefaultSkin"
       name="properties.html"

Added: Products.CMFDefault/trunk/Products/CMFDefault/browser/new_folder.py
===================================================================
--- Products.CMFDefault/trunk/Products/CMFDefault/browser/new_folder.py	                        (rev 0)
+++ Products.CMFDefault/trunk/Products/CMFDefault/browser/new_folder.py	2009-01-12 21:22:51 UTC (rev 94710)
@@ -0,0 +1,413 @@
+import urllib
+
+from DocumentTemplate import sequence
+
+from zope.interface import Interface, directlyProvides
+from zope import schema
+from zope.schema import Bool, TextLine, Int, Choice
+from zope.schema.vocabulary import SimpleTerm, SimpleVocabulary
+
+from zope.formlib import form
+
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+
+from Products.CMFCore.interfaces import IDynamicType
+
+from Products.CMFDefault.exceptions import CopyError
+from Products.CMFDefault.exceptions import zExceptions_Unauthorized
+from Products.CMFDefault.permissions import ListFolderContents
+from Products.CMFDefault.permissions import ManageProperties
+from Products.CMFDefault.formlib.form import ContentEditFormBase
+from Products.CMFDefault.utils import Message as _
+
+from utils import ViewBase
+from utils import decode
+from utils import memoize
+
+def contents_delta_vocabulary(context):
+    """Vocabulary for the pulldown for moving objects up
+    and down."""
+    length = len(context.contentIds())
+    deltas = [SimpleTerm(str(i), str(i), str(i)) 
+            for i in range(1, min(5, length)) + range(5, length, 5)]
+    return SimpleVocabulary(deltas)
+
+class IFolderItem(Interface):
+    """Schema for folderish objects contents."""
+    
+    select = Bool(
+        required=False)
+        
+    name = TextLine(
+        title=u"Name",
+        required=False,
+        readonly=True)
+
+class IDeltaItem(Interface):
+    """Schema for delta"""    
+    delta = Choice(
+        title=u"By",
+        description=u"Move an object up or down the chosen number of places.",
+        required=False,
+        vocabulary=u'cmf.contents delta vocabulary',
+        default=u'1')
+        
+
+class ContentsView(ContentEditFormBase):
+    """Folder contents view"""
+    
+    template = ViewPageTemplateFile('templates/contents.pt')
+    
+    object_actions = form.Actions(
+        form.Action(
+            name='rename',
+            label=_(u'Rename'),
+            validator='validate_items',
+            condition='has_subobjects',
+            success='handle_rename'),
+        form.Action(
+            name='cut',
+            label=_(u'Cut'),
+            condition='has_subobjects',
+            validator='validate_items',
+            success='handle_cut'),
+        form.Action(
+            name='copy',
+            label=_(u'Copy'),
+            condition='has_subobjects',
+            validator='validate_items',
+            success='handle_copy'),
+        form.Action(
+            name='paste',
+            label=_(u'Paste'),
+            condition='check_clipboard_data',
+            success='handle_paste'),
+        form.Action(
+            name='delete',
+            label=_(u'Delete'),
+            condition='has_subobjects',
+            validator='validate_items',
+            success='handle_delete')
+            )
+            
+    delta_actions = form.Actions(
+        form.Action(
+            name='up',
+            label=_(u'Up'),
+            condition='is_orderable',
+            validator='validate_items',
+            success='handle_up'),
+        form.Action(
+            name='down',
+            label=_(u'Down'),
+            condition='is_orderable',
+            validator='validate_items',
+            success='handle_down')
+            )
+            
+    absolute_actions = form.Actions(
+        form.Action(
+            name='top',
+            label=_(u'Top'),
+            condition='is_orderable',
+            validator='validate_items',
+            success='handle_top'),
+        form.Action(
+            name='bottom',
+            label=_(u'Bottom'),
+            condition='is_orderable',
+            validator='validate_items',
+            success='handle_bottom')
+            )
+
+    sort_actions = form.Actions(
+        form.Action(
+            name='sort_order',
+            label=_(u'Set as Default Sort'),
+            condition='can_sort_be_changed',
+            validator='validate_items',
+            success='handle_top')
+            )
+            
+    actions = object_actions + delta_actions + absolute_actions + sort_actions
+    
+    errors = ()
+    
+    def __init__(self, *args, **kw):
+        super(ContentsView, self).__init__(*args, **kw)
+        self.form_fields = form.FormFields()
+        self.delta_field = form.FormFields(IDeltaItem)
+        self.contents = self.context.contentValues()
+
+        for item in self.contents:
+            for n, f in schema.getFieldsInOrder(IFolderItem):
+                field = form.FormField(f, n, item.id)
+                self.form_fields += form.FormFields(field)
+          
+    @memoize
+    @decode
+    def up_info(self):
+        """Link to the contens view of the parent object"""
+        up_obj = self.context.aq_inner.aq_parent
+        mtool = self._getTool('portal_membership')
+        allowed = mtool.checkPermission(ListFolderContents, up_obj)
+        if allowed:
+            if IDynamicType.providedBy(up_obj):
+                up_url = up_obj.getActionInfo('object/folderContents')['url']
+                return {'icon': '%s/UpFolder_icon.gif' % self._getPortalURL(),
+                        'id': up_obj.getId(),
+                        'url': up_url}
+            else:
+                return {'icon': '',
+                        'id': 'Root',
+                        'url': ''}
+        else:
+            return {}
+        
+    def setUpWidgets(self, ignore_request=False):
+        """Create widgets for the folder contents."""
+        data = {}
+        for i in self.contents:
+            data['%s.name' %i.id] = i.getId()
+        self.widgets = form.setUpDataWidgets(
+                self.form_fields, self.prefix, self.context,
+                self.request, data=data, ignore_request=ignore_request)
+        self.widgets += form.setUpDataWidgets(self.delta_field, self.prefix,
+                        self.context, self.request, ignore_request=ignore_request)
+                
+    def _get_sorting(self):
+        """How should the contents be sorted"""
+        key = self.request.form.get('key', None)
+        if key:
+            return (key, self.request.form.get('reverse', 0))
+        else:
+            return self.context.getDefaultSorting()
+    
+    def column_headings(self):
+        (key, reverse) = self._get_sorting()
+        columns = ( {'key': 'Type',
+                     'title': _(u'Type'),
+                     'colspan': '2'}
+                  , {'key': 'getId',
+                     'title': _(u'Name')}
+                  , {'key': 'modified',
+                     'title': _(u'Last Modified')}
+                  , {'key': 'position',
+                     'title': _(u'Position')}
+                  )
+        for column in columns:
+            if key == column['key'] and not reverse and key != 'position':
+                query = urllib.urlencode({'key':column['key'], 'reverse':1})
+            else:
+                query = urllib.urlencode({'key':column['key']})
+            column['url'] = '%s?%s' % (self._getViewURL(), query)
+        return tuple(columns)
+        
+    def _get_items(self):
+        (key, reverse) = self._get_sorting()
+        items = self.contents
+        return sequence.sort(items,
+                             ((key, 'cmp', reverse and 'desc' or 'asc'),))
+    
+    def layout_fields(self):
+        """Return the widgets for the form in the interface field order"""
+        fields = []
+
+        for item in self._get_items():
+            field = {'ModificationDate':item.ModificationDate()}
+            field['select'] = self.widgets['%s.select' % item.getId()]
+            field['name'] = self.widgets['%s.name' % item.getId()]
+            field['url'] = item.absolute_url()
+            field['title'] = item.TitleOrId()
+            field['icon'] = item.icon
+            field['position'] = self.context.contentIds().index(item.getId()) + 1
+            field['type'] = item.Type() or None
+            fields.append(field.copy())
+        return fields
+                
+    def _get_ids(self, data):
+        """Strip prefixes from ids that have been selected"""
+        ids = [k.split(".")[0] for k, v in data.items() if v == True]
+        return ids
+        
+    
+    #Action conditions
+    @memoize
+    def has_subobjects(self, action=None):
+        """Return false if the user cannot rename subobjects"""
+        return bool(self.contents)
+    
+    @memoize
+    def check_clipboard_data(self, action=None):
+        """Any data in the clipboard"""
+        return bool(self.context.cb_dataValid())
+    
+    @memoize
+    def can_sort_be_changed(self, action=None):
+        """Returns true if the default sort key may be changed 
+            may be sorted for display"""
+        items_move_allowed = self._checkPermission(ManageProperties)
+        return items_move_allowed and not \
+            self._get_sorting() == self.context.getDefaultSorting()
+
+    @memoize
+    def is_orderable(self, action=None):
+        """Returns true if the displayed contents can be
+            reorded."""
+        (key, reverse) = self._get_sorting()        
+        return key == 'position' and len(self.contents) > 1
+
+    #Actions validators
+    def validate_items(self, action=None, data=None):
+        """Check whether any items have been selected for 
+        the requested action."""
+        if data is None:
+            data = {}
+        if len(self._get_ids(data)) == 0:
+            return [_(u"Please select one or more items first.")]
+        else:
+            return []
+            
+    #Action handlers
+    def handle_rename(self, action, data):
+        """Redirect to rename view passing the ids of objects to be renamed"""
+        return self._setRedirect('portal_types', 'object/rename_items')
+    
+    def handle_cut(self, action, data):
+        """Cut the selected objects and put them in the clipboard"""
+        ids = self._get_ids(data)
+        
+        try:
+            self.context.manage_cutObjects(ids, self.request)
+            if len(ids) == 1:
+                self.status = _(u'Item cut.')
+            else:
+                self.status = _(u'Items cut.')
+        except CopyError:
+            self.status = _(u'CopyError: Cut failed.')
+        except zExceptions_Unauthorized:
+            self.status = _(u'Unauthorized: Cut failed.')
+        return self._setRedirect('portal_types', 'object/folderContents')    
+
+    def handle_copy(self, action, data):
+        """Copy the selected objects to the clipboard"""
+        ids = self._get_ids(data)
+
+        try:
+            self.context.manage_copyObjects(ids, self.request)
+            if len(ids) == 1:
+                self.status = _(u'Item copied.')
+            else:
+                self.status = _(u'Items copied.')
+        except CopyError:
+            self.status = _(u'CopyError: Copy failed.')
+        return self._setRedirect('portal_types', 'object/new_contents')
+    
+    def handle_paste(self, action, data):
+        """Paste the objects from the clipboard into the folder"""
+        try:
+            result = self.context.manage_pasteObjects(self.request['__cp'])
+            if len(result) == 1:
+                self.status = _(u'Item pasted.')
+            else:
+                self.status = _(u'Items pasted.')
+        except CopyError, error:
+            self.status = _(u'CopyError: Paste failed.')
+            self.request['RESPONSE'].expireCookie('__cp', 
+                    path='%s' % (self.request['BASEPATH1'] or "/"))
+
+        except zExceptions_Unauthorized:
+            self.status = _(u'Unauthorized: Paste failed.')
+        return self._setRedirect('portal_types', 'object/new_contents')
+
+    def handle_delete(self, action, data):
+        """Delete the selected objects"""
+        ids = self._get_ids(data)
+        self.context.manage_delObjects(list(ids))
+        if len(ids) == 1:
+            self.status = _(u'Item deleted.')
+        else:
+            self.status = _(u'Items deleted.')
+        return self._setRedirect('portal_types', 'object/new_contents')
+    
+    def handle_up(self, action, data):
+        """Move the selected objects up the selected number of places"""
+        ids = self._get_ids(data)
+        delta = self.request.form.get('delta', 1)
+        subset_ids = [ obj.getId()
+                       for obj in self.context.listFolderContents() ]
+        try:
+            attempt = self.context.moveObjectsUp(ids, delta,
+                                                 subset_ids=subset_ids)
+            if attempt == 1:
+                self.status = _(u'Item moved up.')
+            elif attempt > 1:
+                self.status = _(u'Items moved up.')
+            else:
+                self.status = _(u'Nothing to change.')
+        except ValueError:
+            self.status = _(u'ValueError: Move failed.')
+        return self._setRedirect('portal_types', 'object/new_contents')
+
+    def handle_down(self, action, data):
+        """Move the selected objects down the selected number of places"""
+        ids = self._get_ids(data)
+        delta = self.request.form.get('delta', 1)
+        subset_ids = [ obj.getId()
+                       for obj in self.context.listFolderContents() ]
+        try:
+            attempt = self.context.moveObjectsDown(ids, delta,
+                                                 subset_ids=subset_ids)
+            if attempt == 1:
+                self.status = _(u'Item moved down.')
+            elif attempt > 1:
+                self.status = _(u'Items moved down.')
+            else:
+                self.status = _(u'Nothing to change.')
+        except ValueError:
+            self.status = _(u'ValueError: Move failed.')
+        return self._setRedirect('portal_types', 'object/new_contents')
+            
+    def handle_top(self, action, data):
+        """Move the selected objects to the top of the page"""
+        ids = self._get_ids(data)
+        subset_ids = [ obj.getId()
+                       for obj in self.context.listFolderContents() ]
+        try:
+            attempt = self.context.moveObjectsToTop(ids,
+                                                    subset_ids=subset_ids)
+            if attempt == 1:
+                self.status = _(u'Item moved to top.')
+            elif attempt > 1:
+                self.status = _(u'Items moved to top.')
+            else:
+                self.status = _(u'Nothing to change.')
+        except ValueError:
+            self.status = _(u'ValueError: Move failed.')
+        return self._setRedirect('portal_types', 'object/new_contents')
+
+    def handle_bottom(self, action, data):
+        """Move the selected objects to the bottom of the page"""
+        ids = self._get_ids(data)
+        subset_ids = [ obj.getId()
+                       for obj in self.context.listFolderContents() ]
+        try:
+            attempt = self.context.moveObjectsToBottom(ids,
+                                                       subset_ids=subset_ids)
+            if attempt == 1:
+                self.status = _(u'Item moved to bottom.')
+            elif attempt > 1:
+                self.status = _(u'Items moved to bottom.')
+            else:
+                self.status = _(u'Nothing to change.')
+        except ValueError:
+            self.status = _(u'ValueError: Move failed.')
+        return self._setRedirect('portal_types', 'object/new_contents')
+        
+    def handle_sort_order(self, action, data):
+        """Set the sort options for the folder."""
+        key = data['position']
+        reverse = data.get('reverse', 0)
+        self.context.setDefaultSorting(key, reverse)
+        self.status = _(u"Sort order changed")
+        return self._setRedirect('portal_types', 'object/new_contents')
\ No newline at end of file

Added: Products.CMFDefault/trunk/Products/CMFDefault/browser/templates/contents.pt
===================================================================
--- Products.CMFDefault/trunk/Products/CMFDefault/browser/templates/contents.pt	                        (rev 0)
+++ Products.CMFDefault/trunk/Products/CMFDefault/browser/templates/contents.pt	2009-01-12 21:22:51 UTC (rev 94710)
@@ -0,0 +1,77 @@
+<html metal:use-macro="context/@@standard_macros/page">
+<body>
+
+<metal:slot metal:fill-slot="body" i18n:domain="cmf_default">
+
+<p tal:define="up_info view/up_info" tal:condition="up_info"
+><tal:case tal:condition="up_info/url"
+ ><a href="" tal:attributes="href up_info/url"
+  ><img src="" alt="[Link]" border="0" tal:attributes="src up_info/icon"
+      i18n:attributes="alt" /></a>
+  <span tal:omit-tag="" i18n:translate="">Up to</span>
+  <a href="" tal:attributes="href up_info/url"
+     tal:content="up_info/id">ID</a></tal:case
+><tal:case tal:condition="not: up_info/url"
+ ><span class="mild" i18n:translate="">Root</span></tal:case></p>
+
+<ul class="errors" tal:condition="view/errors">
+ <li tal:repeat="error view/error_views"><tal:span
+     tal:replace="structure error" /></li>
+</ul>
+
+<p class="status" 
+  tal:condition="exists: request/portal_status_message"
+  tal:content="request/portal_status_message"></p>
+  
+<form class="form" action="." method="post" enctype="multipart/form-data"
+   tal:attributes="action request/ACTUAL_URL">
+   <table tal:condition="view/has_subobjects">
+     <tr>
+       <th tal:repeat="column view/column_headings"
+            tal:attributes="colspan column/colspan | nothing"><a href="column"
+         tal:content="column/title"
+         tal:attributes="href column/url"
+         >Column Title</a></th>
+     </tr>
+   <tr tal:repeat="item view/layout_fields" 
+      tal:attributes="class python: (repeat['item'].even() and 'row-hilite') or ''">
+     <td tal:content="structure item/select">Checkbox</td>
+     <td><a href="" tal:attributes="href item/url"
+         tal:condition="item/icon"
+      ><img src="" alt="" border="0"
+          tal:attributes="src item/icon; alt item/type"
+          i18n:attributes="alt" /></a></td>
+     <td><a tal:attributes="href string:${item/url}/edit.html" tal:content="string:${item/name} (${item/title})"></a></td>
+     <td tal:content="item/ModificationDate"></td>
+    <td tal:content="item/position"></td>
+   </tr>
+   </table>
+<div class="buttons">
+  <tal:loop tal:repeat="action view/object_actions" 
+   tal:replace="structure action/render" />
+</div>
+<div class="buttons">
+  <tal:loop tal:repeat="action view/delta_actions" 
+   tal:replace="structure action/render" />
+   <div tal:condition="view/is_orderable"
+        tal:define="widget python:view.widgets.get('delta');
+                    hint widget/hint | nothing">
+     <label tal:attributes="for widget/name; title python: hint or None"
+       tal:content="widget/label">Move By</label>
+     <tal:block tal:replace="structure view/widgets/delta" />
+   </div>
+</div>
+<div class="buttons">
+  <tal:loop tal:repeat="action view/absolute_actions" 
+   tal:replace="structure action/render" />
+</div>
+<div class="buttons">
+  <tal:loop tal:repeat="action view/sort_actions" 
+   tal:replace="structure action/render" />
+</div>
+</form>
+
+</metal:slot>
+
+</body>
+</html>

Added: Products.CMFDefault/trunk/Products/CMFDefault/browser/tests/folder_utest.txt
===================================================================
--- Products.CMFDefault/trunk/Products/CMFDefault/browser/tests/folder_utest.txt	                        (rev 0)
+++ Products.CMFDefault/trunk/Products/CMFDefault/browser/tests/folder_utest.txt	2009-01-12 21:22:51 UTC (rev 94710)
@@ -0,0 +1,97 @@
+Browser Views for IFolderish
+
+
+  The required environment:
+
+    Setting up a dummy site with required tools::
+
+      >>> from Products.CMFCore.tests.base.dummy import DummySite
+      >>> site = DummySite('site')
+
+      >>> from Products.CMFCore.tests.base.dummy import DummyTool
+      >>> from zope.component import getSiteManager
+      >>> from Products.CMFCore.interfaces import IPropertiesTool
+      >>> sm = getSiteManager()
+      >>> mtool = site._setObject('portal_membership', DummyTool())
+      >>> ptool = site._setObject('portal_properties', DummyTool())
+      >>> sm.registerUtility(ptool, IPropertiesTool)
+      >>> ttool = site._setObject('portal_types', DummyTool())
+      >>> utool = site._setObject('portal_url', DummyTool())
+
+
+  Basic functionality without security setup:
+
+    Setting up a simple request and an empty context object::
+
+      >>> class DummyRequest(dict):
+      ...     def __init__(self):
+      ...         self['ACTUAL_URL'] = 'actual_url'
+      ...         self.form = {}
+      >>> request = DummyRequest()
+
+      >>> from Products.CMFCore.PortalFolder import PortalFolder
+      >>> context = PortalFolder('foo').__of__(site)
+
+    The FolderView interface used by templates::
+
+      >>> from Products.CMFDefault.browser.folder import FolderView
+      >>> view = FolderView(context, request)
+
+      >>> view.title()
+      u''
+
+      >>> view.description()
+      u''
+
+      >>> view.has_local()
+      False
+
+    The ContentsView interface used by templates::
+
+      >>> from Products.CMFDefault.browser.new_folder import ContentsView
+      >>> view = ContentsView(context, request)
+
+      >>> view.title()
+      u''
+
+      >>> view.description
+      u''
+
+      >>> view.up_info()
+      {'url': u'', 'id': u'Root', 'icon': u''}
+
+      >>> view.column_headings()
+      ({'url': 'actual_url?key=Type', 'colspan': '2',
+        'key': 'Type', 'title': u'Type'},
+       {'url': 'actual_url?key=getId', 'key': 'getId', 'title': u'Name'},
+       {'url': 'actual_url?key=modified', 'key': 'modified', 'title': u'Last Modified'},
+       {'url': 'actual_url?key=position', 'key': 'position', 'title': u'Position'})
+
+      >>> view.layout_fields()
+      []
+
+      >>> view.is_orderable()
+      False
+
+      >>> view.can_sort_be_changed()
+      False
+
+    The ContentsView actions checkers:
+
+      >>> view.has_subobjects()
+      False
+
+      >>> view.check_clipboard_data()
+      False
+
+    The ContentsView has one validator:
+      >>> view.validate_items()
+      [u'Please select one or more items first.']
+
+      >>> view.validate_items(data={'foo':True})
+      []
+
+    Finally we have to clean up::
+
+      >>> from zope.testing.cleanup import cleanUp
+      >>> cleanUp()

Added: Products.CMFDefault/trunk/Products/CMFDefault/browser/tests/test_folder.py
===================================================================
--- Products.CMFDefault/trunk/Products/CMFDefault/browser/tests/test_folder.py	                        (rev 0)
+++ Products.CMFDefault/trunk/Products/CMFDefault/browser/tests/test_folder.py	2009-01-12 21:22:51 UTC (rev 94710)
@@ -0,0 +1,33 @@
+##############################################################################
+#
+# Copyright (c) 2006 Zope Corporation and Contributors. All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""CMFDefault browser tests.
+
+$Id: tests.py 92781 2008-11-04 17:43:00Z yuppie $
+"""
+
+import unittest
+from Testing import ZopeTestCase
+from zope.testing import doctest
+
+from Products.CMFDefault.testing import FunctionalLayer
+
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(doctest.DocFileSuite('folder_utest.txt',
+                                    optionflags=doctest.NORMALIZE_WHITESPACE))
+    return suite
+
+if __name__ == '__main__':
+    from Products.CMFCore.testing import run
+    run(test_suite())



More information about the Checkins mailing list