diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index e0c6efdc9..fc22071fc 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -39,7 +39,7 @@ jobs: python3 -m venv py-venv source py-venv/bin/activate python3 -m pip install --upgrade pip - python3 -m pip install --upgrade build packaging setuptools wheel pytest + python3 -m pip install --upgrade build packaging setuptools wheel python3 -m pip install --upgrade -r requirements_mpi.txt python3 -m pip install --upgrade -r src/python/impactx/dashboard/requirements.txt python3 -m pip install --upgrade -r examples/requirements.txt diff --git a/src/python/impactx/dashboard/Analyze/plotsMain.py b/src/python/impactx/dashboard/Analyze/plotsMain.py index 4d71ffb86..e85d49068 100644 --- a/src/python/impactx/dashboard/Analyze/plotsMain.py +++ b/src/python/impactx/dashboard/Analyze/plotsMain.py @@ -152,6 +152,7 @@ async def print_lines(): ctrl.terminal_print(line) ctrl.terminal_print("Simulation complete.") + asyncio.create_task(print_lines()) @@ -175,6 +176,7 @@ def on_filtered_data_change(**kwargs): @ctrl.add("run_simulation") def run_simulation_and_store(): + state.simulation_complete = True, state.plot_options = available_plot_options(simulationClicked=True) run_simulation_impactX() update_plot() @@ -228,7 +230,7 @@ def plot(): with vuetify.VCard(style="height: 50vh; width: 150vh;"): with vuetify.VTabs(v_model=("active_tab", 0)): vuetify.VTab("Plot") - vuetify.VTab("Interact") + vuetify.VTab("Interact", id="interact") vuetify.VDivider() with vuetify.VTabsItems(v_model="active_tab"): with vuetify.VTabItem(): diff --git a/src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py b/src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py index b57e16fe7..f66101da1 100644 --- a/src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py +++ b/src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py @@ -139,7 +139,6 @@ def on_distribution_type_change(**kwargs): @ctrl.add("updateDistributionParameters") def on_distribution_parameter_change(parameter_name, parameter_value, parameter_type): - parameter_value, input_type = generalFunctions.determine_input_type(parameter_value) error_message = generalFunctions.validate_against(parameter_value, parameter_type) update_distribution_parameters(parameter_name, parameter_value, error_message) @@ -175,6 +174,7 @@ def card(): with vuetify.VCol(cols=8): vuetify.VCombobox( label="Select Distribution", + id="selected_distribution", v_model=("selectedDistribution",), items=("listOfDistributions",), dense=True, @@ -182,6 +182,7 @@ def card(): with vuetify.VCol(cols=4): vuetify.VSelect( v_model=("selectedDistributionType",), + id="selected_distribution_type", label="Type", items=(["Native", "Twiss"],), # change=(ctrl.kin_energy_unit_change, "[$event]"), @@ -199,6 +200,7 @@ def card(): ): vuetify.VTextField( label=("parameter.parameter_name",), + id=("parameter.parameter_name",), v_model=("parameter.parameter_default_value",), change=( ctrl.updateDistributionParameters, diff --git a/src/python/impactx/dashboard/Input/inputParameters/inputMain.py b/src/python/impactx/dashboard/Input/inputParameters/inputMain.py index 52e7e63ba..25245c6bd 100644 --- a/src/python/impactx/dashboard/Input/inputParameters/inputMain.py +++ b/src/python/impactx/dashboard/Input/inputParameters/inputMain.py @@ -93,6 +93,7 @@ def card(self): vuetify.VCombobox( v_model=("particle_shape",), label="Particle Shape", + id="particle_shape", items=([1, 2, 3],), dense=True, ) @@ -101,6 +102,7 @@ def card(self): vuetify.VTextField( v_model=("npart",), label="Number of Particles", + id="npart", error_messages=("npart_validation",), change=( ctrl.on_input_change, @@ -114,6 +116,7 @@ def card(self): vuetify.VTextField( v_model=("kin_energy",), label="Kinetic Energy", + id="kin_energy", error_messages=("kin_energy_validation",), change=( ctrl.on_input_change, @@ -127,6 +130,7 @@ def card(self): vuetify.VSelect( v_model=("kin_energy_unit",), label="Unit", + id="kin_energy_unit", items=(["meV", "eV", "keV", "MeV", "GeV", "TeV"],), change=(ctrl.kin_energy_unit_change, "[$event]"), dense=True, @@ -135,6 +139,7 @@ def card(self): with vuetify.VCol(cols=8, classes="py-0"): vuetify.VTextField( label="Bunch Charge", + id="bunch_charge_C", v_model=("bunch_charge_C",), error_messages=("bunch_charge_C_validation",), change=( diff --git a/src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py b/src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py index 5371042c2..144610daf 100644 --- a/src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py +++ b/src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py @@ -165,7 +165,6 @@ def on_add_lattice_element_click(): def on_lattice_element_parameter_change( index, parameter_name, parameter_value, parameter_type ): - parameter_value, input_type = generalFunctions.determine_input_type(parameter_value) error_message = generalFunctions.validate_against(parameter_value, parameter_type) update_latticeElement_parameters( @@ -253,6 +252,7 @@ def card(): with vuetify.VCol(cols=8): vuetify.VCombobox( label="Select Accelerator Lattice", + id="selected_lattice", v_model=("selectedLattice", None), items=("listOfLatticeElements",), error_messages=("isSelectedLatticeListEmpty",), @@ -262,6 +262,7 @@ def card(): with vuetify.VCol(cols="auto"): vuetify.VBtn( "ADD", + id="add_button", color="primary", dense=True, classes="mr-2", @@ -270,6 +271,7 @@ def card(): with vuetify.VCol(cols="auto"): vuetify.VBtn( "CLEAR", + id="clear_button", color="secondary", dense=True, classes="mr-2", @@ -279,6 +281,7 @@ def card(): vuetify.VIcon( "mdi-cog", click="showDialog_settings = true", + id="lattice_settings_icon", ) with vuetify.VRow(): with vuetify.VCol(): @@ -337,6 +340,7 @@ def card(): ): vuetify.VTextField( label=("parameter.parameter_name",), + id=("parameter.parameter_name + index",), v_model=( "parameter.parameter_default_value", ), @@ -420,6 +424,7 @@ def dialog_lattice_settings(): with vuetify.VCol(no_gutters=True): vuetify.VTextField( v_model=("nsliceDefaultValue",), + id="nslice_default_value", change=( ctrl.nsliceDefaultChange, "['nslice', $event]", @@ -431,3 +436,13 @@ def dialog_lattice_settings(): style="max-width: 75px", classes="ma-0 pa-0", ) + vuetify.VDivider() + with vuetify.VCardActions(): + vuetify.VSpacer() + vuetify.VBtn( + "Close", + id="lattice_settings_close", + color="primary", + text=True, + click="showDialog_settings = false", + ) diff --git a/src/python/impactx/dashboard/Input/trameFunctions.py b/src/python/impactx/dashboard/Input/trameFunctions.py index 599e00c03..a423389cc 100644 --- a/src/python/impactx/dashboard/Input/trameFunctions.py +++ b/src/python/impactx/dashboard/Input/trameFunctions.py @@ -40,4 +40,4 @@ def create_route(route_title, mdi_icon): with vuetify.VListItemIcon(): vuetify.VIcon(mdi_icon) with vuetify.VListItemContent(): - vuetify.VListItemTitle(route_title) + vuetify.VListItemTitle(route_title, id=f"{route_title}_route") diff --git a/src/python/impactx/dashboard/Toolbar/toolbarMain.py b/src/python/impactx/dashboard/Toolbar/toolbarMain.py index ff5894770..8295047c1 100644 --- a/src/python/impactx/dashboard/Toolbar/toolbarMain.py +++ b/src/python/impactx/dashboard/Toolbar/toolbarMain.py @@ -29,6 +29,7 @@ def plot_options(): v_model=("active_plot", "1D plots over s"), items=("plot_options",), label="Select plot to view", + id="select_plot", hide_details=True, dense=True, style="max-width: 250px", @@ -39,11 +40,23 @@ def plot_options(): def run_simulation_button(): vuetify.VBtn( "Run Simulation", + id="run_simulation_button", style="background-color: #00313C; color: white; margin: 0 20px;", click=ctrl.run_simulation, disabled=("disableRunSimulationButton", True), ) + @staticmethod + def show_simulation_complete(): + vuetify.VAlert( + "Simulation Complete", + v_model=("simulation_complete", False), + id="simulation_complete", + type="success", + dense=True, + classes="mt-4", + ) + # ----------------------------------------------------------------------------- # Content @@ -62,6 +75,7 @@ def run_toolbar(): """ vuetify.VSpacer(), + ToolbarElements.show_simulation_complete(), ToolbarElements.run_simulation_button(), @staticmethod diff --git a/src/python/impactx/dashboard/__main__.py b/src/python/impactx/dashboard/__main__.py index 285c3fc18..9ab37bd28 100644 --- a/src/python/impactx/dashboard/__main__.py +++ b/src/python/impactx/dashboard/__main__.py @@ -55,7 +55,7 @@ # GUI # ----------------------------------------------------------------------------- def init_terminal(): - with xterm.XTerm(v_if="$route.path == '/Run'") as term: + with xterm.XTerm(v_show="$route.path == '/Run'", id="xterm_component") as term: ctrl.terminal_print = term.writeln diff --git a/tests/python/dashboard/test_dashboard.py b/tests/python/dashboard/test_dashboard.py new file mode 100644 index 000000000..a455d88d9 --- /dev/null +++ b/tests/python/dashboard/test_dashboard.py @@ -0,0 +1,88 @@ +import importlib + +import pytest +from util import ( + check_until_visible, + set_input_value, + start_dashboard, + wait_for_dashboard_ready, + wait_for_ready, +) + +TIMEOUT = 60 + + +@pytest.mark.skipif( + importlib.util.find_spec("seleniumbase") is None, + reason="seleniumbase is not available", +) +def test_dashboard(): + """ + This test runs the FODO example on the dashboard and verifies + that the simulation has ran successfully. + """ + from seleniumbase import SB + + app_process = None + + try: + with SB(headless=True) as sb: + app_process = start_dashboard() + wait_for_dashboard_ready(app_process, timeout=TIMEOUT) + + url = "http://localhost:8080/index.html#/Input" + sb.open(url) + + wait_for_ready(sb, ".trame__loader", TIMEOUT) + + # Adjust beam properties + sb.click("#particle_shape") + sb.click("div.v-list-item:nth-of-type(2)") + set_input_value(sb, "npart", 10000) + set_input_value(sb, "kin_energy", 2.0e3) + set_input_value(sb, "bunch_charge_C", 1.0e-9) + + # Adjust beam distribution + set_input_value(sb, "selected_distribution", "Waterbag") + set_input_value(sb, "lambdaX", 3.9984884770e-5) + set_input_value(sb, "lambdaY", 3.9984884770e-5) + set_input_value(sb, "lambdaT", 1.0e-3) + set_input_value(sb, "lambdaPx", 2.6623538760e-5) + set_input_value(sb, "lambdaPy", 2.6623538760e-5) + set_input_value(sb, "lambdaPt", 2.0e-3) + set_input_value(sb, "muxpx", -0.846574929020762) + set_input_value(sb, "muypy", 0.846574929020762) + set_input_value(sb, "mutpt", 0.0) + + # Adjust lattice configuration + sb.click("#lattice_settings_icon") + set_input_value(sb, "nslice_default_value", 25) + sb.click("#lattice_settings_close") + sb.click("#clear_button") + set_input_value(sb, "selected_lattice", "Drift") + sb.click("#add_button") + set_input_value(sb, "ds0", 0.25) + set_input_value(sb, "selected_lattice", "Quad") + sb.click("#add_button") + set_input_value(sb, "ds1", 1.0) + set_input_value(sb, "k1", 1.0) + set_input_value(sb, "selected_lattice", "Drift") + sb.click("#add_button") + set_input_value(sb, "ds2", 0.5) + set_input_value(sb, "selected_lattice", "Quad") + sb.click("#add_button") + set_input_value(sb, "ds3", 1.0) + set_input_value(sb, "k3", -1.0) + set_input_value(sb, "selected_lattice", "Drift") + sb.click("#add_button") + set_input_value(sb, "ds4", 0.25) + + # Run simulation + sb.click("#Run_route") + sb.click("#run_simulation_button") + + assert check_until_visible(sb, "#simulation_complete"), "Simulation did not complete successfully." + + finally: + if app_process is not None: + app_process.terminate() diff --git a/tests/python/dashboard/util.py b/tests/python/dashboard/util.py new file mode 100644 index 000000000..4b656fe50 --- /dev/null +++ b/tests/python/dashboard/util.py @@ -0,0 +1,88 @@ +import os +import subprocess +import sys +import time + +from selenium.common.exceptions import ( + ElementNotInteractableException, + JavascriptException, +) + + +def wait_for_ready(sb, element_name, timeout=10): + """ + Waits until the specified element is present in the DOM. + """ + for i in range(timeout): + print(f"wait_for_ready {i}") + if sb.is_element_present(element_name): + sb.sleep(1) + else: + print("Ready") + return + + +def wait_for_dashboard_ready(process, timeout=60): + """ + Function waits until the dashboard server is ready by checking the process output. + """ + for i in range(timeout): + line = process.stdout.readline() + if line: + print(line, end="") + if "App running at:" in line: + print("Dashboard is ready!") + return + raise Exception("Dashboard did not start correctly.") + + +def set_input_value(sb, element_id, value, timeout=60): + """ + Function to clear, update, and trigger a change event on an input field by ID. + Waits until the element is interactable before performing actions. + """ + + selector = f"#{element_id}" + end_time = time.time() + timeout + + while True: + try: + sb.clear(selector) + sb.update_text(selector, value) + sb.send_keys(selector, "\n") + break + except (ElementNotInteractableException, JavascriptException): + if time.time() > end_time: + raise Exception( + f"Element {selector} not interactable after {timeout} seconds." + ) + + +def check_until_visible(sb, selector, timeout=10, interval=1): + """ + Function which retries checking if an element is visible. + """ + + end_time = time.time() + timeout + while time.time() < end_time: + if sb.is_element_visible(selector): + return True + time.sleep(interval) + return False + + +def start_dashboard(): + """ + Function which starts up impactx-dashboard server. + """ + script_dir = os.path.dirname(os.path.abspath(__file__)) + working_directory = os.path.join(script_dir, "../../../src/python/impactx") + working_directory = os.path.normpath(working_directory) + + return subprocess.Popen( + [sys.executable, "-m", "dashboard", "--server"], + cwd=working_directory, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) diff --git a/tests/python/requirements.txt b/tests/python/requirements.txt index 1052c9366..318cac888 100644 --- a/tests/python/requirements.txt +++ b/tests/python/requirements.txt @@ -1,2 +1,3 @@ -r ../../examples/requirements.txt pytest +seleniumbase