Repository: ManiacalLabs/BiblioPixel (python)

Issue:
projects.py level global pre-processing of special values
Thanks to @haooken for pointing this out.

There are several special types of values that are settable inside a project by using a more raw representation but not by a nice, human friendly, value. We want projects to be human friendly.

For example, colors. Right now you have to enter [255, 0, 0] instead of colors.Red.

All of these special types internally are part of an enum or class so we can key off that.

Options are:

colors
LEDTYPE (this is a little weird becacuse there's two... serial and SPI)
Rotation
gamma

Probably more. Can't find them right now.
What I recommend is we register those types then look for the type followed by a period. If so, we try to resolve and replace that value inside the dictionary. 

Yes, this requires blindly walking the whole dictionary. But that's not really too hard.

This will make projects much cleaner to use and read.

Pull request description:
Special values in projects (fix #242).
All right!  Worked out quite well.  I sort of overengineered "parsing time", but I still like the code.

You can enter the values in human-readable form - like [this](https://github.com/rec/BiblioPixel/blob/special-values/test/project_test.py#L30-L37).

There's a very orderly way of doing the parsing, with modules containing single-dispatch functions - [like this](https://github.com/rec/BiblioPixel/blob/special-values/bibliopixel/project/types/gamma.py) to parse gamma arguments.

I realize now that I'm missing rotations, but I'm going to create an issue for this and assign it to @adammhaile just so that the knowledge of how to make these gets spread around... 

Patch:
diff --git a/bibliopixel/colors/__init__.py b/bibliopixel/colors/__init__.py
index a3cd331..f158124 100644
--- a/bibliopixel/colors/__init__.py
+++ b/bibliopixel/colors/__init__.py
@@ -196,3 +196,6 @@ MediumAquamarine = (102, 205, 170)
 Gold = (255, 215, 0)
 Orchid = (218, 112, 214)
 OldLace = (253, 245, 230)
+
+# COLORS is a dictionary of lowercase color names to color values.
+COLORS = {k.lower(): v for k, v in locals().items() if k.istitle()}
diff --git a/bibliopixel/drivers/dummy_driver.py b/bibliopixel/drivers/dummy_driver.py
index 34923fb..5501a9a 100644
--- a/bibliopixel/drivers/dummy_driver.py
+++ b/bibliopixel/drivers/dummy_driver.py
@@ -5,9 +5,14 @@ import time
 class Dummy(DriverBase):
     """For Testing: Provides no ouput, just a valid interface"""
 
-    def __init__(self, num, delay=0, **_):
-        """delay: time to wait in seconds to simulate actual hardware interface time"""
+    def __init__(self, num, delay=0, **kwds):
+        """
+        Args
+            delay: time to wait in seconds to simulate actual hardware
+            interface time
+        """
         super().__init__(num)
+        self._kwds = kwds
         self._delay = delay
 
     # Push new data to strand
diff --git a/bibliopixel/main/demo.py b/bibliopixel/main/demo.py
index c60a255..56e62fe 100644
--- a/bibliopixel/main/demo.py
+++ b/bibliopixel/main/demo.py
@@ -40,7 +40,7 @@ def make_runnable(demo, args):
             layout['z'] = layout['z'] or args.depth
 
     defaults = _run.get_project_default_arguments(args)
-    return project.project_to_runnable(demo, defaults)
+    return project.project_to_animation(project=demo, defaults=defaults).start
 
 
 def usage():
diff --git a/bibliopixel/main/run.py b/bibliopixel/main/run.py
index 974ca9e..e0e8e0b 100644
--- a/bibliopixel/main/run.py
+++ b/bibliopixel/main/run.py
@@ -1,5 +1,7 @@
+import json
 from .. project import project
 from .. import log
+from .. util import files
 
 HELP = """
 Run a project description file.
@@ -33,10 +35,20 @@ def get_project_default_arguments(args):
     }
 
 
+def project_to_animation(name, is_json, defaults):
+    data = name if is_json else files.opener(name).read()
+    try:
+        desc = json.loads(data)
+    except ValueError as e:
+        e.args += tuple(['in %s' % name])
+        raise
+    return project.project_to_animation(desc, defaults)
+
+
 def run(args, settings):
     if args.name:
         defaults = get_project_default_arguments(args)
-        project.run(args.name, not args.json, defaults)
+        project_to_animation(args.name, args.json, defaults).start()
 
 
 def set_parser(parser):
diff --git a/bibliopixel/project/importer.py b/bibliopixel/project/importer.py
index 8d34866..10787e9 100644
--- a/bibliopixel/project/importer.py
+++ b/bibliopixel/project/importer.py
@@ -1,3 +1,4 @@
+from .types import make
 from ..util import importer
 from distutils.version import LooseVersion
 
@@ -60,6 +61,11 @@ def import_symbol(typename, package=None):
         raise
 
 
-def make_object(*args, typename, package=None, **kwds):
+def make_object(*args, typename, package=None, field_types=None, **kwds):
     """Make an object from a symbol."""
-    return import_symbol(typename, package)(*args, **kwds)
+    symbol = import_symbol(typename, package)
+    if hasattr(symbol, 'FIELD_TYPES'):
+        field_types = symbol.FIELD_TYPES
+
+    kwds = make.component(kwds, field_types)
+    return symbol(*args, **kwds)
diff --git a/bibliopixel/project/project.py b/bibliopixel/project/project.py
index ef51eb6..1cd6a27 100644
--- a/bibliopixel/project/project.py
+++ b/bibliopixel/project/project.py
@@ -1,6 +1,6 @@
 import gitty, json, os, sys
-from . import defaults
-from . importer import make_object
+from . import defaults, importer
+from . types.defaults import FIELD_TYPES
 from .. animation import runner
 from .. import data_maker
 from .. layout.geometry import gen_matrix
@@ -9,7 +9,11 @@ from .. util import files
 from .. drivers.serial import codes
 
 
-def make_layout(driver, layout, maker=None):
+def _make_object(*args, field_types=FIELD_TYPES, **kwds):
+    return importer.make_object(*args, field_types=field_types, **kwds)
+
+
+def _make_layout(driver, layout, maker=None):
     maker = data_maker.Maker(**(maker or {}))
     drivers = []
 
@@ -18,7 +22,7 @@ def make_layout(driver, layout, maker=None):
 
         for id in device_ids:
             build.addRow(gen_matrix(width, height, serpentine=serpentine))
-            d = make_object(width=width, height=height, deviceID=id, **kwds)
+            d = _make_object(width=width, height=height, deviceID=id, **kwds)
             drivers.append(d)
 
         return build.map
@@ -27,29 +31,29 @@ def make_layout(driver, layout, maker=None):
         if multimap:
             return multi_drivers(**kwds)
 
-        drivers.append(make_object(**kwds))
+        drivers.append(_make_object(**kwds))
 
     coordMap = make_drivers(maker=maker, **driver)
-    return make_object(drivers, coordMap=coordMap, maker=maker, **layout)
+    return _make_object(drivers, coordMap=coordMap, maker=maker, **layout)
 
 
 def make_animation(layout, animation, run=None):
-    animation = make_object(layout, **animation)
+    animation = _make_object(layout, **animation)
     animation.set_runner(runner.Runner(**(run or {})))
     return animation
 
 
-def project_to_animation(*, path=None, **project):
+def _make_project(path=None, **project):
     gitty.sys_path.extend(path)
 
     kwds = defaults.apply_defaults(project)
     animation = kwds.pop('animation', {})
     run = kwds.pop('run', {})
-    layout = make_layout(**kwds)
+    layout = _make_layout(**kwds)
     return make_animation(layout, animation, run)
 
 
-def apply_defaults(project, defaults):
+def _apply_defaults(project, defaults):
     # TODO: consolidate with bp.project.defaults.
     for k, v in defaults.items():
         if k == 'ledtype' or not v:
@@ -77,12 +81,6 @@ def apply_defaults(project, defaults):
             # consistent across the whole system.
 
 
-def project_to_runnable(project, defaults=None):
-    apply_defaults(project, defaults or {})
-    return project_to_animation(**project).start
-
-
-def run(s, is_filename=True, defaults=None):
-    project = files.read_json(s, is_filename)
-    runnable = project_to_runnable(project, defaults)
-    runnable()
+def project_to_animation(project, defaults=None):
+    _apply_defaults(project, defaults or {})
+    return _make_project(**project)
diff --git a/bibliopixel/project/types/__init__.py b/bibliopixel/project/types/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/bibliopixel/project/types/channel_order.py b/bibliopixel/project/types/channel_order.py
new file mode 100644
index 0000000..0447269
--- /dev/null
+++ b/bibliopixel/project/types/channel_order.py
@@ -0,0 +1,43 @@
+import functools
+from ...drivers.channel_order import ChannelOrder
+
+USAGE = """A ChannelOrder can be initialized with:
+
+* A list, tuple or string of lengths three: '012', 'bgr', (0, 2, 1)
+* An integer from 0 to 5.
+"""
+
+NAMES = {
+    'r': 0,
+    'g': 1,
+    'b': 2,
+    'R': 0,
+    'G': 1,
+    'B': 2,
+    '0': 0,
+    '1': 1,
+    '2': 2,
+    0: 0,
+    1: 1,
+    2: 2,
+}
+
+
+@functools.singledispatch
+def make(c):
+    raise ValueError("Don't understand type %s" % type(c), USAGE)
+
+
+@make.register(tuple)
+@make.register(list)
+@make.register(str)
+def _(c):
+    i = ChannelOrder.ORDERS.index(tuple(NAMES[i] for i in c))
+    return ChannelOrder.ORDERS[i]
+
+
+@make.register(int)
+def _(c):
+    if c < 0:
+        raise IndexError('RGB indices cannot be negative')
+    return ChannelOrder.ORDERS[c]
diff --git a/bibliopixel/project/types/color.py b/bibliopixel/project/types/color.py
new file mode 100644
index 0000000..d25658c
--- /dev/null
+++ b/bibliopixel/project/types/color.py
@@ -0,0 +1,47 @@
+import functools, numbers
+from ... import colors
+
+USAGE = """A Color can be initialized with:
+
+* A list of three numbers: [0, 0, 0] or [255, 0, 255].
+* A single number which represents a brightness/gray level: 0, 255, 127
+* A string:  "red", "yellow", "gold" naming a color from ...colors.COLORS.
+
+All numbers must be in the range [0, 256) - 0 <= x < 256
+"""
+
+
+@functools.singledispatch
+def make(c):
+    raise ValueError("Don't understand type %s" % type(c), USAGE)
+
+
+@make.register(tuple)
+def _(c):
+    if len(c) != 3:
+        raise ValueError('Length %d is not 3' % len(c), USAGE)
+
+    if not all(0 <= x <= 255 for x in c):
+        raise ValueError('All components must be between 0 and 255', USAGE)
+
+    return c
+
+
+@make.register(list)
+def _(c):
+    return make(tuple(c))
+
+
+@make.register(numbers.Number)
+def _(c):
+    if not (0 <= c <= 255):
+        raise ValueError('All components must be between 0 and 255', USAGE)
+    return (c, c, c)
+
+
+@make.register(str)
+def _(c):
+    try:
+        return colors.COLORS[c.lower()]
+    except:
+        raise ValueError("Don't understand color name", USAGE)
diff --git a/bibliopixel/project/types/defaults.py b/bibliopixel/project/types/defaults.py
new file mode 100644
index 0000000..d297296
--- /dev/null
+++ b/bibliopixel/project/types/defaults.py
@@ -0,0 +1,11 @@
+from . import channel_order, color, duration, gamma, ledtype
+
+FIELD_TYPES = {
+    'c_order': channel_order,
+    'color': color,
+    'duration': duration,
+    'gamma': gamma,
+    'ledtype': ledtype,
+    'time': duration,
+    'type': ledtype,
+}
diff --git a/bibliopixel/project/types/duration.py b/bibliopixel/project/types/duration.py
new file mode 100644
index 0000000..3c74857
--- /dev/null
+++ b/bibliopixel/project/types/duration.py
@@ -0,0 +1,31 @@
+import functools, numbers
+from ...util import duration
+
+
+USAGE = """A time can be initialized with:
+
+* A number that is greater than or equal to zero, representing a time in
+  seconds.
+
+* A string:
+
+2 weeks, 1 day, 1 hours, 3 minutes, 45 seconds, 340.4ms....
+
+"""
+
+
+@functools.singledispatch
+def make(c):
+    raise ValueError("Don't understand type %s" % type(c), USAGE)
+
+
+@make.register(numbers.Number)
+def _(t):
+    if t < 0:
+        raise ValueError('Times must not be negative', USAGE)
+    return t
+
+
+@make.register(str)
+def _(s):
+    return duration.parse(s)
diff --git a/bibliopixel/project/types/gamma.py b/bibliopixel/project/types/gamma.py
new file mode 100644
index 0000000..ed1ed05
--- /dev/null
+++ b/bibliopixel/project/types/gamma.py
@@ -0,0 +1,42 @@
+import functools, numbers
+from ... import gamma
+
+NAMES = [d for d in dir(gamma) if d.isupper()]
+
+USAGE = """A Gamma table is represented by one of:
+
+* A number, representing a floating gamma correction value
+* A string, representing a name of a standard gamma function
+* A list of up to three elements: arguments to Gamma's constructor.
+* A dictionary representing arguments to Gamma's constructor.
+
+Possible string names  are """ + ', '.join(NAMES)
+
+
+@functools.singledispatch
+def make(c):
+    raise ValueError("Don't understand type %s" % type(c), USAGE)
+
+
+@make.register(numbers.Number)
+def _(c):
+    return gamma.Gamma(c)
+
+
+@make.register(str)
+def _(c):
+    if c not in NAMES:
+        raise ValueError('Unknown gamma %s: valid names are %s' % (c, NAMES))
+
+    return getattr(gamma, c)
+
+
+@make.register(list)
+@make.register(tuple)
+def _(c):
+    return gamma.Gamma(*c)
+
+
+@make.register(dict)
+def _(c):
+    return gamma.Gamma(**c)
diff --git a/bibliopixel/project/types/ledtype.py b/bibliopixel/project/types/ledtype.py
new file mode 100644
index 0000000..6ffd1bd
--- /dev/null
+++ b/bibliopixel/project/types/ledtype.py
@@ -0,0 +1,16 @@
+import functools
+from ...drivers.serial import codes
+
+USAGE = """An LEDTYPE is represented by a string.
+
+Possible LEDTYPEs are """ + ', '.join(codes.LEDTYPE.__members__)
+
+
+@functools.singledispatch
+def make(c):
+    raise ValueError("Don't understand type %s" % type(c), USAGE)
+
+
+@make.register(str)
+def _(c):
+    return codes.LEDTYPE[c]
diff --git a/bibliopixel/project/types/make.py b/bibliopixel/project/types/make.py
new file mode 100644
index 0000000..3d730c2
--- /dev/null
+++ b/bibliopixel/project/types/make.py
@@ -0,0 +1,17 @@
+def field_value(field, value, field_types):
+    if not (field_types and field in field_types):
+        return value
+
+    try:
+        return field_types[field].make(value)
+    except Exception as e:
+        e.args += ('for field %s and value %s' % (field, value), )
+        raise
+
+
+def component(comp, field_types):
+    return {k: field_value(k, v, field_types) for k, v in comp.items()}
+
+
+def project(proj, field_types):
+    return {k: component(v, field_types) for k, v in proj.items()}
diff --git a/bibliopixel/util/duration.py b/bibliopixel/util/duration.py
new file mode 100644
index 0000000..713d9de
--- /dev/null
+++ b/bibliopixel/util/duration.py
@@ -0,0 +1,106 @@
+import re
+
+"""Convert a string duration to a number of seconds.
+"""
+
+SI_PREFIXES = (
+    (10 ** 24, 'yotta', 'Y'),
+    (10 ** 21, 'zetta', 'Z'),
+    (10 ** 18, 'exa', 'E'),
+    (10 ** 15, 'peta', 'P'),
+    (10 ** 12, 'tera', 'T'),
+    (10 ** 9, 'giga', 'G'),
+    (10 ** 6, 'mega', 'M'),
+    (10 ** 3, 'kilo', 'k'),
+    (10 ** 2, 'hecto', 'h'),
+    (10 ** 1, 'deka', 'da'),
+    (10 ** -1, 'deci', 'd'),
+    (10 ** -2, 'centi', 'c'),
+    (10 ** -3, 'milli', 'm'),
+    (10 ** -6, 'micro', 'µ'),
+    (10 ** -9, 'nano', 'n'),
+    (10 ** -12, 'pico', 'p'),
+    (10 ** -15, 'femto', 'f'),
+    (10 ** -18, 'atto', 'a'),
+    (10 ** -21, 'zepto', 'z'),
+    (10 ** -24, 'yocto', 'y'),
+)
+
+SI_SCALES = dict(
+    [(p[1], p[0]) for p in SI_PREFIXES] +
+    [(p[2], p[0]) for p in SI_PREFIXES] +
+    [['u', 10 ** -9]])  # Cheat to allow usec
+
+UNITS = {
+    'week': 7 * 24 * 60 * 60,
+    'day': 24 * 60 * 60,
+
+    'hour': 60 * 60,
+    'hr': 60 * 60,
+
+    'minute': 60,
+    'min': 60,
+
+    'second': 1,
+    'sec': 1,
+    's': 1,
+}
+
+PART_MATCH = re.compile(r'([0-9.]+)([a-zA-Z]+)').fullmatch
+
+
+def get_units(s):
+    # Some subtleties to handle the possibility of plural units.
+    if s.endswith('ss'):
+        raise ValueError('Unknown unit')
+
+    def split_unit(s):
+        for u in UNITS:
+            if s.endswith(u):
+                return s[:-len(u)], u
+
+    prefix, unit = (
+        s.endswith('s') and split_unit(s[:-1]) or
+        split_unit(s) or
+        (s, ''))
+
+    if not unit:
+        raise ValueError('Unknown unit ' + s)
+
+    scale = UNITS[unit]
+
+    if not prefix:
+        return scale
+
+    if prefix not in SI_SCALES:
+        raise ValueError('Unknown metric prefix ' + prefix)
+
+    return SI_SCALES[prefix] * scale
+
+
+def parse(s):
+    """Parse a string into a duration in seconds or raise an exception."""
+
+    parts = s.replace(',', ' ').split()
+    if not parts:
+        raise ValueError('Cannot parse empty string')
+
+    pieces = []
+    for part in parts:
+        m = PART_MATCH(part)
+        pieces.extend(m.groups() if m else [part])
+
+    if len(pieces) == 1:
+        pieces.append('s')
+
+    if len(pieces) % 2:
+        raise ValueError('Malformed duration %s: %s: %s' % (s, parts, pieces))
+
+    result = 0
+    for number, units in zip(*[iter(pieces)] * 2):
+        number = float(number)
+        if number < 0:
+            raise ValueError('Durations cannot have negative components')
+        result += number * get_units(units)
+
+    return result
diff --git a/bibliopixel/util/files.py b/bibliopixel/util/files.py
index 9d728f8..018a29b 100644
--- a/bibliopixel/util/files.py
+++ b/bibliopixel/util/files.py
@@ -22,16 +22,6 @@ def opener(fname, mode='r', *args, **kwds):
     raise IOError('%s: Can\'t open URL %s' % (response.status_code, url))
 
 
-def read_json(s, is_filename=True):
-    data = opener(s).read() if is_filename else s
-
-    try:
-        return json.loads(data)
-    except ValueError as e:
-        e.args = ['%s\nin filename %s' % (e.args[0], s)]
-        raise
-
-
 @contextlib.contextmanager
 def remove_on_failure(dirname, remove=True):
     """Tries to create and fill a directory, removes it on failure."""
diff --git a/tox.ini b/tox.ini
index dd72df9..05b2513 100644
--- a/tox.ini
+++ b/tox.ini
@@ -17,6 +17,9 @@ ignore =
     # function name should be lowercase
     N802,
     # variable in function should be lowercase
-    N806
+    N806,
+    # redefinition of unused
+    F811
+
 exclude = build/*, *.ini, *.in, MANIFEST*, *.md
 max-complexity = -1


Test patch:
diff --git a/test/duration_test.py b/test/duration_test.py
new file mode 100644
index 0000000..376f147
--- /dev/null
+++ b/test/duration_test.py
@@ -0,0 +1,55 @@
+import unittest
+from bibliopixel.util import duration
+
+
+class DurationTest(unittest.TestCase):
+    def test_simple(self):
+        self.assertEqual(duration.parse('1'), 1)
+        self.assertEqual(duration.parse('23.5'), 23.5)
+
+        with self.assertRaises(ValueError):
+            duration.parse('-23.5')
+
+    def test_simple_units(self):
+        self.assertEqual(duration.parse('1s'), 1)
+        self.assertEqual(duration.parse('1 s'), 1)
+        self.assertEqual(duration.parse('1 sec'), 1)
+        self.assertEqual(duration.parse('2 seconds'), 2)
+        self.assertEqual(duration.parse('2 seconds'), 2)
+
+    def test_simple_failure(self):
+        with self.assertRaises(ValueError):
+            duration.parse('1ss')
+
+        with self.assertRaises(ValueError):
+            duration.parse('1secondss')
+
+        with self.assertRaises(ValueError):
+            duration.parse('1milisecond')
+
+        with self.assertRaises(ValueError):
+            duration.parse('1 killosecond')
+
+    def test_complex(self):
+        self.assertEqual(duration.parse('2 hours, 3 minutes, 3.5 seconds'),
+                         2 * 60 * 60 + 3 * 60 + 3.5)
+        self.assertEqual(duration.parse('2 hours, 3 minutes, 3.5 picoseconds'),
+                         2 * 60 * 60 + 3 * 60 + 3.5 * 10 ** -12)
+        self.assertEqual(duration.parse('2hrs, 3 minutes, 3.5ps'),
+                         2 * 60 * 60 + 3 * 60 + 3.5 * 10 ** -12)
+
+    def test_complex_failure(self):
+        with self.assertRaises(ValueError):
+            duration.parse('2 hourses, 3 minutes, 3.5 seconds')
+
+        with self.assertRaises(ValueError):
+            duration.parse('2 hours minutes, 3 minutes, 3.5 seconds')
+
+        with self.assertRaises(ValueError):
+            duration.parse('2 3 hours, 3 minutes, 3.5 seconds')
+
+        with self.assertRaises(ValueError):
+            duration.parse('hours, 3 minutes, 3.5 seconds')
+
+        with self.assertRaises(ValueError):
+            duration.parse('3 hours, 3 minutes, 3.5')
diff --git a/test/project_test.py b/test/project_test.py
index fd142bb..7da0d25 100644
--- a/test/project_test.py
+++ b/test/project_test.py
@@ -1,7 +1,9 @@
 import json, unittest
 
-from bibliopixel.project import project
-from . mark_tests import long_test
+from bibliopixel.main import run
+from bibliopixel import gamma
+from bibliopixel.drivers.serial import codes
+from . mark_tests import SKIP_LONG_TESTS
 
 
 PROJECT = """
@@ -19,6 +21,29 @@ PROJECT = """
 }
 """
 
+PROJECT_TYPES = """
+{
+    "driver": {
+        "typename": "dummy",
+        "num": 12,
+
+        "c_order": "GBR",
+        "color": "green",
+        "duration": "1 hour, 2 minutes",
+        "gamma": "APA102",
+        "ledtype": "WS2812",
+        "time": "35ks",
+        "type": "GENERIC"
+    },
+
+    "layout": "strip",
+    "animation": "strip_test",
+    "run": {
+        "max_steps": 2
+    }
+}
+"""
+
 PROJECT_MULTI = """
 {
     "driver": {
@@ -86,23 +111,36 @@ PROJECT_SIM = """
 """
 
 
+def start(name, is_json=True):
+    animation = run.project_to_animation(name, is_json, None)
+    if not SKIP_LONG_TESTS:
+        animation.start()
+    return animation
+
+
 class ProjectTest(unittest.TestCase):
-    @long_test
     def test_simple(self):
-        project.run(PROJECT, False)
+        start(PROJECT)
+
+    def test_types(self):
+        animation = start(PROJECT_TYPES)
+        kwds = animation.layout.drivers[0]._kwds
+        self.assertEquals(kwds['c_order'], (1, 2, 0))
+        self.assertEquals(kwds['color'], (0, 255, 0))
+        self.assertEquals(kwds['duration'], 3720)
+        self.assertEquals(kwds['gamma'], gamma.APA102)
+        self.assertEquals(kwds['ledtype'], codes.LEDTYPE.WS2812)
+        self.assertEquals(kwds['time'], 35000)
+        self.assertEquals(kwds['type'], codes.LEDTYPE.GENERIC)
 
-    @long_test
     def test_file(self):
-        project.run('test/project.json')
+        start('test/project.json', False)
 
-    @long_test
     def test_multi(self):
-        project.run(PROJECT_MULTI, False)
+        start(PROJECT_MULTI)
 
-    @long_test
     def test_shared(self):
-        project.run(PROJECT_SHARED, False)
+        start(PROJECT_SHARED)
 
-    @long_test
     def test_sim(self):
-        project.run(PROJECT_SIM, False)
+        start(PROJECT_SIM)
diff --git a/test/types_test.py b/test/types_test.py
new file mode 100644
index 0000000..4d1377d
--- /dev/null
+++ b/test/types_test.py
@@ -0,0 +1,139 @@
+import unittest
+
+from bibliopixel.project.types import make, defaults
+from bibliopixel import colors, gamma
+from bibliopixel.drivers import channel_order
+
+
+class TypesBaseTest(unittest.TestCase):
+    def make(self, name, c, result=None):
+        component = make.component({name: c}, field_types=defaults.FIELD_TYPES)
+        if result is not None:
+            self.assertEquals(component, {name: result})
+        return component
+
+
+class ColorTypesTest(TypesBaseTest):
+    def test_unchanged(self):
+        for p in {}, {'a': {}}, {'a': {'b': 'c'}}:
+            self.assertEqual(make.project(p, {}), p)
+            self.assertEqual(make.project(p, defaults.FIELD_TYPES), p)
+
+    def test_color_names(self):
+        # Test every color.
+        for name in dir(colors):
+            if name.istitle():
+                c = getattr(colors, name)
+                for n in name, name.lower(), name.upper():
+                    self.make('color', n, c)
+
+    def test_color_numbers(self):
+        for i in range(256):
+            self.make('color', i, (i, i, i))
+
+        for c in [0, 0, 0], [127, 128, 255], [4, 200, 77]:
+            self.make('color', c, tuple(c))
+
+        for c in (0, 0, 0), (127, 128, 255), (4, 200, 77):
+            self.make('color', c, c)
+
+    def test_errors(self):
+        with self.assertRaises(ValueError):
+            self.make('color', -1)
+
+        with self.assertRaises(ValueError):
+            self.make('color', 256)
+
+        with self.assertRaises(ValueError):
+            self.make('color', '')
+
+        with self.assertRaises(ValueError):
+            self.make('color', '255')
+
+        with self.assertRaises(ValueError):
+            self.make('color', '[0, 0, 0]')
+
+        with self.assertRaises(ValueError):
+            self.make('color', '(0, 0, 0)')
+
+        with self.assertRaises(ValueError):
+            self.make('color', 'dog')
+
+        with self.assertRaises(ValueError):
+            self.make('color', ())
+
+        with self.assertRaises(ValueError):
+            self.make('color', (1,))
+
+        with self.assertRaises(ValueError):
+            self.make('color', (1, 2))
+
+        self.make('color', (1, 2, 3))
+
+        with self.assertRaises(ValueError):
+            self.make('color', (1, 2, 3, 4))
+
+
+class DurationTypesTest(TypesBaseTest):
+    def test_some(self):
+        self.make('duration', '1', 1)
+        self.make('duration', '1s', 1)
+        self.make('duration', '2.5s', 2.5)
+        self.make('duration', '10 mins, 2.5s', 602.5)
+
+
+class LEDTYPETypesTest(TypesBaseTest):
+    def test_some(self):
+        self.make('ledtype', 'LPD8806')
+        self.make('ledtype', 'GENERIC')
+
+        with self.assertRaises(ValueError):
+            self.make('ledtype', 2)
+
+        with self.assertRaises(KeyError):
+            self.make('ledtype', 'NONE')
+
+
+class GammaTypesTest(TypesBaseTest):
+    def test_some(self):
+        self.make('gamma', 'LPD8806', gamma.LPD8806)
+        self.make('gamma', 'DEFAULT', gamma.DEFAULT)
+
+        gam = self.make('gamma', {'gamma': 2.5, 'offset': 0.5})
+        self.assertEquals(gam['gamma'].table, gamma.APA102.table)
+
+        gam = self.make('gamma', [2.5, 0.5, 128])
+        self.assertEquals(gam['gamma'].table, gamma.LPD8806.table)
+
+        with self.assertRaises(TypeError):
+            self.make('gamma', [0, 1, 2, 3])
+
+        with self.assertRaises(ValueError):
+            self.make('gamma', None)
+
+
+class ChannelOrderTypesTest(TypesBaseTest):
+    def test_some(self):
+        def test(i, *cases):
+            expected = channel_order.ChannelOrder.ORDERS[i]
+            for case in cases:
+                actual = self.make('c_order', case)['c_order']
+                self.assertIs(expected, actual)
+
+        test(0, 'rgb', (0, 1, 2), [0, 1, 2], '012')
+        test(5, 'bgr', (2, 1, 0), [2, 1, 0], 'b1R')
+
+        with self.assertRaises(IndexError):
+            self.make('c_order', -1)
+
+        with self.assertRaises(IndexError):
+            self.make('c_order', 6)
+
+        with self.assertRaises(KeyError):
+            self.make('c_order', 'NONE')
+
+        with self.assertRaises(ValueError):
+            self.make('c_order', 'RGG')
+
+        with self.assertRaises(ValueError):
+            self.make('c_order', None)