diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index d05b1f5d7..906996153 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -79,6 +79,16 @@ jobs: run: | ctest --test-dir build --output-on-failure -E pytest.AMReX + - name: Upload dashboard screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: dashboard-screenshots-macos + path: | + build/**/pytest.ImpactX.dashboard/screenshots/**/*.png + build/**/screenshots/**/*.png + if-no-files-found: ignore + - name: run installed python module run: | cmake --build build --target pip_install diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index 0353e955e..0f16081a8 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -69,6 +69,16 @@ jobs: run: | ctest --test-dir build --output-on-failure --label-exclude slow + - name: Upload dashboard screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: dashboard-screenshots-ubuntu + path: | + build/**/pytest.ImpactX.dashboard/screenshots/**/*.png + build/**/screenshots/**/*.png + if-no-files-found: ignore + - name: run installed app run: | cmake --build build --target install diff --git a/src/python/impactx/dashboard/Input/components/navigation.py b/src/python/impactx/dashboard/Input/components/navigation.py index c964639fa..8d64d8439 100644 --- a/src/python/impactx/dashboard/Input/components/navigation.py +++ b/src/python/impactx/dashboard/Input/components/navigation.py @@ -47,8 +47,8 @@ def create_dialog_tabs(name: str, num_tabs: int, tab_names: list[str]) -> None: card = vuetify.VCard() with card: with vuetify.VTabs(v_model=(f"{name}", 0)): - for tab_name in tab_names: - vuetify.VTab(tab_name) + for i, tab_name in enumerate(tab_names): + vuetify.VTab(tab_name, id=f"{name}_tab_{i}") vuetify.VDivider() return card diff --git a/src/python/impactx/dashboard/Input/lattice/defaults_handler/__init__.py b/src/python/impactx/dashboard/Input/lattice/defaults_handler/__init__.py new file mode 100644 index 000000000..987aa246d --- /dev/null +++ b/src/python/impactx/dashboard/Input/lattice/defaults_handler/__init__.py @@ -0,0 +1,5 @@ +from .ui import LatticeDefaultsHandler + +__all__ = [ + "LatticeDefaultsHandler", +] diff --git a/src/python/impactx/dashboard/Input/lattice/defaults_handler/components.py b/src/python/impactx/dashboard/Input/lattice/defaults_handler/components.py new file mode 100644 index 000000000..8b75b5bec --- /dev/null +++ b/src/python/impactx/dashboard/Input/lattice/defaults_handler/components.py @@ -0,0 +1,95 @@ +""" +This file is part of ImpactX + +Copyright 2025 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from .... import ctrl, html, vuetify + + +def header_summary(): + """ + Renders a small legend for match count below the search bar. + """ + vuetify.VIcon( + "mdi-checkbox-blank-circle", + size="x-small", + color="primary", + classes="mr-2", + ) + html.Span( + "Matches: {{ lattice_defaults_filtered.length }} / {{ lattice_defaults.length }}", + id="lattice_defaults_search_summary", + classes="text-caption text-grey-darken-1", + aria_live="polite", + __properties=["aria-live"], + ) + + +def text_field(**kwargs): + """ + Shared text field with common defaults for this view. + Distinct props can be passed via kwargs and will override defaults. + """ + defaults = dict( + variant="outlined", + density="compact", + hide_details=True, + style="min-width: 0;", + ) + defaults.update(kwargs) + return vuetify.VTextField(**defaults) + + +def search_bar(): + """ + Search input with match summary on a separate row for visual clarity. + """ + # Row 1: Search field (full width) + with vuetify.VRow(classes="align-start"): + with vuetify.VCol(cols=12, classes="pb-0"): + text_field( + v_model=("lattice_defaults_filter", ""), + label="Search parameters", + placeholder="e.g., nslice", + prepend_inner_icon="mdi-magnify", + clearable=True, + classes="text-body-2", + id="lattice_defaults_search", + aria_label="Search parameters", + aria_describedby="lattice_defaults_search_summary", + __properties=["aria-label", "aria-describedby"], + ) + + # Row 2: Match summary (subtle, full width) + with vuetify.VRow(classes="mt-n2 mb-1"): + with vuetify.VCol(cols=12, classes="d-flex align-center"): + header_summary() + + +def pagination(): + """ + Pagination control bound to lattice defaults filter list. + """ + return vuetify.VPagination( + v_model=("lattice_defaults_page", 1), + length=("Math.max(1, Math.ceil(lattice_defaults_filtered.length / 5))",), + total_visible=7, + __properties=["length", "total_visible"], + density="comfortable", + ) + + +def reset_button(): + """ + Reset defaults button. + """ + return vuetify.VBtn( + "Reset Defaults", + id="reset_lattice_defaults", + color="primary", + click=ctrl.reset_lattice_defaults, + block=True, + ) diff --git a/src/python/impactx/dashboard/Input/lattice/defaults_handler/ui.py b/src/python/impactx/dashboard/Input/lattice/defaults_handler/ui.py new file mode 100644 index 000000000..3fdbc734d --- /dev/null +++ b/src/python/impactx/dashboard/Input/lattice/defaults_handler/ui.py @@ -0,0 +1,220 @@ +""" +This file is part of ImpactX + +Copyright 2025 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from copy import deepcopy + +from .... import ctrl, html, state, vuetify +from . import components +from . import utils as _utils + +# Initialize applied and staged defaults +state.lattice_defaults_applied = _utils.build_initial_defaults_list() +state.lattice_defaults = deepcopy(state.lattice_defaults_applied) +state.is_only_default = len(state.lattice_defaults) == 1 +state.lattice_defaults_filter = "" +state.lattice_defaults_page = 1 +state.lattice_defaults_filtered = [] +state.lattice_defaults_no_results = False +state.lattice_defaults_has_changes = False + + +def _sync_has_changes_flag() -> None: + """ + Set has_changes when staged edits differ from applied values. + """ + state.lattice_defaults_has_changes = ( + state.lattice_defaults != state.lattice_defaults_applied + ) + state.dirty("lattice_defaults_has_changes") + + +# ----------------------------------------------------------------------------- +# State listeners and controllers +# ----------------------------------------------------------------------------- + + +@state.change("lattice_defaults") +def _on_defaults_change(*_args, **_kwargs): + # Only update UI-related state; do not apply globally until "Apply" is clicked + _utils.update_delete_availability() + _utils.sync_filtered_defaults() + + +@state.change("lattice_defaults_filter") +def _on_filter_change(*_args, **_kwargs): + """ + Reset to the first page when filter changes and update filtered list + """ + state.lattice_defaults_page = 1 + state.dirty("lattice_defaults_page") + _utils.sync_filtered_defaults() + + +@ctrl.add("update_lattice_default") +def _on_update_default(field_name: str, identifier, new_value) -> None: + """ + Update a field of a lattice default entry. + field_name: which field to update, e.g. "value" + identifier: either the row index (int) or the parameter name (str) + new_value: the value to assign + """ + # Resolve the index: use it directly if it's int, otherwise look up by name + if isinstance(identifier, int): + row_index = identifier + else: + row_index = next( + ( + i + for i, row in enumerate(state.lattice_defaults) + if row.get("name") == identifier + ), + None, + ) + if row_index is None: + return + + entry = state.lattice_defaults[row_index] + if field_name == "value": + entry["value"] = new_value + + state.dirty("lattice_defaults") + _sync_has_changes_flag() + + +@ctrl.add("reset_lattice_defaults") +def _on_reset_defaults() -> None: + state.lattice_defaults = _utils.build_initial_defaults_list() + state.lattice_defaults_filter = "" + state.dirty("lattice_defaults") + _sync_has_changes_flag() + + +@ctrl.add("apply_lattice_defaults") +def _on_apply_defaults() -> None: + """ + Mark current overrides as applied. Recompute parameter map to ensure + downstream consumers see the latest values, then clear the dirty flag. + """ + # Persist staged edits as applied and recompute parameter map + state.lattice_defaults_applied = deepcopy(state.lattice_defaults) + _utils.apply_overrides_to_parameter_map() + state.lattice_defaults_has_changes = False + state.dirty("lattice_defaults_has_changes") + + +@state.change("lattice_configuration_dialog_settings") +def _on_dialog_visibility_change(lattice_configuration_dialog_settings, **_): + # On close/cancel, revert staged edits to last applied and clear dirty flag + if not lattice_configuration_dialog_settings: + state.lattice_defaults = deepcopy(state.lattice_defaults_applied) + _utils.sync_filtered_defaults() + state.lattice_defaults_has_changes = False + state.dirty("lattice_defaults", "lattice_defaults_has_changes") + + +class LatticeDefaultsHandler: + """ + UI entry point for editing lattice parameter default overrides. + """ + + @staticmethod + def _build_initial_defaults_list(): + return _utils.build_initial_defaults_list() + + @staticmethod + def defaults_handler(): + """ + Renders the Defaults tab using a Variables-like UI. + """ + if ( + isinstance(state.lattice_defaults, list) + and len(state.lattice_defaults) == 1 + and not ( + state.lattice_defaults[0].get("name") + or state.lattice_defaults[0].get("value") + ) + ): + state.lattice_defaults = _utils.build_initial_defaults_list() + state.dirty("lattice_defaults") + _utils.sync_filtered_defaults() + elif not state.lattice_defaults_filtered: + _utils.sync_filtered_defaults() + with vuetify.VCardText(classes="py-1 pb-0"): + components.search_bar() + with vuetify.VCardText(classes="pt-1"): + with vuetify.VContainer(fluid=True): + with vuetify.VRow( + v_for=( + "(item, index) in lattice_defaults_filtered.slice((lattice_defaults_page - 1) * 5, (lattice_defaults_page) * 5)", + ), + classes="align-center justify-center py-0", + ): + with vuetify.VCol(cols=5, classes="pr-0"): + components.text_field( + placeholder="Parameter Name", + v_model=("item.name",), + id=("'default_name_' + (item.name || '')",), + background_color="grey lighten-4", + readonly=True, + clearable=False, + ) + with vuetify.VCol(cols=1, classes="px-0 text-center"): + html.Span("=", classes="mx-0") + with vuetify.VCol(cols=4, classes="pl-0"): + components.text_field( + placeholder="Default Value", + v_model=("item.value",), + id=("'default_value_' + (item.name || '')",), + type="text", + background_color="grey lighten-4", + update_modelValue=( + ctrl.update_lattice_default, + "['value', item.name, $event]", + ), + clearable=True, + ) + vuetify.VAlert( + "No matches found", + type="info", + variant="tonal", + density="compact", + border=True, + classes="ma-2", + v_show=("lattice_defaults_no_results",), + ) + with vuetify.VCardText(classes="pt-0 pb-2"): + # Pagination centered above the footer actions + with vuetify.VRow(classes="justify-center"): + with vuetify.VCol(cols="auto"): + components.pagination() + + vuetify.VDivider(classes="my-2") + + # Footer actions: Reset (left) and Close/Apply (right) + with vuetify.VRow(classes="align-center"): + with vuetify.VCol(cols=6): + vuetify.VBtn( + "Reset", + color="primary", + variant="tonal", + click=ctrl.reset_lattice_defaults, + ) + with vuetify.VCol(cols=6, classes="d-flex justify-end"): + vuetify.VBtn( + "Close", + variant="text", + color="#00313C", + click="lattice_configuration_dialog_settings = false", + classes="mr-2", + ) + vuetify.VBtn( + "Apply", + color="primary", + disabled=("!lattice_defaults_has_changes",), + click=ctrl.apply_lattice_defaults, + ) diff --git a/src/python/impactx/dashboard/Input/lattice/defaults_handler/utils.py b/src/python/impactx/dashboard/Input/lattice/defaults_handler/utils.py new file mode 100644 index 000000000..21877cb4b --- /dev/null +++ b/src/python/impactx/dashboard/Input/lattice/defaults_handler/utils.py @@ -0,0 +1,102 @@ +""" +This file is part of ImpactX + +Copyright 2025 ImpactX contributors +Authors: Parthib Roy, Axel Huebl +License: BSD-3-Clause-LBNL +""" + +from impactx import elements + +from .... import state +from ...defaults_helper import InputDefaultsHelper +from ...utils import GeneralFunctions + +EXCLUDED_LATTICE_DEFAULTS: set[str] = {"name"} +EXCLUDED_LATTICE_CLASSES: set[str] = {"BeamMonitor"} + + +def apply_overrides_to_parameter_map() -> None: + """ + Rebuild the element->(name, default, type) map and apply overrides. + Cast numeric overrides to the appropriate type for downstream use. + """ + data = InputDefaultsHelper.class_parameters_with_defaults(elements) + + # Quick lookup of parameter types for casting + type_lookup: dict[str, str] = {} + for params in data.values(): + for pname, _pdefault, ptype in params: + type_lookup[pname] = ptype + + # Use the applied overrides (not the staged edits) + overrides = getattr(state, "lattice_defaults_applied", []) or [] + for override in overrides: + name = (override.get("name") or "").strip() + value = override.get("value") + if not name: + continue + if name in EXCLUDED_LATTICE_DEFAULTS: + continue + + ptype = type_lookup.get(name) + cast_value = value + if ptype in {"int", "float"}: + cast_value = GeneralFunctions.convert_to_numeric(value) + + for key, parameters in data.items(): + if key in EXCLUDED_LATTICE_CLASSES: + continue + for i, (pname, _pdefault, ptype) in enumerate(parameters): + if pname == name: + parameters[i] = (pname, cast_value, ptype) + + state.listOfLatticeElementParametersAndDefault = data + + +def update_delete_availability() -> None: + state.is_only_default = len(state.lattice_defaults) == 1 + + +def build_initial_defaults_list() -> list[dict]: + """ + Build an initial list of defaults exposing all known lattice parameter names + with their current default values. + """ + rows: list[dict] = [] + seen: set[str] = set() + data = InputDefaultsHelper.class_parameters_with_defaults(elements) + for class_name, params in data.items(): + if class_name in EXCLUDED_LATTICE_CLASSES: + continue + for pname, pdefault, _ptype in params: + if pname in EXCLUDED_LATTICE_DEFAULTS: + continue + if pname in seen: + continue + seen.add(pname) + rows.append( + { + "name": pname, + "value": pdefault if pdefault is not None else "", + "error_message": "", + } + ) + return rows + + +def _filter_defaults_by_query() -> list[dict]: + query = (state.lattice_defaults_filter or "").lower() + defaults = state.lattice_defaults or [] + if not query: + return defaults + return [row for row in defaults if query in (row.get("name") or "").lower()] + + +def sync_filtered_defaults() -> None: + filtered = _filter_defaults_by_query() + state.lattice_defaults_filtered = filtered + state.lattice_defaults_no_results = ( + bool(state.lattice_defaults_filter) and not filtered + ) + state.dirty("lattice_defaults_filtered", "lattice_defaults_no_results") diff --git a/src/python/impactx/dashboard/Input/lattice/ui.py b/src/python/impactx/dashboard/Input/lattice/ui.py index c095b6be3..9ad6a1b30 100644 --- a/src/python/impactx/dashboard/Input/lattice/ui.py +++ b/src/python/impactx/dashboard/Input/lattice/ui.py @@ -19,6 +19,7 @@ from ..defaults import BEAM_MONITOR_DEFAULT_NAME from ..defaults_helper import InputDefaultsHelper from ..validation import DashboardValidation, errors_tracker +from .defaults_handler import LatticeDefaultsHandler from .utils import LatticeConfigurationHelper from .variable_handler import LatticeVariableHandler @@ -31,7 +32,6 @@ ) state.selected_lattice_list = [] -state.nslice = "" def add_lattice_element() -> dict: @@ -194,16 +194,7 @@ def on_move_latticeElementIndex_down_click(index): state.dirty("selected_lattice_list") -@ctrl.add("nsliceDefaultChange") -def update_default_value(parameter_name, new_value): - data = InputDefaultsHelper.class_parameters_with_defaults(elements) - - for key, parameters in data.items(): - for i, param in enumerate(parameters): - if param[0] == parameter_name: - parameters[i] = (param[0], new_value, param[2]) - - state.listOfLatticeElementParametersAndDefault = data +# Default overrides are managed by LatticeDefaultsHandler # ----------------------------------------------------------------------------- @@ -296,19 +287,9 @@ def defaults_handler(): Allows users to pre-determine default values for any parameter name. Example: user can set 'nslice' to 25 and every element added thereafter will have the nslice value - of 25 as default. + of 25 as default. UI mirrors the Variables handler. """ - with vuetify.VCardText(): - with vuetify.VRow(): - with vuetify.VCol(cols=3): - InputComponents.text_field( - label="nslice", - v_model_name="nslice", - change=( - ctrl.nsliceDefaultChange, - "['nslice', $event]", - ), - ) + LatticeDefaultsHandler.defaults_handler() # ----------------------------------------------------------------------------- # Dialogs diff --git a/src/python/impactx/dashboard/Input/utils.py b/src/python/impactx/dashboard/Input/utils.py index 81b0e28fe..2c591ef66 100644 --- a/src/python/impactx/dashboard/Input/utils.py +++ b/src/python/impactx/dashboard/Input/utils.py @@ -115,6 +115,16 @@ def reset_inputs(input_section): state.selected_lattice_list = [] state.variables = [{"name": "", "value": "", "error_message": ""}] state.dirty("variables") + # Reset lattice defaults to built-in values + try: + from .lattice.defaults_handler import LatticeDefaultsHandler + + new_defaults = LatticeDefaultsHandler._build_initial_defaults_list() + state.lattice_defaults_applied = new_defaults + state.lattice_defaults = new_defaults + state.dirty("lattice_defaults") + except Exception: + pass elif input_section == "space_charge": state.dirty("max_level") @@ -126,6 +136,16 @@ def reset_inputs(input_section): state.dirty("max_level") state.variables = [{"name": "", "value": "", "error_message": ""}] state.dirty("variables") + # Reset lattice defaults to built-in values + try: + from .lattice.defaults_handler import LatticeDefaultsHandler + + new_defaults = LatticeDefaultsHandler._build_initial_defaults_list() + state.lattice_defaults_applied = new_defaults + state.lattice_defaults = new_defaults + state.dirty("lattice_defaults") + except Exception: + pass @staticmethod def set_state_to_numeric(state_name: str) -> None: diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt index f5f5f5209..d60d9ee3b 100644 --- a/tests/python/CMakeLists.txt +++ b/tests/python/CMakeLists.txt @@ -32,6 +32,9 @@ set_property(TEST ${dashboard_test_name} PROPERTY LABELS "dashboard") set_property(TEST ${pytest_name} APPEND PROPERTY ENVIRONMENT "OMP_NUM_THREADS=2") set_property(TEST ${pytest_name}.dashboard APPEND PROPERTY ENVIRONMENT "OMP_NUM_THREADS=2") +# Save dashboard test screenshots to a known path for CI artifact collection +set_property(TEST ${pytest_name}.dashboard APPEND PROPERTY ENVIRONMENT "IMPACTX_SCREENSHOT_DIR=${pytest_rundir}.dashboard/screenshots") + # set PYTHONPATH and PATH (for .dll files) impactx_test_set_pythonpath(${pytest_name}) diff --git a/tests/python/dashboard/conftest.py b/tests/python/dashboard/conftest.py index 84152e6b4..bc3a40737 100644 --- a/tests/python/dashboard/conftest.py +++ b/tests/python/dashboard/conftest.py @@ -1,42 +1,48 @@ -import pytest -from seleniumbase import SB - -from .utils import ( - DashboardTester, - start_dashboard, - wait_for_interaction_ready, - wait_for_server_ready, -) - - -@pytest.fixture(scope="session") -def dashboard(): - """ - Sets up a single ImpactX dashboard server and headless browser for all tests in the session. - Automatically shuts down the server after all tests complete. - """ - - app_process = None - - try: - app_process = start_dashboard() - wait_for_server_ready(app_process) - - with SB(headless=True) as sb: - sb.open("http://localhost:8080/index.html#/Input") - wait_for_interaction_ready(sb) - yield DashboardTester(sb) - finally: - if app_process: - app_process.terminate() - - -@pytest.fixture(autouse=True) -def reset_dashboard_inputs(dashboard): - """ - Resets the dashboard to its default state before each test. - - This ensures all tests start from a clean, consistent baseline. - """ - dashboard.sb.click("#Input_route") - dashboard.sb.click("#reset_all_inputs_button") +import pytest +from seleniumbase import SB + +from .utils import ( + DashboardTester, + save_failure_screenshot, + start_dashboard, + wait_for_interaction_ready, + wait_for_server_ready, +) + + +@pytest.fixture(scope="session") +def dashboard(): + """ + Sets up a single ImpactX dashboard server and headless browser for all tests in the session. + Automatically shuts down the server after all tests complete. + """ + + app_process = None + + try: + app_process = start_dashboard() + wait_for_server_ready(app_process) + + with SB(headless=True) as sb: + sb.open("http://localhost:8080/index.html#/Input") + wait_for_interaction_ready(sb) + yield DashboardTester(sb) + finally: + if app_process: + app_process.terminate() + + +@pytest.fixture(autouse=True) +def reset_dashboard_inputs(dashboard, request): + """ + Resets the dashboard to its default state before each test. + This ensures all tests start from a clean, consistent baseline. + """ + + dashboard.sb.click("#Input_route") + dashboard.sb.click("#reset_all_inputs_button") + # Teardown: on failure, save a screenshot for debugging + yield + + # Always save a screenshot after each test for now (CI artifact) + save_failure_screenshot(dashboard, request) diff --git a/tests/python/dashboard/test_dashboard.py b/tests/python/dashboard/test_dashboard.py index 0f897aea7..a6935542b 100644 --- a/tests/python/dashboard/test_dashboard.py +++ b/tests/python/dashboard/test_dashboard.py @@ -1,3 +1,12 @@ +""" +This file is part of ImpactX + +Copyright 2025 ImpactX contributors +Authors: Parthib Roy +License: BSD-3-Clause-LBNL +""" + + def test_dashboard(dashboard): """ End-to-end test of the ImpactX dashboard by directly setting inputs via UI, @@ -45,6 +54,9 @@ def test_dashboard(dashboard): for name in ["Drift", "Quad", "Drift", "Quad", "Drift"]: dashboard.add_lattice_element(name) + # Ensure the lattice list is fully populated before setting parameters + dashboard.assert_state("total_elements", 5) + LATTICE_PARAMS = { "ds1": 0.25, "nslice1": "ns", diff --git a/tests/python/dashboard/test_lattice_defaults_handler.py b/tests/python/dashboard/test_lattice_defaults_handler.py new file mode 100644 index 000000000..7fc917971 --- /dev/null +++ b/tests/python/dashboard/test_lattice_defaults_handler.py @@ -0,0 +1,72 @@ +""" +This file is part of ImpactX + +Copyright 2025 ImpactX contributors +Authors: Parthib Roy +License: BSD-3-Clause-LBNL +""" + +import time + + +def assert_lattice_param_sim_input( + dashboard, lattice_index: int, param_name: str, expected_value +): + """ + Asserts that a lattice parameter's sim_input matches expected value, with retry logic. + """ + + def get_sim_input(): + lattice_list = dashboard.get_state("selected_lattice_list") + for param in lattice_list[lattice_index]["parameters"]: + if param["parameter_name"] == param_name: + return param["sim_input"] + return None + + for _ in range(10): + current_value = get_sim_input() + if current_value == expected_value: + return + time.sleep(1) + + raise AssertionError( + f"Parameter '{param_name}' sim_input never became '{expected_value}' (got: {current_value})" + ) + + +def test_lattice_defaults_handler(dashboard): + """ + Verifies the lattice defaults handler + + 1. Set a default for 'nslice' and ensure new elements use it. + 2. Add a second default and ensure subsequent elements reflect it. + 3. Enter an unknown parameter name and confirm an error is stored. + 4. Reset defaults and verify the handler resets to a single row. + """ + + # Open lattice settings dialog and switch to Defaults tab + dashboard.sb.click("#lattice_settings") + dashboard.sb.click("#lattice_configuration_dialog_tab_settings_tab_1") + + # Locate the row for 'nslice' and set its value (use search to ensure visible) + dashboard.set_input("lattice_defaults_search", "nslice") + dashboard.set_input("default_value_nslice", 25) + + # Add a lattice element that has nslice + dashboard.add_lattice_element("Sbend") + assert_lattice_param_sim_input(dashboard, 0, "nslice", 25) + + # Add another default (rc) and create another element to use it + # Set rc default as well + dashboard.set_input("lattice_defaults_search", "rc") + dashboard.set_input("default_value_rc", 12.5) + + dashboard.add_lattice_element("Sbend") + assert_lattice_param_sim_input(dashboard, 1, "rc", 12.5) + + # Unknown parameter should set an error message in state + # Reset and verify the table is repopulated with known parameters + dashboard.sb.click("#reset_lattice_defaults") + defaults_state = dashboard.get_state("lattice_defaults") + assert len(defaults_state) > 1 + assert all(row.get("name") for row in defaults_state) diff --git a/tests/python/dashboard/test_lattice_variable_handler.py b/tests/python/dashboard/test_lattice_variable_handler.py index a231109a2..b3b08f3e3 100644 --- a/tests/python/dashboard/test_lattice_variable_handler.py +++ b/tests/python/dashboard/test_lattice_variable_handler.py @@ -10,6 +10,8 @@ import pytest +from .utils import APPROX_TOL, TIMEOUT + def lattice_value(state, index: int, param_name: str) -> float: """ @@ -53,10 +55,19 @@ def get_sim_input(): return None # Simple retry logic similar to assert_state - for i in range(10): + for i in range(TIMEOUT): current_value = get_sim_input() - if current_value == expected_value: - return + try: + if isinstance(expected_value, (int, float)) and isinstance( + current_value, (int, float) + ): + if current_value == pytest.approx(expected_value, **APPROX_TOL): + return + elif current_value == expected_value: + return + except Exception: + if current_value == expected_value: + return time.sleep(1) raise AssertionError( diff --git a/tests/python/dashboard/test_python_import.py b/tests/python/dashboard/test_python_import.py index 5a8f89371..ff143b20e 100644 --- a/tests/python/dashboard/test_python_import.py +++ b/tests/python/dashboard/test_python_import.py @@ -1,3 +1,16 @@ +""" +This file is part of ImpactX + +Copyright 2025 ImpactX contributors +Authors: Parthib Roy +License: BSD-3-Clause-LBNL +""" + +import pytest + +from .utils import APPROX_TOL + + def test_python_import(dashboard): """ End-to-end test of the ImpactX dashboard by importing input values from a Python file. @@ -60,6 +73,6 @@ def test_python_import(dashboard): # Check input values for element_id, expected_value in DISTRIBUTION_VALUES + LATTICE_CONFIGURATION: actual_value = float(dashboard.sb.get_value(element_id)) - assert actual_value == expected_value, ( + assert actual_value == pytest.approx(expected_value, **APPROX_TOL), ( f"{element_id}: expected {expected_value}, got {actual_value}" ) diff --git a/tests/python/dashboard/utils.py b/tests/python/dashboard/utils.py index 0bc35638b..e811b2635 100644 --- a/tests/python/dashboard/utils.py +++ b/tests/python/dashboard/utils.py @@ -12,9 +12,11 @@ import time from pathlib import Path +import pytest from selenium.common.exceptions import TimeoutException -TIMEOUT = 60 +TIMEOUT = 120 +APPROX_TOL = {"rel": 1e-12, "abs": 1e-12} def start_dashboard() -> subprocess.Popen[str]: @@ -119,6 +121,7 @@ def add_lattice_element(self, element_name: str) -> None: """ try: self.set_state("selected_lattice", element_name) + self.assert_state("is_selected_element_invalid", False) self.sb.click("#add_lattice_element") except Exception as error: raise Exception( @@ -136,17 +139,42 @@ def set_js_input(self, element_id: str, new_input) -> None: :param new_input: New value to set for the input element. """ try: - self.sb.execute_script( - f'document.getElementById("{element_id}").value = "{new_input}";' - ) - self.sb.execute_script( - f'document.getElementById("{element_id}").dispatchEvent(new Event("input"));' - ) + self._commit_input_js(element_id, new_input) except Exception as error: raise Exception( f"Unable to set input for lattice element '{element_id}': {str(error)}" ) + def _commit_input_js(self, element_id: str, new_input) -> None: + """ + Update a Vuetify text input field in the browser using JavaScript. + + What happens step by step: + 1. Find the input element by its ID. + 2. Focus the element (like clicking inside it so it’s active). + 3. Set the element’s value to the new text. + 4. Trigger an "input" event so the web app reacts as if you typed the text. + 5. Trigger a "change" event so the app thinks you finished editing. + 6. Blur the element (like clicking away so it’s no longer active). + + Purpose: + This makes the page behave exactly as if a real user typed into the field + and then clicked out, ensuring the app updates correctly. + """ + js = ( + "var el = document.getElementById(arguments[0]);" + "if (!el) { throw new Error('element not found'); }" + "el.focus();" + "el.value = arguments[1];" + "el.dispatchEvent(new InputEvent('input', {bubbles:true,cancelable:true}));" + "el.dispatchEvent(new Event('change', {bubbles:true}));" + "el.blur();" + "return true;" + ) + successful_input = self.sb.execute_script(js, element_id, str(new_input)) + if not successful_input: + raise RuntimeError("failed to commit value via JS") + def set_state(self, state_name: str, state_value): """ Sets the given state name to the specified value. @@ -213,8 +241,14 @@ def assert_state(self, state_name: str, expected_input, timeout=TIMEOUT): value = None if isinstance(expected_input, (int, float)): - if value is not None and float(value) == float(expected_input): - return + try: + v_num = None if value is None else float(value) + if v_num is not None and v_num == pytest.approx( + float(expected_input), **APPROX_TOL + ): + return + except (TypeError, ValueError): + pass elif value == expected_input: return @@ -240,3 +274,24 @@ def get_state(self, state_name): return null; """ return self.sb.execute_script(js_script, state_name) + + +def save_failure_screenshot( + dashboard, request, directory: str | None = None +) -> str | None: + """ + Save a screenshot PNG for the current test and return the absolute path. + + - Respects env var `IMPACTX_SCREENSHOT_DIR` to override the output folder. + - Falls back to `directory` arg or `screenshots/` in the current CWD. + Returns the absolute file path on success, else None. + """ + base_dir = os.environ.get("IMPACTX_SCREENSHOT_DIR", directory or "screenshots") + try: + os.makedirs(base_dir, exist_ok=True) + name = f"{request.node.name}.png".replace(os.sep, "_") + path = os.path.abspath(os.path.join(base_dir, name)) + dashboard.sb.driver.save_screenshot(path) + return path + except Exception: + return None