diff --git a/vasl_templates/webapp/file_server/vasl_mod.py b/vasl_templates/webapp/file_server/vasl_mod.py index 4a50838..a1b37d8 100644 --- a/vasl_templates/webapp/file_server/vasl_mod.py +++ b/vasl_templates/webapp/file_server/vasl_mod.py @@ -48,8 +48,18 @@ class VaslMod: def get_piece_info( self ): """Get information about each piece.""" + def image_count( piece, key ): + """Return the number of images the specified piece has.""" + if not piece[key]: + return 0 + return len(piece[key]) if isinstance( piece[key], list ) else 1 return { - p["gpid"]: { "name": p["name"], "is_small": p["is_small"] } + p["gpid"]: { + "name": p["name"], + "front_images": image_count( p, "front_images" ), + "back_images": image_count( p, "back_images" ), + "is_small": p["is_small"], + } for p in self.pieces.values() } diff --git a/vasl_templates/webapp/files.py b/vasl_templates/webapp/files.py index 857b96c..2366b3b 100644 --- a/vasl_templates/webapp/files.py +++ b/vasl_templates/webapp/files.py @@ -16,7 +16,7 @@ if app.config.get( "VASL_MOD" ): # --------------------------------------------------------------------- @app.route( "/counter///" ) -@app.route( "/counter//", defaults={"index":1} ) +@app.route( "/counter//", defaults={"index":0} ) def get_counter_image( gpid, side, index ): """Get a counter image.""" @@ -25,7 +25,7 @@ def get_counter_image( gpid, side, index ): return app.send_static_file( "images/missing-image.png" ) # return the specified counter image - image_path, image_data = vasl_mod.get_piece_image( int(gpid), side, int(index)-1 ) + image_path, image_data = vasl_mod.get_piece_image( int(gpid), side, int(index) ) if not image_data: abort( 404 ) return send_file( diff --git a/vasl_templates/webapp/static/css/main.css b/vasl_templates/webapp/static/css/main.css index 78a85b1..f8c4342 100644 --- a/vasl_templates/webapp/static/css/main.css +++ b/vasl_templates/webapp/static/css/main.css @@ -122,12 +122,20 @@ button.edit-template img { height: 18px ; vertical-align: middle ; margin-right: #select-vo .select2-search { padding: 0 0 5px 0 ; } #select-vo .select2-results__options { max-height: none ; } #select-vo .select2-dropdown { border: none ; } -#select-vo .select2-dropdown .vo-entry { display: flex ; align-items: center ; } -#select-vo .select2-dropdown .vo-entry img { width: 3.5em ; margin-right: 0.5em ; } -#select-vo .select2-dropdown .vo-entry.small-piece img { width: 2.7em ; margin-left: 0.4em ; margin-right: 0.9em ; } +#select-vo .select2-dropdown .vo-entry { display: flex ; } +#select-vo .select2-dropdown .vo-entry img { height: 3.5em ; margin-right: 0.5em ; } +#select-vo .select2-dropdown .vo-entry .content { display: flex ; flex-direction: column ; justify-content: center ; } +#select-vo .select2-dropdown .vo-entry.small-piece img { height: 2.7em ; margin-left: 0.4em ; margin-right: 0.9em ; } #select-vo .select2-dropdown .vo-entry .vo-type { font-size: 80% ; font-style: italic ; color: #888 ; } -.ui-dialog.select-vo .ui-dialog-buttonpane { border: none ; padding: 0 ; font-size: 75% ; } -.ui-dialog.select-vo button { margin: 0 0 0 5px ; padding: 0.1em 0.2em ; } +#select-vo .select2-results__option--highlighted[aria-selected] .vo-type { color: #fff ; } +#select-vo .select2-dropdown .vo-entry input.select-vo-image { width: 15px ; position: relative ; top: 10px ; } +.ui-dialog.select-vo .ui-dialog-buttonpane { border: none ; padding: 0 ; } +.ui-dialog.select-vo .ui-dialog-buttonpane button { margin: 0 0 0 5px ; padding: 0.1em 0.2em ; } + +.ui-dialog.select-vo-image { padding: 5px ; } +.ui-dialog.select-vo-image .ui-dialog-titlebar { display: none ; } +.ui-dialog.select-vo-image .ui-dialog-content { padding: 0 ; height: 100% !important ; overflow: hidden ; } +.ui-dialog.select-vo-image .vo-images img { margin: 5px ; padding: 10px ; border: 1px dotted #ddd ; } .growl-title { display: none ; } .growl .pre { font-family: monospace ; } diff --git a/vasl_templates/webapp/static/css/tabs-ob.css b/vasl_templates/webapp/static/css/tabs-ob.css index baca140..a322f7c 100644 --- a/vasl_templates/webapp/static/css/tabs-ob.css +++ b/vasl_templates/webapp/static/css/tabs-ob.css @@ -17,7 +17,7 @@ .panel-ob_vehicles .footer { margin-top: 0.5em ; display: flex ; align-items: center ; } .panel-ob_vehicles .sortable { font-size: 90% ; } -.panel-ob_vehicles .sortable img { display: inline-block ; vertical-align: middle ; width: 3.5em ; margin-right: 0.5em ; } +.panel-ob_vehicles .sortable img { display: inline-block ; vertical-align: middle ; height: 3.5em ; margin-right: 0.5em ; } /* -------------------------------------------------------------------- */ @@ -26,5 +26,5 @@ .panel-ob_ordnance .footer { margin-top: 0.5em ; display: flex ; align-items: center ; } .panel-ob_ordnance .sortable { font-size: 90% ; } -.panel-ob_ordnance .sortable img { display: inline-block ; vertical-align: middle ; width: 3.5em ; margin-right: 0.5em ; } -.panel-ob_ordnance .sortable .small-piece img { width: 2.5em ; margin-left: 0.5em ; margin-right: 1em ; } +.panel-ob_ordnance .sortable img { display: inline-block ; vertical-align: middle ; height: 3.5em ; margin-right: 0.5em ; } +.panel-ob_ordnance .sortable .small-piece img { height: 2.5em ; margin-left: 0.5em ; margin-right: 1em ; } diff --git a/vasl_templates/webapp/static/images/select-vo-image.png b/vasl_templates/webapp/static/images/select-vo-image.png new file mode 100755 index 0000000..717dd02 Binary files /dev/null and b/vasl_templates/webapp/static/images/select-vo-image.png differ diff --git a/vasl_templates/webapp/static/snippets.js b/vasl_templates/webapp/static/snippets.js index eb2313f..d0949af 100644 --- a/vasl_templates/webapp/static/snippets.js +++ b/vasl_templates/webapp/static/snippets.js @@ -211,19 +211,21 @@ function unload_snippet_params( params, check_date_capabilities ) var $sortable2 = $( "#ob_" + vo_type + "-sortable_" + player_no ) ; var objs = [] ; $sortable2.children( "li" ).each( function() { - var entry = $(this).data( "sortable2-data" ).vo_entry ; + var vo_entry = $(this).data( "sortable2-data" ).vo_entry ; + var vo_image_id = $(this).data( "sortable2-data" ).vo_image_id ; var obj = { - id: entry.id, - name: entry.name, - note_number: entry.note_number, - notes: entry.notes + id: vo_entry.id, + image_id: (vo_image_id !== null) ? vo_image_id[0]+"/"+vo_image_id[1] : null, + name: vo_entry.name, + note_number: vo_entry.note_number, + notes: vo_entry.notes } ; - if ( entry.no_radio ) - obj.no_radio = entry.no_radio ; - if ( entry.no_if ) { + if ( vo_entry.no_radio ) + obj.no_radio = vo_entry.no_radio ; + if ( vo_entry.no_if ) { obj.no_if = "no IF" ; - if ( typeof(entry.no_if) === "string" ) { // nb: only for the French B1-bis :-/ - var no_if = entry.no_if ; + if ( typeof(vo_entry.no_if) === "string" ) { // nb: only for the French B1-bis :-/ + var no_if = vo_entry.no_if ; if ( no_if.substring(no_if.length-1) == "\u2020" ) obj.no_if += ""+no_if.substring(0,no_if.length-1)+"\u2020" ; else @@ -239,7 +241,7 @@ function unload_snippet_params( params, check_date_capabilities ) // get a lot of use :-/ var nat = params[ "PLAYER_"+player_no ] ; var capabilities = make_capabilities( - entry, + vo_entry, nat, params.SCENARIO_THEATER, params.SCENARIO_YEAR, params.SCENARIO_MONTH, check_date_capabilities, @@ -248,7 +250,7 @@ function unload_snippet_params( params, check_date_capabilities ) if ( capabilities ) obj.capabilities = capabilities ; capabilities = make_capabilities( - entry, + vo_entry, nat, params.SCENARIO_THEATER, params.SCENARIO_YEAR, params.SCENARIO_MONTH, check_date_capabilities, @@ -256,7 +258,7 @@ function unload_snippet_params( params, check_date_capabilities ) ) ; if ( capabilities ) obj.raw_capabilities = capabilities ; - var crew_survival = make_crew_survival( entry ) ; + var crew_survival = make_crew_survival( vo_entry ) ; if ( crew_survival ) obj.crew_survival = crew_survival ; objs.push( obj ) ; @@ -274,29 +276,29 @@ function unload_snippet_params( params, check_date_capabilities ) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -function make_capabilities( entry, nat, scenario_theater, scenario_year, scenario_month, check_date_capabilities, raw ) +function make_capabilities( vo_entry, nat, scenario_theater, scenario_year, scenario_month, check_date_capabilities, raw ) { var capabilities = [] ; // extract the static capabilities var i ; - if ( "capabilities" in entry ) { - for ( i=0 ; i < entry.capabilities.length ; ++i ) - capabilities.push( entry.capabilities[i] ) ; + if ( "capabilities" in vo_entry ) { + for ( i=0 ; i < vo_entry.capabilities.length ; ++i ) + capabilities.push( vo_entry.capabilities[i] ) ; } // extract the variable capabilities - if ( "capabilities2" in entry ) { + if ( "capabilities2" in vo_entry ) { var indeterminate_caps=[], unexpected_caps=[], invalid_caps=[] ; - for ( var key in entry.capabilities2 ) { + for ( var key in vo_entry.capabilities2 ) { // check if the capability is dependent on the scenario date - if ( !( entry.capabilities2[key] instanceof Array ) ) { - capabilities.push( key + entry.capabilities2[key] ) ; + if ( !( vo_entry.capabilities2[key] instanceof Array ) ) { + capabilities.push( key + vo_entry.capabilities2[key] ) ; continue ; } // check for LF if ( key == "LF" ) { - var caps = $.extend( true, [], entry.capabilities2[key] ) ; + var caps = $.extend( true, [], vo_entry.capabilities2[key] ) ; if ( caps[caps.length-1] == "\u2020" ) { caps.pop() ; capabilities.push( "LF\u2020" ) ; @@ -315,14 +317,14 @@ function make_capabilities( entry, nat, scenario_theater, scenario_year, scenari raw = true ; } if ( raw ) { - capabilities.push( make_raw_capability( key, entry.capabilities2[key] ) ) ; + capabilities.push( make_raw_capability( key, vo_entry.capabilities2[key] ) ) ; } else { - var cap = _select_capability_by_date( entry.capabilities2[key], nat, scenario_theater, scenario_year, scenario_month ) ; + var cap = _select_capability_by_date( vo_entry.capabilities2[key], nat, scenario_theater, scenario_year, scenario_month ) ; if ( cap === null ) continue ; if ( cap == "" ) { - invalid_caps.push( entry.name + ": " + key + ": " + entry.capabilities2[key] ) ; + invalid_caps.push( vo_entry.name + ": " + key + ": " + vo_entry.capabilities2[key] ) ; continue ; } capabilities.push( key + cap ) ; @@ -352,14 +354,14 @@ function make_capabilities( entry, nat, scenario_theater, scenario_year, scenari } // extract any other capabilities - if ( "capabilities_other" in entry ) { - for ( i=0 ; i < entry.capabilities_other.length ; ++i ) - capabilities.push( entry.capabilities_other[i] ) ; + if ( "capabilities_other" in vo_entry ) { + for ( i=0 ; i < vo_entry.capabilities_other.length ; ++i ) + capabilities.push( vo_entry.capabilities_other[i] ) ; } // include damage points (for Landing Craft) - if ( "damage_points" in entry ) - capabilities.push( "DP " + entry.damage_points ) ; + if ( "damage_points" in vo_entry ) + capabilities.push( "DP " + vo_entry.damage_points ) ; return capabilities.length > 0 ? capabilities : null ; } @@ -510,7 +512,7 @@ function has_ref( val ) return null ; } -function make_crew_survival( entry ) +function make_crew_survival( vo_entry ) { function make_cs_string( prefix, val ) { if ( val.length === 2 && val[0] === null && val[1] === "\u2020" ) @@ -521,10 +523,10 @@ function make_crew_survival( entry ) // check if the vehicle has a crew survival field var crew_survival = null ; - if ( "CS#" in entry ) - crew_survival = make_cs_string( "CS", entry["CS#"] ) ; - else if ( "cs#" in entry ) - crew_survival = make_cs_string( "cs", entry["cs#"] ) ; + if ( "CS#" in vo_entry ) + crew_survival = make_cs_string( "CS", vo_entry["CS#"] ) ; + else if ( "cs#" in vo_entry ) + crew_survival = make_cs_string( "cs", vo_entry["cs#"] ) ; if ( crew_survival === null ) return null ; @@ -741,8 +743,16 @@ function do_load_scenario_data( params ) vo_id = params[key][i].name ; // nb: we store the name in the ID variable, in case we have to log an error below vo_entry = find_vo_by_name( vo_type, nat, vo_id ) ; } + var vo_image_id = null ; + if ( "image_id" in params[key][i] ) { + var matches = params[key][i].image_id.match( /^(\d{3,4})\/(\d)$/ ) ; + if ( matches ) + vo_image_id = [ parseInt(matches[1]), parseInt(matches[2]) ] ; + else + warnings.push( "Invalid V/O image ID for '" + params[key][i].name + "': " + params[key][i].image_id ) ; + } if ( vo_entry ) - do_add_vo( vo_type, player_no, vo_entry ) ; + do_add_vo( vo_type, player_no, vo_entry, vo_image_id ) ; else unknown_vo.push( vo_id || "(not set)" ) ; } @@ -847,14 +857,17 @@ function unload_params_for_save() function extract_vo_entries( key ) { if ( !(key in params) ) return ; - var vo_entries = [] ; + var entries = [] ; for ( var i=0 ; i < params[key].length ; ++i ) { - vo_entries.push( { + var entry = { id: params[key][i].id, name: params[key][i].name, // nb: not necessary, but convenient - } ) ; + } ; + if ( params[key][i].image_id !== null ) + entry.image_id = params[key][i].image_id ; + entries.push( entry ) ; } - params[key] = vo_entries ; + params[key] = entries ; } var params = {} ; unload_snippet_params( params, false ) ; diff --git a/vasl_templates/webapp/static/sortable.js b/vasl_templates/webapp/static/sortable.js index 99e3d2e..297e892 100644 --- a/vasl_templates/webapp/static/sortable.js +++ b/vasl_templates/webapp/static/sortable.js @@ -195,7 +195,7 @@ $.fn.sortable2 = function( action, args ) $entries.each( function() { var fixed_height = $(this).data( "sortable2-data" ).fixed_height ; if ( fixed_height ) - $(this).css( "height", fixed_height+"px" ) ; + $(this).css( "height", fixed_height ) ; else $(this).css({ "max-height": max_height+"px", "overflow-y": "hidden" }) ; // check for overflow diff --git a/vasl_templates/webapp/static/utils.js b/vasl_templates/webapp/static/utils.js index 7243008..eb60185 100644 --- a/vasl_templates/webapp/static/utils.js +++ b/vasl_templates/webapp/static/utils.js @@ -143,12 +143,18 @@ function auto_dismiss_dialog( $dlg, evt, btn_text ) // check if the user pressed Ctrl-Enter if ( evt.keyCode == 13 && evt.ctrlKey ) { // yup - locate the target button and click it - var $dlg2 = $( ".ui-dialog." + $dlg.dialog("option","dialogClass") ) ; - $( $dlg2.find( ".ui-dialog-buttonpane button:contains('" + btn_text + "')" ) ).click() ; + click_dialog_button( $dlg, btn_text ) ; evt.preventDefault() ; } } +function click_dialog_button( $dlg, btn_text ) +{ + // locate the target button and click it + var $dlg2 = $( ".ui-dialog." + $dlg.dialog("option","dialogClass") ) ; + $( $dlg2.find( ".ui-dialog-buttonpane button:contains('" + btn_text + "')" ) ).click() ; +} + // -------------------------------------------------------------------- function ask( title, msg, args ) diff --git a/vasl_templates/webapp/static/vo.js b/vasl_templates/webapp/static/vo.js index 70940ab..8a7038d 100644 --- a/vasl_templates/webapp/static/vo.js +++ b/vasl_templates/webapp/static/vo.js @@ -31,13 +31,27 @@ function add_vo( vo_type, player_no ) var div_class = "vo-entry" ; if ( is_small_vasl_piece( vo_entry ) ) div_class += " small-piece" ; - var buf2 = ["
", - "", + var buf2 = [ "
", + "", + "
", vo_entry.name, vo_entry.type ? " ("+vo_entry.type+")" : "", + "
", "
" ] ; - return $( buf2.join("") ) ; + $entry = $( buf2.join("") ) ; + $entry.find( "img" ).data( "vo-image-id", null ) ; + var vo_images = get_vo_images( vo_entry ) ; + if ( vo_images.length > 1 ) { + $entry.find( "img" ).data( "vo-images", vo_images ) ; + var $btn = $( "" ) ; + $entry.children( ".content" ).append( $btn ) ; + $btn.click( function() { + $(this).blur() ; + on_select_vo_image( $(this) ) ; + } ) ; + } + return $entry ; } var $sel = $( "#select-vo select" ) ; $sel.html( buf.join("") ).select2( { @@ -90,7 +104,9 @@ function add_vo( vo_type, player_no ) var data = $sel.select2( "data" ) ; if ( ! data ) return ; - do_add_vo( vo_type, player_no, entries[data[0].id] ) ; + var $img = $( "#"+data[0]._resultId ).find( "img[class='vasl-image']" ) ; + var vo_image_id = $img.data( "vo-image-id" ) ; + do_add_vo( vo_type, player_no, entries[data[0].id], vo_image_id ) ; $(this).dialog( "close" ) ; }, Cancel: function() { $(this).dialog( "close" ) ; }, @@ -100,22 +116,23 @@ function add_vo( vo_type, player_no ) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -function do_add_vo( vo_type, player_no, vo_entry ) +function do_add_vo( vo_type, player_no, vo_entry, vo_image_id ) { // add the specified vehicle/ordnance // NOTE: We set a fixed height for the sortable2 entries (based on the CSS settings in tabs-ob.css), // so that the vehicle/ordnance images won't get truncated if there are a lot of them. var $sortable2 = $( "#ob_" + vo_type + "-sortable_" + player_no ) ; var div_tag = "" + vo_entry.name + "
" ), - data: { caption: vo_entry.name, vo_entry: vo_entry, fixed_height: fixed_height }, + content: $( div_tag + "" + vo_entry.name + "" ), + data: { caption: vo_entry.name, vo_entry: vo_entry, vo_image_id: vo_image_id, fixed_height: fixed_height }, } ) ; } @@ -145,12 +162,115 @@ function find_vo_by_name( vo_type, nat, name ) // -------------------------------------------------------------------- -function _get_vo_image_url( vo_entry ) +function get_vo_images( vo_entry ) { - if ( $.isArray( vo_entry.gpid ) ) // FIXME! if > 1 image available, let the user pick which one - return "/counter/" + vo_entry.gpid[0] + "/front" ; - if ( vo_entry.gpid ) - return "/counter/" + vo_entry.gpid + "/front" ; + // NOTE: Mapping Chapter H vehicles/ordnance to VASL images is quite messy :-/ Most map one-to-one, + // but some V/O have multiple GPID's, some GPID's have multiple images. Also, some V/O don't have + // a matching GPID (are they in a VASL extension somewhere?), so we can't show an image for them at all. + // So, we identify VASL images by a GPID plus index (if there are multiple images for that GPID). + var images = [] ; + function add_gpid_images( gpid ) { + if ( ! gpid || !(gpid in gVaslPieceInfo) ) + return ; + for ( var i=0 ; i < gVaslPieceInfo[gpid].front_images ; ++i ) + images.push( [gpid,i] ) ; + } + if ( $.isArray(vo_entry.gpid) ) { + for ( var i=0 ; i < vo_entry.gpid.length ; ++i ) + add_gpid_images( vo_entry.gpid[i] ) ; + } else + add_gpid_images( vo_entry.gpid ) ; + + return images ; +} + +function on_select_vo_image( $btn ) { + + // initialize + var $img = $btn.parent().parent().find( "img.vasl-image" ) ; + var vo_images = $img.data( "vo-images" ) ; + var vo_image_id = $img.data( "vo-image-id" ) ; + + // NOTE: We need to do this after the dialog has opened, since we need to wait for all the images + // to finish loading, so that we can figure out how big to make the dialog. + function on_open_dialog() { + + // load the vehicle/ordnance images + var $images = $( "#select-vo-image .vo-images" ) ; + var n_images_loaded=0, total_width=0, max_height=0 ; + function on_image_loaded() { + total_width += $(this).width() ; + max_height = Math.max( $(this).height(), max_height ) ; + if ( ++n_images_loaded == vo_images.length ) { + // all images have loaded - resize the dialog + var width = 5 + total_width + 20*vo_images.length + 10*vo_images.length + 5 ; + var height = 5 + 10+max_height+10 + 5 ; + $( ".ui-dialog.select-vo-image" ).width( width ).height( height ) ; + } + } + $images.empty() ; + for ( var i=0 ; i < vo_images.length ; ++i ) { + var $elem = $( "" ) + .bind( "load", on_image_loaded ) + .attr( "src", _get_vo_image_url( null, vo_images[i] ) ) ; + $images.append( $elem ) ; + } + + // highlight the currently-selected image + var sel_index = (vo_image_id === null) ? 0 : vo_images.indexOf(vo_image_id) ; + if ( sel_index === -1 ) { + console.log( "Couldn't find V/O image ID '" + vo_image_id + "' in V/O images: " + vo_images ) ; + sel_index = 0 ; + } + $images.children( "img:eq("+sel_index+")" ).css( "background", "#5897fb" ) ; + + // highlight images on mouse-over + var prev_bgd ; + $images.children( "img" ).on( { + "mouseenter": function() { + prev_bgd = $(this).css( "backgroundColor" ) ; + if ( $(this).data("index") != sel_index ) + $(this).css( "background", "#ddd" ) ; + }, + "mouseleave": function() { $(this).css( "backgroundColor", prev_bgd ) ; } + } ) ; + + // handle image selection + $images.children( "img" ).click( function() { + vo_image_id = vo_images[ $(this).data("index") ] ; + $img.attr( "src", _get_vo_image_url(vo_image_id) ) ; + $img.data( "vo-image-id", vo_image_id ) ; + $dlg.dialog( "close" ) ; + // nb: if the user selected an image, we take that to mean they also want to add that vehicle/ordnance + click_dialog_button( $("#select-vo"), "OK" ) ; + } ) ; + + } + + // show the dialog + var $dlg = $("#select-vo-image").dialog( { + dialogClass: "select-vo-image", + modal: true, + position: { my: "left top", at: "left-50 bottom+5", of: $btn, "collision": "fit" }, + width: 1, height: 1, // nb: to avoid flicker; we set the size when the images have finished loading + minWidth: 200, + minHeight: 100, + resizable: false, + "open": on_open_dialog, + } ) ; +} + +function _get_vo_image_url( vo_entry, vo_image_id ) +{ + if ( vo_image_id ) + return "/counter/" + vo_image_id[0] + "/front/" + vo_image_id[1] ; + else { + // no V/O image ID was provided, just use the first available image + if ( $.isArray( vo_entry.gpid ) ) + return "/counter/" + vo_entry.gpid[0] + "/front" ; + if ( vo_entry.gpid ) + return "/counter/" + vo_entry.gpid + "/front" ; + } return gImagesBaseUrl + "/missing-image.png" ; } diff --git a/vasl_templates/webapp/templates/index.html b/vasl_templates/webapp/templates/index.html index e80ce48..8a78b07 100644 --- a/vasl_templates/webapp/templates/index.html +++ b/vasl_templates/webapp/templates/index.html @@ -281,6 +281,10 @@ + + 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/alphanumeric-gpid.json new file mode 100644 index 0000000..7e94b27 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/alphanumeric-gpid.json @@ -0,0 +1,10 @@ +{ + "PLAYER_1": "german", + "OB_VEHICLES_1": [ + { + "id": "ge/v:990", + "name": "a german vehicle", + "image_id": "abc123/0" + } + ] +} diff --git a/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/alphanumeric-index.json b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/alphanumeric-index.json new file mode 100644 index 0000000..e3c15e5 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/alphanumeric-index.json @@ -0,0 +1,10 @@ +{ + "PLAYER_1": "german", + "OB_VEHICLES_1": [ + { + "id": "ge/v:990", + "name": "a german vehicle", + "image_id": "1234/x" + } + ] +} diff --git a/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/empty.json b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/empty.json new file mode 100644 index 0000000..b0bcd3e --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/empty.json @@ -0,0 +1,10 @@ +{ + "PLAYER_1": "german", + "OB_VEHICLES_1": [ + { + "id": "ge/v:990", + "name": "a german vehicle", + "image_id": "" + } + ] +} 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 new file mode 100644 index 0000000..b2657f2 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/long-gpid.json @@ -0,0 +1,10 @@ +{ + "PLAYER_1": "german", + "OB_VEHICLES_1": [ + { + "id": "ge/v:990", + "name": "a german vehicle", + "image_id": "12345/0" + } + ] +} diff --git a/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/missing-gpid.json b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/missing-gpid.json new file mode 100644 index 0000000..650b334 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/missing-gpid.json @@ -0,0 +1,10 @@ +{ + "PLAYER_1": "german", + "OB_VEHICLES_1": [ + { + "id": "ge/v:990", + "name": "a german vehicle", + "image_id": "/0" + } + ] +} diff --git a/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/missing-index.json b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/missing-index.json new file mode 100644 index 0000000..4e6ebb8 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/missing-index.json @@ -0,0 +1,10 @@ +{ + "PLAYER_1": "german", + "OB_VEHICLES_1": [ + { + "id": "ge/v:990", + "name": "a german vehicle", + "image_id": "1234/" + } + ] +} diff --git a/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/short-gpid.json b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/short-gpid.json new file mode 100644 index 0000000..240a2e6 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/invalid-vo-image-ids/short-gpid.json @@ -0,0 +1,10 @@ +{ + "PLAYER_1": "german", + "OB_VEHICLES_1": [ + { + "id": "ge/v:990", + "name": "a german vehicle", + "image_id": "12/0" + } + ] +} diff --git a/vasl_templates/webapp/tests/test_counters.py b/vasl_templates/webapp/tests/test_counters.py index c6677a3..ff8399d 100644 --- a/vasl_templates/webapp/tests/test_counters.py +++ b/vasl_templates/webapp/tests/test_counters.py @@ -8,10 +8,9 @@ import urllib.request import pytest import tabulate -from vasl_templates.webapp.file_server.vasl_mod import VaslMod from vasl_templates.webapp.file_server.utils import get_vo_gpids from vasl_templates.webapp.config.constants import DATA_DIR -from vasl_templates.webapp import files as webapp_files +from vasl_templates.webapp.tests.utils import load_vasl_mod # --------------------------------------------------------------------- @@ -44,7 +43,7 @@ def test_counter_images( webapp, monkeypatch ): assert locals()["check_"+side]( resp_code, resp_data ) # test counter images when no VASL module has been configured - monkeypatch.setattr( webapp_files, "vasl_mod", None ) + load_vasl_mod( None, monkeypatch ) fname = os.path.join( os.path.split(__file__)[0], "../static/images/missing-image.png" ) missing_image_data = open( fname, "rb" ).read() check_images( @@ -59,8 +58,7 @@ def test_counter_images( webapp, monkeypatch ): for fname in glob.glob(fspec): # install the VASL module file - vasl_mod = VaslMod( fname, DATA_DIR ) - monkeypatch.setattr( webapp_files, "vasl_mod", vasl_mod ) + vasl_mod = load_vasl_mod( DATA_DIR, monkeypatch ) # check the pieces loaded buf = io.StringIO() diff --git a/vasl_templates/webapp/tests/test_scenario_persistence.py b/vasl_templates/webapp/tests/test_scenario_persistence.py index 7e3b1f1..78b9a50 100644 --- a/vasl_templates/webapp/tests/test_scenario_persistence.py +++ b/vasl_templates/webapp/tests/test_scenario_persistence.py @@ -110,7 +110,7 @@ def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-st assert lhs == rhs # save the scenario and check the results - saved_scenario = _save_scenario() + saved_scenario = save_scenario() expected = { k: v for tab in SCENARIO_PARAMS.values() for k,v in tab.items() } @@ -136,7 +136,7 @@ def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-st wait_for( 2, lambda: get_stored_msg("_last-info_") == "The scenario was reset." ) check_window_title( "" ) check_ob_tabs( "german", "russian" ) - data = _save_scenario() + data = save_scenario() data2 = { k: v for k,v in data.items() if v } assert data2 == { "SCENARIO_THEATER": "ETO", @@ -156,7 +156,7 @@ def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-st } # load a scenario and make sure it was loaded into the UI correctly - _load_scenario( saved_scenario ) + load_scenario( saved_scenario ) check_window_title( "my test scenario" ) check_ob_tabs( "russian", "german" ) for tab_id in SCENARIO_PARAMS: @@ -201,7 +201,7 @@ def test_loading_ssrs( webapp, webdriver ): select_tab( "scenario" ) sortable = find_child( "#ssr-sortable" ) def do_test( ssrs ): # pylint: disable=missing-docstring - _load_scenario( { "SSR": ssrs } ) + load_scenario( { "SSR": ssrs } ) assert get_sortable_entry_text(sortable) == ssrs # load a scenario that has SSR's into a UI with no SSR's @@ -240,7 +240,7 @@ def test_unknown_vo( webapp, webdriver ): "OB_ORDNANCE_2": [ { "name": "unknown ordnance 2" } ], } _ = set_stored_msg_marker( "_last-warning_" ) - _load_scenario( SCENARIO_PARAMS ) + load_scenario( SCENARIO_PARAMS ) last_warning = get_stored_msg( "_last-warning_" ) assert last_warning.startswith( "Unknown vehicles/ordnance:" ) for key,vals in SCENARIO_PARAMS.items(): @@ -250,14 +250,14 @@ def test_unknown_vo( webapp, webdriver ): # --------------------------------------------------------------------- -def _load_scenario( scenario ): +def load_scenario( scenario ): """Load a scenario into the UI.""" set_stored_msg( "_scenario-persistence_", json.dumps(scenario) ) _ = set_stored_msg_marker( "_last-info_" ) select_menu_option( "load_scenario" ) wait_for( 2, lambda: get_stored_msg("_last-info_") == "The scenario was loaded." ) -def _save_scenario(): +def save_scenario(): """Save the scenario.""" marker = set_stored_msg_marker( "_scenario-persistence_" ) select_menu_option( "save_scenario" ) diff --git a/vasl_templates/webapp/tests/test_vehicles_ordnance.py b/vasl_templates/webapp/tests/test_vehicles_ordnance.py index 3d23bc4..641e8b8 100644 --- a/vasl_templates/webapp/tests/test_vehicles_ordnance.py +++ b/vasl_templates/webapp/tests/test_vehicles_ordnance.py @@ -1,13 +1,18 @@ """ Test generating vehicle/ordnance snippets. """ +import os import re +import json from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.support.ui import Select from selenium.webdriver.common.keys import Keys +from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario, save_scenario from vasl_templates.webapp.tests.utils import \ - init_webapp, select_tab, set_template_params, find_child, find_children, \ - wait_for_clipboard, click_dialog_button + init_webapp, load_vasl_mod, select_tab, set_template_params, find_child, find_children, \ + wait_for_clipboard, click_dialog_button, select_menu_option, select_droplist_val, \ + set_stored_msg_marker, get_stored_msg from vasl_templates.webapp.config.constants import DATA_DIR as REAL_DATA_DIR # --------------------------------------------------------------------- @@ -218,6 +223,7 @@ def test_html_names( webapp, webdriver, monkeypatch ): # initialize monkeypatch.setitem( webapp.config, "DATA_DIR", REAL_DATA_DIR ) + load_vasl_mod( REAL_DATA_DIR, monkeypatch ) init_webapp( webapp, webdriver ) def get_available_ivfs(): @@ -230,7 +236,7 @@ def test_html_names( webapp, webdriver, monkeypatch ): select_tab( "ob{}".format( 1 ) ) add_vehicle_btn = find_child( "#ob_vehicles-add_1" ) add_vehicle_btn.click() - assert get_available_ivfs() == [ "PzKpfw IVF\n1\n (MT)", "PzKpfw IVF\n2\n (MT)" ] + assert get_available_ivfs() == [ "PzKpfw IVF1 (MT)", "PzKpfw IVF2 (MT)" ] # add the PzKw IVF2 elem = find_child( ".ui-dialog .select2-search__field" ) @@ -244,7 +250,7 @@ def test_html_names( webapp, webdriver, monkeypatch ): # start to add another vehicle - make sure only the PzKw IVF1 is present add_vehicle_btn.click() - assert get_available_ivfs() == [ "PzKpfw IVF\n1\n (MT)" ] + assert get_available_ivfs() == [ "PzKpfw IVF1 (MT)" ] # add the PzKw IVF1 elem = find_child( ".ui-dialog .select2-search__field" ) @@ -267,7 +273,201 @@ def test_html_names( webapp, webdriver, monkeypatch ): # start to add another vehicle - make sure the PzKw IVF2 is available again add_vehicle_btn.click() - assert get_available_ivfs() == [ "PzKpfw IVF\n2\n (MT)" ] + assert get_available_ivfs() == [ "PzKpfw IVF2 (MT)" ] + +# --------------------------------------------------------------------- + +def test_vo_images( webapp, webdriver, monkeypatch ): #pylint: disable=too-many-statements + """Test handling of vehicles/ordnance that have multiple images.""" + + # initialize + monkeypatch.setitem( webapp.config, "DATA_DIR", REAL_DATA_DIR ) + load_vasl_mod( REAL_DATA_DIR, monkeypatch ) + init_webapp( webapp, webdriver, scenario_persistence=1 ) + + def check_sortable2_entries( player_no, expected ): + """Check the settings on the player's vehicles.""" + entries = find_children( "#ob_vehicles-sortable_{} li".format( player_no ) ) + for i,entry in enumerate(entries): + # check the displayed image + elem = find_child( "img", entry ) + assert elem.get_attribute( "src" ).endswith( expected[i][0] ) + # check the attached data + data = webdriver.execute_script( "return $(arguments[0]).data('sortable2-data')", entry ) + assert data["vo_entry"]["id"] == expected[i][1] + assert data["vo_image_id"] == expected[i][2] + + def check_save_scenario( player_no, expected ): + """Check the vo_entry and vo_image_id fields are saved correctly.""" + data = save_scenario() + assert data[ "OB_VEHICLES_{}".format(player_no) ] == expected + return data + + # start to add a PzKw VIB + select_tab( "ob{}".format( 1 ) ) + add_vehicle_btn = find_child( "#ob_vehicles-add_1" ) + add_vehicle_btn.click() + search_field = find_child( ".ui-dialog .select2-search__field" ) + search_field.send_keys( "VIB" ) + + # make sure there is only 1 image available + elem = find_child( "#select-vo .select2-results li img[class='vasl-image']" ) + assert elem.get_attribute( "src" ).endswith( "/counter/2602/front" ) + vo_images = webdriver.execute_script( "return $(arguments[0]).data('vo-images')", elem ) + assert vo_images is None + assert not find_child( "#select-vo .select2-results li input.select-vo-image" ) + + # add the PzKw VIB, make sure the sortable2 entry has its data set correctly + search_field.send_keys( Keys.RETURN ) + check_sortable2_entries( 1, [ + ( "/counter/2602/front", "ge/v:035", None ) + ] ) + + # check that the vehicles are saved correctly + check_save_scenario( 1, [ + { "id": "ge/v:035", "name": "PzKpfw VIB" }, + ] ) + + # start to add a PzKw IVH (this has multiple GPID's) + add_vehicle_btn.click() + search_field = find_child( ".ui-dialog .select2-search__field" ) + search_field.send_keys( "IVH" ) + + # make sure multiple images are available + elem = find_child( "#select-vo .select2-results li img[class='vasl-image']" ) + assert elem.get_attribute( "src" ).endswith( "/counter/2584/front" ) + vo_images = webdriver.execute_script( "return $(arguments[0]).data('vo-images')", elem ) + assert vo_images == [ [2584,0], [2586,0], [2807,0], [2809,0] ] + assert find_child( "#select-vo .select2-results li input.select-vo-image" ) + + # add the PzKw IVH, make sure the sortable2 entry has its data set correctly + search_field.send_keys( Keys.RETURN ) + check_sortable2_entries( 1, [ + ( "/counter/2602/front", "ge/v:035", None ), + ( "/counter/2584/front", "ge/v:027", None ) # nb: there is no V/O image ID if it's not necessary + ] ) + + # check that the vehicles are saved correctly + check_save_scenario( 1, [ + { "id": "ge/v:035", "name": "PzKpfw VIB" }, + { "id": "ge/v:027", "name": "PzKpfw IVH" }, # nb: there is no V/O image ID if it's not necessary + ] ) + + # delete the PzKw IVH + delete_vo( "vehicles", 1, "PzKpfw IVH", webdriver ) + + # add the PzKw IVH, with a different image, make sure the sortable2 entry has its data set correctly + add_vehicle_btn.click() + search_field = find_child( ".ui-dialog .select2-search__field" ) + search_field.send_keys( "IVH" ) + elem = find_child( "#select-vo .select2-results li img[class='vasl-image']" ) + assert elem.get_attribute( "src" ).endswith( "/counter/2584/front" ) + btn = find_child( "#select-vo .select2-results li input.select-vo-image" ) + btn.click() + images = find_children( ".ui-dialog.select-vo-image .vo-images img" ) + assert len(images) == 4 + images[2].click() + check_sortable2_entries( 1, [ + ( "/counter/2602/front", "ge/v:035", None ), + ( "/counter/2807/front/0", "ge/v:027", [2807,0] ) + ] ) + + # check that the vehicles are saved correctly + check_save_scenario( 1, [ + { "id": "ge/v:035", "name": "PzKpfw VIB" }, + { "id": "ge/v:027", "image_id": "2807/0", "name": "PzKpfw IVH" }, + ] ) + + # set the British as player 2 + select_tab("scenario" ) + player2_sel = Select( find_child( "select[name='PLAYER_2']" ) ) + select_droplist_val( player2_sel, "british" ) + + # start to add a 2pdr Portee (this has multiple images for a single GPID) + select_tab( "ob{}".format( 2 ) ) + add_vehicle_btn = find_child( "#ob_vehicles-add_2" ) + add_vehicle_btn.click() + search_field = find_child( ".ui-dialog .select2-search__field" ) + search_field.send_keys( "2pdr" ) + + # make sure multiple images are available + elem = find_child( "#select-vo .select2-results li img[class='vasl-image']" ) + assert elem.get_attribute( "src" ).endswith( "/counter/1555/front" ) + vo_images = webdriver.execute_script( "return $(arguments[0]).data('vo-images')", elem ) + assert vo_images == [ [1555,0], [1555,1] ] + assert find_child( "#select-vo .select2-results li input.select-vo-image" ) + + # add the 2pdr Portee, make sure the sortable2 entry has its data set correctly + search_field.send_keys( Keys.RETURN ) + check_sortable2_entries( 2, [ + ( "/counter/1555/front", "br/v:115", None ) # nb: there is no V/O image ID if it's not necessary + ] ) + + # check that the vehicles are saved correctly + check_save_scenario( 2, [ + { "id": "br/v:115", "name": "2pdr Portee" }, # nb: there is no V/O image ID if it's not necessary + ] ) + + # delete the 2pdr Portee + delete_vo( "vehicles", 2, "2pdr Portee", webdriver ) + + # add the 2pdr Portee, with a different image, make sure the sortable2 entry has its data set correctly + add_vehicle_btn.click() + search_field = find_child( ".ui-dialog .select2-search__field" ) + search_field.send_keys( "2pdr" ) + elem = find_child( "#select-vo .select2-results li img[class='vasl-image']" ) + assert elem.get_attribute( "src" ).endswith( "/counter/1555/front" ) + btn = find_child( "#select-vo .select2-results li input.select-vo-image" ) + btn.click() + images = find_children( ".ui-dialog.select-vo-image .vo-images img" ) + assert len(images) == 2 + images[1].click() + check_sortable2_entries( 2, [ + ( "/counter/1555/front/1", "br/v:115", [1555,1] ) + ] ) + + # check that the vehicles are saved correctly + saved_scenario = check_save_scenario( 2, [ + { "id": "br/v:115", "image_id": "1555/1", "name": "2pdr Portee" }, + ] ) + + # reset the scenario + select_menu_option( "new_scenario" ) + check_sortable2_entries( 1, [] ) + check_sortable2_entries( 2, [] ) + + # load the last saved scenario, make sure the correct images are displayed + load_scenario( saved_scenario ) + check_sortable2_entries( 1, [ + ( "/counter/2602/front", "ge/v:035", None ), + ( "/counter/2807/front/0", "ge/v:027", [2807,0] ) + ] ) + check_sortable2_entries( 2, [ + ( "/counter/1555/front/1", "br/v:115", [1555,1] ) + ] ) + +# --------------------------------------------------------------------- + +def test_invalid_vo_image_ids( webapp, webdriver ): + """Test loading scenarios that contain invalid V/O image ID's.""" + + # initialize + init_webapp( webapp, webdriver, scenario_persistence=1 ) + + # test each save file + dname = os.path.join( os.path.split(__file__)[0], "fixtures/invalid-vo-image-ids" ) + for root,_,fnames in os.walk(dname): + for fname in fnames: + fname = os.path.join( root, fname ) + if os.path.splitext( fname )[1] != ".json": + continue + + # load the next scenario, make sure a warning was issued for the V/O image ID + data = json.load( open(fname,"r") ) + set_stored_msg_marker( "_last-warning_" ) + load_scenario( data ) + last_warning = get_stored_msg( "_last-warning_" ) + assert "Invalid V/O image ID" in last_warning # --------------------------------------------------------------------- diff --git a/vasl_templates/webapp/tests/utils.py b/vasl_templates/webapp/tests/utils.py index 0330391..feff613 100644 --- a/vasl_templates/webapp/tests/utils.py +++ b/vasl_templates/webapp/tests/utils.py @@ -6,6 +6,7 @@ import json import time import re import uuid +import glob import pytest from PyQt5.QtWidgets import QApplication @@ -14,6 +15,9 @@ from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.action_chains import ActionChains from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException +from vasl_templates.webapp.file_server.vasl_mod import VaslMod +from vasl_templates.webapp import files as webapp_files + # standard templates _STD_TEMPLATES = { "scenario": [ "scenario", "players", "victory_conditions", "scenario_notes", "ssr" ], @@ -42,6 +46,23 @@ def init_webapp( webapp, webdriver, **options ): webdriver.get( webapp.url_for( "main", **options ) ) wait_for( 5, lambda: find_child("#_page-loaded_") is not None ) +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def load_vasl_mod( data_dir, monkeypatch ): + """Load a VASL module.""" + + if data_dir: + # 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. + fspec = os.path.join( pytest.config.option.vasl_mods, "*.vmod" ) #pylint: disable=no-member + fname = glob.glob( fspec )[0] + vasl_mod = VaslMod( fname, data_dir ) + else: + vasl_mod = None + monkeypatch.setattr( webapp_files, "vasl_mod", vasl_mod ) + + return vasl_mod + # --------------------------------------------------------------------- def for_each_template( func ): #pylint: disable=too-many-branches @@ -332,6 +353,7 @@ def _do_select_droplist( sel, val ): if e.text == val ] assert len(elems) == 1 + _webdriver.execute_script( "arguments[0].scrollIntoView()", elems[0] ) ActionChains(_webdriver).click( elems[0] ).perform() def get_droplist_vals_index( sel ):