initial import

The original osmo-gsm-tester was an internal development at sysmocom, mostly by
D. Laszlo Sitzer <dlsitzer@sysmocom.de>, of which this public osmo-gsm-tester
is a refactoring / rewrite.

This imports an early state of the refactoring and is not functional yet. Bits
from the earlier osmo-gsm-tester will be added as needed. The earlier commit
history is not imported.
diff --git a/test/Makefile b/test/Makefile
new file mode 100644
index 0000000..692c971
--- /dev/null
+++ b/test/Makefile
@@ -0,0 +1,9 @@
+.PHONY: check update
+
+check:
+	./all_tests.py
+
+update:
+	./all_tests.py -u
+
+# vim: noexpandtab tabstop=8 shiftwidth=8
diff --git a/test/_prep.py b/test/_prep.py
new file mode 100644
index 0000000..bfbe7b8
--- /dev/null
+++ b/test/_prep.py
@@ -0,0 +1,16 @@
+import sys, os
+
+script_dir = sys.path[0]
+top_dir = os.path.join(script_dir, '..')
+src_dir = os.path.join(top_dir, 'src')
+
+# to find the osmo_gsm_tester py module
+sys.path.append(src_dir)
+
+from osmo_gsm_tester import log
+
+log.targets = [ log.TestsTarget() ]
+
+if '-v' in sys.argv:
+    log.style_change(trace=True)
+
diff --git a/test/all_tests.py b/test/all_tests.py
new file mode 100755
index 0000000..f09fc0e
--- /dev/null
+++ b/test/all_tests.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import subprocess
+import time
+import difflib
+import argparse
+
+parser = argparse.ArgumentParser()
+parser.add_argument('testdir_or_test', nargs='*',
+        help='subdir name or test script name')
+parser.add_argument('-u', '--update', action='store_true',
+        help='Update test expecations instead of verifying them')
+args = parser.parse_args()
+
+def run_test(path):
+    print(path)
+    p = subprocess.Popen(path, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    o,e = p.communicate()
+    while True:
+        retval = p.poll()
+        if retval is not None:
+            break;
+        p.kill()
+        time.sleep(.1)
+    return retval, o.decode('utf-8'), e.decode('utf-8')
+
+def udiff(expect, got, expect_path):
+    expect = expect.splitlines(1)
+    got =  got.splitlines(1)
+    for line in difflib.unified_diff(expect, got,
+                                     fromfile=expect_path, tofile='got'):
+        sys.stderr.write(line)
+        if not line.endswith('\n'):
+            sys.stderr.write('[no-newline]\n')
+
+def verify_output(got, expect_file, update=False):
+    if os.path.isfile(expect_file):
+        if update:
+            with open(expect_file, 'w') as f:
+                f.write(got)
+            return True
+
+        with open(expect_file, 'r') as f:
+            expect = f.read()
+        if expect != got:
+            udiff(expect, got, expect_file)
+            sys.stderr.write('output mismatch: %r\n'
+                            % os.path.basename(expect_file))
+            return False
+    return True
+
+
+script_dir = sys.path[0]
+
+tests = []
+for f in os.listdir(script_dir):
+    file_path = os.path.join(script_dir, f)
+    if not os.path.isfile(file_path):
+        continue
+
+    if not (file_path.endswith('_test.py') or file_path.endswith('_test.sh')):
+        continue
+    tests.append(file_path)
+
+ran = []
+errors = []
+
+for test in sorted(tests):
+
+    if args.testdir_or_test:
+        if not any([t in test for t in args.testdir_or_test]):
+            continue
+
+    ran.append(test)
+
+    success = True
+
+    name, ext = os.path.splitext(test)
+    ok_file = name + '.ok'
+    err_file = name + '.err'
+
+    rc, out, err = run_test(test)
+
+    if rc != 0:
+        sys.stderr.write('%r: returned %d\n' % (os.path.basename(test), rc))
+        success = False
+
+    if not verify_output(out, ok_file, args.update):
+        success = False
+    if not verify_output(err, err_file, args.update):
+        success = False
+
+    if not success:
+        sys.stderr.write('--- stdout ---\n')
+        sys.stderr.write(out)
+        sys.stderr.write('--- stderr ---\n')
+        sys.stderr.write(err)
+        sys.stderr.write('---\n')
+        sys.stderr.write('Test failed: %r\n\n' % os.path.basename(test))
+        errors.append(test)
+
+if errors:
+    print('%d of %d TESTS FAILED:\n  %s' % (len(errors), len(ran), '\n  '.join(errors)))
+    exit(1)
+
+print('%d tests ok' % len(ran))
+exit(0)
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/config_test.err b/test/config_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/config_test.err
diff --git a/test/config_test.ok b/test/config_test.ok
new file mode 100644
index 0000000..dc88ae2
--- /dev/null
+++ b/test/config_test.ok
@@ -0,0 +1,46 @@
+{'bts': [{'addr': '10.42.42.114',
+          'name': 'sysmoBTS 1002',
+          'trx': [{'band': 'GSM-1800',
+                   'timeslots': ['CCCH+SDCCH4',
+                                 'SDCCH8',
+                                 'TCH/F_TCH/H_PDCH',
+                                 'TCH/F_TCH/H_PDCH',
+                                 'TCH/F_TCH/H_PDCH',
+                                 'TCH/F_TCH/H_PDCH',
+                                 'TCH/F_TCH/H_PDCH',
+                                 'TCH/F_TCH/H_PDCH']},
+                  {'band': 'GSM-1900',
+                   'timeslots': ['SDCCH8',
+                                 'PDCH',
+                                 'PDCH',
+                                 'PDCH',
+                                 'PDCH',
+                                 'PDCH',
+                                 'PDCH',
+                                 'PDCH']}],
+          'type': 'sysmobts'}],
+ 'modems': [{'dbus_path': '/sierra_0',
+             'imsi': '901700000009001',
+             'ki': 'D620F48487B1B782DA55DF6717F08FF9',
+             'msisdn': '7801'},
+            {'dbus_path': '/sierra_1',
+             'imsi': '901700000009002',
+             'ki': 'D620F48487B1B782DA55DF6717F08FF9',
+             'msisdn': '7802'}]}
+- expect validation success:
+Validation: OK
+- unknown item:
+--- - ERR: ValueError: config item not known: 'bts[].unknown_item'
+Validation: Error
+- wrong type modems[].imsi:
+--- - ERR: ValueError: config item is dict but should be a leaf node of type 'str': 'modems[].imsi'
+Validation: Error
+- invalid key with space:
+--- - ERR: ValueError: invalid config key: 'imsi '
+Validation: Error
+- list instead of dict:
+--- - ERR: ValueError: config item not known: 'a_dict[]'
+Validation: Error
+- unknown band:
+--- (item='bts[].trx[].band') ERR: ValueError: Unknown GSM band: 'what'
+Validation: Error
diff --git a/test/config_test.py b/test/config_test.py
new file mode 100755
index 0000000..de4ffb9
--- /dev/null
+++ b/test/config_test.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+
+import _prep
+
+import sys
+import os
+import io
+import pprint
+import copy
+
+from osmo_gsm_tester import config, log
+
+example_config_file = 'test.cfg'
+example_config = os.path.join(_prep.script_dir, 'config_test', example_config_file)
+cfg = config.read(example_config)
+
+pprint.pprint(cfg)
+
+test_schema = {
+    'modems[].dbus_path': config.STR,
+    'modems[].msisdn': config.STR,
+    'modems[].imsi': config.STR,
+    'modems[].ki': config.STR,
+    'bts[].name' : config.STR,
+    'bts[].type' : config.STR,
+    'bts[].addr' : config.STR,
+    'bts[].trx[].timeslots[]' : config.STR,
+    'bts[].trx[].band' : config.BAND,
+    'a_dict.foo' : config.INT,
+    }
+
+def val(which):
+    try:
+        config.validate(which, test_schema)
+        print('Validation: OK')
+    except ValueError:
+        log.log_exn()
+        print('Validation: Error')
+
+print('- expect validation success:')
+val(cfg)
+
+print('- unknown item:')
+c = copy.deepcopy(cfg)
+c['bts'][0]['unknown_item'] = 'no'
+val(c)
+
+print('- wrong type modems[].imsi:')
+c = copy.deepcopy(cfg)
+c['modems'][0]['imsi'] = {'no':'no'}
+val(c)
+
+print('- invalid key with space:')
+c = copy.deepcopy(cfg)
+c['modems'][0]['imsi '] = '12345'
+val(c)
+
+print('- list instead of dict:')
+c = copy.deepcopy(cfg)
+c['a_dict'] = [ 1, 2, 3 ]
+val(c)
+
+print('- unknown band:')
+c = copy.deepcopy(cfg)
+c['bts'][0]['trx'][0]['band'] = 'what'
+val(c)
+
+exit(0)
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/config_test/test.cfg b/test/config_test/test.cfg
new file mode 100644
index 0000000..c6d61bf
--- /dev/null
+++ b/test/config_test/test.cfg
@@ -0,0 +1,39 @@
+modems:
+
+- dbus_path: /sierra_0
+  msisdn: 7801
+  imsi: 901700000009001
+  ki: D620F48487B1B782DA55DF6717F08FF9
+
+- dbus_path: /sierra_1
+  msisdn: '7802'
+  imsi: '901700000009002'
+  ki: D620F48487B1B782DA55DF6717F08FF9
+
+# comment
+BTS:
+
+- name: sysmoBTS 1002
+  TYPE: sysmobts
+  addr: 10.42.42.114
+  trx:
+  - timeslots:
+    - CCCH+SDCCH4
+    - SDCCH8
+    - TCH/F_TCH/H_PDCH
+    - TCH/F_TCH/H_PDCH
+    - TCH/F_TCH/H_PDCH
+    - TCH/F_TCH/H_PDCH
+    - TCH/F_TCH/H_PDCH
+    - TCH/F_TCH/H_PDCH
+    band: GSM-1800
+  - timeslots:
+    - SDCCH8
+    - PDCH
+    - PDCH
+    - PDCH
+    - PDCH
+    - PDCH
+    - PDCH
+    - PDCH
+    band: GSM-1900
diff --git a/test/lock_test.err b/test/lock_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/lock_test.err
diff --git a/test/lock_test.ok b/test/lock_test.ok
new file mode 100644
index 0000000..2c0f31b
--- /dev/null
+++ b/test/lock_test.ok
@@ -0,0 +1,8 @@
+acquired lock: 'long_name'
+launched first, locked by: long_name
+launched second, locked by: long_name
+leaving lock: 'long_name'
+acquired lock: 'shorter'
+waited, locked by: shorter
+leaving lock: 'shorter'
+waited more, locked by: 
diff --git a/test/lock_test.sh b/test/lock_test.sh
new file mode 100755
index 0000000..c82d141
--- /dev/null
+++ b/test/lock_test.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+python3 ./lock_test_help.py long name &
+sleep .2
+echo "launched first, locked by: $(cat /tmp/lock_test)"
+python3 ./lock_test_help.py shorter &
+echo "launched second, locked by: $(cat /tmp/lock_test)"
+sleep .4
+echo "waited, locked by: $(cat /tmp/lock_test)"
+sleep .5
+echo "waited more, locked by: $(cat /tmp/lock_test)"
diff --git a/test/lock_test_help.py b/test/lock_test_help.py
new file mode 100644
index 0000000..720e100
--- /dev/null
+++ b/test/lock_test_help.py
@@ -0,0 +1,17 @@
+import sys
+import time
+
+import _prep
+
+from osmo_gsm_tester.utils import FileLock
+
+fl = FileLock('/tmp/lock_test', '_'.join(sys.argv[1:]))
+
+with fl:
+    print('acquired lock: %r' % fl.owner)
+    sys.stdout.flush()
+    time.sleep(0.5)
+    print('leaving lock: %r' % fl.owner)
+    sys.stdout.flush()
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/log_test.err b/test/log_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/log_test.err
diff --git a/test/log_test.ok b/test/log_test.ok
new file mode 100644
index 0000000..70257d5
--- /dev/null
+++ b/test/log_test.ok
@@ -0,0 +1,41 @@
+- Testing global log functions
+01:02:03 tst <origin>: from log.log()
+01:02:03 tst <origin> DBG: from log.dbg()
+01:02:03 tst <origin> ERR: from log.err()
+- Testing log.Origin functions
+01:02:03 tst some-name(some='detail'): hello log
+01:02:03 tst some-name(some='detail') ERR: hello err
+01:02:03 tst some-name(some='detail'): message {int=3, none=None, str='str\n', tuple=('foo', 42)}
+01:02:03 tst some-name(some='detail') DBG: hello dbg
+- Testing log.style()
+01:02:03: only time
+tst: only category
+DBG: only level
+some-name(some='detail'): only origin
+only src  [log_test.py:69]
+- Testing log.style_change()
+no log format
+01:02:03: add time
+but no time format
+01:02:03 DBG: add level
+01:02:03 tst DBG: add category
+01:02:03 tst DBG: add src  [log_test.py:84]
+01:02:03 tst some-name(some='detail') DBG: add origin  [log_test.py:86]
+- Testing origin_width
+01:02:03 tst               shortname: origin str set to 23 chars  [log_test.py:93]
+01:02:03 tst very long name(and_some=(3, 'things', 'in a tuple'), some='details'): long origin str  [log_test.py:95]
+01:02:03 tst very long name(and_some=(3, 'things', 'in a tuple'), some='details') DBG: long origin str dbg  [log_test.py:96]
+01:02:03 tst very long name(and_some=(3, 'things', 'in a tuple'), some='details') ERR: long origin str err  [log_test.py:97]
+- Testing log.Origin with omitted info
+01:02:03 tst                 LogTest: hello log, name implicit from class name  [log_test.py:102]
+01:02:03 ---           explicit_name: hello log, no category set  [log_test.py:106]
+01:02:03 ---                 LogTest: hello log, no category nor name set  [log_test.py:109]
+01:02:03 ---                 LogTest DBG: debug message, no category nor name set  [log_test.py:112]
+- Testing logging of Exceptions, tracing origins
+Not throwing an exception in 'with:' works.
+nested print just prints
+01:02:03 tst level1->level2->level3: nested log()  [log_test.py:144]
+01:02:03 tst level1->level2: nested l2 log() from within l3 scope  [log_test.py:145]
+01:02:03 tst level1->level2->level3 ERR: ValueError: bork  [log_test.py:146: raise ValueError('bork')]
+- Enter the same Origin context twice
+01:02:03 tst level1->level2: nested log  [log_test.py:158]
diff --git a/test/log_test.py b/test/log_test.py
new file mode 100755
index 0000000..6eca6aa
--- /dev/null
+++ b/test/log_test.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+
+# osmo_gsm_tester: logging tests
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import _prep
+
+import sys
+import os
+
+from osmo_gsm_tester import log
+
+#log.targets[0].get_time_str = lambda: '01:02:03'
+fake_time = '01:02:03'
+log.style_change(time=True, time_fmt=fake_time)
+
+print('- Testing global log functions')
+log.log('<origin>', log.C_TST, 'from log.log()')
+log.dbg('<origin>', log.C_TST, 'from log.dbg(), not seen')
+log.set_level(log.C_TST, log.L_DBG)
+log.dbg('<origin>', log.C_TST, 'from log.dbg()')
+log.set_level(log.C_TST, log.L_LOG)
+log.err('<origin>', log.C_TST, 'from log.err()')
+
+print('- Testing log.Origin functions')
+class LogTest(log.Origin):
+    pass
+
+t = LogTest()
+t.set_log_category(log.C_TST)
+t.set_name('some', 'name', some="detail")
+	
+t.log("hello log")
+t.err("hello err")
+t.dbg("hello dbg not visible")
+
+t.log("message", int=3, tuple=('foo', 42), none=None, str='str\n')
+
+log.set_level(log.C_TST, log.L_DBG)
+t.dbg("hello dbg")
+
+print('- Testing log.style()')
+
+log.style(time=True, category=False, level=False, origin=False, src=False, time_fmt=fake_time)
+t.dbg("only time")
+log.style(time=False, category=True, level=False, origin=False, src=False, time_fmt=fake_time)
+t.dbg("only category")
+log.style(time=False, category=False, level=True, origin=False, src=False, time_fmt=fake_time)
+t.dbg("only level")
+log.style(time=False, category=False, level=False, origin=True, src=False, time_fmt=fake_time)
+t.dbg("only origin")
+log.style(time=False, category=False, level=False, origin=False, src=True, time_fmt=fake_time)
+t.dbg("only src")
+
+print('- Testing log.style_change()')
+log.style(time=False, category=False, level=False, origin=False, src=False, time_fmt=fake_time)
+t.dbg("no log format")
+log.style_change(time=True)
+t.dbg("add time")
+log.style_change(time=True, time_fmt=0)
+t.dbg("but no time format")
+log.style_change(time=True, time_fmt=fake_time)
+log.style_change(level=True)
+t.dbg("add level")
+log.style_change(category=True)
+t.dbg("add category")
+log.style_change(src=True)
+t.dbg("add src")
+log.style_change(origin=True)
+t.dbg("add origin")
+
+print('- Testing origin_width')
+t = LogTest()
+t.set_log_category(log.C_TST)
+t.set_name('shortname')
+log.style(origin_width=23, time_fmt=fake_time)
+t.log("origin str set to 23 chars")
+t.set_name('very long name', some='details', and_some=(3, 'things', 'in a tuple'))
+t.log("long origin str")
+t.dbg("long origin str dbg")
+t.err("long origin str err")
+
+print('- Testing log.Origin with omitted info')
+t = LogTest()
+t.set_log_category(log.C_TST)
+t.log("hello log, name implicit from class name")
+
+t = LogTest()
+t.set_name('explicit_name')
+t.log("hello log, no category set")
+
+t = LogTest()
+t.log("hello log, no category nor name set")
+t.dbg("hello log, no category nor name set, not seen")
+log.set_level(log.C_DEFAULT, log.L_DBG)
+t.dbg("debug message, no category nor name set")
+
+print('- Testing logging of Exceptions, tracing origins')
+log.style(time_fmt=fake_time)
+
+class Thing(log.Origin):
+    def __init__(self, some_path):
+        self.set_log_category(log.C_TST)
+        self.set_name(some_path)
+
+    def say(self, msg):
+        print(msg)
+
+#log.style_change(trace=True)
+
+with Thing('print_redirected'):
+    print("Not throwing an exception in 'with:' works.")
+
+def l1():
+    level1 = Thing('level1')
+    with level1:
+        l2()
+
+def l2():
+    level2 = Thing('level2')
+    with level2:
+        l3(level2)
+
+def l3(level2):
+    level3 = Thing('level3')
+    with level3:
+        print('nested print just prints')
+        level3.log('nested log()')
+        level2.log('nested l2 log() from within l3 scope')
+        raise ValueError('bork')
+
+try:
+    l1()
+except Exception:
+    log.log_exn()
+
+print('- Enter the same Origin context twice')
+with Thing('level1'):
+    l2 = Thing('level2')
+    with l2:
+        with l2:
+            l2.log('nested log')
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/resource_test.err b/test/resource_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/resource_test.err
diff --git a/test/resource_test.ok b/test/resource_test.ok
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/resource_test.ok
diff --git a/test/resource_test.py b/test/resource_test.py
new file mode 100755
index 0000000..87e0473
--- /dev/null
+++ b/test/resource_test.py
@@ -0,0 +1,20 @@
+#!/usr/bin/env python3
+
+import tempfile
+import os
+
+import _prep
+
+from osmo_gsm_tester import config, log, resource
+
+
+workdir = tempfile.mkdtemp()
+try:
+
+    r = resource.Resources(os.path.join(_prep.script_dir, 'etc', 'resources.conf'),
+                           workdir)
+
+finally:
+	os.removedirs(workdir)
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/resource_test/etc/resources.conf b/test/resource_test/etc/resources.conf
new file mode 100644
index 0000000..b6de84a
--- /dev/null
+++ b/test/resource_test/etc/resources.conf
@@ -0,0 +1,115 @@
+# all hardware and interfaces available to this osmo-gsm-tester
+
+nitb_iface:
+- 10.42.42.1
+- 10.42.42.2
+- 10.42.42.3
+
+bts:
+- label: sysmoBTS 1002
+  type: sysmo
+  unit_id: 1
+  addr: 10.42.42.114
+  trx:
+  - band: GSM-1800
+
+- label: octBTS 3000
+  type: oct
+  unit_id: 5
+  addr: 10.42.42.115
+  trx:
+  - band: GSM-1800
+    hwaddr: 00:0c:90:32:b5:8a
+
+- label: nanoBTS 1900
+  type: nanobts
+  unit_id: 1902
+  addr: 10.42.42.190
+  trx:
+  - band: GSM-1900
+    hwaddr: 00:02:95:00:41:b3
+
+arfcn:
+- GSM-1800: [512, 514, 516, 518, 520]
+- GSM-1900: [540, 542, 544, 546, 548]
+
+modem:
+- label: m7801
+  path: '/wavecom_0'
+  imsi: 901700000007801
+  ki: D620F48487B1B782DA55DF6717F08FF9
+
+- label: m7802
+  path: '/wavecom_1'
+  imsi: 901700000007802
+  ki: 47FDB2D55CE6A10A85ABDAD034A5B7B3
+
+- label: m7803
+  path: '/wavecom_2'
+  imsi: 901700000007803
+  ki: ABBED4C91417DF710F60675B6EE2C8D2
+
+- label: m7804
+  path: '/wavecom_3'
+  imsi: 901700000007804
+  ki: 8BA541179156F2BF0918CA3CFF9351B0
+
+- label: m7805
+  path: '/wavecom_4'
+  imsi: 901700000007805
+  ki: 82BEC24B5B50C9FAA69D17DEC0883A23
+
+- label: m7806
+  path: '/wavecom_5'
+  imsi: 901700000007806
+  ki: DAF6BD6A188F7A4F09866030BF0F723D
+
+- label: m7807
+  path: '/wavecom_6'
+  imsi: 901700000007807
+  ki: AEB411CFE39681A6352A1EAE4DDC9DBA
+
+- label: m7808
+  path: '/wavecom_7'
+  imsi: 901700000007808
+  ki: F5DEF8692B305D7A65C677CA9EEE09C4
+
+- label: m7809
+  path: '/wavecom_8'
+  imsi: 901700000007809
+  ki: A644F4503E812FD75329B1C8D625DA44
+
+- label: m7810
+  path: '/wavecom_9'
+  imsi: 901700000007810
+  ki: EF663BDF3477DCD18D3D2293A2BAED67
+
+- label: m7811
+  path: '/wavecom_10'
+  imsi: 901700000007811
+  ki: E88F37F048A86A9BC4D652539228C039
+
+- label: m7812
+  path: '/wavecom_11'
+  imsi: 901700000007812
+  ki: E8D940DD66FCF6F1CD2C0F8F8C45633D
+
+- label: m7813
+  path: '/wavecom_12'
+  imsi: 901700000007813
+  ki: DBF534700C10141C49F699B0419107E3
+
+- label: m7814
+  path: '/wavecom_13'
+  imsi: 901700000007814
+  ki: B36021DEB90C4EA607E408A92F3B024D
+
+- label: m7815
+  path: '/wavecom_14'
+  imsi: 901700000007815
+  ki: 1E209F6F839F9195778C4F96BE281A24
+
+- label: m7816
+  path: '/wavecom_15'
+  imsi: 901700000007816
+  ki: BF827D219E739DD189F6F59E60D6455C
diff --git a/test/suite_test.err b/test/suite_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/suite_test.err
diff --git a/test/suite_test.ok b/test/suite_test.ok
new file mode 100644
index 0000000..173fee9
--- /dev/null
+++ b/test/suite_test.ok
@@ -0,0 +1,24 @@
+- non-existing suite dir
+cnf does_not_exist ERR: RuntimeError: No such directory: 'does_not_exist'
+- no suite.conf
+--- empty_dir->suite_test/empty_dir/suite.conf ERR: FileNotFoundError: [Errno 2] No such file or directory: 'suite_test/empty_dir/suite.conf'
+- valid suite dir
+defaults:
+  timeout: 60s
+resources:
+  bts: '1'
+  modem: '2'
+  msisdn: '2'
+  nitb: '1'
+  nitb_iface: '1'
+
+- run hello world test
+tst test_suite->hello_world.py: hello world
+tst test_suite->hello_world.py: I am 'suite_test/test_suite' / 'hello_world.py'
+tst test_suite->hello_world.py: one
+tst test_suite->hello_world.py: two
+tst test_suite->hello_world.py: three
+- a test with an error
+tst test_suite->test_error.py: I am 'test_error.py'  [test_error.py:1]
+tst test_suite->test_error.py ERR: AssertionError:   [test_error.py:2: assert(False)]
+- graceful exit.
diff --git a/test/suite_test.py b/test/suite_test.py
new file mode 100755
index 0000000..5e6c312
--- /dev/null
+++ b/test/suite_test.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+import os
+import _prep
+from osmo_gsm_tester import log, suite, config
+
+#log.style_change(trace=True)
+
+print('- non-existing suite dir')
+assert(log.run_logging_exceptions(suite.load, 'does_not_exist') == None)
+
+print('- no suite.conf')
+assert(log.run_logging_exceptions(suite.load, os.path.join('suite_test', 'empty_dir')) == None)
+
+print('- valid suite dir')
+example_suite_dir = os.path.join('suite_test', 'test_suite')
+s = suite.load(example_suite_dir)
+assert(isinstance(s, suite.Suite))
+print(config.tostr(s.conf))
+
+print('- run hello world test')
+s.run_tests_by_name('hello_world')
+
+log.style_change(src=True)
+#log.style_change(trace=True)
+print('- a test with an error')
+s.run_tests_by_name('test_error')
+
+print('- graceful exit.')
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/suite_test/empty_dir/.unrelated_file b/test/suite_test/empty_dir/.unrelated_file
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/suite_test/empty_dir/.unrelated_file
diff --git a/test/suite_test/test_suite/hello_world.py b/test/suite_test/test_suite/hello_world.py
new file mode 100644
index 0000000..c992139
--- /dev/null
+++ b/test/suite_test/test_suite/hello_world.py
@@ -0,0 +1,3 @@
+print('hello world')
+print('I am %r / %r' % (this.suite, this.test))
+print('one\ntwo\nthree')
diff --git a/test/suite_test/test_suite/mo_mt_sms.py b/test/suite_test/test_suite/mo_mt_sms.py
new file mode 100644
index 0000000..cf44357
--- /dev/null
+++ b/test/suite_test/test_suite/mo_mt_sms.py
@@ -0,0 +1,18 @@
+nitb_iface = resources.nitb_iface()
+nitb = resources.nitb()
+bts = resources.bts()
+ms_mo = resources.modem()
+ms_mt = resources.modem()
+
+nitb.start(nitb_iface)
+bts.start(nitb)
+
+nitb.add_subscriber(ms_mo, resources.msisdn())
+nitb.add_subscriber(ms_mt, resources.msisdn())
+
+ms_mo.start()
+ms_mt.start()
+wait(nitb.subscriber_attached, ms_mo, ms_mt)
+
+sms = ms_mo.sms_send(ms_mt.msisdn)
+wait(nitb.sms_received, sms)
diff --git a/test/suite_test/test_suite/mo_sms.py b/test/suite_test/test_suite/mo_sms.py
new file mode 100644
index 0000000..d9517dd
--- /dev/null
+++ b/test/suite_test/test_suite/mo_sms.py
@@ -0,0 +1,20 @@
+nitb_iface = resources.nitb_iface()
+nitb = resources.nitb()
+bts = resources.bts()
+ms_ext = resources.msisdn()
+fake_ext = resources.msisdn()
+ms = resources.modem()
+
+nitb.configure(nitb_iface, bts)
+bts.configure(nitb)
+
+nitb.start()
+bts.start()
+
+nitb.add_fake_ext(fake_ext)
+nitb.add_subscriber(ms, ms_ext)
+
+ms.start()
+wait(nitb.subscriber_attached, ms)
+sms = ms.sms_send(fake_ext)
+wait(nitb.sms_received, sms)
diff --git a/test/suite_test/test_suite/suite.conf b/test/suite_test/test_suite/suite.conf
new file mode 100644
index 0000000..7596ca0
--- /dev/null
+++ b/test/suite_test/test_suite/suite.conf
@@ -0,0 +1,9 @@
+resources:
+  nitb_iface: 1
+  nitb: 1
+  bts: 1
+  msisdn: 2
+  modem: 2
+
+defaults:
+  timeout: 60s
diff --git a/test/suite_test/test_suite/test_error.py b/test/suite_test/test_suite/test_error.py
new file mode 100644
index 0000000..a45f7a6
--- /dev/null
+++ b/test/suite_test/test_suite/test_error.py
@@ -0,0 +1,2 @@
+print('I am %r' % this.test)
+assert(False)
diff --git a/test/suite_test/test_suite/test_error2.py b/test/suite_test/test_suite/test_error2.py
new file mode 100755
index 0000000..7e04588
--- /dev/null
+++ b/test/suite_test/test_suite/test_error2.py
@@ -0,0 +1,8 @@
+#!/usr/bin/env python3
+
+from osmo_gsm_tester import test
+from osmo_gsm_tester.test import resources
+
+print('I am %r / %r' % (test.suite.name(), test.test.name()))
+
+assert(False)
diff --git a/test/template_test.err b/test/template_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/template_test.err
diff --git a/test/template_test.ok b/test/template_test.ok
new file mode 100644
index 0000000..0ccc23a
--- /dev/null
+++ b/test/template_test.ok
@@ -0,0 +1,151 @@
+- Testing: fill a config file with values
+cnf Templates DBG: rendering osmo-nitb.cfg.tmpl
+!
+! OpenBSC configuration saved from vty
+!
+password foo
+!
+log stderr
+ logging filter all 1
+ logging color 0
+ logging print category 0
+ logging print extended-timestamp 1
+ logging level all debug
+!
+line vty
+ no login
+ bind val_vty_bind_ip
+!
+e1_input
+ e1_line 0 driver ipa
+ ipa bind val_abis_bind_ip
+network
+ network country code val_mcc
+ mobile network code val_mnc
+ short name val_net_name_short
+ long name val_net_name_long
+ auth policy val_net_auth_policy
+ location updating reject cause 13
+ encryption a5 val_encryption
+ neci 1
+ rrlp mode none
+ mm info 1
+ handover 0
+ handover window rxlev averaging 10
+ handover window rxqual averaging 1
+ handover window rxlev neighbor averaging 10
+ handover power budget interval 6
+ handover power budget hysteresis 3
+ handover maximum distance 9999
+ timer t3101 10
+ timer t3103 0
+ timer t3105 0
+ timer t3107 0
+ timer t3109 4
+ timer t3111 0
+ timer t3113 60
+ timer t3115 0
+ timer t3117 0
+ timer t3119 0
+ timer t3141 0
+smpp
+ local-tcp-ip val_smpp_bind_ip 2775
+ system-id test
+ policy closed
+ esme test
+  password test
+  default-route
+ctrl
+ bind val_ctrl_bind_ip
+ bts 0
+  type val_type_bts0
+  band val_band_bts0
+  cell_identity 0
+  location_area_code val_bts.location_area_code_bts0
+  training_sequence_code 7
+  base_station_id_code val_bts.base_station_id_code_bts0
+  ms max power 15
+  cell reselection hysteresis 4
+  rxlev access min 0
+  channel allocator ascending
+  rach tx integer 9
+  rach max transmission 7
+  ip.access unit_id val_bts.unit_id_bts0 0
+  oml ip.access stream_id val_bts.stream_id_bts0 line 0
+  gprs mode none
+  trx 0
+   rf_locked 0
+   arfcn val_trx_arfcn_trx0
+   nominal power 23
+   max_power_red val_trx_max_power_red_trx0
+   rsl e1 tei 0
+   timeslot 0
+    phys_chan_config val_phys_chan_config_0
+   timeslot 1
+    phys_chan_config val_phys_chan_config_1
+   timeslot 2
+    phys_chan_config val_phys_chan_config_2
+   timeslot 3
+    phys_chan_config val_phys_chan_config_3
+  trx 1
+   rf_locked 0
+   arfcn val_trx_arfcn_trx1
+   nominal power 23
+   max_power_red val_trx_max_power_red_trx1
+   rsl e1 tei 0
+   timeslot 0
+    phys_chan_config val_phys_chan_config_0
+   timeslot 1
+    phys_chan_config val_phys_chan_config_1
+   timeslot 2
+    phys_chan_config val_phys_chan_config_2
+   timeslot 3
+    phys_chan_config val_phys_chan_config_3
+ bts 1
+  type val_type_bts1
+  band val_band_bts1
+  cell_identity 0
+  location_area_code val_bts.location_area_code_bts1
+  training_sequence_code 7
+  base_station_id_code val_bts.base_station_id_code_bts1
+  ms max power 15
+  cell reselection hysteresis 4
+  rxlev access min 0
+  channel allocator ascending
+  rach tx integer 9
+  rach max transmission 7
+  ip.access unit_id val_bts.unit_id_bts1 0
+  oml ip.access stream_id val_bts.stream_id_bts1 line 0
+  gprs mode none
+  trx 0
+   rf_locked 0
+   arfcn val_trx_arfcn_trx0
+   nominal power 23
+   max_power_red val_trx_max_power_red_trx0
+   rsl e1 tei 0
+   timeslot 0
+    phys_chan_config val_phys_chan_config_0
+   timeslot 1
+    phys_chan_config val_phys_chan_config_1
+   timeslot 2
+    phys_chan_config val_phys_chan_config_2
+   timeslot 3
+    phys_chan_config val_phys_chan_config_3
+  trx 1
+   rf_locked 0
+   arfcn val_trx_arfcn_trx1
+   nominal power 23
+   max_power_red val_trx_max_power_red_trx1
+   rsl e1 tei 0
+   timeslot 0
+    phys_chan_config val_phys_chan_config_0
+   timeslot 1
+    phys_chan_config val_phys_chan_config_1
+   timeslot 2
+    phys_chan_config val_phys_chan_config_2
+   timeslot 3
+    phys_chan_config val_phys_chan_config_3
+
+- Testing: expect to fail on invalid templates dir
+sucess: setting non-existing templates dir raised RuntimeError
+
diff --git a/test/template_test.py b/test/template_test.py
new file mode 100755
index 0000000..38495bf
--- /dev/null
+++ b/test/template_test.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+
+import _prep
+
+import sys
+import os
+
+from osmo_gsm_tester import template, log
+
+log.set_level(log.C_CNF, log.L_DBG)
+
+print('- Testing: fill a config file with values')
+
+mock_timeslot_list=(
+        { 'phys_chan_config': 'val_phys_chan_config_0' },
+        { 'phys_chan_config': 'val_phys_chan_config_1' },
+        { 'phys_chan_config': 'val_phys_chan_config_2' },
+        { 'phys_chan_config': 'val_phys_chan_config_3' },
+        )
+
+mock_bts = {
+    'type': 'val_type',
+    'band': 'val_band',
+    'location_area_code': 'val_bts.location_area_code',
+    'base_station_id_code': 'val_bts.base_station_id_code',
+    'unit_id': 'val_bts.unit_id',
+    'stream_id': 'val_bts.stream_id',
+    'trx_list': (
+            dict(arfcn='val_trx_arfcn_trx0',
+                max_power_red='val_trx_max_power_red_trx0',
+                timeslot_list=mock_timeslot_list),
+            dict(arfcn='val_trx_arfcn_trx1',
+                max_power_red='val_trx_max_power_red_trx1',
+                timeslot_list=mock_timeslot_list),
+            )
+}
+
+def clone_mod(d, val_ext):
+    c = dict(d)
+    for name in c.keys():
+        if isinstance(c[name], str):
+            c[name] = c[name] + val_ext
+        elif isinstance(c[name], dict):
+            c[name] = clone_mod(c[name], val_ext)
+    return c
+
+mock_bts0 = clone_mod(mock_bts, '_bts0')
+mock_bts1 = clone_mod(mock_bts, '_bts1')
+
+vals = dict(
+        vty_bind_ip='val_vty_bind_ip',
+        abis_bind_ip='val_abis_bind_ip',
+        mcc='val_mcc',
+        mnc='val_mnc',
+        net_name_short='val_net_name_short',
+        net_name_long='val_net_name_long',
+        net_auth_policy='val_net_auth_policy',
+        encryption='val_encryption',
+        smpp_bind_ip='val_smpp_bind_ip',
+        ctrl_bind_ip='val_ctrl_bind_ip',
+        bts_list=(mock_bts0, mock_bts1)
+        )
+
+print(template.render('osmo-nitb.cfg', vals))
+
+print('- Testing: expect to fail on invalid templates dir')
+try:
+    template.set_templates_dir('non-existing dir')
+    sys.stderr.write('Error: setting non-existing templates dir should raise RuntimeError\n')
+    assert(False)
+except RuntimeError:
+    # not logging exception to omit non-constant path name from expected output
+    print('sucess: setting non-existing templates dir raised RuntimeError\n')
+    pass
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/template_test/osmo-nitb.cfg.tmpl b/test/template_test/osmo-nitb.cfg.tmpl
new file mode 100644
index 0000000..3404b7f
--- /dev/null
+++ b/test/template_test/osmo-nitb.cfg.tmpl
@@ -0,0 +1,87 @@
+!
+! OpenBSC configuration saved from vty
+!
+password foo
+!
+log stderr
+ logging filter all 1
+ logging color 0
+ logging print category 0
+ logging print extended-timestamp 1
+ logging level all debug
+!
+line vty
+ no login
+ bind ${vty_bind_ip}
+!
+e1_input
+ e1_line 0 driver ipa
+ ipa bind ${abis_bind_ip}
+network
+ network country code ${mcc}
+ mobile network code ${mnc}
+ short name ${net_name_short}
+ long name ${net_name_long}
+ auth policy ${net_auth_policy}
+ location updating reject cause 13
+ encryption a5 ${encryption}
+ neci 1
+ rrlp mode none
+ mm info 1
+ handover 0
+ handover window rxlev averaging 10
+ handover window rxqual averaging 1
+ handover window rxlev neighbor averaging 10
+ handover power budget interval 6
+ handover power budget hysteresis 3
+ handover maximum distance 9999
+ timer t3101 10
+ timer t3103 0
+ timer t3105 0
+ timer t3107 0
+ timer t3109 4
+ timer t3111 0
+ timer t3113 60
+ timer t3115 0
+ timer t3117 0
+ timer t3119 0
+ timer t3141 0
+smpp
+ local-tcp-ip ${smpp_bind_ip} 2775
+ system-id test
+ policy closed
+ esme test
+  password test
+  default-route
+ctrl
+ bind ${ctrl_bind_ip}
+%for bts in bts_list:
+ bts ${loop.index}
+  type ${bts.type}
+  band ${bts.band}
+  cell_identity 0
+  location_area_code ${bts.location_area_code}
+  training_sequence_code 7
+  base_station_id_code ${bts.base_station_id_code}
+  ms max power 15
+  cell reselection hysteresis 4
+  rxlev access min 0
+  channel allocator ascending
+  rach tx integer 9
+  rach max transmission 7
+  ip.access unit_id ${bts.unit_id} 0
+  oml ip.access stream_id ${bts.stream_id} line 0
+  gprs mode none
+% for trx in bts.trx_list:
+  trx ${loop.index}
+   rf_locked 0
+   arfcn ${trx.arfcn}
+   nominal power 23
+   max_power_red ${trx.max_power_red}
+   rsl e1 tei 0
+%  for ts in trx.timeslot_list:
+   timeslot ${loop.index}
+    phys_chan_config ${ts.phys_chan_config}
+%  endfor
+% endfor
+%endfor