diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..42cee20 Binary files /dev/null and b/.coverage differ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2daada5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Run Tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install UV + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> $GITHUB_PATH # Ensure UV is available + + - name: Run Pytest with Coverage + run: | + uv run pytest --junitxml=pytest-report.xml --cov=src # Ensure `src` matches your package + + diff --git a/pyproject.toml b/pyproject.toml index f5e1ab9..568c3b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,12 @@ [project] name = "eida-a10y" -version = "1.0.3" +version = "1.0.4" readme = "README.md" requires-python = "<4.0,>=3.11" dependencies = [ "aiohttp>=3.11.12", "aiosignal>=1.3.1", + "appdirs>=1.4.4", "async-timeout>=4.0.3", "attrs>=23.1.0", "certifi>=2023.11.17", @@ -21,6 +22,8 @@ dependencies = [ "msgpack>=1.1.0", "multidict>=6.0.4", "pygments>=2.17.2", + "pytest>=8.3.4", + "pytest-asyncio>=0.25.3", "requests>=2.31.0", "rich>=13.7.0", "textual==0.43.2", @@ -46,4 +49,9 @@ packages = ["src/a10y"] [build-system] requires = ["hatchling"] -build-backend = "hatchling.build" \ No newline at end of file +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest-cov>=6.0.0", +] diff --git a/src/a10y/__init__.py b/src/a10y/__init__.py index 2384194..1fc82a3 100644 --- a/src/a10y/__init__.py +++ b/src/a10y/__init__.py @@ -2,4 +2,5 @@ A10y: A terminal-based availability tool with a Textual UI. """ -__version__ = "1.0.3" \ No newline at end of file +__version__ = "1.0.4" + diff --git a/src/a10y/a10y.tcss b/src/a10y/a10y.tcss new file mode 100644 index 0000000..53694a4 --- /dev/null +++ b/src/a10y/a10y.tcss @@ -0,0 +1,153 @@ +Screen { + max-width:200; + overflow: hidden; +} +Header { + max-width: 200; +} + + +.box { + border: solid green; + min-width: 160; + max-width:200; +} + +.hide { + display: none; +} + +Explanations { + border: solid gray; + padding: 1; + background: black; + color: white; +} + +#explanations-keys { + text-style: bold; + padding: 1; + width: auto; +} + +.version { + color: cyan; + text-style: bold italic; + padding-top: 1; +} + +Requests { + layout: grid; + grid-size: 6 5; + grid-columns: 0.7fr 0.5fr 0.7fr 1fr 1fr 1fr; + grid-rows: 1 3 3 3 3; + max-height: 20; + grid-gutter: 1; +} + +#request-title { + column-span: 6; +} + +#nodes-container { + row-span: 3; + max-width: 24; +} +#reload-nodes{ + height:100%; + align: center middle; + text-align: center; + +} +#nodes { + height: 100%; +} + +#send-request{ + column-span:4; + width:100%; +} +#options,#nslc,#timeframe{ + column-span: 5; + width:100%; +} + +.request-label { + margin-top: 1; +} + +.short-input { + width: 16; +} + + + +AutoComplete { + max-height: 4; + max-width: 16; + margin-right: 3; + align: left top; +} + +.date-input { + width: 27; + margin-right: 1; +} + +#times { + width: 30; +} + +#mergegaps { + max-width: 12; +} + +#request-button { + margin: 0 5 0 5; +} + +#post-file { + width: 50; +} + +#status-container { + max-height: 5; +} + +#status-collapse { + max-height: 10; +} + +#error-results { + margin-left: 2; +} + +ContentSwitcher { + margin-left: 2; +} + +#info-bar { + background: $primary; +} + +#lines { + height: auto; +} + +#results-container { + max-height: 29; + height: auto; +} + +#plain-container { + max-height: 30; + height: auto; +} + +.result-item { + height: auto; +} + +CursoredText { + margin-left: 2; +} \ No newline at end of file diff --git a/src/a10y/app.py b/src/a10y/app.py index e7394a4..ffae9cd 100644 --- a/src/a10y/app.py +++ b/src/a10y/app.py @@ -1,17 +1,26 @@ from textual.app import App from textual.widgets import Header, Footer, Checkbox, Select, Input, Button, Collapsible, ContentSwitcher,Static,Label from textual.containers import ScrollableContainer , Container, Horizontal -from widgets import Explanations, Requests, Results, Status, CursoredText # Import modular widgets +from a10y.widgets import Explanations, Requests, Results, Status, CursoredText # Import modular widgets import requests from datetime import datetime, timedelta from textual.binding import Binding -from textual_autocomplete import AutoComplete, Dropdown, DropdownItem -from textual.app import App, ComposeResult +from textual_autocomplete import DropdownItem +from textual.app import ComposeResult from textual import work from textual.worker import get_current_worker import math import os import sys +import json +import threading +from pathlib import Path +from appdirs import user_cache_dir +from urllib.parse import urlparse +from a10y import __version__ +CACHE_DIR = Path(user_cache_dir("a10y")) +CACHE_FILE = CACHE_DIR / "nodes_cache.json" +QUERY_URL = "https://www.orfeus-eu.org/eidaws/routing/1/globalconfig?format=fdsn" class AvailabilityUI(App): def __init__(self, nodes_urls, routing, **kwargs): @@ -19,7 +28,7 @@ def __init__(self, nodes_urls, routing, **kwargs): self.routing = routing # Store routing URL self.config = kwargs # Store remaining settings super().__init__() - + def action_quit(self) -> None: """Ensure terminal resets properly when quitting.""" self.exit() @@ -28,17 +37,20 @@ def action_quit(self) -> None: else: os.system("reset") # Linux/macOS: Reset terminal - CSS_PATH = "a10y.css" + CSS_PATH = "a10y.tcss" BINDINGS = [ + Binding("ctrl+c", "quit", "Quit"), Binding("tab/shift+tab", "navigate", "Navigate"), Binding("ctrl+s", "send_button", "Send Request"), Binding("?", "toggle_help", "Help"), - Binding("Submit Issues", "", "https://github.com/EIDA/a10y/issues"), + Binding("Submit Issues", "", "https://github.com/EIDA/a10y/issues",show=True), + Binding("Version","",f"{__version__}"), Binding("ctrl+t", "first_line", "Move to first line", show=False), Binding("ctrl+b", "last_line", "Move to last line", show=False), Binding("t", "lines_view", "Toggle view to lines", show=False), Binding("escape", "cancel_request", "Cancel request", show=False), + ] req_text = "" @@ -54,19 +66,61 @@ def compose(self) -> ComposeResult: id="application-container" ) yield Footer() + def fetch_nodes_from_api(self): + """Fetch fresh nodes from API and update cache.""" + nodes_urls = [] + try: + response = requests.get(QUERY_URL, timeout=60) + response.raise_for_status() + data = response.json() + + for node in data.get("datacenters", []): + node_name = node["name"] + fdsnws_url = None + + for repo in node.get("repositories", []): + for service in repo.get("services", []): + if service["name"] == "fdsnws-station-1": + fdsnws_url = service["url"] + break + if fdsnws_url: + break + + if fdsnws_url: + parsed_url = urlparse(fdsnws_url) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/fdsnws/" + nodes_urls.append((node_name, base_url, True)) + + if nodes_urls: + self.save_nodes_to_cache(nodes_urls) + except requests.RequestException: + pass + finally: + self.exit() - - + def save_nodes_to_cache(self, nodes): + """Save nodes to cache file permanently (no expiration).""" + CACHE_DIR.mkdir(parents=True, exist_ok=True) + with open(CACHE_FILE, "w", encoding="utf-8") as f: + json.dump({"nodes": nodes}, f) def on_checkbox_changed(self, event: Checkbox.Changed) -> None: - """A function to select/deselect all nodes when corresponding checkbox is clicked""" - if event.checkbox == self.query_one("#all-nodes"): - if self.query_one("#all-nodes").value: - self.query_one("#nodes").select_all() - else: - self.query_one("#nodes").deselect_all() + """Toggle between 'Select all' and 'Deselect all' when the checkbox is clicked.""" + all_nodes_checkbox = self.query_one("#all-nodes") # Get the checkbox widget + nodes_list = self.query_one("#nodes") # Get the nodes list + + if all_nodes_checkbox.value: + nodes_list.deselect_all() + all_nodes_checkbox.label = "Select all" # ✅ Change to "Select all" + else: + nodes_list.select_all() + all_nodes_checkbox.label = "Deselect all" # ✅ Change to "Deselect all" + + all_nodes_checkbox.refresh() # ✅ Force UI update + + def on_select_changed(self, event: Select.Changed) -> None: """A function to issue appropriate request and update status when a Node or when a common time frame is selected""" if event.select == self.query_one("#times"): @@ -119,7 +173,7 @@ def parallel_requests_autocomplete(self, url, data) -> None: @work(exclusive=True, thread=True) - def on_input_submitted(self, event: Input.Submitted) -> None: + def on_input_changed(self, event: Input.Changed) -> None: """A function to change status when an NSLC input field is submitted (i.e. is typed and enter is hit)""" # COULD BE ON Change # keep app responsive while making requests @@ -231,9 +285,13 @@ def change_button_disabled(self, disabled: bool) -> None: @work(exclusive=True, thread=True) async def on_button_pressed(self, event: Button.Pressed) -> None: - # Disable the button to prevent multiple clicks self.call_from_thread(lambda: self.change_button_disabled(True)) - + # Disable the button to prevent multiple clicks + if event.button.id == "reload-nodes": + button = self.query_one("#reload-nodes") + button.label = "Reloading..." + button.disabled = True + self.fetch_nodes_from_api() try: start = self.query_one("#start").value end = self.query_one("#end").value @@ -331,7 +389,11 @@ async def show_results(self, r): infoBar = Static("Quality: Timestamp: Trace start: Trace end: ", id="info-bar") self.query_one('#lines').mount(infoBar) self.query_one('#lines').mount(ScrollableContainer(id="results-container")) - num_spans = 130 + # Dynamically calculate num_spans based on the results container width + num_spans = self.query_one("#results-widget").size.width // 2 # Scale width properly + num_spans = max(num_spans, 160) # Ensure a reasonable span count + + if not self.query_one("#start").value.strip(): self.query_one("#status-line").update( f"{self.query_one('#status-line').renderable}\n[orange1]⚠️ Please enter a start date![/orange1]" diff --git a/src/a10y/main.py b/src/a10y/main.py index cb93488..13fe64d 100644 --- a/src/a10y/main.py +++ b/src/a10y/main.py @@ -4,8 +4,36 @@ import logging import tomli from datetime import datetime, timedelta -from app import AvailabilityUI # Import from same directory # Import the main UI application - +from a10y.app import AvailabilityUI +from pathlib import Path +from appdirs import user_cache_dir +import json +from urllib.parse import urlparse +import time +import threading +import itertools +import sys +from urllib.parse import urlparse +# Common constants +DEFAULT_NODES = [ + ("GFZ", "https://geofon.gfz.de/fdsnws/", True), + ("ODC", "https://orfeus-eu.org/fdsnws/", True), + ("ETHZ", "https://eida.ethz.ch/fdsnws/", True), + ("RESIF", "https://ws.resif.fr/fdsnws/", True), + ("INGV", "https://webservices.ingv.it/fdsnws/", True), + ("LMU", "https://erde.geophysik.uni-muenchen.de/fdsnws/", True), + ("ICGC", "https://ws.icgc.cat/fdsnws/", True), + ("NOA", "https://eida.gein.noa.gr/fdsnws/", True), + ("BGR", "https://eida.bgr.de/fdsnws/", True), + ("BGS", "https://eida.bgs.ac.uk/fdsnws/", True), + ("NIEP", "https://eida-sc3.infp.ro/fdsnws/", True), + ("KOERI", "https://eida.koeri.boun.edu.tr/fdsnws/", True), + ("UIB-NORSAR", "https://eida.geo.uib.no/fdsnws/", True), +] + +CACHE_DIR = Path(user_cache_dir("a10y")) +CACHE_FILE = CACHE_DIR / "nodes_cache.json" +QUERY_URL = "https://www.orfeus-eu.org/eidaws/routing/1/globalconfig?format=fdsn" def parse_arguments(): """Parse command-line arguments.""" @@ -14,36 +42,103 @@ def parse_arguments(): parser.add_argument("-c", "--config", default=None, help="Configuration file path") return parser.parse_args() +def ensure_cache_dir(): + """Ensure the cache directory exists.""" + CACHE_DIR.mkdir(parents=True, exist_ok=True) -def load_nodes(): - """Fetch node URLs dynamically or use fallback values if request fails.""" + + +QUERY_URL = "https://www.orfeus-eu.org/eidaws/routing/1/globalconfig?format=fdsn" + +def loading_animation(stop_event): + """Display a loading animation while nodes are being fetched.""" + for frame in itertools.cycle(['|', '/', '-', '\\']): # Simple animation + if stop_event.is_set(): + break + sys.stdout.write(f"\rPlease wait... {frame} ") # Overwrite the same line + sys.stdout.flush() + time.sleep(0.5) + sys.stdout.write("\rFetching complete! ✅\n") # Clear animation + +def fetch_nodes_from_api(): + """Fetch fresh nodes from API and save to cache, with a loading animation.""" nodes_urls = [] + stop_event = threading.Event() + + # Start loading animation in a separate thread + animation_thread = threading.Thread(target=loading_animation, args=(stop_event,)) + animation_thread.start() + try: - response = requests.get("https://orfeus-eu.org/epb/nodes", timeout=5) - if response.status_code == 200: - nodes_urls = [(n["node_code"], f"https://{n['node_url_base']}/fdsnws/", True) for n in response.json()] - except requests.RequestException: - pass # Fall back to default nodes if request fails - - # Fallback nodes - if not nodes_urls: - nodes_urls = [ - ("GFZ", "https://geofon.gfz-potsdam.de/fdsnws/", True), - ("ODC", "https://orfeus-eu.org/fdsnws/", True), - ("ETHZ", "https://eida.ethz.ch/fdsnws/", True), - ("RESIF", "https://ws.resif.fr/fdsnws/", True), - ("INGV", "https://webservices.ingv.it/fdsnws/", True), - ("LMU", "https://erde.geophysik.uni-muenchen.de/fdsnws/", True), - ("ICGC", "https://ws.icgc.cat/fdsnws/", True), - ("NOA", "https://eida.gein.noa.gr/fdsnws/", True), - ("BGR", "https://eida.bgr.de/fdsnws/", True), - ("BGS", "https://eida.bgs.ac.uk/fdsnws/", True), - ("NIEP", "https://eida-sc3.infp.ro/fdsnws/", True), - ("KOERI", "https://eida.koeri.boun.edu.tr/fdsnws/", True), - ("UIB-NORSAR", "https://eida.geo.uib.no/fdsnws/", True), - ] - return nodes_urls + response = requests.get(QUERY_URL, timeout=60) + response.raise_for_status() + data = response.json() + + for node in data.get("datacenters", []): + node_name = node["name"] + fdsnws_url = None + + for repo in node.get("repositories", []): + for service in repo.get("services", []): + if service["name"] == "fdsnws-station-1": + fdsnws_url = service["url"] + break + if fdsnws_url: + break + if fdsnws_url: + parsed_url = urlparse(fdsnws_url) + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/fdsnws/" + nodes_urls.append((node_name, base_url, True)) + + if nodes_urls: + save_nodes_to_cache(nodes_urls) + + except requests.RequestException as e: + logging.warning(f"Failed to fetch nodes from API: {e}") + + finally: + stop_event.set() # Stop loading animation + animation_thread.join() # Wait for the animation to stop + + return nodes_urls if nodes_urls else None + + +def save_nodes_to_cache(nodes): + """Save nodes to cache file.""" + ensure_cache_dir() + with open(CACHE_FILE, "w", encoding="utf-8") as f: + json.dump({"nodes": nodes}, f) + +def load_cached_nodes(): + """Load cached nodes, fetch from API if missing or invalid.""" + ensure_cache_dir() + + if CACHE_FILE.exists(): + try: + with open(CACHE_FILE, "r", encoding="utf-8") as f: + cache_data = json.load(f) + nodes = cache_data.get("nodes", []) + + if not all(isinstance(n, list) and len(n) == 3 for n in nodes): + raise ValueError("Invalid cache format") + + return [(str(name), str(url), True) for name, url, _ in nodes] + + except (json.JSONDecodeError, ValueError) as e: + logging.warning(f"Cache file is corrupted: {e}. Deleting it.") + CACHE_FILE.unlink() + + nodes_from_api = fetch_nodes_from_api() + if nodes_from_api: + return nodes_from_api + + return DEFAULT_NODES + +def load_nodes(): + """Always load from cache if available, otherwise use fallback values.""" + cached_nodes = load_cached_nodes() + return cached_nodes if cached_nodes else DEFAULT_NODES def load_defaults(): """Return default configuration values.""" @@ -62,21 +157,15 @@ def load_defaults(): "default_includerestricted": True, } - - def load_config(config_path, defaults): """Load configuration from a TOML file and update defaults.""" - if not config_path: - # No config file provided, try the default location config_dir = os.getenv("XDG_CONFIG_DIR", "") config_path = os.path.join(config_dir, "a10y", "config.toml") if config_dir else "./config.toml" if not os.path.isfile(config_path): - # Config file is missing, return defaults without modification return defaults - # Try to load the config file try: with open(config_path, "rb") as f: config = tomli.load(f) @@ -84,7 +173,7 @@ def load_config(config_path, defaults): logging.error(f"Invalid format in config file {config_path}") raise ValueError(f"Invalid TOML format in config file: {config_path}") - # Handle starttime + # Process starttime if "starttime" in config: try: parts = config["starttime"].split() @@ -92,39 +181,32 @@ def load_config(config_path, defaults): num = int(parts[0]) defaults["default_starttime"] = (datetime.now() - timedelta(days=num)).strftime("%Y-%m-%dT%H:%M:%S") else: - datetime.strptime(config["starttime"], "%Y-%m-%dT%H:%M:%S") # Validate format + datetime.strptime(config["starttime"], "%Y-%m-%dT%H:%M:%S") defaults["default_starttime"] = config["starttime"] except (ValueError, IndexError): raise ValueError(f"Invalid starttime format in {config_path}") - # Handle endtime + # Process other config options if "endtime" in config: - if config["endtime"].lower() == "now": - pass # Keep default - else: + if config["endtime"].lower() != "now": try: - datetime.strptime(config["endtime"], "%Y-%m-%dT%H:%M:%S") # Validate format + datetime.strptime(config["endtime"], "%Y-%m-%dT%H:%M:%S") defaults["default_endtime"] = config["endtime"] except ValueError: raise ValueError(f"Invalid endtime format in {config_path}") - # Handle mergegaps if "mergegaps" in config: try: - defaults["default_mergegaps"] = str(float(config["mergegaps"])) # Ensure it's a valid number + defaults["default_mergegaps"] = str(float(config["mergegaps"])) except ValueError: raise ValueError(f"Invalid mergegaps format in {config_path}") - # Handle quality settings if "quality" in config: if not isinstance(config["quality"], list) or any(q not in ["D", "R", "Q", "M"] for q in config["quality"]): raise ValueError(f"Invalid quality codes in {config_path}") - defaults["default_quality_D"] = "D" in config["quality"] - defaults["default_quality_R"] = "R" in config["quality"] - defaults["default_quality_Q"] = "Q" in config["quality"] - defaults["default_quality_M"] = "M" in config["quality"] + for code in ["D", "R", "Q", "M"]: + defaults[f"default_quality_{code}"] = code in config["quality"] - # Handle merge options if "merge" in config: if not isinstance(config["merge"], list) or any(m not in ["samplerate", "quality", "overlap"] for m in config["merge"]): raise ValueError(f"Invalid merge options in {config_path}") @@ -132,42 +214,24 @@ def load_config(config_path, defaults): defaults["default_merge_quality"] = "quality" in config["merge"] defaults["default_merge_overlap"] = "overlap" in config["merge"] - # Handle restricted data setting if "includerestricted" in config: defaults["default_includerestricted"] = bool(config["includerestricted"]) - return defaults # Return updated defaults - - - + return defaults def main(): - - - # Parse command-line arguments args = parse_arguments() - - # Load network nodes nodes_urls = load_nodes() - - # Load default settings defaults = load_defaults() - - # Load configuration from file (if provided) - defaults["default_file"] = args.post # Overwrite default POST file if provided + defaults["default_file"] = args.post defaults = load_config(args.config, defaults) - routing = "https://www.orfeus-eu.org/eidaws/routing/1/query?" - - # Run the application with loaded settings app = AvailabilityUI( nodes_urls=nodes_urls, - routing=routing, # Pass routing URL - **defaults # Pass unpacked defaults + routing="https://www.orfeus-eu.org/eidaws/routing/1/query?", + **defaults ) app.run() - -# Ensure the script can still be executed manually if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/src/a10y/widgets.py b/src/a10y/widgets.py index 7b84663..b414df2 100644 --- a/src/a10y/widgets.py +++ b/src/a10y/widgets.py @@ -8,15 +8,21 @@ from textual import events from rich.text import Text from rich.cells import get_character_cell_size +from a10y import __version__ + class Explanations(Static): """Explanations box with common key functions""" def compose(self) -> ComposeResult: - yield Static("[b]Useful Keys[/b]") + yield Static("[b]Useful Keys[/b]", id="explanations-title") yield Static( """[gold3]ctrl+c[/gold3]: close app [gold3]tab/shif+tab[/gold3]: cycle through options [gold3]ctrl+s[/gold3]: send request [gold3]esc[/gold3]: cancel request [gold3]up/down/pgUp/pgDown[/gold3]: scroll up/down if in scrollable window""", - id="explanations-keys") + id="explanations-keys" + ) + yield Static(f"[b]Version:[/b] {__version__}", classes="version") # Styled version + + class Requests(Static): @@ -28,9 +34,10 @@ def __init__(self, nodes_urls, config, *args, **kwargs): def compose(self) -> ComposeResult: yield Static("[b]Requests Control[/b]", id="request-title") yield Container( - Checkbox("Select all Nodes", True, id="all-nodes"), + Checkbox("Deselect all ", False, id="all-nodes"), SelectionList(*self.nodes_urls, id="nodes"), id="nodes-container" + ) yield Horizontal( @@ -54,8 +61,11 @@ def compose(self) -> ComposeResult: Input(classes="short-input", id="channel"), Dropdown(items=[], id="channels") ), + id="nslc" ) + + yield Horizontal( Label("Start Time:", classes="request-label"), Input(classes="date-input", id="start", value=self.config["default_starttime"]), @@ -86,6 +96,7 @@ def compose(self) -> ComposeResult: Checkbox("M", self.config["default_quality_M"], id="qm"), id="options" ) + yield Button("Reload Nodes\n(Restart the app)", variant="primary", id="reload-nodes", disabled=False) yield Horizontal( Checkbox("Include Restricted", self.config["default_includerestricted"], id="restricted"), Button("Send", variant="primary", id="request-button",disabled=False), @@ -93,6 +104,7 @@ def compose(self) -> ComposeResult: Button("File", variant="primary", id="file-button"), id="send-request" ) + class Status(Static): diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..db54225 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,40 @@ +from a10y.app import AvailabilityUI +import pytest + +@pytest.mark.asyncio +async def test_send_button(): + """Test clicking the send button.""" + + + config = { + "default_starttime": "2024-01-01T00:00:00", + "default_endtime": "2024-01-02T00:00:00", + "default_mergegaps": "0.0", + "default_merge_samplerate": False, + "default_merge_quality": False, + "default_merge_overlap": False, + "default_quality_D": False, + "default_quality_R": False, + "default_quality_Q": False, + "default_quality_M": False, + "default_includerestricted": False, + "default_file": "", + } + + app = AvailabilityUI(nodes_urls=[], routing = "https://www.orfeus-eu.org/eidaws/routing/1/query?", **config) + + async with app.run_test() as pilot: + + button = app.query_one("#request-button") + assert button is not None, "Button not found!" + + # Click the send button + await pilot.click("#request-button") + + + assert button.disabled is True, "Button should be disabled after click" + + await pilot.pause(2) # Waits for 500ms before checking the button + + assert button.disabled is False, "Button should be re-enabled after request found" +