diff --git a/.github/workflows/build-windows-executable-app.yaml b/.github/workflows/build-windows-executable-app.yaml index e1af54b..f33aac0 100644 --- a/.github/workflows/build-windows-executable-app.yaml +++ b/.github/workflows/build-windows-executable-app.yaml @@ -55,7 +55,7 @@ jobs: shell: bash run: | choco install ccache ninja -y --no-progress - choco install cmake --version=3.31.1 -y --no-progress --force + choco install cmake --version=3.31.12 -y --no-progress --force ## GH CLI "SHOULD BE" installed. Sometimes I had to manually install nonetheless. Super weird. # https://github.com/actions/runner-images/blob/main/images/win/scripts/Installers/Install-GitHub-CLI.ps1 echo "C:/Program Files (x86)/GitHub CLI" >> $GITHUB_PATH diff --git a/src/workflow/StreamlitUI.py b/src/workflow/StreamlitUI.py index d426e3a..320e81d 100644 --- a/src/workflow/StreamlitUI.py +++ b/src/workflow/StreamlitUI.py @@ -555,7 +555,9 @@ def _select_input_file_impl(self, key, name, multiple, display_file_path, reacti if not path.exists(): st.warning(f"No **{name}** files!") return - options = [str(f) for f in path.iterdir() if "external_files.txt" not in str(f)] + options = sorted( + str(f) for f in path.iterdir() if "external_files.txt" not in str(f) + ) # Check if local files are available external_files = Path( @@ -671,11 +673,21 @@ def format_files(input: Any) -> List[str]: key = f"{self.parameter_manager.param_prefix}{key}" + # Streamlit ignores a widget's initial-value argument (value=/default=/index=) + # once that key already exists in session state -- but on Streamlit < 1.50 the + # argument is still hashed into the widget's element id. Since this method feeds + # the persisted parameter straight back in as that argument, the id changed on + # every interaction, the following interaction arrived under the now-stale id and + # was silently dropped: selecting six mzML files kept only three. Seed the widget + # on first render only; from then on session state owns the value. + def seed(**kwargs: Any) -> dict: + return {} if key in st.session_state else kwargs + if widget_type == "text": - st.text_input(name, value=value, key=key, help=help, on_change=on_change) + st.text_input(name, key=key, help=help, on_change=on_change, **seed(value=value)) elif widget_type == "textarea": - st.text_area(name, value=value, key=key, help=help, on_change=on_change) + st.text_area(name, key=key, help=help, on_change=on_change, **seed(value=value)) elif widget_type == "number": number_type = float if isinstance(value, float) else int @@ -689,27 +701,27 @@ def format_files(input: Any) -> List[str]: name, min_value=min_value, max_value=max_value, - value=value, step=step_size, format=None, key=key, help=help, on_change=on_change, + **seed(value=value), ) elif widget_type == "checkbox": - st.checkbox(name, value=value, key=key, help=help, on_change=on_change) + st.checkbox(name, key=key, help=help, on_change=on_change, **seed(value=value)) elif widget_type == "selectbox": if options is not None: st.selectbox( name, options=options, - index=options.index(value) if value in options else 0, key=key, format_func=format_files, help=help, on_change=on_change, + **seed(index=options.index(value) if value in options else 0), ) else: st.warning(f"Select widget '{name}' requires options parameter") @@ -719,11 +731,11 @@ def format_files(input: Any) -> List[str]: st.multiselect( name, options=options, - default=value, key=key, format_func=format_files, help=help, on_change=on_change, + **seed(default=value), ) else: st.warning(f"Select widget '{name}' requires options parameter") @@ -740,12 +752,12 @@ def format_files(input: Any) -> List[str]: name, min_value=min_value, max_value=max_value, - value=value, step=step_size, key=key, format=None, help=help, on_change=on_change, + **seed(value=value), ) else: st.warning( @@ -753,12 +765,12 @@ def format_files(input: Any) -> List[str]: ) elif widget_type == "password": - st.text_input(name, value=value, type="password", key=key, help=help, on_change=on_change) + st.text_input(name, type="password", key=key, help=help, on_change=on_change, **seed(value=value)) elif widget_type == "auto": # Auto-determine widget type based on value if isinstance(value, bool): - st.checkbox(name, value=value, key=key, help=help, on_change=on_change) + st.checkbox(name, key=key, help=help, on_change=on_change, **seed(value=value)) elif isinstance(value, (int, float)): self._input_widget_impl( key, diff --git a/test_gui.py b/test_gui.py index 0485bae..67fe8dd 100644 --- a/test_gui.py +++ b/test_gui.py @@ -1,141 +1,77 @@ -from streamlit.testing.v1 import AppTest -import pytest -from src import fileupload +"""Smoke tests for the pages actually registered in app.py. + +Kept deliberately narrow: the template's original version walked a hard-coded +list of example pages, most of which quantms-web has deleted, so every one of +them failed with FileNotFoundError after a template sync. +""" + +import ast import json from pathlib import Path -import shutil + +import pytest +from streamlit.testing.v1 import AppTest + +# Pages AppTest.from_file can load in isolation. The rest call st.page_link, +# which needs the navigation context that only exists when app.py itself runs +# (loading them directly raises KeyError: 'url_pathname'), so they are covered +# indirectly by test_app_loads. +DIRECTLY_TESTABLE_PAGES = [ + "content/workflow_fileupload.py", + "content/workflow_configure.py", + "content/workflow_run.py", +] + + +def registered_pages() -> list[str]: + """Every st.Page(Path("content", "...")) target declared in app.py.""" + tree = ast.parse(Path("app.py").read_text(encoding="utf-8")) + pages = [] + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "Page" + and node.args + ): + target = node.args[0] + if ( + isinstance(target, ast.Call) + and isinstance(target.func, ast.Name) + and target.func.id == "Path" + ): + parts = [a.value for a in target.args if isinstance(a, ast.Constant)] + if parts: + pages.append("/".join(parts)) + return pages + + +def _init(apptest): + with open("settings.json", "r", encoding="utf-8") as f: + apptest.session_state.settings = json.load(f) + apptest.session_state.settings["test"] = True + apptest.secrets["workspace"] = "test" + return apptest @pytest.fixture def launch(request): - test = AppTest.from_file(request.param) - - ## Initialize session state ## - with open("settings.json", "r") as f: - test.session_state.settings = json.load(f) - test.session_state.settings["test"] = True - test.secrets["workspace"] = "test" - return test - - -# Test launching of all pages -@pytest.mark.parametrize( - "launch", - ( - # "content/quickstart.py", # NOTE: this page does not work due to streamlit.errors.StreamlitPageNotFoundError error - "content/documentation.py", - "content/topp_workflow_file_upload.py", - "content/topp_workflow_parameter.py", - "content/topp_workflow_execution.py", - "content/topp_workflow_results.py", - "content/file_upload.py", - "content/raw_data_viewer.py", - "content/run_example_workflow.py", - "content/download_section.py", - "content/simple_workflow.py", - "content/run_subprocess.py", - ), - indirect=True, -) -def test_launch(launch): - """Test if all pages can be launched without errors.""" - launch.run(timeout=30) # Increased timeout from 10 to 30 seconds - assert not launch.exception + return _init(AppTest.from_file(request.param)) -########### PAGE SPECIFIC TESTS ############ -@pytest.mark.parametrize( - "launch,selection", - [ - ("content/documentation.py", "User Guide"), - ("content/documentation.py", "Installation"), - ( - "content/documentation.py", - "Developers Guide: How to build app based on this template", - ), - ("content/documentation.py", "Developers Guide: TOPP Workflow Framework"), - ("content/documentation.py", "Developer Guide: Windows Executables"), - ("content/documentation.py", "Developers Guide: Deployment"), - ("content/documentation.py", "Developers Guide: Kubernetes Deployment"), - ], - indirect=["launch"], -) -def test_documentation(launch, selection): - launch.run() - launch.selectbox[0].select(selection).run() - assert not launch.exception +def test_registered_pages_exist(): + """Guard against a template sync registering pages this repo has deleted.""" + missing = [p for p in registered_pages() if not Path(p).is_file()] + assert not missing, f"app.py registers pages that do not exist: {missing}" -@pytest.mark.parametrize("launch", ["content/file_upload.py"], indirect=True) -def test_file_upload_load_example(launch): - launch.run() - for i in launch.tabs: - if i.label == "Example Data": - i.button[0].click().run() - assert not launch.exception - - -# NOTE: All tabs are automatically checked -@pytest.mark.parametrize( - "launch,example", - [ - ("content/raw_data_viewer.py", "Blank.mzML"), - ("content/raw_data_viewer.py", "Treatment.mzML"), - ("content/raw_data_viewer.py", "Pool.mzML"), - ("content/raw_data_viewer.py", "Control.mzML"), - ], - indirect=["launch"], -) -def test_view_raw_ms_data(launch, example): - launch.run(timeout=30) # Increased timeout from 10 to 30 seconds - - ## Load Example file, based on implementation of fileupload.load_example_mzML_files() ### - mzML_dir = Path(launch.session_state.workspace, "mzML-files") - - # Copy files from example-data/mzML to workspace mzML directory, add to selected files - for f in Path("example-data", "mzML").glob("*.mzML"): - try: - shutil.copy(f, mzML_dir) - except shutil.SameFileError: - pass # File already exists as a symlink to the same source (on Linux) - launch.run() - - ## TODO: Figure out a way to select a spectrum to be displayed - launch.selectbox[0].select(example).run() +@pytest.mark.parametrize("launch", DIRECTLY_TESTABLE_PAGES, indirect=True) +def test_page_loads(launch): + launch.run(timeout=60) assert not launch.exception -@pytest.mark.parametrize( - "launch,example", - [ - ("content/run_example_workflow.py", ["Blank"]), - ("content/run_example_workflow.py", ["Treatment"]), - ("content/run_example_workflow.py", ["Pool"]), - ("content/run_example_workflow.py", ["Control"]), - ("content/run_example_workflow.py", ["Control", "Blank"]), - ], - indirect=["launch"], -) -def test_run_workflow(launch, example): - launch.run() - ## Load Example file, based on implementation of fileupload.load_example_mzML_files() ### - mzML_dir = Path(launch.session_state.workspace, "mzML-files") - - # Copy files from example-data/mzML to workspace mzML directory, add to selected files - for f in Path("example-data", "mzML").glob("*.mzML"): - try: - shutil.copy(f, mzML_dir) - except shutil.SameFileError: - pass # File already exists as a symlink to the same source (on Linux) - launch.run() - - ## Select experiments to process - for e in example: - launch.multiselect[0].select(e) - - launch.run() - assert not launch.exception - - # Press the "Run Workflow" button - launch.button[1].click().run(timeout=60) - assert not launch.exception +def test_app_loads(): + app = _init(AppTest.from_file("app.py")) + app.run(timeout=60) + assert not app.exception diff --git a/tests/test_run_subprocess.py b/tests/test_run_subprocess.py deleted file mode 100644 index cd6889a..0000000 --- a/tests/test_run_subprocess.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest -import time -from streamlit.testing.v1 import AppTest - -@pytest.fixture -def launch(): - """Launch the Run Subprocess Streamlit page for testing.""" - - app = AppTest.from_file("content/run_subprocess.py") - app.run(timeout=10) - return app - -def test_file_selection(launch): - """Ensure a file can be selected from the dropdown.""" - launch.run() - - assert len(launch.selectbox) > 0, "No file selection dropdown found!" - - if len(launch.selectbox[0].options) > 0: - launch.selectbox[0].select(launch.selectbox[0].options[0]) - launch.run() - - -def test_extract_ids_button(launch): - """Ensure clicking 'Extract IDs' triggers process and UI updates accordingly.""" - launch.run(timeout=10) - time.sleep(3) - - # Ensure 'Extract ids' button exists - extract_button = next((btn for btn in launch.button if "Extract ids" in btn.label), None) - assert extract_button is not None, "Extract ids button not found!" - - # Click the 'Extract ids' button - extract_button.click() - launch.run(timeout=10) - - print("Extract ids button was clicked successfully!") \ No newline at end of file diff --git a/tests/test_simple_workflow.py b/tests/test_simple_workflow.py deleted file mode 100644 index 5a94c41..0000000 --- a/tests/test_simple_workflow.py +++ /dev/null @@ -1,69 +0,0 @@ -import pytest -import time -from streamlit.testing.v1 import AppTest - -""" -Tests for the Simple Workflow page functionality. - -These tests verify: -- Number input widgets function correctly -- Session state updates properly -- Table generation with correct dimensions -- Download button presence -""" - -@pytest.fixture -def launch(): - """Launch the Simple Workflow page for testing.""" - app = AppTest.from_file("content/simple_workflow.py") - app.run(timeout=15) - return app - -def test_number_inputs(launch): - """Ensure x and y dimension inputs exist and update correctly.""" - - assert len(launch.number_input) >= 2, f"Expected at least 2 number inputs, found {len(launch.number_input)}" - - # Set x and y dimensions - x_input = next((ni for ni in launch.number_input if ni.key == "example-x-dimension"), None) - y_input = next((ni for ni in launch.number_input if ni.key == "example-y-dimension"), None) - - assert x_input is not None, "X-dimension input not found!" - assert y_input is not None, "Y-dimension input not found!" - - x_input.set_value(5) - y_input.set_value(4) - launch.run(timeout=10) - - # Validate session state updates - assert "example-x-dimension" in launch.session_state, "X-dimension key missing in session state!" - assert "example-y-dimension" in launch.session_state, "Y-dimension key missing in session state!" - assert launch.session_state["example-x-dimension"] == 5, "X-dimension not updated!" - assert launch.session_state["example-y-dimension"] == 4, "Y-dimension not updated!" - - assert len(launch.dataframe) > 0, "Table not generated!" - - df = launch.dataframe[0].value - assert df.shape == (5, 4), f"Expected table size (5,4) but got {df.shape}" - -def test_download_button(launch): - """Ensure 'Download Table' button appears after table generation.""" - - # Locate number inputs by key - x_input = next((ni for ni in launch.number_input if ni.key == "example-x-dimension"), None) - y_input = next((ni for ni in launch.number_input if ni.key == "example-y-dimension"), None) - - assert x_input is not None, "X-dimension input not found!" - assert y_input is not None, "Y-dimension input not found!" - - # Set values and trigger app update - x_input.set_value(3) - y_input.set_value(2) - launch.run(timeout=15) - time.sleep(5) - - assert len(launch.dataframe) > 0, "Table not generated!" - - # Find the "Download Table" button correctly - download_elements = [comp for comp in launch.main if hasattr(comp, "label") and "Download" in comp.label] - assert len(download_elements) > 0, "Download Table button is missing!" diff --git a/tests/test_tool_instance_name.py b/tests/test_tool_instance_name.py index cd060ca..d1eef17 100644 --- a/tests/test_tool_instance_name.py +++ b/tests/test_tool_instance_name.py @@ -24,6 +24,23 @@ _original_streamlit = sys.modules.get('streamlit') sys.modules['streamlit'] = mock_streamlit + +def _drop_cached_workflow_modules() -> None: + """Forget any cached src.workflow modules. + + A module binds `st` once, at import time. If an earlier test file has already + imported src.workflow.ParameterManager against the real streamlit, the import + below is just a cache hit and the mock never takes effect - which is why these + tests passed when run alone but failed in a full-suite run. + """ + for _key in list(sys.modules.keys()): + if _key.startswith('src.workflow'): + sys.modules.pop(_key, None) + + +# Drop first, so the import below really binds the mock. +_drop_cached_workflow_modules() + from src.workflow.ParameterManager import ParameterManager if _original_streamlit is not None: @@ -31,10 +48,9 @@ else: sys.modules.pop('streamlit', None) -# Remove cached src.workflow modules -for _key in list(sys.modules.keys()): - if _key.startswith('src.workflow'): - sys.modules.pop(_key, None) +# Drop again, so later test files re-import against the real streamlit instead of +# the mock-bound modules this file just created. +_drop_cached_workflow_modules() @pytest.fixture