diff --git a/vasl_templates/webapp/static/scenarios.js b/vasl_templates/webapp/static/scenarios.js index b800ec0..e9590e6 100644 --- a/vasl_templates/webapp/static/scenarios.js +++ b/vasl_templates/webapp/static/scenarios.js @@ -395,6 +395,13 @@ function loadScenarioCard( $target, scenarioId, briefMode, scenarioDateOverride, } gActiveScenaridCardRequest = scenarioId ; + // initialize + // NOTE: We tag the scenario card with a seq# for the benefit of the test suite, so that it can tell + // when a scenario card has finished loading, and it's safe to read values out of the UI. + // It won't work under load, but we only need it for the simple case of a single request coming in, + // and waiting for it to load completely. + var seqNo = $target.attr( "data-seqNo" ) || 1 ; + // load the specified scenario var url = gGetScenarioCardUrl.replace( "ID", scenarioId ) ; if ( briefMode ) @@ -431,6 +438,7 @@ function loadScenarioCard( $target, scenarioId, briefMode, scenarioDateOverride, $target.html( $card ).fadeIn( 100 ) ; $target.attr( "data-id", scenarioId ) ; onDone( scenario ) ; + $target.attr( "data-seqNo", parseInt(seqNo)+1 ) ; } ) ; } ).fail( onError ) ; diff --git a/vasl_templates/webapp/tests/test_scenario_search.py b/vasl_templates/webapp/tests/test_scenario_search.py index e0b95ba..6a09cf6 100644 --- a/vasl_templates/webapp/tests/test_scenario_search.py +++ b/vasl_templates/webapp/tests/test_scenario_search.py @@ -1,12 +1,11 @@ """" Test scenario search. """ import os -import time import pytest from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.keys import Keys -from selenium.common.exceptions import ElementClickInterceptedException, ElementNotInteractableException +from selenium.common.exceptions import StaleElementReferenceException from vasl_templates.webapp.tests.test_scenario_persistence import save_scenario, load_scenario from vasl_templates.webapp.tests.utils import init_webapp, select_tab, new_scenario, \ @@ -76,37 +75,38 @@ def test_import_scenario( webapp, webdriver ): # import the "full" scenario dlg = _do_scenario_search( "full", [1], webdriver ) find_child( "button.import", dlg ).click() - _check_scenario( + wait_for( 2, lambda: _check_scenario( SCENARIO_NAME="Full content scenario", SCENARIO_ID="FCS-1", SCENARIO_LOCATION="Some place", PLAYER_1="dutch", PLAYER_1_DESCRIPTION="1st Dutch Army", PLAYER_2="romanian", PLAYER_2_DESCRIPTION="1st Romanian Army", THEATER="PTO" - ) + ) ) # import the "empty" scenario _unlink_scenario() dlg = _do_scenario_search( "Untitled", ["no-content"], webdriver ) - find_child( "button.import", dlg ).click() - find_child( "button.confirm-import", dlg ).click() + _import_scenario_and_confirm( dlg ) # NOTE: Since there are no players defined in the scenario, what's on-screen will be left unchanged. - _check_scenario( + wait_for( 2, lambda: _check_scenario( SCENARIO_NAME="Untitled scenario (#no-content)", SCENARIO_ID="", SCENARIO_LOCATION="", PLAYER_1="dutch", PLAYER_1_DESCRIPTION="", PLAYER_2="romanian", PLAYER_2_DESCRIPTION="", THEATER="ETO" - ) + ) ) def _check_scenario( **kwargs ): """Check the scenario import.""" - keys = [ "SCENARIO_NAME", "SCENARIO_ID", "SCENARIO_LOCATION", "PLAYER_1_DESCRIPTION", "PLAYER_2_DESCRIPTION" ] - for key in keys: + for key in ["SCENARIO_NAME","SCENARIO_ID","SCENARIO_LOCATION","PLAYER_1_DESCRIPTION","PLAYER_2_DESCRIPTION"]: elem = find_child( "input[name='{}']".format( key ) ) - assert elem.get_attribute( "value" ) == kwargs[ key ] - assert get_player_nat( 1 ) == kwargs[ "PLAYER_1" ] - assert get_player_nat( 2 ) == kwargs[ "PLAYER_2" ] - assert get_theater() == kwargs[ "THEATER" ] + if elem.get_attribute( "value" ) != kwargs[ key ]: + return False + if get_player_nat( 1 ) != kwargs["PLAYER_1"] or get_player_nat( 2 ) != kwargs["PLAYER_2"]: + return False + if get_theater() != kwargs[ "THEATER" ]: + return False + return True # --------------------------------------------------------------------- @@ -121,6 +121,10 @@ def test_import_warnings( webapp, webdriver ): #pylint: disable=too-many-stateme find_child( "button.import", dlg ).click() wait_for( 2, lambda: not dlg.is_displayed() ) + def check_warnings( expected ): #pylint: disable=missing-docstring + warnings = find_children( ".warnings input[type='checkbox']", dlg ) + return [ w.get_attribute( "name" ) for w in warnings ] == expected + def do_test( param_name, expected_warning, expected_val, curr_val="CURR-VAL" ): #pylint: disable=missing-docstring # start with a new scenario @@ -137,22 +141,17 @@ def test_import_warnings( webapp, webdriver ): #pylint: disable=too-many-stateme elem = find_child( "[name='{}']".format( param_name ) ) if expected_warning: # yup - make sure they are being shown - warnings = find_children( ".warnings input[type='checkbox']", dlg ) - if expected_warning: - assert [ w.get_attribute( "name" ) for w in warnings ] == [ expected_warning ] - else: - assert not warnings + wait_for( 2, lambda: check_warnings( [expected_warning] ) ) # cancel the import find_child( "button.cancel-import", dlg ).click() wait_for( 2, lambda: not find_child( ".warnings", dlg ).is_displayed() ) # do the import again, and accept it - find_child( "#scenario-search button.import" ).click() - find_child( "button.confirm-import", dlg ).click() - assert not dlg.is_displayed() + _import_scenario_and_confirm( dlg ) assert elem.get_attribute( "value" ) == expected_val else: # nope - check that the import was done - assert not dlg.is_displayed() + wait_for( 2, lambda: not dlg.is_displayed() ) + # assert not dlg.is_displayed() assert elem.get_attribute( "value" ) == expected_val # do the tests @@ -171,10 +170,10 @@ def test_import_warnings( webapp, webdriver ): #pylint: disable=too-many-stateme load_scenario( { "PLAYER_1": "dutch", } ) - _do_scenario_search( "full", [1], webdriver ) - find_child( "#scenario-search button.import" ).click() - warnings = find_children( ".warnings input[type='checkbox']", dlg ) - assert [ w.get_attribute( "name" ) for w in warnings ] == [] + dlg = _do_scenario_search( "full", [1], webdriver ) + find_child( "button.import", dlg ).click() + check_warnings( [] ) + wait_for( 2, lambda: not dlg.is_displayed() ) # test importing a scenario on top of existing OB owned by the same nationality new_scenario() @@ -182,8 +181,8 @@ def test_import_warnings( webapp, webdriver ): #pylint: disable=too-many-stateme "PLAYER_1": "dutch", "OB_SETUPS_1": [ { "caption": "Dutch setup note" } ] } ) - _do_scenario_search( "full", [1], webdriver ) - find_child( "#scenario-search button.import" ).click() + dlg = _do_scenario_search( "full", [1], webdriver ) + find_child( "button.import", dlg ).click() wait_for( 2, lambda: not dlg.is_displayed() ) # test importing a scenario on top of existing OB owned by the different nationality @@ -192,18 +191,13 @@ def test_import_warnings( webapp, webdriver ): #pylint: disable=too-many-stateme "PLAYER_1": "german", "OB_SETUPS_1": [ { "caption": "German setup note" } ] } ) - _do_scenario_search( "full", [1], webdriver ) - find_child( "#scenario-search button.import", dlg ).click() + dlg = _do_scenario_search( "full", [1], webdriver ) + find_child( "button.import", dlg ).click() warnings = wait_for( 2, lambda: find_children( ".warnings input[type='checkbox']", dlg ) ) assert [ w.get_attribute( "name" ) for w in warnings ] == [ "defender_name" ] assert not warnings[0].is_selected() - try: - warnings[0].click() - except (ElementClickInterceptedException, ElementNotInteractableException): - # FUDGE! We sometimes get a "Other element would receive the click" (div.warning) error, - # I suspect because the warnings panel is still sliding up. - time.sleep( 0.5 ) - warnings[0].click() + wait_for( 2, lambda: warnings[0].is_displayed() ) + warnings[0].click() find_child( "button.confirm-import", dlg ).click() assert not dlg.is_displayed() assert get_player_nat( 1 ) == "dutch" @@ -230,7 +224,7 @@ def test_oba_info( webapp, webdriver ): check_oba_info( _unload_scenario_card(), expected ) # import the scenario and check the OBA info - find_child( "button.import" ).click() + find_child( "button.import", dlg ).click() wait_for( 2, lambda: not dlg.is_displayed() ) check_oba_info( _get_scenario_info(), expected ) @@ -247,14 +241,14 @@ def test_oba_info( webapp, webdriver ): check_oba_info( _get_scenario_info(), expected ) # check a scenario where only the defender has OBA - dlg = _do_scenario_search( "Defender OBA", ["5b"], webdriver ) + _do_scenario_search( "Defender OBA", ["5b"], webdriver ) check_oba_info( _unload_scenario_card(), [ [ "Burmese", "-", "-" ], None ] ) # check a scenario where the attacker has OBA, the defender is an unknwon nationality - dlg = _do_scenario_search( "Attacker OBA", ["5c"], webdriver ) + _do_scenario_search( "Attacker OBA", ["5c"], webdriver ) check_oba_info( _unload_scenario_card(), [ [ "The Other Guy", "?", "?" ], [ "Russian", "3B", "4R" ] @@ -410,70 +404,81 @@ def test_roar_linking( webapp, webdriver ): def check( bgraph, connect, disconnect ): """Check the scenario card.""" - # unload the scenario card - if find_child( "#scenario-search" ).is_displayed(): - parent = find_child( "#scenario-search .scenario-card" ) - elif find_child( "#scenario-info-dialog" ).is_displayed(): - parent = find_child( "#scenario-info-dialog .scenario-card" ) - else: - assert False - card = _unload_scenario_card() - # check if the balance graph is shown + try: + card = _unload_scenario_card() + except StaleElementReferenceException: + # NOTE: We can get here if the scenario card is reloaded while we're reading it. + return False if bgraph: - balance = card["balances"]["roar"] - assert balance[0]["name"] == bgraph[0] - assert balance[1]["name"] == bgraph[1] + balance = card["balances"].get( "roar" ) + if not balance: + return False + if balance[0]["name"] != bgraph[0] or balance[1]["name"] != bgraph[1]: + return False else: - assert "roar" not in card["balances"] + if "roar" in card["balances"]: + return False # check if the "connect to ROAR" button is shown - elem = find_child( ".connect-roar", parent ) + elem = find_child( "#scenario-info-dialog .connect-roar" ) if connect: - assert elem.is_displayed() + if not elem.is_displayed(): + return False else: - assert not elem.is_displayed() + if elem and elem.is_displayed(): + return False # check if the "disconnect from ROAR" is shown - elem = find_child( ".disconnect-roar", parent ) + elem = find_child( "#scenario-info-dialog .disconnect-roar" ) if disconnect: - assert elem.is_displayed() + if not elem.is_displayed(): + return False else: - assert not elem or not elem.is_displayed() + if elem and elem.is_displayed(): + return False + + return True + + def disconnect_roar(): + """Disconnect the scenario from ROAR.""" + btn = find_child( "#scenario-info-dialog .disconnect-roar" ) + btn.click() + wait_for( 2, lambda: not btn.is_displayed() ) + wait_for( 2, lambda: check( None, True, False ) ) # import the "Fighting Withdrawal" scenario - _do_scenario_search( "withdrawal", [2], webdriver ) - check( ["Russian","Finnish"], False, False ) + dlg = _do_scenario_search( "withdrawal", [2], webdriver ) + wait_for( 2, lambda: check( ["Russian","Finnish"], False, False ) ) find_child( "#scenario-search button.import" ).click() + wait_for( 2, lambda: not dlg.is_displayed() ) # connect to another ROAR scenario find_child( "button.scenario-search" ).click() - check( ["Russian","Finnish"], False, True ) - find_child( "#scenario-info-dialog .disconnect-roar" ).click() - check( None, True, False ) + wait_for( 2, lambda: check( ["Russian","Finnish"], False, True ) ) + disconnect_roar() find_child( "#scenario-info-dialog .connect-roar" ).click() dlg = wait_for_elem( 2, ".ui-dialog.select-roar-scenario" ) find_child( ".select2-search__field", dlg ).send_keys( "another" ) find_child( ".select2-search__field", dlg ).send_keys( Keys.RETURN ) - check( ["British","French"], False, True ) + wait_for( 2, lambda: check( ["British","French"], False, True ) ) find_child( ".ui-dialog.scenario-info button.ok" ).click() # disconnect from the ROAR scenario find_child( "button.scenario-search" ).click() - check( ["British","French"], False, True ) - find_child( "#scenario-info-dialog .disconnect-roar" ).click() - check( None, True, False ) + wait_for( 2, lambda: check( ["British","French"], False, True ) ) + disconnect_roar() find_child( ".ui-dialog.scenario-info button.ok" ).click() # connect to a ROAR scenario find_child( "button.scenario-search" ).click() - check( None, True, False ) + wait_for( 2, lambda: check( None, True, False ) ) find_child( "#scenario-info-dialog .connect-roar" ).click() dlg = wait_for_elem( 2, ".ui-dialog.select-roar-scenario" ) elem = find_child( ".select2-search__field", dlg ) elem.send_keys( "withdrawal" ) elem.send_keys( Keys.RETURN ) - check( ["Russian","Finnish"], False, True ) + wait_for( 2, lambda: check( ["Russian","Finnish"], False, True ) ) find_child( ".ui-dialog.scenario-info button.ok" ).click() # --------------------------------------------------------------------- @@ -512,15 +517,13 @@ def test_scenario_linking( webapp, webdriver ): check( "1" ) # import the "empty" scenario (on top of the current scenario) - _do_scenario_search( "Untitled", ["no-content"], webdriver ) - find_child( "#scenario-search button.import" ).click() - find_child( "#scenario-search button.confirm-import" ).click() + dlg = _do_scenario_search( "Untitled", ["no-content"], webdriver ) + _import_scenario_and_confirm( dlg ) check( "no-content" ) # import the "Fighting Withdrawal" scenario (on top of the current scenario) - _do_scenario_search( "Fighting Withdrawal", [2], webdriver ) - find_child( "#scenario-search button.import" ).click() - find_child( "#scenario-search button.confirm-import" ).click() + dlg = _do_scenario_search( "Fighting Withdrawal", [2], webdriver ) + _import_scenario_and_confirm( dlg ) check( "2" ) # unlink the scenario @@ -541,12 +544,29 @@ def _do_scenario_search( query, expected, webdriver ): dlg = wait_for_elem( 2, "#scenario-search" ) ActionChains( webdriver ).key_up( Keys.SHIFT ).perform() + # initialize + card = find_child( ".scenario-card", dlg ) + seq_no = card.get_attribute( "data-seqNo" ) + # do the search and check the results elem = find_child( "input.select2-search__field", dlg ) elem.clear() - elem.send_keys( query ) - results = _unload_search_results() - assert [ r[0] for r in results ] == [ str(e) for e in expected ] + # IMPORTANT: We can't use send_keys() here because it simulates a key-press for each character in the query, + # and the incremental search feature means that we will constantly be loading scenario cards as the results + # change, which makes it difficult for us to be able to tell when everything's stopped and it's safe to unload + # the scenario card. Instead, we manually load the text box and trigger an event to update the UI. + webdriver.execute_script( + "arguments[0].value = arguments[1] ; $(arguments[0]).trigger('input')", + elem, query + ) + def check_search_results(): #pylint: disable=missing-docstring + results = _unload_search_results() + return [ r[0] for r in results ] == [ str(e) for e in expected ] + wait_for( 2, check_search_results ) + + # wait for the scenario card to finish loading + # NOTE: We do this here since the typical use case is to search for something, then check what was found. + wait_for( 2, lambda: card.get_attribute( "data-seqNo" ) != seq_no ) return dlg @@ -560,12 +580,8 @@ def _unload_search_results(): def _unload_scenario_card(): #pylint: disable=too-many-branches,too-many-locals """Unload the scenario card.""" - if find_child( "#scenario-search" ).is_displayed(): - card = find_child( "#scenario-search .scenario-card" ) - elif find_child( "#scenario-info-dialog" ).is_displayed(): - card = find_child( "#scenario-info-dialog .scenario-card" ) - else: - assert False + # initialize + card = wait_for( 2, _find_scenario_card ) results = {} # unload the basic text content @@ -653,7 +669,7 @@ def _unload_scenario_card(): #pylint: disable=too-many-branches,too-many-locals comments = find_child( ".{} .comments".format(player), oba ).text if comments: oba_info[-1].extend( comments.split( "\n" ) ) - elem = find_child( ".date-warning", oba ) + elem = find_child( ".date-warning", oba ) if elem.is_displayed(): oba_info.append( elem.text ) results[ "oba" ] = oba_info @@ -754,3 +770,18 @@ def _unlink_scenario(): btn = find_children( ".ui-dialog .ui-dialog-buttonpane button" )[1] assert btn.text == "Unlink" btn.click() + +def _import_scenario_and_confirm( dlg ): + """Import a scenario, confirming any warnings.""" + find_child( "button.import", dlg ).click() + btn = wait_for_elem( 2, "button.confirm-import", dlg ) + btn.click() + wait_for( 2, lambda: not dlg.is_displayed() ) + +def _find_scenario_card(): + """Find the currently-displayed scenario card.""" + if find_child( "#scenario-search" ).is_displayed(): + return find_child( "#scenario-search .scenario-card" ) + if find_child( "#scenario-info-dialog" ).is_displayed(): + return find_child( "#scenario-info-dialog .scenario-card" ) + return None diff --git a/vasl_templates/webapp/tests/utils.py b/vasl_templates/webapp/tests/utils.py index ae99f33..0b3f480 100644 --- a/vasl_templates/webapp/tests/utils.py +++ b/vasl_templates/webapp/tests/utils.py @@ -589,11 +589,11 @@ def wait_for( timeout, func ): assert time.time() - start_time < timeout time.sleep( 0.1 ) -def wait_for_elem( timeout, elem_id, parent=None ): +def wait_for_elem( timeout, sel, parent=None ): """Wait for an element to appear .""" args = { "elem": None } def check_elem(): #pylint: disable=missing-docstring - args["elem"] = find_child( elem_id, parent ) + args["elem"] = find_child( sel, parent ) return args["elem"] is not None and args["elem"].is_displayed() wait_for( timeout, check_elem ) return args["elem"]