Repository: nvaccess/nvda

Issue:
Easier method for documenting and setting gestures for global plugin scripts
I propose and will implement the following:

``` python
class GlobalPlugin(GlobalPlugin):

    @document(__("""Short description in gesture editor"""))
    @gesture("kb:NVDA+shift+g")
    def script_do_something(self, gesture):
        pass #script contents here
```

Easier method for documenting and setting gestures for global plugin scripts
I propose and will implement the following:

``` python
class GlobalPlugin(GlobalPlugin):

    @document(__("""Short description in gesture editor"""))
    @gesture("kb:NVDA+shift+g")
    def script_do_something(self, gesture):
        pass #script contents here
```



Discussion:
CC @jcsteh, @camlorn, @derekriemer, @nvdaes

In general, I like this idea. A few comments:
1. Tiny correction: NVDA doesn't do lazy gettext at this stage, so `__(` should just be `_(`.
2. We'd want support for multiple gestures. Perhaps it could take a list. If you want, it could take either a list or a string to make it easier for the majority case, though that does make it difficult to know whether to use gesture/gestures (singular/plural).
3. Any reason not to combine this into one decorator?
   
   ```
     # Translators: Describes a command.
     @script(doc=_("Short desc"),
         gestures=["kb:NVDA+shift+g"])
     def script_foo(self, gesture):
         ...
   ```
4. This should not be specific to global plugins. If you implement it on baseObject.ScriptableObject, it should work everywhere (tm).

I'm wondering if this can cover the assignment of sequential gestures
too, for instance: kb:NVDA`+g c, kb:NVDA+g d, as done for some add-ons
currently.
Also, should be script categories cover too? Thanks.

2016-08-16 2:55 GMT+02:00, James Teh notifications@github.com:

> In general, I like this idea. A few comments:
> 1. Tiny correction: NVDA doesn't do lazy gettext at this stage, so `__(`
>    should just be `_(`.
> 2. We'd want support for multiple gestures. Perhaps it could take a list. If
>    you want, it could take either a list or a string to make it easier for the
>    majority case, though that does make it difficult to know whether to use
>    gesture/gestures (singular/plural).
> 3. Any reason not to combine this into one decorator?
>    
>    ```
>    # Translators: Describes a command.
>    @script(doc=_("Short desc"),
>        gestures=["kb:NVDA+shift+g"])
>    def script_foo(self, gesture):
>        ...
>    ```
> 4. This should not be specific to global plugins. If you implement it on
>    baseObject.ScriptableObject, it should work everywhere (tm).
> 
> ## 
> 
> You are receiving this because you were mentioned.
> Reply to this email directly or view it on GitHub:
> https://github.com/nvaccess/nvda/issues/6266#issuecomment-239973194

I think it should also work on plane old functions.

I think that you can detect the difference with isinstance. As I discussed on IRC we should really move toward some sort of simpler, class-free, single-file script API.  Unfortunately, I'd be willing to bet this needs some sort of changes in core to work.

There are very good reasons I don't think a non-OO model is going to 
work for us. However, that is off topic for this issue. Let's not scope 
bloat.

> I'm wondering if this can cover the assignment of sequential gestures
> too, for instance: kb:NVDA`+g c, kb:NVDA+g d, as done for some add-ons
> currently.

This is a different issue: #3687. Note that if that gets implemented, this should just work for the decorator being discussed here too (or at least it should be fairly easy to tweak).

> Also, should be script categories cover too?

Definitely. if we go with the `@script` decorator, it can just be an additional keyword argument (`category=SCRCAT_FOO`).

Currently categories appear to be set on the class level, So if you were 
to define multiple @scripts it would set the last one as the category, 
assuming we don't instead move the category attribute to the function 
itself.
Should I leave this as is? Modify inputCore to check the function for a 
scriptCategory attribute before the class? Something else entirely?

On 8/17/2016 4:26 PM, James Teh wrote:

> ```
> I'm wondering if this can cover the assignment of sequential gestures
> too, for instance: kb:NVDA`+g c, kb:NVDA+g d, as done for some add-ons
> currently.
> ```
> 
> This is a different issue: #3687 
> https://github.com/nvaccess/nvda/issues/3687. Note that if that gets 
> implemented, this should just work for the decorator being discussed 
> here too (or at least it should be fairly easy to tweak).
> 
> ```
> Also, should be script categories cover too?
> ```
> 
> Definitely. if we go with the |@script| decorator, it can just be an 
> additional keyword argument (|category=SCRCAT_FOO|).
> 
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub 
> https://github.com/nvaccess/nvda/issues/6266#issuecomment-240568434, 
> or mute the thread 
> https://github.com/notifications/unsubscribe-auth/AAOcCHDSzgMReMHOlGFDthrmJ1sLjMJAks5qg4qhgaJpZM4Jk39r.

## 

```
    -Q

Accessible Software for the Blind
```

http://GetAccessibleApps.com

There are two levels of category: class and method. For a method, there 
is a .category attribute. See globalCommands for an example of where 
this is used. The class category is used as the default if a method 
doesn't explicitly specify a category.

Woof. I just grepped for scriptCategory assuming it would be the same 
attribute everywhere. @script will just set the .category of the method 
then. I should have enough to make this happen now.

On 8/17/2016 4:33 PM, James Teh wrote:

> There are two levels of category: class and method. For a method, there
> is a .category attribute. See globalCommands for an example of where
> this is used. The class category is used as the default if a method
> doesn't explicitly specify a category.
> 
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub 
> https://github.com/nvaccess/nvda/issues/6266#issuecomment-240569800, 
> or mute the thread 
> https://github.com/notifications/unsubscribe-auth/AAOcCFUyZREW70yIYMNBWsM1FJ6M0vi9ks5qg4wrgaJpZM4Jk39r.

## 

```
    -Q

Accessible Software for the Blind
```

http://GetAccessibleApps.com

Thanks.

2016-08-18 0:26 GMT+02:00, James Teh notifications@github.com:

> > I'm wondering if this can cover the assignment of sequential gestures
> > too, for instance: kb:NVDA`+g c, kb:NVDA+g d, as done for some add-ons
> > currently.
> 
> This is a different issue: #3687. Note that if that gets implemented, this
> should just work for the decorator being discussed here too (or at least it
> should be fairly easy to tweak).
> 
> > Also, should be script categories cover too?
> 
> Definitely. if we go with the `@script` decorator, it can just be an
> additional keyword argument (`category=SCRCAT_FOO`).
> 
> ## 
> 
> You are receiving this because you were mentioned.
> Reply to this email directly or view it on GitHub:
> https://github.com/nvaccess/nvda/issues/6266#issuecomment-240568434

CC @jcsteh, @camlorn, @derekriemer, @nvdaes

In general, I like this idea. A few comments:
1. Tiny correction: NVDA doesn't do lazy gettext at this stage, so `__(` should just be `_(`.
2. We'd want support for multiple gestures. Perhaps it could take a list. If you want, it could take either a list or a string to make it easier for the majority case, though that does make it difficult to know whether to use gesture/gestures (singular/plural).
3. Any reason not to combine this into one decorator?
   
   ```
     # Translators: Describes a command.
     @script(doc=_("Short desc"),
         gestures=["kb:NVDA+shift+g"])
     def script_foo(self, gesture):
         ...
   ```
4. This should not be specific to global plugins. If you implement it on baseObject.ScriptableObject, it should work everywhere (tm).

I'm wondering if this can cover the assignment of sequential gestures
too, for instance: kb:NVDA`+g c, kb:NVDA+g d, as done for some add-ons
currently.
Also, should be script categories cover too? Thanks.

2016-08-16 2:55 GMT+02:00, James Teh notifications@github.com:

> In general, I like this idea. A few comments:
> 1. Tiny correction: NVDA doesn't do lazy gettext at this stage, so `__(`
>    should just be `_(`.
> 2. We'd want support for multiple gestures. Perhaps it could take a list. If
>    you want, it could take either a list or a string to make it easier for the
>    majority case, though that does make it difficult to know whether to use
>    gesture/gestures (singular/plural).
> 3. Any reason not to combine this into one decorator?
>    
>    ```
>    # Translators: Describes a command.
>    @script(doc=_("Short desc"),
>        gestures=["kb:NVDA+shift+g"])
>    def script_foo(self, gesture):
>        ...
>    ```
> 4. This should not be specific to global plugins. If you implement it on
>    baseObject.ScriptableObject, it should work everywhere (tm).
> 
> ## 
> 
> You are receiving this because you were mentioned.
> Reply to this email directly or view it on GitHub:
> https://github.com/nvaccess/nvda/issues/6266#issuecomment-239973194

I think it should also work on plane old functions.

I think that you can detect the difference with isinstance. As I discussed on IRC we should really move toward some sort of simpler, class-free, single-file script API.  Unfortunately, I'd be willing to bet this needs some sort of changes in core to work.

There are very good reasons I don't think a non-OO model is going to 
work for us. However, that is off topic for this issue. Let's not scope 
bloat.

> I'm wondering if this can cover the assignment of sequential gestures
> too, for instance: kb:NVDA`+g c, kb:NVDA+g d, as done for some add-ons
> currently.

This is a different issue: #3687. Note that if that gets implemented, this should just work for the decorator being discussed here too (or at least it should be fairly easy to tweak).

> Also, should be script categories cover too?

Definitely. if we go with the `@script` decorator, it can just be an additional keyword argument (`category=SCRCAT_FOO`).

Currently categories appear to be set on the class level, So if you were 
to define multiple @scripts it would set the last one as the category, 
assuming we don't instead move the category attribute to the function 
itself.
Should I leave this as is? Modify inputCore to check the function for a 
scriptCategory attribute before the class? Something else entirely?

On 8/17/2016 4:26 PM, James Teh wrote:

> ```
> I'm wondering if this can cover the assignment of sequential gestures
> too, for instance: kb:NVDA`+g c, kb:NVDA+g d, as done for some add-ons
> currently.
> ```
> 
> This is a different issue: #3687 
> https://github.com/nvaccess/nvda/issues/3687. Note that if that gets 
> implemented, this should just work for the decorator being discussed 
> here too (or at least it should be fairly easy to tweak).
> 
> ```
> Also, should be script categories cover too?
> ```
> 
> Definitely. if we go with the |@script| decorator, it can just be an 
> additional keyword argument (|category=SCRCAT_FOO|).
> 
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub 
> https://github.com/nvaccess/nvda/issues/6266#issuecomment-240568434, 
> or mute the thread 
> https://github.com/notifications/unsubscribe-auth/AAOcCHDSzgMReMHOlGFDthrmJ1sLjMJAks5qg4qhgaJpZM4Jk39r.

## 

```
    -Q

Accessible Software for the Blind
```

http://GetAccessibleApps.com

There are two levels of category: class and method. For a method, there 
is a .category attribute. See globalCommands for an example of where 
this is used. The class category is used as the default if a method 
doesn't explicitly specify a category.

Woof. I just grepped for scriptCategory assuming it would be the same 
attribute everywhere. @script will just set the .category of the method 
then. I should have enough to make this happen now.

On 8/17/2016 4:33 PM, James Teh wrote:

> There are two levels of category: class and method. For a method, there
> is a .category attribute. See globalCommands for an example of where
> this is used. The class category is used as the default if a method
> doesn't explicitly specify a category.
> 
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub 
> https://github.com/nvaccess/nvda/issues/6266#issuecomment-240569800, 
> or mute the thread 
> https://github.com/notifications/unsubscribe-auth/AAOcCFUyZREW70yIYMNBWsM1FJ6M0vi9ks5qg4wrgaJpZM4Jk39r.

## 

```
    -Q

Accessible Software for the Blind
```

http://GetAccessibleApps.com

Thanks.

2016-08-18 0:26 GMT+02:00, James Teh notifications@github.com:

> > I'm wondering if this can cover the assignment of sequential gestures
> > too, for instance: kb:NVDA`+g c, kb:NVDA+g d, as done for some add-ons
> > currently.
> 
> This is a different issue: #3687. Note that if that gets implemented, this
> should just work for the decorator being discussed here too (or at least it
> should be fairly easy to tweak).
> 
> > Also, should be script categories cover too?
> 
> Definitely. if we go with the `@script` decorator, it can just be an
> additional keyword argument (`category=SCRCAT_FOO`).
> 
> ## 
> 
> You are receiving this because you were mentioned.
> Reply to this email directly or view it on GitHub:
> https://github.com/nvaccess/nvda/issues/6266#issuecomment-240568434


Resolving patch:
diff --git a/source/NVDAObjects/window/excel.py b/source/NVDAObjects/window/excel.py
--- a/source/NVDAObjects/window/excel.py
+++ b/source/NVDAObjects/window/excel.py
@@ -857,8 +857,6 @@ def __init__(self,windowHandle=None,excelWindowObject=None,excelWorksheetObject=
 		self.excelWindowObject=excelWindowObject
 		self.excelWorksheetObject=excelWorksheetObject
 		super(ExcelWorksheet,self).__init__(windowHandle=windowHandle)
-		for gesture in self.__changeSelectionGestures:
-			self.bindGesture(gesture, "changeSelection")
 
 	def _get_name(self):
 		return self.excelWorksheetObject.name
@@ -878,32 +876,7 @@ def _get_states(self):
 			states.add(controlTypes.STATE_PROTECTED)
 		return states
 
-	def script_changeSelection(self,gesture):
-		oldSelection=api.getFocusObject()
-		gesture.send()
-		import eventHandler
-		import time
-		newSelection=None
-		curTime=startTime=time.time()
-		while (curTime-startTime)<=0.15:
-			if scriptHandler.isScriptWaiting():
-				# Prevent lag if keys are pressed rapidly
-				return
-			if eventHandler.isPendingEvents('gainFocus'):
-				return
-			newSelection=self._getSelection()
-			if newSelection and newSelection!=oldSelection:
-				break
-			api.processPendingEvents(processEventQueue=False)
-			time.sleep(0.015)
-			curTime=time.time()
-		if newSelection:
-			if oldSelection.parent==newSelection.parent:
-				newSelection.parent=oldSelection.parent
-			eventHandler.executeEvent('gainFocus',newSelection)
-	script_changeSelection.canPropagate=True
-
-	__changeSelectionGestures = (
+	@scriptHandler.script(gestures=(
 		"kb:tab",
 		"kb:shift+tab",
 		"kb:enter",
@@ -948,7 +921,30 @@ def script_changeSelection(self,gesture):
 		"kb:control+a",
 		"kb:control+v",
 		"kb:shift+f11",
-	)
+	), canPropagate=True)
+	def script_changeSelection(self,gesture):
+		oldSelection=api.getFocusObject()
+		gesture.send()
+		import eventHandler
+		import time
+		newSelection=None
+		curTime=startTime=time.time()
+		while (curTime-startTime)<=0.15:
+			if scriptHandler.isScriptWaiting():
+				# Prevent lag if keys are pressed rapidly
+				return
+			if eventHandler.isPendingEvents('gainFocus'):
+				return
+			newSelection=self._getSelection()
+			if newSelection and newSelection!=oldSelection:
+				break
+			api.processPendingEvents(processEventQueue=False)
+			time.sleep(0.015)
+			curTime=time.time()
+		if newSelection:
+			if oldSelection.parent==newSelection.parent:
+				newSelection.parent=oldSelection.parent
+			eventHandler.executeEvent('gainFocus',newSelection)
 
 class ExcelCellTextInfo(NVDAObjectTextInfo):
 
diff --git a/source/baseObject.py b/source/baseObject.py
--- a/source/baseObject.py
+++ b/source/baseObject.py
@@ -1,6 +1,6 @@
 #baseObject.py
 #A part of NonVisual Desktop Access (NVDA)
-#Copyright (C) 2007-2017 NV Access Limited
+#Copyright (C) 2007-2018 NV Access Limited, Christopher Toth, Babbage B.V.
 #This file is covered by the GNU General Public License.
 #See the file COPYING for more details.
 
@@ -128,6 +128,23 @@ def invalidateCaches(cls):
 		for instance in cls.__instances.keys():
 			instance.invalidateCache()
 
+class ScriptableType(AutoPropertyType):
+	"""A metaclass used for collecting and caching gestures on a ScriptableObject"""
+
+	def __new__(meta, name, bases, dict):
+		cls = super(ScriptableType, meta).__new__(meta, name, bases, dict)
+		gestures = getattr(cls, "_%s__gestures" % cls.__name__, {})
+		# Python 3 incompatible.
+		for name, script in dict.iteritems():
+			if not name.startswith('script_'):
+				continue
+			scriptName = name[len("script_"):]
+			if hasattr(script, 'gestures'):
+				for gesture in script.gestures:
+					gestures[gesture] = scriptName
+		setattr(cls, "_%s__gestures" % cls.__name__, gestures)
+		return cls
+
 class ScriptableObject(AutoPropertyObject):
 	"""A class that implements NVDA's scripting interface.
 	Input gestures are bound to scripts such that the script will be executed when the appropriate input gesture is received.
@@ -143,16 +160,24 @@ class ScriptableObject(AutoPropertyObject):
 	@type scriptCategory: basestring
 	"""
 
+	__metaclass__ = ScriptableType
+
 	def __init__(self):
 		#: Maps input gestures to script functions.
 		#: @type: dict
 		self._gestureMap = {}
 		# Bind gestures specified on the class.
+		# This includes gestures specified on decorated scripts.
+		# This does not include the gestures that are added when creating a DynamicNVDAObjectType.
 		for cls in reversed(self.__class__.__mro__):
 			try:
 				self.bindGestures(getattr(cls, "_%s__gestures" % cls.__name__))
 			except AttributeError:
 				pass
+			try:
+				self.bindGestures(cls._scriptDecoratorGestures)
+			except AttributeError:
+				pass
 		super(ScriptableObject, self).__init__()
 
 	def bindGesture(self, gestureIdentifier, scriptName):
diff --git a/source/scriptHandler.py b/source/scriptHandler.py
--- a/source/scriptHandler.py
+++ b/source/scriptHandler.py
@@ -231,3 +231,62 @@ def isCurrentScript(scriptFunc):
 		return False
 	return givenFunc==realFunc
 
+def script(
+	description="",
+	category=None,
+	gesture=None,
+	gestures=None,
+	canPropagate=False,
+	bypassInputHelp=False,
+	resumeSayAllMode=None
+):
+	"""Define metadata for a script.
+	This function is to be used as a decorator to set metadata used by the scripting system and gesture editor.
+	It can only decorate methods which name start swith "script_"
+	@param description: A short translatable description of the script to be used in the gesture editor, etc.
+	@type description: string 
+	@param category: The category of the script displayed in the gesture editor.
+	@type category: string
+	@param gesture: A gesture associated with this script.
+	@type gesture: string
+	@param gestures: A list of gestures associated with this script
+	@type gestures: list(string)
+	@param canPropagate: Whether this script should also apply when it belongs to a  focus ancestor object.
+	@type canPropagate: bool
+	@param bypassInputHelp: Whether this script should run when input help is active.
+	@type bypassInputHelp: bool
+	@param resumeSayAllMode: The say all mode that should be resumed when active before executing this script.
+		One of the C{sayAllHandler.CURSOR_*} constants.
+	@type resumeSayAllMode: int
+	"""
+	if gestures is None:
+		gestures = []
+	def script_decorator(decoratedScript):
+		# Scripts are unbound instance methods in python 2 and functions in python 3.
+		# Therefore, we use inspect.isroutine to check whether a script is either a function or instance method.
+		if not inspect.isroutine(decoratedScript):
+			log.warning(
+				"Using the script decorator is unsupported for %r" % decoratedScript,
+				stack_info=True
+			)
+			return decoratedScript
+		if not decoratedScript.__name__.startswith("script_"):
+			log.warning(
+				"Can't apply  script decorator to %r which name does not start with 'script_'" % decoratedScript.__name__,
+				stack_info=True
+			)
+			return decoratedScript
+		decoratedScript.__doc__ = description
+		if category is not None:
+			decoratedScript.category = category
+		if gesture is not None:
+			gestures.append(gesture)
+		if gestures:
+			decoratedScript.gestures = gestures
+		decoratedScript.canPropagate = canPropagate
+		decoratedScript.bypassInputHelp = bypassInputHelp
+		if resumeSayAllMode is not None:
+			decoratedScript.resumeSayAllMode = resumeSayAllMode
+		return decoratedScript
+	return script_decorator
+


Test patch:
diff --git a/tests/unit/test_baseObject.py b/tests/unit/test_baseObject.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_baseObject.py
@@ -0,0 +1,111 @@
+#tests/unit/test_baseObject.py
+#A part of NonVisual Desktop Access (NVDA)
+#This file is covered by the GNU General Public License.
+#See the file COPYING for more details.
+#Copyright (C) 2018 NV Access Limited, Babbage B.V.
+
+"""Unit tests for the baseObject module, its classes and their derivatives."""
+
+import unittest
+from baseObject import ScriptableObject
+from objectProvider import PlaceholderNVDAObject
+from scriptHandler import script
+
+class NVDAObjectWithDecoratedScript(PlaceholderNVDAObject):
+	"""An object with a decorated script."""
+
+	@script(gestures=["kb:a"])
+	def script_alpha(self, gesture):
+		return
+
+class NVDAObjectWithGesturesDictionary(PlaceholderNVDAObject):
+	"""An object with a script that is bound to a gesture in a L{__gestures} dictionary."""
+
+	def script_bravo(self, gesture):
+		return
+
+	__gestures = {
+		"kb:b": "bravo"
+	}
+
+class NVDAObjectWithDecoratedScriptAndGesturesDictionary(PlaceholderNVDAObject):
+	"""An object with a decorated script
+	and a script that is bound to a gesture in a L{__gestures} dictionary.
+	"""
+
+	@script(gestures=["kb:c"])
+	def script_charlie(self, gesture):
+		return
+
+	def script_delta(self, gesture):
+		return
+
+	__gestures = {
+		"kb:d": "delta",
+	}
+
+class SubclassedNVDAObjectWithDecoratedScriptAndGesturesDictionary(
+	NVDAObjectWithDecoratedScript,
+	NVDAObjectWithGesturesDictionary,
+	NVDAObjectWithDecoratedScriptAndGesturesDictionary
+):
+	"""An object with decorated scripts and L{__gestures} dictionaries, based on subclassing."""
+
+	@script(gestures=["kb:e"])
+	def script_echo(self, gesture):
+		return
+
+	def script_foxtrot(self, gesture):
+		return
+
+	__gestures = {
+		"kb:f": "foxtrot",
+	}
+
+class DynamicNVDAObjectWithDecoratedScriptAndGesturesDictionary(PlaceholderNVDAObject):
+	"""An object with decorated scripts and L{__gestures} dictionaries,
+	using the chooseOverlayClasses logic to construct a dynamic object."""
+
+	def findOverlayClasses(self, clsList):
+		clsList.extend([
+			NVDAObjectWithDecoratedScript,
+			NVDAObjectWithGesturesDictionary,
+			NVDAObjectWithDecoratedScriptAndGesturesDictionary
+		])
+
+	@script(gestures=["kb:g"])
+	def script_golf(self, gesture):
+		return
+
+	def script_hotel(self, gesture):
+		return
+
+	__gestures = {
+		"kb:h": "hotel",
+	}
+
+class TestScriptableObject(unittest.TestCase):
+	"""A test that verifies whether scripts are properly bound to associated gestures."""
+
+	def test_decoratedScript(self):
+		obj = NVDAObjectWithDecoratedScript()
+		self.assertIn("kb:a", obj._gestureMap)
+
+	def test_gesturesDictionary(self):
+		obj = NVDAObjectWithGesturesDictionary()
+		self.assertIn("kb:b", obj._gestureMap)
+
+	def test_decoratedScriptAndGesturesDictionary(self):
+		obj = NVDAObjectWithDecoratedScriptAndGesturesDictionary()
+		self.assertIn("kb:c", obj._gestureMap)
+		self.assertIn("kb:d", obj._gestureMap)
+
+	def test_decoratedScriptsAndGestureDictionariesIfSubclassed(self):
+		obj = SubclassedNVDAObjectWithDecoratedScriptAndGesturesDictionary()
+		for key in ("a", "b", "c", "d", "e", "f"):
+			self.assertIn("kb:%s" % key, obj._gestureMap)
+
+	def test_decoratedScriptsAndGestureDictionariesIfDynamic(self):
+		obj = DynamicNVDAObjectWithDecoratedScriptAndGesturesDictionary()
+		for key in ("a", "b", "c", "d", "g", "h"):
+			self.assertIn("kb:%s" % key, obj._gestureMap)
diff --git a/tests/unit/test_scriptHandler.py b/tests/unit/test_scriptHandler.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_scriptHandler.py
@@ -0,0 +1,35 @@
+#tests/unit/test_scriptHandler.py
+#A part of NonVisual Desktop Access (NVDA)
+#This file is covered by the GNU General Public License.
+#See the file COPYING for more details.
+#Copyright (C) 2018 NV Access Limited, Babbage B.V.
+
+"""Unit tests for the scriptHandler module."""
+
+import unittest
+from scriptHandler import *
+from inputCore import SCRCAT_MISC
+from sayAllHandler import CURSOR_CARET
+
+class TestScriptDecorator(unittest.TestCase):
+	"""A test that verifies the functionality of the L{scriptHandler.script} decorator."""
+
+	def test_scriptdecoration(self):
+		@script(
+			description="description",
+			category=SCRCAT_MISC,
+			gesture="kb:a",
+			gestures=["kb:b", "kb:c"],
+			canPropagate=True,
+			bypassInputHelp=True,
+			resumeSayAllMode=CURSOR_CARET
+		)
+		def script_test(self, gesture):
+			return
+
+		self.assertEqual(script_test.__doc__, "description")
+		self.assertEqual(script_test.category, SCRCAT_MISC)
+		self.assertItemsEqual(script_test.gestures, ["kb:a", "kb:b", "kb:c"])
+		self.assertTrue(script_test.canPropagate)
+		self.assertTrue(script_test.bypassInputHelp)
+		self.assertEqual(script_test.resumeSayAllMode, CURSOR_CARET)