diff --git a/Dockerfile b/Dockerfile index 3c7e41c..eaa9371 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ # docker run --rm -it --name vasl-templates \ # -p 5010:5010 \ # -v .../vasl-6.4.3.vmod:/data/vasl.vmod \ +# -v .../vasl-extensions:/data/vasl-extensions \ # vasl-templates # If you have Chapter H data, add the following: # -v .../chapter-h-notes:/data/chapter-h-notes diff --git a/conftest.py b/conftest.py index 510a16e..e670713 100644 --- a/conftest.py +++ b/conftest.py @@ -54,6 +54,10 @@ def pytest_addoption( parser ): "--vasl-mods", action="store", dest="vasl_mods", default=None, help="Directory containing the VASL .vmod file(s)." ) + parser.addoption( + "--vasl-extensions", action="store", dest="vasl_extensions", default=None, + help="Directory containing the VASL extensions." + ) # NOTE: Some tests require VASSAL to be installed. This option allows the caller to specify # where it is (multiple installations can be placed in sub-directories). diff --git a/docker/config/debug.cfg b/docker/config/debug.cfg index bbc32f9..42791a0 100644 --- a/docker/config/debug.cfg +++ b/docker/config/debug.cfg @@ -1,3 +1,4 @@ [Debug] -TEST_VASL_MODS = /test-data/vasl-vmods +TEST_VASL_MODS = /test-data/vasl-vmods/ +TEST_VASL_EXTENSIONS_DIR = /test-data/vasl-extensions/ diff --git a/docker/config/site.cfg b/docker/config/site.cfg index 54816ee..c01c6b7 100644 --- a/docker/config/site.cfg +++ b/docker/config/site.cfg @@ -3,4 +3,5 @@ FLASK_HOST = 0.0.0.0 VASL_MOD = /data/vasl.vmod +VASL_EXTENSIONS = /data/vasl-extensions/ CHAPTER_H_NOTES = /data/chapter-h-notes/ diff --git a/vasl_templates/main.py b/vasl_templates/main.py index e65f0c5..843feab 100755 --- a/vasl_templates/main.py +++ b/vasl_templates/main.py @@ -114,7 +114,7 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin # install the server settings try: from vasl_templates.server_settings import install_server_settings #pylint: disable=cyclic-import - install_server_settings() + install_server_settings( True ) except Exception as ex: #pylint: disable=broad-except from vasl_templates.main_window import MainWindow #pylint: disable=cyclic-import MainWindow.showErrorMsg( diff --git a/vasl_templates/server_settings.py b/vasl_templates/server_settings.py index 7cba99d..1b9302a 100644 --- a/vasl_templates/server_settings.py +++ b/vasl_templates/server_settings.py @@ -8,10 +8,10 @@ from PyQt5.QtGui import QIcon from vasl_templates.main import app_settings from vasl_templates.main_window import MainWindow -from vasl_templates.webapp.config.constants import DATA_DIR -from vasl_templates.webapp.vassal import SUPPORTED_VASSAL_VERSIONS_DISPLAY -from vasl_templates.webapp.file_server.vasl_mod import VaslMod, SUPPORTED_VASL_MOD_VERSIONS_DISPLAY -from vasl_templates.webapp.files import install_vasl_mod +from vasl_templates.utils import show_msg_store +from vasl_templates.webapp.vassal import VassalShim, SUPPORTED_VASSAL_VERSIONS_DISPLAY +from vasl_templates.webapp.utils import MsgStore +from vasl_templates.webapp.file_server.vasl_mod import set_vasl_mod, SUPPORTED_VASL_MOD_VERSIONS_DISPLAY # --------------------------------------------------------------------- @@ -122,7 +122,7 @@ class ServerSettingsDialog( QDialog ): # NOTE: We should really do this before saving the new settings, but that's more trouble # than it's worth at this stage... :-/ try: - install_server_settings() + install_server_settings( False ) except Exception as ex: #pylint: disable=broad-except MainWindow.showErrorMsg( "Couldn't install the server settings:\n\n{}".format( ex ) ) return @@ -148,7 +148,7 @@ def _make_exe_filter_string(): # --------------------------------------------------------------------- -def install_server_settings(): +def install_server_settings( is_startup ): """Install the server settings.""" # install the server settings @@ -159,10 +159,20 @@ def install_server_settings(): app.config["JAVA_PATH"] = app_settings.value( "ServerSettings/java-path" ) app.config["WEBDRIVER_PATH"] = app_settings.value( "ServerSettings/webdriver-path" ) + # initialize + if is_startup: + # nb: we let the web page show startup messages + msg_store = None + else: + msg_store = MsgStore() + # load the VASL module fname = app_settings.value( "ServerSettings/vasl-mod" ) - if fname: - vasl_mod = VaslMod( fname, DATA_DIR ) - else: - vasl_mod = None - install_vasl_mod( vasl_mod ) + set_vasl_mod( fname, msg_store ) + + # check the VASSAL version + VassalShim.check_vassal_version( msg_store ) + + # show any messages + if msg_store: + show_msg_store( msg_store ) diff --git a/vasl_templates/utils.py b/vasl_templates/utils.py index 4024e20..51535c4 100644 --- a/vasl_templates/utils.py +++ b/vasl_templates/utils.py @@ -27,3 +27,15 @@ def catch_exceptions( caption="EXCEPTION", retval=None ): return retval return wrapper return decorator + +# --------------------------------------------------------------------- + +def show_msg_store( msg_store ): + """Show messages in a MsgStore.""" + + # NOTE: It would be nice to show a single dialog with all the messages, each one tagged with + # a pretty little icon, but for now, we just show a message box for each message :-/ + from vasl_templates.main_window import MainWindow + for msg_type in ("error","warning"): + for msg in msg_store.get_msgs( msg_type ): + MainWindow.showErrorMsg( msg ) diff --git a/vasl_templates/webapp/data/extensions/kgs-v1.1.json b/vasl_templates/webapp/data/extensions/kgs-v1.1.json new file mode 100644 index 0000000..bd795bf --- /dev/null +++ b/vasl_templates/webapp/data/extensions/kgs-v1.1.json @@ -0,0 +1,21 @@ +{ + +"extensionId": "f97", +"version": "v1.1", + +"vehicles": [ + +{ + "_comment_": "Matilda II(b)", + "id": "ru/v:078", + "gpid": [ "f97:178", "f97:184" ] +}, +{ + "_comment_": "T-60 M40", + "id": "ru/v:004", + "gpid": [ "f97:186" ] +} + +] + +} diff --git a/vasl_templates/webapp/file_server/utils.py b/vasl_templates/webapp/file_server/utils.py index 83871cb..9b3a436 100644 --- a/vasl_templates/webapp/file_server/utils.py +++ b/vasl_templates/webapp/file_server/utils.py @@ -5,28 +5,40 @@ import json # --------------------------------------------------------------------- -def get_vo_gpids( data_dir ): +def get_vo_gpids( data_dir, extns ): #pylint: disable=too-many-locals,too-many-branches """Get the GPID's for the vehicles/ordnance.""" gpids = set() - for vo_type in ("vehicles","ordnance"): - dname = os.path.join( data_dir, vo_type ) + for vo_type in ("vehicles","ordnance"): #pylint: disable=too-many-nested-blocks # process each file + dname = os.path.join( data_dir, vo_type ) for root,_,fnames in os.walk(dname): for fname in fnames: if os.path.splitext(fname)[1] != ".json": continue # load the GPID's from the next file + # NOTE: We originally assumed that GPID's are integers, but the main VASL build file started + # to have non-numeric values, as do, apparently, extensions :-/ For back-compat, we support both. entries = json.load( open( os.path.join(root,fname), "r" ) ) for entry in entries: - if isinstance( entry["gpid"], list): - gpids.update( get_effective_gpid(gpid) for gpid in entry["gpid"] ) - else: - gpids.add( entry["gpid"] ) - - gpids.remove( None ) + entry_gpids = entry[ "gpid" ] + if not isinstance( entry_gpids, list ): + entry_gpids = [ entry_gpids ] + for gpid in entry_gpids: + if gpid: + gpids.add( get_effective_gpid( str(gpid) ) ) + + # process any extensions + if extns: + for extn in extns: + extn_info = extn[1] + for vo_type in ["vehicles","ordnance"]: + if vo_type not in extn_info: + continue + for piece in extn_info[vo_type]: + gpids.update( piece["gpid"] ) return gpids @@ -40,8 +52,8 @@ def get_vo_gpids( data_dir ): # will break. This kind of thing is going to happen again, so we provide a generic mechanism # for dealing with this kind of thing... GPID_REMAPPINGS = { - 7140: 2775, # SdKfz 10/5 - 7146: 2772, # SdKfz 10/4 + "7140": "2775", # SdKfz 10/5 + "7146": "2772", # SdKfz 10/4 } def get_effective_gpid( gpid ): diff --git a/vasl_templates/webapp/file_server/vasl_mod.py b/vasl_templates/webapp/file_server/vasl_mod.py index a878f98..4365d1c 100644 --- a/vasl_templates/webapp/file_server/vasl_mod.py +++ b/vasl_templates/webapp/file_server/vasl_mod.py @@ -1,7 +1,9 @@ -""" Serve files from a VASL module file. """ +""" Wrapper around a VASL module file and extensions. """ import os +import threading import json +import glob import zipfile import re import xml.etree.ElementTree @@ -9,6 +11,8 @@ import xml.etree.ElementTree import logging _logger = logging.getLogger( "vasl_mod" ) +from vasl_templates.webapp import app +from vasl_templates.webapp.config.constants import DATA_DIR from vasl_templates.webapp.file_server.utils import get_vo_gpids, get_effective_gpid SUPPORTED_VASL_MOD_VERSIONS = [ "6.4.0", "6.4.1", "6.4.2", "6.4.3" ] @@ -16,16 +20,150 @@ SUPPORTED_VASL_MOD_VERSIONS_DISPLAY = "6.4.0-6.4.3" # --------------------------------------------------------------------- +# NOTE: The lock only controls access to the _vasl_mod variable, not the VaslMod object it points to. +# In practice this doesn't really matter, since it will be loaded once at startup, then never changes; +# it's only the tests that are constantly changing the underlying object. +_vasl_mod_lock = threading.RLock() +_vasl_mod = None + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def get_vasl_mod(): + """Return the global VaslMod object.""" + with _vasl_mod_lock: + global _vasl_mod + if _vasl_mod is None: + # check if a VASL module has been configured + # NOTE: We will be doing this check every time someone wants the global VaslMod object, + # even if one hasn't been configured, but in all likelihood, everyone will have it configured, + # in which case, the check will only be done once, and the global _vasl_mod variable set. + fname = app.config.get( "VASL_MOD" ) + if fname: + # yup - load it + from vasl_templates.webapp.main import startup_msg_store #pylint: disable=cyclic-import + set_vasl_mod( fname, startup_msg_store ) + return _vasl_mod + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def set_vasl_mod( vmod_fname, msg_store ): + """Install a new global VaslMod object.""" + with _vasl_mod_lock: + global _vasl_mod + if vmod_fname: + extns_dir = app.config.get( "VASL_EXTENSIONS_DIR" ) + extns = _load_vasl_extns( extns_dir, msg_store ) + _vasl_mod = VaslMod( vmod_fname, DATA_DIR, extns ) + if _vasl_mod.vasl_version not in SUPPORTED_VASL_MOD_VERSIONS: + msg_store.warning( + "VASL {} is unsupported.

Things might work, but they might not...".format( + _vasl_mod.vasl_version + ) + ) + else: + _vasl_mod = None + +def _load_vasl_extns( extn_dir, msg_store ): #pylint: disable=too-many-locals + """Locate VASL extensions and their corresponding vehicle/ordnance info files.""" + + if not extn_dir: + return [] + + # load our extension info files + all_extn_info = {} + if "_VASL_EXTN_INFO_DIR_" in app.config: + dname = app.config["_VASL_EXTN_INFO_DIR_"] # nb: for the test suite + else: + dname = os.path.join( DATA_DIR, "extensions" ) + for fname in glob.glob( os.path.join(dname,"*.json") ): + _logger.debug( "Loading VASL extension info: %s", fname ) + with open( fname, "r" ) as fp: + extn_info = json.load( fp ) + all_extn_info[ ( extn_info["extensionId"], extn_info["version"] ) ] = extn_info + _logger.debug( "- id=%s ; version=%s", extn_info["extensionId"], extn_info["version"] ) + + # figure out what filename extensions we will recognize + valid_fname_extns = app.config.get( "VASL_EXTENSION_FILENAME_EXTNS", ".mdx .zip" ) + valid_fname_extns = valid_fname_extns.replace( ";", " " ).replace( ",", " " ).split() + + # process each VASL extension + extns = [] + for extn_fname in os.listdir( extn_dir ): + + # check if this is a file we're interested in + if os.path.splitext(extn_fname)[1] not in valid_fname_extns: + continue + extn_fname = os.path.join( extn_dir, extn_fname ) + + # try to load the extension + _logger.debug( "Loading VASL extension: %s", extn_fname ) + try: + zip_file = zipfile.ZipFile( extn_fname, "r" ) + except zipfile.BadZipFile: + msg_store.warning( "Can't load VASL extension (not a ZIP file): {}", extn_fname, logger=_logger ) + continue + try: + build_info = zip_file.read( "buildFile" ) + except KeyError: + msg_store.warning( "Missing buildFile: {}", extn_fname, logger=_logger ) + continue + doc = xml.etree.ElementTree.fromstring( build_info ) + node = doc.findall( "." )[0] + if node.tag != "VASSAL.build.module.ModuleExtension": + msg_store.warning( "Unexpected root node ({}) for VASL extension: {}", + node.tag, extn_fname, logger=_logger + ) + continue + + # get the extension's ID and version string + extn_id = node.attrib.get( "extensionId" ) + if not extn_id: + msg_store.warning( "Can't find ID for VASL extension: {}", extn_fname, logger=_logger ) + continue + extn_version = node.attrib.get( "version" ) + if not extn_version: + msg_store.warning( "Can't find version for VASL extension: {}", extn_fname, logger=_logger ) + continue + _logger.debug( "- id=%s ; version=%s", extn_id, extn_version ) + + # check if we have a corresponding info file + extn_info = all_extn_info.get( ( extn_id, extn_version ) ) + if not extn_info: + msg_store.warning( "Not loading VASL extension \"{}\".

No extension info file for {}/{}.".format( + os.path.split(extn_fname)[1], extn_id, extn_version + ) ) + _logger.warning( "Not loading VASL extension (no info file for %s/%s): %s", + extn_id, extn_version, extn_fname + ) + continue + + # yup - add the extension to the list + extns.append( ( extn_fname, extn_info ) ) + + return extns + +# --------------------------------------------------------------------- + class VaslMod: - """Serve files from a VASL module file.""" + """Wrapper around a VASL module file and extensions.""" + + def __init__( self, fname, data_dir, extns ) : - def __init__( self, fname, data_dir ) : # initialize - self.pieces = {} - # parse the VASL module file - _logger.info( "Loading VASL module: %s", fname ) - self.zip_file = zipfile.ZipFile( fname, "r" ) - self.vasl_version = self._parse_vmod( data_dir ) + self.filename = fname + self.extns = extns + + # initialize + self._pieces = {} + self._files = [ ( zipfile.ZipFile(fname,"r"), None ) ] + if extns: + for extn in extns: + self._files.append( + ( zipfile.ZipFile(extn[0],"r"), extn[1] ) + ) + + # load the VASL module and any extensions + self.vasl_version = self._load_vmod( data_dir ) if self.vasl_version not in SUPPORTED_VASL_MOD_VERSIONS: _logger.warning( "Unsupported VASL version: %s", self.vasl_version ) @@ -34,11 +172,11 @@ class VaslMod: # get the image path gpid = get_effective_gpid( gpid ) - if gpid not in self.pieces: + if gpid not in self._pieces: return None, None - entry = self.pieces[ get_effective_gpid( gpid ) ] + piece = self._pieces[ get_effective_gpid( gpid ) ] assert side in ("front","back") - image_paths = entry[ side+"_images" ] + image_paths = piece[ side + "_images" ] if not image_paths: return None, None if not isinstance( image_paths, list ): @@ -50,7 +188,7 @@ class VaslMod: # load the image data image_path = os.path.join( "images", image_path ) image_path = re.sub( r"[\\/]+", "/", image_path ) # nb: in case we're on Windows :-/ - image_data = self.zip_file.read( image_path ) + image_data = piece[ "zip_file" ].read( image_path ) return image_path, image_data @@ -68,11 +206,19 @@ class VaslMod: "back_images": image_count( p, "back_images" ), "is_small": p["is_small"], } - for p in self.pieces.values() + for p in self._pieces.values() } - def _parse_vmod( self, data_dir ): #pylint: disable=too-many-branches,too-many-locals - """Parse a .vmod file.""" + def get_extns( self ): + """Return the loaded VASL extensions.""" + return [ + ( files[0].filename, files[1] ) + for files in self._files + if files[1] + ] + + def _load_vmod( self, data_dir ): #pylint: disable=too-many-branches,too-many-locals + """Load a VASL module file and any extensions.""" # load our overrides fname = os.path.join( data_dir, "vasl-overrides.json" ) @@ -81,30 +227,54 @@ class VaslMod: expected_multiple_images = json.load( open( fname, "r" ) ) # figure out which pieces we're interested in - target_gpids = get_vo_gpids( data_dir ) + target_gpids = get_vo_gpids( data_dir, self.get_extns() ) + + # parse the VASL module and any extensions + for i,files in enumerate( self._files ): + _logger.info( "Loading VASL %s: %s", ("module" if i == 0 else "extension"), files[0].filename ) + version = self._parse_zip_file( files[0], target_gpids, vasl_overrides, expected_multiple_images ) + if i == 0: + vasl_version = version + + # make sure we found all the pieces we need + _logger.info( "Loaded %d pieces.", len(self._pieces) ) + if target_gpids: + _logger.warning( "Couldn't find pieces: %s", target_gpids ) + + # make sure all the overrides defined were used + if vasl_overrides: + gpids = ", ".join( vasl_overrides.keys() ) + _logger.warning( "Unused VASL overrides: %s", gpids ) + if expected_multiple_images: + gpids = ", ".join( expected_multiple_images.keys() ) + _logger.warning( "Expected multiple images but didn't find them: %s", gpids ) + + return vasl_version + + def _parse_zip_file( self, zip_file, target_gpids, vasl_overrides, expected_multiple_images ): #pylint: disable=too-many-locals + """Parse a VASL module or extension.""" + + # load the build file + build_info = zip_file.read( "buildFile" ) + doc = xml.etree.ElementTree.fromstring( build_info ) def check_override( gpid, piece, override ): """Check that the values in an override entry match what we have.""" for key in override: if piece[key] != override[key]: - _logger.warning( "Unexpected value in VASL override for '%s' (gpid=%d): %s", key, gpid, piece[key] ) + _logger.warning( "Unexpected value in VASL override for '%s' (gpid=%s): %s", key, gpid, piece[key] ) return False return True - # parse the VASL build info - build_info = self.zip_file.read( "buildFile" ) - doc = xml.etree.ElementTree.fromstring( build_info ) + # iterate over each PieceSlot in the build file for node in doc.iter( "VASSAL.build.widget.PieceSlot" ): # load the next entry - # FUDGE! 6.4.3 introduced weird GPID's for "Hex Grid" pieces :-/ - if node.attrib["gpid"].startswith( "4d0:" ): - continue - gpid = int( node.attrib["gpid"] ) + gpid = node.attrib[ "gpid" ] if gpid not in target_gpids: continue - if gpid in self.pieces: - _logger.warning( "Found duplicate GPID: %d", gpid ) + if gpid in self._pieces: + _logger.warning( "Found duplicate GPID: %s", gpid ) front_images, back_images = self._get_image_paths( gpid, node.text ) piece = { "gpid": gpid, @@ -112,43 +282,31 @@ class VaslMod: "front_images": front_images, "back_images": back_images, "is_small": int(node.attrib["height"]) <= 48, + "zip_file": zip_file, } # check if we want to override any values - override = vasl_overrides.get( str(gpid) ) + override = vasl_overrides.get( gpid ) if override: if check_override( gpid, piece, override["expected"] ): for key in override["updated"]: piece[key] = override["updated"][key] - del vasl_overrides[ str(gpid) ] + del vasl_overrides[ gpid ] # save the loaded entry - self.pieces[gpid] = piece + self._pieces[ gpid ] = piece target_gpids.remove( gpid ) _logger.debug( "- Loaded piece: %s", piece ) # check for multiple images if isinstance(piece["front_images"],list) or isinstance(piece["back_images"],list): - expected = expected_multiple_images.get( str(gpid) ) + expected = expected_multiple_images.get( gpid ) if expected: check_override( gpid, piece, expected ) - del expected_multiple_images[ str(gpid) ] + del expected_multiple_images[ gpid ] else: _logger.warning( "Found multiple images: %s", piece ) - # make sure we found all the pieces we need - _logger.info( "Loaded %d pieces.", len(self.pieces) ) - if target_gpids: - _logger.warning( "Couldn't find pieces: %s", target_gpids ) - - # make sure all the overrides defined were used - if vasl_overrides: - gpids = ", ".join( vasl_overrides.keys() ) - _logger.warning( "Unused VASL overrides: %s", gpids ) - if expected_multiple_images: - gpids = ", ".join( expected_multiple_images.keys() ) - _logger.warning( "Expected multiple images but didn't find them: %s", gpids ) - return doc.attrib.get( "version" ) @staticmethod @@ -180,7 +338,7 @@ class VaslMod: fields = [ f for f in fields if f ] return fields if not fields: - _logger.warning( "Couldn't find any image paths for gpid=%d.", gpid ) + _logger.warning( "Couldn't find any image paths for gpid=%s.", gpid ) return None, None if len(fields) == 1: # the piece only has front image(s) @@ -188,14 +346,14 @@ class VaslMod: else: # the piece has front and back image(s) if len(fields) > 2: - _logger.warning( "Found > 2 image paths for gpid=%d", gpid ) + _logger.warning( "Found > 2 image paths for gpid=%s", gpid ) front_images, back_images = split_fields(fields[1]), split_fields(fields[0]) # ignore dismantled ordnance if len(front_images) > 1: if front_images[-1].endswith( "dm" ): if back_images[-1].endswith( "dmb" ): - _logger.debug( "Ignoring dismantled images: gpid=%d, front=%s, back=%s", + _logger.debug( "Ignoring dismantled images: gpid=%s, front=%s, back=%s", gpid, front_images, back_images ) front_images.pop() @@ -207,7 +365,7 @@ class VaslMod: if len(front_images) > 1: if front_images[-1].endswith( "l" ): if back_images[-1].endswith( ("lb","l-b") ): - _logger.debug( "Ignoring limbered images: gpid=%d, front=%s, back=%s", + _logger.debug( "Ignoring limbered images: gpid=%s, front=%s, back=%s", gpid, front_images, back_images ) front_images.pop() @@ -216,7 +374,7 @@ class VaslMod: _logger.warning( "Unexpected limbered images: %s %s", front_images, back_images ) elif front_images[-1].endswith( "B.png" ) and front_images[0] == front_images[-1][:-5]+".png": # nb: this is for Finnish Guns - _logger.debug( "Ignoring limbered images: gpid=%d, front=%s, back=%s", + _logger.debug( "Ignoring limbered images: gpid=%s, front=%s, back=%s", gpid, front_images, back_images ) front_images.pop() diff --git a/vasl_templates/webapp/files.py b/vasl_templates/webapp/files.py index 7b606de..07e2a76 100644 --- a/vasl_templates/webapp/files.py +++ b/vasl_templates/webapp/files.py @@ -9,12 +9,7 @@ import mimetypes from flask import send_file, send_from_directory, jsonify, redirect, url_for, abort from vasl_templates.webapp import app -from vasl_templates.webapp.file_server.vasl_mod import VaslMod -from vasl_templates.webapp.config.constants import DATA_DIR - -vasl_mod = None -if app.config.get( "VASL_MOD" ): - vasl_mod = VaslMod( app.config["VASL_MOD"], DATA_DIR ) +from vasl_templates.webapp.file_server.vasl_mod import get_vasl_mod # --------------------------------------------------------------------- @@ -64,24 +59,18 @@ def get_user_file( path ): # --------------------------------------------------------------------- -def install_vasl_mod( new_vasl_mod ): - """Install a new VASL module.""" - global vasl_mod - vasl_mod = new_vasl_mod - -# --------------------------------------------------------------------- - @app.route( "/counter///" ) @app.route( "/counter//", defaults={"index":0} ) def get_counter_image( gpid, side, index ): """Get a counter image.""" # check if a VASL module has been configured + vasl_mod = get_vasl_mod() if not vasl_mod: return redirect( url_for( "static", filename="images/missing-image.png" ), code=302 ) # return the specified counter image - image_path, image_data = vasl_mod.get_piece_image( int(gpid), side, int(index) ) + image_path, image_data = vasl_mod.get_piece_image( gpid, side, int(index) ) if not image_data: abort( 404 ) return send_file( @@ -96,6 +85,7 @@ def get_vasl_piece_info(): """Get information about the VASL pieces.""" # check if a VASL module has been configured + vasl_mod = get_vasl_mod() if not vasl_mod: return jsonify( {} ) diff --git a/vasl_templates/webapp/main.py b/vasl_templates/webapp/main.py index 191160d..3a61840 100644 --- a/vasl_templates/webapp/main.py +++ b/vasl_templates/webapp/main.py @@ -6,8 +6,12 @@ import json from flask import request, render_template, jsonify, send_file, redirect, url_for, abort from vasl_templates.webapp import app +from vasl_templates.webapp.utils import MsgStore from vasl_templates.webapp.config.constants import DATA_DIR +startup_msg_store = MsgStore() # store messages generated during startup +_check_versions = True + # --------------------------------------------------------------------- @app.route( "/" ) @@ -17,6 +21,28 @@ def main(): # --------------------------------------------------------------------- +@app.route( "/startup-msgs" ) +def get_startup_msgs(): + """Return any messages generated during startup.""" + + global _check_versions + if _check_versions: + _check_versions = False + # check the VASSAL version + from vasl_templates.webapp.vassal import VassalShim + VassalShim.check_vassal_version( startup_msg_store ) + + # collect all the startup messages + startup_msgs = {} + for msg_type in ("info","warning","error"): + msgs = startup_msg_store.get_msgs( msg_type ) + if msgs: + startup_msgs[ msg_type ] = msgs + + return jsonify( startup_msgs ) + +# --------------------------------------------------------------------- + @app.route( "/help" ) def show_help(): """Show the help page.""" diff --git a/vasl_templates/webapp/static/main.js b/vasl_templates/webapp/static/main.js index 0e670c0..1d253ca 100644 --- a/vasl_templates/webapp/static/main.js +++ b/vasl_templates/webapp/static/main.js @@ -281,20 +281,6 @@ $(document).ready( function () { showErrorMsg( "Can't get the template pack:

" + escapeHTML(errorMsg) + "
" ) ; } ) ; - // check the VASSAL/VASL versions - $.get( gCheckVassalVersionUrl, function( resp ) { - if ( resp ) - showWarningMsg( resp ) ; - } ).fail( function( xhr, status, errorMsg ) { - showErrorMsg( "Can't check the VASSAL version:
" + escapeHTML(errorMsg) + "
" ) ; - } ) ; - $.get( gCheckVaslVersionUrl, function( resp ) { - if ( resp ) - showWarningMsg( resp ) ; - } ).fail( function( xhr, status, errorMsg ) { - showErrorMsg( "Can't check the VASL version:
" + escapeHTML(errorMsg) + "
" ) ; - } ) ; - // fixup the layout var prevHeight = [] ; $(window).resize( function() { @@ -452,7 +438,13 @@ gPageLoadStatus = [ "main", "vehicle-listings", "ordnance-listings", "vehicle-no function update_page_load_status( id ) { // track the page load progress - gPageLoadStatus.splice( gPageLoadStatus.indexOf(id), 1 ) ; + var pos = gPageLoadStatus.indexOf( id ) ; + if ( pos === -1 ) { + if ( id !== "default-scenario" ) + console.log( "Multiple page-load status:", id ) ; + return ; + } + gPageLoadStatus.splice( pos, 1 ) ; if ( id === "template-pack" ) $("fieldset[name='scenario']").fadeIn( 2*1000 ) ; @@ -464,6 +456,13 @@ function update_page_load_status( id ) do_on_new_scenario( false ) ; } + function show_startup_msgs( msgs, msg_type ) { + if ( msg_type in msgs ) { + for ( var i=0 ; i < msgs[msg_type].length ; ++i ) + doShowNotificationMsg( msg_type, msgs[msg_type][i] ) ; + } + } + // check if the page has finished loading if ( gPageLoadStatus.length === 0 ) { // yup - update the UI @@ -482,6 +481,15 @@ function update_page_load_status( id ) // notify the PyQT desktop application if ( gWebChannelHandler ) gWebChannelHandler.on_app_loaded() ; + // show any startuup messages + $.get( gGetStartupMsgsUrl, function( resp ) { + $("body").append( $("
") ) ; + show_startup_msgs( resp, "error" ) ; + show_startup_msgs( resp, "warning" ) ; + show_startup_msgs( resp, "info" ) ; + } ).fail( function( xhr, status, errorMsg ) { + showErrorMsg( "Can't get the startup messages:
" + escapeHTML(errorMsg) + "
" ) ; + } ) ; } } diff --git a/vasl_templates/webapp/static/snippets.js b/vasl_templates/webapp/static/snippets.js index c12f656..5f2391d 100644 --- a/vasl_templates/webapp/static/snippets.js +++ b/vasl_templates/webapp/static/snippets.js @@ -1039,9 +1039,9 @@ function do_load_scenario_data( params ) } var vo_image_id = null ; if ( "image_id" in params[key][i] ) { - var matches = params[key][i].image_id.match( /^(\d{3,4})\/(\d)$/ ) ; + var matches = params[key][i].image_id.match( /^([a-z0-9:]{3,10})\/(\d)$/ ) ; if ( matches ) - vo_image_id = [ parseInt(matches[1]), parseInt(matches[2]) ] ; + vo_image_id = [ matches[1], parseInt(matches[2]) ] ; else warnings.push( "Invalid V/O image ID for '" + params[key][i].name + "': " + params[key][i].image_id ) ; } diff --git a/vasl_templates/webapp/static/vo.js b/vasl_templates/webapp/static/vo.js index 977c2a2..0354bb5 100644 --- a/vasl_templates/webapp/static/vo.js +++ b/vasl_templates/webapp/static/vo.js @@ -353,9 +353,9 @@ function _find_vo_image_id( vo_images, vo_image_id ) // find the specified V/O image ID (because indexOf() doesn't handle arrays :-/) if ( vo_image_id === null ) return 0 ; - vo_image_id = vo_image_id.join(":") ; + vo_image_id = vo_image_id.join( ":" ) ; for ( var i=0 ; i < vo_images.length ; ++i ) { - if ( vo_images[i].join(":") == vo_image_id ) + if ( vo_images[i].join( ":" ) === vo_image_id ) return i ; } return -1 ; diff --git a/vasl_templates/webapp/templates/index.html b/vasl_templates/webapp/templates/index.html index 4f5a6a1..3b9b74d 100644 --- a/vasl_templates/webapp/templates/index.html +++ b/vasl_templates/webapp/templates/index.html @@ -88,8 +88,7 @@ gAppName = "{{APP_NAME}}" ; gAppVersion = "{{APP_VERSION}}" ; gImagesBaseUrl = "{{url_for('static',filename='images')}}" ; -gCheckVassalVersionUrl = "{{url_for('check_vassal_version')}}" ; -gCheckVaslVersionUrl = "{{url_for('check_vasl_version')}}" ; +gGetStartupMsgsUrl = "{{url_for('get_startup_msgs')}}" ; gGetTemplatePackUrl = "{{url_for('get_template_pack')}}" ; gGetDefaultScenarioUrl = "{{url_for('get_default_scenario')}}" ; gVehicleListingsUrl = "{{url_for('get_vehicle_listings',merge_common=1)}}" ; diff --git a/vasl_templates/webapp/testing.py b/vasl_templates/webapp/testing.py index ed1ca5d..ab3448c 100644 --- a/vasl_templates/webapp/testing.py +++ b/vasl_templates/webapp/testing.py @@ -1,12 +1,15 @@ """Webapp handlers for testing porpoises.""" import inspect +import base64 from flask import request, jsonify, abort from vasl_templates.webapp import app from vasl_templates.webapp.tests.remote import ControlTests +_control_tests = ControlTests( app ) + # --------------------------------------------------------------------- @app.route( "/control-tests/" ) @@ -18,8 +21,7 @@ def control_tests( action ): abort( 404 ) # figure out what we're being asked to do - controller = ControlTests( app ) - func = getattr( controller, action ) + func = getattr( _control_tests, action ) if not func: abort( 404 ) @@ -27,8 +29,10 @@ def control_tests( action ): sig = inspect.signature( func ) kwargs = {} for param in sig.parameters.values(): - if param.name in ("vengine","vmod","gpids","dtype","fname","dname"): + if param.name in ("vengine","vmod","gpids","dtype","fname","dname","extns_dtype","bin_data"): kwargs[ param.name ] = request.args.get( param.name, param.default ) + if param.name == "bin_data": + kwargs["bin_data"] = base64.b64decode( kwargs["bin_data"] ) # execute the command resp = func( **kwargs ) @@ -37,5 +41,5 @@ def control_tests( action ): if isinstance( resp, (str,list,dict) ): return jsonify( resp ) else: - assert resp == controller, "Methods should return self if there is no response data." + assert resp == _control_tests, "Methods should return self if there is no response data." return "ok" diff --git a/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/alphanumeric-gpid.json b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/invalid-gpid.json similarity index 80% rename from vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/alphanumeric-gpid.json rename to vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/invalid-gpid.json index 7e94b27..4eb2fe0 100644 --- a/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/alphanumeric-gpid.json +++ b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/invalid-gpid.json @@ -4,7 +4,7 @@ { "id": "ge/v:990", "name": "a german vehicle", - "image_id": "abc123/0" + "image_id": "abc123!/0" } ] } diff --git a/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/long-gpid.json b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/long-gpid.json index b2657f2..a3bc4fd 100644 --- a/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/long-gpid.json +++ b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/long-gpid.json @@ -4,7 +4,7 @@ { "id": "ge/v:990", "name": "a german vehicle", - "image_id": "12345/0" + "image_id": "12345678901234567890/0" } ] } diff --git a/vasl_templates/webapp/tests/fixtures/vasl-extensions/good-match/test-extn.json b/vasl_templates/webapp/tests/fixtures/vasl-extensions/good-match/test-extn.json new file mode 100644 index 0000000..e0f9e02 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/vasl-extensions/good-match/test-extn.json @@ -0,0 +1,6 @@ +{ + +"extensionId": "test", +"version": "v0.1" + +} diff --git a/vasl_templates/webapp/tests/fixtures/vasl-extensions/mismatched-id/test-extn.json b/vasl_templates/webapp/tests/fixtures/vasl-extensions/mismatched-id/test-extn.json new file mode 100644 index 0000000..76e3c32 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/vasl-extensions/mismatched-id/test-extn.json @@ -0,0 +1,6 @@ +{ + +"extensionId": "unknown", +"version": "v0.1" + +} diff --git a/vasl_templates/webapp/tests/fixtures/vasl-extensions/mismatched-version/test-extn.json b/vasl_templates/webapp/tests/fixtures/vasl-extensions/mismatched-version/test-extn.json new file mode 100644 index 0000000..96bd877 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/vasl-extensions/mismatched-version/test-extn.json @@ -0,0 +1,6 @@ +{ + +"extensionId": "test", +"version": "v9.9" + +} diff --git a/vasl_templates/webapp/tests/fixtures/vasl-extensions/test-extn.xml b/vasl_templates/webapp/tests/fixtures/vasl-extensions/test-extn.xml new file mode 100644 index 0000000..8bb62ae --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/vasl-extensions/test-extn.xml @@ -0,0 +1,5 @@ + + + diff --git a/vasl_templates/webapp/tests/remote.py b/vasl_templates/webapp/tests/remote.py index 9b47b1d..af428e8 100644 --- a/vasl_templates/webapp/tests/remote.py +++ b/vasl_templates/webapp/tests/remote.py @@ -10,6 +10,8 @@ import os import urllib.request import json import glob +import base64 +import tempfile import logging import random @@ -19,10 +21,9 @@ from vasl_templates.webapp import app from vasl_templates.webapp.config.constants import DATA_DIR from vasl_templates.webapp import main as webapp_main from vasl_templates.webapp import snippets as webapp_snippets -from vasl_templates.webapp import files as webapp_files from vasl_templates.webapp import vo_notes as webapp_vo_notes from vasl_templates.webapp.file_server import utils as webapp_file_server_utils -from vasl_templates.webapp.file_server.vasl_mod import VaslMod +from vasl_templates.webapp.file_server.vasl_mod import set_vasl_mod _logger = logging.getLogger( "control_tests" ) @@ -39,6 +40,11 @@ class ControlTests: self.server_url = pytest.config.option.server_url #pylint: disable=no-member except AttributeError: self.server_url = None + # set up a temp directory for our test VASL extensions + self._vasl_extns_temp_dir = tempfile.TemporaryDirectory() + + def __del__( self ): + self._vasl_extns_temp_dir.cleanup() def __getattr__( self, name ): """Generic entry point for handling control requests.""" @@ -56,6 +62,8 @@ class ControlTests: def _remote_test_control( self, action, **kwargs ): """Invoke a handler function on the remote server.""" + if "bin_data" in kwargs: + kwargs["bin_data"] = base64.b64encode( kwargs["bin_data"] ) resp = urllib.request.urlopen( self.webapp.url_for( "control_tests", action=action, **kwargs ) ).read() @@ -117,29 +125,77 @@ class ControlTests: try: dname = pytest.config.option.vasl_mods #pylint: disable=no-member except AttributeError: - dname = app.config["TEST_VASL_MODS"] + dname = app.config[ "TEST_VASL_MODS" ] fspec = os.path.join( dname, "*.vmod" ) return glob.glob( fspec ) - def _set_vasl_mod( self, vmod=None ): + def _set_vasl_mod( self, vmod=None, extns_dtype=None ): """Install a VASL module.""" - if vmod is None: - _logger.info( "Installing VASL module: %s", vmod ) - webapp_files.vasl_mod = None - if "VASL_MOD" in app.config: - del app.config[ "VASL_MOD" ] + + # configure the VASL extensions + if extns_dtype: + if extns_dtype == "real": + try: + dname = pytest.config.option.vasl_extensions #pylint: disable=no-member + except AttributeError: + dname = app.config[ "TEST_VASL_EXTENSIONS_DIR" ] + elif extns_dtype == "test": + dname = self._vasl_extns_temp_dir.name + else: + assert False, "Unknown extensions directory type: "+extns_dtype + _logger.info( "Enabling VASL extensions: %s", dname ) + app.config[ "VASL_EXTENSIONS_DIR" ] = dname else: - fnames = self._do_get_vasl_mods() + _logger.info( "Disabling VASL extensions." ) + app.config.pop( "VASL_EXTENSIONS_DIR", None ) + + # configure the VASL module + if vmod: + vmod_fnames = self._do_get_vasl_mods() if vmod == "random": # NOTE: Some tests require a VASL module to be loaded, and since they should all # should behave in the same way, it doesn't matter which one we load. - fname = random.choice( fnames ) + vmod = random.choice( vmod_fnames ) else: - assert vmod in fnames - fname = vmod - _logger.info( "Installing VASL module: %s", fname ) - app.config[ "VASL_MOD" ] = fname - webapp_files.vasl_mod = VaslMod( fname, DATA_DIR ) + assert vmod in vmod_fnames + app.config[ "VASL_MOD" ] = vmod + else: + app.config.pop( "VASL_MOD", None ) + _logger.info( "Installing VASL module: %s", vmod ) + + # install the new VASL module + from vasl_templates.webapp.main import startup_msg_store + startup_msg_store.reset() + set_vasl_mod( vmod, startup_msg_store ) + + return self + + def _get_vasl_extns( self ): #pylint: disable=no-self-use + """Return the loaded VASL extensions.""" + from vasl_templates.webapp.file_server.vasl_mod import get_vasl_mod + extns = get_vasl_mod().get_extns() + _logger.debug( "Returning VASL extensions:\n%s", + "\n".join( "- {}".format( e ) for e in extns ) + ) + return extns + + def _set_test_vasl_extn( self, fname=None, bin_data=None ): + """Set the test VASL extension.""" + fname = os.path.join( self._vasl_extns_temp_dir.name, fname ) + with open( fname, "wb" ) as fp: + fp.write( bin_data ) + return self + + def _set_vasl_extn_info_dir( self, dtype=None ): + """Set the directory containing the VASL extension info files.""" + if dtype: + dname = os.path.join( os.path.split(__file__)[0], "fixtures/vasl-extensions" ) + dname = os.path.join( dname, dtype ) + _logger.info( "Setting the default VASL extension info directory: %s", dname ) + app.config[ "_VASL_EXTN_INFO_DIR_" ] = dname + else: + _logger.info( "Using the default VASL extension info directory." ) + app.config.pop( "_VASL_EXTN_INFO_DIR_", None ) return self def _get_vassal_engines( self ): @@ -155,7 +211,7 @@ class ControlTests: try: dname = pytest.config.option.vassal #pylint: disable=no-member except AttributeError: - dname = app.config[ "TEST_VASSAL_ENGINES"] + dname = app.config[ "TEST_VASSAL_ENGINES" ] vassal_engines = [] for root,_,fnames in os.walk( dname ): for fname in fnames: diff --git a/vasl_templates/webapp/tests/test_counters.py b/vasl_templates/webapp/tests/test_counters.py index 9b007a0..ac60f7f 100644 --- a/vasl_templates/webapp/tests/test_counters.py +++ b/vasl_templates/webapp/tests/test_counters.py @@ -32,7 +32,7 @@ def test_counter_images( webapp ): # NOTE: This is ridiculously slow on Windows :-/ # figure out which pieces we're interested in - gpids = get_vo_gpids( DATA_DIR ) + gpids = get_vo_gpids( DATA_DIR, None ) def check_images( check_front, check_back ): #pylint: disable=unused-argument """Check getting the front and back images for each counter.""" @@ -61,20 +61,20 @@ def test_counter_images( webapp ): # test each VASL module file in the specified directory fname = os.path.join( os.path.split(__file__)[0], "fixtures/vasl-pieces.txt" ) expected_vasl_pieces = open( fname, "r" ).read() - vasl_mods = control_tests.get_vasl_mods() - for vasl_mod in vasl_mods: + vmod_fnames = control_tests.get_vasl_mods() + for vmod_fname in vmod_fnames: # install the VASL module file - control_tests.set_vasl_mod( vmod=vasl_mod ) + control_tests.set_vasl_mod( vmod=vmod_fname ) # NOTE: We assume we have access to the same VASL modules as the server, but the path on the webserver # might be different to what it is locally, so we translate it here. - fname = os.path.split( vasl_mod )[1] + fname = os.path.split( vmod_fname )[1] vasl_mods_dir = pytest.config.option.vasl_mods #pylint: disable=no-member fname = os.path.join( vasl_mods_dir, fname ) # check the pieces loaded - vasl_mod = VaslMod( fname, DATA_DIR ) + vasl_mod = VaslMod( fname, DATA_DIR, None ) buf = io.StringIO() _dump_pieces( vasl_mod, buf ) assert buf.getvalue() == expected_vasl_pieces @@ -92,8 +92,10 @@ def _dump_pieces( vasl_mod, out ): # dump the VASL pieces results = [ [ "GPID", "Name", "Front images", "Back images"] ] - for gpid in sorted(vasl_mod.pieces.keys()): - piece = vasl_mod.pieces[ gpid ] + pieces = vasl_mod._pieces #pylint: disable=protected-access + gpids = sorted( pieces.keys(), key=int ) # nb: because GPID's changed from int to str :-/ + for gpid in gpids: + piece = pieces[ gpid ] assert piece["gpid"] == gpid results.append( [ gpid, piece["name"], piece["front_images"], piece["back_images"] ] ) print( tabulate.tabulate( results, headers="firstrow" ), file=out ) @@ -132,13 +134,13 @@ def test_gpid_remapping( webapp, webdriver ): else: assert check_gpid_image( gpid ) == 404 - def do_test( vasl_mod, valid_images ): + def do_test( vmod_fname, valid_images ): """Do the test.""" # initialize (using the specified VASL vmod) init_webapp( webapp, webdriver, scenario_persistence=1, reset = lambda ct: ct.set_data_dir( dtype="real" ) \ - .set_vasl_mod( vmod=vasl_mod ) + .set_vasl_mod( vmod=vmod_fname ) ) load_scenario( scenario_data ) # check that the German vehicles loaded correctly @@ -154,10 +156,10 @@ def test_gpid_remapping( webapp, webdriver ): scenario_data = json.load( open( fname, "r" ) ) # locate the VASL modules - vasl_mods = control_tests.get_vasl_mods() + vmod_fnames = control_tests.get_vasl_mods() def find_vasl_mod( version ): """Find the VASL module for the specified version.""" - matches = [ vmod for vmod in vasl_mods if "vasl-{}.vmod".format(version) in vmod ] + matches = [ fname for fname in vmod_fnames if "vasl-{}.vmod".format(version) in fname ] assert len(matches) == 1 return matches[0] diff --git a/vasl_templates/webapp/tests/test_vasl_extensions.py b/vasl_templates/webapp/tests/test_vasl_extensions.py new file mode 100644 index 0000000..f007343 --- /dev/null +++ b/vasl_templates/webapp/tests/test_vasl_extensions.py @@ -0,0 +1,193 @@ +""" Test VASL extensions. """ + +import os +import zipfile +import urllib +import json +import re +import typing + +import pytest +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.keys import Keys + +from vasl_templates.webapp.utils import TempFile +from vasl_templates.webapp.tests.utils import init_webapp, set_player, find_child, find_children, wait_for +from vasl_templates.webapp.tests.test_vehicles_ordnance import add_vo + +# --------------------------------------------------------------------- + +def test_load_vasl_extensions( webapp, webdriver ): + """Test loading VASL extensions.""" + + # initialize + control_tests = init_webapp( webapp, webdriver ) + + def do_test( build_info_fname, build_info, expected ): #pylint: disable=missing-docstring + + # create the test VASL extension + _set_test_vasl_extn( control_tests, build_info, build_info_fname ) + + # reload the webapp + control_tests.set_vasl_mod( vmod="random", extns_dtype="test" ) + webdriver.refresh() + _check_startup_messages( webapp, expected ) + + # try loading an extension that has no buildFile + do_test( "foo", "", "Missing buildFile:" ) + + # try loading extensions with missing information + do_test( "buildFile", '', + "Can't find ID for VASL extension:" + ) + do_test( "buildFile", '', + "Can't find version for VASL extension:" + ) + + # try loading an extension with an unknown ID + do_test( "buildFile", '', + re.compile( r'Not loading VASL extension "test\.zip".+No extension info file for unknown/v0\.1' ) + ) + + # try loading something that's not a ZIP file + control_tests.set_test_vasl_extn( fname="test.zip", bin_data=b"This is not a ZIP file." ) \ + .set_vasl_mod( vmod="random", extns_dtype="test" ) + webdriver.refresh() + _check_startup_messages( webapp, "Can't load VASL extension (not a ZIP file):" ) + +# --------------------------------------------------------------------- + +def test_vasl_extension_info( webapp, webdriver ): + """Test matching VASL extensions with our extension info files.""" + + # initialize + control_tests = init_webapp( webapp, webdriver ) + + # prepare our test VASL extension + fname = os.path.join( os.path.split(__file__)[0], "fixtures/vasl-extensions/test-extn.xml" ) + _set_test_vasl_extn( control_tests, open(fname,"r").read() ) + + def do_test( dtype, expected ): #pylint: disable=missing-docstring + control_tests.set_vasl_extn_info_dir( dtype=dtype ) \ + .set_vasl_mod( vmod="random", extns_dtype="test" ) + webdriver.refresh() + _check_startup_messages( webapp, expected ) + + # try loading the VASL extension, with no matching extension info + do_test( "mismatched-id", + re.compile( r'Not loading VASL extension.+No extension info file for test/v0\.1' ) + ) + do_test( "mismatched-version", + re.compile( r'Not loading VASL extension.+No extension info file for test/v0\.1' ) + ) + + # try loading the VASL extension, with matching extension info + do_test( "good-match", None ) + extns = control_tests.get_vasl_extns() + assert len(extns) == 1 + extn = extns[0] + assert extn[1] == { "extensionId": "test", "version": "v0.1" } + +# --------------------------------------------------------------------- + +@pytest.mark.skipif( + not pytest.config.option.vasl_extensions, #pylint: disable=no-member + reason = "--vasl-extensions not specified" +) +def test_kgs_extensions( webapp, webdriver ): + """Test the KGS extension.""" + + # initialize + control_tests = init_webapp( webapp, webdriver, + reset = lambda ct: ct.set_data_dir( dtype="real" ) + ) + + def check_counter_images( veh_name, expected ): + """Check the counter images available for the specified vehicle.""" + + # add the specified vehicle + add_vo( webdriver, "vehicles", 2, veh_name ) + + # edit the vehicle + vehicles_sortable = find_child( "#ob_vehicles-sortable_2" ) + elems = find_children( "li", vehicles_sortable ) + ActionChains(webdriver).double_click( elems[-1] ).perform() + dlg = find_child( ".ui-dialog.edit-vo" ) + + # check the currently-selected counter + image_url = find_child( "img.vasl-image", dlg ).get_attribute( "src" ) + if expected: + assert image_url.endswith( "/counter/{}/front".format( expected[0] ) ) + else: + assert image_url.endswith( "/missing-image.png" ) + + # check the available counters + btn = find_child( "input.select-vo-image", dlg ) + if expected and len(expected) > 1: + btn.click() + dlg2 = find_child( ".ui-dialog.select-vo-image" ) + image_urls = [ + elem.get_attribute( "src" ) + for elem in find_children( ".vo-images img", dlg2 ) + ] + assert len(image_urls) == len(expected) + for image_url,piece_id in zip( image_urls, expected ): + assert image_url.endswith( "/counter/{}/front/0".format(piece_id) ) + dlg2.send_keys( Keys.ESCAPE ) + else: + assert btn is None + dlg.send_keys( Keys.ESCAPE ) + + def do_test( enable_extns ): #pylint: disable=missing-docstring + + # initialize + control_tests.set_vasl_mod( vmod="random", + extns_dtype = "real" if enable_extns else None + ) + webdriver.refresh() + set_player( 2, "russian" ) + + # check the Matilda II(b) + check_counter_images( "Matilda II(b) (HT)", + ["f97:178","f97:184"] if enable_extns else None + ) + + # check the T60-M40 + check_counter_images( "T-60 M40 (Tt)", + ["547","f97:186"] if enable_extns else ["547"] + ) + + # do the tests + do_test( True ) + do_test( False ) + +# --------------------------------------------------------------------- + +def _set_test_vasl_extn( control_tests, build_info, build_info_fname="buildFile" ): + """Install a test VASL extension file.""" + with TempFile() as temp_file: + with zipfile.ZipFile( temp_file.name, "w" ) as zip_file: + zip_file.writestr( build_info_fname, build_info ) + temp_file.close() + with open( temp_file.name, "rb" ) as fp: + zip_data = fp.read() + control_tests.set_test_vasl_extn( fname="test.zip", bin_data=zip_data ) + +def _check_startup_messages( webapp, expected ): + """Check that the startup messages are what we expect.""" + + # wait for the startup messages to become available + wait_for( 2, lambda: find_child("#_startup-msgs-ready_") is not None ) + + # check the startup messages + url = webapp.url_for( "get_startup_msgs" ) + startup_msgs = json.load( urllib.request.urlopen( url ) ) + if expected: + assert list(startup_msgs.keys()) == [ "warning" ] + assert len(startup_msgs["warning"]) == 1 + if isinstance( expected, typing.re.Pattern ): + assert expected.search( startup_msgs["warning"][0] ) + else: + assert startup_msgs["warning"][0].startswith( expected ) + else: + assert not startup_msgs diff --git a/vasl_templates/webapp/tests/test_vehicles_ordnance.py b/vasl_templates/webapp/tests/test_vehicles_ordnance.py index 5af2032..a0bead6 100644 --- a/vasl_templates/webapp/tests/test_vehicles_ordnance.py +++ b/vasl_templates/webapp/tests/test_vehicles_ordnance.py @@ -580,10 +580,10 @@ def test_vo_images( webapp, webdriver ): #pylint: disable=too-many-statements load_scenario( saved_scenario ) check_sortable2_entries( 1, [ ( "/counter/2602/front", "ge/v:035", None ), - ( "/counter/2807/front/0", "ge/v:027", [2807,0] ) + ( "/counter/2807/front/0", "ge/v:027", ["2807",0] ) ] ) check_sortable2_entries( 2, [ - ( "/counter/1555/front/1", "br/v:115", [1555,1] ) + ( "/counter/1555/front/1", "br/v:115", ["1555",1] ) ] ) # --------------------------------------------------------------------- diff --git a/vasl_templates/webapp/tests/utils.py b/vasl_templates/webapp/tests/utils.py index a0d62cf..65a38db 100644 --- a/vasl_templates/webapp/tests/utils.py +++ b/vasl_templates/webapp/tests/utils.py @@ -55,7 +55,8 @@ def init_webapp( webapp, webdriver, **options ): .set_data_dir( dtype="test" ) \ .set_default_scenario( fname=None ) \ .set_default_template_pack( dname=None ) \ - .set_vasl_mod( vmod=None ) \ + .set_vasl_extn_info_dir( dtype=None ) \ + .set_vasl_mod( vmod=None, extns_dtype=None ) \ .set_vassal_engine( vengine=None ) \ .set_vo_notes_dir( dtype=None ) \ .set_user_files_dir( dtype=None ) diff --git a/vasl_templates/webapp/utils.py b/vasl_templates/webapp/utils.py index b721238..2af3e7f 100644 --- a/vasl_templates/webapp/utils.py +++ b/vasl_templates/webapp/utils.py @@ -3,6 +3,45 @@ import os import tempfile import pathlib +from collections import defaultdict + +# --------------------------------------------------------------------- + +class MsgStore: + """Store different types of messages.""" + + def __init__( self ): + self._msgs = None + self.reset() + + def reset( self ): + """Reset the MsgStore.""" + self._msgs = defaultdict( list ) + + def info( self, msg, *args, **kwargs ): + """Add an informational message.""" + self._add_msg( "info", msg, *args, **kwargs ) + + def warning( self, msg, *args, **kwargs ): + """Add a warning message.""" + self._add_msg( "warning", msg, *args, **kwargs ) + + def error( self, msg, *args, **kwargs ): + """Add an error message.""" + self._add_msg( "error", msg, *args, **kwargs ) + + def get_msgs( self, msg_type ): + """Get stored messages.""" + return self._msgs[ msg_type ] + + def _add_msg( self, msg_type, msg, *args, **kwargs ): + """Add a message to the store.""" + logger = kwargs.pop( "logger", None ) + msg = msg.format( *args, **kwargs ) + self._msgs[ msg_type ].append( msg ) + if logger: + func = getattr( logger, "warn" if msg_type == "warning" else msg_type ) + func( msg ) # --------------------------------------------------------------------- diff --git a/vasl_templates/webapp/vassal.py b/vasl_templates/webapp/vassal.py index e610f79..b0d400b 100644 --- a/vasl_templates/webapp/vassal.py +++ b/vasl_templates/webapp/vassal.py @@ -16,8 +16,7 @@ from flask import request from vasl_templates.webapp import app from vasl_templates.webapp.config.constants import BASE_DIR, IS_FROZEN -from vasl_templates.webapp.files import vasl_mod -from vasl_templates.webapp.file_server.vasl_mod import SUPPORTED_VASL_MOD_VERSIONS +from vasl_templates.webapp.file_server.vasl_mod import get_vasl_mod from vasl_templates.webapp.utils import TempFile, SimpleError from vasl_templates.webapp.webdriver import WebDriver @@ -227,11 +226,8 @@ class VassalShim: raise SimpleError( "Can't find the VASL boards: {}".format( self.boards_dir ) ) # locate the VASL module - self.vasl_mod = app.config.get( "VASL_MOD" ) - if not self.vasl_mod: + if not get_vasl_mod(): raise SimpleError( "The VASL module has not been configured." ) - if not os.path.isfile( self.vasl_mod ): - raise SimpleError( "Can't find VASL module: {}".format( self.vasl_mod ) ) # locate the VASSAL shim JAR self.shim_jar = app.config.get( "VASSAL_SHIM" ) @@ -283,7 +279,7 @@ class VassalShim: args[0] ] if args[0] in ("dump","update"): - args2.append( self.vasl_mod ) + args2.append( get_vasl_mod().filename ) args2.extend( args[1:] ) # figure out how long to the let the VASSAL shim run @@ -340,6 +336,17 @@ class VassalShim: raise VassalShimError( proc.returncode, stdout, stderr ) return stdout + @staticmethod + def check_vassal_version( msg_store ): + """Check the version of VASSAL.""" + if not app.config.get( "VASSAL_DIR" ) or not msg_store: + return + version = VassalShim().get_version() + if version not in SUPPORTED_VASSAL_VERSIONS: + msg_store.warning( + "VASSAL {} is unsupported.

Things might work, but they might not...".format( version ) + ) + # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - class VassalShimError( Exception ): @@ -350,25 +357,3 @@ class VassalShimError( Exception ): self.retcode = retcode self.stdout = stdout self.stderr = stderr - -# --------------------------------------------------------------------- - -@app.route( "/check-vassal-version" ) -def check_vassal_version(): - """Check if we're running a supported version of VASSAL.""" - vassal_dir = app.config.get( "VASSAL_DIR" ) - if vassal_dir: - vassal_shim = VassalShim() - version = vassal_shim.get_version() - if version not in SUPPORTED_VASSAL_VERSIONS: - return "VASSAL {} is unsupported.

Things might work, but they might not...".format( version ) - return "" - -# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -@app.route( "/check-vasl-version" ) -def check_vasl_version(): - """Check if we're running a supported version of VASL.""" - if vasl_mod and vasl_mod.vasl_version not in SUPPORTED_VASL_MOD_VERSIONS: - return "VASL {} is unsupported.

Things might work, but they might not...".format( vasl_mod.vasl_version ) - return "" diff --git a/vasl_templates/webapp/vo.py b/vasl_templates/webapp/vo.py index a1effd4..91e50df 100644 --- a/vasl_templates/webapp/vo.py +++ b/vasl_templates/webapp/vo.py @@ -2,11 +2,13 @@ import os import json +import logging from flask import request, render_template, jsonify, abort from vasl_templates.webapp import app from vasl_templates.webapp.config.constants import DATA_DIR +from vasl_templates.webapp.file_server.vasl_mod import get_vasl_mod # --------------------------------------------------------------------- @@ -22,7 +24,7 @@ def get_ordnance_listings(): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -def _do_get_listings( listings_type ): #pylint: disable=too-many-branches +def _do_get_listings( vo_type ): #pylint: disable=too-many-locals,too-many-branches """Load the vehicle/ordnance listings.""" # locate the data directory @@ -30,7 +32,7 @@ def _do_get_listings( listings_type ): #pylint: disable=too-many-branches dname = DATA_DIR # nb: always use the real data for reports, not the test fixtures else: dname = app.config.get( "DATA_DIR", DATA_DIR ) - dname = os.path.join( dname, listings_type ) + dname = os.path.join( dname, vo_type ) if not os.path.isdir( dname ): abort( 404 ) @@ -61,7 +63,7 @@ def _do_get_listings( listings_type ): #pylint: disable=too-many-branches listings[nat].extend( listings[minor_type+"-common"] ) del listings[ minor_type+"-common" ] # merge landing craft - if listings_type == "vehicles": + if vo_type == "vehicles": for lc in listings.get("landing-craft",[]): if lc["name"] in ("Daihatsu","Shohatsu"): listings["japanese"].append( lc ) @@ -69,8 +71,46 @@ def _do_get_listings( listings_type ): #pylint: disable=too-many-branches listings["american"].append( lc ) listings["british"].append( lc ) + # apply any changes for VASL extensions + vasl_mod = get_vasl_mod() + if vasl_mod: + # build an index of the pieces + piece_index = {} + for nat,pieces in listings.items(): + for piece in pieces: + piece_index[ piece["id"] ] = piece + # process each VASL extension + for extn in vasl_mod.get_extns(): + if vo_type not in extn[1]: + continue + _apply_extn_info( extn[0], extn[1], piece_index, vo_type ) + return jsonify( listings ) +def _apply_extn_info( extn_fname, extn_info, piece_index, vo_type ): + """Update the vehicle/ordnance listings for the specified VASL extension.""" + + # initialize + logger = logging.getLogger( "vasl_mod" ) + logger.info( "Updating %s for VASL extension: %s", vo_type, os.path.split(extn_fname)[1] ) + + # process each entry + for entry in extn_info[vo_type]: + piece = piece_index.get( entry["id"] ) + if piece: + # update an existing piece + logger.debug( "- Updating GPID's for %s: %s", entry["id"], entry["gpid"] ) + if piece["gpid"]: + prev_gpids = piece["gpid"] + if not isinstance( piece["gpid"], list ): + piece["gpid"] = [ piece["gpid"] ] + piece["gpid"].extend( entry["gpid"] ) + else: + prev_gpids = "(none)" + piece["gpid"] = entry["gpid"] + logger.debug( " - %s => %s", prev_gpids, piece["gpid"] ) + else: + logger.warning( "- Updating V/O entry with extension info not supported: %s", entry["id"] ) # --------------------------------------------------------------------- @app.route( "////", defaults={"month":1} ) diff --git a/vasl_templates/webapp/vo_notes.py b/vasl_templates/webapp/vo_notes.py index 929bbc4..d300fbc 100644 --- a/vasl_templates/webapp/vo_notes.py +++ b/vasl_templates/webapp/vo_notes.py @@ -120,6 +120,7 @@ def get_vo_note( vo_type, nat, key ): # locate the file with _vo_notes_lock: + # NOTE: We assume that the client has already loaded the vehicle/ordnance notes. if not _vo_notes_file_server: abort( 404 ) vo_notes = _do_get_vo_notes( vo_type )