Merge commit

Thu, 26 Aug 2021 19:36:44 +0300

author
Teemu Piippo <teemu@hecknology.net>
date
Thu, 26 Aug 2021 19:36:44 +0300
changeset 147
bec55b021ae7
parent 146
3555679d276b (current diff)
parent 145
fde18c4d6784 (diff)
child 148
8f621aa4cfd7

Merge commit

colours.py file | annotate | diff | comparison | revisions
ldcheck.py file | annotate | diff | comparison | revisions
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgtags	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,1 @@
+8c2ca8d368d4c17c3d46f0ee3c5db562cf578bb5 1.0
--- a/LICENSE	Thu Aug 26 19:16:25 2021 +0300
+++ b/LICENSE	Thu Aug 26 19:36:44 2021 +0300
@@ -1,4 +1,4 @@
-Copyright (c) 2019, Teemu Piippo
+Copyright (c) 2019 - 2020, Teemu Piippo
 All rights reserved.
 
 Redistribution and use in source and binary forms, with or without
--- a/colours.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/colours.py	Thu Aug 26 19:36:44 2021 +0300
@@ -19,26 +19,6 @@
     @property
     def is_direct_colour(self):
         return self.index >= 0x2000000
-    @property
-    def is_ldconfig_colour(self):
-        return self.index in ldconfig_colour_data
-    @property
-    def name(self):
-        if self.is_ldconfig_colour:
-            return ldconfig_colour_data[self.index]['name']
-        else:
-            return str(self)
-    @property
-    def face_colour(self):
-        if self.is_ldconfig_colour:
-            return ldconfig_colour_data[self.index]['value']
-        elif self.is_direct_colour:
-            return '#%06X' % (self.index & 0xffffff)
-        else:
-            return '#000000'
-    @property
-    def is_valid(self):
-        return self.is_ldconfig_colour or self.is_direct_colour
     def __eq__(self, other):
         return self.index == other.index
     def __lt__(self, other):
@@ -95,12 +75,9 @@
             colour = parse_ldconfig_ldr_line(line)
             yield (colour['code'], colour)
 
-# LDConfig lookup table
-ldconfig_colour_data = {}
-
 def load_colours(ldconfig_ldr):
     '''
         Loads colours. Expects a file pointer to LDConfig.ldr as the parameter.
+        Returns a lookup table
     '''
-    global ldconfig_colour_data
-    ldconfig_colour_data = dict(parse_ldconfig_ldr(ldconfig_ldr))
+    return dict(parse_ldconfig_ldr(ldconfig_ldr))
--- a/filecache.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/filecache.py	Thu Aug 26 19:36:44 2021 +0300
@@ -40,19 +40,12 @@
             self.problem = None
             self.has_studs = None # Whether or not it contains studs
             self.vertices = set()
-    def __init__(self, ldraw_directories):
+    def __init__(self, context):
         '''
             Initializes a new subfile cache
         '''
         self.cache = dict()
-        if ldraw_directories and isinstance(ldraw_directories[0], str):
-            self.ldraw_directories = [
-                Path(os.path.expanduser(directory))
-                for directory in ldraw_directories
-            ]
-        else:
-            from copy import copy
-            self.ldraw_directories = copy(ldraw_directories)
+        self.context = context
         self.reference_stack = []
     def flatness_of(self, filename):
         '''
@@ -69,7 +62,7 @@
     def find_file(self, filename):
         return find_ldraw_file(
             filename = filename,
-            libraries = self.ldraw_directories,
+            libraries = self.context.libraries,
         )
     def prepare_file(self, filename):
         '''
@@ -90,10 +83,7 @@
         try:
             path = self.find_file(filename)
             with path.open('rb') as file:
-                model = parse.read_ldraw(
-                    file,
-                    ldraw_directories = self.ldraw_directories,
-                )
+                model = parse.read_ldraw(file, context = self.context)
         except (FileNotFoundError, IOError, PermissionError) as error:
             subfile.valid = False
             subfile.problem = str(error)
@@ -129,6 +119,3 @@
                     for subfile_reference in model.subfile_references
                 )
         self.reference_stack.pop()
-
-if __name__ == '__main__':
-    cache = SubfileCache(ldraw_directories = ["~/ldraw"])
--- a/geometry.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/geometry.py	Thu Aug 26 19:36:44 2021 +0300
@@ -195,20 +195,16 @@
     def perimeter_lines(self):
         for v1, v2 in pairs(self.vertices):
             yield LineSegment(v1, v2)
-    @property
-    def angles(self):
-        from math import acos, isclose
+    def angle_cosines(self):
+        from math import isclose
         for v1, v2, v3 in pairs(self.vertices, count = 3):
             vec1 = (position_vector(v3) - position_vector(v2)).normalized()
             vec2 = (position_vector(v1) - position_vector(v2)).normalized()
             if not isclose(vec1.length(), 0) and not isclose(vec2.length(), 0):
                 cosine = dot_product(vec1, vec2) / vec1.length() / vec2.length()
-                yield acos(cosine)
-    @property
-    def smallest_angle(self):
-        return min(
-            angle_magnitude_key(angle)
-            for angle in self.angles)
+                yield cosine
+            else:
+                yield 1 # cos(0)
     @property
     def hairline_ratio(self):
         lengths = [line.length for line in self.perimeter_lines]
--- a/header.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/header.py	Thu Aug 26 19:36:44 2021 +0300
@@ -3,6 +3,10 @@
 import datetime
 
 class Header:
+    '''
+        Result type of header processing, this contains all the header
+        information.
+    '''
     def __init__(self):
         self.description = None
         self.name = None
@@ -17,18 +21,27 @@
         self.keywords = ''
         self.cmdline = None
         self.history = []
-        self.first_occurrence = dict()
+        from collections import defaultdict
+        self.occurrences = defaultdict(list)
     @property
     def valid(self):
         return True
     @property
     def effective_filetype(self):
+        '''
+            What's the effective file type? The "Unofficial_" prefix is
+            left out.
+        '''
         if self.filetype.startswith('Unofficial_'):
             return self.filetype.rsplit('Unofficial_')[1]
         else:
             return self.filetype
     @property
     def effective_category(self):
+        '''
+            Returns the category of the part. Leading punctuation marks
+            are ignored.
+        '''
         if self.category:
             return self.category
         else:
@@ -39,6 +52,11 @@
             return category
 
 class BadHeader:
+    '''
+        If header processing fails this object is returned as the resulting
+        header instead. It contains the details of where the header could not
+        be understood and why.
+    '''
     def __init__(self, index, reason):
         self.index = index
         self.reason = reason
@@ -57,9 +75,14 @@
         and entry.text == "BFC INVERTNEXT"
 
 def is_suitable_header_object(entry):
+    '''
+        Is the given object something that we can consider to be
+        part of the header?
+    '''
     if is_invertnext(entry):
-        # BFC INVERTNEXT is not a header command anymore.
+        # It's BFC INVERTNEXT, that's not a header command.
         return False
+    # Check if it's one of the functional linetypes
     return not any(
         isinstance(entry, linetype)
         for linetype in [
@@ -74,6 +97,9 @@
     )
 
 class HeaderError(Exception):
+    '''
+        An error raised during header parsing
+    '''
     def __init__(self, index, reason):
         self.index, self.reason = index, reason
     def __repr__(self):
@@ -86,6 +112,9 @@
         return reason
 
 class HistoryEntry:
+    '''
+        Represents a single !HISTORY entry
+    '''
     def __init__(self, date, user, text):
         self.date, self.user, self.text = date, user, text
     def __repr__(self):
@@ -111,9 +140,14 @@
         self.skip_to_next()
         result.name = self.parse_pattern(r'^Name: (.+)$', 'name')[0]
         self.skip_to_next()
-        result.author, result.username = self.parse_pattern(r'^Author: (?:([^ \[]*[^\[]+) )?(?:\[([^\]]+)\])?', 'author')
+        # Parse author line
+        result.author, result.username = self.parse_pattern(r'^Author: (?:([^\[]+))?(?:\[([^\]]+)\])?', 'author')
+        if isinstance(result.author, str):
+            # clean leading spaces
+            result.author = str.strip(result.author)
         if not result.author and not result.username:
             self.parse_error('author line does not contain a name nor username')
+        # use more patterns to parse the rest of the header
         for header_entry in self.get_more_header_stuff():
             if self.try_to_match(
                 r'^!LDRAW_ORG ' \
@@ -179,24 +213,33 @@
             self.parse_error('LDRAW_ORG line is missing')
         return {
             'header': result,
-            'end-index': self.cursor + 1,
+            'end-index': self.cursor + 1, # record where the header ended
         }
     def parse_error(self, message):
         raise HeaderError(index = self.cursor, reason = message)
     def get_more_header_stuff(self):
+        '''
+            Iterates through the header and yields metacommand entries
+            one after the other.
+        '''
         self.cursor += 1
         new_cursor = self.cursor
         while new_cursor < len(self.model_body):
             entry = self.model_body[new_cursor]
             if not is_suitable_header_object(entry):
+                # looks like the header ended
                 break
             if isinstance(entry, linetypes.MetaCommand):
                 self.cursor = new_cursor
                 yield entry
             new_cursor += 1
     def skip_to_next(self, *, spaces_expected = 0):
+        '''
+            Skip to the next header line.
+        '''
         while True:
             if self.cursor + 1 >= len(self.model_body):
+                # wound up past the end of model
                 self.parse_error('file does not have a proper header')
             self.cursor += 1
             entry = self.model_body[self.cursor]
@@ -205,21 +248,32 @@
             if isinstance(entry, linetypes.MetaCommand):
                 return
     def try_to_match(self, pattern, patterntype):
+        '''
+            Tries to parse the specified pattern and to store the groups in
+            self.groups. Returns whether or not this succeeded.
+        '''
         try:
             self.groups = self.parse_pattern(pattern, patterntype)
             return True
         except:
             return False
     def current(self):
+        '''
+            Returns the text of the header line we're currently processing.
+        '''
         entry = self.model_body[self.cursor]
         assert isinstance(entry, linetypes.MetaCommand)
         return entry.text
     def parse_pattern(self, pattern, description):
+        '''
+            Matches the current header line against the specified pattern.
+            If not, raises an exception. See try_to_match for a softer wrapper
+            that does not raise exceptions.
+        '''
         match = re.search(pattern, self.current())
         if match:
             self.order.append(description)
-            if description not in self.result.first_occurrence:
-                self.result.first_occurrence[description] = self.cursor
+            list.append(self.result.occurrences[description], self.cursor)
             return match.groups()
         else:
             self.parse_error(str.format("couldn't parse {}", description))
--- a/ldcheck.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/ldcheck.py	Thu Aug 26 19:36:44 2021 +0300
@@ -1,14 +1,29 @@
 #!/usr/bin/env python3
+<<<<<<< /home/teemu/dev/ldcheck/ldcheck.py
 import sys
 if sys.version_info < (3, 4):
+=======
+import argparse
+from sys import version_info
+if version_info < (3, 4):
+>>>>>>> /tmp/ldcheck~other.ou_xbg_k.py
     raise RuntimeError('Python 3.4 or newer required')
+<<<<<<< /home/teemu/dev/ldcheck/ldcheck.py
 from colours import load_colours
+=======
+
+appname = 'ldcheck'
+version = (1, 0, 9999)
+version_string = str.join('.', map(str, version))
+
+>>>>>>> /tmp/ldcheck~other.ou_xbg_k.py
 from geometry import *
 from pathlib import Path
 import linetypes
 import header
 import parse
 
+<<<<<<< /home/teemu/dev/ldcheck/ldcheck.py
 def check_library_paths(library_paths):
     for library_path in library_paths:
         if not library_path.exists():
@@ -37,7 +52,104 @@
     for ldconfig_ldr_path in ldconfig_ldr_paths:
         with ldconfig_ldr_path.open() as ldconfig_ldr:
             load_colours(ldconfig_ldr)
+=======
+from os.path import realpath
+script_directory = Path(realpath(__file__)).parent
 
+def config_dirs():
+    import appdirs
+    appauthor = 'Teemu Piippo'
+    dirs = appdirs.AppDirs(appname, appauthor)
+    return {
+        'user': Path(dirs.user_config_dir),
+        'system': Path(dirs.site_config_dir),
+    }
+
+def ldraw_dirs_from_config():
+    libraries = []
+    dirs = config_dirs()
+    for dirpath in [dirs['system'], dirs['user']]:
+        filename = dirpath / 'ldcheck.cfg'
+        from configobj import ConfigObj
+        config = ConfigObj(str(filename), encoding = 'UTF8')
+        if 'libraries' in config:
+            libraries = expand_paths(config['libraries'])
+    return libraries
+
+def expand_paths(paths):
+    return [
+        Path(library).expanduser()
+        for library in paths
+    ]
+
+class LDrawContext:
+    '''
+        Contains context-dependant LDraw information, like library directory
+        paths and the colour table.
+    '''
+    def __init__(self, libraries = None):
+        self._libraries = libraries
+        if not self._libraries:
+            self._libraries = ldraw_dirs_from_config()
+        self.ldconfig_colour_data = self.load_ldconfig_ldr()
+        self.check_library_paths()
+    @property
+    def libraries(self):
+        return self._libraries
+    @property
+    def colours(self):
+        return self.ldconfig_colour_data
+    def ldconfig_ldr_discovery(self):
+        for library_path in self.libraries:
+            yield from [
+                library_path / path
+                for path in ['LDConfig.ldr', 'ldconfig.ldr']
+                if (library_path / path).is_file()
+            ]
+    def load_ldconfig_ldr(self):
+        from colours import load_colours
+        for ldconfig_ldr_path in self.ldconfig_ldr_discovery():
+            with open(ldconfig_ldr_path) as ldconfig_ldr:
+                return load_colours(ldconfig_ldr)
+    def check_library_paths(self):
+        from sys import stderr
+        problems = False
+        have_paths = False
+        for library_path in self.libraries:
+            have_paths = True
+            if not library_path.exists():
+                problems = True
+                print(str.format(
+                    'Library path {} does not exist',
+                    library_path,
+                ), file = stderr)
+            elif not library_path.exists():
+                problems = True
+                print(str.format(
+                    'Library path {} is not a directory',
+                    library_path,
+                ), file = stderr)
+        if not have_paths:
+            raise RuntimeError('no LDraw library paths specified')
+    def is_ldconfig_colour(self, colour):
+        return colour.index in self.colours
+    def colour_name(self, colour):
+        if self.is_ldconfig_colour(colour):
+            return self.colours[self.index]['name']
+        else:
+            return str(colour)
+    def colour_face(self, colour):
+        if self.is_ldconfig_colour(colour):
+            return self.colours[self.index]['value']
+        elif colour.is_direct_colour:
+            return '#%06X' % (self.index & 0xffffff)
+        else:
+            return '#000000'
+    def is_valid_colour(self, colour):
+        return self.is_ldconfig_colour(colour) or colour.is_direct_colour
+>>>>>>> /tmp/ldcheck~other.ou_xbg_k.py
+
+<<<<<<< /home/teemu/dev/ldcheck/ldcheck.py
 def parse_commandline_arguments():
     import os
     rcpath = Path(os.path.expanduser('~/.config/ldcheckrc'))
@@ -60,6 +172,51 @@
                     message = warning_type.placeholder_message(),
                 ))
             sys.exit(0)
+=======
+class ListProblemsAction(argparse.Action):
+    def __init__(self, option_strings, dest, nargs = None, **kwargs):
+        super().__init__(option_strings, dest, nargs = 0, **kwargs)
+    def __call__(self, *args, **kwargs):
+        import testsuite
+        from sys import exit
+        from re import sub
+        test_suite = testsuite.load_tests()
+        for warning_type in testsuite.all_problem_types(test_suite):
+            print(str.format('{name}: {severity}: "{message}"',
+                name = warning_type.name,
+                severity = warning_type.severity,
+                message = warning_type.placeholder_message(),
+            ))
+        exit(0)
+
+def format_report(report, model, test_suite, *, use_colors = True):
+    from testsuite import problem_text
+    messages = []
+    for problem in report['problems']:
+        text_colour = ''
+        if use_colors:
+            if problem.severity == 'hold':
+                text_colour = colorama.Fore.LIGHTRED_EX
+            elif problem.severity == 'warning':
+                text_colour = colorama.Fore.LIGHTBLUE_EX
+        ldraw_code = model.body[problem.body_index].textual_representation()
+        message = str.format(
+            '{text_colour}{model_name}:{line_number}: {problem_type}: {message}'
+            '{colour_reset}\n\t{ldraw_code}',
+            text_colour = text_colour,
+            model_name = model.name,
+            line_number = problem.line_number,
+            problem_type = problem.severity,
+            message = problem_text(problem, test_suite),
+            colour_reset = use_colors and colorama.Fore.RESET or '',
+            ldraw_code = ldraw_code,
+        )
+        messages.append(message)
+    return '\n'.join(messages)
+
+if __name__ == '__main__':
+    from sys import argv, stderr, exit
+>>>>>>> /tmp/ldcheck~other.ou_xbg_k.py
     parser = argparse.ArgumentParser()
     parser.add_argument('filename')
     parser.add_argument('--list',
@@ -79,6 +236,7 @@
         action = 'store_true',
         help = 'finds a subfile by name and prints out information about it'
     )
+<<<<<<< /home/teemu/dev/ldcheck/ldcheck.py
     parser.add_argument('-l', '--library', action = 'append')
     arglist = rcargs + sys.argv[1:]
     return parser.parse_args(arglist)
@@ -97,12 +255,49 @@
     libraries = [Path(os.path.expanduser(library)) for library in args.library]
     check_library_paths(libraries)
     load_ldconfig(libraries)
+=======
+    parser.add_argument('--color',
+        action = 'store_true',
+        help = 'use colors'
+    )
+    parser.add_argument('-d', '--ldraw-dir',
+        nargs = '+',
+        help = 'specify LDraw directory path(s)',
+    )
+    parser.add_argument('-v', '--version',
+        action = 'version',
+        version = str.format('{appname} {version}',
+            appname = appname,
+            version = version_string,
+        ),
+    )
+    args = parser.parse_args()
+    libraries = ldraw_dirs_from_config()
+    if args.ldraw_dir:
+        libraries = expand_paths(args.ldraw_dir)
+    try:
+        context = LDrawContext(libraries)
+    except RuntimeError as error:
+        print('error:', str(error), file = stderr)
+        exit(1)
+    if args.color:
+        try:
+            import colorama
+            colorama.init()
+        except ImportError:
+            print('Use of --color requires the colorama module, disabling colours', file = stderr)
+            args.color = False
+>>>>>>> /tmp/ldcheck~other.ou_xbg_k.py
     if args.subfile:
         # Subfile debug mode: searches for the specified subfile from the LDraw
         # libraries, opens it as if it was referenced by something and prints
         # out all information that is calculated from this subfile.
         import filecache
+<<<<<<< /home/teemu/dev/ldcheck/ldcheck.py
         cache = filecache.SubfileCache(ldraw_directories = libraries)
+=======
+        cache = filecache.SubfileCache(context = context)
+>>>>>>> /tmp/ldcheck~other.ou_xbg_k.py
         subfile = cache.prepare_file(args.filename)
         if not subfile.valid:
             print(subfile.problem)
@@ -111,6 +306,7 @@
             print('Description:', repr(subfile.description))
             print('Contains studs:', repr(subfile.has_studs))
     else:
+<<<<<<< /home/teemu/dev/ldcheck/ldcheck.py
         with open(args.filename, 'rb') as file:
             from os.path import basename
             model = parse.read_ldraw(
@@ -154,3 +350,40 @@
         import sys
         print('error:', str(e), file = sys.stderr)
         sys.exit(1)
+=======
+        try:
+            with open(args.filename, 'rb') as file:
+                from os.path import basename
+                model = parse.read_ldraw(
+                    file,
+                    name = basename(args.filename),
+                    context = context)
+                if args.dump:
+                    print('header: ' + type(model.header).__name__)
+                    for key in sorted(dir(model.header)):
+                        if not key.startswith('__'):
+                            print('\t' + key + ': ' + repr(getattr(model.header, key)))
+                    for i, entry in enumerate(model.body):
+                        if model.header.valid and i == model.header_size:
+                            print('--------- End of header')
+                        print(entry)
+                elif args.rebuild:
+                    for entry in model.body:
+                        print(entry.textual_representation(), end = '\r\n')
+                else:
+                    from testsuite import load_tests, check_model
+                    test_suite = load_tests()
+                    report = check_model(model, test_suite)
+                    print(format_report(
+                        report,
+                        model,
+                        test_suite,
+                        use_colors = args.color
+                    ))
+        except FileNotFoundError:
+            print(str.format(
+                'no such file: {filename!r}',
+                filename = args.filename
+            ), file = stderr)
+            exit(1)
+>>>>>>> /tmp/ldcheck~other.ou_xbg_k.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/library-standards.ini	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,134 @@
+[scaling restrictions]
+axle.dat = y-scaling only
+axleend.dat = y-scaling only
+axl?hol?.dat = y-scaling only
+axlesphe.dat = no scaling
+bushloc?.dat = no scaling
+steerend.dat = no scaling
+connect.dat = no scaling
+connect?.dat = no scaling
+confric?.dat = no scaling
+connhol?.dat = no scaling
+beamhole.dat = no scaling
+peghole.dat = y-scaling only
+peghole?.dat = y-scaling only
+npeghol?.dat = y-scaling only
+tooth*.dat = no scaling
+daxle.dat = y-scaling only
+daxlehole.dat = y-scaling only
+daxlehub.dat = y-scaling only
+dtoothc.dat = no scaling
+stug?*.dat = no scaling
+arm?.dat = no scaling
+clh?*.dat = no scaling
+4-4crh?.dat = no scaling
+clip?.dat = no scaling
+clip??.dat = no scaling
+finger1.dat = no scaling
+h[12].dat = no scaling
+plug34.dat = no scaling
+wpin*.dat = no scaling
+bump5000.dat = no scaling
+slotm.dat = no scaling
+handle*.dat = no scaling
+rail12v.dat = no scaling
+primotop.dat = no scaling
+primobot.dat = no scaling
+zstud.dat = no scaling
+# no scaling on studs
+stud*.dat = no scaling
+# allow y-scaling only on stud4 for historical reasons
+stud4.dat = y-scaling only
+# allow -1 y-scaling only on some stud primitives for historical reasons
+stud3.dat = stud3-like scaling
+stud4s.dat = stud3-like scaling
+stud4s2.dat = stud3-like scaling
+stud16.dat = stud3-like scaling
+stud21a.dat = stud3-like scaling
+stud22a.dat = stud3-like scaling
+
+[categories]
+Animal =
+Antenna =
+Arch =
+Arm =
+Bar =
+Baseplate =
+Belville =
+Boat =
+Bracket =
+Brick =
+Car =
+Clikits =
+Cockpit =
+Cone =
+Constraction =
+Constraction Accessory =
+Container =
+Conveyor =
+Crane =
+Cylinder =
+Dish =
+Door =
+Electric =
+Exhaust =
+Fence =
+Figure =
+Figure Accessory =
+Flag =
+Forklift =
+Freestyle =
+Garage =
+Glass =
+Grab =
+Hinge =
+Homemaker =
+Hose =
+Ladder =
+Lever =
+Magnet =
+Minifig =
+Minifig Accessory =
+Minifig Footwear =
+Minifig Headwear =
+Minifig Hipwear =
+Minifig Neckwear =
+Monorail =
+Obsolete =
+Panel =
+Plane =
+Plant =
+Plate =
+Platform =
+Propellor =
+Rack =
+Roadsign =
+Rock =
+Scala =
+Screw =
+Sheet Cardboard =
+Sheet Fabric =
+Sheet Plastic =
+Slope =
+Sphere =
+Staircase =
+Sticker =
+Support =
+Tail =
+Tap =
+Technic =
+Tile =
+Tipper =
+Tractor =
+Trailer =
+Train =
+Turntable =
+Tyre =
+Vehicle =
+Wedge =
+Wheel =
+Winch =
+Window =
+Windscreen =
+Wing =
+Znap =
--- a/librarystandards.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/librarystandards.py	Thu Aug 26 19:36:44 2021 +0300
@@ -2,7 +2,7 @@
 from pathlib import Path
 from os.path import dirname
 
-ini_path = Path(dirname(__file__)) / 'tests' / 'library-standards.ini'
+ini_path = Path(dirname(__file__)) / 'library-standards.ini'
 library_standards = ConfigParser()
 
 with ini_path.open() as file:
--- a/parse.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/parse.py	Thu Aug 26 19:36:44 2021 +0300
@@ -9,13 +9,13 @@
 
 class Model:
     def __init__(
-        self, header, body, *, ldraw_directories, \
+        self, header, body, *, context, \
         header_size = 0, line_ending_errors = None
     ):
         self.header = header
         self.body = body
         self.header_size = header_size
-        self.ldraw_directories = ldraw_directories
+        self.context = context # contains stuff like library paths
         self.line_ending_errors = line_ending_errors
     def filter_by_type(self, type):
         yield from [
@@ -39,7 +39,15 @@
     def has_header(self):
         return self.header and not isinstance(self.header, header.BadHeader)
     def find_first_header_object(self, object_type):
-        return self.body[self.header.first_occurrence[object_type]]
+        return self.find_header_object(object_type, 0)
+    def find_header_object(self, object_type, n):
+        try:
+            return self.body[self.header.occurrences[object_type][n]]
+        except IndexError:
+            raise KeyError(str.format(
+                '{type} not found in header',
+                type = object_type
+            ))
 
 def model_vertices(
     model,
@@ -50,7 +58,7 @@
         transformation_matrix = complete_matrix(Matrix3x3(), Vertex(0, 0, 0))
     if file_cache is None:
         import filecache
-        file_cache = filecache.SubfileCache(model.ldraw_directories)
+        file_cache = filecache.SubfileCache(ldraw_directories = model.context.libraries)
     for element in model.body:
         if isinstance(element, linetypes.BasePolygon):
             for point in element.geometry.vertices:
@@ -65,7 +73,7 @@
                 point @= matrix_4x4
                 yield point @ transformation_matrix
 
-def read_ldraw(file, *, ldraw_directories, name = ''):
+def read_ldraw(file, *, context, name = ''):
     line_ending_errors = {
         'count': 0,
         'first-at': None,
@@ -92,7 +100,7 @@
         header = header_object,
         body = model_body,
         header_size = end,
-        ldraw_directories = ldraw_directories,
+        context = context,
         line_ending_errors = line_ending_errors,
     )
     model.name = name
--- a/templates/webfront.html	Thu Aug 26 19:16:25 2021 +0300
+++ b/templates/webfront.html	Thu Aug 26 19:36:44 2021 +0300
@@ -7,7 +7,7 @@
 </head>
 <body>
 <div class="top-form">
-    <h1>Check your part here</h1>
+    <h1>{{appname}} {{version}} - Check your part here</h1>
     <form method="post" enctype="multipart/form-data">
         <input type="file" name="file"/>
         <input type="submit" />
@@ -15,6 +15,10 @@
 </div>
 <div class="report-container">
 
+{% if is_debug %}
+<p>Note: this is an unreleased version, everything may not work as intended.</p>
+{% endif %}
+
 {% if report %}
     <h1>{{name}}</h1>
     {% if report['problems'] %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/headertest.py	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,151 @@
+from testsuite import problem_type, report_problem
+import linetypes
+
+@problem_type('bad-header',
+    severity = 'hold',
+    message = lambda reason: str.format('bad header: {}', reason),
+)
+@problem_type('no-license-set',
+    severity = 'hold',
+    message = 'no license set',
+)
+@problem_type('non-ca-license',
+    severity = 'hold',
+    message = 'no new non-CA-submits are accepted',
+)
+def bad_header(model):
+    import header
+    ca_license = 'Redistributable under CCAL version 2.0 : see CAreadme.txt'
+    if isinstance(model.header, header.BadHeader):
+        yield report_problem(
+            'bad-header',
+            bad_object = model.body[model.header.index],
+            reason = model.header.reason,
+        )
+    elif not model.header.license:
+        yield report_problem(
+            'no-license-set',
+            bad_object = model.body[0],
+        )
+    elif model.header.license != ca_license:
+        yield report_problem(
+            'non-ca-license',
+            bad_object = model.find_first_header_object('license'),
+        )
+
+@problem_type('bfc-nocertify',
+    severity = 'hold',
+    message = 'all new parts must be BFC certified',
+)
+def nocertify_test(model):
+    import header
+    if model.header.valid and model.header.bfc == 'NOCERTIFY':
+        yield report_problem(
+            'bfc-nocertify',
+            bad_object = model.find_first_header_object('bfc'),
+        )
+
+@problem_type('physical-colour-part',
+    severity = 'hold',
+    message = 'no new physical colour parts are accepted',
+)
+def physical_colours_test(model):
+    if model.header.valid and 'Physical_Colour' in model.header.qualifiers:
+        yield report_problem(
+            'physical-colour-part',
+            bad_object = model.find_first_header_object('part type'),
+        )
+
+@problem_type('unofficial-part',
+    severity = 'hold',
+    message = 'new parts must be unofficial',
+)
+def unofficiality_test(model):
+    if model.header.valid and not model.header.filetype.startswith('Unofficial_'):
+        yield report_problem(
+            'unofficial-part',
+            bad_object = model.find_first_header_object('part type')
+        )
+
+@problem_type('primitive-non-ccw',
+    severity = 'hold',
+    message = 'primitives must have CCW winding',
+)
+@problem_type('no-bfc-line',
+    severity = 'hold',
+    message = 'BFC declaration is missing',
+)
+def header_bfc_test(model):
+    if model.header.valid and not model.header.bfc:
+        yield report_problem(
+            'no-bfc-line',
+            bad_object = model.body[0],
+        )
+    elif model.header.valid \
+    and model.header.filetype.endswith('Primitive') \
+    and model.header.bfc != 'CERTIFY CCW':
+        yield report_problem(
+            'primitive-non-ccw',
+            bad_object = model.find_first_header_object('bfc'),
+        )
+
+@problem_type('keywords-for-nonparts',
+    severity = 'warning',
+    message = lambda type: str.format(
+        'keywords are not allowed for {type} files',
+        type = type,
+    ),
+)
+def keywords_tests(model):
+    if model.header.valid:
+        if model.header.keywords \
+        and model.header.effective_filetype != 'Part':
+            yield report_problem(
+                'keywords-for-nonparts',
+                bad_object = model.find_first_header_object('keywords'),
+                type = model.header.effective_filetype,
+            )
+
+@problem_type('bad-colour-24-nonline',
+    severity = 'hold',
+    message = 'colour 24 used on non-lines',
+)
+@problem_type('bad-colour-24-line',
+    severity = 'hold',
+    message = 'line with colour other than 24',
+)
+def colour_24_test(model):
+    for element in model.body:
+        if hasattr(element, 'colour'):
+            is_line = isinstance(element, linetypes.LineSegment)
+            if not is_line and element.colour.index == 24:
+                yield report_problem('bad-colour-24-nonline', bad_object = element)
+            if is_line and element.colour.index != 24:
+                yield report_problem('bad-colour-24-line', bad_object = element)
+
+@problem_type('moved-to-with-extension',
+    severity = 'hold',
+    message = 'moved-to files must not contain the '
+        '".dat"-extension in the description',
+)
+def moved_to_with_extension_test(model):
+    if model.header.valid \
+    and model.header.description.startswith('~Moved to') \
+    and '.dat' in model.header.description:
+        yield report_problem(
+            'moved-to-with-extension',
+            bad_object = model.body[0],
+        )
+
+manifest = {
+    'tests': [
+        bad_header,
+        nocertify_test,
+        physical_colours_test,
+        unofficiality_test,
+        header_bfc_test,
+        keywords_tests,
+        colour_24_test,
+        moved_to_with_extension_test,
+    ],
+}
--- a/tests/library-standards.ini	Thu Aug 26 19:16:25 2021 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,134 +0,0 @@
-[scaling restrictions]
-axle.dat = y-scaling only
-axleend.dat = y-scaling only
-axl?hol?.dat = y-scaling only
-axlesphe.dat = no scaling
-bushloc?.dat = no scaling
-steerend.dat = no scaling
-connect.dat = no scaling
-connect?.dat = no scaling
-confric?.dat = no scaling
-connhol?.dat = no scaling
-beamhole.dat = no scaling
-peghole.dat = y-scaling only
-peghole?.dat = y-scaling only
-npeghol?.dat = y-scaling only
-tooth*.dat = no scaling
-daxle.dat = y-scaling only
-daxlehole.dat = y-scaling only
-daxlehub.dat = y-scaling only
-dtoothc.dat = no scaling
-stug?*.dat = no scaling
-arm?.dat = no scaling
-clh?*.dat = no scaling
-4-4crh?.dat = no scaling
-clip?.dat = no scaling
-clip??.dat = no scaling
-finger1.dat = no scaling
-h[12].dat = no scaling
-plug34.dat = no scaling
-wpin*.dat = no scaling
-bump5000.dat = no scaling
-slotm.dat = no scaling
-handle*.dat = no scaling
-rail12v.dat = no scaling
-primotop.dat = no scaling
-primobot.dat = no scaling
-zstud.dat = no scaling
-# no scaling on studs
-stud*.dat = no scaling
-# allow y-scaling only on stud4 for historical reasons
-stud4.dat = y-scaling only
-# allow -1 y-scaling only on some stud primitives for historical reasons
-stud3.dat = stud3-like scaling
-stud4s.dat = stud3-like scaling
-stud4s2.dat = stud3-like scaling
-stud16.dat = stud3-like scaling
-stud21a.dat = stud3-like scaling
-stud22a.dat = stud3-like scaling
-
-[categories]
-Animal =
-Antenna =
-Arch =
-Arm =
-Bar =
-Baseplate =
-Belville =
-Boat =
-Bracket =
-Brick =
-Car =
-Clikits =
-Cockpit =
-Cone =
-Constraction =
-Constraction Accessory =
-Container =
-Conveyor =
-Crane =
-Cylinder =
-Dish =
-Door =
-Electric =
-Exhaust =
-Fence =
-Figure =
-Figure Accessory =
-Flag =
-Forklift =
-Freestyle =
-Garage =
-Glass =
-Grab =
-Hinge =
-Homemaker =
-Hose =
-Ladder =
-Lever =
-Magnet =
-Minifig =
-Minifig Accessory =
-Minifig Footwear =
-Minifig Headwear =
-Minifig Hipwear =
-Minifig Neckwear =
-Monorail =
-Obsolete =
-Panel =
-Plane =
-Plant =
-Plate =
-Platform =
-Propellor =
-Rack =
-Roadsign =
-Rock =
-Scala =
-Screw =
-Sheet Cardboard =
-Sheet Fabric =
-Sheet Plastic =
-Slope =
-Sphere =
-Staircase =
-Sticker =
-Support =
-Tail =
-Tap =
-Technic =
-Tile =
-Tipper =
-Tractor =
-Trailer =
-Train =
-Turntable =
-Tyre =
-Vehicle =
-Wedge =
-Wheel =
-Winch =
-Window =
-Windscreen =
-Wing =
-Znap =
--- a/tests/misc.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/tests/misc.py	Thu Aug 26 19:36:44 2021 +0300
@@ -14,7 +14,7 @@
     from collections import defaultdict
     bad_colours = defaultdict(lambda: {'count': 0, 'first-occurrence': None})
     for element in model.body:
-        if hasattr(element, 'colour') and not element.colour.is_valid:
+        if hasattr(element, 'colour') and not model.context.is_valid_colour(element.colour):
             bad_colours[element.colour.index]['count'] += 1
             if not bad_colours[element.colour.index]['first-occurrence']:
                 bad_colours[element.colour.index]['first-occurrence'] = element
@@ -42,167 +42,6 @@
         if isinstance(element, linetypes.Error)
     )
 
-@problem_type('bad-header',
-    severity = 'hold',
-    message = lambda reason: str.format('bad header: {}', reason),
-)
-@problem_type('no-license-set',
-    severity = 'hold',
-    message = 'no license set',
-)
-@problem_type('non-ca-license',
-    severity = 'hold',
-    message = 'no new non-CA-submits are accepted',
-)
-def bad_header(model):
-    import header
-    ca_license = 'Redistributable under CCAL version 2.0 : see CAreadme.txt'
-    if isinstance(model.header, header.BadHeader):
-        yield report_problem(
-            'bad-header',
-            bad_object = model.body[model.header.index],
-            reason = model.header.reason,
-        )
-    elif not model.header.license:
-        yield report_problem(
-            'no-license-set',
-            bad_object = model.body[0],
-        )
-    elif model.header.license != ca_license:
-        yield report_problem(
-            'non-ca-license',
-            bad_object = model.find_first_header_object('license'),
-        )
-
-@problem_type('bfc-nocertify',
-    severity = 'hold',
-    message = 'all new parts must be BFC certified',
-)
-def nocertify_test(model):
-    import header
-    if model.header.valid and model.header.bfc == 'NOCERTIFY':
-        yield report_problem(
-            'bfc-nocertify',
-            bad_object = model.find_first_header_object('bfc'),
-        )
-
-@problem_type('physical-colour-part',
-    severity = 'hold',
-    message = 'no new physical colour parts are accepted',
-)
-def physical_colours_test(model):
-    if model.header.valid and 'Physical_Colour' in model.header.qualifiers:
-        yield report_problem(
-            'physical-colour-part',
-            bad_object = model.find_first_header_object('part type'),
-        )
-
-@problem_type('unofficial-part',
-    severity = 'hold',
-    message = 'new parts must be unofficial',
-)
-def unofficiality_test(model):
-    if model.header.valid and not model.header.filetype.startswith('Unofficial_'):
-        yield report_problem(
-            'unofficial-part',
-            bad_object = model.find_first_header_object('part type')
-        )
-
-@problem_type('primitive-ccw',
-    severity = 'hold',
-    message = 'primitives must have CCW winding',
-)
-@problem_type('no-bfc-line',
-    severity = 'hold',
-    message = 'BFC declaration is missing',
-)
-def header_bfc_test(model):
-    if model.header.valid and not model.header.bfc:
-        yield report_problem(
-            'no-bfc-line',
-            bad_object = model.body[0],
-        )
-    elif model.header.valid \
-    and model.header.filetype.endswith('Primitive') \
-    and model.header.bfc != 'CERTIFY CCW':
-        yield report_problem(
-            'primitive-bfc-ccw',
-            bad_object = model.find_first_header_object('bfc'),
-        )
-
-@problem_type('keywords-for-nonparts',
-    severity = 'warning',
-    message = lambda type: str.format(
-        'keywords are not allowed for {type} files',
-        type = type,
-    ),
-)
-def keywords_tests(model):
-    if model.header.valid:
-        if model.header.keywords \
-        and model.header.effective_filetype != 'Part':
-            yield report_problem(
-                'keywords-for-nonparts',
-                bad_object = model.find_first_header_object('keywords'),
-                type = model.header.effective_filetype,
-            )
-
-@problem_type('bad-colour-24-nonline',
-    severity = 'hold',
-    message = 'colour 24 used on non-lines',
-)
-@problem_type('bad-colour-24-line',
-    severity = 'hold',
-    message = 'line with colour other than 24',
-)
-def colour_24_test(model):
-    for element in model.body:
-        if hasattr(element, 'colour'):
-            is_line = isinstance(element, linetypes.LineSegment)
-            if not is_line and element.colour.index == 24:
-                yield report_problem('bad-colour-24-nonline', bad_object = element)
-            if is_line and element.colour.index != 24:
-                yield report_problem('bad-colour-24-line', bad_object = element)
-
-@problem_type('moved-to-with-extension',
-    severity = 'hold',
-    message = 'moved-to files must not contain the '
-        '".dat"-extension in the description',
-)
-def moved_to_with_extension_test(model):
-    if model.header.valid \
-    and model.header.description.startswith('~Moved to') \
-    and '.dat' in model.header.description:
-        yield report_problem(
-            'moved-to-with-extension',
-            bad_object = model.body[0],
-        )
-
-@problem_type('bfc-invertnext-not-on-subfile',
-    severity = 'hold',
-    message = '"BFC INVERTNEXT" not followed by a type-1 line',
-)
-def bfc_invertnext_not_on_subfile_test(model):
-    def get_invertnexts(model):
-        yield from [
-            (index, element)
-            for index, element in enumerate(model.body)
-            if isinstance(element, linetypes.MetaCommand) \
-                and element.text == 'BFC INVERTNEXT'
-        ]
-    def has_subfile_after_invertnext(index):
-        index_subfile = index + 1 # subfile reference should be on the next line
-        if index_subfile >= len(model.body):
-            return False # past the end...
-        else:
-            element = model.body[index_subfile]
-            return isinstance(element, linetypes.SubfileReference)
-    for index, invertnext in get_invertnexts(model):
-        if not has_subfile_after_invertnext(index):
-            yield report_problem('bfc-invertnext-not-on-subfile',
-                bad_object = model.body[index],
-            )
-
 @problem_type('unknown-metacommand',
     severity = 'hold',
     message = lambda command_text: str.format(
@@ -246,18 +85,35 @@
             count = model.line_ending_errors['count'],
         )
 
+@problem_type('bfc-invertnext-not-on-subfile',
+    severity = 'hold',
+    message = '"BFC INVERTNEXT" not followed by a type-1 line',
+)
+def bfc_invertnext_not_on_subfile_test(model):
+    def get_invertnexts(model):
+        yield from [
+            (index, element)
+            for index, element in enumerate(model.body)
+            if isinstance(element, linetypes.MetaCommand) \
+                and element.text == 'BFC INVERTNEXT'
+        ]
+    def has_subfile_after_invertnext(index):
+        index_subfile = index + 1 # subfile reference should be on the next line
+        if index_subfile >= len(model.body):
+            return False # past the end...
+        else:
+            element = model.body[index_subfile]
+            return isinstance(element, linetypes.SubfileReference)
+    for index, invertnext in get_invertnexts(model):
+        if not has_subfile_after_invertnext(index):
+            yield report_problem('bfc-invertnext-not-on-subfile',
+                bad_object = model.body[index],
+            )
+
 manifest = {
     'tests': [
         colours_test,
         syntax_errors,
-        bad_header,
-        nocertify_test,
-        physical_colours_test,
-        unofficiality_test,
-        header_bfc_test,
-        keywords_tests,
-        colour_24_test,
-        moved_to_with_extension_test,
         bfc_invertnext_not_on_subfile_test,
         metacommands_test,
         line_endings_test,
--- a/tests/quadrilaterals.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/tests/quadrilaterals.py	Thu Aug 26 19:36:44 2021 +0300
@@ -25,14 +25,14 @@
 @problem_type('skew-major',
     severity = 'hold',
     message = lambda skew_angle:
-        str.format('skew quadrilateral (plane angle {})',
+        str.format('skew (non-coplanar) quadrilateral (plane angle {})',
             degree_rep(skew_angle),
         ),
 )
 @problem_type('skew-minor',
     severity = 'warning',
     message = lambda skew_angle:
-        str.format('slightly skew quadrilateral (plane angle {})',
+        str.format('slightly skew (non-coplanar) quadrilateral (plane angle {})',
             degree_rep(skew_angle),
         ),
 )
@@ -60,7 +60,7 @@
 
 @problem_type('self-intersecting',
     severity = 'hold',
-    message = 'self-intersecting quadrilateral',
+    message = 'bowtie (self-intersecting) quadrilateral',
 )
 def bowtie_test(model):
     for quadrilateral in model.quadrilaterals:
@@ -77,31 +77,34 @@
                 )
                 break
 
-@problem_type('collinear', severity = 'hold', message = 'collinear polygon')
+@problem_type('collinear',
+    severity = 'hold',
+    message = lambda min_angle, max_angle: str.format(
+        'collinear polygon (smallest interior angle {min_angle}, largest {max_angle})',
+        min_angle = degree_rep(min_angle),
+        max_angle = degree_rep(max_angle),
+    ),
+)
 def collinear_test(model):
+    from math import radians, cos, acos
+    # according to the LDraw spec, all interior angles must be 
+    # between 0.025 degrees and 179.9 degrees. Note that between 0 and pi,
+    # cos(x) is a downwards curve, so the minimum cosine is for the maximum
+    # angle.
+    min_cosine = cos(radians(179.9))
+    max_cosine = cos(radians(0.025))
     for element in model.body:
         if hasattr(element, 'geometry') and len(element.geometry.vertices) >= 3:
-            for a, b, c in pairs(element.geometry.vertices, count = 3):
-                if cross_product(b - a, c - a).length() < 1e-5:
-                    yield report_problem('collinear', bad_object = element)
-                    break
-
-@problem_type('hairline-polygon',
-    severity = 'warning',
-    message = lambda smallest_angle: str.format(
-        'hairline polygon (smallest angle {})',
-        degree_rep(smallest_angle),
-    ),
-)
-def hairline_test(model):
-    for element in model.body:
-        if hasattr(element, 'geometry') and len(element.geometry.vertices) >= 3:
-            smallest_angle = element.geometry.smallest_angle
-            if smallest_angle < radians(0.5):
+            cosines = list(element.geometry.angle_cosines())
+            if any(
+                not(min_cosine < cosine < max_cosine)
+                for cosine in cosines
+            ):
                 yield report_problem(
-                    'hairline-polygon',
+                    'collinear',
                     bad_object = element,
-                    smallest_angle = smallest_angle,
+                    min_angle = min(map(acos, cosines)),
+                    max_angle = max(map(acos, cosines)),
                 )
 
 manifest = {
@@ -110,6 +113,5 @@
         concave_test,
         bowtie_test,
         collinear_test,
-        hairline_test,
     ],
 }
--- a/tests/subfiles.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/tests/subfiles.py	Thu Aug 26 19:36:44 2021 +0300
@@ -16,7 +16,7 @@
     yield from (
         report_problem('zero-determinant', bad_object = subfile_reference)
         for subfile_reference in model.subfile_references
-        if abs(subfile_reference.matrix.determinant() - 0) < 1e-15
+        if abs(subfile_reference.matrix.determinant() - 0) < 1e-5
     )
 
 def scaling_description(scaling, axes = 'xyz'):
@@ -115,7 +115,7 @@
         ),
 )
 @problem_type('bad-subfile',
-    severity = 'hold',
+    severity = 'warning',
     message = lambda path, problem_text:
         str.format('cannot process subfile "{path}": {problem_text}',
             **locals(),
@@ -162,7 +162,7 @@
         Checks whether flat subfiles are scaled in the flat direction.
     '''
     import filecache
-    cache = filecache.SubfileCache(model.ldraw_directories)
+    cache = filecache.SubfileCache(context = model.context)
     if model.header.valid:
         cache.reference_stack.append(model.header.name)
     failed_subfiles = set()
--- a/testsuite.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/testsuite.py	Thu Aug 26 19:36:44 2021 +0300
@@ -129,48 +129,6 @@
         message = message(**problem.args)
     return message
 
-def format_report_html(report, model, test_suite):
-    messages = []
-    for problem in report['problems']:
-        ldraw_code = model.body[problem.body_index].textual_representation()
-        message = str.format(
-            '<li class="{problem_type}">{model_name}:{line_number}:'
-            '{problem_type}: {message}<br />{ldraw_code}</li>',
-            model_name = model.name,
-            line_number = problem.line_number,
-            problem_type = problem.severity,
-            message = problem_text(problem, test_suite),
-            ldraw_code = ldraw_code,
-        )
-        messages.append(message)
-    return '\n'.join(messages)
-
-def format_report(report, model, test_suite):
-    import colorama
-    colorama.init()
-    messages = []
-    for problem in report['problems']:
-        if problem.severity == 'hold':
-            text_colour = colorama.Fore.LIGHTRED_EX
-        elif problem.severity == 'warning':
-            text_colour = colorama.Fore.LIGHTBLUE_EX
-        else:
-            text_colour = ''
-        ldraw_code = model.body[problem.body_index].textual_representation()
-        message = str.format(
-            '{text_colour}{model_name}:{line_number}: {problem_type}: {message}'
-            '{colour_reset}\n\t{ldraw_code}',
-            text_colour = text_colour,
-            model_name = model.name,
-            line_number = problem.line_number,
-            problem_type = problem.severity,
-            message = problem_text(problem, test_suite),
-            colour_reset = colorama.Fore.RESET,
-            ldraw_code = ldraw_code,
-        )
-        messages.append(message)
-    return '\n'.join(messages)
-
 def iterate_problems(test_suite):
     for test_function in test_suite['tests']:
         yield from test_function.ldcheck_problem_types.values()
@@ -180,7 +138,12 @@
         iterate_problems(test_suite),
         key = lambda problem_type: problem_type.name
     )
-    
+
+def all_problem_type_names(test_suite):
+    return set(
+        problem_type.name
+        for problem_type in iterate_problems(test_suite)
+    )
 
 if __name__ == '__main__':
     from pprint import pprint
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittest.py	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+from ldcheck import appname, version, version_string
+from ldcheck import script_directory
+from pathlib import Path
+from parse import read_ldraw
+from testsuite import load_tests, check_model, problem_text, all_problem_type_names
+
+def unit_test_discovery():
+    ''' Yields unit test paths '''
+    import os
+    unit_test_dir = Path(script_directory) / 'unittests'
+    for dirpath, dirnames, filenames in os.walk(unit_test_dir):
+        yield from sorted(
+            Path(dirpath) / filename
+            for filename in filenames
+            if filename.endswith('.test')
+        )
+
+def parse_expectation(text):
+    problem_type, line_no_str = str.split(text, ':')
+    return problem_type, int(line_no_str)
+
+def load_unit_test(unit_test_path, *, name, context):
+    with open(unit_test_path, 'rb') as device:
+        import re
+        problem_types = set()
+        expecting = None
+        while True:
+            pos = device.tell()
+            line = bytes.decode(device.readline())
+            if not line:
+                raise ValueError('unit test ended unexpectedly')
+            match = re.match('^0 Testing: (.+)', line)
+            if match:
+                set.update(problem_types, match.group(1).split())
+            elif str.strip(line) == '0 Expecting: none':
+                expecting = set()
+            else:
+                match = re.match('^0 Expecting: (.+)', line)
+                if match:
+                    if not expecting:
+                        expecting = set()
+                    set.update(expecting, map(parse_expectation, match.group(1).split()))
+                else:
+                    device.seek(pos)
+                    break
+        if not problem_types or expecting is None:
+            raise ValueError(str.format(
+                'Unit test {name} does not have a proper manifest',
+                name = name,
+            ))
+        return {
+            'problem_types': problem_types,
+            'expecting': expecting,
+            'model': read_ldraw(
+                device,
+                name = name,
+                context = context
+            ),
+        }
+
+def parse_problem(problem):
+    return problem.problem_class.name, problem.line_number
+
+def run_unit_test(unit_test_path, *, context, test_suite):
+    from os.path import basename
+    unit_test = load_unit_test(
+        unit_test_path,
+        name = basename(unit_test_path),
+        context = context,
+    )
+    bad_problems = set.difference(
+        unit_test['problem_types'],
+        all_problem_type_names(test_suite)
+    )
+    if bad_problems:
+        raise ValueError(str.format(
+            'unknown problem type: {names}',
+            names = ' '.join(sorted(bad_problems))
+        ))
+    problem_types = unit_test['problem_types']
+    report = check_model(unit_test['model'], test_suite)
+    expected_problems = unit_test['expecting']
+    problems = set(
+        filter(
+            lambda problem: problem[0] in problem_types,
+            map(
+                parse_problem,
+                report['problems']
+            )
+        )
+    )
+    return {
+        'passed': problems == expected_problems,
+        'unexpected': set.difference(problems, expected_problems),
+        'missing': set.difference(expected_problems, problems),
+        'problem_types': problem_types,
+    }
+
+def format_problem_tuple(problem_tuple):
+    return problem_tuple[0] + ':' + str(problem_tuple[1])
+
+def run_unit_test_suite():
+    from argparse import ArgumentParser
+    parser = ArgumentParser()
+    parser.add_argument('-d', '--debug', action = 'store_true')
+    args = parser.parse_args()
+    from ldcheck import LDrawContext
+    context = LDrawContext()
+    test_suite = load_tests()
+    num_tested = 0
+    num_passed = 0
+    all_problem_types = all_problem_type_names(test_suite)
+    problem_types_tested = set()
+    print('Running unit test suite.')
+    for unit_test_path in unit_test_discovery():
+        try:
+            unit_test_report = run_unit_test(
+                unit_test_path,
+                context = context,
+                test_suite = test_suite
+            )
+        except Exception as error:
+            if args.debug:
+                raise
+            else:
+                print(str.format(
+                    'Error running {test_name}: {error}',
+                    test_name = unit_test_path.name,
+                    error = str(error),
+                ))
+        else:
+            print(str.format(
+                '{name}: {verdict}',
+                name = unit_test_path.name,
+                verdict = ('FAILED', 'PASSED')[unit_test_report['passed']],
+            ))
+            num_tested += 1
+            num_passed += int(unit_test_report['passed'])
+            set.update(problem_types_tested, unit_test_report['problem_types'])
+            if not unit_test_report['passed']:
+                def format_problem_list(key):
+                    return str.join(
+                        ' ',
+                        map(format_problem_tuple, unit_test_report[key])
+                    )
+                print('\tunexpected:', format_problem_list('unexpected'))
+                print('\tmissing:', format_problem_list('missing'))
+    print(str.format(
+        '{num_tested} tests run, {num_passed} tests passed.',
+        num_tested = num_tested,
+        num_passed = num_passed,
+    ))
+    untested_problem_types = set.difference(all_problem_types, problem_types_tested)
+    if untested_problem_types:
+        print('The following problem types were not tested:')
+        for problem_type in sorted(untested_problem_types):
+            print('\t' + problem_type)
+if __name__ == '__main__':
+    run_unit_test_suite()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/alias-bad.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,11 @@
+0 Testing: alias-bad-colour alias-not-prefixed-with-equals alias-with-polygon
+0 Expecting: alias-bad-colour:8 alias-not-prefixed-with-equals:1 alias-with-polygon:9
+0 Test part
+0 Name: 1234.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part Alias
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
+
+0 // Alias of 2345
+1 4 0 0 0 1 0 0 0 1 0 0 0 1 2345.dat
+3 16 0 0 0 1 0 0 1 0 1
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/alias-ok.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,10 @@
+0 Testing: alias-bad-colour alias-not-prefixed-with-equals alias-with-polygon
+0 Expecting: none
+0 =Test part
+0 Name: 1234.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part Alias
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
+
+0 // Alias of 2345
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 2345.dat
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/bad-line-endings-dos.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,3 @@
+0 Testing: bad-line-endings
+0 Expecting: none
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 box.dat
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/bad-line-endings-unix.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,3 @@
+0 Testing: bad-line-endings
+0 Expecting: bad-line-endings:1
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 box.dat
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/bowtie.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,3 @@
+0 Testing: self-intersecting
+0 Expecting: self-intersecting:1
+4 16 0 0 0 2 2 0 0 2 0 2 0 0
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/category-2.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,11 @@
+0 Testing: bad-header bad-category bad-category-in-description
+0 Expecting: bad-category:9
+0 Test part
+0 Name: 3002.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
+
+0 BFC CERTIFY CCW
+
+0 !CATEGORY Brix
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/category-3.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,9 @@
+0 Testing: bad-header bad-category bad-category-in-description
+0 Expecting: bad-category-in-description:1
+0 Minifig Accessory Test
+0 Name: 3002.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
+
+0 BFC CERTIFY CCW
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/category.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,11 @@
+0 Testing: bad-header bad-category bad-category-in-description
+0 Expecting: none
+0 Test part
+0 Name: 3002.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
+
+0 BFC CERTIFY CCW
+
+0 !CATEGORY Brick
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/collinearity.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,4 @@
+0 Testing: collinear
+0 Expecting: collinear:1 collinear:2
+3 16 0 0 0 0 100 0 0.01745 100 0
+3 16 0 0 0 0.0436 100 0 0.0436 -100 0
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/colors.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,5 @@
+0 Testing: bad-colour bad-colour-24-line bad-colour-24-nonline
+0 Expecting: bad-colour-24-nonline:1 bad-colour-24-line:2 bad-colour:3
+4 24 0 0 0 1 0 0 1 1 0 0 1 0
+2 4 0 0 0 1 0 0
+4 123 0 0 0 1 0 0 1 1 0 0 1 0
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/concave.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,3 @@
+0 Testing: concave
+0 Expecting: concave:1
+4 16 0 0 0 -1 2 0 0 1 0 1 2 0
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/determinant.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,8 @@
+0 Testing: zero-determinant
+0 Expecting: zero-determinant:2 zero-determinant:3
+0 Expecting: zero-determinant:4 zero-determinant:5
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 box.dat
+1 16 0 0 0 0 0 0 0 1 0 0 0 1 box.dat
+1 16 0 0 0 1 0 0 0 1 0 0 0 0 box.dat
+1 16 0 0 0 1 0 0 0 0 0 0 0 1 box.dat
+1 16 0 0 0 0 0 0 0 0 0 0 0 0 box.dat
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/illegal-scaling.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,5 @@
+0 Testing: illegal-scaling unnecessary-scaling
+0 Expecting: illegal-scaling:2 unnecessary-scaling:3
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 stud.dat
+1 16 0 0 0 1 0 0 0 10 0 0 0 1 stud.dat
+1 16 0 0 0 1 0 0 0 5 0 0 0 1 4-4disc.dat
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/invertnext.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,9 @@
+0 Testing: bfc-invertnext-not-on-subfile unnecessary-invertnext
+0 Expecting: unnecessary-invertnext:4 bfc-invertnext-not-on-subfile:6
+0 BFC INVERTNEXT
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 box.dat
+0 BFC INVERTNEXT
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 4-4disc.dat
+3 16 1 0 0 3 0 0 1 0 -3
+0 BFC INVERTNEXT
+3 16 6 0 0 9 0 0 6 0 -3
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/keywords-for-nonparts-2.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,7 @@
+0 Testing: keywords-for-nonparts
+0 Expecting: none
+0 Example primitive
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Primitive
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/keywords-for-nonparts.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,8 @@
+0 Testing: keywords-for-nonparts
+0 Expecting: keywords-for-nonparts:6
+0 Example primitive
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Primitive
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
+0 !KEYWORDS a, b, c
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/license-ca.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,7 @@
+0 Testing: no-license-set non-ca-license
+0 Expecting: none
+0 Example part
+0 Name: 1234.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/license-nonca.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,7 @@
+0 Testing: no-license-set non-ca-license
+0 Expecting: non-ca-license:5
+0 Example part
+0 Name: 1234.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
+0 !LICENSE Not redistributable
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/license-none.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,6 @@
+0 Testing: no-license-set non-ca-license
+0 Expecting: no-license-set:1
+0 Example part
+0 Name: 1234.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/mirrored-studs.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,6 @@
+0 Testing: mirrored-studs mirrored-studs-indirect
+0 Expecting: mirrored-studs:2 mirrored-studs-indirect:4
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 stud.dat
+1 16 0 0 0 1 0 0 0 1 0 0 0 -1 stud.dat
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 stug-3x3.dat
+1 16 0 0 0 1 0 0 0 1 0 0 0 -1 stug-3x3.dat
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/moved-extension-1.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,7 @@
+0 Testing: moved-to-with-extension
+0 Expecting: none
+0 ~Moved to 1234
+0 Name: 1.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/moved-extension-2.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,7 @@
+0 Testing: moved-to-with-extension
+0 Expecting: moved-to-with-extension:1
+0 ~Moved to 1234.dat
+0 Name: 1.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/moved.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,4 @@
+0 Testing: moved-file-used
+0 Expecting: moved-file-used:1
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 1-16ri19.dat
+1 16 0 0 1 1 0 0 0 1 0 0 0 1 1-19ring19.dat
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/official-part.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,6 @@
+0 Testing: unofficial-part
+0 Expecting: unofficial-part:4
+0 Example part
+0 Name: 1234.dat
+0 Author: Me
+0 !LDRAW_ORG Part UPDATE 2000-01
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/part-bfc-nocertify-2.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,8 @@
+0 Testing: bfc-nocertify
+0 Expecting: none
+0 Example part
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
+0 BFC CERTIFY CCW
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/part-bfc-nocertify.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,8 @@
+0 Testing: bfc-nocertify
+0 Expecting: bfc-nocertify:6
+0 Example part
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
+0 BFC NOCERTIFY
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/physical-color-2.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,7 @@
+0 Testing: physical-colour-part
+0 Expecting: none
+0 Example part
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/physical-color.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,7 @@
+0 Testing: physical-colour-part
+0 Expecting: physical-colour-part:4
+0 Example part
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part Physical_Colour
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/primitive-ccw.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,8 @@
+0 Testing: primitive-non-ccw
+0 Expecting: none
+0 Example primitive
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Primitive
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
+0 BFC CERTIFY CCW
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/primitive-cw.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,8 @@
+0 Testing: primitive-non-ccw
+0 Expecting: primitive-non-ccw:6
+0 Example primitive
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Primitive
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
+0 BFC CERTIFY CW
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/primitive-nobfc.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,7 @@
+0 Testing: no-bfc-line
+0 Expecting: no-bfc-line:1
+0 Example primitive
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Primitive
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/primitive-nocertify.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,8 @@
+0 Testing: primitive-non-ccw
+0 Expecting: primitive-non-ccw:6
+0 Example primitive
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Primitive
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
+0 BFC NOCERTIFY
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/skew.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,8 @@
+0 Testing: skew-major skew-minor
+0 Expecting: skew-major:1 skew-major:3 skew-minor:5
+4 16 25 0 -5 25 0 -15 35 0 -15 35 5 -5
+4 16 25 0 -5 25 0 -15 35 0 -15 35 0 -5
+4 16 40 0 -5 40 0 -15 50 5 -15 50 0 -5
+4 16 40 0 -5 40 0 -15 50 0 -15 50 0 -5
+4 16 0 0 0 100 0 0 100 0 100 0 5 100
+4 16 0 0 0 100 0 0 100 0 100 0 0 100
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/syntax.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,4 @@
+0 Testing: syntax-error
+0 Expecting: syntax-error:2
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 box.dat
+Blah
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/unknown-metacommand.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,7 @@
+0 Testing: unknown-metacommand
+0 Expecting: unknown-metacommand:5
+0 Example part
+0 Name: 1234.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
+0 !WEIRD
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/unknown-subfile.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,4 @@
+0 Testing: bad-subfile
+0 Expecting: bad-subfile:2
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 box.dat
+1 16 0 0 0 1 0 0 0 1 0 0 0 1 companioncube.dat
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/unofficial-part.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,6 @@
+0 Testing: unofficial-part
+0 Expecting: none
+0 Example part
+0 Name: 1234.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Part
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/whitespace-1.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,7 @@
+0 Testing: bad-whitespace
+0 Expecting: none
+0 Example part with regular spaces
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Primitive
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/unittests/whitespace-2.test	Thu Aug 26 19:36:44 2021 +0300
@@ -0,0 +1,7 @@
+0 Testing: bad-whitespace
+0 Expecting: bad-whitespace:1
+0 Example   part  with weird spaces
+0 Name: example.dat
+0 Author: Me
+0 !LDRAW_ORG Unofficial_Primitive
+0 !LICENSE Redistributable under CCAL version 2.0 : see CAreadme.txt
--- a/webfront.py	Thu Aug 26 19:16:25 2021 +0300
+++ b/webfront.py	Thu Aug 26 19:36:44 2021 +0300
@@ -1,29 +1,50 @@
 #!/usr/bin/env python3
 from flask import Flask, render_template, redirect, request
-from ldcheck import load_config, load_colours, find_ldconfig_ldr_paths
+from ldcheck import appname, version, version_string
 from parse import read_ldraw
 from testsuite import load_tests, check_model, problem_text, all_problem_types
 
-app = Flask('LDCheck')
+app = Flask(appname)
+
+from ldcheck import LDrawContext
+try:
+    context = LDrawContext()
+except RuntimeError as error:
+    from sys import stderr, exit
+    print('error:', str(error), file = stderr)
+    exit(1)
+
+def format_report_html(report, model, test_suite):
+    messages = []
+    for problem in report['problems']:
+        ldraw_code = model.body[problem.body_index].textual_representation()
+        message = str.format(
+            '<li class="{problem_type}">{model_name}:{line_number}:'
+            '{problem_type}: {message}<br />{ldraw_code}</li>',
+            model_name = model.name,
+            line_number = problem.line_number,
+            problem_type = problem.severity,
+            message = problem_text(problem, test_suite),
+            ldraw_code = ldraw_code,
+        )
+        messages.append(message)
+    return '\n'.join(messages)
 
 @app.route('/', methods = ['GET', 'POST'])
 def web_front():
     test_suite = load_tests()
+    common_args = {
+        'appname': appname,
+        'version': version_string,
+        'is_debug': max(version) == 9999,
+    }
     if request.method == 'POST':
         # check if the post request has the file part
         if 'file' not in request.files or not request.files['file'].filename:
             return redirect(request.url)
         file = request.files['file']
         filename = file.filename
-        config = load_config('ldcheck.cfg')
-        for ldconfig_ldr_path in find_ldconfig_ldr_paths(config):
-            with ldconfig_ldr_path.open() as ldconfig_ldr:
-                load_colours(ldconfig_ldr)
-        model = read_ldraw(
-            file.stream,
-            name = filename,
-            ldraw_directories = config['libraries'],
-        )
+        model = read_ldraw(file.stream, name = filename, context = context)
         report = check_model(model, test_suite)
 
         # Amend human-readable messages into the report
@@ -34,14 +55,16 @@
         return render_template('webfront.html',
             report = report,
             name = filename,
-            problem_types = all_problem_types(test_suite)
+            problem_types = all_problem_types(test_suite),
+            **common_args,
         )
     else:
         test_suite = load_tests()
         return render_template('webfront.html',
             report = None,
             name = None,
-            problem_types = all_problem_types(test_suite)
+            problem_types = all_problem_types(test_suite),
+            **common_args,
         )
 
 @app.route('/static/<path:path>')

mercurial