diff --git a/.gitignore b/.gitignore
index bf52402..b589e30 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ src/tiles/
 *pycache*
 *swp
 *bak
+/.idea
\ No newline at end of file
diff --git a/README.md b/README.md
index dfc5d17..79382c7 100644
--- a/README.md
+++ b/README.md
@@ -7,3 +7,31 @@ Python dependencies:
 ```bash
 pip3 install -r requirements.txt
 ```
+
+## Running unittests
+
+To run all unittests execute next command from project root folder:
+
+```bash
+python3 -m unittest discover -s tests
+```
+
+this will search for all `test*.py` files within `tests` directory
+and execute tests from those files.
+
+## Running integration tests
+
+File `integration-tests/full_drules_gen.py` is intended to generate drules
+files for all 6 themes from main Organic Maps repo. It could be used to understand
+which parts of the project are actually used by Organic Maps repo.
+
+Usage:
+
+```shell
+cd integration-tests
+python3 full_drules_gen.py -d ../../../data -o drules --txt
+```
+
+This command will run generation for styles - default light, default dark,
+outdoors light, outdoors dark, vehicle light, vehicle dark and put `*.bin`
+and `*.txt` files into 'drules' subfolder.
diff --git a/integration-tests/drules/.gitkeep b/integration-tests/drules/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/integration-tests/full_drules_gen.py b/integration-tests/full_drules_gen.py
new file mode 100755
index 0000000..986a9af
--- /dev/null
+++ b/integration-tests/full_drules_gen.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+
+import sys
+from copy import deepcopy
+from optparse import OptionParser
+from pathlib import Path
+import logging
+
+# Add `src` directory to the import paths
+sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
+
+import libkomwm
+
+FORMAT = '%(asctime)s [%(levelname)s] %(message)s'
+logging.basicConfig(format=FORMAT)
+log = logging.getLogger('test_drules_gen')
+log.setLevel(logging.INFO)
+
+styles = {
+    'default_light':  ['styles/default/light/style.mapcss',  'styles/default/include'],
+    'default_dark':   ['styles/default/dark/style.mapcss',   'styles/default/include'],
+    'outdoors_light': ['styles/outdoors/light/style.mapcss', 'styles/outdoors/include'],
+    'outdoors_dark':  ['styles/outdoors/dark/style.mapcss',  'styles/outdoors/include'],
+    'vehicle_light':  ['styles/vehicle/light/style.mapcss',  'styles/vehicle/include'],
+    'vehicle_dark':   ['styles/vehicle/dark/style.mapcss',   'styles/vehicle/include'],
+}
+
+
+def full_styles_regenerate(options):
+    log.info("Start generating styles")
+    libkomwm.MULTIPROCESSING = False
+    prio_ranges_orig = deepcopy(libkomwm.prio_ranges)
+
+    for name, (style_path, include_path) in styles.items():
+        log.info(f"Generating {name} style ...")
+
+        # Restore initial state
+        libkomwm.prio_ranges = deepcopy(prio_ranges_orig)
+        libkomwm.visibilities = {}
+
+        options.filename = options.data + '/' + style_path
+        options.priorities_path = options.data + '/' + include_path
+        options.outfile = options.outdir + '/' + name
+
+        # Run generation
+        libkomwm.komap_mapswithme(options)
+    log.info(f"Done!")
+
+def main():
+    parser = OptionParser()
+    parser.add_option("-d", "--data-path", dest="data",
+                      help="path to mapcss-mapping.csv and other files", metavar="PATH")
+    parser.add_option("-o", "--output-dir", dest="outdir", default="drules",
+                      help="output directory", metavar="DIR")
+    parser.add_option("-f", "--minzoom", dest="minzoom", default=0, type="int",
+                      help="minimal available zoom level", metavar="ZOOM")
+    parser.add_option("-t", "--maxzoom", dest="maxzoom", default=20, type="int",
+                      help="maximal available zoom level", metavar="ZOOM")
+    parser.add_option("-x", "--txt", dest="txt", action="store_true",
+                      help="create a text file for output", default=False)
+
+    (options, args) = parser.parse_args()
+
+    if options.data is None:
+        parser.error("Please specify base 'data' path.")
+
+    if options.outdir is None:
+        parser.error("Please specify base output path.")
+
+    full_styles_regenerate(options)
+
+if __name__ == '__main__':
+    main()
diff --git a/requirements.txt b/requirements.txt
index 577a8b3..1d3ed8c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1 @@
-# The core is using protobuf 3.3.0 still (3party/protobuf/), so no point to require newer versions.
-# E.g. Ubuntu 24.04 LTS ships with python3-protobuf 3.21.12 and it works fine.
-protobuf~=3.21.0
+protobuf~=4.23.0
diff --git a/src/libkomwm.py b/src/libkomwm.py
index 2a85102..ac6992b 100644
--- a/src/libkomwm.py
+++ b/src/libkomwm.py
@@ -89,6 +89,7 @@ Priorities ranges' rendering order overview:
 - BG-by-size: landcover areas sorted by their size
 '''
 
+# TODO: Implement better error handling
 validation_errors_count = 0
 
 def to_boolean(s):
@@ -104,6 +105,9 @@ def mwm_encode_color(colors, st, prefix='', default='black'):
     if prefix:
         prefix += "-"
     opacity = hex(255 - int(255 * float(st.get(prefix + "opacity", 1))))
+    # TODO: Refactoring idea: here color is converted from float to hex. While MapCSS class
+    #       reads colors from *.mapcss files and converts to float. How about changing MapCSS
+    #       to keep hex values and avoid Hex->Float->Hex operations?
     color = whatever_to_hex(st.get(prefix + 'color', default))[1:]
     result = int(opacity + color, 16)
     colors.add(result)
@@ -118,6 +122,7 @@ def mwm_encode_image(st, prefix='icon', bgprefix='symbol'):
         return False
     # strip last ".svg"
     handle = st.get(prefix + "image")[:-4]
+    # TODO: return `handle` only once
     return handle, handle
 
 
@@ -454,6 +459,7 @@ def get_drape_priority(cl, dr_type, object_id, auto_dr_type = None, auto_comment
     return 0
 
 
+# TODO: Split large function to smaller ones
 def komap_mapswithme(options):
     if options.data and os.path.isdir(options.data):
         ddir = options.data
@@ -464,6 +470,7 @@ def komap_mapswithme(options):
     class_order = []
     class_tree = {}
 
+    # TODO: Introduce new function to parse `colors.txt` for better testability
     colors_file_name = os.path.join(ddir, 'colors.txt')
     colors = set()
     if os.path.exists(colors_file_name):
@@ -472,6 +479,7 @@ def komap_mapswithme(options):
             colors.add(int(colorLine))
         colors_in_file.close()
 
+    # TODO: Introduce new function to parse `patterns.txt` for better testability
     patterns = []
     def addPattern(dashes):
         if dashes and dashes not in patterns:
@@ -488,9 +496,11 @@ def komap_mapswithme(options):
     types_file = open(os.path.join(ddir, 'types.txt'), "w")
 
     # The mapcss-mapping.csv format is described inside the file itself.
+    # TODO: introduce new function to parse 'mapcss-mapping.csv' for better testability
     cnt = 1
     unique_types_check = set()
-    for row in csv.reader(open(os.path.join(ddir, 'mapcss-mapping.csv')), delimiter=';'):
+    mapping_file = open(os.path.join(ddir, 'mapcss-mapping.csv'))
+    for row in csv.reader(mapping_file, delimiter=';'):
         if len(row) <= 1 or row[0].startswith('#'):
             # Allow for empty lines and comment lines starting with '#'.
             continue
@@ -537,6 +547,7 @@ def komap_mapswithme(options):
                 print("mapswithme", file=types_file)
         class_tree[cl] = row[0]
     class_order.sort()
+    mapping_file.close()
     types_file.close()
 
     output = ''
@@ -554,8 +565,10 @@ def komap_mapswithme(options):
         for i, t in enumerate(v.keys()):
             mapcss_static_tags[t] = mapcss_static_tags.get(t, True) and i == 0
 
+    # TODO: Introduce new function to parse `mapcss-dynamic.txt` for better testability
     # Get all mapcss dynamic tags from mapcss-dynamic.txt
-    mapcss_dynamic_tags = set([line.rstrip() for line in open(os.path.join(ddir, 'mapcss-dynamic.txt'))])
+    with open(os.path.join(ddir, 'mapcss-dynamic.txt')) as dynamic_file:
+        mapcss_dynamic_tags = set([line.rstrip() for line in dynamic_file])
 
     # Parse style mapcss
     global style
@@ -579,6 +592,7 @@ def komap_mapswithme(options):
 
     style.finalize_choosers_tree()
 
+    # TODO: Introduce new function to work with colors for better testability
     # Get colors section from style
     style_colors = {}
     raw_style_colors = style.get_colors()
@@ -616,6 +630,7 @@ def komap_mapswithme(options):
 
     all_draw_elements = set()
 
+    # TODO: refactor next for-loop for readability and testability
     global validation_errors_count
     for results in imapfunc(query_style, ((cl, classificator[cl], options.minzoom, options.maxzoom) for cl in class_order)):
         for result in results:
@@ -920,6 +935,7 @@ def komap_mapswithme(options):
         return -1
     viskeys.sort(key=functools.cmp_to_key(cmprepl))
 
+    # TODO: Introduce new function to dump `visibility.txt` and `classificator.txt` for better testability
     visibility_file = open(os.path.join(ddir, 'visibility.txt'), "w")
     classificator_file = open(os.path.join(ddir, 'classificator.txt'), "w")
 
@@ -942,11 +958,13 @@ def komap_mapswithme(options):
     visibility_file.close()
     classificator_file.close()
 
+    # TODO: Introduce new function to dump `colors.txt` for better testability
     colors_file = open(colors_file_name, "w")
     for c in sorted(colors):
         colors_file.write("%d\n" % (c))
     colors_file.close()
 
+    # TODO: Introduce new function to dump `patterns.txt` for better testability
     patterns_file = open(patterns_file_name, "w")
     for p in patterns:
         patterns_file.write("%s\n" % (' '.join(str(elem) for elem in p)))
diff --git a/src/mapcss/Condition.py b/src/mapcss/Condition.py
index a3b84cb..50034ed 100644
--- a/src/mapcss/Condition.py
+++ b/src/mapcss/Condition.py
@@ -24,7 +24,7 @@ class Condition:
             params = (params,)
         self.params = params      # e.g. ('highway','primary')
         if typez == "regex":
-            self.regex = re.compile(self.params[0], re.I)
+            self.regex = re.compile(self.params[1], re.I)
 
     def extract_tag(self):
         if self.params[0][:2] == "::" or self.type == "regex":
diff --git a/src/mapcss/Eval.py b/src/mapcss/Eval.py
index 131e6bb..e37e670 100644
--- a/src/mapcss/Eval.py
+++ b/src/mapcss/Eval.py
@@ -15,6 +15,10 @@
 #   You should have received a copy of the GNU General Public License
 #   along with kothic.  If not, see <http://www.gnu.org/licenses/>.
 
+import logging
+
+logger = logging.getLogger('mapcss.Eval')
+logger.setLevel(logging.ERROR)
 
 class Eval():
     def __init__(self, s='eval()'):
@@ -57,6 +61,8 @@ class Eval():
                  "any": fake_compute,
                  "min": fake_compute,
                  "max": fake_compute,
+                 "cond": fake_compute,
+                 "boolean": fake_compute,
                  })
         return tags
 
@@ -99,12 +105,15 @@ class Eval():
                 return "{:.4g}".format(result)
 
             return str(result)
-        except:
+        except Exception as e:
+            logger.warning(f"Error evaluating expression `{self.expr_text}`", e)
             return ""
 
     def __repr__(self):
         return "eval(%s)" % self.expr_text
 
+    def __eq__(self, other):
+        return type(self) == type(other) and self.expr_text == other.expr_text
 
 def m_boolean(expr):
     expr = str(expr)
diff --git a/src/mapcss/StyleChooser.py b/src/mapcss/StyleChooser.py
index 9d3518e..0f5eb50 100644
--- a/src/mapcss/StyleChooser.py
+++ b/src/mapcss/StyleChooser.py
@@ -76,6 +76,8 @@ class StyleChooser:
     The styles property is an array of all the style objects to be drawn
         if any of the ruleChains evaluate to true.
     """
+    # TODO: use logging for debug logs
+
     def __repr__(self):
         return "{(%s) : [%s] }\n" % (self.ruleChains, self.styles)
 
@@ -122,6 +124,7 @@ class StyleChooser:
 
         return rule.runtime_conditions
 
+    # TODO: Rename to "applyStyles"
     def updateStyles(self, sl, tags, xscale, zscale, filter_by_runtime_conditions):
         # Are any of the ruleChains fulfilled?
         rule_and_object_id = self.testChains(tags)
@@ -143,6 +146,7 @@ class StyleChooser:
                 for a, b in r.items():
                     "calculating eval()'s"
                     if type(b) == self.eval_type:
+                        # TODO: Move next block to a separate function
                         combined_style = {}
                         for t in sl:
                             combined_style.update(t)
@@ -235,6 +239,7 @@ class StyleChooser:
         """
         adds to this.styles
         """
+        # TODO: move next for-loop to a new method. Don't call it on every style append
         for r in self.ruleChains:
             if not self.selzooms:
                 self.selzooms = [r.minZoom, r.maxZoom]
diff --git a/src/mapcss/__init__.py b/src/mapcss/__init__.py
index 8af9730..cc1713a 100644
--- a/src/mapcss/__init__.py
+++ b/src/mapcss/__init__.py
@@ -25,6 +25,7 @@ from .Condition import Condition
 NEEDED_KEYS = set(["width", "casing-width", "casing-width-add", "fill-color", "fill-image", "icon-image", "text", "extrude",
                    "background-image", "background-color", "pattern-image", "shield-color", "symbol-shape"])
 
+# TODO: Unused constant
 WHITESPACE = re.compile(r'\s+ ', re.S | re.X)
 
 COMMENT = re.compile(r'\/\* .*? \*\/ \s* ', re.S | re.X)
@@ -40,29 +41,30 @@ VARIABLE_SET = re.compile(r'@([a-z][\w\d]*) \s* : \s* (.+?) \s* ; \s* ', re.S |
 UNKNOWN = re.compile(r'(\S+) \s* ', re.S | re.X)
 
 ZOOM_MINMAX = re.compile(r'(\d+)\-(\d+) $', re.S | re.X)
-ZOOM_MIN = re.compile(r'(\d+)\-      $', re.S | re.X)
-ZOOM_MAX = re.compile(r'     \-(\d+) $', re.S | re.X)
+ZOOM_MIN    = re.compile(r'(\d+)\-      $', re.S | re.X)
+ZOOM_MAX    = re.compile(r'     \-(\d+) $', re.S | re.X)
 ZOOM_SINGLE = re.compile(r'       (\d+) $', re.S | re.X)
 
-CONDITION_TRUE = re.compile(r'\s* ([:\w]+) \s* [?] \s*  $', re.I | re.S | re.X)
+# TODO: move to Condition.py
+CONDITION_TRUE    = re.compile(r'\s* ([:\w]+) \s* [?] \s*  $', re.I | re.S | re.X)
 CONDITION_invTRUE = re.compile(r'\s* [!] \s* ([:\w]+) \s* [?] \s*  $', re.I | re.S | re.X)
-CONDITION_FALSE = re.compile(r'\s* ([:\w]+) \s* = \s* no  \s*  $', re.I | re.S | re.X)
-CONDITION_SET = re.compile(r'\s* ([-:\w]+) \s* $', re.S | re.X)
-CONDITION_UNSET = re.compile(r'\s* !([:\w]+) \s* $', re.S | re.X)
-CONDITION_EQ = re.compile(r'\s* ([:\w]+) \s* =  \s* (.+) \s* $', re.S | re.X)
-CONDITION_NE = re.compile(r'\s* ([:\w]+) \s* != \s* (.+) \s* $', re.S | re.X)
-CONDITION_GT = re.compile(r'\s* ([:\w]+) \s* >  \s* (.+) \s* $', re.S | re.X)
-CONDITION_GE = re.compile(r'\s* ([:\w]+) \s* >= \s* (.+) \s* $', re.S | re.X)
-CONDITION_LT = re.compile(r'\s* ([:\w]+) \s* <  \s* (.+) \s* $', re.S | re.X)
-CONDITION_LE = re.compile(r'\s* ([:\w]+) \s* <= \s* (.+) \s* $', re.S | re.X)
-CONDITION_REGEX = re.compile(r'\s* ([:\w]+) \s* =~\/ \s* (.+) \/ \s* $', re.S | re.X)
+CONDITION_FALSE   = re.compile(r'\s* ([:\w]+) \s* = \s* no  \s*  $', re.I | re.S | re.X)
+CONDITION_SET     = re.compile(r'\s* ([-:\w]+) \s* $', re.S | re.X)
+CONDITION_UNSET   = re.compile(r'\s* !([:\w]+) \s* $', re.S | re.X)
+CONDITION_EQ      = re.compile(r'\s* ([:\w]+) \s* =  \s* (.+) \s* $', re.S | re.X)
+CONDITION_NE      = re.compile(r'\s* ([:\w]+) \s* != \s* (.+) \s* $', re.S | re.X)
+CONDITION_GT      = re.compile(r'\s* ([:\w]+) \s* >  \s* (.+) \s* $', re.S | re.X)
+CONDITION_GE      = re.compile(r'\s* ([:\w]+) \s* >= \s* (.+) \s* $', re.S | re.X)
+CONDITION_LT      = re.compile(r'\s* ([:\w]+) \s* <  \s* (.+) \s* $', re.S | re.X)
+CONDITION_LE      = re.compile(r'\s* ([:\w]+) \s* <= \s* (.+) \s* $', re.S | re.X)
+CONDITION_REGEX   = re.compile(r'\s* ([:\w]+) \s* =~\/ \s* (.+) \/ \s* $', re.S | re.X)
 
 ASSIGNMENT_EVAL = re.compile(r"\s* (\S+) \s* \:      \s* eval \s* \( \s* ' (.+?) ' \s* \) \s* $", re.I | re.S | re.X)
-ASSIGNMENT = re.compile(r'\s* (\S+) \s* \:      \s*          (.+?) \s*                   $', re.S | re.X)
-SET_TAG_EVAL = re.compile(r"\s* set \s+(\S+)\s* = \s* eval \s* \( \s* ' (.+?) ' \s* \) \s* $", re.I | re.S | re.X)
-SET_TAG = re.compile(r'\s* set \s+(\S+)\s* = \s*          (.+?) \s*                   $', re.I | re.S | re.X)
-SET_TAG_TRUE = re.compile(r'\s* set \s+(\S+)\s* $', re.I | re.S | re.X)
-EXIT = re.compile(r'\s* exit \s* $', re.I | re.S | re.X)
+ASSIGNMENT      = re.compile(r'\s* (\S+) \s* \:      \s*          (.+?) \s*                   $', re.S | re.X)
+SET_TAG_EVAL    = re.compile(r"\s* set \s+(\S+)\s* = \s* eval \s* \( \s* ' (.+?) ' \s* \) \s* $", re.I | re.S | re.X)
+SET_TAG         = re.compile(r'\s* set \s+(\S+)\s* = \s*          (.+?) \s*                   $', re.I | re.S | re.X)
+SET_TAG_TRUE    = re.compile(r'\s* set \s+(\S+)\s* $', re.I | re.S | re.X)
+EXIT            = re.compile(r'\s* exit \s* $', re.I | re.S | re.X)
 
 oNONE = 0
 oZOOM = 2
@@ -73,6 +75,7 @@ oDECLARATION = 6
 oSUBPART = 7
 oVARIABLE_SET = 8
 
+# TODO: Following block of variables is never used
 DASH = re.compile(r'\-/g')
 COLOR = re.compile(r'color$/')
 BOLD = re.compile(r'^bold$/i')
@@ -81,6 +84,7 @@ UNDERLINE = re.compile(r'^underline$/i')
 CAPS = re.compile(r'^uppercase$/i')
 CENTER = re.compile(r'^center$/i')
 
+# TODO: Remove unused HEX variable
 HEX = re.compile(r'^#([0-9a-f]+)$/i')
 VARIABLE = re.compile(r'@([a-z][\w\d]*)')
 
@@ -98,6 +102,7 @@ class MapCSS():
         self.choosers_by_type = {}
         self.choosers_by_type_zoom_tag = {}
         self.variables = {}
+        self.unused_variables = set()
         self.style_loaded = False
 
     def parseZoom(self, s):
@@ -110,6 +115,7 @@ class MapCSS():
         elif ZOOM_SINGLE.match(s):
             return float(ZOOM_SINGLE.match(s).groups()[0]), float(ZOOM_SINGLE.match(s).groups()[0])
         else:
+            # TODO: Should we raise an exception here?
             logging.error("unparsed zoom: %s" % s)
 
     def build_choosers_tree(self, clname, type, cltag):
@@ -163,6 +169,8 @@ class MapCSS():
                     runtime_rules.append(runtime_conditions)
         return runtime_rules
 
+    # TODO: Renamed to `get_styles` because it returns a list of styles for each class `::XXX`
+    # Refactoring idea: Maybe return dict with `object-id` as a key
     def get_style(self, clname, type, tags, zoom, xscale, zscale, filter_by_runtime_conditions):
         style = []
         if type in self.choosers_by_type_zoom_tag:
@@ -207,6 +215,8 @@ class MapCSS():
 
     def get_variable(self, m):
         name = m.group()[1:]
+        if name in self.unused_variables:
+            self.unused_variables.remove(name)
         if not name in self.variables:
             raise Exception("Variable not found: " + str(format(name)))
         return self.variables[name] if name in self.variables else m.group()
@@ -219,7 +229,8 @@ class MapCSS():
         if filename:
             basepath = os.path.dirname(filename)
         if not css:
-            css = open(filename).read()
+            with open(filename) as css_file:
+                css = css_file.read()
         if not self.style_loaded:
             self.choosers = []
 
@@ -339,7 +350,8 @@ class MapCSS():
                         import_filename = os.path.join(basepath, IMPORT.match(css).groups()[0])
                         try:
                             css = IMPORT.sub("", css, 1)
-                            import_text = open(import_filename, "r").read()
+                            with open(import_filename, "r") as import_file:
+                                import_text = import_file.read()
                             stck[-1][1] = css # store remained part
                             stck.append([import_filename, import_text, import_text])
                             wasBroken = True
@@ -352,6 +364,7 @@ class MapCSS():
                         name = VARIABLE_SET.match(css).groups()[0]
                         log.debug("variable set found: %s" % name)
                         self.variables[name] = VARIABLE_SET.match(css).groups()[1]
+                        self.unused_variables.add( name )
                         css = VARIABLE_SET.sub("", css, 1)
                         previous = oVARIABLE_SET
 
@@ -359,7 +372,7 @@ class MapCSS():
                     elif UNKNOWN.match(css):
                         raise Exception("Unknown construction: " + UNKNOWN.match(css).group())
 
-                    # Must be unreacheable
+                    # Must be unreachable
                     else:
                         raise Exception("Unexpected construction: " + css)
 
@@ -377,10 +390,13 @@ class MapCSS():
             css_orig = stck[-1][2] # original
             css = stck[-1][1] # remained
             line = css_orig[:-len(css)].count("\n") + 1
+            # TODO: Handle filename is None
             msg = str(e) + "\nFile: " + filename + "\nLine: " + str(line)
+            # TODO: Print stack trace of original exception `e`
             raise Exception(msg)
 
         try:
+            # TODO: Drop support of z-index because `clamp` is always False and z-index properties unused in Organic Maps)
             if clamp:
                 "clamp z-indexes, so they're tightly following integers"
                 zindex = set()
@@ -398,8 +414,10 @@ class MapCSS():
                             else:
                                 stylez['z-index'] = res
         except TypeError:
+            # TODO: Better error handling here
             pass
 
+        # Group MapCSS styles by object type: 'area', 'line', 'way', 'node'
         for chooser in self.choosers:
             for t in chooser.compatible_types:
                 if t not in self.choosers_by_type:
@@ -407,7 +425,11 @@ class MapCSS():
                 else:
                     self.choosers_by_type[t].append(chooser)
 
+        if self.unused_variables:
+            # TODO: Do not print warning here. Instead let libkomwn.komap_mapswithme(...) analyze unused_variables
+            print(f"Warning: Unused variables: {', '.join(self.unused_variables)}")
 
+# TODO: move to Condition.py
 def parseCondition(s):
     log = logging.getLogger('mapcss.parser.condition')
 
@@ -488,7 +510,7 @@ def parseDeclaration(s):
             logging.debug("%s == %s" % (tzz[0], tzz[1]))
         else:
             logging.debug("unknown %s" % (a))
-    return [t]
+    return [t] # TODO: don't wrap `t` dict into a list. Return `t` instead.
 
 
 if __name__ == "__main__":
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/assets/case-1-import/colors.mapcss b/tests/assets/case-1-import/colors.mapcss
new file mode 100644
index 0000000..db1c68b
--- /dev/null
+++ b/tests/assets/case-1-import/colors.mapcss
@@ -0,0 +1,6 @@
+colors {
+  GuiText-color: #FFFFFF;
+  GuiText-opacity: 0.7;
+  Route-color: #0000FF;
+  Route-opacity: 0.5;
+}
diff --git a/tests/assets/case-1-import/import1.mapcss b/tests/assets/case-1-import/import1.mapcss
new file mode 100644
index 0000000..cfa0b6a
--- /dev/null
+++ b/tests/assets/case-1-import/import1.mapcss
@@ -0,0 +1 @@
+@import("import2.mapcss");
diff --git a/tests/assets/case-1-import/import2.mapcss b/tests/assets/case-1-import/import2.mapcss
new file mode 100644
index 0000000..9ab0b8d
--- /dev/null
+++ b/tests/assets/case-1-import/import2.mapcss
@@ -0,0 +1 @@
+@import("colors.mapcss");
diff --git a/tests/assets/case-1-import/main.mapcss b/tests/assets/case-1-import/main.mapcss
new file mode 100644
index 0000000..ca997c1
--- /dev/null
+++ b/tests/assets/case-1-import/main.mapcss
@@ -0,0 +1 @@
+@import("import1.mapcss");
diff --git a/tests/assets/case-2-generate-drules-mini/.gitignore b/tests/assets/case-2-generate-drules-mini/.gitignore
new file mode 100644
index 0000000..24a9e5d
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/.gitignore
@@ -0,0 +1,7 @@
+classificator.txt
+colors.txt
+patterns.txt
+style.bin.bin
+style.bin.txt
+types.txt
+visibility.txt
diff --git a/tests/assets/case-2-generate-drules-mini/include/Roads.mapcss b/tests/assets/case-2-generate-drules-mini/include/Roads.mapcss
new file mode 100644
index 0000000..794dd08
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/include/Roads.mapcss
@@ -0,0 +1,136 @@
+/* ~~~~ CONTENT OF ROADS ~~~~~
+
+1.Z-INDEX ROADS
+2.WORLD LEVEL ROAD 4-9 ZOOM
+3.TRUNK & MOTORWAY 6-22 ZOOM
+  3.1 Trunk & Motorway 6-22 ZOOM
+  3.2 Trunk & Motorway tunnel 12-22 ZOOM
+  3.3 Trunk & Motorway bridge 13-22 ZOOM
+4.PRIMARY 8-22 ZOOM
+  4.1 Primary 8-22 ZOOM
+  4.2 Primary tunnel 14-22 ZOOM
+  4.3 Primary bridge 14-22 ZOOM
+5.SECONDARY 10-22 ZOOM
+  5.1 Secondary 10-22 ZOOM
+  5.2 Secondary tunnel 16-22 ZOOM
+  5.3 Secondary bridge 14-22 ZOOM
+6.TERTIARY & UNCLASSIFIED 11-22 ZOOM
+  6.1 Tertiary & Unclassified 11-22 ZOOM
+  6.2 Tertiary & Unclassified tunnel 16-22 ZOOM
+  6.3 Tertiary & Unclassified bridge 14-22 ZOOM
+7.RESIDENTAL, ROAD, STREETS & SERVICE 12-22 ZOOM
+  7.1 Residential, Road, Street 12-22 ZOOM
+  7.2 Residential, Road, Street tunnel 16-22 ZOOM
+  7.3 Residential, Road, Street bridge 14-22 ZOOM
+  7.4 Service 15-22 ZOOM
+8.OTHERS ROADS 13-22 ZOOM
+  8.1 Pedestrian & ford 13-22 ZOOM
+  8.2 Pedestrian & ford tunnel 16-22 ZOOM
+  8.3 Pedestrian & other brige 13-22 ZOOM
+  8.4 Cycleway 13-22 ZOOM
+  8.5 Construction 13-22 ZOOM
+  8.6 Track & Path 14-22 ZOOM
+  8.7 Footway 15-22 ZOOM
+  8.8 Steps 15-22 ZOOM
+  8.9 Bridleway 14-22 ZOOM
+  8.11 Runway 12-22 ZOOM
+9.RAIL 11-22 ZOOM
+  9.1 RAIL 11-22 ZOOM
+  9.2 Rail tunnel 14-22 ZOOM
+  9.3 Rail bridge 14-22 ZOOM
+  9.4 Monorail 14-22 ZOOM
+  9.5 Tram line 13-22 ZOOM
+  9.6 Funicular 12-22 ZOOM
+10.PISTE 12-22 ZOOM
+ 10.1 Lift 12-22 ZOOM
+ 10.2 Aerialway 12-22 ZOOM
+ 10.3 Piste & Route 14-22 ZOOM
+11.FERRY
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+*/
+
+/* 2.WORLD LEVEL ROAD 4-9 ZOOM */
+
+line|z6-9[highway=world_towns_level],
+line|z4-9[highway=world_level],
+{color: @trunk1;opacity: 1;}
+
+line|z4[highway=world_level]
+{width: 0.5;}
+line|z5-6[highway=world_level],
+{width: 0.7;}
+line|z6[highway=world_towns_level],
+{width: 0.9;}
+line|z7[highway=world_towns_level],
+line|z7[highway=world_level]
+{width: 0.7;}
+line|z8[highway=world_towns_level],
+line|z8[highway=world_level]
+{width: 0.9;}
+line|z9[highway=world_towns_level],
+line|z9[highway=world_level]
+{width: 0.8;}
+
+/* 3.TRUNK & MOTORWAY 6-22 ZOOM */
+
+line|z6[highway=trunk],
+line|z6[highway=motorway],
+{color: @trunk0; opacity: 0.3;}
+line|z7-9[highway=trunk],
+line|z7-9[highway=motorway],
+{color: @trunk0; opacity: 0.7;}
+
+line|z10-[highway=trunk],
+line|z10-[highway=motorway],
+{color: @trunk1; opacity: 0.7;}
+line|z10-[highway=motorway_link],
+line|z10-[highway=trunk_link],
+{color: @primary0; opacity: 0.7;}
+
+/* 3.1 Trunk & Motorway 6-22 ZOOM */
+
+line|z6[highway=trunk],
+line|z6[highway=motorway],
+{width: 0.8;}
+line|z7[highway=trunk],
+line|z7[highway=motorway]
+{width: 0.9;}
+line|z8[highway=trunk],
+line|z8[highway=motorway]
+{width: 1.1;}
+line|z9[highway=trunk],
+line|z9[highway=motorway]
+{width: 1.2;}
+line|z10[highway=trunk],
+line|z10[highway=motorway]
+{width: 1.5;}
+
+line|z10[highway=motorway_link],
+line|z10[highway=trunk_link]
+{width: 0.8;}
+
+
+/* 4.PRIMARY 8-22 ZOOM */
+
+line|z8-10[highway=primary],
+{color: @primary0; opacity: 0.7;}
+
+/* 4.1 Primary 8-22 ZOOM */
+
+line|z8[highway=primary],
+{width: 0.7;}
+line|z9[highway=primary],
+{width: 0.8;}
+line|z10[highway=primary],
+{width: 1.2;}
+
+/* 5.SECONDARY 10-22 ZOOM */
+
+line|z10[highway=secondary],
+{color: @secondary0; opacity: 0.8;}
+
+/* 5.1 Secondary 10-22 ZOOM */
+
+line|z10[highway=secondary],
+{width: 1.2;}
+
diff --git a/tests/assets/case-2-generate-drules-mini/include/Roads_label.mapcss b/tests/assets/case-2-generate-drules-mini/include/Roads_label.mapcss
new file mode 100644
index 0000000..718d47d
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/include/Roads_label.mapcss
@@ -0,0 +1,75 @@
+/* ~~~~ CONTENT OF ROADS ~~~~~
+
+1.Z-INDEX ROADS
+2.SHIELD 10-22 ZOOM
+3.TRUNK & MOTORWAY 10-22 ZOOM
+4.PRIMARY 10-22 ZOOM
+5.SECONDARY 10-22 ZOOM
+6.RESIDENTAL & TERTIARY 12-22 ZOOM
+7.ROAD, STREETS, UNCLASSIFIED & SERVICE 15-22 ZOOM
+8.OTHERS ROADS 15-22 ZOOM
+9.RAIL 15-22 ZOOM ????
+  9.1 Monorail 14-22 ZOOM
+  9.2 Tram line 13-22 ZOOM
+  9.3 Funicular 12-22 ZOOM
+10.PISTE 12-22 ZOOM ????
+ 10.1 Lift 12-22 ZOOM
+ 10.2 Aerialway 12-22 ZOOM
+ 10.3 Piste & Route 14-22 ZOOM
+11.FERRY 10-22 ZOOM
+12.ONEWAY ARROWS 15-22 ZOOM
+13.JUNCTION 15-22 ZOOM
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+*/
+
+line[highway]
+{text-position: line;}
+
+/* 2.SHIELD 10-22 ZOOM */
+
+line|z10-[highway=motorway]::shield,
+line|z10-[highway=trunk]::shield,
+line|z10-[highway=motorway_link]::shield,
+line|z10-[highway=trunk_link]::shield,
+line|z10-[highway=primary]::shield,
+{shield-font-size: 9;shield-text-color: @shield_text;shield-text-halo-radius: 0;shield-text-halo-color: @shield_text_halo;shield-color: @shield;shield-outline-radius: 1;shield-outline-color: @shield_outline;}
+
+line|z10[highway=motorway]::shield,
+line|z10[highway=trunk]::shield,
+line|z10[highway=motorway_link]::shield,
+line|z10[highway=trunk_link]::shield,
+line|z10[highway=primary]::shield,
+{shield-min-distance: 85;}
+
+/* 3.TRUNK & MOTORWAY 10-22 ZOOM */
+
+line|z10-[highway=trunk],
+line|z10-[highway=motorway],
+line|z10-[highway=motorway_link],
+line|z10-[highway=trunk_link],
+{text: name; text-halo-radius: 1; text-halo-color: @label_halo_medium;}
+
+line|z10-[highway=motorway],
+line|z10-[highway=trunk],
+{font-size: 11; text-color: @label_medium; text-halo-opacity: 0.9;}
+
+line|z10-[highway=motorway_link],
+line|z10-[highway=trunk_link],
+{font-size: 10; text-color: @label_medium; text-halo-opacity: 0.7;}
+
+/* 4.PRIMARY 10-22 ZOOM */
+
+line|z10-[highway=primary],
+{text: name; text-halo-radius: 1; text-halo-color: @label_halo_medium;}
+
+line|z10-[highway=primary],
+{font-size: 10; text-color: @label_medium; text-halo-opacity: 0.7;}
+
+/* 5.SECONDARY 10-22 ZOOM */
+
+line|z10-[highway=secondary],
+{text: name; text-halo-radius: 1; text-halo-color: @label_halo_medium;}
+
+line|z10-[highway=secondary],
+{font-size: 10; text-color: @label_light; text-halo-opacity: 0.7;}
+
diff --git a/tests/assets/case-2-generate-drules-mini/include/colors.mapcss b/tests/assets/case-2-generate-drules-mini/include/colors.mapcss
new file mode 100644
index 0000000..d7b4817
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/include/colors.mapcss
@@ -0,0 +1,16 @@
+/* 5.1 All roads */
+@trunk0: #FF7326;
+@trunk1: #FF7A26;
+@primary0:  #FF8726;
+@secondary0: #FFB226;
+
+/* 6.1 Main labels */
+@label_medium: #333333;
+@label_light: #444444;
+@label_halo_medium: #EDEBDB;
+
+/* 6.4 Road labels */
+@shield_text: #000000;
+@shield_text_halo: #000000;
+@shield: #FFFFFF;
+@shield_outline: #000000;
diff --git a/tests/assets/case-2-generate-drules-mini/include/priorities_1_BG-by-size.prio.txt b/tests/assets/case-2-generate-drules-mini/include/priorities_1_BG-by-size.prio.txt
new file mode 100644
index 0000000..385b1ab
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/include/priorities_1_BG-by-size.prio.txt
@@ -0,0 +1,16 @@
+# This file is automatically re-formatted and re-sorted in priorities descending order
+# when generate_drules.sh is run. All comments (automatic priorities of e.g. optional captions, drule types visibilities, etc.)
+# are generated automatically for information only. Custom formatting and comments are not preserved.
+#
+# BG-by-size geometry: background areas rendered below BG-top and everything else.
+# Smaller areas are rendered above larger ones (area's size is estimated as the size of its' bounding box).
+# So effectively priority values of BG-by-size areas are not used at the moment.
+# But we might use them later for some special cases, e.g. to determine a main area type of a multi-type feature.
+# Keep them in a logical importance order please.
+#
+# Priorities ranges' rendering order overview:
+# - overlays (icons, captions...)
+# - FG: foreground areas and lines
+# - BG-top: water (linear and areal)
+# - BG-by-size: landcover areas sorted by their size
+
diff --git a/tests/assets/case-2-generate-drules-mini/include/priorities_2_BG-top.prio.txt b/tests/assets/case-2-generate-drules-mini/include/priorities_2_BG-top.prio.txt
new file mode 100644
index 0000000..9e47413
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/include/priorities_2_BG-top.prio.txt
@@ -0,0 +1,18 @@
+# This file is automatically re-formatted and re-sorted in priorities descending order
+# when generate_drules.sh is run. All comments (automatic priorities of e.g. optional captions, drule types visibilities, etc.)
+# are generated automatically for information only. Custom formatting and comments are not preserved.
+#
+# BG-top geometry: background lines and areas that should be always below foreground ones
+# (including e.g. layer=-10 underwater tunnels), but above background areas sorted by size (BG-by-size),
+# because ordering by size doesn't always work with e.g. water mapped over a forest,
+# so water should be on top of other landcover always, but linear waterways should be hidden beneath it.
+# Still, e.g. a layer=-1 BG-top feature will be rendered under a layer=0 BG-by-size feature
+# (so areal water tunnels are hidden beneath other landcover area) and a layer=1 landcover areas
+# are displayed above layer=0 BG-top.
+#
+# Priorities ranges' rendering order overview:
+# - overlays (icons, captions...)
+# - FG: foreground areas and lines
+# - BG-top: water (linear and areal)
+# - BG-by-size: landcover areas sorted by their size
+
diff --git a/tests/assets/case-2-generate-drules-mini/include/priorities_3_FG.prio.txt b/tests/assets/case-2-generate-drules-mini/include/priorities_3_FG.prio.txt
new file mode 100644
index 0000000..f249605
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/include/priorities_3_FG.prio.txt
@@ -0,0 +1,41 @@
+# This file is automatically re-formatted and re-sorted in priorities descending order
+# when generate_drules.sh is run. All comments (automatic priorities of e.g. optional captions, drule types visibilities, etc.)
+# are generated automatically for information only. Custom formatting and comments are not preserved.
+#
+# FG geometry: foreground lines and areas (e.g. buildings) are rendered always below overlays
+# and always on top of background geometry (BG-top & BG-by-size) even if a foreground feature
+# is layer=-10 (as tunnels should be visibile over landcover and water).
+#
+# Priorities ranges' rendering order overview:
+# - overlays (icons, captions...)
+# - FG: foreground areas and lines
+# - BG-top: water (linear and areal)
+# - BG-by-size: landcover areas sorted by their size
+
+highway-motorway                                    # line z6- (also has pathtext z10-, shield::shield z10-)
+highway-motorway-bridge                             # line z6- (also has pathtext z10-, shield::shield z10-)
+highway-motorway-tunnel                             # line z6- (also has pathtext z10-, shield::shield z10-)
+highway-trunk                                       # line z6- (also has pathtext z10-, shield::shield z10-)
+highway-trunk-bridge                                # line z6- (also has pathtext z10-, shield::shield z10-)
+highway-trunk-tunnel                                # line z6- (also has pathtext z10-, shield::shield z10-)
+highway-world_level                                 # line z4-9
+highway-world_towns_level                           # line z6-9
+=== 310
+
+highway-primary                                     # line z8- (also has pathtext z10-, shield::shield z10-)
+highway-primary-bridge                              # line z8- (also has pathtext z10-, shield::shield z10-)
+highway-primary-tunnel                              # line z8- (also has pathtext z10-, shield::shield z10-)
+=== 290
+
+highway-secondary                                   # line z10- (also has pathtext z10-)
+highway-secondary-bridge                            # line z10- (also has pathtext z10-)
+highway-secondary-tunnel                            # line z10- (also has pathtext z10-)
+=== 270
+
+highway-motorway_link                               # line z10- (also has pathtext z10-, shield::shield z10-)
+highway-motorway_link-bridge                        # line z10- (also has pathtext z10-, shield::shield z10-)
+highway-motorway_link-tunnel                        # line z10- (also has pathtext z10-, shield::shield z10-)
+highway-trunk_link                                  # line z10- (also has pathtext z10-, shield::shield z10-)
+highway-trunk_link-bridge                           # line z10- (also has pathtext z10-, shield::shield z10-)
+highway-trunk_link-tunnel                           # line z10- (also has pathtext z10-, shield::shield z10-)
+=== 228
diff --git a/tests/assets/case-2-generate-drules-mini/include/priorities_4_overlays.prio.txt b/tests/assets/case-2-generate-drules-mini/include/priorities_4_overlays.prio.txt
new file mode 100644
index 0000000..2eaecb6
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/include/priorities_4_overlays.prio.txt
@@ -0,0 +1,61 @@
+# This file is automatically re-formatted and re-sorted in priorities descending order
+# when generate_drules.sh is run. All comments (automatic priorities of e.g. optional captions, drule types visibilities, etc.)
+# are generated automatically for information only. Custom formatting and comments are not preserved.
+#
+# Overlays (icons, captions, path texts and shields) are rendered on top of all the geometry (lines, areas).
+# Overlays don't overlap each other, instead the ones with higher priority displace the less important ones.
+# Optional captions (which have an icon) are usually displayed only if there are no other overlays in their way
+# (technically, max overlays priority value (10000) is subtracted from their priorities automatically).
+#
+# Priorities ranges' rendering order overview:
+# - overlays (icons, captions...)
+# - FG: foreground areas and lines
+# - BG-top: water (linear and areal)
+# - BG-by-size: landcover areas sorted by their size
+
+highway-motorway                                    # pathtext z10- (also has shield::shield z10-, line z6-)
+highway-motorway-bridge                             # pathtext z10- (also has shield::shield z10-, line z6-)
+highway-motorway-tunnel                             # pathtext z10- (also has shield::shield z10-, line z6-)
+highway-trunk                                       # pathtext z10- (also has shield::shield z10-, line z6-)
+highway-trunk-bridge                                # pathtext z10- (also has shield::shield z10-, line z6-)
+highway-trunk-tunnel                                # pathtext z10- (also has shield::shield z10-, line z6-)
+=== 6750
+
+highway-motorway::shield                            # shield::shield z10- (also has pathtext z10-, line z6-)
+highway-motorway-bridge::shield                     # shield::shield z10- (also has pathtext z10-, line z6-)
+highway-motorway-tunnel::shield                     # shield::shield z10- (also has pathtext z10-, line z6-)
+highway-trunk::shield                               # shield::shield z10- (also has pathtext z10-, line z6-)
+highway-trunk-bridge::shield                        # shield::shield z10- (also has pathtext z10-, line z6-)
+highway-trunk-tunnel::shield                        # shield::shield z10- (also has pathtext z10-, line z6-)
+=== 6740
+
+highway-primary                                     # pathtext z10- (also has shield::shield z10-, line z8-)
+highway-primary-bridge                              # pathtext z10- (also has shield::shield z10-, line z8-)
+highway-primary-tunnel                              # pathtext z10- (also has shield::shield z10-, line z8-)
+=== 6200
+
+highway-motorway_link                               # pathtext z10- (also has shield::shield z10-, line z10-)
+highway-motorway_link-bridge                        # pathtext z10- (also has shield::shield z10-, line z10-)
+highway-motorway_link-tunnel                        # pathtext z10- (also has shield::shield z10-, line z10-)
+highway-trunk_link                                  # pathtext z10- (also has shield::shield z10-, line z10-)
+highway-trunk_link-bridge                           # pathtext z10- (also has shield::shield z10-, line z10-)
+highway-trunk_link-tunnel                           # pathtext z10- (also has shield::shield z10-, line z10-)
+=== 6150
+
+highway-motorway_link::shield                       # shield::shield z10- (also has pathtext z10-, line z10-)
+highway-motorway_link-bridge::shield                # shield::shield z10- (also has pathtext z10-, line z10-)
+highway-motorway_link-tunnel::shield                # shield::shield z10- (also has pathtext z10-, line z10-)
+highway-trunk_link::shield                          # shield::shield z10- (also has pathtext z10-, line z10-)
+highway-trunk_link-bridge::shield                   # shield::shield z10- (also has pathtext z10-, line z10-)
+highway-trunk_link-tunnel::shield                   # shield::shield z10- (also has pathtext z10-, line z10-)
+=== 6140
+
+highway-secondary                                   # pathtext z10- (also has line z10-)
+highway-secondary-bridge                            # pathtext z10- (also has line z10-)
+highway-secondary-tunnel                            # pathtext z10- (also has line z10-)
+=== 5600
+
+highway-primary::shield                             # shield::shield z10- (also has pathtext z10-, line z8-)
+highway-primary-bridge::shield                      # shield::shield z10- (also has pathtext z10-, line z8-)
+highway-primary-tunnel::shield                      # shield::shield z10- (also has pathtext z10-, line z8-)
+=== 2975
diff --git a/tests/assets/case-2-generate-drules-mini/main.mapcss b/tests/assets/case-2-generate-drules-mini/main.mapcss
new file mode 100644
index 0000000..300b07d
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/main.mapcss
@@ -0,0 +1,133 @@
+*::int_name
+{
+  text-offset: 1;
+}
+
+@import("include/colors.mapcss");
+@import("include/Roads.mapcss");
+@import("include/Roads_label.mapcss");
+
+colors
+{
+  GuiText-color: #4D4D4D;
+  GuiText-opacity: 0.86;
+  MyPositionAccuracy-color: #000000;
+  MyPositionAccuracy-opacity: 0.08;
+  Selection-color: #1E96F0;
+  Selection-opacity: 0.64;
+  Route-color: #0087FF;
+  RouteOutline-color: #055FCD;
+  RouteTrafficG0-color: #9B2300;
+  RouteTrafficG1-color: #E82705;
+  RouteTrafficG2-color: #E82705;
+  RouteTrafficG3-color: #FFE500;
+  RouteTrafficG3-opacity: 0.0;
+  RoutePedestrian-color: #1D339E;
+  RoutePedestrian-opacity: 0.8;
+  RouteBicycle-color: #9C27B0;
+  RouteBicycle-opacity: 0.8;
+  RouteRuler-color: #66347F;
+  RouteRuler-opacity: 0.9;
+  RoutePreview-color: #000000;
+  RoutePreview-opacity: 0.3;
+  RouteMaskCar-color: #000000;
+  RouteMaskCar-opacity: 0.3;
+  RouteFirstSegmentArrowsMaskCar-color: #033B80;
+  RouteFirstSegmentArrowsMaskCar-opacity: 0.0;
+  RouteArrowsMaskCar-color: #033B80;
+  RouteArrowsMaskCar-opacity: 0.2;
+  RouteMaskBicycle-color: #000000;
+  RouteMaskBicycle-opacity: 0.5;
+  RouteFirstSegmentArrowsMaskBicycle-color: #9C27B0;
+  RouteFirstSegmentArrowsMaskBicycle-opacity: 0.0;
+  RouteArrowsMaskBicycle-color: #9C27B0;
+  RouteArrowsMaskBicycle-opacity: 0.2;
+  RouteMaskPedestrian-color: #000000;
+  RouteMaskPedestrian-opacity: 0.5;
+  RouteFake-color: #A8A8A8;
+  RouteFakeOutline-color: #717171;
+  Arrow3D-color: #50AAFF;
+  Arrow3DObsolete-color: #82AAC8;
+  Arrow3DObsolete-opacity: 0.72;
+  Arrow3DShadow-color: #3C3C3C;
+  Arrow3DShadow-opacity: 0.24;
+  Arrow3DOutline-color: #FFFFFF;
+  TrackHumanSpeed-color: #1D339E;
+  TrackCarSpeed-color: #7C8EDE;
+  TrackPlaneSpeed-color: #A8B7ED;
+  TrackUnknownDistance-color: #616161;
+  TrafficG0-color: #7E1712;
+  TrafficG1-color: #E42300;
+  TrafficG2-color: #E42300;
+  TrafficG3-color: #FCDE00;
+  TrafficG3-opacity: 0.0;
+  TrafficG4-color: #39962E;
+  TrafficG5-color: #39962E;
+  TrafficTempBlock-color: #525252;
+  TrafficUnknown-color: #000000;
+  TrafficArrowLight-color: #FFFFFF;
+  TrafficArrowDark-color: #473635;
+  TrafficOutline-color: #E8E6DC;
+  RoadShieldBlackText-color: #000000;
+  RoadShieldWhiteText-color: #FFFFFF;
+  RoadShieldUKYellowText-color: #FFD400;
+  RoadShieldBlueBackground-color: #1A5EC1;
+  RoadShieldGreenBackground-color: #309302;
+  RoadShieldRedBackground-color: #E63534;
+  RoadShieldOrangeBackground-color: #FFBE00;
+  PoiHotelTextOutline-color: #FFFFFF;
+  PoiHotelTextOutline-opacity: 0.6;
+  PoiDeletedMask-color: #FFFFFF;
+  PoiDeletedMask-opacity: 0.3;
+  PoiVisitedMask-color: #FFFFFF;
+  PoiVisitedMask-opacity: 0.7;
+  DefaultTrackColor-color: #1E96F0;
+  RouteMarkPrimaryText-color: #000000;
+  RouteMarkPrimaryTextOutline-color: #FFFFFF;
+  RouteMarkSecondaryText-color: #000000;
+  RouteMarkSecondaryTextOutline-color: #FFFFFF;
+  TransitMarkPrimaryText-color: #000000;
+  TransitMarkPrimaryTextOutline-color: #FFFFFF;
+  TransitMarkSecondaryText-color: #000000;
+  TransitMarkSecondaryTextOutline-color: #FFFFFF;
+  TransitTransferOuterMarker-color: #000000;
+  TransitTransferInnerMarker-color: #FFFFFF;
+  TransitStopInnerMarker-color: #FFFFFF;
+  LocalAdsPrimaryText-color: #000000;
+  LocalAdsPrimaryTextOutline-color: #FFFFFF;
+  LocalAdsSecondaryText-color: #000000;
+  LocalAdsSecondaryTextOutline-color: #FFFFFF;
+  TransitBackground-color: #FFFFFF;
+  TransitBackground-opacity: 0.4;
+  BookmarkRed-color: #E51B23;
+  BookmarkPink-color: #FF4182;
+  BookmarkPurple-color: #9B24B2;
+  BookmarkDeepPurple-color: #6639BF;
+  BookmarkBlue-color: #0066CC;
+  BookmarkLightBlue-color: #249CF2;
+  BookmarkCyan-color: #14BECD;
+  BookmarkTeal-color: #00A58C;
+  BookmarkGreen-color: #3C8C3C;
+  BookmarkLime-color: #93BF39;
+  BookmarkYellow-color: #FFC800;
+  BookmarkOrange-color: #FF9600;
+  BookmarkDeepOrange-color: #F06432;
+  BookmarkBrown-color: #804633;
+  BookmarkGray-color: #737373;
+  BookmarkBlueGray-color: #597380;
+  SearchmarkPreparing-color: #597380;
+  SearchmarkNotAvailable-color: #597380;
+  SearchmarkSelectedNotAvailable-color: #F06432;
+  RatingBad-color: #F06432;
+  RatingGood-color: #3C8C3C;
+  RatingNone-color: #249CF2;
+  SearchmarkDefault-color: #249CF2;
+  RatingText-color: #FFFFFF;
+  UGCRatingText-color: #000000;
+  SpeedCameraMarkText-color: #FFFFFF;
+  SpeedCameraMarkBg-color: #F51E30;
+  SpeedCameraMarkOutline-color: #FFFFFF;
+  GuideCityMarkText-color: #6639BF;
+  GuideOutdoorMarkText-color: #3C8C3C;
+  HotelPriceText-color: #000000;
+}
diff --git a/tests/assets/case-2-generate-drules-mini/mapcss-dynamic.txt b/tests/assets/case-2-generate-drules-mini/mapcss-dynamic.txt
new file mode 100644
index 0000000..60746b0
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/mapcss-dynamic.txt
@@ -0,0 +1,4 @@
+population
+name
+bbox_area
+rating
diff --git a/tests/assets/case-2-generate-drules-mini/mapcss-mapping.csv b/tests/assets/case-2-generate-drules-mini/mapcss-mapping.csv
new file mode 100644
index 0000000..077f771
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/mapcss-mapping.csv
@@ -0,0 +1,148 @@
+highway|residential;2;
+highway|service;3;
+highway|unclassified;5;
+highway|footway;7;
+highway|track;8;
+highway|tertiary;9;
+highway|secondary;13;
+highway|path;16;
+highway|bus_stop;17;
+highway|footway|sidewalk;[highway=footway][footway=sidewalk];;name;int_name;18;
+highway|primary;27;
+highway|service|parking_aisle;[highway=service][service=parking_aisle];;name;int_name;29;
+moved:highway|road:05.2024;31;highway|road
+deprecated:highway|track|grade2:04.2024;[highway=track][tracktype=grade2];x;name;int_name;32;highway|track
+deprecated:highway|track|grade3:04.4024;[highway=track][tracktype=grade3];x;name;int_name;34;highway|track
+highway|cycleway;37;
+deprecated:highway|track|grade1:04.2024;[highway=track][tracktype=grade1];x;name;int_name;40;highway|track
+highway|service|driveway;[highway=service][service=driveway];;name;int_name;42;
+highway|motorway_link;44;
+deprecated:highway|track|grade4:04.2024;[highway=track][tracktype=grade4];x;name;int_name;46;highway|track
+highway|footway|crossing;[highway=footway][footway=crossing];;name;int_name;51;
+highway|path|bicycle;[highway=path][bicycle=designated];;;;53;
+highway|living_street;55;
+highway|motorway;58;
+highway|steps;59;
+deprecated:highway|track|grade5:04.2024;[highway=track][tracktype=grade5];x;name;int_name;63;highway|track
+highway|trunk;66;
+highway|pedestrian;70;
+highway|motorway|bridge;[highway=motorway][bridge?];;name;int_name;72;
+highway|residential|bridge;[highway=residential][bridge?];;name;int_name;81;
+highway|secondary|bridge;[highway=secondary][bridge?];;name;int_name;85;
+highway|tertiary|bridge;[highway=tertiary][bridge?];;name;int_name;86;
+highway|trunk_link;91;
+highway|unclassified|bridge;[highway=unclassified][bridge?];;name;int_name;92;
+highway|primary|bridge;[highway=primary][bridge?];;name;int_name;95;
+highway|primary_link;96;
+highway|footway|bridge;[highway=footway][bridge?];;name;int_name;98;
+deprecated:highway|path|hiking:04.2024;[highway=path][route=hiking],[highway=path][sac_scale=hiking];x;name;int_name;113;highway|path
+highway|trunk|bridge;[highway=trunk][bridge?];;name;int_name;116;
+highway|motorway_junction;121;
+highway|footway|bicycle;[highway=footway][bicycle=designated];;;;141;
+highway|motorway_link|bridge;[highway=motorway_link][bridge?];;name;int_name;143;
+deprecated:highway|footway|permissive:12.2023;[highway=footway][access=permissive],[highway=footway][foot=permissive];x;name;int_name;153;highway|footway
+highway|pedestrian|area;[highway=pedestrian][area?];;name;int_name;158;
+highway|construction;163;
+highway|cycleway|bridge;[highway=cycleway][bridge?];;name;int_name;164;
+deprecated:highway|path|mountain_hiking:04.2024;[highway=path][sac_scale=mountain_hiking];x;name;int_name;166;highway|path
+highway|bridleway;168;
+highway|secondary_link;177;
+highway|footway|tunnel;[highway=footway][tunnel?],[highway=footway][location=underground];;name;int_name;183;
+highway|track|bridge;[highway=track][bridge?];;name;int_name;193;
+highway|path|bridge;[highway=path][bridge?];;name;int_name;194;
+highway|service|bridge;[highway=service][bridge?];;name;int_name;203;
+highway|service|area;[highway=service][area?];;name;int_name;226;
+highway|residential|area;[highway=residential][area?];;name;int_name;227;
+deprecated:highway|track|permissive:12.2023;[highway=track][access=permissive];x;name;int_name;229;highway|track
+highway|cycleway|tunnel;[highway=cycleway][tunnel?];;name;int_name;232;
+highway|unclassified|tunnel;[highway=unclassified][tunnel?];;name;int_name;235;
+highway|residential|tunnel;[highway=residential][tunnel?];;name;int_name;238;
+deprecated:highway|path|permissive:12.2023;[highway=path][access=permissive];x;name;int_name;240;highway|path
+highway|trunk_link|bridge;[highway=trunk_link][bridge?];;name;int_name;261;
+highway|service|tunnel;[highway=service][tunnel?];;name;int_name;263;
+highway|tertiary|tunnel;[highway=tertiary][tunnel?];;name;int_name;269;
+highway|tertiary_link;273;
+highway|footway|area;[highway=footway][area?];;name;int_name;276;
+highway|road|bridge;[highway=road][bridge?];;name;int_name;280;
+highway|secondary|tunnel;[highway=secondary][tunnel?];;name;int_name;297;
+deprecated:highway|path|demanding_mountain_hiking:04.2024;[highway=path][sac_scale=demanding_mountain_hiking];x;name;int_name;300;highway|path|difficult
+highway|pedestrian|bridge;[highway=pedestrian][bridge?];;name;int_name;304;
+highway|raceway;308;
+highway|primary|tunnel;[highway=primary][tunnel?];;name;int_name;309;
+highway|primary_link|bridge;[highway=primary_link][bridge?];;name;int_name;310;
+deprecated:highway|footway|hiking:04.2024;[highway=footway][sac_scale=hiking];x;name;int_name;314;highway|path
+highway|path|horse;[highway=path][horse?];;name;int_name;317;
+highway|trunk|tunnel;[highway=trunk][tunnel?];;name;int_name;326;
+highway|steps|tunnel;[highway=steps][tunnel?],[highway=steps][location=underground];;name;int_name;327;
+highway|steps|bridge;[highway=steps][bridge?];;name;int_name;330;
+highway|pedestrian|tunnel;[highway=pedestrian][tunnel?],[highway=pedestrian][location=underground];;name;int_name;332;
+highway|path|tunnel;[highway=path][tunnel?],[highway=path][location=underground];;name;int_name;336;
+deprecated:highway|path|alpine_hiking:04.2024;[highway=path][sac_scale=alpine_hiking];x;name;int_name;350;highway|path|expert
+deprecated:highway|cycleway|permissive:12.2023;[highway=cycleway][access=permissive];x;name;int_name;353;highway|cycleway
+highway|unclassified|area;[highway=unclassified][area?];;name;int_name;354;
+deprecated:highway|footway|mountain_hiking:04.2024;[highway=footway][sac_scale=mountain_hiking];x;name;int_name;361;highway|path
+deprecated:highway|service|driveway|bridge:01.2020;[highway=service][service=driveway][bridge?];x;name;int_name;362;highway|service|driveway
+deprecated:highway|bridleway|permissive:12.2023;[highway=bridleway][access=permissive];x;name;int_name;370;highway|bridleway
+highway|bridleway|bridge;[highway=bridleway][bridge?];;name;int_name;378;
+deprecated:highway|service|driveway|tunnel:01.2020;[highway=service][service=driveway][tunnel?];x;name;int_name;379;highway|service|driveway
+deprecated:highway|service|driveway|area:01.2020;[highway=service][service=driveway][area?];x;name;int_name;386;highway|service|driveway
+deprecated:highway|path|demanding_alpine_hiking:04.2024;[highway=path][sac_scale=demanding_alpine_hiking];x;name;int_name;395;highway|path|expert
+highway|secondary_link|bridge;[highway=secondary_link][bridge?];;name;int_name;397;
+area:highway|living_street;401;
+highway|living_street|bridge;[highway=living_street][bridge?];;name;int_name;407;
+highway|road;411;
+highway|motorway|tunnel;[highway=motorway][tunnel?];;name;int_name;416;
+area:highway|service;418;
+highway|road|tunnel;[highway=road][tunnel?];;name;int_name;423;
+highway|ford;427;
+area:highway|path;428;
+highway|track|area;[highway=track][area?];;name;int_name;430;
+deprecated:highway|path|difficult_alpine_hiking:04.2024;[highway=path][sac_scale=difficult_alpine_hiking];x;name;int_name;444;highway|path|expert
+deprecated:highway|footway|demanding_mountain_hiking:04.2024;[highway=footway][sac_scale=demanding_mountain_hiking];x;name;int_name;452;highway|path|difficult
+highway|living_street|tunnel;[highway=living_street][tunnel?];;name;int_name;457;
+highway|path|difficult;[highway=path][_path_grade=difficult];;name;int_name;464;
+highway|path|expert;[highway=path][_path_grade=expert];;name;int_name;465;
+area:highway|steps;470;
+highway|bridleway|tunnel;[highway=bridleway][tunnel?];;name;int_name;488;
+highway|motorway_link|tunnel;[highway=motorway_link][tunnel?];;name;int_name;489;
+highway|tertiary_link|bridge;[highway=tertiary_link][bridge?];;name;int_name;493;
+highway|trunk_link|tunnel;[highway=trunk_link][tunnel?];;name;int_name;503;
+highway|primary_link|tunnel;[highway=primary_link][tunnel?];;name;int_name;528;
+deprecated:highway|footway|alpine_hiking:04.2024;[highway=footway][sac_scale=alpine_hiking];x;name;int_name;529;highway|path|expert
+deprecated:amenity|speed_trap:10.2021;542;highway|speed_camera
+area:highway|track;543;
+area:highway|primary;544;
+deprecated:highway|footway|demanding_alpine_hiking:04.2024;[highway=footway][sac_scale=demanding_alpine_hiking];x;name;int_name;555;highway|path|expert
+highway|secondary_link|tunnel;[highway=secondary_link][tunnel?];;name;int_name;578;
+highway|track|grade3|permissive;[highway=track][tracktype=grade3][access=permissive];x;name;int_name;591;highway|track
+deprecated:highway|footway|difficult_alpine_hiking:04.2024;[highway=footway][sac_scale=difficult_alpine_hiking];x;name;int_name;627;highway|path|expert
+highway|track|grade5|permissive;[highway=track][tracktype=grade5][access=permissive];x;name;int_name;631;highway|track
+highway|tertiary_link|tunnel;[highway=tertiary_link][tunnel?];;name;int_name;634;
+highway|track|grade4|permissive;[highway=track][tracktype=grade4][access=permissive];x;name;int_name;675;highway|track
+highway|track|grade3|no-access;[highway=track][tracktype=grade3][access=no];x;name;int_name;821;highway|track
+highway|track|grade4|no-access;[highway=track][tracktype=grade4][access=no];x;name;int_name;822;highway|track
+highway|track|grade5|no-access;[highway=track][tracktype=grade5][access=no];x;name;int_name;823;highway|track
+highway|track|no-access;[highway=track][access=no];;name;int_name;824;
+deprecated:highway|service|busway:10.2023;[highway=service][service=busway];x;name;int_name;857;highway|busway
+highway|busway;[highway=busway],[highway=service][service=busway],[highway=service][service=bus];;name;int_name;858;
+highway|busway|bridge;[highway=busway][bridge?];;name;int_name;859;
+highway|busway|tunnel;[highway=busway][tunnel?];;name;int_name;860;
+area:highway|footway;866;
+area:highway|residential;868;
+area:highway|secondary;869;
+area:highway|tertiary;870;
+area:highway|pedestrian;873;
+area:highway|unclassified;874;
+area:highway|cycleway;877;
+area:highway|motorway;879;
+area:highway|trunk;880;
+highway|speed_camera;991;
+highway|world_level;1052;
+highway|world_towns_level;1053;
+highway|elevator;1059;
+highway|rest_area;1080;
+highway|traffic_signals;1081;
+hwtag|nobicycle;1114;
+hwtag|yesbicycle;1115;
+hwtag|bidir_bicycle;1116;
+highway|services;1173;
diff --git a/tests/assets/case-2-generate-drules-mini/readme.md b/tests/assets/case-2-generate-drules-mini/readme.md
new file mode 100644
index 0000000..d57323c
--- /dev/null
+++ b/tests/assets/case-2-generate-drules-mini/readme.md
@@ -0,0 +1,4 @@
+Files for testLibkomwm.test_generate_drules_mini() method.
+
+These styles contain only zooms 0-10 and only highway=* rules.
+So we can verify generated files content.
diff --git a/tests/assets/case-3-styles-validation/readme.md b/tests/assets/case-3-styles-validation/readme.md
new file mode 100644
index 0000000..a937244
--- /dev/null
+++ b/tests/assets/case-3-styles-validation/readme.md
@@ -0,0 +1 @@
+Files for testLibkomwm.test_generate_drules_validation_errors() method.
diff --git a/tests/testCondition.py b/tests/testCondition.py
new file mode 100644
index 0000000..5ab7b68
--- /dev/null
+++ b/tests/testCondition.py
@@ -0,0 +1,300 @@
+import re
+import unittest
+import sys
+from pathlib import Path
+
+# Add `src` directory to the import paths
+sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
+
+from mapcss import parseCondition
+from mapcss.Condition import Condition
+
+class ConditionTest(unittest.TestCase):
+
+    def test_parser_eq(self):
+        cond:Condition = parseCondition("natural=coastline")
+        self.assertEqual(cond.type, "eq")
+        self.assertEqual(cond.params, ("natural", "coastline"))
+        self.assertTrue(cond.test({'natural': 'coastline'}))
+        self.assertFalse(cond.test({'Natural': 'Coastline'}))
+
+        cond = parseCondition("  highway\t=\tprimary")
+        self.assertEqual(cond.type, "eq")
+        self.assertEqual(cond.params, ("highway", "primary"))
+        self.assertTrue(cond.test({'highway': 'primary'}))
+        self.assertFalse(cond.test({'highway': 'secondary'}))
+
+        cond = parseCondition("  admin_level  =   3")
+        self.assertEqual(cond.type, "eq")
+        self.assertEqual(cond.params, ("admin_level", "3"))
+        self.assertTrue(cond.test({'admin_level': '3'}))
+        self.assertFalse(cond.test({'admin_level': '32'}))
+
+        cond = Condition('eq', ("::class", "::*"))
+        self.assertEqual(cond.type, "eq")
+        self.assertEqual(cond.params, ("::class", "::*"))
+        self.assertEqual(cond.extract_tag(), "*")
+        self.assertEqual(cond.test({'any_key': 'any_value'}), "::*")
+        self.assertTrue(cond.test({'any_key': 'any_value'}))
+
+        cond = Condition('eq', ("::class", "::int_name"))
+        self.assertEqual(cond.type, "eq")
+        self.assertEqual(cond.params, ("::class", "::int_name"))
+        self.assertEqual(cond.extract_tag(), "*")
+        self.assertEqual(cond.test({'any_key': 'any_value'}), "::int_name")
+        self.assertTrue(cond.test({'any_key': 'any_value'}))
+
+    def test_parser_regex(self):
+        """ Test conditions in format natural =~/water.+/
+            Note that such conditions are not used by Organic Maps styles.
+        """
+        cond:Condition = parseCondition("natural =~/water.+/")
+        self.assertEqual(cond.type, "regex")
+        self.assertEqual(cond.params, ("natural", "water.+"))
+        self.assertEqual(type(cond.regex), re.Pattern)
+        self.assertTrue(cond.test({"natural": "waterway"}))
+        self.assertTrue(cond.test({"natural": "water123"}))
+        self.assertFalse(cond.test({"natural": "water"}))
+        self.assertFalse(cond.test({"natural": " waterway "}))
+
+    def test_parser_ge(self):
+        cond:Condition = parseCondition("population>=0")
+        self.assertEqual(cond.type, ">=")
+        self.assertEqual(cond.params, ("population", "0"))
+        self.assertTrue(cond.test({"population": "0"}))
+        self.assertTrue(cond.test({"population": "100000"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"population": "-1"}))
+
+        cond:Condition = parseCondition("population >= 150000")
+        self.assertEqual(cond.type, ">=")
+        self.assertEqual(cond.params, ("population", "150000"))
+        self.assertTrue(cond.test({"population": "150000"}))
+        self.assertTrue(cond.test({"population": "250000"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"population": "10000"}))
+
+        cond:Condition = parseCondition("\tbbox_area  >= 4000000")
+        self.assertEqual(cond.type, ">=")
+        self.assertEqual(cond.params, ("bbox_area", "4000000"))
+        self.assertTrue(cond.test({"bbox_area": "4000000"}))
+        self.assertTrue(cond.test({"bbox_area": "8000000"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"bbox_area": "999"}))
+
+    def test_parser_gt(self):
+        """ Test conditions in format population > 100000
+            Note that such conditions are not used by Organic Maps styles.
+        """
+        cond:Condition = parseCondition("population>0")
+        self.assertEqual(cond.type, ">")
+        self.assertEqual(cond.params, ("population", "0"))
+        self.assertTrue(cond.test({"population": "100"}))
+        self.assertFalse(cond.test({"population": "000"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"population": "-1"}))
+
+        cond:Condition = parseCondition("population > 150000")
+        self.assertEqual(cond.type, ">")
+        self.assertEqual(cond.params, ("population", "150000"))
+        self.assertTrue(cond.test({"population": "250000"}))
+        self.assertFalse(cond.test({"population": "150000"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"population": "10000"}))
+
+        cond:Condition = parseCondition("\tbbox_area > 4000000 ")
+        self.assertEqual(cond.type, ">")
+        self.assertEqual(cond.params, ("bbox_area", "4000000 ")) # TODO fix parser to exclude trailing space
+        self.assertTrue(cond.test({"bbox_area": "8000000"}))
+        self.assertFalse(cond.test({"bbox_area": "4000000"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"bbox_area": "999"}))
+
+    def test_parser_lt(self):
+        cond:Condition = parseCondition("population<40000")
+        self.assertEqual(cond.type, "<")
+        self.assertEqual(cond.params, ("population", "40000"))
+        self.assertTrue(cond.test({"population": "100"}))
+        self.assertTrue(cond.test({"population": "-1"}))
+        self.assertFalse(cond.test({"population": "40000"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"population": "500000"}))
+
+        cond:Condition = parseCondition("\tbbox_area < 4000000\n")
+        self.assertEqual(cond.type, "<")
+        self.assertEqual(cond.params, ("bbox_area", "4000000\n")) # TODO fix parser to exclude trailing \n
+        self.assertTrue(cond.test({"bbox_area": "100"}))
+        self.assertTrue(cond.test({"bbox_area": "-1"}))
+        self.assertTrue(cond.test({"bbox_area": "000"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"bbox_area": "4000000"}))
+        self.assertFalse(cond.test({"bbox_area": "8000000"}))
+
+    def test_parser_le(self):
+        """ Test conditions in format population <= 100000
+            Note that such conditions are not used by Organic Maps styles.
+        """
+        cond:Condition = parseCondition("population<=40000")
+        self.assertEqual(cond.type, "<=")
+        self.assertEqual(cond.params, ("population", "40000"))
+        self.assertTrue(cond.test({"population": "100"}))
+        self.assertTrue(cond.test({"population": "-1"}))
+        self.assertTrue(cond.test({"population": "40000"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"population": "500000"}))
+
+        cond:Condition = parseCondition("\tbbox_area <= 4000000\n")
+        self.assertEqual(cond.type, "<=")
+        self.assertEqual(cond.params, ("bbox_area", "4000000\n")) # TODO fix parser to exclude trailing \n
+        self.assertTrue(cond.test({"bbox_area": "100"}))
+        self.assertTrue(cond.test({"bbox_area": "-1"}))
+        self.assertTrue(cond.test({"bbox_area": "000"}))
+        self.assertTrue(cond.test({"bbox_area": "4000000"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"bbox_area": "8000000"}))
+
+    def test_parser_ne(self):
+        cond:Condition = parseCondition("capital!=2")
+        self.assertEqual(cond.type, "ne")
+        self.assertEqual(cond.params, ("capital", "2"))
+        self.assertTrue(cond.test({"capital": "1"}))
+        self.assertTrue(cond.test({"capital": "22"}))
+        self.assertTrue(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"capital": "2"}))
+
+        cond:Condition = parseCondition("\tcapital  !=  2")
+        self.assertEqual(cond.type, "ne")
+        self.assertEqual(cond.params, ("capital", "2"))
+        self.assertTrue(cond.test({"capital": "1"}))
+        self.assertTrue(cond.test({"capital": "22"}))
+        self.assertTrue(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"capital": "2"}))
+
+        cond:Condition = parseCondition("garden:type != residential")
+        self.assertEqual(cond.type, "ne")
+        self.assertEqual(cond.params, ("garden:type", "residential"))
+        self.assertTrue(cond.test({"garden:type": "public"}))
+        self.assertTrue(cond.test({"garden:type": "res"}))
+        self.assertTrue(cond.test({"garden:type": "residential_plus"}))
+        self.assertTrue(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"garden:type": "residential"}))
+
+    def test_parser_set(self):
+        cond:Condition = parseCondition("tunnel")
+        self.assertEqual(cond.type, "set")
+        self.assertEqual(cond.params, ("tunnel", ))
+        self.assertTrue(cond.test({"tunnel": "yes"}))
+        self.assertTrue(cond.test({"tunnel": "maybe"}))
+        self.assertTrue(cond.test({"tunnel": "+1"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+
+        cond:Condition = parseCondition("building\t")
+        self.assertEqual(cond.type, "set")
+        self.assertEqual(cond.params, ("building", ))
+        self.assertTrue(cond.test({"building": "yes"}))
+        self.assertTrue(cond.test({"building": "apartment"}))
+        self.assertTrue(cond.test({"building": "1"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"building:part": "yes"}))
+
+        cond:Condition = parseCondition(" addr:housenumber ")
+        self.assertEqual(cond.type, "set")
+        self.assertEqual(cond.params, ("addr:housenumber", ))
+        self.assertTrue(cond.test({"addr:housenumber": "1"}))
+        self.assertTrue(cond.test({"addr:housenumber": "yes"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"addr:street": "Baker st"}))
+
+        cond:Condition = parseCondition(" some-tag ")
+        self.assertEqual(cond.type, "set")
+        self.assertEqual(cond.params, ("some-tag", ))
+        self.assertTrue(cond.test({"some-tag": "1"}))
+        self.assertTrue(cond.test({"some-tag": "yes"}))
+        self.assertFalse(cond.test({"highway": "secondary"}))
+        self.assertFalse(cond.test({"some": "tag"}))
+
+    def test_parser_unset(self):
+        cond:Condition = parseCondition("!tunnel")
+        self.assertEqual(cond.type, "unset")
+        self.assertEqual(cond.params, ("tunnel", ))
+        self.assertTrue(cond.test({"capital": "1"}))
+        self.assertFalse(cond.test({"tunnel": "yes"}))
+        self.assertFalse(cond.test({"tunnel": "no"}))
+
+        cond:Condition = parseCondition("\t!name  ")
+        self.assertEqual(cond.type, "unset")
+        self.assertEqual(cond.params, ("name", ))
+        self.assertTrue(cond.test({"capital": "1"}))
+        self.assertTrue(cond.test({"int_name": "1"}))
+        self.assertFalse(cond.test({"name": "London"}))
+
+    def test_parser_false(self):
+        """ Test conditions in format some_tag = no
+            Note that such conditions are not used by Organic Maps styles.
+        """
+        cond:Condition = parseCondition("access=no")
+        self.assertEqual(cond.type, "false")
+        self.assertEqual(cond.params, ("access", ))
+        #self.assertTrue(cond.test({"access": "no"}))      # test is not implemented for `false` condition
+        #self.assertTrue(cond.test({"access": "private"})) # test is not implemented for `false` condition
+        self.assertFalse(cond.test({"tunnel": "yes"}))
+
+    def test_parser_invTrue(self):
+        """ Test conditions in format [!some_tag?] It works the same way as [some_tag != yes]
+            Note that such conditions are not used by Organic Maps styles.
+        """
+        cond:Condition = parseCondition("!oneway?")
+        self.assertEqual(cond.type, "ne")
+        self.assertEqual(cond.params, ("oneway", "yes"))
+        self.assertTrue(cond.test({"oneway": "no"}))
+        self.assertTrue(cond.test({"oneway": "nobody_knows"}))
+        self.assertTrue(cond.test({"access": "private"}))
+        self.assertFalse(cond.test({"oneway": "yes"}))
+
+        cond:Condition = parseCondition("\t! intermittent ?\n")
+        self.assertEqual(cond.type, "ne")
+        self.assertEqual(cond.params, ("intermittent", "yes"))
+        self.assertTrue(cond.test({"intermittent": "no"}))
+        self.assertTrue(cond.test({"intermittent": "maybe"}))
+        self.assertTrue(cond.test({"access": "private"}))
+        self.assertFalse(cond.test({"intermittent": "yes"}))
+
+    def test_parser_true(self):
+        """ Test conditions in format [some_tag?] It works the same way as [some_tag = yes] """
+        cond:Condition = parseCondition("area?")
+        self.assertEqual(cond.type, "true")
+        self.assertEqual(cond.params, ("area", ))
+        self.assertTrue(cond.test({"area": "yes"}))
+        self.assertFalse(cond.test({"area": "no"}))
+        self.assertFalse(cond.test({"access": "private"}))
+        self.assertFalse(cond.test({"oneway": "nobody_knows"}))
+
+        cond:Condition = parseCondition("\tbridge ? ")
+        self.assertEqual(cond.type, "true")
+        self.assertEqual(cond.params, ("bridge", ))
+        self.assertTrue(cond.test({"bridge": "yes"}))
+        self.assertFalse(cond.test({"bridge": "no"}))
+        self.assertFalse(cond.test({"access": "private"}))
+        self.assertFalse(cond.test({"bridge": "maybe"}))
+
+    def test_untrue(self):
+        """ parseCondition(...) doesn't support this type of condition.
+            Not sure if it's ever used.
+        """
+        cond:Condition = Condition("untrue", "access")
+        self.assertEqual(cond.type, "untrue")
+        self.assertEqual(cond.params, ("access", ))
+        self.assertTrue(cond.test({"access": "no"}))
+        self.assertFalse(cond.test({"access": "private"}))
+        self.assertFalse(cond.test({"oneway": "yes"}))
+
+    def test_parser_errors(self):
+        with self.assertRaises(Exception):
+            parseCondition("! tunnel")
+        with self.assertRaises(Exception):
+            """ Symbol '-' is only supported in simple 'set' rule. E.g. [key-with-dash]
+                But not in 'unset' rule [!key-with-dash] """
+            parseCondition("key-with-dash?")
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/testEval.py b/tests/testEval.py
new file mode 100644
index 0000000..94edc2a
--- /dev/null
+++ b/tests/testEval.py
@@ -0,0 +1,124 @@
+import unittest
+import sys
+from pathlib import Path
+
+# Add `src` directory to the import paths
+sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
+
+from mapcss.Eval import Eval
+
+class EvalTest(unittest.TestCase):
+    """ Test eval(...) feature for CSS properties.
+        NOTE: eval() is not used in Organic Maps styles. We can drop it completely.
+    """
+    def test_eval_tag(self):
+        a = Eval("""eval( tag("lanes") )""")
+        self.assertEqual(a.compute({"lanes": "4"}), "4")
+        self.assertEqual(a.compute({"natural": "trees"}), "")
+        self.assertSetEqual(a.extract_tags(), {"lanes"})
+
+    def test_eval_prop(self):
+        a = Eval("""eval( prop("dpi") / 2 )""")
+        self.assertEqual(a.compute({"lanes": "4"}, {"dpi": 144}), "72")
+        self.assertEqual(a.compute({"lanes": "4"}, {"orientation": "vertical"}), "")
+        self.assertSetEqual(a.extract_tags(), set())
+
+    def test_eval_num(self):
+        a = Eval("""eval( num(tag("lanes")) + 2 )""")
+        self.assertEqual(a.compute({"lanes": "4"}), "6")
+        self.assertEqual(a.compute({"lanes": "many"}), "2")
+        self.assertSetEqual(a.extract_tags(), {"lanes"})
+
+    def test_eval_metric(self):
+        a = Eval("""eval( metric(tag("height")) )""")
+        self.assertEqual(a.compute({"height": "512"}), "512")
+        self.assertEqual(a.compute({"height": "10m"}), "10")
+        self.assertEqual(a.compute({"height": " 10m"}), "10")
+        self.assertEqual(a.compute({"height": "500cm"}), "5")
+        self.assertEqual(a.compute({"height": "500 cm"}), "5")
+        self.assertEqual(a.compute({"height": "250CM"}), "2.5")
+        self.assertEqual(a.compute({"height": "250 CM"}), "2.5")
+        self.assertEqual(a.compute({"height": "30см"}), "0.3")
+        self.assertEqual(a.compute({"height": " 30 см"}), "0.3")
+        self.assertEqual(a.compute({"height": "1200 mm"}), "1.2")
+        self.assertEqual(a.compute({"height": "2400MM"}), "2.4")
+        self.assertEqual(a.compute({"height": "2800 мм"}), "2.8")
+        self.assertSetEqual(a.extract_tags(), {"height"})
+
+    def test_eval_metric_with_scale(self):
+        a = Eval("""eval( metric(tag("height")) )""")
+        self.assertEqual(a.compute({"height": "512"}, xscale=4), "2048")
+        self.assertEqual(a.compute({"height": "512"}, zscale=4), "512")
+        self.assertEqual(a.compute({"height": "10m"}, xscale=4), "40")
+        self.assertEqual(a.compute({"height": " 10m"}, xscale=4), "40")
+        self.assertEqual(a.compute({"height": "500cm"}, xscale=4), "20")
+        self.assertEqual(a.compute({"height": "500 cm"}, xscale=4), "20")
+        self.assertEqual(a.compute({"height": "250CM"}, xscale=4), "10")
+        self.assertEqual(a.compute({"height": "250 CM"}, xscale=4), "10")
+        self.assertEqual(a.compute({"height": "30см"}, xscale=4), "1.2")
+        self.assertEqual(a.compute({"height": " 30 см"}, xscale=4), "1.2")
+        self.assertEqual(a.compute({"height": "1200 mm"}, xscale=4), "4.8")
+        self.assertEqual(a.compute({"height": "2400MM"}, xscale=4), "9.6")
+        self.assertEqual(a.compute({"height": "2800 мм"}, xscale=4), "11.2")
+        self.assertSetEqual(a.extract_tags(), {"height"})
+
+    def test_eval_zmetric(self):
+        a = Eval("""eval( zmetric(tag("depth")) )""")
+        self.assertEqual(a.compute({"depth": "512"}), "256")
+        self.assertEqual(a.compute({"depth": "10m"}), "5")
+        self.assertEqual(a.compute({"depth": " 10m"}), "5")
+        self.assertEqual(a.compute({"depth": "500cm"}), "2.5")
+        self.assertEqual(a.compute({"depth": "500 cm"}), "2.5")
+        self.assertEqual(a.compute({"depth": "250CM"}), "1.25")
+        self.assertEqual(a.compute({"depth": "250 CM"}), "1.25")
+        self.assertEqual(a.compute({"depth": "30см"}), "0.15")
+        self.assertEqual(a.compute({"depth": " 30 см"}), "0.15")
+        self.assertEqual(a.compute({"depth": "1200 mm"}), "0.6")
+        self.assertEqual(a.compute({"depth": "2400MM"}), "1.2")
+        self.assertEqual(a.compute({"depth": "2800 мм"}), "1.4")
+        self.assertSetEqual(a.extract_tags(), {"depth"})
+
+    def test_eval_str(self):
+        a = Eval("""eval( str( num(tag("width")) - 200 ) )""")
+        self.assertEqual(a.compute({"width": "400"}), "200.0")
+        self.assertSetEqual(a.extract_tags(), {"width"})
+
+    def test_eval_any(self):
+        a = Eval("""eval( any(tag("building"), tag("building:part"), "no") )""")
+        self.assertEqual(a.compute({"building": "apartment"}), "apartment")
+        self.assertEqual(a.compute({"building:part": "roof"}), "roof")
+        self.assertEqual(a.compute({"junction": "roundabout"}), "no")
+        self.assertSetEqual(a.extract_tags(), {"building", "building:part"})
+
+    def test_eval_min(self):
+        a = Eval("""eval( min( num(tag("building:levels")) * 3, 50) )""")
+        self.assertEqual(a.compute({"natural": "wood"}), "0")
+        self.assertEqual(a.compute({"building:levels": "0"}), "0")
+        self.assertEqual(a.compute({"building:levels": "10"}), "30")
+        self.assertEqual(a.compute({"building:levels": "30"}), "50")
+        self.assertSetEqual(a.extract_tags(), {"building:levels"})
+
+    def test_eval_max(self):
+        a = Eval("""eval( max( tag("speed:limit"), 60) )""")
+        self.assertEqual(a.compute({"natural": "wood"}), "60")
+        self.assertEqual(a.compute({"speed:limit": "30"}), "60")
+        self.assertEqual(a.compute({"speed:limit": "60"}), "60")
+        self.assertEqual(a.compute({"speed:limit": "90"}), "90")
+        self.assertSetEqual(a.extract_tags(), {"speed:limit"})
+
+    def test_eval_cond(self):
+        a = Eval("""eval( cond( boolean(tag("oneway")), 200, 100) )""")
+        self.assertEqual(a.compute({"natural": "wood"}), "100")
+        self.assertEqual(a.compute({"oneway": "yes"}), "200")
+        self.assertEqual(a.compute({"oneway": "no"}), "100")
+        self.assertEqual(a.compute({"oneway": "true"}), "200")
+        self.assertEqual(a.compute({"oneway": "probably no"}), "200")
+        self.assertSetEqual(a.extract_tags(), {"oneway"})
+
+    def test_complex_eval(self):
+        a = Eval(""" eval( any( metric(tag("height")), metric ( num(tag("building:levels")) * 3), metric("1m"))) """)
+        self.assertEqual(a.compute({"building:levels": "3"}), "9")
+        self.assertSetEqual(a.extract_tags(), {"height", "building:levels"})
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/testLibkomwm.py b/tests/testLibkomwm.py
new file mode 100644
index 0000000..c2f3e52
--- /dev/null
+++ b/tests/testLibkomwm.py
@@ -0,0 +1,68 @@
+import unittest
+import sys
+from pathlib import Path
+from copy import deepcopy
+
+# Add `src` directory to the import paths
+sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
+
+import libkomwm
+from libkomwm import komap_mapswithme
+
+
+class LibKomwmTest(unittest.TestCase):
+    def test_generate_drules_mini(self):
+        assets_dir = Path(__file__).parent / 'assets' / 'case-2-generate-drules-mini'
+
+        class Options(object):
+            pass
+
+        options = Options()
+        options.data = None
+        options.minzoom = 0
+        options.maxzoom = 10
+        options.txt = True
+        options.filename = str( assets_dir / "main.mapcss" )
+        options.outfile = str( assets_dir / "style_output" )
+        options.priorities_path = str( assets_dir / "include" )
+
+        try:
+            # Save state
+            libkomwm.MULTIPROCESSING = False
+            prio_ranges_orig = deepcopy(libkomwm.prio_ranges)
+            libkomwm.visibilities = {}
+
+            # Run style generation
+            komap_mapswithme(options)
+
+            # Restore state
+            libkomwm.prio_ranges = prio_ranges_orig
+            libkomwm.MULTIPROCESSING = True
+            libkomwm.visibilities = {}
+
+            # Check that types.txt contains 1173 lines
+            with open(assets_dir / "types.txt", "rt") as typesFile:
+                lines = [l.strip() for l in typesFile]
+                self.assertEqual(len(lines), 1173, "Generated types.txt file should contain 1173 lines")
+                self.assertEqual(len([l for l in lines if l!="mapswithme"]), 148, "Actual types count should be 148 as in mapcss-mapping.csv")
+
+            # Check that style_output.bin has 20 styles
+            with open(assets_dir / "style_output.bin", "rb") as protobuf_file:
+                protobuf_data = protobuf_file.read()
+            drules = libkomwm.ContainerProto()
+            drules.ParseFromString(protobuf_data)
+
+            self.assertEqual(len(drules.cont), 20, "Generated style_output.bin should contain 20 styles")
+
+        finally:
+            # Clean up generated files
+            files2delete = ["classificator.txt", "colors.txt", "patterns.txt", "style_output.bin",
+                            "style_output.txt", "types.txt", "visibility.txt"]
+            for filename in files2delete:
+                (assets_dir / filename).unlink(missing_ok=True)
+
+    def test_generate_drules_validation_errors(self):
+        assets_dir = Path(__file__).parent / 'assets' / 'case-3-styles-validation'
+        # TODO: needs refactoring of libkomwm.validation_errors_count to have a list
+        #       of validation errors.
+        self.assertTrue(True)
diff --git a/tests/testMapCSS.py b/tests/testMapCSS.py
new file mode 100644
index 0000000..c5ffa6d
--- /dev/null
+++ b/tests/testMapCSS.py
@@ -0,0 +1,364 @@
+import unittest
+import sys
+from pathlib import Path
+
+# Add `src` directory to the import paths
+sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
+
+from mapcss import parseDeclaration, MapCSS
+
+
+class MapCSSTest(unittest.TestCase):
+    def test_declarations(self):
+        decl = parseDeclaration(""" linejoin: round; """)
+        self.assertEqual(len(decl), 1)
+        self.assertEqual(decl[0], {"linejoin": "round"})
+
+        decl = parseDeclaration("""\tlinejoin :\nround ; """)
+        self.assertEqual(len(decl), 1)
+        self.assertEqual(decl[0], {"linejoin": "round"})
+
+        decl = parseDeclaration(""" icon-image: parking_private-s.svg; text: "name"; """)
+        self.assertEqual(len(decl), 1)
+        self.assertEqual(decl[0], {
+            "icon-image": "parking_private-s.svg",
+            "text": "name"
+        })
+
+        decl = parseDeclaration("""
+            pattern-offset: 90\t;
+            pattern-image:\tarrow-m.svg   ;
+            pattern-spacing: @trunk0 ;""")
+        self.assertEqual(len(decl), 1)
+        self.assertEqual(decl[0], {
+            "pattern-offset": "90",
+            "pattern-image": "arrow-m.svg",
+            "pattern-spacing": "@trunk0",
+        })
+
+    def test_parse_variables(self):
+        parser = MapCSS()
+        parser.parse("""
+@city_label: #999999;
+@country_label: #444444;
+@wave_length: 25;
+""")
+        self.assertEqual(parser.variables, {
+            "city_label": "#999999",
+            "country_label": "#444444",
+            "wave_length": "25"
+        })
+
+    def test_parse_colors(self):
+        parser = MapCSS()
+        parser.parse("""
+@city_label : #999999;
+@country_label: #444444 ;
+  @wave_length: 25;
+""")
+        self.assertEqual(parser.variables, {
+            "city_label": "#999999",
+            "country_label": "#444444",
+            "wave_length": "25"
+        })
+
+    def test_parse_import(self):
+        parser = MapCSS()
+        mapcssFile = Path(__file__).parent / 'assets' / 'case-1-import' / 'main.mapcss'
+        parser.parse(filename=str(mapcssFile))
+
+        colors = parser.get_colors()
+        self.assertEqual(colors, {
+            "GuiText-color": (1.0, 1.0, 1.0),
+            "GuiText-opacity": 0.7,
+            "Route-color": (0.0, 0.0, 1.0),
+            "Route-opacity": 0.5,
+        })
+
+    def test_parse_basic_chooser(self):
+        parser = MapCSS()
+        static_tags = {"tourism": True, "office": True,
+                        "craft": True, "amenity": True}
+        parser.parse("""
+node|z17-[tourism],
+area|z17-[tourism],
+node|z18-[office],
+area|z18-[office],
+node|z18-[craft],
+area|z18-[craft],
+node|z19-[amenity],
+area|z19-[amenity],
+{text: name; text-color: #000030; text-offset: 1;}
+""", static_tags=static_tags)
+
+        self.assertEqual(len(parser.choosers), 1)
+        self.assertEqual(len(parser.choosers[0].ruleChains), 8)
+
+    def test_parse_basic_chooser_2(self):
+        parser = MapCSS()
+        static_tags = {"highway": True}
+        parser.parse("""
+@trunk0: #FF7326;
+
+line|z6[highway=trunk],
+line|z6[highway=motorway],
+{color: @trunk0; opacity: 0.3;}
+line|z7-9[highway=trunk],
+line|z7-9[highway=motorway],
+{color: @trunk0; opacity: 0.7;}
+""", static_tags=static_tags)
+
+        self.assertEqual(len(parser.choosers), 2)
+        self.assertEqual(len(parser.choosers[0].ruleChains), 2)
+        self.assertEqual(parser.choosers[0].ruleChains[0].subject, 'line')
+        self.assertEqual(parser.choosers[0].selzooms, [6, 6])
+        self.assertEqual(parser.choosers[1].selzooms, [7, 9])
+
+        rule, object_id = parser.choosers[0].testChains({"highway": "trunk"})
+        self.assertEqual(object_id, "::default")
+
+    def test_parse_basic_chooser_3(self):
+        parser = MapCSS()
+        static_tags = {"addr:housenumber": True, "addr:street": False}
+        parser.parse("""
+/* Some Comment Here */
+
+/*
+   This sample is borrowed from Organic Maps Basemap_label.mapcss file
+ */
+node|z18-[addr:housenumber][addr:street]::int_name
+{text: int_name; text-color: #65655E; text-position: center;}
+""", static_tags=static_tags)
+
+        building_tags = {"building": "yes", "addr:housenumber": "12", "addr:street": "Baker street"}
+
+        # Check that mapcss parsed correctly
+        self.assertEqual(len(parser.choosers), 1)
+        styleChooser = parser.choosers[0]
+        self.assertEqual(len(styleChooser.ruleChains), 1)
+        self.assertEqual(styleChooser.selzooms, [18, 19])
+        rule, object_id = styleChooser.testChains(building_tags)
+        self.assertEqual(object_id, "::int_name")
+
+        rule = styleChooser.ruleChains[0]
+        self.assertEqual(rule.subject, 'node')
+        self.assertEqual(rule.extract_tags(), {'addr:housenumber', 'addr:street'})
+
+    def test_parse_basic_chooser_class(self):
+        parser = MapCSS()
+        parser.parse("""
+way|z-13::*
+{
+  linejoin: round;
+}
+""")
+
+        # Check that mapcss parsed correctly
+        self.assertEqual(len(parser.choosers), 1)
+        styleChooser = parser.choosers[0]
+        self.assertEqual(len(styleChooser.ruleChains), 1)
+        self.assertEqual(styleChooser.selzooms, [0, 13])
+        rule, object_id = styleChooser.testChains({})
+        self.assertEqual(object_id, "::*")
+
+        rule = styleChooser.ruleChains[0]
+        self.assertEqual(rule.subject, 'way')
+        self.assertEqual(rule.extract_tags(), {'*'})
+
+    def test_parse_basic_chooser_class_2(self):
+        parser = MapCSS()
+        parser.parse("""
+way|z10-::*
+{
+  linejoin: round;
+}
+""")
+
+        # Check that mapcss parsed correctly
+        self.assertEqual(len(parser.choosers), 1)
+        styleChooser = parser.choosers[0]
+        self.assertEqual(len(styleChooser.ruleChains), 1)
+        self.assertEqual(styleChooser.selzooms, [10, 19])
+        rule, object_id = styleChooser.testChains({})
+        self.assertEqual(object_id, "::*")
+
+        rule = styleChooser.ruleChains[0]
+        self.assertEqual(rule.subject, 'way')
+        self.assertEqual(rule.extract_tags(), {'*'})
+
+    def test_parse_basic_chooser_colors(self):
+        parser = MapCSS()
+        parser.parse("""
+way|z-6::*
+{
+  linejoin: round;
+}
+
+colors {
+  GuiText-color: #FFFFFF;
+  GuiText-opacity: 0.7;
+  MyPositionAccuracy-color: #FFFFFF;
+  MyPositionAccuracy-opacity: 0.06;
+  Selection-color: #FFFFFF;
+  Selection-opacity: 0.64;
+  Route-color: #0000FF;
+  RouteOutline-color: #00FFFF;
+}
+""")
+
+        # Check that colors from mapcss parsed correctly
+        colors = parser.get_colors()
+        self.assertEqual(colors, {
+            "GuiText-color": (1.0, 1.0, 1.0),
+            "GuiText-opacity": 0.7,
+            "MyPositionAccuracy-color": (1.0, 1.0, 1.0),
+            "MyPositionAccuracy-opacity": 0.06,
+            "Selection-color": (1.0, 1.0, 1.0),
+            "Selection-opacity": 0.64,
+            "Route-color": (0.0, 0.0, 1.0),
+            "RouteOutline-color": (0.0, 1.0, 1.0)
+        })
+
+    def test_parser_choosers_tree(self):
+        parser = MapCSS()
+        static_tags = {"tourism": True, "office": True,
+                       "craft": True, "amenity": True}
+
+        parser.parse("""
+node|z17-[office=lawyer],
+area|z17-[office=lawyer],
+{text: name;text-color: #444444;text-offset: 1;font-size: 10;}
+
+node|z17-[tourism],
+area|z17-[tourism],
+node|z18-[office],
+area|z18-[office],
+node|z18-[craft],
+area|z18-[craft],
+node|z19-[amenity],
+area|z19-[amenity],
+{text: name; text-color: #000030; text-offset: 1;}
+
+node|z18-[office],
+area|z18-[office],
+node|z18-[craft],
+area|z18-[craft],
+{font-size: 11;}
+
+node|z17-[office=lawyer],
+area|z17-[office=lawyer]
+{icon-image: lawyer-m.svg;}
+""", static_tags=static_tags)
+
+        for obj_type in ["line", "area", "node"]:
+            parser.build_choosers_tree("tourism", obj_type, "tourism")
+            parser.build_choosers_tree("office", obj_type, "office")
+            parser.build_choosers_tree("craft", obj_type, "craft")
+            parser.build_choosers_tree("amenity", obj_type, "amenity")
+
+        parser.finalize_choosers_tree()
+
+        # Pick style for zoom = 17
+        styles18 = parser.get_style("office", "node", {"office": "lawyer"},
+                                    zoom=18, xscale=1, zscale=1, filter_by_runtime_conditions=False)
+
+        self.assertEqual(len(styles18), 1),
+        self.assertEqual(styles18[0], {'object-id': '::default',
+            'font-size': '11',
+            'text': 'name',
+            'text-color': (0, 0, 16*3/255),
+            'text-offset': 1.0,
+            'icon-image': 'lawyer-m.svg'})
+
+        # Pick style for zoom = 17
+        styles17 = parser.get_style("office", "node", {"office": "lawyer"},
+                                    zoom=17, xscale=1, zscale=1, filter_by_runtime_conditions=False)
+
+        self.assertEqual(len(styles17), 1),
+        self.assertEqual(styles17[0], {'object-id': '::default',
+            'font-size': '10',
+            'text': 'name',
+            'text-color': (68/255, 68/255, 68/255),
+            'text-offset': 1.0,
+            'icon-image': 'lawyer-m.svg'})
+
+        # Pick style for zoom = 15
+        styles15 = parser.get_style("office", "node", {"office": "lawyer"},
+                                    zoom=15, xscale=1, zscale=1, filter_by_runtime_conditions=False)
+
+        self.assertEqual(styles15, []),
+
+    def test_parser_choosers_tree_with_classes(self):
+        parser = MapCSS()
+        static_tags = {"highway": True}
+
+        parser.parse("""
+line|z10-[highway=motorway]::shield,
+line|z10-[highway=trunk]::shield,
+line|z10-[highway=motorway_link]::shield,
+line|z10-[highway=trunk_link]::shield,
+line|z10-[highway=primary]::shield,
+line|z11-[highway=primary_link]::shield,
+line|z12-[highway=secondary]::shield,
+line|z13-[highway=tertiary]::shield,
+line|z15-[highway=residential]::shield,
+{
+  shield-font-size: 9;
+  shield-text-color: #000000;
+  shield-text-halo-radius: 0;
+  shield-color: #FFFFFF;
+  shield-outline-radius: 1;
+}
+
+line|z12-[highway=residential],
+line|z12-[highway=tertiary],
+line|z18-[highway=tertiary_link]
+{
+  text: name;
+  text-color: #333333;
+  text-halo-opacity: 0.8;
+  text-halo-radius: 1;
+}
+
+line|z12-13[highway=residential],
+line|z12-13[highway=tertiary]
+{
+    font-size: 12;
+    text-color: #444444;
+}
+""", static_tags=static_tags)
+
+        parser.build_choosers_tree("highway", "line", "highway")
+        parser.finalize_choosers_tree()
+
+        # Pick style for zoom = 10
+        styles10 = parser.get_style("highway", "line", {"highway": "primary"},
+                                    zoom=10, xscale=1, zscale=1, filter_by_runtime_conditions=False)
+
+        self.assertEqual(len(styles10), 1),
+        self.assertEqual(styles10[0], {'object-id': '::shield',
+            'shield-font-size': '9',
+            'shield-text-color': (0.0, 0.0, 0.0),
+            'shield-text-halo-radius': 0.0,
+            'shield-color': (1.0, 1.0, 1.0),
+            'shield-outline-radius': 1.0})
+
+        # Pick style for zoom = 15. Expecting two `object-id` values: '::shield' and '::default'
+        styles15 = parser.get_style("highway", "line", {"highway": "tertiary"},
+                                    zoom=15, xscale=1, zscale=1, filter_by_runtime_conditions=False)
+
+        self.assertEqual(len(styles15), 2),
+        self.assertEqual(styles15[0], {'object-id': '::shield',
+            'shield-font-size': '9',
+            'shield-text-color': (0.0, 0.0, 0.0),
+            'shield-text-halo-radius': 0.0,
+            'shield-color': (1.0, 1.0, 1.0),
+            'shield-outline-radius': 1.0})
+        self.assertEqual(styles15[1], {'object-id': '::default',
+            'text': 'name',
+            'text-color': (51/255, 51/255, 51/255),
+            'text-halo-opacity': 0.8,
+            'text-halo-radius': 1.0})
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/testRule.py b/tests/testRule.py
new file mode 100644
index 0000000..f3eec89
--- /dev/null
+++ b/tests/testRule.py
@@ -0,0 +1,114 @@
+import unittest
+import sys
+from pathlib import Path
+
+# Add `src` directory to the import paths
+sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
+
+from mapcss.Rule import Rule
+from mapcss.Condition import Condition
+from mapcss import parseCondition
+
+class RuleTest(unittest.TestCase):
+    def test_rule_subject(self):
+        self.assertEqual(Rule().subject, "")
+        self.assertEqual(Rule("*").subject, "")
+        self.assertEqual(Rule("way").subject, "way")
+        self.assertEqual(Rule("area").subject, "area")
+        self.assertEqual(Rule("node").subject, "node")
+        self.assertEqual(Rule("planet").subject, "planet")
+
+    def test_rule_type_matches(self):
+        self.assertCountEqual(Rule().type_matches, ('area', 'line', 'way', 'node'))
+        self.assertCountEqual(Rule("*").type_matches, ('area', 'line', 'way', 'node'))
+        self.assertCountEqual(Rule("way").type_matches, ('area', 'line', 'way'))
+        self.assertCountEqual(Rule("area").type_matches, ('area', 'way'))
+        self.assertCountEqual(Rule("node").type_matches, ('node', ))
+        self.assertCountEqual(Rule("planet").type_matches, set())
+
+    def test_rule_with_conditions(self):
+        rule = Rule()
+        rule.conditions = [
+            parseCondition("aeroway=aerodrome"),
+            parseCondition("aerodrome=international")
+        ]
+
+        tt = rule.test({
+            "aeroway": "aerodrome",
+            "aerodrome": "international",
+            "name": "JFK"
+        })
+        self.assertTrue(tt)
+        self.assertEqual(tt, "::default")
+
+        self.assertCountEqual(rule.extract_tags(), ["aeroway", "aerodrome"])
+
+        # Negative test cases
+        self.assertFalse(rule.test({
+            "aeroway": "aerodrome",
+            "name": "JFK"
+        }))
+
+    def test_rule_with_class(self):
+        rule = Rule()
+        rule.conditions = [
+            parseCondition("highway=unclassified"),
+            parseCondition("bridge?"),
+            Condition("eq", ("::class", "::bridgeblack"))
+        ]
+
+        tt = rule.test({
+            "highway": "unclassified",
+            "bridge": "yes",
+            "layer": "1"
+        })
+        self.assertTrue(tt)
+        self.assertEqual(tt, "::bridgeblack")
+
+        self.assertCountEqual(rule.extract_tags(), ["highway", "bridge"])
+
+        # Negative test cases
+        self.assertFalse(rule.test({
+            "highway": "unclassified",
+            "bridge": "no",
+            "layer": "1"
+        }))
+        self.assertFalse(rule.test({
+            "highway": "unclassified",
+            "tunnel": "yes",
+            "layer": "-1"
+        }))
+
+    def test_tags_from_rule_with_class(self):
+        # Class condition doesn't add new tags
+        rule = Rule()
+        rule.conditions = [
+            parseCondition("highway=unclassified"),
+            parseCondition("bridge?"),
+            Condition("eq", ("::class", "::bridgeblack")),
+        ]
+
+        self.assertCountEqual(rule.extract_tags(), ["highway", "bridge"])
+
+        # Class condition doesn't add new tags
+        rule = Rule()
+        rule.conditions = [
+            parseCondition("highway=unclassified"),
+            Condition("eq", ("::class", "::*")),
+            parseCondition("bridge?"),
+        ]
+
+        self.assertCountEqual(rule.extract_tags(), ["highway", "bridge"])
+
+        # BUT having class as a first item overrides all the others
+        rule = Rule()
+        rule.conditions = [
+            Condition("eq", ("::class", "::int_name")),
+            parseCondition("highway=unclassified"),
+            parseCondition("bridge?"),
+        ]
+
+        self.assertCountEqual(rule.extract_tags(), ["*"])
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/testStyleChooser.py b/tests/testStyleChooser.py
new file mode 100644
index 0000000..a359e58
--- /dev/null
+++ b/tests/testStyleChooser.py
@@ -0,0 +1,297 @@
+import unittest
+import sys
+from pathlib import Path
+
+from mapcss.Rule import Rule
+
+# Add `src` directory to the import paths
+sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
+
+from mapcss import parseCondition, Condition
+from mapcss.Eval import Eval
+from mapcss.StyleChooser import StyleChooser, make_nice_style
+
+
+class StyleChooserTest(unittest.TestCase):
+    def test_rules_chain(self):
+        sc = StyleChooser((0, 16))
+
+        sc.newObject()
+        sc.addCondition(parseCondition("highway=footway"))
+        sc.addCondition(parseCondition("footway=sidewalk"))
+
+        sc.newObject()
+        sc.addCondition(parseCondition("highway=footway"))
+        sc.addCondition(parseCondition("footway=crossing"))
+        sc.addCondition(Condition("eq", ("::class", "::*")))
+
+        self.assertTrue( sc.testChains({ "highway": "footway", "footway": "sidewalk" }) )
+        self.assertTrue( sc.testChains({ "highway": "footway", "footway": "crossing" }) )
+        self.assertFalse( sc.testChains({ "highway": "footway"}) )
+        self.assertFalse( sc.testChains({ "highway": "residential", "footway": "crossing" }) )
+
+        rule1, tt = sc.testChains({ "highway": "footway", "footway": "sidewalk" })
+        self.assertEqual(tt, "::default")
+
+        rule2, tt = sc.testChains({ "highway": "footway", "footway": "crossing" })
+        self.assertEqual(tt, "::*")
+
+        self.assertNotEqual(rule1, rule2)
+
+    def test_zoom(self):
+        sc = StyleChooser((0, 16))
+
+        sc.newObject()
+        sc.addZoom( (10, 19) )
+        sc.addCondition(parseCondition("railway=station"))
+        sc.addCondition(parseCondition("transport=subway"))
+        sc.addCondition(parseCondition("city=yerevan"))
+
+        sc.newObject()
+        sc.addZoom( (4, 15) )
+        sc.addCondition(parseCondition("railway=station"))
+        sc.addCondition(parseCondition("transport=subway"))
+        sc.addCondition(parseCondition("city=yokohama"))
+
+        rule1, tt = sc.testChains({ "railway": "station", "transport": "subway", "city": "yerevan" })
+        self.assertEqual(rule1.minZoom, 10)
+        self.assertEqual(rule1.maxZoom, 19)
+
+        rule2, tt = sc.testChains({ "railway": "station", "transport": "subway", "city": "yokohama" })
+        self.assertEqual(rule2.minZoom, 4)
+        self.assertEqual(rule2.maxZoom, 15)
+
+    def test_extract_tags(self):
+        sc = StyleChooser((0, 16))
+
+        sc.newObject()
+        sc.addCondition(parseCondition("aerialway=rope_tow"))
+
+        sc.newObject()
+        sc.addCondition(parseCondition("piste:type=downhill"))
+
+        self.assertSetEqual(sc.extract_tags(), {"aerialway", "piste:type"})
+
+        sc = StyleChooser((0, 16))
+
+        sc.newObject()
+        sc.addCondition(parseCondition("aeroway=terminal"))
+        sc.addCondition(parseCondition("building"))
+
+        sc.newObject()
+        sc.addCondition(parseCondition("waterway=dam"))
+        sc.addCondition(parseCondition("building:part"))
+
+        self.assertSetEqual(sc.extract_tags(), {"waterway", "building:part", "building", "aeroway"})
+
+    def test_make_nice_style(self):
+        style = make_nice_style({
+            "outline-color": "none",
+            "bg-color": "red",
+            "dash-color": "#ffff00",
+            "front-color": "rgb(0, 255, 255)",
+            "line-width": Eval("""eval(min(tag("line_width"), 10))"""),
+            "outline-width": "2.5",
+            "arrow-opacity": "0.5",
+            "offset-2": "20",
+            "border-radius": "4",
+            "line-extrude": "16",
+            "dashes": "3,3,1.5,3",
+            "wrong-dashes": "yes, yes, yes, no",
+            "make-nice": True,
+            "additional-len": 44.5
+        })
+
+        expectedStyle = {
+            "outline-color": "none",
+            "bg-color": (1.0, 0.0, 0.0),
+            "dash-color": (1.0, 1.0, 0.0),
+            "front-color": (0.0, 1.0, 1.0),
+            "line-width": Eval("""eval(min(tag("line_width"), 10))"""),
+            "outline-width": 2.5,
+            "arrow-opacity": 0.5,
+            "offset-2": 20.0,
+            "border-radius": 4.0,
+            "line-extrude": 16.0,
+            "dashes": [3.0, 3.0, 1.5, 3.0],
+            "wrong-dashes": [],
+            "make-nice": True,
+            "additional-len": 44.5
+        }
+
+        self.assertEqual(style, expectedStyle)
+
+    def test_add_styles(self):
+        sc = StyleChooser((15, 19))
+        sc.newObject()
+        sc.addStyles([{
+            "width": "1.3",
+            "opacity": "0.6",
+            "bg-color": "blue"
+        }])
+        sc.addStyles([{
+            "color": "#FFFFFF",
+            "casing-width": "+10"
+        }])
+
+        self.assertEqual(len(sc.styles), 2)
+        self.assertEqual(sc.styles[0], {
+            "width": 1.3,
+            "opacity": 0.6,
+            "bg-color": (0.0, 0.0, 1.0)
+        })
+        self.assertEqual(sc.styles[1], {
+            "color": (1.0, 1.0, 1.0),
+            "casing-width": 5.0
+        })
+
+    def test_update_styles(self):
+        styles = [{"primary_color": (1.0, 1.0, 1.0)}]
+
+        sc = StyleChooser((15, 19))
+        sc.newObject()
+        sc.addStyles([{
+            "width": "1.3",
+            "opacity": "0.6",
+            "bg-color": """eval( prop("primary_color") )""", # Check that property from `styles` is applied
+            "text-offset": """eval( cond( boolean(tag("oneway")), 10, 5) )""" # Check that tags are applied
+        }])
+
+        object_tags = {"highway": "service",
+                       "oneway": "yes"}
+        new_styles = sc.updateStyles(styles, object_tags, 1.0, 1.0, False)
+        expected_new_styles = {
+            "width": 1.3,
+            "opacity": 0.6,
+            "bg-color": (1.0, 1.0, 1.0),
+            "text-offset": 10.0,
+            "object-id": "::default"
+        }
+
+        self.assertEqual(len(new_styles), 2)
+        self.assertEqual(new_styles[-1], expected_new_styles)
+
+    def test_update_styles_2(self):
+        styles = []
+
+        sc = StyleChooser((15, 19))
+
+        sc.newObject()
+        sc.addCondition(Condition("eq", ("::class", "::int_name") )) # Class should be added to the style
+        sc.addCondition(parseCondition("oneway?"))
+
+        sc.addStyles([{
+            "width": "1.3",
+            "bg-color": "black"
+        }])
+
+        object_tags = {"highway": "service", "oneway": "yes"}
+        new_styles = sc.updateStyles(styles, object_tags, 1.0, 1.0, False)
+        expected_new_styles = {
+            "width": 1.3,
+            "bg-color": (0.0, 0.0, 0.0),
+            "object-id": "::int_name" # Check that class from sc.ruleChains is added to the style
+        }
+
+        self.assertEqual(len(new_styles), 1)
+        self.assertEqual(new_styles[-1], expected_new_styles)
+
+
+    def test_update_styles_by_class(self):
+        # Predefined styles
+        styles = [{
+            "some-width": 2.5,
+            "object-id": "::flats"
+        },
+        {
+            "some-width": 3.5,
+            "object-id": "::bridgeblack"
+        },
+        {
+            "some-width": 4.5,
+            "object-id": "::default"
+        }]
+
+        sc = StyleChooser((15, 19))
+
+        sc.newObject()
+        sc.addCondition(Condition("eq", ("::class", "::flats") )) # `sc` styles should apply only to `::flats` class
+        sc.addCondition(parseCondition("oneway?"))
+
+        sc.newObject()
+        sc.addCondition(Condition("eq", ("::class", "::bridgeblack") )) # This class is ignored by StyleChooser
+        sc.addCondition(parseCondition("oneway?"))
+
+        sc.addStyles([{
+            "some-width": "1.5",
+            "other-offset": "4"
+        }])
+
+        object_tags = {"highway": "service", "oneway": "yes"}
+
+        # Apply new style to predefined styles with filter by class
+        new_styles = sc.updateStyles(styles, object_tags, 1.0, 1.0, False)
+
+        expected_new_styles = [{ # The first style changes
+            "some-width": 1.5,
+            "other-offset": 4.0,
+            "object-id": "::flats"
+        },
+        { # Style not changed (class is not `::flats`)
+            "some-width": 3.5,
+            "object-id": "::bridgeblack"
+        },
+        { # Style not changed (class is not `::flats`)
+            "some-width": 4.5,
+            "object-id": "::default"
+        }]
+
+        self.assertEqual(len(new_styles), 3)
+        self.assertEqual(new_styles, expected_new_styles)
+
+
+    def test_update_styles_by_class_all(self):
+        # Predefined styles
+        styles = [{ # This is applied to StyleChooser styles
+            "some-width": 2.5,
+            "corner-radius": 2.5,
+            "object-id": "::*"
+        },
+        {
+            "some-width": 3.5,
+            "object-id": "::bridgeblack"
+        }]
+
+        sc = StyleChooser((15, 19))
+
+        sc.newObject()
+        sc.addCondition(parseCondition("tunnel"))
+
+        sc.addStyles([{
+            "some-width": "1.5",
+            "other-offset": "4"
+        }])
+        object_tags = {"highway": "service", "tunnel": "yes"}
+
+        # Apply new style to predefined styles with filter by class
+        new_styles = sc.updateStyles(styles, object_tags, 1.0, 1.0, False)
+
+        # Check that new style with new `object-id` is added.
+        # This style is built from `styles[0]` and styles from `sc`
+        expected_new_style = {
+            "some-width": 1.5,
+            "corner-radius": 2.5,
+            "other-offset": 4.0,
+            "object-id": "::default"  # New class, never listed in `styles`
+        }
+
+        self.assertEqual(len(new_styles), 3)
+        self.assertEqual(new_styles[-1], expected_new_style)
+
+
+    def test_runtime_conditions(self):
+        # TODO: Create test with  sc.addRuntimeCondition(Condition(condType, ('extra_tag', cond)))
+        pass
+
+if __name__ == '__main__':
+    unittest.main()