Testlog: Add better defined schema for StationTestRun event

Previously, FIELDS in StationTestRun only checks for top-level
definition (Dict/List). Introducing the utils.schema to perform
a throughout check against Testlog specification.
Miscellaneous bugs are fixed as well:
  1. CastFields misses super, but it should call that.
  2. CastFields in StationTestRun can't work ideally since
     __setitem__ is overrided.
  3. status in StationTestRun['parameters'] use PASS or FAIL,
     which is not consistent with Testlog playbook.

BUG=b:38484982
TEST=pass unittest
CQ-DEPEND=CL:504047

Change-Id: I13617bf0e5fdb829053f1f52d3194649aa3c589f
Reviewed-on: https://chromium-review.googlesource.com/509431
Commit-Ready: Chun-Tsen Kuo <chuntsen@chromium.org>
Tested-by: Chun-Tsen Kuo <chuntsen@chromium.org>
Reviewed-by: Chun-ta Lin <itspeter@chromium.org>
diff --git a/py/testlog/testlog.py b/py/testlog/testlog.py
index 99e6c32..ab63bc7 100644
--- a/py/testlog/testlog.py
+++ b/py/testlog/testlog.py
@@ -52,6 +52,7 @@
 import testlog_utils
 import testlog_validator
 from utils import file_utils
+from utils import schema
 from utils import sys_utils
 from utils import time_utils
 from utils import type_utils
@@ -234,7 +235,7 @@
     metadata = last_test_run.pop(
         Testlog.FIELDS._METADATA)  # pylint: disable=protected-access
     return {
-        Testlog.FIELDS.LAST_TEST_RUN: Event.FromDict(last_test_run),
+        Testlog.FIELDS.LAST_TEST_RUN: Event.FromDict(last_test_run, False),
         Testlog.FIELDS.LOG_ROOT: metadata[Testlog.FIELDS.LOG_ROOT],
         Testlog.FIELDS.PRIMARY_JSON: metadata[Testlog.FIELDS.PRIMARY_JSON],
         Testlog.FIELDS.SESSION_JSON: session_json_path,
@@ -666,16 +667,17 @@
   def CheckMissingFields(self):
     """Returns a list of missing field."""
     mro = self.__class__.__mro__
-    missing_fileds = []
+    missing_fields = []
     # Find the corresponding requirement.
     for cls in mro:
       if cls is object:
         break
       for field_name, metadata in cls.FIELDS.iteritems():
         if metadata[0] and field_name not in self._data:
-          missing_fileds.append(field_name)
+          missing_fields.append(field_name)
 
-    return missing_fileds
+    if missing_fields != []:
+      raise testlog_utils.TestlogError('Missing fields: %s' % missing_fields)
 
   @classmethod
   def GetEventType(cls):
@@ -693,6 +695,7 @@
     """
     for key in data.iterkeys():
       data_type = type(data[key])
+      # TODO(chuntsen): raise exception when the empty list/dict has wrong key.
       if data_type == list:
         for value in data[key]:
           self[key] = value
@@ -734,16 +737,18 @@
           'Input event does not have a valid `type`.')
 
   @classmethod
-  def FromJSON(cls, json_string):
+  def FromJSON(cls, json_string, check_missing=True):
     """Converts JSON data into an Event instance."""
-    return cls.FromDict(json.loads(json_string))
+    return cls.FromDict(json.loads(json_string), check_missing)
 
   @classmethod
-  def FromDict(cls, data):
+  def FromDict(cls, data, check_missing=True):
     """Converts Python dict data into an Event instance."""
     event = cls.DetermineClass(data)()
     event.Populate(data)
     event.CastFields()
+    if check_missing:
+      event.CheckMissingFields()
     return event
 
   def ToJSON(self):
@@ -903,6 +908,76 @@
   # TODO(itspeter): Document 'argument', 'operatorId', 'failures',
   #                 'serialNumbers', 'parameters' and 'series'.
 
+  @classmethod
+  def _NumericSchema(cls, label):
+    return schema.AnyOf([
+        schema.Scalar(label, int),
+        schema.Scalar(label, long),
+        schema.Scalar(label, float)])
+
+  def _ValidatorArgumentWrapper(*args, **kwargs):  # pylint: disable=E0211
+    SCHEMA = schema.FixedDict(
+        'arguments.value',
+        items={'value': schema.Scalar('value', object)},
+        optional_items={
+            'description': schema.Scalar('description', basestring)})
+    kwargs['schema'] = SCHEMA
+    return testlog_validator.Validator.Dict(*args, **kwargs)
+
+  def _ValidatorFailureWrapper(*args, **kwargs):  # pylint: disable=E0211
+    SCHEMA = schema.FixedDict(
+        'failures.value',
+        items={'code': schema.Scalar('code', basestring),
+               'details': schema.Scalar('details', basestring)})
+    kwargs['schema'] = SCHEMA
+    return testlog_validator.Validator.List(*args, **kwargs)
+
+  def _ValidatorSerialNumberWrapper(*args, **kwargs):  # pylint: disable=E0211
+    SCHEMA = schema.Scalar('serialNumbers.value', basestring)
+    kwargs['schema'] = SCHEMA
+    return testlog_validator.Validator.Dict(*args, **kwargs)
+
+  def _ValidatorParameterWrapper(*args, **kwargs):  # pylint: disable=E0211
+    SCHEMA = schema.FixedDict(
+        'parameters.value',
+        items={},
+        optional_items={
+            'description': schema.Scalar('description', basestring),
+            'group': schema.Scalar('group', basestring),
+            'status': schema.Scalar('status', basestring, ['PASS', 'FAIL']),
+            'valueUnit': schema.Scalar('valueUnit', basestring),
+            'numericValue': StationTestRun._NumericSchema('numericValue'),
+            'expectedMinimum': StationTestRun._NumericSchema('expectedMinimum'),
+            'expectedMaximum': StationTestRun._NumericSchema('expectedMaximum'),
+            'textValue': schema.Scalar('textValue', basestring),
+            'expectedRegex': schema.Scalar('expectedRegex', basestring)})
+    kwargs['schema'] = SCHEMA
+    return testlog_validator.Validator.Dict(*args, **kwargs)
+
+  def _ValidatorSeriesWrapper(*args, **kwargs):  # pylint: disable=E0211
+    DATA_SCHEMA = schema.List('data', schema.FixedDict(
+        'data',
+        items={
+            'key': StationTestRun._NumericSchema('key')},
+        optional_items={
+            'status': schema.Scalar('status', basestring, ['PASS', 'FAIL']),
+            'numericValue': StationTestRun._NumericSchema('numericValue'),
+            'expectedMinimum': StationTestRun._NumericSchema('expectedMinimum'),
+            'expectedMaximum': StationTestRun._NumericSchema('expectedMaximum')
+        }))
+    SCHEMA = schema.FixedDict(
+        'series.value',
+        items={},
+        optional_items={
+            'description': schema.Scalar('description', basestring),
+            'group': schema.Scalar('group', basestring),
+            'status': schema.Scalar('status', basestring, ['PASS', 'FAIL']),
+            'keyUnit': schema.Scalar('keyUnit', basestring),
+            'valueUnit': schema.Scalar('valueUnit', basestring),
+            'data': DATA_SCHEMA})
+    kwargs['schema'] = SCHEMA
+    return testlog_validator.Validator.Dict(*args, **kwargs)
+
   def _ValidatorAttachmentWrapper(*args, **kwargs):  # pylint: disable=E0211
     # Because the FIELDS map must be assigned at the time of loading the
     # module. However, Testlog singleton is not ready yet, we pass the
@@ -914,17 +989,17 @@
       'testRunId': (True, testlog_validator.Validator.String),
       'testName': (True, testlog_validator.Validator.String),
       'testType': (True, testlog_validator.Validator.String),
-      'arguments': (False, testlog_validator.Validator.Dict),
+      'arguments': (False, _ValidatorArgumentWrapper),
       'status': (True, testlog_validator.Validator.Status),
       'startTime': (True, testlog_validator.Validator.Time),
       'endTime': (False, testlog_validator.Validator.Time),
-      'duration': (False, testlog_validator.Validator.Float),
+      'duration': (False, testlog_validator.Validator.Number),
       'operatorId': (False, testlog_validator.Validator.String),
       'attachments': (False, _ValidatorAttachmentWrapper),
-      'failures': (False, testlog_validator.Validator.List),
-      'serialNumbers': (False, testlog_validator.Validator.Dict),
-      'parameters': (False, testlog_validator.Validator.Dict),
-      'series': (False, testlog_validator.Validator.Dict),
+      'failures': (False, _ValidatorFailureWrapper),
+      'serialNumbers': (False, _ValidatorSerialNumberWrapper),
+      'parameters': (False, _ValidatorParameterWrapper),
+      'series': (False, _ValidatorSeriesWrapper),
   }
 
   # Possible values for the `status` field.
@@ -938,10 +1013,11 @@
     return 'station.test_run'
 
   def CastFields(self):
+    super(StationTestRun, self).CastFields()
     if 'series' in self:
       s = Series(__METADATA__={})
       s.update(self['series'])
-      self['series'] = s
+      self._data.update(series=s)
 
   @staticmethod
   def _CheckParamArguments(value, description, value_unit,
diff --git a/py/testlog/testlog_unittest.py b/py/testlog/testlog_unittest.py
index f58e94d..ad2639b 100755
--- a/py/testlog/testlog_unittest.py
+++ b/py/testlog/testlog_unittest.py
@@ -55,7 +55,7 @@
 
   def testEventSerializeUnserialize(self):
     original = testlog.StationInit()
-    output = testlog.Event.FromJSON(original.ToJSON())
+    output = testlog.Event.FromJSON(original.ToJSON(), False)
     self.assertEquals(output, original)
 
   def testNewEventTime(self):
@@ -79,8 +79,11 @@
   def testCheckMissingFields(self):
     event = testlog.StationInit()
     event['failureMessage'] = 'Missed fields'
-    self.assertItemsEqual(event.CheckMissingFields(),
-                          ['count', 'success', 'uuid', 'apiVersion', 'time'])
+    try:
+      event.CheckMissingFields()
+    except testlog_utils.TestlogError as e:
+      self.assertEqual(repr(e), 'TestlogError("Missing fields: [\'count\', '
+                       '\'success\', \'uuid\', \'apiVersion\', \'time\']",)')
 
   def testAddArgument(self):
     event = testlog.StationTestRun()
diff --git a/py/testlog/testlog_validator.py b/py/testlog/testlog_validator.py
index 9c273f7..7335a76 100644
--- a/py/testlog/testlog_validator.py
+++ b/py/testlog/testlog_validator.py
@@ -32,7 +32,9 @@
     Validator.Object(inst, key, value)
 
   @staticmethod
-  def Float(inst, key, value):
+  def Number(inst, key, value):
+    if isinstance(value, (int, long)):
+      value = float(value)
     if not isinstance(value, float):
       raise ValueError(
           'key[%s] accepts type of float. Not %r '
@@ -56,7 +58,7 @@
     Validator.Object(inst, key, value)
 
   @staticmethod
-  def Dict(inst, key, value):
+  def Dict(inst, key, value, schema=None):
     """Inserts an item into the inst._data[key].
 
     Assuming inst._data[key] is a dictionary, the inserted element will be
@@ -68,6 +70,14 @@
           'Validator.Dict accepts value in form of {%r:..., %r:...}, not %s' % (
               'key', 'value', pprint.pformat(value)))
 
+    if not isinstance(value['key'], basestring):
+      raise ValueError(
+          'The key of %s accepts type of basestring. Not %r '
+          'Please convert before assign' % (key, type(value['key'])))
+
+    if schema:
+      schema.Validate(value['value'])
+
     # pylint: disable=protected-access
     updated_dict = inst._data[key] if key in inst._data else {}
     sub_key = value['key']
@@ -80,8 +90,12 @@
     inst._data[key] = updated_dict
 
   @staticmethod
-  def List(inst, key, value):
+  def List(inst, key, value, schema=None):
     logging.debug('Validator.List called with (%s, %s)', key, value)
+
+    if schema:
+      schema.Validate(value)
+
     # pylint: disable=protected-access
     updated_list = inst._data[key] if key in inst._data else []
     updated_list.append(value)
diff --git a/py/testlog/testlog_validator_unittest.py b/py/testlog/testlog_validator_unittest.py
index 5589bca..2bd9a63 100755
--- a/py/testlog/testlog_validator_unittest.py
+++ b/py/testlog/testlog_validator_unittest.py
@@ -17,6 +17,7 @@
 import testlog
 import testlog_utils
 import testlog_validator
+from utils import schema
 from utils import time_utils
 
 
@@ -25,7 +26,7 @@
     """A derived class from Event that can be initialized."""
     FIELDS = {
         'Long':    (True, testlog_validator.Validator.Long),
-        'Float':   (True, testlog_validator.Validator.Float),
+        'Number':  (True, testlog_validator.Validator.Number),
         'String':  (True, testlog_validator.Validator.String),
         'Boolean': (True, testlog_validator.Validator.Boolean),
         'Dict':    (True, testlog_validator.Validator.Dict),
@@ -53,14 +54,18 @@
     event['Long'] = 3333
     self.assertEquals(event['Long'], 3333)
 
-  def testFloatValidator(self):
+  def testNumberValidator(self):
     event = self.TestEvent()
     with self.assertRaises(ValueError):
-      event['Float'] = '66'
+      event['Number'] = '66'
     with self.assertRaises(ValueError):
-      event['Float'] = 'aa'
-    event['Float'] = 33.33
-    self.assertAlmostEqual(event['Float'], 33.33)
+      event['Number'] = 'aa'
+    event['Number'] = 33.33
+    self.assertAlmostEqual(event['Number'], 33.33)
+    event['Number'] = -1
+    self.assertAlmostEqual(event['Number'], -1)
+    event['Number'] = 999999999999999
+    self.assertAlmostEqual(event['Number'], 999999999999999)
 
   def testStringValidator(self):
     event = self.TestEvent()
@@ -93,7 +98,7 @@
     self.assertEquals(event['Dict'],
                       {'PARA1': 33, 'PARA2': 'aaa'})
     # Converts to JSON and convert back.
-    event2 = testlog.EventBase.FromJSON(event.ToJSON())
+    event2 = testlog.EventBase.FromJSON(event.ToJSON(), False)
     self.assertEquals(event, event2)
 
   def testListValidator(self):
@@ -112,8 +117,167 @@
     self.assertEquals(event['Time'], copy.copy(valid_datetime))
 
 
-class StationTestRunValidatorTest(unittest.TestCase):
+class StationTestRunFieldsValidatorTest(unittest.TestCase):
+  """Checks the correctness of FIELDS' validator and SCHEMA's"""
+
+  def testArgumentValidator(self):
+    event = testlog.StationTestRun()
+    # Valid argument
+    event['arguments'] = {'key': 'KeyIsAString', 'value': {
+        'value': 'This is a string.'}}
+    event['arguments'] = {'key': 'KeyIsANumber', 'value': {
+        'value': 987.654321,
+        'description': 'G'}}
+    event['arguments'] = {'key': 'KeyCanBeAnything', 'value': {
+        'value': True}}
+    # Wrong top-level type
+    with self.assertRaises(schema.SchemaException):
+      event['arguments'] = {'key': 'A', 'value': 'Google'}
+    # Wrong key type
+    with self.assertRaises(ValueError):
+      event['arguments'] = {'key': 3, 'value': {'value': 0}}
+    # Additional item in Dict
+    with self.assertRaises(schema.SchemaException):
+      event['arguments'] = {'key': 'A', 'value': {'value': 0, 'A': 'B'}}
+    # Missing item
+    with self.assertRaises(schema.SchemaException):
+      event['arguments'] = {'key': 'A', 'value': {'description': 'yoyo'}}
+    # Empty dict
+    with self.assertRaises(schema.SchemaException):
+      event['arguments'] = {'key': 'A', 'value': {}}
+
+  def testFailuresValidator(self):
+    event = testlog.StationTestRun()
+    # Valid failure
+    event['failures'] = {
+        'code': 'WiFiThroughputSpeedLimitFail',
+        'details': '2.4G WiFi throughput test fail: ... bla bla bla'}
+    # Wrong top-level type
+    with self.assertRaises(schema.SchemaException):
+      event['failures'] = 'Google'
+    # Wrong value type
+    with self.assertRaises(schema.SchemaException):
+      event['failures'] = {'code': 'C', 'details': 3}
+    # Additional item in Dict
+    with self.assertRaises(schema.SchemaException):
+      event['failures'] = {'code': 'C', 'details': 'D', 'A': 'B'}
+    # Missing item
+    with self.assertRaises(schema.SchemaException):
+      event['failures'] = {'code': 'C'}
+    # Empty dict
+    with self.assertRaises(schema.SchemaException):
+      event['failures'] = {}
+
+  def testSerialNumbersValidator(self):
+    event = testlog.StationTestRun()
+    # Valid serial number
+    event['serialNumbers'] = {'key': 'mlb', 'value': 'A1234567890'}
+    # Wrong key type
+    with self.assertRaises(ValueError):
+      event['serialNumbers'] = {'key': 3, 'value': 'Google'}
+    # Wrong value type
+    with self.assertRaises(schema.SchemaException):
+      event['serialNumbers'] = {'key': 'A', 'value': {}}
+    with self.assertRaises(schema.SchemaException):
+      event['serialNumbers'] = {'key': 'A', 'value': 3}
+
+  def testParametersValidator(self):
+    event = testlog.StationTestRun()
+    # Valid parameter
+    event['parameters'] = {'key': 'a', 'value': {}}
+    event['parameters'] = {'key': 'b', 'value': {'status': 'PASS'}}
+    event['parameters'] = {'key': 'ec_firmware_version', 'value': {
+        'description': 'Version of the EC firmware on the DUT.',
+        'group': 'What is this?',
+        'status': 'FAIL',
+        'valueUnit': 'version',
+        'numericValue': 0,
+        'expectedMinimum': 1.2,
+        'expectedMaximum': 3L,
+        'textValue': '1.56a',
+        'expectedRegex': '^1.5'
+    }}
+    # Wrong top-level type
+    with self.assertRaises(schema.SchemaException):
+      event['parameters'] = {'key': 'A', 'value': 'Google'}
+    # Wrong key type
+    with self.assertRaises(ValueError):
+      event['parameters'] = {'key': 3, 'value': {}}
+    # Wrong value type
+    with self.assertRaises(schema.SchemaException):
+      event['parameters'] = {'key': 'A', 'value': {'description': 0}}
+    with self.assertRaises(schema.SchemaException):
+      event['parameters'] = {'key': 'A', 'value': {'numericValue': 'Google'}}
+    # Value not in choices(PASS, FAIL)
+    with self.assertRaises(schema.SchemaException):
+      event['parameters'] = {'key': 'A', 'value': {'status': 'Google'}}
+    # Additional item in Dict
+    with self.assertRaises(schema.SchemaException):
+      event['parameters'] = {'key': 'A', 'value': {'NonExist': 'Hi'}}
+
+  def testSeriesValidator(self):
+    event = testlog.StationTestRun()
+    # Valid parameter
+    event['series'] = {'key': 'a', 'value': {}}
+    event['series'] = {'key': 'b', 'value': {'status': 'PASS', 'data': []}}
+    event['series'] = {'key': '5g_throughput', 'value': {
+        'description': '5G throughput speeds at an interval of 1 per ... .',
+        'group': 'Still do not know.',
+        'status': 'FAIL',
+        'keyUnit': 'second',
+        'valueUnit': 'MBit/s',
+        'data': [
+            {
+                'key': 1,
+                'status': 'FAIL',
+                'numericValue': 19.8,
+                'expectedMinimum': 20,
+                'expectedMaximum': 999L
+            }, {
+                'key': 2.0,
+                'status': 'PASS'
+            }
+        ]
+    }}
+    # Wrong top-level type, value should be a Dict
+    with self.assertRaises(schema.SchemaException):
+      event['series'] = {'key': 'A', 'value': 'Google'}
+    # Wrong second-level type, data should be a List
+    with self.assertRaises(schema.SchemaException):
+      event['series'] = {'key': 'A', 'value': {'data': 'Google'}}
+    with self.assertRaises(schema.SchemaException):
+      event['series'] = {'key': 'A', 'value': {'data': {}}}
+    # Wrong key type
+    with self.assertRaises(ValueError):
+      event['series'] = {'key': 3, 'value': {}}
+    # Wrong value type
+    with self.assertRaises(schema.SchemaException):
+      event['series'] = {'key': 'A', 'value': {'description': 0}}
+    with self.assertRaises(schema.SchemaException):
+      event['series'] = {'key': 'A', 'value': {'data': [{'key': 'Google'}]}}
+    with self.assertRaises(schema.SchemaException):
+      event['series'] = {'key': 'A', 'value': {
+          'data': [{'key': 0, 'status': 0}]}}
+    # Value not in choices(PASS, FAIL)
+    with self.assertRaises(schema.SchemaException):
+      event['series'] = {'key': 'A', 'value': {'status': 'Google'}}
+    with self.assertRaises(schema.SchemaException):
+      event['series'] = {'key': 'A', 'value': {
+          'data': [{'key': 0, 'status': 'Google'}]}}
+    # Additional item in Dict
+    with self.assertRaises(schema.SchemaException):
+      event['series'] = {'key': 'A', 'value': {'NonExist': 'Hi'}}
+    with self.assertRaises(schema.SchemaException):
+      event['series'] = {'key': 'A', 'value': {
+          'data': [{'key': 0, 'A': 'B'}]}}
+    # Missing item
+    with self.assertRaises(schema.SchemaException):
+      event['series'] = {'key': 'A', 'value': {'data': [{}]}}
+
+
+class StationTestRunApiValidatorTest(unittest.TestCase):
   """Validators primarily serve for StationTestRun."""
+
   def setUp(self):
     self.state_dir = tempfile.mkdtemp()
     self.tmp_dir = tempfile.mkdtemp()
@@ -268,6 +432,42 @@
     # Make sure the file names are distinguished
     self.assertEquals(len(paths), 2)
 
+  def testFromDict(self):
+    self._SimulateSubSession()
+    example_dict = {
+        'uuid': '8b127476-2604-422a-b9b1-f05e4f14bf72',
+        'type': 'station.test_run',
+        'apiVersion': '0.1',
+        'time': '2017-01-05T13:01:45.503Z',
+        'seq': 8202191,
+        'stationDeviceId': 'e7d3227e-f12d-42b3-9c64-0d9e8fa02f6d',
+        'stationInstallationId': '92228272-056e-4329-a432-64d3ed6dfa0c',
+        'testRunId': '8b127472-4593-4be8-9e94-79f228fc1adc',
+        'testName': 'the_test',
+        'testType': 'aaaa',
+        'arguments': {},
+        'status': 'PASSED',
+        'startTime': '2017-01-05T13:01:45.489Z',
+        'failures': [],
+        'parameters': {},
+        'series': {}
+    }
+    _unused_valid_event = testlog.EventBase.FromDict(example_dict)
+    example_dict['arguments']['A'] = {'value': 'yoyo'}
+    example_dict['arguments']['B'] = {'value': 9.53543, 'description': 'number'}
+    example_dict['arguments']['C'] = {'value': -9}
+    example_dict['failures'].append({'code': 'C', 'details': 'D'})
+    example_dict['serialNumbers'] = {}
+    example_dict['serialNumbers']['A'] = 'B'
+    example_dict['parameters']['A'] = {'description': 'D'}
+    example_dict['series']['A'] = {'description': 'D', 'data': [
+        {'key': 987, 'status': 'PASS'},
+        {'key': 7.8, 'status': 'FAIL'}]}
+    _unused_valid_event = testlog.EventBase.FromDict(example_dict)
+    with self.assertRaises(schema.SchemaException):
+      example_dict['arguments']['D'] = {}
+      _unused_invalid_event = testlog.EventBase.FromDict(example_dict)
+
 
 if __name__ == '__main__':
   logging.basicConfig(