Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-windows-executable-app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 22 additions & 10 deletions src/workflow/StreamlitUI.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -740,25 +752,25 @@ 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(
f"Slider widget '{name}' requires min_value and max_value parameters"
)

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,
Expand Down
192 changes: 64 additions & 128 deletions test_gui.py
Original file line number Diff line number Diff line change
@@ -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
37 changes: 0 additions & 37 deletions tests/test_run_subprocess.py

This file was deleted.

Loading
Loading