diff --git a/vasl_templates/webapp/__init__.py b/vasl_templates/webapp/__init__.py index 374e115..517d93e 100644 --- a/vasl_templates/webapp/__init__.py +++ b/vasl_templates/webapp/__init__.py @@ -36,6 +36,11 @@ def _on_startup(): from vasl_templates.webapp.vo_notes import load_vo_notes #pylint: disable=cyclic-import load_vo_notes() + # initialize ROAR integration + from vasl_templates.webapp.roar import init_roar #pylint: disable=cyclic-import + from vasl_templates.webapp.main import startup_msg_store #pylint: disable=cyclic-import + init_roar( startup_msg_store ) + # --------------------------------------------------------------------- def _load_config( fname, section ): @@ -95,6 +100,7 @@ import vasl_templates.webapp.snippets #pylint: disable=cyclic-import import vasl_templates.webapp.files #pylint: disable=cyclic-import import vasl_templates.webapp.vassal #pylint: disable=cyclic-import import vasl_templates.webapp.vo_notes #pylint: disable=cyclic-import +import vasl_templates.webapp.roar #pylint: disable=cyclic-import if app.config.get( "ENABLE_REMOTE_TEST_CONTROL" ): print( "*** WARNING: Remote test control enabled! ***" ) import vasl_templates.webapp.testing #pylint: disable=cyclic-import diff --git a/vasl_templates/webapp/roar.py b/vasl_templates/webapp/roar.py new file mode 100644 index 0000000..e255c4a --- /dev/null +++ b/vasl_templates/webapp/roar.py @@ -0,0 +1,130 @@ +"""Provide integration with ROAR.""" +# Bodhgaya, India (APR/19) + +import os.path +import threading +import json +import time +import datetime +import tempfile +import logging +import urllib.request + +from flask import render_template, jsonify + +from vasl_templates.webapp import app + +_roar_scenario_index = {} +_roar_scenario_index_lock = threading.Lock() + +_logger = logging.getLogger( "roar" ) + +ROAR_SCENARIO_INDEX_URL = "http://vasl-templates.org/services/roar/scenario-index.json" +CACHE_TTL = 6 * 60*60 + +# --------------------------------------------------------------------- + +def init_roar( msg_store ): + """Initialize ROAR integration.""" + + # initialize + download = True + cache_fname = os.path.join( tempfile.gettempdir(), "vasl-templates.roar-scenario-index.json" ) + enable_cache = not app.config.get( "DISABLE_ROAR_SCENARIO_INDEX_CACHE" ) + if not enable_cache: + cache_fname = None + + # check if we have a cached copy of the scenario index + if enable_cache and os.path.isfile( cache_fname ): + # yup - load it, so that we have something until we finish downloading a fresh copy + _logger.info( "Loading cached ROAR scenario index: %s", cache_fname ) + with open( cache_fname, "r" ) as fp: + _load_roar_scenario_index( fp.read(), "cached", msg_store ) + # check if we should download a fresh copy + mtime = os.path.getmtime( cache_fname ) + age = int( time.time() - mtime ) + _logger.debug( "Cached scenario index age: %s (ttl=%s) (mtime=%s)", + datetime.timedelta(seconds=age), datetime.timedelta(seconds=CACHE_TTL), + time.strftime( "%Y-%m-%d %H:%M:%S", time.gmtime(mtime) ) + ) + if age < CACHE_TTL: + download = False + + # check if we should download the ROAR scenario index + if download: + if app.config.get("DISABLE_ROAR_SCENARIO_INDEX_DOWNLOAD"): + _logger.warning( "Downloading the ROAR scenario index has been disabled." ) + else: + # yup - make it so (nb: we do it in a background thread to avoid blocking the startup process) + # NOTE: This is the only place we do this, so if it fails, the program needs to be restarted to try again. + # This is not great, but we can live it (e.g. we will generally be using the cached copy). + threading.Thread( target = _download_roar_scenario_index, + args = ( cache_fname, msg_store ) + ).start() + +def _download_roar_scenario_index( save_fname, msg_store ): + """Download the ROAR scenario index.""" + + # download the ROAR scenario index + url = app.config.get( "ROAR_SCENARIO_INDEX_URL", "https://vasl-templates.org/services/roar/scenario-index.json" ) + _logger.info( "Downloading ROAR scenario index: %s", url ) + try: + fp = urllib.request.urlopen( url ) + data = fp.read().decode( "utf-8" ) + except Exception as ex: #pylint: disable=broad-except + # NOTE: We catch all exceptions, since we don't want an error here to stop us from running :-/ + error_msg = "Can't download ROAR scenario index: {}".format( getattr(ex,"reason",str(ex)) ) + _logger.warning( error_msg ) + if msg_store: + msg_store.warning( error_msg ) + return + if not _load_roar_scenario_index( data, "downloaded", msg_store ): + # NOTE: If we fail to load the scenario index (e.g. because of invalid JSON), we exit here + # and won't overwrite the cached copy of the file with the bad data. + return + + # save a copy of the data + if save_fname: + _logger.debug( "Saving a copy of the ROAR scenario index: %s", save_fname ) + with open( save_fname, "w" ) as fp: + fp.write( data ) + +def _load_roar_scenario_index( data, data_type, msg_store ): + """Load the ROAR scenario index.""" + + # load the ROAR scenario index + try: + scenario_index = json.loads( data ) + except Exception as ex: #pylint: disable=broad-except + # NOTE: We catch all exceptions, since we don't want an error here to stop us from running :-/ + error_msg = "Can't load {} ROAR scenario index: {}".format( data_type, ex ) + _logger.warning( error_msg ) + if msg_store: + msg_store.warning( error_msg ) + return False + _logger.debug( "Loaded %s ROAR scenario index OK: #scenarios=%d", data_type, len(scenario_index) ) + _logger.debug( "- Last updated: %s", scenario_index.get( "_lastUpdated_", "n/a" ) ) + _logger.debug( "- # playings: %s", str( scenario_index.get( "_nPlayings_", "n/a" ) ) ) + _logger.debug( "- Generated at: %s", scenario_index.get( "_generatedAt_", "n/a" ) ) + + # install the new ROAR scenario index + with _roar_scenario_index_lock: + global _roar_scenario_index + _roar_scenario_index = scenario_index + + return True + +# --------------------------------------------------------------------- + +@app.route( "/roar/scenario-index" ) +def get_roar_scenario_index(): + """Return the ROAR scenario index.""" + with _roar_scenario_index_lock: + return jsonify( _roar_scenario_index ) + +# --------------------------------------------------------------------- + +@app.route( "/roar/check" ) +def check_roar(): + """Check the ROAR data (for testing porpoises only).""" + return render_template( "check-roar.html" ) diff --git a/vasl_templates/webapp/static/css/select-roar-scenario-dialog.css b/vasl_templates/webapp/static/css/select-roar-scenario-dialog.css new file mode 100644 index 0000000..37faccc --- /dev/null +++ b/vasl_templates/webapp/static/css/select-roar-scenario-dialog.css @@ -0,0 +1,19 @@ +#select-roar-scenario { overflow: hidden ; } + +.ui-dialog.select-roar-scenario .ui-dialog-titlebar { background: #ffffcc ; border: 1px solid #e0e0cc ; } +.ui-dialog.select-roar-scenario .ui-dialog-content { padding-top: 0 !important ; } +.ui-dialog.select-roar-scenario .ui-dialog-buttonpane { border: none ; margin-top: 0 !important ; padding-top: 0 !important ; } + +#select-roar-scenario .header { height: 1.75em ; margin-top: 0.25em ; font-size: 80% ; } + +#select-roar-scenario .select2-selection { display: none ; } +#select-roar-scenario .select2-search { padding: 0 0 5px 0 ; } +#select-roar-scenario .select2-results { border: 1px solid #ccc ; } +#select-roar-scenario .select2-results__options { max-height: none ; } +#select-roar-scenario .select2-dropdown { border: none ; } + +#select-roar-scenario .select2-dropdown .scenario .scenario-id { font-size: 90% ; color: #666 ; } +#select-roar-scenario .select2-dropdown .scenario .publication { display: block ; font-size: 80% ; font-style: italic ; color: #888 ; } + +#select-roar-scenario .select2-results__option--highlighted[aria-selected] .scenario-id { color: #eee ; } +#select-roar-scenario .select2-results__option--highlighted[aria-selected] .publication { color: #eee ; } diff --git a/vasl_templates/webapp/static/css/tabs-scenario.css b/vasl_templates/webapp/static/css/tabs-scenario.css index c24c340..0202a20 100644 --- a/vasl_templates/webapp/static/css/tabs-scenario.css +++ b/vasl_templates/webapp/static/css/tabs-scenario.css @@ -5,6 +5,8 @@ #panel-scenario .row { display: flex ; align-items: center ; } #panel-scenario input { flex-grow: 1 ; } #panel-scenario input[name='SCENARIO_ID'] { margin-left: 0.25em ; width: 80px ; flex-grow: 0 ; } +#panel-scenario #search-roar { width: 25px ; height: 22px ; margin: 0 0 0 0.25em ; padding: 0 ; margin-top:-4px; } +#panel-scenario #search-roar img { margin-top: 2px ; width: 16px ; } #panel-scenario input[name='SCENARIO_DATE'] { width: 6em ; flex-grow: 0 ; } #panel-scenario label[for='PLAYER_1'] { margin-top: 2px ; } @@ -22,6 +24,28 @@ #panel-scenario .select2-container[name="SCENARIO_THEATER"] .select2-selection { height: 22px !important ; margin-top: -4px ;} #panel-scenario .select2-container[name="SCENARIO_THEATER"] .select2-selection__arrow { margin-top: -6px ; } +/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ + +#roar-info { position: relative ; margin-top: 0.75em ; border: 1px dotted #ccc ; border-radius: 3px ; padding: 0.5em ; background: #fcfcfc ; } +#roar-info .name { font-size: 90% ; } +#roar-info .count { font-size: 70% ; font-style: italic ; color: #666 ; } +#roar-info .progressbar { width: 4em ; height: 1em ; } +#roar-info .progressbar.player1 { background: #ffffcc ; border-top-right-radius: 0 ; border-bottom-right-radius: 0 ; border-right: 0 !important ; } +#roar-info .progressbar.player1 .ui-progressbar-value { background: #fff ; border-right: 1px solid #eee ; } +#roar-info .progressbar.player2 { background: #fff ; border-top-left-radius: 0 ; border-bottom-left-radius: 0 ; } +#roar-info .progressbar.player2 .ui-progressbar-value { background: #ffffcc ; border-right: 1px solid #eee ; } + +#roar-info .progressbar { position: relative ; } +#roar-info .progressbar .label { position: absolute ; font-size: 80% ; font-style: italic ; color: #666 ; left: 35% ; } + +input[name='ROAR_ID'] { flex-grow: 0 !important ; width: 3em ; } + +button#go-to-roar { height: 2em ; width: 4em ; margin-right: 0.5em ; padding: 2px 5px ; background: #ffffcc ; font-weight: bold ; } +button#disconnect-roar { background: #fcfcfc ; width: 20px ; height: 19px ; padding: 0 ; border: none ; + position: absolute ; right: -2px ; top: 0 ; +} +button#disconnect-roar img { width: 12px ; } + /* -------------------------------------------------------------------- */ #panel-vc { height: 100% ; display: flex ; flex-direction: column ; } diff --git a/vasl_templates/webapp/static/help/index.html b/vasl_templates/webapp/static/help/index.html index 316f3cc..f6e456d 100644 --- a/vasl_templates/webapp/static/help/index.html +++ b/vasl_templates/webapp/static/help/index.html @@ -136,6 +136,7 @@ The program will then not attempt to create the embedded browser, and will just

First, we enter the basic details about the scenario.

Click on one of the Snippet buttons, and the program will generate an HTML snippet and put it into your clipboard, which you can then copy into a VASL label. +

You can also click on the Search ROAR button, to look for a scenario in ROAR. The basic details for the scenario will be loaded, along with the latest results.

diff --git a/vasl_templates/webapp/static/images/cross.png b/vasl_templates/webapp/static/images/cross.png new file mode 100644 index 0000000..c1cd9a1 Binary files /dev/null and b/vasl_templates/webapp/static/images/cross.png differ diff --git a/vasl_templates/webapp/static/images/search.png b/vasl_templates/webapp/static/images/search.png new file mode 100644 index 0000000..26c38df Binary files /dev/null and b/vasl_templates/webapp/static/images/search.png differ diff --git a/vasl_templates/webapp/static/main.js b/vasl_templates/webapp/static/main.js index 5c94685..8e47ecd 100644 --- a/vasl_templates/webapp/static/main.js +++ b/vasl_templates/webapp/static/main.js @@ -103,6 +103,15 @@ $(document).ready( function () { var navHeight = $("#tabs .ui-tabs-nav").height() ; $("#tabs .ui-tabs-nav a").click( function() { $(this).blur() ; } ) ; + // initialize ROAR integration + $("#search-roar").button( {} ) + .html( "" ) + .click( search_roar ) ; + $("#go-to-roar").button( {} ).click( go_to_roar_scenario ) ; + $("#disconnect-roar").button( {} ) + .html( "" ) + .click( disconnect_roar ) ; + // initialize the scenario theater init_select2( $("select[name='SCENARIO_THEATER']"), "5em", false, null ) ; @@ -634,12 +643,7 @@ function on_player_change_with_confirm( player_no ) return ; // check if we should confirm this operation - var is_empty = true ; - $( "#tabs-ob" + player_no + " .sortable" ).each( function() { - if ( $(this).children( "li" ).length > 0 ) - is_empty = false ; - } ) ; - if ( is_empty ) { + if ( is_player_ob_empty( player_no ) ) { // nope - just do it on_player_change( player_no ) ; } else { @@ -654,6 +658,17 @@ function on_player_change_with_confirm( player_no ) } } +function is_player_ob_empty( player_no ) +{ + // check if the specified player's OB is empty + var is_empty = true ; + $( "#tabs-ob" + player_no + " .sortable" ).each( function() { + if ( $(this).children( "li" ).length > 0 ) + is_empty = false ; + } ) ; + return is_empty ; +} + function on_player_change( player_no ) { // update the tab label @@ -689,6 +704,9 @@ function on_player_change( player_no ) $("input[name='OB_VEHICLES_WIDTH_"+player_no+"']").val( "" ) ; $( "#ob_ordnance-sortable_" + player_no ).sortable2( "delete-all" ) ; $("input[name='OB_ORDNANCE_WIDTH_"+player_no+"']").val( "" ) ; + + // update the ROAR info panel + set_roar_scenario( $("input[name='ROAR_ID']").val() ) ; } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vasl_templates/webapp/static/roar.js b/vasl_templates/webapp/static/roar.js new file mode 100644 index 0000000..70f0e55 --- /dev/null +++ b/vasl_templates/webapp/static/roar.js @@ -0,0 +1,310 @@ +gRoarScenarioIndex = null ; + +// -------------------------------------------------------------------- + +function _get_roar_scenario_index( on_ready ) +{ + // check if we already have the ROAR scenario index + if ( gRoarScenarioIndex && Object.keys(gRoarScenarioIndex).length > 0 ) { + // yup - just do it + on_ready() ; + } else { + // nope - download it (nb: we do this on-demand, instead of during startup, + // to give the backend time if it wants to download a fresh copy). + // NOTE: We will also get here if we downloaded the scenario index, but it's empty. + // This can happen if the cached file is not there, and the server is still downloading + // a fresh copy, in which case, we will keep retrying until we get something. + $.getJSON( gGetRoarScenarioIndexUrl, function(data) { + gRoarScenarioIndex = data ; + on_ready() ; + } ).fail( function( xhr, status, errorMsg ) { + showErrorMsg( "Can't get the ROAR scenario index:

" + escapeHTML(errorMsg) + "
" ) ; + } ) ; + } +} + +// -------------------------------------------------------------------- + +function search_roar() +{ + var unknown_nats = [] ; + function on_scenario_selected( roar_id ) { + // update the UI for the selected ROAR scenario + set_roar_scenario( roar_id ) ; + // populate the scenario name/ID + var scenario = gRoarScenarioIndex[ roar_id ] ; + if ( $("input[name='SCENARIO_NAME']").val() === "" && $("input[name='SCENARIO_ID']").val() === "" ) { + $("input[name='SCENARIO_NAME']").val( scenario.name ) ; + $("input[name='SCENARIO_ID']").val( scenario.scenario_id ) ; + } + // update the player nationalities + // NOTE: The player order as returned by ROAR is undetermined (and could change from call to call), + // so what we set here might not match what's in the scenario card, but we've got a 50-50 chance of being right... :-/ + update_player( scenario, 1 ) ; + update_player( scenario, 2 ) ; + if ( unknown_nats.length > 0 ) { + var buf = [ "Unrecognized nationality in ROAR:", "" ) ; + showWarningMsg( buf.join("") ) ; + } + } + + function update_player( scenario, player_no ) { + var roar_nat = scenario.results[ player_no-1 ][0] ; + var nat = convert_roar_nat( roar_nat ) ; + if ( ! nat ) { + unknown_nats.push( roar_nat ) ; + return ; + } + if ( nat === get_player_nat( player_no ) ) + return ; + if ( ! is_player_ob_empty( player_no ) ) + return ; + $( "select[name='PLAYER_" + player_no + "']" ).val( nat ).trigger( "change" ) ; + on_player_change( player_no ) ; + } + + // ask the user to select a ROAR scenario + _get_roar_scenario_index( function() { + do_search_roar( on_scenario_selected ) ; + } ) ; +} + +function do_search_roar( on_ok ) +{ + // initialize the select2 + var $sel = $( "#select-roar-scenario select" ) ; + $sel.select2( { + width: "100%", + templateResult: function( opt ) { return opt.id ? _format_entry(opt.id) : opt.text ; }, + dropdownParent: $("#select-roar-scenario"), // FUDGE! need this for the searchbox to work :-/ + closeOnSelect: false, + } ) ; + + // stop the select2 droplist from closing up + $sel.on( "select2:closing", function(evt) { + evt.preventDefault() ; + } ) ; + + // let the user select a scenario + function on_resize( $dlg ) { + $( ".select2-results ul" ).height( $dlg.height() - 50 ) ; + } + var $dlg = $("#select-roar-scenario").dialog( { + title: "Search ROAR", + dialogClass: "select-roar-scenario", + modal: true, + minWidth: 400, + minHeight: 350, + create: function() { + // initialize the dialog + init_dialog( $(this), "OK", false ) ; + // handle ENTER and double-click + function auto_select_scenario( evt ) { + if ( $sel.val() ) { + $( ".ui-dialog.select-roar-scenario button:contains('OK')" ).click() ; + evt.preventDefault() ; + } + } + $("#select-roar-scenario").keydown( function(evt) { + if ( evt.keyCode == $.ui.keyCode.ENTER ) + auto_select_scenario( evt ) ; + else if ( evt.keyCode == $.ui.keyCode.ESCAPE ) + $(this).dialog( "close" ) ; + } ).dblclick( function(evt) { + auto_select_scenario( evt ) ; + } ) ; + }, + open: function() { + // initialize + // NOTE: We do this herem instead of in the "create" handler, to handle the case + // where the scenario index was initially unavailable but the download has since completed. + _load_select2( $sel ) ; + on_dialog_open( $(this) ) ; + $sel.select2( "open" ) ; + // update the UI + on_resize( $(this) ) ; + }, + resize: function() { on_resize( $(this) ) ; }, + buttons: { + OK: function() { + // notify the caller about the selected scenario + var roar_id = $sel.select2("data")[0].id ; + on_ok( roar_id ) ; + $dlg.dialog( "close" ) ; + }, + Cancel: function() { $(this).dialog( "close" ) ; }, + }, + } ) ; +} + +function _load_select2( $sel ) +{ + function remove_quotes( lquote, rquote ) { + var len = name.length ; + if ( name.substr( 0, lquote.length ) === lquote && name.substr( len-rquote.length ) === rquote ) + name = name.substr( lquote.length, len-lquote.length-rquote.length ) ; + if ( name.substr( 0, lquote.length ) == lquote ) + name = name.substr( lquote.length ) ; + return name ; + } + + // sort the scenarios + var roar_ids=[], roar_id, scenario ; + for ( roar_id in gRoarScenarioIndex ) { + if ( roar_id[0] === "_" ) + continue ; + roar_ids.push( roar_id ) ; + scenario = gRoarScenarioIndex[ roar_id ] ; + var name = scenario.name ; + name = remove_quotes( '"', '"' ) ; + name = remove_quotes( "'", "'" ) ; + name = remove_quotes( """, """ ) ; + name = remove_quotes( "\u2018", "\u2019" ) ; + name = remove_quotes( "\u201c", "\u201d" ) ; + if ( name.substring(0,3) === "..." ) + name = name.substr( 3 ) ; + scenario._sort_name = name.trim().toUpperCase() ; + } + roar_ids.sort( function( lhs, rhs ) { + lhs = gRoarScenarioIndex[ lhs ]._sort_name ; + rhs = gRoarScenarioIndex[ rhs ]._sort_name ; + if ( lhs < rhs ) + return -1 ; + else if ( lhs > rhs ) + return +1 ; + return 0 ; + } ) ; + + // get the currently-active ROAR scenario + var curr_roar_id = $("input[name='ROAR_ID']").val() ; + + // load the select2 + var buf = [] ; + for ( var i=0 ; i < roar_ids.length ; ++i ) { + roar_id = roar_ids[ i ] ; + scenario = gRoarScenarioIndex[ roar_id ] ; + // NOTE: The " + ) ; + } + $sel.html( buf.join("") ) ; +} + +function _format_entry( roar_id ) { + // generate the HTML for a scenario + var scenario = gRoarScenarioIndex[ roar_id ] ; + var buf = [ "
", + scenario.name, + " [", strReplaceAll(scenario.scenario_id," "," "), "]", + " ", scenario.publication, "", + "
" + ] ; + return $( buf.join("") ) ; +} + +// -------------------------------------------------------------------- + +function disconnect_roar() +{ + // disconnect from the ROAR scenario + set_roar_scenario( null ) ; +} + +// -------------------------------------------------------------------- + +function go_to_roar_scenario() +{ + // go the currently-active ROAR scenario + var roar_id = $( "input[name='ROAR_ID']" ).val() ; + var url = gRoarScenarioIndex[ roar_id ].url ; + if ( gWebChannelHandler ) + window.location = url ; // nb: AppWebPage will intercept this and launch a new browser window + else + window.open( url ) ; +} + +// -------------------------------------------------------------------- + +function set_roar_scenario( roar_id ) +{ + var total_playings ; + function safe_score( nplayings ) { return total_playings === 0 ? 0 : nplayings / total_playings ; } + function get_label( score ) { return total_playings === 0 ? "" : percentString( score ) ; } + + function do_set_roar_scenaro() { + if ( roar_id ) { + // save the ROAR ID + $( "input[name='ROAR_ID']" ).val( roar_id ) ; + // update the progress bars + var scenario = gRoarScenarioIndex[ roar_id ] ; + if ( ! scenario ) + return ; + var results = scenario.results ; + if ( convert_roar_nat(results[0][0]) === get_player_nat(2) || convert_roar_nat(results[1][0]) === get_player_nat(1) ) { + // FUDGE! The order of players returned by ROAR is indeterminate (and could change from call to call), + // so we try to show the results in the way that best matches what's on-screen. + results = [ results[1], results[0] ] ; + } + total_playings = results[0][1] + results[1][1] ; + $( "#roar-info .name.player1" ).html( results[0][0] ) ; + $( "#roar-info .count.player1" ).html( "(" + results[0][1] + ")" ) ; + var score = 100 * safe_score( results[0][1] ) ; + $( "#roar-info .progressbar.player1" ).progressbar( { value: 100-score } ) + .find( ".label" ).text( get_label( score ) ) ; + $( "#roar-info .name.player2" ).html( results[1][0] ) ; + $( "#roar-info .count.player2" ).html( "(" + results[1][1] + ")" ) ; + score = 100 * safe_score( results[1][1] ) ; + $( "#roar-info .progressbar.player2" ).progressbar( { value: score } ) + .find( ".label" ).text( get_label( score ) ) ; + // show the ROAR scenario details + $( "#go-to-roar" ).attr( "title", scenario.name+" ["+scenario.scenario_id+"]\n" + scenario.publication ) ; + // NOTE: We see the fade in if the panel is already visible and we load a scenario that has a ROAR ID, + // because we reset the scenario the scenario before loading another one, which causes the panel + // to be hidden. Fixing this is more trouble than it's worth... :-/ + $( "#roar-info" ).fadeIn( 1*1000 ) ; + } else { + // there is no associated ROAR scenario - hide the info panel + $( "input[name='ROAR_ID']" ).val( "" ) ; + $( "#roar-info" ).hide() ; + } + } + + // set the ROAR scenario + _get_roar_scenario_index( do_set_roar_scenaro ) ; +} + +// -------------------------------------------------------------------- + +function convert_roar_nat( roar_nat ) +{ + // clean up the ROAR nationality + roar_nat = roar_nat.toUpperCase() ; + var pos = roar_nat.indexOf( "/" ) ; + if ( pos > 0 ) + roar_nat = roar_nat.substr( 0, pos ) ; // e.g. "British/Partisan" -> "British" + else { + var match = roar_nat.match( /\(.*\)$/ ) ; + if ( match ) + roar_nat = roar_nat.substr( 0, roar_nat.length-match[0].length ).trim() ; // e.g. "Thai (Chinese)" -> "Thai" + } + + // try to match the ROAR nationality with one of ours + for ( var nat in gTemplatePack.nationalities ) { + if ( roar_nat === gTemplatePack.nationalities[nat].display_name.toUpperCase() ) + return nat ; + } + + return null ; +} diff --git a/vasl_templates/webapp/static/snippets.js b/vasl_templates/webapp/static/snippets.js index 0787651..159c1a6 100644 --- a/vasl_templates/webapp/static/snippets.js +++ b/vasl_templates/webapp/static/snippets.js @@ -1294,8 +1294,11 @@ function do_load_scenario_data( params ) warnings.push( "Invalid scenario date: " + escapeHTML( params[key] ) ) ; } } - else + else { $elem.val( params[key] ) ; + if ( key === "ROAR_ID" ) + set_roar_scenario( params[key] ) ; + } if ( $elem[0].nodeName.toLowerCase() === "select" ) $elem.trigger( "change" ) ; params_loaded[key] = true ; @@ -1387,6 +1390,8 @@ function do_load_scenario_data( params ) set_param( $(this), key ).trigger( "change" ) ; } ) ; } + if ( ! params.ROAR_ID ) + set_roar_scenario( null ) ; // look for unrecognized keys var buf = [] ; diff --git a/vasl_templates/webapp/static/utils.js b/vasl_templates/webapp/static/utils.js index 309a3b7..f6b3e51 100644 --- a/vasl_templates/webapp/static/utils.js +++ b/vasl_templates/webapp/static/utils.js @@ -365,6 +365,16 @@ function pluralString( n, str1, str2 ) return (n == 1) ? str1 : str2 ; } +function percentString( val ) +{ + val = Math.round( val ) ; + if ( val < 0 ) + val = 0 ; + else if ( val > 100 ) + val = 100 ; + return val + "%" ; +} + function strReplaceAll( val, searchFor, replaceWith ) { // str.replace() only replaces a single instance!?!? :wtf: diff --git a/vasl_templates/webapp/templates/check-roar.html b/vasl_templates/webapp/templates/check-roar.html new file mode 100644 index 0000000..7e29dc9 --- /dev/null +++ b/vasl_templates/webapp/templates/check-roar.html @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/vasl_templates/webapp/templates/index.html b/vasl_templates/webapp/templates/index.html index 9954c15..05f199c 100644 --- a/vasl_templates/webapp/templates/index.html +++ b/vasl_templates/webapp/templates/index.html @@ -22,6 +22,7 @@ + @@ -63,6 +64,7 @@ {%include "select-vo-dialog.html"%} {%include "select-vo-image-dialog.html"%} {%include "edit-vo-dialog.html"%} +{%include "select-roar-scenario-dialog.html"%} {%include "vassal.html"%} {%include "snippets.html"%} @@ -98,6 +100,7 @@ gOrdnanceListingsUrl = "{{url_for('get_ordnance_listings',merge_common=1)}}" ; gVehicleNotesUrl = "{{url_for('get_vehicle_notes')}}" ; gOrdnanceNotesUrl = "{{url_for('get_ordnance_notes')}}" ; gGetVaslPieceInfoUrl = "{{url_for('get_vasl_piece_info')}}" ; +gGetRoarScenarioIndexUrl = "{{url_for('get_roar_scenario_index')}}" ; gUpdateVsavUrl = "{{url_for('update_vsav')}}" ; gMakeSnippetImageUrl = "{{url_for('make_snippet_image')}}" ; gHelpUrl = "{{url_for('show_help')}}" ; @@ -110,6 +113,7 @@ gHelpUrl = "{{url_for('show_help')}}" ; + diff --git a/vasl_templates/webapp/templates/select-roar-scenario-dialog.html b/vasl_templates/webapp/templates/select-roar-scenario-dialog.html new file mode 100644 index 0000000..e8d3843 --- /dev/null +++ b/vasl_templates/webapp/templates/select-roar-scenario-dialog.html @@ -0,0 +1,3 @@ + diff --git a/vasl_templates/webapp/templates/tabs-scenario.html b/vasl_templates/webapp/templates/tabs-scenario.html index 7039fcd..bed54d6 100644 --- a/vasl_templates/webapp/templates/tabs-scenario.html +++ b/vasl_templates/webapp/templates/tabs-scenario.html @@ -4,12 +4,13 @@ diff --git a/vasl_templates/webapp/tests/fixtures/roar-scenario-index.json b/vasl_templates/webapp/tests/fixtures/roar-scenario-index.json new file mode 100644 index 0000000..34be9d0 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/roar-scenario-index.json @@ -0,0 +1,29 @@ +{ + +"1": { "scenario_id": "TEST 1", "name": "Fighting Withdrawal", + "publication": "Beyond Valor", + "results": [ [ "Finnish", 200 ], [ "Russian", 300 ] ], + "url": "http://test.com/1" +}, + +"2": { "scenario_id": "TEST 2", "name": "Whitewash 1", + "results": [ [ "American", 10 ], [ "Japanese", 0 ] ], + "url": "http://test.com/2" +}, + +"3": { "scenario_id": "TEST 3", "name": "Whitewash 2", + "results": [ [ "American", 0 ], [ "Japanese", 10 ] ], + "url": "http://test.com/3" +}, + +"4": { "scenario_id": "TEST 4", "name": "No playings", + "results": [ [ "British", 0 ], [ "French", 0 ] ], + "url": "http://test.com/4" +}, + +"5": { "scenario_id": "TEST 5", "name": "Unknown nationality", + "results": [ [ "American", 1 ], [ "Martian", 1 ] ], + "url": "http://test.com/5" +} + +} diff --git a/vasl_templates/webapp/tests/remote.py b/vasl_templates/webapp/tests/remote.py index 9087097..3eae5c7 100644 --- a/vasl_templates/webapp/tests/remote.py +++ b/vasl_templates/webapp/tests/remote.py @@ -22,6 +22,7 @@ from vasl_templates.webapp.config.constants import DATA_DIR from vasl_templates.webapp.vasl_mod import set_vasl_mod from vasl_templates.webapp import main as webapp_main from vasl_templates.webapp import snippets as webapp_snippets +from vasl_templates.webapp import roar as webapp_roar from vasl_templates.webapp import vasl_mod as vasl_mod_module _logger = logging.getLogger( "control_tests" ) @@ -287,3 +288,16 @@ class ControlTests: _logger.info( "Reseting the default template pack." ) globvars.template_pack = None return self + + def _set_roar_scenario_index( self, fname=None ): + """Set the ROAR scenario index file.""" + if fname: + dname = os.path.join( os.path.split(__file__)[0], "fixtures" ) + fname = os.path.join( dname, fname ) + _logger.info( "Setting the ROAR scenario index file: %s", fname ) + with open( fname, "r" ) as fp: + with webapp_roar._roar_scenario_index_lock: #pylint: disable=protected-access + webapp_roar._roar_scenario_index = json.load( fp ) #pylint: disable=protected-access + else: + assert False + return self diff --git a/vasl_templates/webapp/tests/test_dirty_scenario_checks.py b/vasl_templates/webapp/tests/test_dirty_scenario_checks.py index 3d36f4a..e90623d 100644 --- a/vasl_templates/webapp/tests/test_dirty_scenario_checks.py +++ b/vasl_templates/webapp/tests/test_dirty_scenario_checks.py @@ -57,9 +57,12 @@ def test_dirty_scenario_checks( webapp, webdriver ): ] if e ) if target.tag_name in ("input","textarea"): prev_val = target.get_attribute( "value" ) - target.clear() new_val = "01/01/2000" if param == "SCENARIO_DATE" else "changed value" - target.send_keys( new_val ) + if target.is_displayed(): + target.clear() + target.send_keys( new_val ) + else: + webdriver.execute_script( "arguments[0].value = arguments[1]", target, new_val ) return target, prev_val, new_val elif target.tag_name == "select": sel = Select( target ) @@ -90,8 +93,11 @@ def test_dirty_scenario_checks( webapp, webdriver ): elif param in VEHICLE_ORDNANCE: drag_sortable_entry_to_trash( state, 0 ) elif state[0].tag_name in ("input","textarea"): - state[0].clear() - state[0].send_keys( state[1] ) + if state[0].is_displayed(): + state[0].clear() + state[0].send_keys( state[1] ) + else: + webdriver.execute_script( "arguments[0].value = arguments[1]", state[0], state[1] ) elif state[0].tag_name == "select": select_droplist_val( Select(state[0]), state[1] ) else: diff --git a/vasl_templates/webapp/tests/test_roar.py b/vasl_templates/webapp/tests/test_roar.py new file mode 100644 index 0000000..1be2f4e --- /dev/null +++ b/vasl_templates/webapp/tests/test_roar.py @@ -0,0 +1,148 @@ +"""Test ROAR integration.""" + +import re + +from selenium.webdriver.support.ui import Select +from selenium.webdriver.common.keys import Keys + +from vasl_templates.webapp.tests.utils import init_webapp, select_tab, select_menu_option, click_dialog_button, \ + set_stored_msg_marker, get_stored_msg, set_template_params, add_simple_note, \ + find_child, find_children, wait_for_elem + +# --------------------------------------------------------------------- + +def test_roar( webapp, webdriver ): + """Test ROAR integration.""" + + # initialize + init_webapp( webapp, webdriver ) + + # check the ROAR info panel + _check_roar_info( webdriver, None ) + + # select a ROAR scenario + _select_roar_scenario( "fighting withdrawal" ) + _check_roar_info( webdriver, ( + ( "Fighting Withdrawal", "TEST 1" ), + ( "Finnish", 200, "Russian", 300 ), + ( 40, 60 ) + ) ) + + # select some other ROAR scenarios + # NOTE: The scenario name/ID are already populated, so they don't get updated with the new details. + _select_roar_scenario( "whitewash 1" ) + _check_roar_info( webdriver, ( + ( "Fighting Withdrawal", "TEST 1" ), + ( "American", 10, "Japanese", 0 ), + ( 100, 0 ) + ) ) + _select_roar_scenario( "whitewash 2" ) + _check_roar_info( webdriver, ( + ( "Fighting Withdrawal", "TEST 1" ), + ( "American", 0, "Japanese", 10 ), + ( 0, 100 ) + ) ) + + # unlink from the ROAR scenario + btn = find_child( "#disconnect-roar" ) + btn.click() + _check_roar_info( webdriver, None ) + + # select another ROAR scenario (that has no playings) + set_template_params( { "SCENARIO_NAME": "", "SCENARIO_ID": "" } ) + _select_roar_scenario( "no playings" ) + _check_roar_info( webdriver, ( + ( "No playings", "TEST 4" ), + ( "British", 0, "French", 0 ), + None + ) ) + +# --------------------------------------------------------------------- + +def test_setting_players( webapp, webdriver ): + """Test setting players after selecting a ROAR scenario.""" + + # initialize + init_webapp( webapp, webdriver ) + + # select a ROAR scenario + _select_roar_scenario( "fighting withdrawal" ) + _check_players( "finnish", "russian" ) + + # add something to the Player 1 OB + select_tab( "ob1" ) + add_simple_note( find_child("#ob_setups-sortable_1"), "a setup note", None ) + + # select another ROAR scenario + select_tab( "scenario" ) + _select_roar_scenario( "whitewash 1" ) + _check_players( "finnish", "japanese" ) # nb: player 1 remains unchanged + + # add something to the Player 2 OB + select_tab( "ob2" ) + add_simple_note( find_child("#ob_setups-sortable_2"), "another setup note", None ) + + # select another ROAR scenario + select_tab( "scenario" ) + _select_roar_scenario( "no playings" ) + _check_players( "finnish", "japanese" ) # nb: both players remain unchanged + + # reset the scenario and select a ROAR scenario with an unknown nationality + select_menu_option( "new_scenario" ) + click_dialog_button( "OK" ) # nb: dismiss the "discard changes?" prompt + _ = set_stored_msg_marker( "_last-warning_" ) + _select_roar_scenario( "unknown nationality" ) + _check_players( "american", "russian" ) + last_warning = get_stored_msg( "_last-warning_" ) + assert re.search( r"Unrecognized nationality.+\bMartian\b", last_warning ) + +# --------------------------------------------------------------------- + +def _select_roar_scenario( scenario_name ): + """Select a ROAR scenario.""" + btn = find_child( "#search-roar" ) + btn.click() + dlg = wait_for_elem( 2, ".ui-dialog.select-roar-scenario" ) + search_field = find_child( "input", dlg ) + search_field.send_keys( scenario_name ) + elems = find_children( ".select2-results li", dlg ) + assert len(elems) == 1 + search_field.send_keys( Keys.RETURN ) + +def _check_roar_info( webdriver, expected ): + """Check the state of the ROAR info panel.""" + + # check if the panel is displayed or hidden + panel = find_child( "#roar-info" ) + if not expected: + assert not panel.is_displayed() + return + assert panel.is_displayed() + + # check the displayed information + assert find_child( ".name.player1", panel ).text == expected[1][0] + assert find_child( ".count.player1", panel ).text == "({})".format( expected[1][1] ) + assert find_child( ".name.player2", panel ).text == expected[1][2] + assert find_child( ".count.player2", panel ).text == "({})".format( expected[1][3] ) + + # check the progress bars + progress1 = find_child( ".progressbar.player1", panel ) + progress2 = find_child( ".progressbar.player2", panel ) + if expected[2]: + label1 = "{}%".format( expected[2][0] ) + label2 = "{}%".format( expected[2][1] ) + expected1, expected2 = 100-expected[2][0], expected[2][1] + else: + label1 = label2 = "" + expected1, expected2 = 100, 0 + assert find_child( ".label", progress1 ).text == label1 + assert webdriver.execute_script( "return $(arguments[0]).progressbar('value')", progress1 ) == expected1 + assert find_child( ".label", progress2 ).text == label2 + assert webdriver.execute_script( "return $(arguments[0]).progressbar('value')", progress2 ) == expected2 + +def _check_players( expected1, expected2 ): + """Check the selected players.""" + sel = Select( find_child( "select[name='PLAYER_1']" ) ) + assert sel.first_selected_option.get_attribute("value") == expected1 + sel = Select( find_child( "select[name='PLAYER_2']" ) ) + assert sel.first_selected_option.get_attribute("value") == expected2 diff --git a/vasl_templates/webapp/tests/test_scenario_persistence.py b/vasl_templates/webapp/tests/test_scenario_persistence.py index 928615b..502b0b2 100644 --- a/vasl_templates/webapp/tests/test_scenario_persistence.py +++ b/vasl_templates/webapp/tests/test_scenario_persistence.py @@ -18,6 +18,7 @@ ALL_SCENARIO_PARAMS = { "SCENARIO_NAME", "SCENARIO_ID", "SCENARIO_LOCATION", "SCENARIO_THEATER", "SCENARIO_DATE", "SCENARIO_WIDTH", + "ROAR_ID", "PLAYER_1", "PLAYER_1_ELR", "PLAYER_1_SAN", "PLAYER_2", "PLAYER_2_ELR", "PLAYER_2_SAN", "VICTORY_CONDITIONS", "VICTORY_CONDITIONS_WIDTH", @@ -70,6 +71,7 @@ def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-st "SCENARIO_THEATER": "PTO", "SCENARIO_DATE": "12/31/1945", "SCENARIO_WIDTH": "101", + "ROAR_ID": "", "PLAYER_1": "russian", "PLAYER_1_ELR": "1", "PLAYER_1_SAN": "2", "PLAYER_2": "german", "PLAYER_2_ELR": "3", "PLAYER_2_SAN": "4", "VICTORY_CONDITIONS": "just do it!", "VICTORY_CONDITIONS_WIDTH": "102", diff --git a/vasl_templates/webapp/tests/test_vassal.py b/vasl_templates/webapp/tests/test_vassal.py index 2973f4a..9ba800c 100644 --- a/vasl_templates/webapp/tests/test_vassal.py +++ b/vasl_templates/webapp/tests/test_vassal.py @@ -52,6 +52,7 @@ def test_full_update( webapp, webdriver ): "SCENARIO_THEATER": "PTO", "SCENARIO_DATE": "12/31/1945", "SCENARIO_WIDTH": "101", + "ROAR_ID": "", "PLAYER_1": "russian", "PLAYER_1_ELR": "5", "PLAYER_1_SAN": "4", "PLAYER_2": "german", "PLAYER_2_ELR": "3", "PLAYER_2_SAN": "2", "VICTORY_CONDITIONS": "Just do it!", "VICTORY_CONDITIONS_WIDTH": "102", diff --git a/vasl_templates/webapp/tests/utils.py b/vasl_templates/webapp/tests/utils.py index f9e6e9b..1bc2b07 100644 --- a/vasl_templates/webapp/tests/utils.py +++ b/vasl_templates/webapp/tests/utils.py @@ -65,7 +65,8 @@ def init_webapp( webapp, webdriver, **options ): .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 ) + .set_user_files_dir( dtype=None ) \ + .set_roar_scenario_index( fname="roar-scenario-index.json" ) if "reset" in options: options.pop( "reset" )( control_tests ) @@ -260,13 +261,20 @@ def set_template_params( params ): #pylint: disable=too-many-branches if elem.tag_name == "select": select_droplist_val( Select(elem), val ) else: - elem.clear() - if val: - elem.send_keys( val ) - if key == "SCENARIO_DATE": - elem.send_keys( Keys.TAB ) # nb: force the calendar popup to close :-/ - wait_for( 5, lambda: find_child("#ui-datepicker-div").value_of_css_property("display") == "none" ) - time.sleep( 0.25 ) + if elem.is_displayed(): + elem.clear() + if val: + elem.send_keys( val ) + if key == "SCENARIO_DATE": + elem.send_keys( Keys.TAB ) # nb: force the calendar popup to close :-/ + wait_for( 5, + lambda: find_child( "#ui-datepicker-div" ).value_of_css_property( "display" ) == "none" + ) + time.sleep( 0.25 ) + else: + # FUDGE! Selenium can't interact with hidden elements, so we do it like this. + # However, we don't do this for everything since it doesn't always triggers events. + _webdriver.execute_script( "arguments[0].value = arguments[1]", elem, val ) # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -