From 90a0a6a800fcbd890b89a664fd2b77935190482e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Fri, 15 May 2026 19:26:24 +0200 Subject: [PATCH 01/13] add new targets for paddler_gui to makefile, add metal feature to paddler_gui --- Makefile | 31 +++++++++++++++++++++++-------- paddler_gui/Cargo.toml | 1 + 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 1f90ea08..4efb5145 100644 --- a/Makefile +++ b/Makefile @@ -20,30 +20,45 @@ node_modules: package-lock.json esbuild-meta.json: $(FRONTEND_SOURCES) jarmuz-static.mjs tsconfig.json package.json node_modules ./jarmuz-static.mjs -target/debug/paddler: $(PADDLER_CLI_SOURCES) - cargo build -p paddler_cli - -target/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json - cargo build --release -p paddler_cli --features web_admin_panel - target/cuda/debug/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json cargo build -p paddler_cli --features cuda,web_admin_panel --target-dir target/cuda +target/cuda/debug/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json + cargo build -p paddler_gui --features cuda,web_admin_panel --target-dir target/cuda + target/cuda/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json cargo build --release -p paddler_cli --features cuda,web_admin_panel --target-dir target/cuda +target/cuda/release/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json + cargo build --release -p paddler_gui --features cuda,web_admin_panel --target-dir target/cuda + +target/debug/paddler: $(PADDLER_CLI_SOURCES) + cargo build -p paddler_cli + +target/debug/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json + cargo build -p paddler_gui --features web_admin_panel + target/metal/debug/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json cargo build -p paddler_cli --features metal,web_admin_panel --target-dir target/metal +target/metal/debug/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json + cargo build -p paddler_gui --features metal,web_admin_panel --target-dir target/metal + target/metal/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json cargo build --release -p paddler_cli --features metal,web_admin_panel --target-dir target/metal -target/vulkan/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json - cargo build --release -p paddler_cli --features vulkan,web_admin_panel --target-dir target/vulkan +target/metal/release/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json + cargo build --release -p paddler_gui --features metal,web_admin_panel --target-dir target/metal + +target/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json + cargo build --release -p paddler_cli --features web_admin_panel target/release/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json cargo build --release -p paddler_gui --features web_admin_panel +target/vulkan/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json + cargo build --release -p paddler_cli --features vulkan,web_admin_panel --target-dir target/vulkan + # ----------------------------------------------------------------------------- # Phony targets # ----------------------------------------------------------------------------- diff --git a/paddler_gui/Cargo.toml b/paddler_gui/Cargo.toml index b59d5d90..cbc4a758 100644 --- a/paddler_gui/Cargo.toml +++ b/paddler_gui/Cargo.toml @@ -32,6 +32,7 @@ workspace = true [features] default = [] cuda = ["paddler/cuda"] +metal = ["paddler/metal"] web_admin_panel = [ "dep:esbuild-metafile", "paddler/web_admin_panel", From 35ce47c02b150e3f818758bcd06ea1261e48731f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 16 May 2026 14:41:15 +0200 Subject: [PATCH 02/13] add test coverage setup for paddler_gui, update project rules --- .claude/rules/code-coverage.md | 3 +++ .claude/rules/github-actions.md | 9 +++++++++ .claude/rules/github-workflows.md | 7 +++++-- Makefile | 23 +++++++++++++++++++++++ package-lock.json | 14 ++++++++++++++ package.json | 1 + rust-toolchain.toml | 2 +- 7 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 .claude/rules/code-coverage.md create mode 100644 .claude/rules/github-actions.md diff --git a/.claude/rules/code-coverage.md b/.claude/rules/code-coverage.md new file mode 100644 index 00000000..26739d6a --- /dev/null +++ b/.claude/rules/code-coverage.md @@ -0,0 +1,3 @@ +# Code Coverage Measurement + +- Use `make coverage` to measure code coverage. This is the authoritative source. diff --git a/.claude/rules/github-actions.md b/.claude/rules/github-actions.md new file mode 100644 index 00000000..1457661c --- /dev/null +++ b/.claude/rules/github-actions.md @@ -0,0 +1,9 @@ +--- +paths: + - ".github/actions/**/*" +--- + +# GitHub Actions Standards + +- Actions must represent a single purpose and a single concern. +- Compose actions if they need to provide a more complex functionality. diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md index 68d68c71..7ed01ae4 100644 --- a/.claude/rules/github-workflows.md +++ b/.claude/rules/github-workflows.md @@ -1,11 +1,14 @@ --- paths: - - ".github/**/*" + - ".github/workflows/**/*" --- # GitHub Workflows Standards -- Always use Makefile targets in the workflow to avoid code duplication. +- Always use Makefile targets in the workflow to avoid code duplication (if they need to run something that is already present in a Makefile). - Never add the tests that use LLMs to GitHub workflows, because the default GitHub worker does not have the capacity to run them. - Only add unit tests to GitHub workflows. - Keep GitHub workflows responsible for only a single concern. For example, run linter, and tests in parallel. +- Treat GitHub workflows as a coding project. Use composable actions, factor similar concerns into actions. +- Encapsulate functionalities in composable actions. +- Keep the workflows clean and purposeful. diff --git a/Makefile b/Makefile index 4efb5145..881571ce 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ .DEFAULT_GOAL := target/release/paddler +COVERAGE_PACKAGES := -p paddler_gui RUST_LOG ?= debug PADDLER_CLI_SOURCES := $(shell find paddler/src paddler_bootstrap/src paddler_cli/src paddler_client/src paddler_types/src -name '*.rs') @@ -74,6 +75,28 @@ clean: clippy: esbuild-meta.json cargo clippy --workspace --all-targets --features web_admin_panel,tests_that_use_llms,tests_that_use_compiled_paddler +.PHONY: coverage +coverage: esbuild-meta.json + cargo llvm-cov clean --workspace + cargo llvm-cov $(COVERAGE_PACKAGES) --features web_admin_panel --no-report + cargo llvm-cov report --json --output-path target/llvm-cov.json + cargo llvm-cov report --lcov --output-path target/lcov.info + cargo llvm-cov report + npx rust-coverage-check target/llvm-cov.json \ + --workspace-root $(CURDIR) \ + --gated paddler_gui \ + --required-percent 100 + +.PHONY: coverage-clean +coverage-clean: + cargo llvm-cov clean --workspace + rm -rf target/llvm-cov-target + rm -f target/llvm-cov.json target/lcov.info + +.PHONY: coverage-report +coverage-report: esbuild-meta.json + cargo llvm-cov $(COVERAGE_PACKAGES) --features web_admin_panel --html + .PHONY: fmt fmt: node_modules ./jarmuz-fmt.mjs diff --git a/package-lock.json b/package-lock.json index 65a10d2e..30c02660 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "zod": "^4.0.17" }, "devDependencies": { + "@intentee/rust-coverage-check": "0.1.0", "@types/hotwired__turbo": "^8", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", @@ -984,6 +985,19 @@ "resolved": "paddler_client_javascript", "link": true }, + "node_modules/@intentee/rust-coverage-check": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@intentee/rust-coverage-check/-/rust-coverage-check-0.1.0.tgz", + "integrity": "sha512-0PpHUxGda5FjSOh7ebMrR99xbFRC93PnWY1mSTeuetX2yMgqVhY8Bjrg5GqedsxXZt6z7OrQ3SOwBA2gTgDUBg==", + "dev": true, + "license": "MIT", + "bin": { + "rust-coverage-check": "src/main.mjs" + }, + "engines": { + "node": ">=24.0.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", diff --git a/package.json b/package.json index cdf98de8..3c686061 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "devDependencies": { + "@intentee/rust-coverage-check": "0.1.0", "@types/hotwired__turbo": "^8", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", diff --git a/rust-toolchain.toml b/rust-toolchain.toml index c0b9b8ec..8a6cc6e8 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] channel = "1.93.0" -components = ["clippy", "rust-analyzer", "rustfmt"] +components = ["clippy", "llvm-tools-preview", "rust-analyzer", "rustfmt"] From a2be23104ef2dce8b37367a76f8d52dceaea887f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 16 May 2026 17:28:52 +0200 Subject: [PATCH 03/13] add paddler_gui test coverage infrastructure and paddler_ports for in-process listener handoff --- Cargo.lock | 53 ++ Cargo.toml | 5 +- Makefile | 2 +- .../compatibility/openai_service/mod.rs | 23 +- paddler/src/balancer/inference_service/mod.rs | 21 +- .../src/balancer/management_service/mod.rs | 23 +- .../balancer/web_admin_panel_service/mod.rs | 24 +- paddler_bootstrap/src/balancer_runner.rs | 16 + .../src/bootstrapped_balancer_handle.rs | 15 + paddler_bootstrap/tests/runners.rs | 5 + paddler_cli/src/cmd/balancer.rs | 5 + paddler_gui/Cargo.toml | 9 + paddler_gui/src/agent_running_data.rs | 82 ++ paddler_gui/src/agent_running_handler.rs | 80 ++ paddler_gui/src/app.rs | 857 +++++++++++++----- paddler_gui/src/current_screen.rs | 23 + paddler_gui/src/detect_network_interfaces.rs | 23 + paddler_gui/src/drive_agent_stream.rs | 71 ++ paddler_gui/src/drive_balancer_stream.rs | 122 +++ .../src/drive_shutdown_signal_stream.rs | 30 + paddler_gui/src/home_handler.rs | 26 + paddler_gui/src/join_balancer_form_handler.rs | 234 +++++ paddler_gui/src/lib.rs | 125 +++ paddler_gui/src/main.rs | 71 +- paddler_gui/src/model_preset.rs | 64 ++ paddler_gui/src/running_balancer_handler.rs | 80 ++ paddler_gui/src/screen.rs | 255 ++++++ .../src/start_balancer_form_handler.rs | 250 +++++ paddler_gui/src/ui/agent_status_label.rs | 160 ++++ paddler_gui/src/ui/display_last_path_part.rs | 33 + paddler_gui/src/ui/format_desired_model.rs | 59 ++ paddler_gui/src/ui/mod.rs | 31 +- paddler_gui/src/ui/style_agent_container.rs | 28 + paddler_gui/src/ui/style_button_disconnect.rs | 22 + paddler_gui/src/ui/style_button_primary.rs | 29 + paddler_gui/src/ui/style_card_container.rs | 20 + .../src/ui/style_download_progress_bar.rs | 21 + paddler_gui/src/ui/style_field_checkbox.rs | 37 + paddler_gui/src/ui/style_field_container.rs | 25 + paddler_gui/src/ui/style_field_pick_list.rs | 22 + .../src/ui/style_field_pick_list_menu.rs | 21 + paddler_gui/src/ui/style_field_text_input.rs | 26 + paddler_gui/src/ui/style_status_indicator.rs | 27 + paddler_gui/src/ui/view_agent_card.rs | 31 +- paddler_gui/src/ui/view_running_balancer.rs | 15 +- .../src/ui/view_start_balancer_form.rs | 2 +- paddler_gui_tests/Cargo.toml | 32 + paddler_gui_tests/src/bind_addresses.rs | 6 + .../src/bind_ephemeral_socket.rs | 18 + paddler_gui_tests/src/lib.rs | 4 + .../src/make_agent_controller_snapshot.rs | 51 ++ .../src/make_balancer_runner_params.rs | 41 + ...progress_while_the_model_is_downloading.rs | 29 + ...card_shows_loaded_model_and_open_issues.rs | 48 + ...g_the_balancer_address_to_the_clipboard.rs | 26 + ...isconnecting_an_agent_from_the_balancer.rs | 44 + ...er_their_label_input_and_optional_error.rs | 38 + ...or_after_a_failed_balancer_or_agent_run.rs | 18 + ...ts_the_user_on_a_balancer_or_agent_flow.rs | 32 + ...ncer_collects_and_submits_agent_details.rs | 46 + ...cer_shows_validation_errors_to_the_user.rs | 24 + ..._panel_from_the_running_balancer_screen.rs | 41 + .../tests/os_signals_quit_the_app.rs | 55 ++ ...stops_cleanly_when_the_user_disconnects.rs | 81 ++ ...an_agents_connection_status_to_the_user.rs | 55 ++ ...er_collects_and_submits_cluster_details.rs | 71 ++ ...he_user_choose_a_model_or_add_one_later.rs | 53 ++ ...cer_shows_validation_errors_to_the_user.rs | 36 + ...ng_a_balancer_succeeds_or_fails_visibly.rs | 128 +++ .../tests/stopping_a_running_balancer.rs | 45 + ..._agents_connected_to_a_running_balancer.rs | 53 ++ paddler_ports/Cargo.toml | 16 + paddler_ports/src/bind_ephemeral_port.rs | 34 + paddler_ports/src/bound_port.rs | 7 + paddler_ports/src/check_optional_port.rs | 44 + paddler_ports/src/check_port.rs | 88 ++ paddler_ports/src/lib.rs | 5 + paddler_ports/src/port_check_error.rs | 29 + paddler_tests/Cargo.toml | 1 + paddler_tests/src/balancer_addresses.rs | 41 +- paddler_tests/src/start_in_process_cluster.rs | 11 +- 81 files changed, 4161 insertions(+), 393 deletions(-) create mode 100644 paddler_gui/src/drive_agent_stream.rs create mode 100644 paddler_gui/src/drive_balancer_stream.rs create mode 100644 paddler_gui/src/drive_shutdown_signal_stream.rs create mode 100644 paddler_gui/src/lib.rs create mode 100644 paddler_gui/src/ui/agent_status_label.rs create mode 100644 paddler_gui/src/ui/display_last_path_part.rs create mode 100644 paddler_gui/src/ui/format_desired_model.rs create mode 100644 paddler_gui_tests/Cargo.toml create mode 100644 paddler_gui_tests/src/bind_addresses.rs create mode 100644 paddler_gui_tests/src/bind_ephemeral_socket.rs create mode 100644 paddler_gui_tests/src/lib.rs create mode 100644 paddler_gui_tests/src/make_agent_controller_snapshot.rs create mode 100644 paddler_gui_tests/src/make_balancer_runner_params.rs create mode 100644 paddler_gui_tests/tests/agent_card_shows_download_progress_while_the_model_is_downloading.rs create mode 100644 paddler_gui_tests/tests/agent_card_shows_loaded_model_and_open_issues.rs create mode 100644 paddler_gui_tests/tests/copying_the_balancer_address_to_the_clipboard.rs create mode 100644 paddler_gui_tests/tests/disconnecting_an_agent_from_the_balancer.rs create mode 100644 paddler_gui_tests/tests/form_fields_render_their_label_input_and_optional_error.rs create mode 100644 paddler_gui_tests/tests/home_screen_shows_an_error_after_a_failed_balancer_or_agent_run.rs create mode 100644 paddler_gui_tests/tests/home_screen_starts_the_user_on_a_balancer_or_agent_flow.rs create mode 100644 paddler_gui_tests/tests/joining_a_balancer_collects_and_submits_agent_details.rs create mode 100644 paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs create mode 100644 paddler_gui_tests/tests/opening_the_web_admin_panel_from_the_running_balancer_screen.rs create mode 100644 paddler_gui_tests/tests/os_signals_quit_the_app.rs create mode 100644 paddler_gui_tests/tests/running_an_agent_stops_cleanly_when_the_user_disconnects.rs create mode 100644 paddler_gui_tests/tests/showing_an_agents_connection_status_to_the_user.rs create mode 100644 paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs create mode 100644 paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs create mode 100644 paddler_gui_tests/tests/starting_a_balancer_shows_validation_errors_to_the_user.rs create mode 100644 paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs create mode 100644 paddler_gui_tests/tests/stopping_a_running_balancer.rs create mode 100644 paddler_gui_tests/tests/viewing_the_list_of_agents_connected_to_a_running_balancer.rs create mode 100644 paddler_ports/Cargo.toml create mode 100644 paddler_ports/src/bind_ephemeral_port.rs create mode 100644 paddler_ports/src/bound_port.rs create mode 100644 paddler_ports/src/check_optional_port.rs create mode 100644 paddler_ports/src/check_port.rs create mode 100644 paddler_ports/src/lib.rs create mode 100644 paddler_ports/src/port_check_error.rs diff --git a/Cargo.lock b/Cargo.lock index 699a3cb2..c42bce60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3004,6 +3004,32 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "iced_selector" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1442ccc30959cc48b3a624233521ef7f3bc3e06528b6fe306c3a775fbddc4fe" +dependencies = [ + "iced_core", +] + +[[package]] +name = "iced_test" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b56c26cc6e63958ce6be06a68fa3136fd5f646da6302ee12abf1a0f311dd0166" +dependencies = [ + "iced_futures", + "iced_program", + "iced_renderer", + "iced_runtime", + "iced_selector", + "nom 8.0.0", + "png 0.18.1", + "sha2", + "thiserror 2.0.18", +] + [[package]] name = "iced_tiny_skia" version = "0.14.0" @@ -5007,6 +5033,32 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "paddler_gui_tests" +version = "4.0.0" +dependencies = [ + "anyhow", + "iced", + "iced_test", + "log", + "nix 0.30.1", + "paddler", + "paddler_bootstrap", + "paddler_gui", + "paddler_types", + "serial_test", + "tokio", + "tokio-util", +] + +[[package]] +name = "paddler_ports" +version = "4.0.0" +dependencies = [ + "anyhow", + "thiserror 2.0.18", +] + [[package]] name = "paddler_tests" version = "4.0.0" @@ -5022,6 +5074,7 @@ dependencies = [ "paddler", "paddler_bootstrap", "paddler_client", + "paddler_ports", "paddler_types", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 235e702d..b0bac3ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["paddler", "paddler_bootstrap", "paddler_cli", "paddler_client", "paddler_client_cli", "paddler_gui", "paddler_tests", "paddler_types"] +members = ["paddler", "paddler_bootstrap", "paddler_cli", "paddler_client", "paddler_client_cli", "paddler_gui", "paddler_gui_tests", "paddler_ports", "paddler_tests", "paddler_types"] resolver = "2" [workspace.package] @@ -58,6 +58,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" shellexpand = "3" iced = { version = "0.14", features = ["image", "svg", "tokio"] } +iced_test = "=0.14.0" if-addrs = "0.13" statum = "0.6" tempfile = "3.20.0" @@ -71,6 +72,8 @@ url = { version = "2.5", features = ["serde"] } paddler = { version = "4.0.0", path = "paddler" } paddler_bootstrap = { version = "4.0.0", path = "paddler_bootstrap" } paddler_client = { version = "4.0.0", path = "paddler_client" } +paddler_gui = { version = "4.0.0", path = "paddler_gui" } +paddler_ports = { version = "4.0.0", path = "paddler_ports" } paddler_tests = { version = "4.0.0", path = "paddler_tests" } paddler_types = { version = "4.0.0", path = "paddler_types" } diff --git a/Makefile b/Makefile index 881571ce..1c023dd5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := target/release/paddler -COVERAGE_PACKAGES := -p paddler_gui +COVERAGE_PACKAGES := -p paddler_gui -p paddler_gui_tests RUST_LOG ?= debug PADDLER_CLI_SOURCES := $(shell find paddler/src paddler_bootstrap/src paddler_cli/src paddler_client/src paddler_types/src -name '*.rs') diff --git a/paddler/src/balancer/compatibility/openai_service/mod.rs b/paddler/src/balancer/compatibility/openai_service/mod.rs index 03ea1cf7..a4098df8 100644 --- a/paddler/src/balancer/compatibility/openai_service/mod.rs +++ b/paddler/src/balancer/compatibility/openai_service/mod.rs @@ -2,6 +2,7 @@ pub mod app_data; pub mod configuration; pub mod http_route; +use std::net::TcpListener; use std::sync::Arc; use actix_web::App; @@ -22,6 +23,7 @@ use crate::service::Service; pub struct OpenAIService { pub buffered_request_manager: Arc, pub inference_service_configuration: InferenceServiceConfiguration, + pub listener: Option, pub openai_service_configuration: OpenAIServiceConfiguration, } @@ -43,8 +45,10 @@ impl Service for OpenAIService { inference_service_configuration: self.inference_service_configuration.clone(), }); - #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] - HttpServer::new(move || { + let taken_listener = self.listener.take(); + let configured_addr = self.openai_service_configuration.addr; + + let bound = HttpServer::new(move || { App::new() .wrap(create_cors_middleware(&cors_allowed_hosts_arc)) .app_data(app_data.clone()) @@ -54,11 +58,16 @@ impl Service for OpenAIService { .shutdown_signal(async move { shutdown.cancelled().await; }) - .disable_signals() - .bind(self.openai_service_configuration.addr) - .expect("Unable to bind server to address") - .run() - .await?; + .disable_signals(); + + #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] + let bound = match taken_listener { + Some(listener) => bound.listen(listener), + None => bound.bind(configured_addr), + } + .expect("Unable to bind/listen server on address"); + + bound.run().await?; Ok(()) } diff --git a/paddler/src/balancer/inference_service/mod.rs b/paddler/src/balancer/inference_service/mod.rs index 844a004a..c827abd9 100644 --- a/paddler/src/balancer/inference_service/mod.rs +++ b/paddler/src/balancer/inference_service/mod.rs @@ -2,6 +2,7 @@ pub mod app_data; pub mod configuration; pub mod http_route; +use std::net::TcpListener; use std::sync::Arc; use actix_web::App; @@ -27,6 +28,7 @@ pub struct InferenceService { pub balancer_applicable_state_holder: Arc, pub buffered_request_manager: Arc, pub configuration: InferenceServiceConfiguration, + pub listener: Option, #[cfg(feature = "web_admin_panel")] pub web_admin_panel_service_configuration: Option, } @@ -56,8 +58,11 @@ impl Service for InferenceService { shutdown: shutdown.clone(), }); + let taken_listener = self.listener.take(); + let configured_addr = self.configuration.addr; + #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] - HttpServer::new(move || { + let bound = HttpServer::new(move || { App::new() .wrap(create_cors_middleware(&cors_allowed_hosts_arc)) .app_data(app_data.clone()) @@ -70,11 +75,15 @@ impl Service for InferenceService { .shutdown_signal(async move { shutdown.cancelled().await; }) - .disable_signals() - .bind(self.configuration.addr) - .expect("Unable to bind server to address") - .run() - .await?; + .disable_signals(); + + let bound = match taken_listener { + Some(listener) => bound.listen(listener), + None => bound.bind(configured_addr), + } + .expect("Unable to bind/listen server on address"); + + bound.run().await?; Ok(()) } diff --git a/paddler/src/balancer/management_service/mod.rs b/paddler/src/balancer/management_service/mod.rs index 6c407c2d..55acdd98 100644 --- a/paddler/src/balancer/management_service/mod.rs +++ b/paddler/src/balancer/management_service/mod.rs @@ -2,6 +2,7 @@ pub mod app_data; pub mod configuration; pub mod http_route; +use std::net::TcpListener; use std::sync::Arc; use actix_web::App; @@ -35,6 +36,7 @@ pub struct ManagementService { pub configuration: ManagementServiceConfiguration, pub embedding_sender_collection: Arc, pub generate_tokens_sender_collection: Arc, + pub listener: Option, pub model_metadata_sender_collection: Arc, pub state_database: Arc, pub statsd_prefix: String, @@ -74,8 +76,10 @@ impl Service for ManagementService { statsd_prefix: self.statsd_prefix.clone(), }); - #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] - HttpServer::new(move || { + let taken_listener = self.listener.take(); + let configured_addr = self.configuration.addr; + + let bound = HttpServer::new(move || { App::new() .wrap(create_cors_middleware(&cors_allowed_hosts_arc)) .app_data(app_data.clone()) @@ -95,11 +99,16 @@ impl Service for ManagementService { .shutdown_signal(async move { shutdown.cancelled().await; }) - .disable_signals() - .bind(self.configuration.addr) - .expect("Unable to bind server to address") - .run() - .await?; + .disable_signals(); + + #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] + let bound = match taken_listener { + Some(listener) => bound.listen(listener), + None => bound.bind(configured_addr), + } + .expect("Unable to bind/listen server on address"); + + bound.run().await?; Ok(()) } diff --git a/paddler/src/balancer/web_admin_panel_service/mod.rs b/paddler/src/balancer/web_admin_panel_service/mod.rs index 715464a9..eeb7c490 100644 --- a/paddler/src/balancer/web_admin_panel_service/mod.rs +++ b/paddler/src/balancer/web_admin_panel_service/mod.rs @@ -3,6 +3,8 @@ pub mod configuration; pub mod http_route; pub mod template_data; +use std::net::TcpListener; + use actix_web::App; use actix_web::HttpServer; use actix_web::web::Data; @@ -16,6 +18,7 @@ use crate::service::Service; pub struct WebAdminPanelService { pub configuration: WebAdminPanelServiceConfiguration, + pub listener: Option, } #[async_trait] @@ -29,8 +32,10 @@ impl Service for WebAdminPanelService { template_data: self.configuration.template_data.clone(), }); - #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] - HttpServer::new(move || { + let taken_listener = self.listener.take(); + let configured_addr = self.configuration.addr; + + let bound = HttpServer::new(move || { App::new() .app_data(app_data.clone()) .configure(http_route::favicon::register) @@ -40,11 +45,16 @@ impl Service for WebAdminPanelService { .shutdown_signal(async move { shutdown.cancelled().await; }) - .disable_signals() - .bind(self.configuration.addr) - .expect("Unable to bind server to address") - .run() - .await?; + .disable_signals(); + + #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] + let bound = match taken_listener { + Some(listener) => bound.listen(listener), + None => bound.bind(configured_addr), + } + .expect("Unable to bind/listen server on address"); + + bound.run().await?; Ok(()) } diff --git a/paddler_bootstrap/src/balancer_runner.rs b/paddler_bootstrap/src/balancer_runner.rs index 6e126403..18a804de 100644 --- a/paddler_bootstrap/src/balancer_runner.rs +++ b/paddler_bootstrap/src/balancer_runner.rs @@ -1,4 +1,5 @@ use std::future::Future; +use std::net::TcpListener; use std::sync::Arc; use std::time::Duration; @@ -23,15 +24,20 @@ use crate::service_thread::ServiceThread; pub struct BalancerRunnerParams { pub buffered_request_timeout: Duration, + pub inference_listener: Option, pub inference_service_configuration: InferenceServiceConfiguration, + pub management_listener: Option, pub management_service_configuration: ManagementServiceConfiguration, pub max_buffered_requests: i32, + pub openai_listener: Option, pub openai_service_configuration: Option, pub cancellation_token: CancellationToken, pub state_database_type: StateDatabaseType, pub statsd_prefix: String, pub statsd_service_configuration: Option, #[cfg(feature = "web_admin_panel")] + pub web_admin_panel_listener: Option, + #[cfg(feature = "web_admin_panel")] pub web_admin_panel_service_configuration: Option, } @@ -47,15 +53,20 @@ impl BalancerRunner { pub async fn start( BalancerRunnerParams { buffered_request_timeout, + inference_listener, inference_service_configuration, + management_listener, management_service_configuration, max_buffered_requests, + openai_listener, openai_service_configuration, cancellation_token, state_database_type, statsd_prefix, statsd_service_configuration, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration, }: BalancerRunnerParams, ) -> Result { @@ -67,14 +78,19 @@ impl BalancerRunner { state_database, } = bootstrap_balancer(BalancerBootstrapConfig { buffered_request_timeout, + inference_listener, inference_service_configuration, + management_listener, management_service_configuration, max_buffered_requests, + openai_listener, openai_service_configuration, state_database_type, statsd_prefix, statsd_service_configuration, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration, }) .await?; diff --git a/paddler_bootstrap/src/bootstrapped_balancer_handle.rs b/paddler_bootstrap/src/bootstrapped_balancer_handle.rs index 1e208648..b000422a 100644 --- a/paddler_bootstrap/src/bootstrapped_balancer_handle.rs +++ b/paddler_bootstrap/src/bootstrapped_balancer_handle.rs @@ -1,3 +1,4 @@ +use std::net::TcpListener; use std::sync::Arc; use std::time::Duration; @@ -31,14 +32,19 @@ use tokio::sync::broadcast; pub struct BalancerBootstrapConfig { pub buffered_request_timeout: Duration, + pub inference_listener: Option, pub inference_service_configuration: InferenceServiceConfiguration, + pub management_listener: Option, pub management_service_configuration: ManagementServiceConfiguration, pub max_buffered_requests: i32, + pub openai_listener: Option, pub openai_service_configuration: Option, pub state_database_type: StateDatabaseType, pub statsd_prefix: String, pub statsd_service_configuration: Option, #[cfg(feature = "web_admin_panel")] + pub web_admin_panel_listener: Option, + #[cfg(feature = "web_admin_panel")] pub web_admin_panel_service_configuration: Option, } @@ -53,14 +59,19 @@ pub struct BootstrappedBalancerHandle { pub async fn bootstrap_balancer( BalancerBootstrapConfig { buffered_request_timeout, + inference_listener, inference_service_configuration, + management_listener, management_service_configuration, max_buffered_requests, + openai_listener, openai_service_configuration, state_database_type, statsd_prefix, statsd_service_configuration, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration, }: BalancerBootstrapConfig, ) -> anyhow::Result { @@ -94,6 +105,7 @@ pub async fn bootstrap_balancer( balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), buffered_request_manager: buffered_request_manager.clone(), configuration: inference_service_configuration.clone(), + listener: inference_listener, #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: web_admin_panel_service_configuration.clone(), }); @@ -106,6 +118,7 @@ pub async fn bootstrap_balancer( configuration: management_service_configuration, embedding_sender_collection, generate_tokens_sender_collection, + listener: management_listener, model_metadata_sender_collection, state_database: state_database.clone(), statsd_prefix, @@ -125,6 +138,7 @@ pub async fn bootstrap_balancer( service_manager.add_service(OpenAIService { buffered_request_manager: buffered_request_manager.clone(), inference_service_configuration, + listener: openai_listener, openai_service_configuration: openai_configuration, }); } @@ -141,6 +155,7 @@ pub async fn bootstrap_balancer( if let Some(web_admin_panel_configuration) = web_admin_panel_service_configuration { service_manager.add_service(WebAdminPanelService { configuration: web_admin_panel_configuration, + listener: web_admin_panel_listener, }); } diff --git a/paddler_bootstrap/tests/runners.rs b/paddler_bootstrap/tests/runners.rs index 3567cb91..5bb3edbe 100644 --- a/paddler_bootstrap/tests/runners.rs +++ b/paddler_bootstrap/tests/runners.rs @@ -50,22 +50,27 @@ fn make_balancer_runner_params( ) -> BalancerRunnerParams { BalancerRunnerParams { buffered_request_timeout: Duration::from_secs(10), + inference_listener: None, inference_service_configuration: InferenceServiceConfiguration { addr: inference_addr, cors_allowed_hosts: vec![], inference_item_timeout: Duration::from_secs(30), }, + management_listener: None, management_service_configuration: ManagementServiceConfiguration { addr: management_addr, cors_allowed_hosts: vec![], }, max_buffered_requests: 30, + openai_listener: None, openai_service_configuration: None, cancellation_token, state_database_type: StateDatabaseType::Memory(Box::default()), statsd_prefix: "paddler_bootstrap_test_".to_owned(), statsd_service_configuration: None, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener: None, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: None, } } diff --git a/paddler_cli/src/cmd/balancer.rs b/paddler_cli/src/cmd/balancer.rs index c2e25aab..1d51361d 100644 --- a/paddler_cli/src/cmd/balancer.rs +++ b/paddler_cli/src/cmd/balancer.rs @@ -113,16 +113,19 @@ impl Handler for Balancer { async fn handle(&self, shutdown: CancellationToken) -> Result<()> { let mut runner = BalancerRunner::start(BalancerRunnerParams { buffered_request_timeout: self.buffered_request_timeout, + inference_listener: None, inference_service_configuration: InferenceServiceConfiguration { addr: self.inference_addr.socket_addr, cors_allowed_hosts: self.inference_cors_allowed_hosts.clone(), inference_item_timeout: self.inference_item_timeout, }, + management_listener: None, management_service_configuration: ManagementServiceConfiguration { addr: self.management_addr.socket_addr, cors_allowed_hosts: self.management_cors_allowed_hosts.clone(), }, max_buffered_requests: self.max_buffered_requests, + openai_listener: None, openai_service_configuration: self.compat_openai_addr.clone().map( |compat_openai_addr| OpenAIServiceConfiguration { addr: compat_openai_addr.socket_addr, @@ -139,6 +142,8 @@ impl Handler for Balancer { } }), #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener: None, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: self.get_web_admin_panel_service_configuration(), }) .await?; diff --git a/paddler_gui/Cargo.toml b/paddler_gui/Cargo.toml index cbc4a758..b7001334 100644 --- a/paddler_gui/Cargo.toml +++ b/paddler_gui/Cargo.toml @@ -23,8 +23,17 @@ statum = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } +[lib] +path = "src/lib.rs" + +[[bin]] +name = "paddler_gui" +path = "src/main.rs" + [dev-dependencies] +anyhow = { workspace = true } dashmap = { workspace = true } +tokio = { workspace = true } [lints] workspace = true diff --git a/paddler_gui/src/agent_running_data.rs b/paddler_gui/src/agent_running_data.rs index e2c6d042..6e28ddcd 100644 --- a/paddler_gui/src/agent_running_data.rs +++ b/paddler_gui/src/agent_running_data.rs @@ -26,3 +26,85 @@ impl AgentRunningData { }; } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use anyhow::Result; + use anyhow::bail; + use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; + + use super::AgentRunningData; + + #[test] + fn apply_status_marks_connected_preserves_existing_name_and_copies_snapshot_fields() + -> Result<()> { + let mut data = AgentRunningData { + balancer_address: "127.0.0.1:8060".to_owned(), + connected: false, + snapshot: AgentControllerSnapshot { + desired_slots_total: 0, + download_current: 0, + download_filename: None, + download_total: 0, + id: "stale-id-should-be-cleared".to_owned(), + issues: BTreeSet::new(), + model_path: None, + name: Some("agent-fixture".to_owned()), + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + }; + + let status = SlotAggregatedStatusSnapshot { + desired_slots_total: 6, + download_current: 100, + download_filename: Some("model.gguf".to_owned()), + download_total: 200, + issues: BTreeSet::new(), + model_path: Some("/models/model.gguf".to_owned()), + slots_processing: 2, + slots_total: 6, + state_application_status: AgentStateApplicationStatus::Applied, + uses_chat_template_override: true, + version: 7, + }; + + data.apply_status(status); + + if !data.connected { + bail!("expected connected to flip to true"); + } + + if data.snapshot.name.as_deref() != Some("agent-fixture") { + bail!("expected existing name to be preserved"); + } + + if !data.snapshot.id.is_empty() { + bail!("expected id to be cleared by apply_status"); + } + + if data.snapshot.desired_slots_total != 6 { + bail!("expected desired_slots_total to be copied from status"); + } + + if data.snapshot.slots_processing != 2 { + bail!("expected slots_processing to be copied from status"); + } + + if data.snapshot.model_path.as_deref() != Some("/models/model.gguf") { + bail!("expected model_path to be copied from status"); + } + + if !data.snapshot.uses_chat_template_override { + bail!("expected uses_chat_template_override to be copied from status"); + } + + Ok(()) + } +} diff --git a/paddler_gui/src/agent_running_handler.rs b/paddler_gui/src/agent_running_handler.rs index 78bfb814..2524065b 100644 --- a/paddler_gui/src/agent_running_handler.rs +++ b/paddler_gui/src/agent_running_handler.rs @@ -25,3 +25,83 @@ impl AgentRunningData { } } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use anyhow::Result; + use anyhow::bail; + use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; + + use super::Action; + use super::AgentRunningData; + use super::Message; + + fn fresh_running_data() -> AgentRunningData { + AgentRunningData { + balancer_address: "127.0.0.1:8060".to_owned(), + connected: false, + snapshot: AgentControllerSnapshot { + desired_slots_total: 0, + download_current: 0, + download_filename: None, + download_total: 0, + id: String::new(), + issues: BTreeSet::new(), + model_path: None, + name: Some("agent-fixture".to_owned()), + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + } + } + + fn applied_status() -> SlotAggregatedStatusSnapshot { + SlotAggregatedStatusSnapshot { + desired_slots_total: 4, + download_current: 0, + download_filename: None, + download_total: 0, + issues: BTreeSet::new(), + model_path: Some("/models/model.gguf".to_owned()), + slots_processing: 1, + slots_total: 4, + state_application_status: AgentStateApplicationStatus::Applied, + uses_chat_template_override: false, + version: 1, + } + } + + #[test] + fn agent_status_updated_marks_connected_and_returns_none_action() -> Result<()> { + let mut data = fresh_running_data(); + + let action = data.update(Message::AgentStatusUpdated(applied_status())); + + match action { + Action::None => {} + Action::Disconnect => bail!("expected Action::None"), + } + + if !data.connected { + bail!("expected connected flag to flip to true after status update"); + } + + Ok(()) + } + + #[test] + fn disconnect_message_returns_disconnect_action() -> Result<()> { + let mut data = fresh_running_data(); + + match data.update(Message::Disconnect) { + Action::Disconnect => Ok(()), + Action::None => bail!("expected Action::Disconnect"), + } + } +} diff --git a/paddler_gui/src/app.rs b/paddler_gui/src/app.rs index 7bfa8024..527c3ab1 100644 --- a/paddler_gui/src/app.rs +++ b/paddler_gui/src/app.rs @@ -10,7 +10,6 @@ use iced::Fill; use iced::Right; use iced::Subscription; use iced::Task; -use iced::futures::SinkExt; use iced::keyboard; use iced::widget::column; use iced::widget::container; @@ -26,27 +25,24 @@ use paddler::balancer::state_database_type::StateDatabaseType; use paddler::balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; #[cfg(feature = "web_admin_panel")] use paddler::balancer::web_admin_panel_service::template_data::TemplateData; -use paddler::produces_snapshot::ProducesSnapshot; #[cfg(feature = "web_admin_panel")] use paddler::resolved_socket_addr::ResolvedSocketAddr; -use paddler::subscribes_to_updates::SubscribesToUpdates as _; -use paddler_bootstrap::agent_runner::AgentRunner; use paddler_bootstrap::agent_runner::AgentRunnerParams; -use paddler_bootstrap::balancer_runner::BalancerRunner; use paddler_bootstrap::balancer_runner::BalancerRunnerParams; use paddler_bootstrap::shutdown_signal::register_shutdown_signals; use paddler_types::balancer_desired_state::BalancerDesiredState; -use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; use crate::agent_running_handler; use crate::current_screen::CurrentScreen; +use crate::drive_agent_stream::drive_agent_stream; +use crate::drive_balancer_stream::drive_balancer_stream; +use crate::drive_shutdown_signal_stream::drive_shutdown_signal_stream; use crate::home_data::HomeData; use crate::home_handler; use crate::join_balancer_form_handler; use crate::message::Message; use crate::running_balancer_handler; -use crate::running_balancer_snapshot::RunningBalancerSnapshot; use crate::screen::AgentRunning; use crate::screen::Screen; use crate::start_balancer_form_handler; @@ -62,29 +58,6 @@ static BETA_IMAGE: LazyLock = LazyLock::new(|| { ImageHandle::from_bytes(include_bytes!("../../resources/images/beta.png").as_slice()) }); -fn shutdown_signal_stream() -> impl iced::futures::Stream { - iced::stream::channel(1, async move |mut output| { - let shutdown_signals = match register_shutdown_signals() { - Ok(shutdown_signals) => shutdown_signals, - Err(error) => { - log::error!("failed to register shutdown signal handlers: {error}"); - - return; - } - }; - - if let Err(error) = shutdown_signals.wait().await { - log::error!("shutdown signal listener failed: {error}"); - - return; - } - - if let Err(err) = output.send(Message::Quit).await { - log::warn!("Failed to deliver Quit message to iced runtime (receiver dropped): {err}"); - } - }) -} - pub struct App { agent_cancel: Option, shutdown: CancellationToken, @@ -318,7 +291,11 @@ impl App { _ => None, }), window::close_requests().map(|_| Message::Quit), - Subscription::run(shutdown_signal_stream), + Subscription::run(|| { + iced::stream::channel(1, |output| { + drive_shutdown_signal_stream(register_shutdown_signals(), output) + }) + }), ]) } @@ -373,71 +350,15 @@ impl App { self.agent_cancel = Some(cancel.clone()); self.screen = CurrentScreen::AgentRunning(screen); - Task::stream(iced::stream::channel(1, async move |mut output| { - let mut runner = AgentRunner::start(AgentRunnerParams { - agent_name, - management_address, - cancellation_token: cancel, - slots, - }); - - let slot_aggregated_status = runner.slot_aggregated_status.clone(); - let mut update_rx = slot_aggregated_status.subscribe_to_updates(); - let completion_future = runner.wait_for_completion(); - tokio::pin!(completion_future); - - loop { - match slot_aggregated_status.make_snapshot() { - Ok(snapshot) => { - if output - .send(Message::AgentRunning( - agent_running_handler::Message::AgentStatusUpdated(snapshot), - )) - .await - .is_err() - { - return; - } - } - Err(error) => { - log::error!("Failed to make agent status snapshot: {error}"); - - return; - } - } - - tokio::select! { - changed = update_rx.changed() => { - if changed.is_err() { - return; - } - } - result = &mut completion_future => { - match result { - Ok(()) => { - if let Err(err) = output.send(Message::AgentStopped).await { - log::warn!( - "Failed to deliver AgentStopped to UI (receiver dropped): {err}" - ); - } - } - Err(error) => { - let detail = error.to_string(); - if let Err(err) = output - .send(Message::AgentFailed(detail.clone())) - .await - { - log::error!( - "Failed to deliver AgentFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" - ); - } - } - } + let params = AgentRunnerParams { + agent_name, + cancellation_token: cancel, + management_address, + slots, + }; - return; - } - } - } + Task::stream(iced::stream::channel(1, move |output| { + drive_agent_stream(params, output) })) } @@ -446,6 +367,36 @@ impl App { self.shutdown.clone() } + #[cfg(test)] + pub fn agent_cancel_for_test(&self) -> Option { + self.agent_cancel.clone() + } + + #[cfg(test)] + pub fn balancer_cancel_for_test(&self) -> Option { + self.balancer_cancel.clone() + } + + #[cfg(test)] + pub fn set_balancer_cancel_for_test(&mut self, token: CancellationToken) { + self.balancer_cancel = Some(token); + } + + #[cfg(test)] + pub fn set_agent_cancel_for_test(&mut self, token: CancellationToken) { + self.agent_cancel = Some(token); + } + + #[cfg(test)] + pub fn current_screen_for_test(&self) -> &CurrentScreen { + &self.screen + } + + #[cfg(test)] + pub fn set_screen_for_test(&mut self, screen: CurrentScreen) { + self.screen = screen; + } + fn spawn_balancer( &mut self, management_addr: SocketAddr, @@ -491,164 +442,648 @@ impl App { let params = BalancerRunnerParams { buffered_request_timeout, + inference_listener: None, inference_service_configuration: InferenceServiceConfiguration { addr: inference_addr, cors_allowed_hosts: vec![], inference_item_timeout: Duration::from_secs(30), }, + management_listener: None, management_service_configuration: ManagementServiceConfiguration { addr: management_addr, cors_allowed_hosts: vec![], }, max_buffered_requests, + openai_listener: None, openai_service_configuration: None, cancellation_token: cancel, state_database_type: StateDatabaseType::Memory(Box::new(desired_state.clone())), statsd_prefix: statsd_prefix.to_owned(), statsd_service_configuration: None, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener: None, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration, }; - Task::stream(iced::stream::channel(1, async move |mut output| { - let mut runner = match BalancerRunner::start(params).await { - Ok(runner) => runner, - Err(error) => { - let detail = error.to_string(); - if let Err(err) = output.send(Message::BalancerFailed(detail.clone())).await { - log::error!( - "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" - ); - } + Task::stream(iced::stream::channel(1, move |output| { + drive_balancer_stream(params, output) + })) + } +} - return; - } - }; +#[cfg(test)] +mod tests { + use std::net::TcpListener; - let completion_future = runner.wait_for_completion(); - tokio::pin!(completion_future); + use anyhow::Result; + use anyhow::bail; - if output.send(Message::BalancerStarted).await.is_err() { - return; - } + use super::*; + use crate::agent_running_data::AgentRunningData; + use crate::join_balancer_form_data::JoinBalancerFormData; + use crate::running_balancer_data::RunningBalancerData; + use crate::running_balancer_snapshot::RunningBalancerSnapshot; + use crate::screen::AgentRunning; + use crate::screen::JoinBalancerForm; + use crate::screen::RunningBalancer; + use crate::screen::StartBalancerForm; + use crate::start_balancer_form_data::StartBalancerFormData; + + fn fresh_join_form_data() -> JoinBalancerFormData { + JoinBalancerFormData::default() + } - let mut desired_state_rx = runner.balancer_desired_state_tx.subscribe(); - let mut current_desired_state = runner.initial_desired_state.clone(); - let mut pool_update_rx = runner.agent_controller_pool.subscribe_to_updates(); - let mut holder_update_rx = runner - .balancer_applicable_state_holder - .subscribe_to_updates(); - - loop { - match RunningBalancerSnapshot::build( - &runner.agent_controller_pool, - &runner.balancer_applicable_state_holder, - current_desired_state.clone(), - ) { - Ok(snapshot) => { - if output - .send(Message::RunningBalancer( - running_balancer_handler::Message::SnapshotUpdated(Box::new( - snapshot, - )), - )) - .await - .is_err() - { - return; - } - } - Err(error) => { - log::error!("Failed to build running balancer snapshot: {error}"); + fn fresh_start_form_data() -> StartBalancerFormData { + StartBalancerFormData { + add_model_later: false, + balancer_address: String::new(), + balancer_address_error: None, + inference_address: String::new(), + inference_address_error: None, + model_error: None, + selected_model: None, + starting: false, + web_admin_panel_address: String::new(), + web_admin_panel_address_error: None, + web_admin_panel_address_placeholder: String::new(), + } + } - return; - } - } + fn fresh_running_data() -> RunningBalancerData { + RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: None, + } + } - tokio::select! { - changed = pool_update_rx.changed() => { - if changed.is_err() { - return; - } - } - changed = holder_update_rx.changed() => { - if changed.is_err() { - return; - } - } - desired_state_result = desired_state_rx.recv() => { - match desired_state_result { - Ok(new_desired_state) => { - current_desired_state = new_desired_state; - } - Err(broadcast::error::RecvError::Lagged(missed)) => { - log::warn!( - "Desired-state broadcast lagged by {missed} messages; \ - continuing with the last known state" - ); - } - Err(broadcast::error::RecvError::Closed) => { - log::info!( - "Desired-state broadcast closed; ending snapshot stream" - ); - - return; - } - } - } - result = &mut completion_future => { - match result { - Ok(()) => { - if let Err(err) = output.send(Message::BalancerStopped).await { - log::warn!( - "Failed to deliver BalancerStopped to UI (receiver dropped): {err}" - ); - } - } - Err(error) => { - let detail = error.to_string(); - if let Err(err) = output - .send(Message::BalancerFailed(detail.clone())) - .await - { - log::error!( - "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" - ); - } - } - } + fn fresh_agent_running_data() -> AgentRunningData { + use std::collections::BTreeSet; + + use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + + AgentRunningData { + balancer_address: "127.0.0.1:8060".to_owned(), + connected: false, + snapshot: AgentControllerSnapshot { + desired_slots_total: 0, + download_current: 0, + download_filename: None, + download_total: 0, + id: String::new(), + issues: BTreeSet::new(), + model_path: None, + name: None, + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + } + } - return; - } - } - } - })) + fn app_with_screen(screen: CurrentScreen) -> App { + let (mut app, _initial_task) = App::new(); + app.set_screen_for_test(screen); + app } -} -#[cfg(test)] -mod tests { - use super::*; + fn screen_join_form(form: JoinBalancerFormData) -> CurrentScreen { + CurrentScreen::JoinBalancerForm( + Screen::::builder().state_data(form).build(), + ) + } + + fn screen_start_form(form: StartBalancerFormData) -> CurrentScreen { + CurrentScreen::StartBalancerForm( + Screen::::builder() + .state_data(form) + .build(), + ) + } + + fn screen_running(data: RunningBalancerData) -> CurrentScreen { + CurrentScreen::RunningBalancer( + Screen::::builder().state_data(data).build(), + ) + } + + fn screen_agent_running(data: AgentRunningData) -> CurrentScreen { + CurrentScreen::AgentRunning( + Screen::::builder().state_data(data).build(), + ) + } + + fn ephemeral_loopback_addr() -> Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let addr = listener.local_addr()?; + drop(listener); + Ok(addr.to_string()) + } + + fn assert_screen_is_home(app: &App) -> Result<()> { + match app.current_screen_for_test() { + CurrentScreen::Home(_) => Ok(()), + _ => bail!("expected Home screen"), + } + } #[test] - fn quit_message_cancels_shutdown_token() { + fn quit_message_cancels_shutdown_token() -> Result<()> { let (mut app, _initial_task) = App::new(); let shutdown = app.shutdown_token_for_test(); - assert!(!shutdown.is_cancelled()); + if shutdown.is_cancelled() { + bail!("expected shutdown token to start uncancelled"); + } let _exit_task = app.update(Message::Quit); - assert!(shutdown.is_cancelled()); + if !shutdown.is_cancelled() { + bail!("expected Quit to cancel shutdown token"); + } + Ok(()) } #[test] - fn quit_message_drops_both_runners() { + fn quit_message_drops_both_runners() -> Result<()> { let (mut app, _initial_task) = App::new(); let _exit_task = app.update(Message::Quit); - assert!(app.agent_cancel.is_none()); - assert!(app.balancer_cancel.is_none()); + if app.agent_cancel_for_test().is_some() { + bail!("expected Quit to drop agent_cancel"); + } + if app.balancer_cancel_for_test().is_some() { + bail!("expected Quit to drop balancer_cancel"); + } + Ok(()) + } + + #[test] + fn iced_event_loop_ready_preserves_current_screen() -> Result<()> { + let (mut app, _) = App::new(); + let _ = app.update(Message::IcedEventLoopReady); + assert_screen_is_home(&app) + } + + #[test] + fn home_start_balancer_message_transitions_to_start_balancer_form_screen() -> Result<()> { + let (mut app, _) = App::new(); + + let _ = app.update(Message::Home(home_handler::Message::StartBalancer)); + + match app.current_screen_for_test() { + CurrentScreen::StartBalancerForm(_) => Ok(()), + _ => bail!("expected StartBalancerForm screen"), + } + } + + #[test] + fn home_join_balancer_message_transitions_to_join_balancer_form_screen() -> Result<()> { + let (mut app, _) = App::new(); + + let _ = app.update(Message::Home(home_handler::Message::JoinBalancer)); + + match app.current_screen_for_test() { + CurrentScreen::JoinBalancerForm(_) => Ok(()), + _ => bail!("expected JoinBalancerForm screen"), + } + } + + #[test] + fn join_form_setter_message_keeps_user_on_the_join_balancer_form() -> Result<()> { + let mut app = app_with_screen(screen_join_form(fresh_join_form_data())); + + let _ = app.update(Message::JoinBalancerForm( + join_balancer_form_handler::Message::SetAgentName("alice".to_owned()), + )); + + match app.current_screen_for_test() { + CurrentScreen::JoinBalancerForm(_) => Ok(()), + _ => bail!("expected to stay on JoinBalancerForm"), + } + } + + #[test] + fn join_form_cancel_message_returns_user_to_home() -> Result<()> { + let mut app = app_with_screen(screen_join_form(fresh_join_form_data())); + + let _ = app.update(Message::JoinBalancerForm( + join_balancer_form_handler::Message::Cancel, + )); + + assert_screen_is_home(&app) + } + + #[test] + fn join_form_connect_action_transitions_to_agent_running_and_sets_agent_cancel_token() + -> Result<()> { + let mut app = app_with_screen(screen_join_form(JoinBalancerFormData { + balancer_address: "127.0.0.1:8060".to_owned(), + slots_count: "2".to_owned(), + ..fresh_join_form_data() + })); + + let _ = app.update(Message::JoinBalancerForm( + join_balancer_form_handler::Message::Connect, + )); + + match app.current_screen_for_test() { + CurrentScreen::AgentRunning(_) => {} + _ => bail!("expected AgentRunning screen"), + } + + if app.agent_cancel_for_test().is_none() { + bail!("expected agent_cancel token to be set"); + } + + // Stop the spawned task immediately so the test does not leave a runner. + if let Some(token) = app.agent_cancel_for_test() { + token.cancel(); + } + + Ok(()) + } + + #[test] + fn start_form_setter_message_keeps_user_on_the_start_balancer_form() -> Result<()> { + let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); + + let _ = app.update(Message::StartBalancerForm( + start_balancer_form_handler::Message::SetBalancerAddress("127.0.0.1:0".to_owned()), + )); + + match app.current_screen_for_test() { + CurrentScreen::StartBalancerForm(_) => Ok(()), + _ => bail!("expected to stay on StartBalancerForm"), + } + } + + #[test] + fn start_form_cancel_message_cancels_pending_balancer_token_and_returns_home() -> Result<()> { + let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); + let token = CancellationToken::new(); + app.set_balancer_cancel_for_test(token.clone()); + + let _ = app.update(Message::StartBalancerForm( + start_balancer_form_handler::Message::Cancel, + )); + + if !token.is_cancelled() { + bail!("expected balancer_cancel token to be cancelled"); + } + + assert_screen_is_home(&app) + } + + #[test] + fn start_form_confirm_action_sets_balancer_cancel_token_and_starts_spawn() -> Result<()> { + let management_addr = ephemeral_loopback_addr()?; + let inference_addr = ephemeral_loopback_addr()?; + + let form = StartBalancerFormData { + balancer_address: management_addr, + inference_address: inference_addr, + add_model_later: true, + ..fresh_start_form_data() + }; + + let mut app = app_with_screen(screen_start_form(form)); + + let _ = app.update(Message::StartBalancerForm( + start_balancer_form_handler::Message::Confirm, + )); + + match app.current_screen_for_test() { + CurrentScreen::StartBalancerForm(_) => {} + _ => bail!("expected to stay on StartBalancerForm during spawn"), + } + + if app.balancer_cancel_for_test().is_none() { + bail!("expected balancer_cancel token to be set after Confirm"); + } + + if let Some(token) = app.balancer_cancel_for_test() { + token.cancel(); + } + + Ok(()) + } + + #[test] + fn balancer_started_message_transitions_from_start_form_to_running_balancer() -> Result<()> { + let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); + + let _ = app.update(Message::BalancerStarted); + + match app.current_screen_for_test() { + CurrentScreen::RunningBalancer(_) => Ok(()), + _ => bail!("expected RunningBalancer screen"), + } + } + + #[test] + fn balancer_failed_message_during_startup_returns_user_to_home_with_error() -> Result<()> { + let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); + app.set_balancer_cancel_for_test(CancellationToken::new()); + + let _ = app.update(Message::BalancerFailed("bind error".to_owned())); + + match app.current_screen_for_test() { + CurrentScreen::Home(home) => { + if home.state_data.error.as_deref() != Some("bind error") { + bail!("expected home error to carry the failure message"); + } + if app.balancer_cancel_for_test().is_some() { + bail!("expected balancer_cancel to be dropped"); + } + Ok(()) + } + _ => bail!("expected Home screen"), + } + } + + #[test] + fn running_balancer_snapshot_update_keeps_user_on_the_running_balancer_screen() -> Result<()> { + let mut app = app_with_screen(screen_running(fresh_running_data())); + + let _ = app.update(Message::RunningBalancer( + running_balancer_handler::Message::SnapshotUpdated(Box::new( + RunningBalancerSnapshot::default(), + )), + )); + + match app.current_screen_for_test() { + CurrentScreen::RunningBalancer(_) => Ok(()), + _ => bail!("expected to stay on RunningBalancer"), + } + } + + #[test] + fn running_balancer_stop_message_cancels_token_and_keeps_user_on_screen() -> Result<()> { + let mut app = app_with_screen(screen_running(fresh_running_data())); + let token = CancellationToken::new(); + app.set_balancer_cancel_for_test(token.clone()); + + let _ = app.update(Message::RunningBalancer( + running_balancer_handler::Message::Stop, + )); + + if !token.is_cancelled() { + bail!("expected Stop to cancel balancer_cancel token"); + } + + match app.current_screen_for_test() { + CurrentScreen::RunningBalancer(_) => Ok(()), + _ => bail!("expected to stay on RunningBalancer"), + } + } + + #[test] + fn running_balancer_copy_to_clipboard_keeps_user_on_screen() -> Result<()> { + let mut app = app_with_screen(screen_running(fresh_running_data())); + + let _ = app.update(Message::RunningBalancer( + running_balancer_handler::Message::CopyToClipboard("text".to_owned()), + )); + + match app.current_screen_for_test() { + CurrentScreen::RunningBalancer(_) => Ok(()), + _ => bail!("expected to stay on RunningBalancer"), + } + } + + #[test] + fn running_balancer_open_url_with_invalid_url_logs_error_but_keeps_user_on_screen() + -> Result<()> { + let mut app = app_with_screen(screen_running(fresh_running_data())); + + let _ = app.update(Message::RunningBalancer( + running_balancer_handler::Message::OpenUrl("not-a-real-scheme://broken".to_owned()), + )); + + match app.current_screen_for_test() { + CurrentScreen::RunningBalancer(_) => Ok(()), + _ => bail!("expected to stay on RunningBalancer"), + } + } + + #[test] + fn balancer_stopped_message_from_running_balancer_returns_user_to_home() -> Result<()> { + let mut app = app_with_screen(screen_running(fresh_running_data())); + app.set_balancer_cancel_for_test(CancellationToken::new()); + + let _ = app.update(Message::BalancerStopped); + + if app.balancer_cancel_for_test().is_some() { + bail!("expected balancer_cancel to be dropped"); + } + + assert_screen_is_home(&app) + } + + #[test] + fn balancer_failed_message_from_running_balancer_returns_user_to_home_with_error() + -> Result<()> { + let mut app = app_with_screen(screen_running(fresh_running_data())); + + let _ = app.update(Message::BalancerFailed("crash".to_owned())); + + match app.current_screen_for_test() { + CurrentScreen::Home(home) => match home.state_data.error.as_deref() { + Some("crash") => Ok(()), + _ => bail!("expected error message to be carried over"), + }, + _ => bail!("expected Home screen"), + } + } + + #[test] + fn agent_running_status_update_keeps_user_on_the_agent_running_screen() -> Result<()> { + use std::collections::BTreeSet; + + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; + + let mut app = app_with_screen(screen_agent_running(fresh_agent_running_data())); + + let _ = app.update(Message::AgentRunning( + agent_running_handler::Message::AgentStatusUpdated(SlotAggregatedStatusSnapshot { + desired_slots_total: 0, + download_current: 0, + download_filename: None, + download_total: 0, + issues: BTreeSet::new(), + model_path: None, + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + version: 0, + }), + )); + + match app.current_screen_for_test() { + CurrentScreen::AgentRunning(_) => Ok(()), + _ => bail!("expected to stay on AgentRunning"), + } + } + + #[test] + fn agent_running_disconnect_message_cancels_token_and_returns_user_to_home() -> Result<()> { + let mut app = app_with_screen(screen_agent_running(fresh_agent_running_data())); + let token = CancellationToken::new(); + app.set_agent_cancel_for_test(token.clone()); + + let _ = app.update(Message::AgentRunning( + agent_running_handler::Message::Disconnect, + )); + + if !token.is_cancelled() { + bail!("expected Disconnect to cancel agent_cancel token"); + } + + assert_screen_is_home(&app) + } + + #[test] + fn agent_stopped_message_returns_user_to_home_without_error() -> Result<()> { + let mut app = app_with_screen(screen_agent_running(fresh_agent_running_data())); + app.set_agent_cancel_for_test(CancellationToken::new()); + + let _ = app.update(Message::AgentStopped); + + if app.agent_cancel_for_test().is_some() { + bail!("expected agent_cancel to be dropped on AgentStopped"); + } + + assert_screen_is_home(&app) + } + + #[test] + fn agent_failed_message_returns_user_to_home_with_error() -> Result<()> { + let mut app = app_with_screen(screen_agent_running(fresh_agent_running_data())); + + let _ = app.update(Message::AgentFailed("agent failure".to_owned())); + + match app.current_screen_for_test() { + CurrentScreen::Home(home) => match home.state_data.error.as_deref() { + Some("agent failure") => Ok(()), + _ => bail!("expected agent failure message on home"), + }, + _ => bail!("expected Home screen"), + } + } + + #[test] + fn tab_pressed_without_shift_focuses_the_next_widget() -> Result<()> { + let (mut app, _) = App::new(); + let _ = app.update(Message::TabPressed { shift: false }); + // No screen change expected — just verify the call returns. + assert_screen_is_home(&app) + } + + #[test] + fn tab_pressed_with_shift_focuses_the_previous_widget() -> Result<()> { + let (mut app, _) = App::new(); + let _ = app.update(Message::TabPressed { shift: true }); + assert_screen_is_home(&app) + } + + #[test] + fn an_unhandled_message_for_the_current_screen_is_logged_and_keeps_the_screen() -> Result<()> { + let (mut app, _) = App::new(); + // BalancerStarted is only meaningful from the StartBalancerForm screen. + let _ = app.update(Message::BalancerStarted); + assert_screen_is_home(&app) + } + + #[test] + fn view_with_home_screen_renders_the_beta_overlay_branch() -> Result<()> { + let (app, _) = App::new(); + let _element = app.view(); + Ok(()) + } + + #[test] + fn view_with_running_balancer_screen_renders_without_the_beta_overlay_branch() -> Result<()> { + let app = app_with_screen(screen_running(fresh_running_data())); + let _element = app.view(); + Ok(()) + } + + #[test] + fn view_with_agent_running_screen_renders_the_agent_running_branch() -> Result<()> { + let app = app_with_screen(screen_agent_running(fresh_agent_running_data())); + let _element = app.view(); + Ok(()) + } + + #[test] + fn view_with_join_balancer_form_renders_the_join_form_branch() -> Result<()> { + let app = app_with_screen(screen_join_form(fresh_join_form_data())); + let _element = app.view(); + Ok(()) + } + + #[test] + fn view_with_start_balancer_form_renders_the_start_form_branch() -> Result<()> { + let app = app_with_screen(screen_start_form(fresh_start_form_data())); + let _element = app.view(); + Ok(()) + } + + fn three_ephemeral_loopback_addrs() -> Result<[String; 3]> { + let listener_one = TcpListener::bind("127.0.0.1:0")?; + let listener_two = TcpListener::bind("127.0.0.1:0")?; + let listener_three = TcpListener::bind("127.0.0.1:0")?; + + let addr_one = listener_one.local_addr()?.to_string(); + let addr_two = listener_two.local_addr()?.to_string(); + let addr_three = listener_three.local_addr()?.to_string(); + + drop(listener_one); + drop(listener_two); + drop(listener_three); + + Ok([addr_one, addr_two, addr_three]) + } + + #[test] + fn start_balancer_with_web_admin_panel_address_builds_web_admin_configuration() -> Result<()> { + let [management_addr, inference_addr, web_admin_addr] = three_ephemeral_loopback_addrs()?; + + let form = StartBalancerFormData { + balancer_address: management_addr, + inference_address: inference_addr, + web_admin_panel_address: web_admin_addr, + add_model_later: true, + ..fresh_start_form_data() + }; + + let mut app = app_with_screen(screen_start_form(form)); + + let _ = app.update(Message::StartBalancerForm( + start_balancer_form_handler::Message::Confirm, + )); + + let Some(token) = app.balancer_cancel_for_test() else { + bail!("expected balancer_cancel token to be set after Confirm with web admin address"); + }; + + token.cancel(); + + Ok(()) + } + + #[test] + fn subscription_returns_a_batch_without_panicking() -> Result<()> { + let (app, _) = App::new(); + let _subscription = app.subscription(); + Ok(()) } } diff --git a/paddler_gui/src/current_screen.rs b/paddler_gui/src/current_screen.rs index 70c40943..d495aa8b 100644 --- a/paddler_gui/src/current_screen.rs +++ b/paddler_gui/src/current_screen.rs @@ -24,3 +24,26 @@ impl Default for CurrentScreen { ) } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + + use super::CurrentScreen; + + #[test] + fn default_current_screen_is_home_with_no_error() -> Result<()> { + let screen = CurrentScreen::default(); + + match screen { + CurrentScreen::Home(home) => { + if home.state_data.error.is_some() { + bail!("expected default home to carry no error"); + } + Ok(()) + } + _ => bail!("expected default to be Home"), + } + } +} diff --git a/paddler_gui/src/detect_network_interfaces.rs b/paddler_gui/src/detect_network_interfaces.rs index c80a52ff..085e11e8 100644 --- a/paddler_gui/src/detect_network_interfaces.rs +++ b/paddler_gui/src/detect_network_interfaces.rs @@ -24,3 +24,26 @@ pub fn detect_network_interfaces() -> Vec { }) .collect() } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + + use super::detect_network_interfaces; + + #[test] + fn detected_addresses_are_ipv4_and_not_loopback() -> Result<()> { + for address in detect_network_interfaces() { + if !address.ip_address.is_ipv4() { + bail!("expected only ipv4 addresses, got {}", address.ip_address); + } + + if address.ip_address.is_loopback() { + bail!("expected loopback to be filtered, got {}", address.ip_address); + } + } + + Ok(()) + } +} diff --git a/paddler_gui/src/drive_agent_stream.rs b/paddler_gui/src/drive_agent_stream.rs new file mode 100644 index 00000000..f1eaf785 --- /dev/null +++ b/paddler_gui/src/drive_agent_stream.rs @@ -0,0 +1,71 @@ +use iced::futures::SinkExt as _; +use iced::futures::channel::mpsc::Sender; +use paddler::produces_snapshot::ProducesSnapshot as _; +use paddler::subscribes_to_updates::SubscribesToUpdates as _; +use paddler_bootstrap::agent_runner::AgentRunner; +use paddler_bootstrap::agent_runner::AgentRunnerParams; + +use crate::agent_running_handler; +use crate::message::Message; + +pub async fn drive_agent_stream(params: AgentRunnerParams, mut output: Sender) { + let mut runner = AgentRunner::start(params); + + let slot_aggregated_status = runner.slot_aggregated_status.clone(); + let mut update_rx = slot_aggregated_status.subscribe_to_updates(); + let completion_future = runner.wait_for_completion(); + tokio::pin!(completion_future); + + loop { + match slot_aggregated_status.make_snapshot() { + Ok(snapshot) => { + if output + .send(Message::AgentRunning( + agent_running_handler::Message::AgentStatusUpdated(snapshot), + )) + .await + .is_err() + { + return; + } + } + Err(error) => { + log::error!("Failed to make agent status snapshot: {error}"); + + return; + } + } + + tokio::select! { + changed = update_rx.changed() => { + if changed.is_err() { + return; + } + } + result = &mut completion_future => { + match result { + Ok(()) => { + if let Err(err) = output.send(Message::AgentStopped).await { + log::warn!( + "Failed to deliver AgentStopped to UI (receiver dropped): {err}" + ); + } + } + Err(error) => { + let detail = error.to_string(); + if let Err(err) = output + .send(Message::AgentFailed(detail.clone())) + .await + { + log::error!( + "Failed to deliver AgentFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" + ); + } + } + } + + return; + } + } + } +} diff --git a/paddler_gui/src/drive_balancer_stream.rs b/paddler_gui/src/drive_balancer_stream.rs new file mode 100644 index 00000000..01f7f9e1 --- /dev/null +++ b/paddler_gui/src/drive_balancer_stream.rs @@ -0,0 +1,122 @@ +use iced::futures::SinkExt as _; +use iced::futures::channel::mpsc::Sender; +use paddler::subscribes_to_updates::SubscribesToUpdates as _; +use paddler_bootstrap::balancer_runner::BalancerRunner; +use paddler_bootstrap::balancer_runner::BalancerRunnerParams; +use tokio::sync::broadcast; + +use crate::message::Message; +use crate::running_balancer_handler; +use crate::running_balancer_snapshot::RunningBalancerSnapshot; + +pub async fn drive_balancer_stream(params: BalancerRunnerParams, mut output: Sender) { + let mut runner = match BalancerRunner::start(params).await { + Ok(runner) => runner, + Err(error) => { + let detail = error.to_string(); + if let Err(err) = output.send(Message::BalancerFailed(detail.clone())).await { + log::error!( + "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" + ); + } + + return; + } + }; + + let completion_future = runner.wait_for_completion(); + tokio::pin!(completion_future); + + if output.send(Message::BalancerStarted).await.is_err() { + return; + } + + let mut desired_state_rx = runner.balancer_desired_state_tx.subscribe(); + let mut current_desired_state = runner.initial_desired_state.clone(); + let mut pool_update_rx = runner.agent_controller_pool.subscribe_to_updates(); + let mut holder_update_rx = runner + .balancer_applicable_state_holder + .subscribe_to_updates(); + + loop { + match RunningBalancerSnapshot::build( + &runner.agent_controller_pool, + &runner.balancer_applicable_state_holder, + current_desired_state.clone(), + ) { + Ok(snapshot) => { + if output + .send(Message::RunningBalancer( + running_balancer_handler::Message::SnapshotUpdated(Box::new(snapshot)), + )) + .await + .is_err() + { + return; + } + } + Err(error) => { + log::error!("Failed to build running balancer snapshot: {error}"); + + return; + } + } + + tokio::select! { + changed = pool_update_rx.changed() => { + if changed.is_err() { + return; + } + } + changed = holder_update_rx.changed() => { + if changed.is_err() { + return; + } + } + desired_state_result = desired_state_rx.recv() => { + match desired_state_result { + Ok(new_desired_state) => { + current_desired_state = new_desired_state; + } + Err(broadcast::error::RecvError::Lagged(missed)) => { + log::warn!( + "Desired-state broadcast lagged by {missed} messages; \ + continuing with the last known state" + ); + } + Err(broadcast::error::RecvError::Closed) => { + log::info!( + "Desired-state broadcast closed; ending snapshot stream" + ); + + return; + } + } + } + result = &mut completion_future => { + match result { + Ok(()) => { + if let Err(err) = output.send(Message::BalancerStopped).await { + log::warn!( + "Failed to deliver BalancerStopped to UI (receiver dropped): {err}" + ); + } + } + Err(error) => { + let detail = error.to_string(); + if let Err(err) = output + .send(Message::BalancerFailed(detail.clone())) + .await + { + log::error!( + "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" + ); + } + } + } + + return; + } + } + } +} diff --git a/paddler_gui/src/drive_shutdown_signal_stream.rs b/paddler_gui/src/drive_shutdown_signal_stream.rs new file mode 100644 index 00000000..3ce62bc9 --- /dev/null +++ b/paddler_gui/src/drive_shutdown_signal_stream.rs @@ -0,0 +1,30 @@ +use anyhow::Result; +use iced::futures::SinkExt as _; +use iced::futures::channel::mpsc::Sender; +use paddler_bootstrap::shutdown_signal::ShutdownSignals; + +use crate::message::Message; + +pub async fn drive_shutdown_signal_stream( + signals: Result, + mut output: Sender, +) { + let shutdown_signals = match signals { + Ok(shutdown_signals) => shutdown_signals, + Err(error) => { + log::error!("failed to register shutdown signal handlers: {error}"); + + return; + } + }; + + if let Err(error) = shutdown_signals.wait().await { + log::error!("shutdown signal listener failed: {error}"); + + return; + } + + if let Err(err) = output.send(Message::Quit).await { + log::warn!("Failed to deliver Quit message to iced runtime (receiver dropped): {err}"); + } +} diff --git a/paddler_gui/src/home_handler.rs b/paddler_gui/src/home_handler.rs index 41cf98b8..77a9cb55 100644 --- a/paddler_gui/src/home_handler.rs +++ b/paddler_gui/src/home_handler.rs @@ -19,3 +19,29 @@ impl HomeData { } } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + + use super::Action; + use super::HomeData; + use super::Message; + + #[test] + fn start_balancer_message_dispatches_to_start_balancer_action() -> Result<()> { + match HomeData::update(Message::StartBalancer) { + Action::StartBalancer => Ok(()), + Action::JoinBalancer => bail!("expected StartBalancer action"), + } + } + + #[test] + fn join_balancer_message_dispatches_to_join_balancer_action() -> Result<()> { + match HomeData::update(Message::JoinBalancer) { + Action::JoinBalancer => Ok(()), + Action::StartBalancer => bail!("expected JoinBalancer action"), + } + } +} diff --git a/paddler_gui/src/join_balancer_form_handler.rs b/paddler_gui/src/join_balancer_form_handler.rs index acf37150..6a1a5ec8 100644 --- a/paddler_gui/src/join_balancer_form_handler.rs +++ b/paddler_gui/src/join_balancer_form_handler.rs @@ -108,3 +108,237 @@ impl JoinBalancerFormData { } } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + + use super::Action; + use super::JoinBalancerFormData; + use super::Message; + + #[test] + fn set_agent_name_records_typed_value_into_form_state() -> Result<()> { + let mut data = JoinBalancerFormData::default(); + + match data.update(Message::SetAgentName("alice".to_owned())) { + Action::None => {} + _ => bail!("expected Action::None"), + } + + if data.agent_name != "alice" { + bail!("expected agent_name to record typed value"); + } + + Ok(()) + } + + #[test] + fn set_balancer_address_clears_previously_set_address_error() -> Result<()> { + let mut data = JoinBalancerFormData { + balancer_address_error: Some("stale".to_owned()), + ..JoinBalancerFormData::default() + }; + + match data.update(Message::SetBalancerAddress("127.0.0.1:8080".to_owned())) { + Action::None => {} + _ => bail!("expected Action::None"), + } + + if data.balancer_address_error.is_some() { + bail!("expected balancer_address_error to be cleared"); + } + + if data.balancer_address != "127.0.0.1:8080" { + bail!("expected new balancer_address to be stored"); + } + + Ok(()) + } + + #[test] + fn set_slots_count_accepts_digit_only_input() -> Result<()> { + let mut data = JoinBalancerFormData::default(); + + let _action = data.update(Message::SetSlotsCount("42".to_owned())); + + if data.slots_count != "42" { + bail!("expected slots_count to be updated to digit string"); + } + + Ok(()) + } + + #[test] + fn set_slots_count_silently_ignores_non_digit_input() -> Result<()> { + let mut data = JoinBalancerFormData { + slots_count: "10".to_owned(), + ..JoinBalancerFormData::default() + }; + + let _action = data.update(Message::SetSlotsCount("abc".to_owned())); + + if data.slots_count != "10" { + bail!("expected slots_count to be unchanged after non-digit input"); + } + + Ok(()) + } + + #[test] + fn cancel_message_returns_cancel_action() -> Result<()> { + let mut data = JoinBalancerFormData::default(); + + match data.update(Message::Cancel) { + Action::Cancel => Ok(()), + _ => bail!("expected Action::Cancel"), + } + } + + #[test] + fn connecting_without_a_cluster_address_records_required_error() -> Result<()> { + let mut data = JoinBalancerFormData { + slots_count: "1".to_owned(), + ..JoinBalancerFormData::default() + }; + + match data.update(Message::Connect) { + Action::None => {} + _ => bail!("expected Action::None when validation fails"), + } + + match data.balancer_address_error.as_deref() { + Some(message) if message.contains("required") => Ok(()), + other => bail!("expected required-message error, got {other:?}"), + } + } + + #[test] + fn connecting_with_an_unparseable_cluster_address_records_invalid_format_error() -> Result<()> { + let mut data = JoinBalancerFormData { + balancer_address: "not-a-socket-addr".to_owned(), + slots_count: "1".to_owned(), + ..JoinBalancerFormData::default() + }; + + let _ = data.update(Message::Connect); + + match data.balancer_address_error.as_deref() { + Some(message) if message.contains("IP:port") => Ok(()), + other => bail!("expected IP:port-format error, got {other:?}"), + } + } + + #[test] + fn connecting_without_a_slot_count_records_required_error() -> Result<()> { + let mut data = JoinBalancerFormData { + balancer_address: "127.0.0.1:8060".to_owned(), + ..JoinBalancerFormData::default() + }; + + let _ = data.update(Message::Connect); + + match data.slots_error.as_deref() { + Some(message) if message.contains("required") => Ok(()), + other => bail!("expected required-slots error, got {other:?}"), + } + } + + #[test] + fn connecting_with_zero_slots_records_must_be_greater_than_zero_error() -> Result<()> { + let mut data = JoinBalancerFormData { + balancer_address: "127.0.0.1:8060".to_owned(), + slots_count: "0".to_owned(), + ..JoinBalancerFormData::default() + }; + + let _ = data.update(Message::Connect); + + match data.slots_error.as_deref() { + Some(message) if message.contains("greater than zero") => Ok(()), + other => bail!("expected greater-than-zero error, got {other:?}"), + } + } + + #[test] + fn connecting_with_an_overflowing_slot_count_records_too_large_error() -> Result<()> { + let mut data = JoinBalancerFormData { + balancer_address: "127.0.0.1:8060".to_owned(), + slots_count: "9999999999".to_owned(), + ..JoinBalancerFormData::default() + }; + + let _ = data.update(Message::Connect); + + match data.slots_error.as_deref() { + Some(message) if message.contains("too large") => Ok(()), + other => bail!("expected too-large error, got {other:?}"), + } + } + + #[test] + fn connecting_with_a_malformed_slot_count_falls_back_to_generic_invalid_error() -> Result<()> { + let mut data = JoinBalancerFormData { + balancer_address: "127.0.0.1:8060".to_owned(), + slots_count: "abc".to_owned(), + ..JoinBalancerFormData::default() + }; + + let _ = data.update(Message::Connect); + + match data.slots_error.as_deref() { + Some(message) if message.contains("Invalid number of slots") => Ok(()), + other => bail!("expected generic invalid-slots error, got {other:?}"), + } + } + + #[test] + fn connecting_with_valid_input_and_no_agent_name_yields_connect_agent_with_name_none() + -> Result<()> { + let mut data = JoinBalancerFormData { + balancer_address: "127.0.0.1:8060".to_owned(), + slots_count: "4".to_owned(), + ..JoinBalancerFormData::default() + }; + + match data.update(Message::Connect) { + Action::ConnectAgent { + agent_name, + management_address, + slots, + } => { + if agent_name.is_some() { + bail!("expected agent_name to be None when field is empty"); + } + if management_address != "127.0.0.1:8060" { + bail!("expected management_address to be forwarded verbatim"); + } + if slots != 4 { + bail!("expected slots=4 to be forwarded"); + } + Ok(()) + } + _ => bail!("expected Action::ConnectAgent"), + } + } + + #[test] + fn connecting_with_valid_input_and_a_filled_agent_name_yields_connect_agent_with_some_name() + -> Result<()> { + let mut data = JoinBalancerFormData { + balancer_address: "127.0.0.1:8060".to_owned(), + agent_name: "primary-agent".to_owned(), + slots_count: "2".to_owned(), + ..JoinBalancerFormData::default() + }; + + match data.update(Message::Connect) { + Action::ConnectAgent { agent_name, .. } => match agent_name.as_deref() { + Some("primary-agent") => Ok(()), + other => bail!("expected agent_name=Some(\"primary-agent\"), got {other:?}"), + }, + _ => bail!("expected Action::ConnectAgent"), + } + } +} diff --git a/paddler_gui/src/lib.rs b/paddler_gui/src/lib.rs new file mode 100644 index 00000000..8b8d76fc --- /dev/null +++ b/paddler_gui/src/lib.rs @@ -0,0 +1,125 @@ +pub mod agent_running_data; +pub mod agent_running_handler; +pub mod app; +pub mod current_screen; +pub mod detect_network_interfaces; +pub mod drive_agent_stream; +pub mod drive_balancer_stream; +pub mod drive_shutdown_signal_stream; +pub mod home_data; +pub mod home_handler; +pub mod join_balancer_form_data; +pub mod join_balancer_form_handler; +pub mod message; +pub mod model_preset; +pub mod network_interface_address; +pub mod running_balancer_data; +pub mod running_balancer_handler; +pub mod running_balancer_snapshot; +#[expect(unsafe_code, reason = "statum macros generate link_section statics")] +pub mod screen; +pub mod start_balancer_form_data; +pub mod start_balancer_form_handler; +pub mod ui; + +use clap::Parser; +use clap::Subcommand; +#[cfg(feature = "web_admin_panel")] +use esbuild_metafile::instance::initialize_instance; +use iced::Size; +use iced::Theme; + +use crate::app::App; + +#[cfg(feature = "web_admin_panel")] +const ESBUILD_META_CONTENTS: &str = include_str!("../../esbuild-meta.json"); + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug, PartialEq, Eq)] +pub enum Commands { + /// Launch the desktop GUI application (default if no subcommand is given) + Launch, +} + +pub fn init_logging() { + let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .try_init(); +} + +pub fn run() -> iced::Result { + init_logging(); + + #[cfg(feature = "web_admin_panel")] + initialize_instance(ESBUILD_META_CONTENTS); + + log::info!("paddler_gui: ready"); + + let Cli { command } = Cli::parse(); + + match command { + Some(Commands::Launch) | None => iced::application(App::new, App::update, App::view) + .font(include_bytes!( + "../../resources/fonts/JetBrainsMono-Regular.ttf" + )) + .font(include_bytes!( + "../../resources/fonts/JetBrainsMono-Bold.ttf" + )) + .theme(Theme::Light) + .window_size(Size::new(800.0, 800.0)) + .subscription(App::subscription) + .run(), + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use clap::Parser as _; + + use super::Cli; + use super::Commands; + use super::init_logging; + + #[test] + fn init_logging_is_idempotent_across_repeated_invocations() -> Result<()> { + init_logging(); + init_logging(); + Ok(()) + } + + #[test] + fn cli_without_subcommand_parses_as_default_launch_intent() -> Result<()> { + let cli = Cli::try_parse_from(["paddler_gui"])?; + + if cli.command.is_some() { + bail!("expected no subcommand to leave Cli.command as None"); + } + + Ok(()) + } + + #[test] + fn cli_with_launch_subcommand_parses_into_launch_variant() -> Result<()> { + let cli = Cli::try_parse_from(["paddler_gui", "launch"])?; + + match cli.command { + Some(Commands::Launch) => Ok(()), + other => bail!("expected Some(Commands::Launch), got {other:?}"), + } + } + + #[test] + fn cli_rejects_unknown_subcommands() -> Result<()> { + match Cli::try_parse_from(["paddler_gui", "bogus"]) { + Err(_) => Ok(()), + Ok(cli) => bail!("expected error for unknown subcommand, got {cli:?}"), + } + } +} diff --git a/paddler_gui/src/main.rs b/paddler_gui/src/main.rs index 9c7a7527..23072480 100644 --- a/paddler_gui/src/main.rs +++ b/paddler_gui/src/main.rs @@ -1,72 +1,3 @@ -mod agent_running_data; -mod agent_running_handler; -mod app; -mod current_screen; -mod detect_network_interfaces; -mod home_data; -mod home_handler; -mod join_balancer_form_data; -mod join_balancer_form_handler; -mod message; -mod model_preset; -mod network_interface_address; -mod running_balancer_data; -mod running_balancer_handler; -mod running_balancer_snapshot; -#[expect(unsafe_code, reason = "statum macros generate link_section statics")] -mod screen; -mod start_balancer_form_data; -mod start_balancer_form_handler; -mod ui; - -use app::App; -use clap::Parser; -use clap::Subcommand; -#[cfg(feature = "web_admin_panel")] -use esbuild_metafile::instance::initialize_instance; -use iced::Size; -use iced::Theme; -use log::info; - -#[cfg(feature = "web_admin_panel")] -const ESBUILD_META_CONTENTS: &str = include_str!("../../esbuild-meta.json"); - -#[derive(Parser)] -#[command(version, about, long_about = None)] -struct Cli { - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum Commands { - /// Launch the desktop GUI application (default if no subcommand is given) - Launch, -} - -fn launch_gui() -> iced::Result { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - - #[cfg(feature = "web_admin_panel")] - initialize_instance(ESBUILD_META_CONTENTS); - - info!("paddler_gui: ready"); - - iced::application(App::new, App::update, App::view) - .font(include_bytes!( - "../../resources/fonts/JetBrainsMono-Regular.ttf" - )) - .font(include_bytes!( - "../../resources/fonts/JetBrainsMono-Bold.ttf" - )) - .theme(Theme::Light) - .window_size(Size::new(800.0, 800.0)) - .subscription(App::subscription) - .run() -} - fn main() -> iced::Result { - match Cli::parse().command { - Some(Commands::Launch) | None => launch_gui(), - } + paddler_gui::run() } diff --git a/paddler_gui/src/model_preset.rs b/paddler_gui/src/model_preset.rs index 923d7ef0..a9e6b8dc 100644 --- a/paddler_gui/src/model_preset.rs +++ b/paddler_gui/src/model_preset.rs @@ -66,3 +66,67 @@ impl fmt::Display for ModelPreset { write!(formatter, "{}", self.display_name) } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use paddler_types::agent_desired_model::AgentDesiredModel; + + use super::ModelPreset; + + #[test] + fn available_presets_returns_at_least_one_preset_per_supported_model() -> Result<()> { + let presets = ModelPreset::available_presets(); + + if presets.len() < 2 { + bail!("expected at least two presets, got {}", presets.len()); + } + + Ok(()) + } + + #[test] + fn preset_without_multimodal_projection_serializes_projection_as_none() -> Result<()> { + let preset = ModelPreset::available_presets() + .into_iter() + .find(|preset| preset.multimodal_projection.is_none()) + .ok_or_else(|| anyhow::anyhow!("expected a preset without multimodal_projection"))?; + + let desired = preset.to_balancer_desired_state(); + + match desired.multimodal_projection { + AgentDesiredModel::None => Ok(()), + other => bail!("expected AgentDesiredModel::None, got {other:?}"), + } + } + + #[test] + fn preset_with_multimodal_projection_serializes_projection_as_huggingface() -> Result<()> { + let preset = ModelPreset::available_presets() + .into_iter() + .find(|preset| preset.multimodal_projection.is_some()) + .ok_or_else(|| anyhow::anyhow!("expected a preset with multimodal_projection"))?; + + let desired = preset.to_balancer_desired_state(); + + match desired.multimodal_projection { + AgentDesiredModel::HuggingFace(_) => Ok(()), + other => bail!("expected AgentDesiredModel::HuggingFace, got {other:?}"), + } + } + + #[test] + fn display_impl_returns_the_preset_display_name() -> Result<()> { + let preset = ModelPreset::available_presets() + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("expected at least one preset"))?; + + if format!("{preset}") != preset.display_name { + bail!("Display impl did not match display_name"); + } + + Ok(()) + } +} diff --git a/paddler_gui/src/running_balancer_handler.rs b/paddler_gui/src/running_balancer_handler.rs index f4903460..8a38942f 100644 --- a/paddler_gui/src/running_balancer_handler.rs +++ b/paddler_gui/src/running_balancer_handler.rs @@ -34,3 +34,83 @@ impl RunningBalancerData { } } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use paddler_types::agent_desired_model::AgentDesiredModel; + use paddler_types::balancer_desired_state::BalancerDesiredState; + + use super::Action; + use super::Message; + use super::RunningBalancerData; + use super::RunningBalancerSnapshot; + + fn fresh_data() -> RunningBalancerData { + RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: None, + } + } + + #[test] + fn snapshot_updated_replaces_snapshot_and_returns_none() -> Result<()> { + let mut data = fresh_data(); + let new_snapshot = RunningBalancerSnapshot { + balancer_desired_state: BalancerDesiredState { + model: AgentDesiredModel::LocalToAgent("/some/model.gguf".to_owned()), + ..BalancerDesiredState::default() + }, + ..RunningBalancerSnapshot::default() + }; + + match data.update(Message::SnapshotUpdated(Box::new(new_snapshot))) { + Action::None => {} + _ => bail!("expected Action::None for SnapshotUpdated"), + } + + match &data.snapshot.balancer_desired_state.model { + AgentDesiredModel::LocalToAgent(path) if path == "/some/model.gguf" => Ok(()), + other => bail!("expected snapshot's model to be replaced, got {other:?}"), + } + } + + #[test] + fn stop_message_sets_stopping_flag_and_returns_stop_action() -> Result<()> { + let mut data = fresh_data(); + + match data.update(Message::Stop) { + Action::Stop => {} + _ => bail!("expected Action::Stop"), + } + + if !data.stopping { + bail!("expected stopping flag to flip to true after Stop"); + } + + Ok(()) + } + + #[test] + fn copy_to_clipboard_message_forwards_content_as_action() -> Result<()> { + let mut data = fresh_data(); + + match data.update(Message::CopyToClipboard("address-value".to_owned())) { + Action::CopyToClipboard(content) if content == "address-value" => Ok(()), + _ => bail!("expected Action::CopyToClipboard with the forwarded content"), + } + } + + #[test] + fn open_url_message_forwards_url_as_action() -> Result<()> { + let mut data = fresh_data(); + + match data.update(Message::OpenUrl("http://example.test".to_owned())) { + Action::OpenUrl(url) if url == "http://example.test" => Ok(()), + _ => bail!("expected Action::OpenUrl with the forwarded url"), + } + } +} diff --git a/paddler_gui/src/screen.rs b/paddler_gui/src/screen.rs index a89daf7d..a820abc0 100644 --- a/paddler_gui/src/screen.rs +++ b/paddler_gui/src/screen.rs @@ -132,3 +132,258 @@ impl Screen { self.transition_with(HomeData { error: Some(error) }) } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + + use super::AgentRunning; + use super::HomeData; + use super::JoinBalancerForm; + use super::JoinBalancerFormData; + use super::RunningBalancer; + use super::RunningBalancerData; + use super::RunningBalancerSnapshot; + use super::Screen; + use super::StartBalancerForm; + use super::StartBalancerFormData; + + fn home() -> Screen { + Screen::::builder() + .state_data(HomeData { error: None }) + .build() + } + + fn join_form(prefilled: JoinBalancerFormData) -> Screen { + Screen::::builder() + .state_data(prefilled) + .build() + } + + fn start_form(prefilled: StartBalancerFormData) -> Screen { + Screen::::builder() + .state_data(prefilled) + .build() + } + + fn agent_running(prefilled: super::AgentRunningData) -> Screen { + Screen::::builder() + .state_data(prefilled) + .build() + } + + fn running(prefilled: RunningBalancerData) -> Screen { + Screen::::builder() + .state_data(prefilled) + .build() + } + + fn fresh_start_form_data() -> StartBalancerFormData { + StartBalancerFormData { + add_model_later: false, + balancer_address: "127.0.0.1:8060".to_owned(), + balancer_address_error: None, + inference_address: "127.0.0.1:8061".to_owned(), + inference_address_error: None, + model_error: None, + selected_model: None, + starting: true, + web_admin_panel_address: String::new(), + web_admin_panel_address_error: None, + web_admin_panel_address_placeholder: String::new(), + } + } + + fn fresh_running_data() -> RunningBalancerData { + RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: None, + } + } + + fn fresh_agent_running_data() -> super::AgentRunningData { + use std::collections::BTreeSet; + + use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + + super::AgentRunningData { + balancer_address: "127.0.0.1:8060".to_owned(), + connected: false, + snapshot: AgentControllerSnapshot { + desired_slots_total: 0, + download_current: 0, + download_filename: None, + download_total: 0, + id: String::new(), + issues: BTreeSet::new(), + model_path: None, + name: None, + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + } + } + + #[test] + fn home_to_join_balancer_form_starts_with_empty_form_state() -> Result<()> { + let _moved = home().join_balancer(); + Ok(()) + } + + #[test] + fn home_to_start_balancer_form_seeds_addresses_from_detected_interfaces() -> Result<()> { + let next = home().start_balancer(); + + if next.state_data.balancer_address.is_empty() { + bail!("expected start form to be seeded with a balancer_address"); + } + + if !next.state_data.balancer_address.ends_with(":8060") { + bail!( + "expected default balancer port suffix :8060, got {}", + next.state_data.balancer_address + ); + } + + Ok(()) + } + + #[test] + fn join_balancer_form_cancel_returns_home_without_error() -> Result<()> { + let next = join_form(JoinBalancerFormData::default()).cancel(); + + if next.state_data.error.is_some() { + bail!("cancel should not surface an error on home"); + } + Ok(()) + } + + #[test] + fn join_balancer_form_connect_with_empty_agent_name_sets_name_none_on_agent_screen() + -> Result<()> { + let form = join_form(JoinBalancerFormData { + balancer_address: "127.0.0.1:8060".to_owned(), + ..JoinBalancerFormData::default() + }); + + let next = form.connect(); + + if next.state_data.snapshot.name.is_some() { + bail!("expected agent name None when form field empty"); + } + Ok(()) + } + + #[test] + fn join_balancer_form_connect_with_filled_agent_name_sets_some_name_on_agent_screen() + -> Result<()> { + let form = join_form(JoinBalancerFormData { + agent_name: "primary".to_owned(), + balancer_address: "127.0.0.1:8060".to_owned(), + ..JoinBalancerFormData::default() + }); + + let next = form.connect(); + + if next.state_data.snapshot.name.as_deref() != Some("primary") { + bail!("expected agent name Some(\"primary\")"); + } + Ok(()) + } + + #[test] + fn agent_running_disconnect_returns_home_without_error() -> Result<()> { + let next = agent_running(fresh_agent_running_data()).disconnect(); + if next.state_data.error.is_some() { + bail!("disconnect should not surface an error on home"); + } + Ok(()) + } + + #[test] + fn agent_running_agent_failed_returns_home_with_error_message() -> Result<()> { + let next = agent_running(fresh_agent_running_data()).agent_failed("oops".to_owned()); + + match next.state_data.error.as_deref() { + Some("oops") => Ok(()), + other => bail!("expected error Some(\"oops\"), got {other:?}"), + } + } + + #[test] + fn start_balancer_form_cancel_returns_home_without_error() -> Result<()> { + let next = start_form(fresh_start_form_data()).cancel(); + if next.state_data.error.is_some() { + bail!("cancel should not surface an error on home"); + } + Ok(()) + } + + #[test] + fn start_balancer_form_balancer_started_with_web_admin_address_carries_it_forward() + -> Result<()> { + let form = start_form(StartBalancerFormData { + web_admin_panel_address: "127.0.0.1:8062".to_owned(), + ..fresh_start_form_data() + }); + + let next = form.balancer_started(); + + match next.state_data.web_admin_panel_address.as_deref() { + Some("127.0.0.1:8062") => Ok(()), + other => bail!("expected web_admin_panel_address forwarded, got {other:?}"), + } + } + + #[test] + fn start_balancer_form_balancer_started_with_empty_web_admin_address_resolves_to_none() + -> Result<()> { + let form = start_form(StartBalancerFormData { + web_admin_panel_address: String::new(), + ..fresh_start_form_data() + }); + + let next = form.balancer_started(); + + if next.state_data.web_admin_panel_address.is_some() { + bail!("expected empty web_admin_panel_address to become None"); + } + Ok(()) + } + + #[test] + fn start_balancer_form_balancer_failed_returns_home_with_error_message() -> Result<()> { + let next = start_form(fresh_start_form_data()).balancer_failed("nope".to_owned()); + + match next.state_data.error.as_deref() { + Some("nope") => Ok(()), + other => bail!("expected error Some(\"nope\"), got {other:?}"), + } + } + + #[test] + fn running_balancer_balancer_stopped_returns_home_without_error() -> Result<()> { + let next = running(fresh_running_data()).balancer_stopped(); + if next.state_data.error.is_some() { + bail!("balancer_stopped should not surface an error on home"); + } + Ok(()) + } + + #[test] + fn running_balancer_balancer_failed_returns_home_with_error_message() -> Result<()> { + let next = running(fresh_running_data()).balancer_failed("kaboom".to_owned()); + + match next.state_data.error.as_deref() { + Some("kaboom") => Ok(()), + other => bail!("expected error Some(\"kaboom\"), got {other:?}"), + } + } + + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; +} diff --git a/paddler_gui/src/start_balancer_form_handler.rs b/paddler_gui/src/start_balancer_form_handler.rs index f3254518..2b6d9171 100644 --- a/paddler_gui/src/start_balancer_form_handler.rs +++ b/paddler_gui/src/start_balancer_form_handler.rs @@ -256,4 +256,254 @@ mod tests { Err(error) => bail!("empty optional input should not error: {error}"), } } + + use paddler_types::agent_desired_model::AgentDesiredModel; + + use super::Action; + use super::Message; + use super::StartBalancerFormData; + use crate::model_preset::ModelPreset; + + fn empty_form() -> StartBalancerFormData { + StartBalancerFormData { + add_model_later: false, + balancer_address: String::new(), + balancer_address_error: None, + inference_address: String::new(), + inference_address_error: None, + model_error: None, + selected_model: None, + starting: false, + web_admin_panel_address: String::new(), + web_admin_panel_address_error: None, + web_admin_panel_address_placeholder: String::new(), + } + } + + fn first_preset() -> Result { + ModelPreset::available_presets() + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("at least one preset must exist")) + } + + fn loopback_socket_with_free_port() -> Result { + let listener = TcpListener::bind(LOOPBACK_ANY_PORT)?; + let addr = listener.local_addr()?; + drop(listener); + Ok(addr) + } + + #[test] + fn selecting_a_model_clears_a_previously_set_model_error() -> Result<()> { + let mut data = empty_form(); + data.model_error = Some("stale".to_owned()); + + let _ = data.update(Message::SelectModel(first_preset()?)); + + if data.model_error.is_some() { + bail!("expected model_error to be cleared after SelectModel"); + } + + Ok(()) + } + + #[test] + fn setting_balancer_address_clears_a_previously_set_balancer_address_error() -> Result<()> { + let mut data = empty_form(); + data.balancer_address_error = Some("stale".to_owned()); + + let _ = data.update(Message::SetBalancerAddress("127.0.0.1:8060".to_owned())); + + if data.balancer_address_error.is_some() { + bail!("expected balancer_address_error to be cleared"); + } + if data.balancer_address != "127.0.0.1:8060" { + bail!("expected balancer_address to be updated"); + } + Ok(()) + } + + #[test] + fn setting_inference_address_clears_a_previously_set_inference_address_error() -> Result<()> { + let mut data = empty_form(); + data.inference_address_error = Some("stale".to_owned()); + + let _ = data.update(Message::SetInferenceAddress("127.0.0.1:8061".to_owned())); + + if data.inference_address_error.is_some() { + bail!("expected inference_address_error to be cleared"); + } + Ok(()) + } + + #[test] + fn setting_web_admin_panel_address_clears_a_previously_set_web_admin_panel_address_error() + -> Result<()> { + let mut data = empty_form(); + data.web_admin_panel_address_error = Some("stale".to_owned()); + + let _ = data.update(Message::SetWebAdminPanelAddress("127.0.0.1:8062".to_owned())); + + if data.web_admin_panel_address_error.is_some() { + bail!("expected web_admin_panel_address_error to be cleared"); + } + Ok(()) + } + + #[test] + fn toggling_add_model_later_on_clears_a_previously_set_model_error() -> Result<()> { + let mut data = empty_form(); + data.model_error = Some("stale".to_owned()); + + let _ = data.update(Message::ToggleAddModelLater(true)); + + if !data.add_model_later { + bail!("expected add_model_later to flip to true"); + } + if data.model_error.is_some() { + bail!("expected model_error to be cleared when toggling on"); + } + Ok(()) + } + + #[test] + fn toggling_add_model_later_off_leaves_a_previously_set_model_error_in_place() -> Result<()> { + let mut data = empty_form(); + data.add_model_later = true; + data.model_error = Some("preserved".to_owned()); + + let _ = data.update(Message::ToggleAddModelLater(false)); + + if data.add_model_later { + bail!("expected add_model_later to flip to false"); + } + if data.model_error.as_deref() != Some("preserved") { + bail!("expected model_error to be preserved when toggling off"); + } + Ok(()) + } + + #[test] + fn cancel_message_returns_cancel_action() -> Result<()> { + let mut data = empty_form(); + + match data.update(Message::Cancel) { + Action::Cancel => Ok(()), + _ => bail!("expected Action::Cancel"), + } + } + + #[test] + fn confirming_without_a_selected_model_records_a_model_required_error() -> Result<()> { + let mut data = empty_form(); + let management_addr = loopback_socket_with_free_port()?; + let inference_addr = loopback_socket_with_free_port()?; + data.balancer_address = management_addr.to_string(); + data.inference_address = inference_addr.to_string(); + + match data.update(Message::Confirm) { + Action::None => {} + _ => bail!("expected Action::None when validation fails"), + } + + if data.model_error.is_none() { + bail!("expected model_error to be set when no model is selected"); + } + + Ok(()) + } + + #[test] + fn confirming_with_an_in_use_balancer_port_records_an_address_error() -> Result<()> { + let bound_listener = TcpListener::bind(LOOPBACK_ANY_PORT)?; + let bound_address = bound_listener.local_addr()?; + + let mut data = empty_form(); + data.balancer_address = bound_address.to_string(); + data.inference_address = loopback_socket_with_free_port()?.to_string(); + data.selected_model = Some(first_preset()?); + + let _ = data.update(Message::Confirm); + + match data.balancer_address_error.as_deref() { + Some(message) if message.contains("already in use") => Ok(()), + other => bail!("expected in-use error, got {other:?}"), + } + } + + #[test] + fn confirming_with_an_unparseable_inference_address_records_an_inference_error() -> Result<()> { + let mut data = empty_form(); + data.balancer_address = loopback_socket_with_free_port()?.to_string(); + data.inference_address = "not-a-socket-addr".to_owned(); + data.selected_model = Some(first_preset()?); + + let _ = data.update(Message::Confirm); + + if data.inference_address_error.is_none() { + bail!("expected inference_address_error to be set for unparseable input"); + } + + Ok(()) + } + + #[test] + fn confirming_with_an_unparseable_web_admin_panel_address_records_a_web_admin_error() + -> Result<()> { + let mut data = empty_form(); + data.balancer_address = loopback_socket_with_free_port()?.to_string(); + data.inference_address = loopback_socket_with_free_port()?.to_string(); + data.web_admin_panel_address = "not-a-socket-addr".to_owned(); + data.selected_model = Some(first_preset()?); + + let _ = data.update(Message::Confirm); + + if data.web_admin_panel_address_error.is_none() { + bail!("expected web_admin_panel_address_error to be set for unparseable input"); + } + + Ok(()) + } + + #[test] + fn confirming_with_valid_input_and_selected_model_returns_start_balancer_action() -> Result<()> + { + let mut data = empty_form(); + data.balancer_address = loopback_socket_with_free_port()?.to_string(); + data.inference_address = loopback_socket_with_free_port()?.to_string(); + data.selected_model = Some(first_preset()?); + + match data.update(Message::Confirm) { + Action::StartBalancer { desired_state, .. } => { + if !data.starting { + bail!("expected starting flag to be set after StartBalancer action"); + } + match desired_state.model { + AgentDesiredModel::HuggingFace(_) => Ok(()), + other => bail!("expected HuggingFace model from preset, got {other:?}"), + } + } + _ => bail!("expected Action::StartBalancer for valid input with preset"), + } + } + + #[test] + fn confirming_with_add_model_later_returns_start_balancer_with_default_desired_state() + -> Result<()> { + let mut data = empty_form(); + data.balancer_address = loopback_socket_with_free_port()?.to_string(); + data.inference_address = loopback_socket_with_free_port()?.to_string(); + data.add_model_later = true; + + match data.update(Message::Confirm) { + Action::StartBalancer { desired_state, .. } => { + match desired_state.model { + AgentDesiredModel::None => Ok(()), + other => bail!("expected default (None) model, got {other:?}"), + } + } + _ => bail!("expected Action::StartBalancer for valid input with add_model_later"), + } + } } diff --git a/paddler_gui/src/ui/agent_status_label.rs b/paddler_gui/src/ui/agent_status_label.rs new file mode 100644 index 00000000..00bdb5da --- /dev/null +++ b/paddler_gui/src/ui/agent_status_label.rs @@ -0,0 +1,160 @@ +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + +pub fn agent_status_label(snapshot: &AgentControllerSnapshot) -> String { + let is_downloading = + snapshot.download_total > 0 && snapshot.download_current < snapshot.download_total; + + if is_downloading { + #[expect( + clippy::cast_precision_loss, + reason = "download sizes fit in f32 mantissa" + )] + let percentage = + (snapshot.download_current as f32 / snapshot.download_total as f32) * 100.0; + + return format!("Downloading ({percentage:.0}%)"); + } + + if snapshot.model_path.is_none() { + return "Waiting for model...".to_owned(); + } + + match snapshot.state_application_status { + AgentStateApplicationStatus::Applied => "OK".to_owned(), + AgentStateApplicationStatus::Fresh => "Pending".to_owned(), + AgentStateApplicationStatus::AttemptedAndRetrying => "Retrying".to_owned(), + AgentStateApplicationStatus::Stuck => "Retrying, but seems stuck?".to_owned(), + AgentStateApplicationStatus::AttemptedAndNotAppliable => "Needs your help".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use anyhow::Result; + use anyhow::bail; + use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + + use super::agent_status_label; + + fn snapshot_with( + download_current: usize, + download_total: usize, + model_path: Option<&str>, + status: AgentStateApplicationStatus, + ) -> AgentControllerSnapshot { + AgentControllerSnapshot { + desired_slots_total: 0, + download_current, + download_filename: None, + download_total, + id: "snapshot-id".to_owned(), + issues: BTreeSet::new(), + model_path: model_path.map(str::to_owned), + name: None, + slots_processing: 0, + slots_total: 0, + state_application_status: status, + uses_chat_template_override: false, + } + } + + #[test] + fn label_reports_download_progress_percentage_when_a_download_is_in_progress() -> Result<()> { + let snapshot = snapshot_with(25, 100, None, AgentStateApplicationStatus::Fresh); + + if agent_status_label(&snapshot) != "Downloading (25%)" { + bail!("expected Downloading (25%)"); + } + Ok(()) + } + + #[test] + fn label_says_waiting_for_model_when_no_model_is_loaded_and_no_download_is_active() + -> Result<()> { + let snapshot = snapshot_with(0, 0, None, AgentStateApplicationStatus::Fresh); + + if agent_status_label(&snapshot) != "Waiting for model..." { + bail!("expected the waiting-for-model copy"); + } + Ok(()) + } + + #[test] + fn label_says_ok_when_state_is_applied() -> Result<()> { + let snapshot = snapshot_with( + 0, + 0, + Some("/models/model.gguf"), + AgentStateApplicationStatus::Applied, + ); + + if agent_status_label(&snapshot) != "OK" { + bail!("expected OK for Applied"); + } + Ok(()) + } + + #[test] + fn label_says_pending_when_state_is_fresh() -> Result<()> { + let snapshot = snapshot_with( + 0, + 0, + Some("/models/model.gguf"), + AgentStateApplicationStatus::Fresh, + ); + + if agent_status_label(&snapshot) != "Pending" { + bail!("expected Pending for Fresh"); + } + Ok(()) + } + + #[test] + fn label_says_retrying_when_state_is_attempted_and_retrying() -> Result<()> { + let snapshot = snapshot_with( + 0, + 0, + Some("/models/model.gguf"), + AgentStateApplicationStatus::AttemptedAndRetrying, + ); + + if agent_status_label(&snapshot) != "Retrying" { + bail!("expected Retrying"); + } + Ok(()) + } + + #[test] + fn label_warns_about_stuck_when_state_is_stuck() -> Result<()> { + let snapshot = snapshot_with( + 0, + 0, + Some("/models/model.gguf"), + AgentStateApplicationStatus::Stuck, + ); + + if agent_status_label(&snapshot) != "Retrying, but seems stuck?" { + bail!("expected stuck copy"); + } + Ok(()) + } + + #[test] + fn label_asks_for_help_when_state_is_attempted_and_not_appliable() -> Result<()> { + let snapshot = snapshot_with( + 0, + 0, + Some("/models/model.gguf"), + AgentStateApplicationStatus::AttemptedAndNotAppliable, + ); + + if agent_status_label(&snapshot) != "Needs your help" { + bail!("expected needs-help copy"); + } + Ok(()) + } +} diff --git a/paddler_gui/src/ui/display_last_path_part.rs b/paddler_gui/src/ui/display_last_path_part.rs new file mode 100644 index 00000000..e9dd471c --- /dev/null +++ b/paddler_gui/src/ui/display_last_path_part.rs @@ -0,0 +1,33 @@ +use std::path::Path; + +pub fn display_last_path_part(path: &str) -> String { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path) + .to_owned() +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + + use super::display_last_path_part; + + #[test] + fn returns_only_the_file_name_when_path_contains_separators() -> Result<()> { + if display_last_path_part("/var/models/model.gguf") != "model.gguf" { + bail!("expected the file name portion"); + } + Ok(()) + } + + #[test] + fn returns_the_input_unchanged_when_there_is_no_separator() -> Result<()> { + if display_last_path_part("just-a-name") != "just-a-name" { + bail!("expected the original input when no separator is present"); + } + Ok(()) + } +} diff --git a/paddler_gui/src/ui/format_desired_model.rs b/paddler_gui/src/ui/format_desired_model.rs new file mode 100644 index 00000000..9cd40f11 --- /dev/null +++ b/paddler_gui/src/ui/format_desired_model.rs @@ -0,0 +1,59 @@ +use paddler_types::agent_desired_model::AgentDesiredModel; + +pub fn format_desired_model(desired_model: &AgentDesiredModel) -> String { + match desired_model { + AgentDesiredModel::HuggingFace(reference) => { + format!( + "HuggingFace {}/{} ({})", + reference.repo_id, reference.filename, reference.revision, + ) + } + AgentDesiredModel::LocalToAgent(path) => format!("Local: {path}"), + AgentDesiredModel::None => "(not set)".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use paddler_types::agent_desired_model::AgentDesiredModel; + use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + + use super::format_desired_model; + + #[test] + fn formats_huggingface_reference_with_repo_filename_and_revision() -> Result<()> { + let model = AgentDesiredModel::HuggingFace(HuggingFaceModelReference { + repo_id: "org/repo".to_owned(), + filename: "model.gguf".to_owned(), + revision: "main".to_owned(), + }); + + if format_desired_model(&model) != "HuggingFace org/repo/model.gguf (main)" { + bail!("HuggingFace formatting does not match the expected layout"); + } + + Ok(()) + } + + #[test] + fn formats_local_to_agent_with_path_prefix() -> Result<()> { + let model = AgentDesiredModel::LocalToAgent("/var/models/model.gguf".to_owned()); + + if format_desired_model(&model) != "Local: /var/models/model.gguf" { + bail!("LocalToAgent formatting does not match the expected layout"); + } + + Ok(()) + } + + #[test] + fn formats_none_as_not_set_placeholder() -> Result<()> { + if format_desired_model(&AgentDesiredModel::None) != "(not set)" { + bail!("None formatting does not match the expected placeholder"); + } + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/mod.rs b/paddler_gui/src/ui/mod.rs index 5a95352c..ddf9f93f 100644 --- a/paddler_gui/src/ui/mod.rs +++ b/paddler_gui/src/ui/mod.rs @@ -1,21 +1,24 @@ +pub mod agent_status_label; +pub mod display_last_path_part; +pub mod format_desired_model; pub mod variables; +pub mod view_agent_card; pub mod view_agent_running; +pub mod view_form_field; pub mod view_home; pub mod view_join_balancer_form; pub mod view_running_balancer; pub mod view_start_balancer_form; -mod font; -mod style_agent_container; -mod style_button_disconnect; -mod style_button_primary; -mod style_card_container; -mod style_download_progress_bar; -mod style_field_checkbox; -mod style_field_container; -mod style_field_pick_list; -mod style_field_pick_list_menu; -mod style_field_text_input; -mod style_status_indicator; -mod view_agent_card; -mod view_form_field; +pub mod font; +pub mod style_agent_container; +pub mod style_button_disconnect; +pub mod style_button_primary; +pub mod style_card_container; +pub mod style_download_progress_bar; +pub mod style_field_checkbox; +pub mod style_field_container; +pub mod style_field_pick_list; +pub mod style_field_pick_list_menu; +pub mod style_field_text_input; +pub mod style_status_indicator; diff --git a/paddler_gui/src/ui/style_agent_container.rs b/paddler_gui/src/ui/style_agent_container.rs index 72609cc4..4cc1e4a0 100644 --- a/paddler_gui/src/ui/style_agent_container.rs +++ b/paddler_gui/src/ui/style_agent_container.rs @@ -19,3 +19,31 @@ pub fn style_agent_container(theme: &Theme) -> container::Style { ..base } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use iced::Background; + use iced::Theme; + + use super::COLOR_AGENT_BACKGROUND; + use super::COLOR_BORDER; + use super::style_agent_container; + + #[test] + fn agent_container_paints_orange_background_with_black_border() -> Result<()> { + let style = style_agent_container(&Theme::Light); + + match style.background { + Some(Background::Color(color)) if color == COLOR_AGENT_BACKGROUND => {} + other => bail!("expected COLOR_AGENT_BACKGROUND, got {other:?}"), + } + + if style.border.color != COLOR_BORDER { + bail!("expected COLOR_BORDER border, got {:?}", style.border.color); + } + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_button_disconnect.rs b/paddler_gui/src/ui/style_button_disconnect.rs index 7973c5ca..f422851f 100644 --- a/paddler_gui/src/ui/style_button_disconnect.rs +++ b/paddler_gui/src/ui/style_button_disconnect.rs @@ -20,3 +20,25 @@ pub fn style_button_disconnect(theme: &Theme, status: button::Status) -> button: ..base } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use iced::Background; + use iced::Theme; + use iced::widget::button; + + use super::COLOR_ERROR; + use super::style_button_disconnect; + + #[test] + fn disconnect_button_paints_error_red_background() -> Result<()> { + let style = style_button_disconnect(&Theme::Light, button::Status::Active); + + match style.background { + Some(Background::Color(color)) if color == COLOR_ERROR => Ok(()), + other => bail!("expected COLOR_ERROR background, got {other:?}"), + } + } +} diff --git a/paddler_gui/src/ui/style_button_primary.rs b/paddler_gui/src/ui/style_button_primary.rs index 9ed1bcad..393ea642 100644 --- a/paddler_gui/src/ui/style_button_primary.rs +++ b/paddler_gui/src/ui/style_button_primary.rs @@ -20,3 +20,32 @@ pub fn style_button_primary(theme: &Theme, status: button::Status) -> button::St ..base } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use iced::Background; + use iced::Theme; + use iced::widget::button; + + use super::COLOR_BODY_BACKGROUND; + use super::COLOR_BORDER; + use super::style_button_primary; + + #[test] + fn primary_button_paints_border_color_background_with_body_background_text() -> Result<()> { + let style = style_button_primary(&Theme::Light, button::Status::Active); + + match style.background { + Some(Background::Color(color)) if color == COLOR_BORDER => {} + other => bail!("expected COLOR_BORDER background, got {other:?}"), + } + + if style.text_color != COLOR_BODY_BACKGROUND { + bail!("expected text_color == COLOR_BODY_BACKGROUND"); + } + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_card_container.rs b/paddler_gui/src/ui/style_card_container.rs index 66a5a254..6d7c52e1 100644 --- a/paddler_gui/src/ui/style_card_container.rs +++ b/paddler_gui/src/ui/style_card_container.rs @@ -19,3 +19,23 @@ pub fn style_card_container(theme: &Theme) -> container::Style { ..base } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use iced::Theme; + + use super::COLOR_BORDER; + use super::style_card_container; + + #[test] + fn card_container_has_border_in_outline_color() -> Result<()> { + let style = style_card_container(&Theme::Light); + + if style.border.color != COLOR_BORDER { + bail!("expected COLOR_BORDER border, got {:?}", style.border.color); + } + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_download_progress_bar.rs b/paddler_gui/src/ui/style_download_progress_bar.rs index 915291d7..f73c92f9 100644 --- a/paddler_gui/src/ui/style_download_progress_bar.rs +++ b/paddler_gui/src/ui/style_download_progress_bar.rs @@ -17,3 +17,24 @@ pub fn style_download_progress_bar(_theme: &Theme) -> progress_bar::Style { }, } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use iced::Background; + use iced::Theme; + + use super::COLOR_BORDER; + use super::style_download_progress_bar; + + #[test] + fn download_progress_bar_fills_with_border_color() -> Result<()> { + let style = style_download_progress_bar(&Theme::Light); + + match style.bar { + Background::Color(color) if color == COLOR_BORDER => Ok(()), + other => bail!("expected COLOR_BORDER bar, got {other:?}"), + } + } +} diff --git a/paddler_gui/src/ui/style_field_checkbox.rs b/paddler_gui/src/ui/style_field_checkbox.rs index df80122d..ca059f8b 100644 --- a/paddler_gui/src/ui/style_field_checkbox.rs +++ b/paddler_gui/src/ui/style_field_checkbox.rs @@ -32,3 +32,40 @@ pub fn style_field_checkbox(_theme: &Theme, status: checkbox::Status) -> checkbo text_color: Some(COLOR_BODY_FONT), } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use iced::Background; + use iced::Theme; + use iced::widget::checkbox; + + use super::COLOR_BODY_BACKGROUND; + use super::COLOR_BORDER; + use super::style_field_checkbox; + + #[test] + fn checkbox_in_checked_state_paints_background_with_border_color() -> Result<()> { + let style = + style_field_checkbox(&Theme::Light, checkbox::Status::Active { is_checked: true }); + + match style.background { + Background::Color(color) if color == COLOR_BORDER => Ok(()), + other => bail!("expected COLOR_BORDER background when checked, got {other:?}"), + } + } + + #[test] + fn checkbox_in_unchecked_state_paints_background_with_body_background_color() -> Result<()> { + let style = style_field_checkbox( + &Theme::Light, + checkbox::Status::Active { is_checked: false }, + ); + + match style.background { + Background::Color(color) if color == COLOR_BODY_BACKGROUND => Ok(()), + other => bail!("expected COLOR_BODY_BACKGROUND when unchecked, got {other:?}"), + } + } +} diff --git a/paddler_gui/src/ui/style_field_container.rs b/paddler_gui/src/ui/style_field_container.rs index df18be3d..a237b773 100644 --- a/paddler_gui/src/ui/style_field_container.rs +++ b/paddler_gui/src/ui/style_field_container.rs @@ -17,3 +17,28 @@ pub fn style_field_container(theme: &Theme) -> container::Style { ..base } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use iced::Theme; + + use super::COLOR_BORDER; + use super::style_field_container; + + #[test] + fn field_container_casts_a_solid_offset_shadow_in_border_color() -> Result<()> { + let style = style_field_container(&Theme::Light); + + if style.shadow.color != COLOR_BORDER { + bail!("expected shadow color == COLOR_BORDER"); + } + + if style.shadow.offset.x != 4.0 || style.shadow.offset.y != 4.0 { + bail!("expected shadow offset (4.0, 4.0)"); + } + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_field_pick_list.rs b/paddler_gui/src/ui/style_field_pick_list.rs index b9fe77b8..6cd9d7ec 100644 --- a/paddler_gui/src/ui/style_field_pick_list.rs +++ b/paddler_gui/src/ui/style_field_pick_list.rs @@ -22,3 +22,25 @@ pub fn style_field_pick_list(theme: &Theme, status: pick_list::Status) -> pick_l }, } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use iced::Theme; + use iced::widget::pick_list; + + use super::COLOR_BORDER; + use super::style_field_pick_list; + + #[test] + fn pick_list_displays_outlined_border_in_body_font_color() -> Result<()> { + let style = style_field_pick_list(&Theme::Light, pick_list::Status::Active); + + if style.border.color != COLOR_BORDER { + bail!("expected border in COLOR_BORDER"); + } + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_field_pick_list_menu.rs b/paddler_gui/src/ui/style_field_pick_list_menu.rs index bce4cb4a..e90855b5 100644 --- a/paddler_gui/src/ui/style_field_pick_list_menu.rs +++ b/paddler_gui/src/ui/style_field_pick_list_menu.rs @@ -24,3 +24,24 @@ pub fn style_field_pick_list_menu(theme: &Theme) -> menu::Style { ..base } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use iced::Theme; + + use super::COLOR_BORDER; + use super::style_field_pick_list_menu; + + #[test] + fn pick_list_open_menu_outlines_options_with_border_color() -> Result<()> { + let style = style_field_pick_list_menu(&Theme::Light); + + if style.border.color != COLOR_BORDER { + bail!("expected menu border in COLOR_BORDER"); + } + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_field_text_input.rs b/paddler_gui/src/ui/style_field_text_input.rs index ff0e4613..02fbba24 100644 --- a/paddler_gui/src/ui/style_field_text_input.rs +++ b/paddler_gui/src/ui/style_field_text_input.rs @@ -23,3 +23,29 @@ pub fn style_field_text_input(theme: &Theme, status: text_input::Status) -> text ..base } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use iced::Theme; + use iced::widget::text_input; + + use super::COLOR_BORDER; + use super::style_field_text_input; + + #[test] + fn text_input_outlines_with_border_color_at_two_pixels() -> Result<()> { + let style = style_field_text_input(&Theme::Light, text_input::Status::Active); + + if style.border.color != COLOR_BORDER { + bail!("expected border in COLOR_BORDER"); + } + + if (style.border.width - 2.0).abs() > f32::EPSILON { + bail!("expected border width of 2.0"); + } + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_status_indicator.rs b/paddler_gui/src/ui/style_status_indicator.rs index b80b49c7..d429c1bc 100644 --- a/paddler_gui/src/ui/style_status_indicator.rs +++ b/paddler_gui/src/ui/style_status_indicator.rs @@ -17,3 +17,30 @@ pub fn style_status_indicator(theme: &Theme) -> container::Style { ..base } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use iced::Background; + use iced::Color; + use iced::Theme; + + use super::style_status_indicator; + + #[test] + fn status_indicator_paints_a_pale_green_pill_against_a_muted_green_border() -> Result<()> { + let style = style_status_indicator(&Theme::Light); + + match style.background { + Some(Background::Color(color)) if color == Color::from_rgb8(0xEE, 0xFF, 0xEE) => {} + other => bail!("expected pale green background, got {other:?}"), + } + + if style.border.color != Color::from_rgb8(0xCC, 0xDD, 0xCC) { + bail!("expected muted green border"); + } + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/view_agent_card.rs b/paddler_gui/src/ui/view_agent_card.rs index c680f0ed..3605b98d 100644 --- a/paddler_gui/src/ui/view_agent_card.rs +++ b/paddler_gui/src/ui/view_agent_card.rs @@ -6,21 +6,15 @@ use iced::widget::progress_bar; use iced::widget::row; use iced::widget::text; use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; -use paddler_types::agent_state_application_status::AgentStateApplicationStatus; +use super::agent_status_label::agent_status_label; +use super::display_last_path_part::display_last_path_part; use super::font::BOLD; use super::font::REGULAR; use super::style_agent_container::style_agent_container; use super::style_download_progress_bar::style_download_progress_bar; use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; -fn display_last_path_part(path: &str) -> String { - std::path::Path::new(path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(path) - .to_owned() -} pub fn view_agent_card( snapshot: &AgentControllerSnapshot, @@ -61,26 +55,7 @@ pub fn view_agent_card( name_row = name_row.push(text(model_label).font(REGULAR)); } - let status_label = if is_downloading { - #[expect( - clippy::cast_precision_loss, - reason = "download sizes fit in f32 mantissa" - )] - let percentage = - (snapshot.download_current as f32 / snapshot.download_total as f32) * 100.0; - - format!("Downloading ({percentage:.0}%)") - } else if snapshot.model_path.is_none() { - "Waiting for model...".to_owned() - } else { - match &snapshot.state_application_status { - AgentStateApplicationStatus::Applied => "OK".to_owned(), - AgentStateApplicationStatus::Fresh => "Pending".to_owned(), - AgentStateApplicationStatus::AttemptedAndRetrying => "Retrying".to_owned(), - AgentStateApplicationStatus::Stuck => "Retrying, but seems stuck?".to_owned(), - AgentStateApplicationStatus::AttemptedAndNotAppliable => "Needs your help".to_owned(), - } - }; + let status_label = agent_status_label(snapshot); let mut status_row_left = column![].spacing(SPACING_HALF); diff --git a/paddler_gui/src/ui/view_running_balancer.rs b/paddler_gui/src/ui/view_running_balancer.rs index a8c4b6f3..855e2b78 100644 --- a/paddler_gui/src/ui/view_running_balancer.rs +++ b/paddler_gui/src/ui/view_running_balancer.rs @@ -8,10 +8,10 @@ use iced::widget::row; use iced::widget::svg; use iced::widget::svg::Handle as SvgHandle; use iced::widget::text; -use paddler_types::agent_desired_model::AgentDesiredModel; use super::font::BOLD; use super::font::REGULAR; +use super::format_desired_model::format_desired_model; use super::style_button_disconnect::style_button_disconnect; use super::style_card_container::style_card_container; use super::style_status_indicator::style_status_indicator; @@ -23,19 +23,6 @@ use super::view_agent_card::view_agent_card; use crate::running_balancer_data::RunningBalancerData; use crate::running_balancer_handler::Message; -fn format_desired_model(desired_model: &AgentDesiredModel) -> String { - match desired_model { - AgentDesiredModel::HuggingFace(reference) => { - format!( - "HuggingFace {}/{} ({})", - reference.repo_id, reference.filename, reference.revision, - ) - } - AgentDesiredModel::LocalToAgent(path) => format!("Local: {path}"), - AgentDesiredModel::None => "(not set)".to_owned(), - } -} - pub fn view_running_balancer(data: &RunningBalancerData) -> Element<'_, Message> { let copy_icon = svg(SvgHandle::from_memory( include_bytes!("../../../resources/icons/copy.svg").as_slice(), diff --git a/paddler_gui/src/ui/view_start_balancer_form.rs b/paddler_gui/src/ui/view_start_balancer_form.rs index d0b9f6c7..8d400a02 100644 --- a/paddler_gui/src/ui/view_start_balancer_form.rs +++ b/paddler_gui/src/ui/view_start_balancer_form.rs @@ -37,7 +37,7 @@ pub fn view_start_balancer_form(data: &StartBalancerFormData) -> Element<'_, Mes .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_primary) } else { - button(text("Start a cluster").font(BOLD)) + button(text("Start cluster").font(BOLD)) .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_primary) .on_press(Message::Confirm) diff --git a/paddler_gui_tests/Cargo.toml b/paddler_gui_tests/Cargo.toml new file mode 100644 index 00000000..dc6bcc18 --- /dev/null +++ b/paddler_gui_tests/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "paddler_gui_tests" +authors.workspace = true +description = "Integration tests for paddler_gui" +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[features] +default = [] +web_admin_panel = ["paddler_gui/web_admin_panel"] + +[dependencies] +anyhow = { workspace = true } +iced = { workspace = true } +iced_test = { workspace = true } +log = { workspace = true } +nix = { workspace = true } +paddler = { workspace = true } +paddler_bootstrap = { workspace = true } +paddler_gui = { workspace = true } +paddler_types = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } + +[dev-dependencies] +serial_test = { workspace = true } + +[lints] +workspace = true diff --git a/paddler_gui_tests/src/bind_addresses.rs b/paddler_gui_tests/src/bind_addresses.rs new file mode 100644 index 00000000..8e842f79 --- /dev/null +++ b/paddler_gui_tests/src/bind_addresses.rs @@ -0,0 +1,6 @@ +use std::net::SocketAddr; + +pub struct BindAddresses { + pub inference_addr: SocketAddr, + pub management_addr: SocketAddr, +} diff --git a/paddler_gui_tests/src/bind_ephemeral_socket.rs b/paddler_gui_tests/src/bind_ephemeral_socket.rs new file mode 100644 index 00000000..21d9854d --- /dev/null +++ b/paddler_gui_tests/src/bind_ephemeral_socket.rs @@ -0,0 +1,18 @@ +use std::net::SocketAddr; +use std::net::TcpListener; + +use anyhow::Context as _; +use anyhow::Result; + +pub fn bind_ephemeral_socket() -> Result { + let listener = + TcpListener::bind("127.0.0.1:0").context("failed to bind ephemeral loopback socket")?; + + let local_addr = listener + .local_addr() + .context("failed to read local address of bound listener")?; + + drop(listener); + + Ok(local_addr) +} diff --git a/paddler_gui_tests/src/lib.rs b/paddler_gui_tests/src/lib.rs new file mode 100644 index 00000000..66a455ab --- /dev/null +++ b/paddler_gui_tests/src/lib.rs @@ -0,0 +1,4 @@ +pub mod bind_addresses; +pub mod bind_ephemeral_socket; +pub mod make_agent_controller_snapshot; +pub mod make_balancer_runner_params; diff --git a/paddler_gui_tests/src/make_agent_controller_snapshot.rs b/paddler_gui_tests/src/make_agent_controller_snapshot.rs new file mode 100644 index 00000000..1456c202 --- /dev/null +++ b/paddler_gui_tests/src/make_agent_controller_snapshot.rs @@ -0,0 +1,51 @@ +use std::collections::BTreeSet; + +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + +pub struct AgentSnapshotFixture { + pub desired_slots_total: i32, + pub download_current: usize, + pub download_filename: Option, + pub download_total: usize, + pub id: String, + pub model_path: Option, + pub name: Option, + pub slots_processing: i32, + pub slots_total: i32, + pub state_application_status: AgentStateApplicationStatus, +} + +impl Default for AgentSnapshotFixture { + fn default() -> Self { + Self { + desired_slots_total: 4, + download_current: 0, + download_filename: None, + download_total: 0, + id: "fixture-agent-id".to_owned(), + model_path: None, + name: None, + slots_processing: 0, + slots_total: 4, + state_application_status: AgentStateApplicationStatus::Fresh, + } + } +} + +pub fn make_agent_controller_snapshot(fixture: AgentSnapshotFixture) -> AgentControllerSnapshot { + AgentControllerSnapshot { + desired_slots_total: fixture.desired_slots_total, + download_current: fixture.download_current, + download_filename: fixture.download_filename, + download_total: fixture.download_total, + id: fixture.id, + issues: BTreeSet::new(), + model_path: fixture.model_path, + name: fixture.name, + slots_processing: fixture.slots_processing, + slots_total: fixture.slots_total, + state_application_status: fixture.state_application_status, + uses_chat_template_override: false, + } +} diff --git a/paddler_gui_tests/src/make_balancer_runner_params.rs b/paddler_gui_tests/src/make_balancer_runner_params.rs new file mode 100644 index 00000000..cebdb462 --- /dev/null +++ b/paddler_gui_tests/src/make_balancer_runner_params.rs @@ -0,0 +1,41 @@ +use std::time::Duration; + +use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; +use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; +use paddler::balancer::state_database_type::StateDatabaseType; +use paddler_bootstrap::balancer_runner::BalancerRunnerParams; +use paddler_types::balancer_desired_state::BalancerDesiredState; +use tokio_util::sync::CancellationToken; + +use crate::bind_addresses::BindAddresses; + +pub fn make_balancer_runner_params( + addrs: BindAddresses, + cancellation_token: CancellationToken, +) -> BalancerRunnerParams { + BalancerRunnerParams { + buffered_request_timeout: Duration::from_secs(10), + inference_listener: None, + inference_service_configuration: InferenceServiceConfiguration { + addr: addrs.inference_addr, + cors_allowed_hosts: vec![], + inference_item_timeout: Duration::from_secs(30), + }, + management_listener: None, + management_service_configuration: ManagementServiceConfiguration { + addr: addrs.management_addr, + cors_allowed_hosts: vec![], + }, + max_buffered_requests: 30, + openai_listener: None, + openai_service_configuration: None, + cancellation_token, + state_database_type: StateDatabaseType::Memory(Box::new(BalancerDesiredState::default())), + statsd_prefix: "paddler_test_".to_owned(), + statsd_service_configuration: None, + #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener: None, + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration: None, + } +} diff --git a/paddler_gui_tests/tests/agent_card_shows_download_progress_while_the_model_is_downloading.rs b/paddler_gui_tests/tests/agent_card_shows_download_progress_while_the_model_is_downloading.rs new file mode 100644 index 00000000..729342eb --- /dev/null +++ b/paddler_gui_tests/tests/agent_card_shows_download_progress_while_the_model_is_downloading.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::message::Message; +use paddler_gui::ui::view_agent_card::view_agent_card; +use paddler_gui_tests::make_agent_controller_snapshot::AgentSnapshotFixture; +use paddler_gui_tests::make_agent_controller_snapshot::make_agent_controller_snapshot; + +#[test] +fn a_named_agent_in_the_middle_of_downloading_renders_a_progress_bar_and_percentage() -> Result<()> +{ + let snapshot = make_agent_controller_snapshot(AgentSnapshotFixture { + name: Some("downloading-agent".to_owned()), + download_current: 25, + download_total: 100, + ..AgentSnapshotFixture::default() + }); + + let mut simulator = simulator(view_agent_card::(&snapshot)); + + if simulator.find("downloading-agent").is_err() { + bail!("expected the agent name to render"); + } + if simulator.find("Status: Downloading (25%)").is_err() { + bail!("expected the download-progress status to render"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/agent_card_shows_loaded_model_and_open_issues.rs b/paddler_gui_tests/tests/agent_card_shows_loaded_model_and_open_issues.rs new file mode 100644 index 00000000..88c73341 --- /dev/null +++ b/paddler_gui_tests/tests/agent_card_shows_loaded_model_and_open_issues.rs @@ -0,0 +1,48 @@ +use std::collections::BTreeSet; + +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::message::Message; +use paddler_gui::ui::view_agent_card::view_agent_card; +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_issue::AgentIssue; +use paddler_types::agent_issue_params::ModelPath; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + +#[test] +fn an_unnamed_agent_with_a_loaded_model_and_open_issues_renders_each_section() -> Result<()> { + let mut issues = BTreeSet::new(); + issues.insert(AgentIssue::ModelFileDoesNotExist(ModelPath { + model_path: "/var/models/model.gguf".to_owned(), + })); + + let snapshot = AgentControllerSnapshot { + desired_slots_total: 4, + download_current: 0, + download_filename: None, + download_total: 0, + id: "unnamed-agent-id".to_owned(), + issues, + model_path: Some("/var/models/model.gguf".to_owned()), + name: None, + slots_processing: 1, + slots_total: 4, + state_application_status: AgentStateApplicationStatus::Applied, + uses_chat_template_override: false, + }; + + let mut simulator = simulator(view_agent_card::(&snapshot)); + + if simulator.find("model.gguf").is_err() { + bail!("expected the model file name to render"); + } + if simulator.find("Status: OK").is_err() { + bail!("expected the status label to render"); + } + if simulator.find("1 issues").is_err() { + bail!("expected the issues count to render"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/copying_the_balancer_address_to_the_clipboard.rs b/paddler_gui_tests/tests/copying_the_balancer_address_to_the_clipboard.rs new file mode 100644 index 00000000..183caaa7 --- /dev/null +++ b/paddler_gui_tests/tests/copying_the_balancer_address_to_the_clipboard.rs @@ -0,0 +1,26 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::running_balancer_data::RunningBalancerData; +use paddler_gui::running_balancer_handler::Message; +use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; +use paddler_gui::ui::view_running_balancer::view_running_balancer; + +#[test] +fn clicking_copy_address_sends_the_copy_to_clipboard_message_with_the_balancer_address() +-> Result<()> { + let data = RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: None, + }; + let mut simulator = simulator(view_running_balancer(&data)); + + simulator.click("Copy address")?; + + match simulator.into_messages().next() { + Some(Message::CopyToClipboard(addr)) if addr == "127.0.0.1:8060" => Ok(()), + other => bail!("expected CopyToClipboard(127.0.0.1:8060), got {other:?}"), + } +} diff --git a/paddler_gui_tests/tests/disconnecting_an_agent_from_the_balancer.rs b/paddler_gui_tests/tests/disconnecting_an_agent_from_the_balancer.rs new file mode 100644 index 00000000..04673d4b --- /dev/null +++ b/paddler_gui_tests/tests/disconnecting_an_agent_from_the_balancer.rs @@ -0,0 +1,44 @@ +use std::collections::BTreeSet; + +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::agent_running_data::AgentRunningData; +use paddler_gui::agent_running_handler::Message; +use paddler_gui::ui::view_agent_running::view_agent_running; +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + +fn fresh_agent_running_data() -> AgentRunningData { + AgentRunningData { + balancer_address: "127.0.0.1:8060".to_owned(), + connected: false, + snapshot: AgentControllerSnapshot { + desired_slots_total: 0, + download_current: 0, + download_filename: None, + download_total: 0, + id: String::new(), + issues: BTreeSet::new(), + model_path: None, + name: None, + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + } +} + +#[test] +fn clicking_disconnect_sends_the_disconnect_message() -> Result<()> { + let data = fresh_agent_running_data(); + let mut simulator = simulator(view_agent_running(&data)); + + simulator.click("Disconnect")?; + + match simulator.into_messages().next() { + Some(Message::Disconnect) => Ok(()), + other => bail!("expected Disconnect message, got {other:?}"), + } +} diff --git a/paddler_gui_tests/tests/form_fields_render_their_label_input_and_optional_error.rs b/paddler_gui_tests/tests/form_fields_render_their_label_input_and_optional_error.rs new file mode 100644 index 00000000..b61a8bd6 --- /dev/null +++ b/paddler_gui_tests/tests/form_fields_render_their_label_input_and_optional_error.rs @@ -0,0 +1,38 @@ +use anyhow::Result; +use anyhow::bail; +use iced::widget::text_input; +use iced_test::simulator; +use paddler_gui::message::Message; +use paddler_gui::ui::view_form_field::view_form_field; + +#[test] +fn a_form_field_without_an_error_renders_only_label_and_input() -> Result<()> { + let input = text_input("placeholder", "value").into(); + let mut simulator = simulator(view_form_field::("Cluster address", input, None)); + + if simulator.find("Cluster address").is_err() { + bail!("expected the form field label to render"); + } + + Ok(()) +} + +#[test] +fn a_form_field_with_an_error_renders_the_error_text_below_the_input() -> Result<()> { + let input = text_input("placeholder", "value").into(); + let error = "Address is required.".to_owned(); + let mut simulator = simulator(view_form_field::( + "Cluster address", + input, + Some(&error), + )); + + if simulator.find("Cluster address").is_err() { + bail!("expected the label to render"); + } + if simulator.find("Address is required.").is_err() { + bail!("expected the error text to render"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/home_screen_shows_an_error_after_a_failed_balancer_or_agent_run.rs b/paddler_gui_tests/tests/home_screen_shows_an_error_after_a_failed_balancer_or_agent_run.rs new file mode 100644 index 00000000..b35dc380 --- /dev/null +++ b/paddler_gui_tests/tests/home_screen_shows_an_error_after_a_failed_balancer_or_agent_run.rs @@ -0,0 +1,18 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::home_data::HomeData; +use paddler_gui::ui::view_home::view_home; + +#[test] +fn home_displays_the_error_message_that_was_carried_over_from_the_previous_screen() -> Result<()> { + let data = HomeData { + error: Some("Balancer crashed during startup".to_owned()), + }; + let mut simulator = simulator(view_home(&data)); + + match simulator.find("Balancer crashed during startup") { + Ok(_) => Ok(()), + Err(error) => bail!("expected to find the error text in the rendered tree: {error}"), + } +} diff --git a/paddler_gui_tests/tests/home_screen_starts_the_user_on_a_balancer_or_agent_flow.rs b/paddler_gui_tests/tests/home_screen_starts_the_user_on_a_balancer_or_agent_flow.rs new file mode 100644 index 00000000..c546aa32 --- /dev/null +++ b/paddler_gui_tests/tests/home_screen_starts_the_user_on_a_balancer_or_agent_flow.rs @@ -0,0 +1,32 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::home_data::HomeData; +use paddler_gui::home_handler::Message; +use paddler_gui::ui::view_home::view_home; + +#[test] +fn clicking_start_a_cluster_takes_the_user_to_the_start_balancer_form() -> Result<()> { + let data = HomeData { error: None }; + let mut simulator = simulator(view_home(&data)); + + simulator.click("Start a cluster")?; + + match simulator.into_messages().next() { + Some(Message::StartBalancer) => Ok(()), + other => bail!("expected StartBalancer message, got {other:?}"), + } +} + +#[test] +fn clicking_join_a_cluster_takes_the_user_to_the_join_balancer_form() -> Result<()> { + let data = HomeData { error: None }; + let mut simulator = simulator(view_home(&data)); + + simulator.click("Join a cluster")?; + + match simulator.into_messages().next() { + Some(Message::JoinBalancer) => Ok(()), + other => bail!("expected JoinBalancer message, got {other:?}"), + } +} diff --git a/paddler_gui_tests/tests/joining_a_balancer_collects_and_submits_agent_details.rs b/paddler_gui_tests/tests/joining_a_balancer_collects_and_submits_agent_details.rs new file mode 100644 index 00000000..ff025471 --- /dev/null +++ b/paddler_gui_tests/tests/joining_a_balancer_collects_and_submits_agent_details.rs @@ -0,0 +1,46 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::join_balancer_form_data::JoinBalancerFormData; +use paddler_gui::join_balancer_form_handler::Message; +use paddler_gui::ui::view_join_balancer_form::view_join_balancer_form; + +#[test] +fn clicking_connect_sends_the_connect_message() -> Result<()> { + let data = JoinBalancerFormData::default(); + let mut simulator = simulator(view_join_balancer_form(&data)); + + simulator.click("Connect")?; + + match simulator.into_messages().next() { + Some(Message::Connect) => Ok(()), + other => bail!("expected Connect message, got {other:?}"), + } +} + +#[test] +fn clicking_cancel_sends_the_cancel_message() -> Result<()> { + let data = JoinBalancerFormData::default(); + let mut simulator = simulator(view_join_balancer_form(&data)); + + simulator.click("Cancel")?; + + match simulator.into_messages().next() { + Some(Message::Cancel) => Ok(()), + other => bail!("expected Cancel message, got {other:?}"), + } +} + +#[test] +fn agent_name_field_is_findable_for_user_input() -> Result<()> { + let data = JoinBalancerFormData { + agent_name: "primary".to_owned(), + ..JoinBalancerFormData::default() + }; + let mut simulator = simulator(view_join_balancer_form(&data)); + + match simulator.find("primary") { + Ok(_) => Ok(()), + Err(error) => bail!("expected the populated agent name to render: {error}"), + } +} diff --git a/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs b/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs new file mode 100644 index 00000000..d8b9a621 --- /dev/null +++ b/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs @@ -0,0 +1,24 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::join_balancer_form_data::JoinBalancerFormData; +use paddler_gui::ui::view_join_balancer_form::view_join_balancer_form; + +#[test] +fn the_cluster_address_and_slots_errors_render_under_their_inputs_when_set() -> Result<()> { + let data = JoinBalancerFormData { + balancer_address_error: Some("Cluster address is required.".to_owned()), + slots_error: Some("Number of slots is required.".to_owned()), + ..JoinBalancerFormData::default() + }; + let mut simulator = simulator(view_join_balancer_form(&data)); + + if simulator.find("Cluster address is required.").is_err() { + bail!("expected cluster address error to render"); + } + if simulator.find("Number of slots is required.").is_err() { + bail!("expected slots error to render"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/opening_the_web_admin_panel_from_the_running_balancer_screen.rs b/paddler_gui_tests/tests/opening_the_web_admin_panel_from_the_running_balancer_screen.rs new file mode 100644 index 00000000..3c6d6f98 --- /dev/null +++ b/paddler_gui_tests/tests/opening_the_web_admin_panel_from_the_running_balancer_screen.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::running_balancer_data::RunningBalancerData; +use paddler_gui::running_balancer_handler::Message; +use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; +use paddler_gui::ui::view_running_balancer::view_running_balancer; + +fn data_with_panel(address: Option<&str>) -> RunningBalancerData { + RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: address.map(str::to_owned), + } +} + +#[test] +fn clicking_open_in_browser_sends_the_open_url_message_when_a_panel_address_is_set() -> Result<()> +{ + let data = data_with_panel(Some("127.0.0.1:8062")); + let mut simulator = simulator(view_running_balancer(&data)); + + simulator.click("Open in browser")?; + + match simulator.into_messages().next() { + Some(Message::OpenUrl(url)) if url == "http://127.0.0.1:8062" => Ok(()), + other => bail!("expected OpenUrl(http://127.0.0.1:8062), got {other:?}"), + } +} + +#[test] +fn the_open_in_browser_button_is_hidden_when_no_panel_address_is_set() -> Result<()> { + let data = data_with_panel(None); + let mut simulator = simulator(view_running_balancer(&data)); + + if simulator.find("Open in browser").is_ok() { + bail!("expected the open-in-browser button to be absent without a panel address"); + } + Ok(()) +} diff --git a/paddler_gui_tests/tests/os_signals_quit_the_app.rs b/paddler_gui_tests/tests/os_signals_quit_the_app.rs new file mode 100644 index 00000000..04723517 --- /dev/null +++ b/paddler_gui_tests/tests/os_signals_quit_the_app.rs @@ -0,0 +1,55 @@ +use std::time::Duration; + +use anyhow::Result; +use anyhow::bail; +use iced::futures::StreamExt as _; +use iced::futures::channel::mpsc; +use nix::sys::signal::Signal; +use nix::sys::signal::kill; +use nix::unistd::Pid; +use paddler_bootstrap::shutdown_signal::register_shutdown_signals; +use paddler_gui::drive_shutdown_signal_stream::drive_shutdown_signal_stream; +use paddler_gui::message::Message; +use serial_test::serial; + +const SIGNAL_DELIVERY_TIMEOUT: Duration = Duration::from_secs(5); + +#[tokio::test] +#[serial] +async fn if_signal_registration_fails_the_app_logs_and_keeps_running_without_quitting() +-> Result<()> { + let (output, mut receiver) = mpsc::channel::(1); + + drive_shutdown_signal_stream(Err(anyhow::anyhow!("simulated registration failure")), output) + .await; + + match receiver.next().await { + None => Ok(()), + Some(message) => bail!("expected no message after registration failure, got {message:?}"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn sigterm_delivered_to_the_process_makes_the_app_quit() -> Result<()> { + let signals = register_shutdown_signals()?; + let (output, mut receiver) = mpsc::channel::(1); + + let driver = tokio::spawn(drive_shutdown_signal_stream(Ok(signals), output)); + + // Give the signal handler a moment to register before we raise the signal. + tokio::task::yield_now().await; + kill(Pid::this(), Signal::SIGTERM)?; + + let received = + tokio::time::timeout(SIGNAL_DELIVERY_TIMEOUT, receiver.next()).await?; + + match received { + Some(Message::Quit) => {} + other => bail!("expected Message::Quit, got {other:?}"), + } + + tokio::time::timeout(SIGNAL_DELIVERY_TIMEOUT, driver).await??; + + Ok(()) +} diff --git a/paddler_gui_tests/tests/running_an_agent_stops_cleanly_when_the_user_disconnects.rs b/paddler_gui_tests/tests/running_an_agent_stops_cleanly_when_the_user_disconnects.rs new file mode 100644 index 00000000..239f2f83 --- /dev/null +++ b/paddler_gui_tests/tests/running_an_agent_stops_cleanly_when_the_user_disconnects.rs @@ -0,0 +1,81 @@ +use std::time::Duration; + +use anyhow::Result; +use anyhow::bail; +use iced::futures::StreamExt as _; +use iced::futures::channel::mpsc; +use paddler_bootstrap::agent_runner::AgentRunnerParams; +use paddler_gui::drive_agent_stream::drive_agent_stream; +use paddler_gui::message::Message; +use paddler_gui_tests::bind_ephemeral_socket::bind_ephemeral_socket; +use tokio_util::sync::CancellationToken; + +const AGENT_STREAM_TIMEOUT: Duration = Duration::from_secs(10); + +fn ephemeral_management_address() -> Result { + Ok(bind_ephemeral_socket()?.to_string()) +} + +#[tokio::test] +async fn cancellation_token_makes_the_agent_send_a_stopped_message_and_finish() -> Result<()> { + let cancellation_token = CancellationToken::new(); + let params = AgentRunnerParams { + agent_name: Some("test-agent".to_owned()), + cancellation_token: cancellation_token.clone(), + management_address: ephemeral_management_address()?, + slots: 1, + }; + + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_agent_stream(params, output)); + + cancellation_token.cancel(); + + let mut observed_stopped = false; + + let collect = async { + while let Some(message) = receiver.next().await { + match message { + Message::AgentStopped => { + observed_stopped = true; + break; + } + Message::AgentFailed(_) => break, + _ => continue, + } + } + }; + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, collect).await?; + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + if !observed_stopped { + bail!("expected AgentStopped to be observed before the stream finished"); + } + + Ok(()) +} + +#[tokio::test] +async fn when_the_ui_goes_away_the_agent_stream_exits_without_panicking() -> Result<()> { + let cancellation_token = CancellationToken::new(); + let params = AgentRunnerParams { + agent_name: None, + cancellation_token: cancellation_token.clone(), + management_address: ephemeral_management_address()?, + slots: 1, + }; + + let (output, receiver) = mpsc::channel::(8); + drop(receiver); + + let driver = tokio::spawn(drive_agent_stream(params, output)); + + cancellation_token.cancel(); + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + Ok(()) +} diff --git a/paddler_gui_tests/tests/showing_an_agents_connection_status_to_the_user.rs b/paddler_gui_tests/tests/showing_an_agents_connection_status_to_the_user.rs new file mode 100644 index 00000000..b28e3c89 --- /dev/null +++ b/paddler_gui_tests/tests/showing_an_agents_connection_status_to_the_user.rs @@ -0,0 +1,55 @@ +use std::collections::BTreeSet; + +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::agent_running_data::AgentRunningData; +use paddler_gui::ui::view_agent_running::view_agent_running; +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + +fn data_with_connected(connected: bool) -> AgentRunningData { + AgentRunningData { + balancer_address: "127.0.0.1:8060".to_owned(), + connected, + snapshot: AgentControllerSnapshot { + desired_slots_total: 0, + download_current: 0, + download_filename: None, + download_total: 0, + id: String::new(), + issues: BTreeSet::new(), + model_path: None, + name: None, + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + } +} + +#[test] +fn the_status_text_says_connected_when_the_agent_has_joined_the_cluster() -> Result<()> { + let data = data_with_connected(true); + let mut simulator = simulator(view_agent_running(&data)); + + if simulator + .find("Connected to the cluster at 127.0.0.1:8060") + .is_err() + { + bail!("expected connected status to render with the balancer address"); + } + Ok(()) +} + +#[test] +fn the_status_text_says_connecting_before_the_agent_has_joined() -> Result<()> { + let data = data_with_connected(false); + let mut simulator = simulator(view_agent_running(&data)); + + if simulator.find("Connecting to the cluster...").is_err() { + bail!("expected connecting status to render before connection completes"); + } + Ok(()) +} diff --git a/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs b/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs new file mode 100644 index 00000000..963bf08d --- /dev/null +++ b/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs @@ -0,0 +1,71 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::start_balancer_form_data::StartBalancerFormData; +use paddler_gui::start_balancer_form_handler::Message; +use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; + +fn empty_form() -> StartBalancerFormData { + StartBalancerFormData { + add_model_later: false, + balancer_address: String::new(), + balancer_address_error: None, + inference_address: String::new(), + inference_address_error: None, + model_error: None, + selected_model: None, + starting: false, + web_admin_panel_address: String::new(), + web_admin_panel_address_error: None, + web_admin_panel_address_placeholder: String::new(), + } +} + +#[test] +fn clicking_start_cluster_sends_the_confirm_message_when_the_form_is_idle() -> Result<()> { + let data = empty_form(); + let mut simulator = simulator(view_start_balancer_form(&data)); + + simulator.click("Start cluster")?; + + match simulator.into_messages().next() { + Some(Message::Confirm) => Ok(()), + other => bail!("expected Confirm message, got {other:?}"), + } +} + +#[test] +fn the_start_button_does_not_react_to_clicks_while_the_balancer_is_already_starting() -> Result<()> +{ + let data = StartBalancerFormData { + starting: true, + ..empty_form() + }; + let mut simulator = simulator(view_start_balancer_form(&data)); + + // The button label changes to "Starting..." and has no on_press. + let click_result = simulator.click("Starting..."); + + if click_result.is_err() { + bail!("expected a Starting... button to be present in the tree"); + } + + if simulator.into_messages().next().is_some() { + bail!("expected no message when the inert Starting button is clicked"); + } + + Ok(()) +} + +#[test] +fn clicking_cancel_sends_the_cancel_message() -> Result<()> { + let data = empty_form(); + let mut simulator = simulator(view_start_balancer_form(&data)); + + simulator.click("Cancel")?; + + match simulator.into_messages().next() { + Some(Message::Cancel) => Ok(()), + other => bail!("expected Cancel message, got {other:?}"), + } +} diff --git a/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs b/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs new file mode 100644 index 00000000..041c7c27 --- /dev/null +++ b/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::start_balancer_form_data::StartBalancerFormData; +use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; + +fn empty_form() -> StartBalancerFormData { + StartBalancerFormData { + add_model_later: false, + balancer_address: String::new(), + balancer_address_error: None, + inference_address: String::new(), + inference_address_error: None, + model_error: None, + selected_model: None, + starting: false, + web_admin_panel_address: String::new(), + web_admin_panel_address_error: None, + web_admin_panel_address_placeholder: String::new(), + } +} + +#[test] +fn with_add_model_later_unchecked_the_form_shows_a_model_picker() -> Result<()> { + let data = empty_form(); + let mut simulator = simulator(view_start_balancer_form(&data)); + + // The "Model" label is always present; the absence of "Model will be added later" placeholder + // confirms we're rendering the pick_list branch. + if simulator.find("Model").is_err() { + bail!("expected the Model label to render"); + } + if simulator.find("Model will be added later").is_ok() { + bail!("did not expect the add-later placeholder while pick_list branch is rendered"); + } + + Ok(()) +} + +#[test] +fn with_add_model_later_checked_the_form_shows_a_disabled_placeholder_input() -> Result<()> { + let data = StartBalancerFormData { + add_model_later: true, + ..empty_form() + }; + let mut simulator = simulator(view_start_balancer_form(&data)); + + if simulator.find("Add a model later").is_err() { + bail!("expected the add-model-later checkbox label to render"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/starting_a_balancer_shows_validation_errors_to_the_user.rs b/paddler_gui_tests/tests/starting_a_balancer_shows_validation_errors_to_the_user.rs new file mode 100644 index 00000000..4f2d4b73 --- /dev/null +++ b/paddler_gui_tests/tests/starting_a_balancer_shows_validation_errors_to_the_user.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::start_balancer_form_data::StartBalancerFormData; +use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; + +#[test] +fn each_address_field_renders_its_own_error_below_the_input_when_set() -> Result<()> { + let data = StartBalancerFormData { + add_model_later: false, + balancer_address: String::new(), + balancer_address_error: Some("Address is required.".to_owned()), + inference_address: String::new(), + inference_address_error: Some("Invalid inference address.".to_owned()), + model_error: Some("Please select a model.".to_owned()), + selected_model: None, + starting: false, + web_admin_panel_address: String::new(), + web_admin_panel_address_error: Some("Invalid web admin address.".to_owned()), + web_admin_panel_address_placeholder: String::new(), + }; + let mut simulator = simulator(view_start_balancer_form(&data)); + + for expected in [ + "Address is required.", + "Invalid inference address.", + "Please select a model.", + "Invalid web admin address.", + ] { + if simulator.find(expected).is_err() { + bail!("expected to find error text {expected:?} in the rendered tree"); + } + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs b/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs new file mode 100644 index 00000000..c5c5e436 --- /dev/null +++ b/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs @@ -0,0 +1,128 @@ +use std::net::SocketAddr; +use std::time::Duration; + +use anyhow::Result; +use anyhow::bail; +use iced::futures::StreamExt as _; +use iced::futures::channel::mpsc; +use paddler_gui::drive_balancer_stream::drive_balancer_stream; +use paddler_gui::message::Message; +use paddler_gui_tests::bind_addresses::BindAddresses; +use paddler_gui_tests::bind_ephemeral_socket::bind_ephemeral_socket; +use paddler_gui_tests::make_balancer_runner_params::make_balancer_runner_params; +use tokio_util::sync::CancellationToken; + +const BALANCER_STREAM_TIMEOUT: Duration = Duration::from_secs(30); + +const INVALID_BIND_ADDR: &str = "192.0.2.1:1"; + +#[tokio::test] +async fn an_invalid_bind_address_tells_the_user_the_balancer_failed_to_start() -> Result<()> { + let invalid: SocketAddr = INVALID_BIND_ADDR.parse()?; + let cancellation_token = CancellationToken::new(); + let params = make_balancer_runner_params( + BindAddresses { + inference_addr: invalid, + management_addr: invalid, + }, + cancellation_token, + ); + + let (output, mut receiver) = mpsc::channel::(8); + let driver = tokio::spawn(drive_balancer_stream(params, output)); + + let collect = async { + let mut observed_failed = false; + while let Some(message) = receiver.next().await { + if matches!(message, Message::BalancerFailed(_)) { + observed_failed = true; + break; + } + } + observed_failed + }; + + let observed_failed = tokio::time::timeout(BALANCER_STREAM_TIMEOUT, collect).await?; + + tokio::time::timeout(BALANCER_STREAM_TIMEOUT, driver).await??; + + if !observed_failed { + bail!("expected BalancerFailed message for an invalid bind address"); + } + + Ok(()) +} + +#[tokio::test] +async fn a_running_balancer_reports_started_and_then_stopped_when_the_user_cancels() -> Result<()> { + let cancellation_token = CancellationToken::new(); + let params = make_balancer_runner_params( + BindAddresses { + inference_addr: bind_ephemeral_socket()?, + management_addr: bind_ephemeral_socket()?, + }, + cancellation_token.clone(), + ); + + let (output, mut receiver) = mpsc::channel::(16); + let driver = tokio::spawn(drive_balancer_stream(params, output)); + + let mut observed_started = false; + let mut observed_stopped = false; + + let collect = async { + while let Some(message) = receiver.next().await { + match message { + Message::BalancerStarted => { + observed_started = true; + cancellation_token.cancel(); + } + Message::BalancerStopped => { + observed_stopped = true; + break; + } + Message::BalancerFailed(error) => { + bail!("unexpected BalancerFailed during happy path: {error}"); + } + _ => continue, + } + } + Ok::<(), anyhow::Error>(()) + }; + + tokio::time::timeout(BALANCER_STREAM_TIMEOUT, collect).await??; + + tokio::time::timeout(BALANCER_STREAM_TIMEOUT, driver).await??; + + if !observed_started { + bail!("expected BalancerStarted to be observed"); + } + if !observed_stopped { + bail!("expected BalancerStopped to be observed after cancellation"); + } + + Ok(()) +} + +#[tokio::test] +async fn when_the_ui_goes_away_the_balancer_stream_exits_without_panicking() -> Result<()> { + let cancellation_token = CancellationToken::new(); + let params = make_balancer_runner_params( + BindAddresses { + inference_addr: bind_ephemeral_socket()?, + management_addr: bind_ephemeral_socket()?, + }, + cancellation_token.clone(), + ); + + let (output, receiver) = mpsc::channel::(1); + drop(receiver); + + let driver = tokio::spawn(drive_balancer_stream(params, output)); + + cancellation_token.cancel(); + + tokio::time::timeout(BALANCER_STREAM_TIMEOUT, driver).await??; + + Ok(()) +} diff --git a/paddler_gui_tests/tests/stopping_a_running_balancer.rs b/paddler_gui_tests/tests/stopping_a_running_balancer.rs new file mode 100644 index 00000000..6776609a --- /dev/null +++ b/paddler_gui_tests/tests/stopping_a_running_balancer.rs @@ -0,0 +1,45 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::running_balancer_data::RunningBalancerData; +use paddler_gui::running_balancer_handler::Message; +use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; +use paddler_gui::ui::view_running_balancer::view_running_balancer; + +fn data_with_stopping(stopping: bool) -> RunningBalancerData { + RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping, + web_admin_panel_address: None, + } +} + +#[test] +fn clicking_stop_cluster_sends_the_stop_message_when_the_balancer_is_running() -> Result<()> { + let data = data_with_stopping(false); + let mut simulator = simulator(view_running_balancer(&data)); + + simulator.click("Stop cluster")?; + + match simulator.into_messages().next() { + Some(Message::Stop) => Ok(()), + other => bail!("expected Stop message, got {other:?}"), + } +} + +#[test] +fn the_stop_button_does_not_react_to_clicks_while_the_balancer_is_already_stopping() -> Result<()> +{ + let data = data_with_stopping(true); + let mut simulator = simulator(view_running_balancer(&data)); + + // The inert button has text "Stopping...". + simulator.click("Stopping...")?; + + if simulator.into_messages().next().is_some() { + bail!("expected no message when the inert Stopping button is clicked"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/viewing_the_list_of_agents_connected_to_a_running_balancer.rs b/paddler_gui_tests/tests/viewing_the_list_of_agents_connected_to_a_running_balancer.rs new file mode 100644 index 00000000..f8efc2b4 --- /dev/null +++ b/paddler_gui_tests/tests/viewing_the_list_of_agents_connected_to_a_running_balancer.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::running_balancer_data::RunningBalancerData; +use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; +use paddler_gui::ui::view_running_balancer::view_running_balancer; +use paddler_gui_tests::make_agent_controller_snapshot::AgentSnapshotFixture; +use paddler_gui_tests::make_agent_controller_snapshot::make_agent_controller_snapshot; + +#[test] +fn when_no_agents_are_connected_the_screen_shows_a_waiting_message() -> Result<()> { + let data = RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: None, + }; + let mut simulator = simulator(view_running_balancer(&data)); + + if simulator.find("Waiting for agents to connect...").is_err() { + bail!("expected the waiting message to render when agent list is empty"); + } + + Ok(()) +} + +#[test] +fn each_connected_agent_appears_as_its_own_card() -> Result<()> { + let agent = make_agent_controller_snapshot(AgentSnapshotFixture { + name: Some("primary-agent".to_owned()), + model_path: Some("/models/model.gguf".to_owned()), + ..AgentSnapshotFixture::default() + }); + + let snapshot = RunningBalancerSnapshot { + agent_snapshots: vec![agent], + ..RunningBalancerSnapshot::default() + }; + + let data = RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot, + stopping: false, + web_admin_panel_address: None, + }; + let mut simulator = simulator(view_running_balancer(&data)); + + if simulator.find("primary-agent").is_err() { + bail!("expected the agent card to render the agent name"); + } + + Ok(()) +} diff --git a/paddler_ports/Cargo.toml b/paddler_ports/Cargo.toml new file mode 100644 index 00000000..e20e6bbd --- /dev/null +++ b/paddler_ports/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "paddler_ports" +authors.workspace = true +description = "Shared port reservation and validation utilities" +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +anyhow = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/paddler_ports/src/bind_ephemeral_port.rs b/paddler_ports/src/bind_ephemeral_port.rs new file mode 100644 index 00000000..f0f2d4cc --- /dev/null +++ b/paddler_ports/src/bind_ephemeral_port.rs @@ -0,0 +1,34 @@ +use std::net::TcpListener; + +use anyhow::Context as _; +use anyhow::Result; + +use crate::bound_port::BoundPort; + +pub fn bind_ephemeral_port() -> Result { + let listener = + TcpListener::bind("127.0.0.1:0").context("failed to bind ephemeral loopback port")?; + + let socket_addr = listener + .local_addr() + .context("failed to read local address of bound listener")?; + + Ok(BoundPort { + socket_addr, + listener, + }) +} + +#[cfg(test)] +mod tests { + use super::bind_ephemeral_port; + + #[test] + fn binding_returns_a_listener_with_a_loopback_address() -> anyhow::Result<()> { + let bound = bind_ephemeral_port()?; + + assert!(bound.socket_addr.ip().is_loopback()); + assert!(bound.socket_addr.port() > 0); + Ok(()) + } +} diff --git a/paddler_ports/src/bound_port.rs b/paddler_ports/src/bound_port.rs new file mode 100644 index 00000000..be16402a --- /dev/null +++ b/paddler_ports/src/bound_port.rs @@ -0,0 +1,7 @@ +use std::net::SocketAddr; +use std::net::TcpListener; + +pub struct BoundPort { + pub socket_addr: SocketAddr, + pub listener: TcpListener, +} diff --git a/paddler_ports/src/check_optional_port.rs b/paddler_ports/src/check_optional_port.rs new file mode 100644 index 00000000..eb3de827 --- /dev/null +++ b/paddler_ports/src/check_optional_port.rs @@ -0,0 +1,44 @@ +use crate::bound_port::BoundPort; +use crate::check_port::check_port; +use crate::port_check_error::PortCheckError; + +pub fn check_optional_port(raw: &str) -> Result, PortCheckError> { + if raw.is_empty() { + return Ok(None); + } + + check_port(raw).map(Some) +} + +#[cfg(test)] +mod tests { + use super::PortCheckError; + use super::check_optional_port; + + #[test] + fn empty_input_resolves_to_no_bound_port() -> anyhow::Result<()> { + let result = check_optional_port("")?; + + assert!(result.is_none()); + Ok(()) + } + + #[test] + fn unparseable_input_propagates_the_check_port_error() { + let result = check_optional_port("not-a-socket-addr"); + + assert!(matches!(result, Err(PortCheckError::Unparseable { .. }))); + } + + #[test] + fn parseable_free_port_resolves_to_some_bound_port() -> anyhow::Result<()> { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + drop(listener); + + let result = check_optional_port(&address.to_string())?; + + assert!(result.is_some()); + Ok(()) + } +} diff --git a/paddler_ports/src/check_port.rs b/paddler_ports/src/check_port.rs new file mode 100644 index 00000000..d6c8878f --- /dev/null +++ b/paddler_ports/src/check_port.rs @@ -0,0 +1,88 @@ +use std::io; +use std::net::SocketAddr; +use std::net::TcpListener; + +use crate::bound_port::BoundPort; +use crate::port_check_error::PortCheckError; + +pub fn check_port(raw: &str) -> Result { + if raw.is_empty() { + return Err(PortCheckError::Empty); + } + + let socket_addr: SocketAddr = raw.parse()?; + + match TcpListener::bind(socket_addr) { + Ok(listener) => Ok(BoundPort { + socket_addr, + listener, + }), + Err(error) if error.kind() == io::ErrorKind::AddrInUse => { + Err(PortCheckError::InUse { socket_addr }) + } + Err(error) => Err(PortCheckError::BindFailed { + socket_addr, + source: error, + }), + } +} + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + + use super::PortCheckError; + use super::check_port; + + #[test] + fn empty_input_reports_empty_error() { + let result = check_port(""); + + assert!(matches!(result, Err(PortCheckError::Empty))); + } + + #[test] + fn unparseable_input_reports_unparseable_error() { + let result = check_port("not-a-socket-addr"); + + assert!(matches!(result, Err(PortCheckError::Unparseable { .. }))); + } + + #[test] + fn input_pointing_at_an_already_bound_port_reports_in_use() -> anyhow::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + + let result = check_port(&address.to_string()); + + assert!(matches!(result, Err(PortCheckError::InUse { .. }))); + Ok(()) + } + + #[test] + fn input_pointing_at_an_unassigned_address_reports_bind_failed() { + let result = check_port("192.0.2.1:0"); + + assert!(matches!(result, Err(PortCheckError::BindFailed { .. }))); + } + + #[test] + fn input_pointing_at_a_free_port_returns_a_bound_listener() -> anyhow::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + drop(listener); + + let result = check_port(&address.to_string()); + + let bound = match result { + Ok(bound) => bound, + Err(error) => return Err(error.into()), + }; + + if bound.socket_addr.port() != address.port() { + anyhow::bail!("expected port {} to be preserved", address.port()); + } + + Ok(()) + } +} diff --git a/paddler_ports/src/lib.rs b/paddler_ports/src/lib.rs new file mode 100644 index 00000000..7d59d64a --- /dev/null +++ b/paddler_ports/src/lib.rs @@ -0,0 +1,5 @@ +pub mod bind_ephemeral_port; +pub mod bound_port; +pub mod check_optional_port; +pub mod check_port; +pub mod port_check_error; diff --git a/paddler_ports/src/port_check_error.rs b/paddler_ports/src/port_check_error.rs new file mode 100644 index 00000000..04d31969 --- /dev/null +++ b/paddler_ports/src/port_check_error.rs @@ -0,0 +1,29 @@ +use std::io; +use std::net::AddrParseError; +use std::net::SocketAddr; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PortCheckError { + #[error("Address is required.")] + Empty, + #[error("Invalid address ({source}), expected format: IP:port")] + Unparseable { + #[from] + source: AddrParseError, + }, + #[error("Port {} is already in use", socket_addr.port())] + InUse { socket_addr: SocketAddr }, + #[error("Cannot bind to {socket_addr}: {source}")] + BindFailed { + socket_addr: SocketAddr, + source: io::Error, + }, +} + +impl PortCheckError { + pub fn user_facing_message(&self) -> String { + self.to_string() + } +} diff --git a/paddler_tests/Cargo.toml b/paddler_tests/Cargo.toml index cd927f8f..9ed23699 100644 --- a/paddler_tests/Cargo.toml +++ b/paddler_tests/Cargo.toml @@ -28,6 +28,7 @@ nix = { workspace = true } paddler = { workspace = true } paddler_bootstrap = { workspace = true } paddler_client = { workspace = true } +paddler_ports = { workspace = true } paddler_types = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } diff --git a/paddler_tests/src/balancer_addresses.rs b/paddler_tests/src/balancer_addresses.rs index 9831c56f..862d9d91 100644 --- a/paddler_tests/src/balancer_addresses.rs +++ b/paddler_tests/src/balancer_addresses.rs @@ -3,43 +3,34 @@ use std::net::TcpListener; use anyhow::Context as _; use anyhow::Result; +use paddler_ports::bind_ephemeral_port::bind_ephemeral_port; use url::Url; pub struct BalancerAddresses { pub compat_openai: SocketAddr, + pub compat_openai_listener: Option, pub inference: SocketAddr, + pub inference_listener: Option, pub management: SocketAddr, + pub management_listener: Option, } impl BalancerAddresses { pub fn pick() -> Result { - let inference_listener = - TcpListener::bind("127.0.0.1:0").context("failed to reserve inference service port")?; - let management_listener = TcpListener::bind("127.0.0.1:0") - .context("failed to reserve management service port")?; - let compat_openai_listener = TcpListener::bind("127.0.0.1:0") - .context("failed to reserve OpenAI-compat service port")?; - - let inference = inference_listener - .local_addr() - .context("failed to read inference listener local address")?; - let management = management_listener - .local_addr() - .context("failed to read management listener local address")?; - let compat_openai = compat_openai_listener - .local_addr() - .context("failed to read OpenAI-compat listener local address")?; - - drop(( - inference_listener, - management_listener, - compat_openai_listener, - )); + let inference_bound = + bind_ephemeral_port().context("failed to reserve inference service port")?; + let management_bound = + bind_ephemeral_port().context("failed to reserve management service port")?; + let compat_openai_bound = + bind_ephemeral_port().context("failed to reserve OpenAI-compat service port")?; Ok(Self { - compat_openai, - inference, - management, + compat_openai: compat_openai_bound.socket_addr, + compat_openai_listener: Some(compat_openai_bound.listener), + inference: inference_bound.socket_addr, + inference_listener: Some(inference_bound.listener), + management: management_bound.socket_addr, + management_listener: Some(management_bound.listener), }) } diff --git a/paddler_tests/src/start_in_process_cluster.rs b/paddler_tests/src/start_in_process_cluster.rs index 6e679c10..19536b03 100644 --- a/paddler_tests/src/start_in_process_cluster.rs +++ b/paddler_tests/src/start_in_process_cluster.rs @@ -32,21 +32,28 @@ pub async fn start_in_process_cluster( wait_for_slots_ready, }: InProcessClusterParams, ) -> Result { - let addresses = BalancerAddresses::pick()?; + let mut addresses = BalancerAddresses::pick()?; let cancel_token = CancellationToken::new(); + let inference_listener = addresses.inference_listener.take(); + let management_listener = addresses.management_listener.take(); + let openai_listener = addresses.compat_openai_listener.take(); + let balancer = BalancerRunner::start(BalancerRunnerParams { buffered_request_timeout, + inference_listener, inference_service_configuration: InferenceServiceConfiguration { addr: addresses.inference, cors_allowed_hosts: inference_cors_allowed_hosts, inference_item_timeout, }, + management_listener, management_service_configuration: ManagementServiceConfiguration { addr: addresses.management, cors_allowed_hosts: management_cors_allowed_hosts, }, max_buffered_requests, + openai_listener, openai_service_configuration: Some(OpenAIServiceConfiguration { addr: addresses.compat_openai, }), @@ -55,6 +62,8 @@ pub async fn start_in_process_cluster( statsd_prefix: "paddler_tests_".to_owned(), statsd_service_configuration: None, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener: None, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: None, }) .await From 131e457e78cbe915e13265e2b4f70ddd5e12b45b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 16 May 2026 17:38:34 +0200 Subject: [PATCH 04/13] collapse paddler_gui form fields into AddressField and SlotCountField enums for structurally non-racy port handoff --- Cargo.lock | 1 + paddler_gui/Cargo.toml | 1 + paddler_gui/src/address_field.rs | 58 +++ paddler_gui/src/app.rs | 146 +++--- paddler_gui/src/join_balancer_form_data.rs | 9 +- paddler_gui/src/join_balancer_form_handler.rs | 330 ++++++------- paddler_gui/src/lib.rs | 2 + paddler_gui/src/screen.rs | 73 ++- paddler_gui/src/slot_count_field.rs | 61 +++ paddler_gui/src/start_balancer_form_data.rs | 10 +- .../src/start_balancer_form_handler.rs | 467 ++++++++---------- paddler_gui/src/ui/view_join_balancer_form.rs | 11 +- .../src/ui/view_start_balancer_form.rs | 16 +- ...cer_shows_validation_errors_to_the_user.rs | 12 +- ...er_collects_and_submits_cluster_details.rs | 10 +- ...he_user_choose_a_model_or_add_one_later.rs | 10 +- ...cer_shows_validation_errors_to_the_user.rs | 19 +- 17 files changed, 651 insertions(+), 585 deletions(-) create mode 100644 paddler_gui/src/address_field.rs create mode 100644 paddler_gui/src/slot_count_field.rs diff --git a/Cargo.lock b/Cargo.lock index c42bce60..e8fd7cfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5027,6 +5027,7 @@ dependencies = [ "open", "paddler", "paddler_bootstrap", + "paddler_ports", "paddler_types", "statum", "tokio", diff --git a/paddler_gui/Cargo.toml b/paddler_gui/Cargo.toml index b7001334..112eac34 100644 --- a/paddler_gui/Cargo.toml +++ b/paddler_gui/Cargo.toml @@ -18,6 +18,7 @@ log = { workspace = true } open = { workspace = true } paddler = { workspace = true } paddler_bootstrap = { workspace = true } +paddler_ports = { workspace = true } paddler_types = { workspace = true } statum = { workspace = true } tokio = { workspace = true } diff --git a/paddler_gui/src/address_field.rs b/paddler_gui/src/address_field.rs new file mode 100644 index 00000000..a98aaebc --- /dev/null +++ b/paddler_gui/src/address_field.rs @@ -0,0 +1,58 @@ +use paddler_ports::bound_port::BoundPort; +use paddler_ports::check_optional_port::check_optional_port; +use paddler_ports::check_port::check_port; + +pub enum AddressField { + Empty, + Bound { raw: String, port: BoundPort }, + Invalid { raw: String, error: String }, +} + +impl AddressField { + pub fn required_from_user_input(raw: String) -> Self { + match check_port(&raw) { + Ok(port) => Self::Bound { raw, port }, + Err(error) => { + if raw.is_empty() { + Self::Empty + } else { + Self::Invalid { + raw, + error: error.user_facing_message(), + } + } + } + } + } + + pub fn optional_from_user_input(raw: String) -> Self { + match check_optional_port(&raw) { + Ok(Some(port)) => Self::Bound { raw, port }, + Ok(None) => Self::Empty, + Err(error) => Self::Invalid { + raw, + error: error.user_facing_message(), + }, + } + } + + pub fn raw_text(&self) -> &str { + match self { + Self::Empty => "", + Self::Bound { raw, .. } | Self::Invalid { raw, .. } => raw, + } + } + + pub fn error_text(&self) -> Option<&str> { + match self { + Self::Invalid { error, .. } => Some(error), + _ => None, + } + } +} + +impl Default for AddressField { + fn default() -> Self { + Self::Empty + } +} diff --git a/paddler_gui/src/app.rs b/paddler_gui/src/app.rs index 527c3ab1..bf66df98 100644 --- a/paddler_gui/src/app.rs +++ b/paddler_gui/src/app.rs @@ -1,5 +1,4 @@ use std::mem; -use std::net::SocketAddr; use std::sync::LazyLock; use std::time::Duration; @@ -30,6 +29,7 @@ use paddler::resolved_socket_addr::ResolvedSocketAddr; use paddler_bootstrap::agent_runner::AgentRunnerParams; use paddler_bootstrap::balancer_runner::BalancerRunnerParams; use paddler_bootstrap::shutdown_signal::register_shutdown_signals; +use paddler_ports::bound_port::BoundPort; use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio_util::sync::CancellationToken; @@ -149,17 +149,17 @@ impl App { Task::none() } start_balancer_form_handler::Action::StartBalancer { - management_addr, - inference_addr, - web_admin_panel_addr, + management_port, + inference_port, + web_admin_panel_port, desired_state, } => { self.screen = CurrentScreen::StartBalancerForm(form); self.spawn_balancer( - management_addr, - inference_addr, - web_admin_panel_addr, + management_port, + inference_port, + web_admin_panel_port, &desired_state, ) } @@ -399,8 +399,8 @@ impl App { fn spawn_balancer( &mut self, - management_addr: SocketAddr, - inference_addr: SocketAddr, + management_port: BoundPort, + inference_port: BoundPort, #[cfg_attr( not(feature = "web_admin_panel"), expect( @@ -408,7 +408,7 @@ impl App { reason = "web admin panel configuration is only built when the feature is enabled" ) )] - web_admin_panel_addr: Option, + web_admin_panel_port: Option, desired_state: &BalancerDesiredState, ) -> Task { let cancel = self.shutdown.child_token(); @@ -418,37 +418,47 @@ impl App { let max_buffered_requests = 30; let statsd_prefix = "paddler_"; + let management_addr = management_port.socket_addr; + let inference_addr = inference_port.socket_addr; + #[cfg(feature = "web_admin_panel")] - let web_admin_panel_service_configuration = - web_admin_panel_addr.map(|addr| WebAdminPanelServiceConfiguration { - addr, - template_data: TemplateData { - buffered_request_timeout, - compat_openai_addr: None, - inference_addr: ResolvedSocketAddr { - input_addr: inference_addr.to_string(), - socket_addr: inference_addr, - }, - management_addr: ResolvedSocketAddr { - input_addr: management_addr.to_string(), - socket_addr: management_addr, - }, - max_buffered_requests, - statsd_addr: None, - statsd_prefix: statsd_prefix.to_owned(), - statsd_reporting_interval: Duration::from_secs(10), - }, - }); + let (web_admin_panel_service_configuration, web_admin_panel_listener) = + match web_admin_panel_port { + Some(bound) => { + let admin_addr = bound.socket_addr; + let configuration = WebAdminPanelServiceConfiguration { + addr: admin_addr, + template_data: TemplateData { + buffered_request_timeout, + compat_openai_addr: None, + inference_addr: ResolvedSocketAddr { + input_addr: inference_addr.to_string(), + socket_addr: inference_addr, + }, + management_addr: ResolvedSocketAddr { + input_addr: management_addr.to_string(), + socket_addr: management_addr, + }, + max_buffered_requests, + statsd_addr: None, + statsd_prefix: statsd_prefix.to_owned(), + statsd_reporting_interval: Duration::from_secs(10), + }, + }; + (Some(configuration), Some(bound.listener)) + } + None => (None, None), + }; let params = BalancerRunnerParams { buffered_request_timeout, - inference_listener: None, + inference_listener: Some(inference_port.listener), inference_service_configuration: InferenceServiceConfiguration { addr: inference_addr, cors_allowed_hosts: vec![], inference_item_timeout: Duration::from_secs(30), }, - management_listener: None, + management_listener: Some(management_port.listener), management_service_configuration: ManagementServiceConfiguration { addr: management_addr, cors_allowed_hosts: vec![], @@ -461,7 +471,7 @@ impl App { statsd_prefix: statsd_prefix.to_owned(), statsd_service_configuration: None, #[cfg(feature = "web_admin_panel")] - web_admin_panel_listener: None, + web_admin_panel_listener, #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration, }; @@ -474,8 +484,6 @@ impl App { #[cfg(test)] mod tests { - use std::net::TcpListener; - use anyhow::Result; use anyhow::bail; @@ -497,15 +505,12 @@ mod tests { fn fresh_start_form_data() -> StartBalancerFormData { StartBalancerFormData { add_model_later: false, - balancer_address: String::new(), - balancer_address_error: None, - inference_address: String::new(), - inference_address_error: None, + balancer_address: crate::address_field::AddressField::Empty, + inference_address: crate::address_field::AddressField::Empty, model_error: None, selected_model: None, starting: false, - web_admin_panel_address: String::new(), - web_admin_panel_address_error: None, + web_admin_panel_address: crate::address_field::AddressField::Empty, web_admin_panel_address_placeholder: String::new(), } } @@ -577,11 +582,23 @@ mod tests { ) } - fn ephemeral_loopback_addr() -> Result { - let listener = TcpListener::bind("127.0.0.1:0")?; - let addr = listener.local_addr()?; - drop(listener); - Ok(addr.to_string()) + fn bound_address_field() -> Result { + let bound = paddler_ports::bind_ephemeral_port::bind_ephemeral_port()?; + Ok(crate::address_field::AddressField::Bound { + raw: bound.socket_addr.to_string(), + port: bound, + }) + } + + fn bound_join_form_data() -> Result { + Ok(JoinBalancerFormData { + agent_name: String::new(), + balancer_address: bound_address_field()?, + slots_count: crate::slot_count_field::SlotCountField::Valid { + raw: "2".to_owned(), + value: 2, + }, + }) } fn assert_screen_is_home(app: &App) -> Result<()> { @@ -682,11 +699,7 @@ mod tests { #[test] fn join_form_connect_action_transitions_to_agent_running_and_sets_agent_cancel_token() -> Result<()> { - let mut app = app_with_screen(screen_join_form(JoinBalancerFormData { - balancer_address: "127.0.0.1:8060".to_owned(), - slots_count: "2".to_owned(), - ..fresh_join_form_data() - })); + let mut app = app_with_screen(screen_join_form(bound_join_form_data()?)); let _ = app.update(Message::JoinBalancerForm( join_balancer_form_handler::Message::Connect, @@ -742,12 +755,9 @@ mod tests { #[test] fn start_form_confirm_action_sets_balancer_cancel_token_and_starts_spawn() -> Result<()> { - let management_addr = ephemeral_loopback_addr()?; - let inference_addr = ephemeral_loopback_addr()?; - let form = StartBalancerFormData { - balancer_address: management_addr, - inference_address: inference_addr, + balancer_address: bound_address_field()?, + inference_address: bound_address_field()?, add_model_later: true, ..fresh_start_form_data() }; @@ -1037,30 +1047,12 @@ mod tests { Ok(()) } - fn three_ephemeral_loopback_addrs() -> Result<[String; 3]> { - let listener_one = TcpListener::bind("127.0.0.1:0")?; - let listener_two = TcpListener::bind("127.0.0.1:0")?; - let listener_three = TcpListener::bind("127.0.0.1:0")?; - - let addr_one = listener_one.local_addr()?.to_string(); - let addr_two = listener_two.local_addr()?.to_string(); - let addr_three = listener_three.local_addr()?.to_string(); - - drop(listener_one); - drop(listener_two); - drop(listener_three); - - Ok([addr_one, addr_two, addr_three]) - } - #[test] fn start_balancer_with_web_admin_panel_address_builds_web_admin_configuration() -> Result<()> { - let [management_addr, inference_addr, web_admin_addr] = three_ephemeral_loopback_addrs()?; - let form = StartBalancerFormData { - balancer_address: management_addr, - inference_address: inference_addr, - web_admin_panel_address: web_admin_addr, + balancer_address: bound_address_field()?, + inference_address: bound_address_field()?, + web_admin_panel_address: bound_address_field()?, add_model_later: true, ..fresh_start_form_data() }; diff --git a/paddler_gui/src/join_balancer_form_data.rs b/paddler_gui/src/join_balancer_form_data.rs index 47bca49c..d8031761 100644 --- a/paddler_gui/src/join_balancer_form_data.rs +++ b/paddler_gui/src/join_balancer_form_data.rs @@ -1,8 +1,9 @@ +use crate::address_field::AddressField; +use crate::slot_count_field::SlotCountField; + #[derive(Default)] pub struct JoinBalancerFormData { pub agent_name: String, - pub balancer_address: String, - pub balancer_address_error: Option, - pub slots_count: String, - pub slots_error: Option, + pub balancer_address: AddressField, + pub slots_count: SlotCountField, } diff --git a/paddler_gui/src/join_balancer_form_handler.rs b/paddler_gui/src/join_balancer_form_handler.rs index 6a1a5ec8..75ac07a9 100644 --- a/paddler_gui/src/join_balancer_form_handler.rs +++ b/paddler_gui/src/join_balancer_form_handler.rs @@ -1,6 +1,8 @@ -use std::net::SocketAddr; +use std::mem; +use crate::address_field::AddressField; use crate::join_balancer_form_data::JoinBalancerFormData; +use crate::slot_count_field::SlotCountField; #[derive(Debug, Clone)] pub enum Message { @@ -30,16 +32,12 @@ impl JoinBalancerFormData { Action::None } Message::SetBalancerAddress(address) => { - self.balancer_address = address; - self.balancer_address_error = None; + self.balancer_address = AddressField::required_from_user_input(address); Action::None } Message::SetSlotsCount(slots) => { - if slots.is_empty() || slots.chars().all(|character| character.is_ascii_digit()) { - self.slots_count = slots; - self.slots_error = None; - } + self.slots_count = SlotCountField::from_user_input(slots); Action::None } @@ -49,49 +47,37 @@ impl JoinBalancerFormData { } fn validate_and_connect(&mut self) -> Action { - self.balancer_address_error = None; - self.slots_error = None; - - if self.balancer_address.is_empty() { - self.balancer_address_error = Some("Cluster address is required.".to_owned()); - } else if self.balancer_address.parse::().is_err() { - self.balancer_address_error = - Some("Invalid address, expected format: IP:port".to_owned()); - } + let balancer_address = mem::take(&mut self.balancer_address); + let slots_count = mem::take(&mut self.slots_count); - let slots = if self.slots_count.is_empty() { - self.slots_error = Some("Number of slots is required.".to_owned()); - None - } else { - match self.slots_count.parse::() { - Ok(slots) if slots > 0 => Some(slots), - Ok(non_positive_slots) => { - log::debug!("User entered non-positive slot count: {non_positive_slots}"); - self.slots_error = Some( - "Invalid number of slots (the number should be greater than zero)." - .to_owned(), - ); - None - } - Err(error) => { - let message = match error.kind() { - std::num::IntErrorKind::PosOverflow => "Number of slots is too large.", - unexpected_kind => { - log::error!("Unexpected slots parse error: {unexpected_kind:?}"); - "Invalid number of slots." - } - }; - self.slots_error = Some(message.to_owned()); - None - } - } + let required_balancer_address = match balancer_address { + AddressField::Empty => AddressField::Invalid { + raw: String::new(), + error: "Cluster address is required.".to_owned(), + }, + other => other, }; + let required_slots_count = match slots_count { + SlotCountField::Empty => SlotCountField::Invalid { + raw: String::new(), + error: "Number of slots is required.".to_owned(), + }, + other => other, + }; + + let address_bound = matches!(required_balancer_address, AddressField::Bound { .. }); + let slots_valid = matches!(required_slots_count, SlotCountField::Valid { .. }); - if self.balancer_address_error.is_some() || self.slots_error.is_some() { + if !address_bound || !slots_valid { + self.balancer_address = required_balancer_address; + self.slots_count = required_slots_count; return Action::None; } - let Some(slots) = slots else { + let AddressField::Bound { raw: address_raw, port: _ } = required_balancer_address else { + return Action::None; + }; + let SlotCountField::Valid { value: slots, .. } = required_slots_count else { return Action::None; }; @@ -103,7 +89,7 @@ impl JoinBalancerFormData { Action::ConnectAgent { agent_name, - management_address: self.balancer_address.clone(), + management_address: address_raw, slots, } } @@ -112,233 +98,239 @@ impl JoinBalancerFormData { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use super::Action; + use super::AddressField; use super::JoinBalancerFormData; use super::Message; + use super::SlotCountField; #[test] fn set_agent_name_records_typed_value_into_form_state() -> Result<()> { let mut data = JoinBalancerFormData::default(); - match data.update(Message::SetAgentName("alice".to_owned())) { - Action::None => {} - _ => bail!("expected Action::None"), - } - - if data.agent_name != "alice" { - bail!("expected agent_name to record typed value"); - } + assert!(matches!( + data.update(Message::SetAgentName("alice".to_owned())), + Action::None + )); + assert_eq!(data.agent_name, "alice"); Ok(()) } #[test] - fn set_balancer_address_clears_previously_set_address_error() -> Result<()> { - let mut data = JoinBalancerFormData { - balancer_address_error: Some("stale".to_owned()), - ..JoinBalancerFormData::default() - }; - - match data.update(Message::SetBalancerAddress("127.0.0.1:8080".to_owned())) { - Action::None => {} - _ => bail!("expected Action::None"), - } - - if data.balancer_address_error.is_some() { - bail!("expected balancer_address_error to be cleared"); - } - - if data.balancer_address != "127.0.0.1:8080" { - bail!("expected new balancer_address to be stored"); - } - - Ok(()) - } - - #[test] - fn set_slots_count_accepts_digit_only_input() -> Result<()> { + fn set_balancer_address_with_unparseable_input_records_invalid_state() -> Result<()> { let mut data = JoinBalancerFormData::default(); - let _action = data.update(Message::SetSlotsCount("42".to_owned())); + let _ = data.update(Message::SetBalancerAddress( + "not-a-socket-addr".to_owned(), + )); - if data.slots_count != "42" { - bail!("expected slots_count to be updated to digit string"); - } + assert!(matches!( + data.balancer_address, + AddressField::Invalid { .. } + )); Ok(()) } #[test] - fn set_slots_count_silently_ignores_non_digit_input() -> Result<()> { + fn set_balancer_address_with_empty_input_records_empty_state() -> Result<()> { let mut data = JoinBalancerFormData { - slots_count: "10".to_owned(), + balancer_address: AddressField::Invalid { + raw: "stale".to_owned(), + error: "stale".to_owned(), + }, ..JoinBalancerFormData::default() }; - let _action = data.update(Message::SetSlotsCount("abc".to_owned())); + let _ = data.update(Message::SetBalancerAddress(String::new())); - if data.slots_count != "10" { - bail!("expected slots_count to be unchanged after non-digit input"); - } + assert!(matches!(data.balancer_address, AddressField::Empty)); Ok(()) } #[test] - fn cancel_message_returns_cancel_action() -> Result<()> { + fn set_slots_count_accepts_digit_input() -> Result<()> { let mut data = JoinBalancerFormData::default(); - match data.update(Message::Cancel) { - Action::Cancel => Ok(()), - _ => bail!("expected Action::Cancel"), - } + let _ = data.update(Message::SetSlotsCount("42".to_owned())); + + assert!(matches!(data.slots_count, SlotCountField::Valid { .. })); + + Ok(()) } #[test] - fn connecting_without_a_cluster_address_records_required_error() -> Result<()> { - let mut data = JoinBalancerFormData { - slots_count: "1".to_owned(), - ..JoinBalancerFormData::default() - }; + fn set_slots_count_with_non_digit_input_records_invalid_state() -> Result<()> { + let mut data = JoinBalancerFormData::default(); - match data.update(Message::Connect) { - Action::None => {} - _ => bail!("expected Action::None when validation fails"), - } + let _ = data.update(Message::SetSlotsCount("abc".to_owned())); - match data.balancer_address_error.as_deref() { - Some(message) if message.contains("required") => Ok(()), - other => bail!("expected required-message error, got {other:?}"), - } + assert!(matches!(data.slots_count, SlotCountField::Invalid { .. })); + + Ok(()) } #[test] - fn connecting_with_an_unparseable_cluster_address_records_invalid_format_error() -> Result<()> { + fn set_slots_count_with_empty_input_records_empty_state() -> Result<()> { let mut data = JoinBalancerFormData { - balancer_address: "not-a-socket-addr".to_owned(), - slots_count: "1".to_owned(), + slots_count: SlotCountField::Valid { + raw: "10".to_owned(), + value: 10, + }, ..JoinBalancerFormData::default() }; - let _ = data.update(Message::Connect); + let _ = data.update(Message::SetSlotsCount(String::new())); - match data.balancer_address_error.as_deref() { - Some(message) if message.contains("IP:port") => Ok(()), - other => bail!("expected IP:port-format error, got {other:?}"), - } + assert!(matches!(data.slots_count, SlotCountField::Empty)); + + Ok(()) } #[test] - fn connecting_without_a_slot_count_records_required_error() -> Result<()> { - let mut data = JoinBalancerFormData { - balancer_address: "127.0.0.1:8060".to_owned(), - ..JoinBalancerFormData::default() - }; + fn cancel_message_returns_cancel_action() -> Result<()> { + let mut data = JoinBalancerFormData::default(); - let _ = data.update(Message::Connect); + assert!(matches!(data.update(Message::Cancel), Action::Cancel)); - match data.slots_error.as_deref() { - Some(message) if message.contains("required") => Ok(()), - other => bail!("expected required-slots error, got {other:?}"), - } + Ok(()) } #[test] - fn connecting_with_zero_slots_records_must_be_greater_than_zero_error() -> Result<()> { + fn connecting_without_a_cluster_address_records_required_error() -> Result<()> { let mut data = JoinBalancerFormData { - balancer_address: "127.0.0.1:8060".to_owned(), - slots_count: "0".to_owned(), + slots_count: SlotCountField::Valid { + raw: "1".to_owned(), + value: 1, + }, ..JoinBalancerFormData::default() }; - let _ = data.update(Message::Connect); + let action = data.update(Message::Connect); - match data.slots_error.as_deref() { - Some(message) if message.contains("greater than zero") => Ok(()), - other => bail!("expected greater-than-zero error, got {other:?}"), + assert!(matches!(action, Action::None)); + match &data.balancer_address { + AddressField::Invalid { error, .. } => assert!(error.contains("required")), + other => panic!("expected required-error invalid state, got {other:?}", other = match other { + AddressField::Empty => "Empty", + AddressField::Bound { .. } => "Bound", + AddressField::Invalid { .. } => "Invalid", + }), } + + Ok(()) } #[test] - fn connecting_with_an_overflowing_slot_count_records_too_large_error() -> Result<()> { + fn connecting_without_a_slot_count_records_required_error() -> Result<()> { + let bound = paddler_ports::bind_ephemeral_port::bind_ephemeral_port()?; let mut data = JoinBalancerFormData { - balancer_address: "127.0.0.1:8060".to_owned(), - slots_count: "9999999999".to_owned(), + balancer_address: AddressField::Bound { + raw: bound.socket_addr.to_string(), + port: bound, + }, ..JoinBalancerFormData::default() }; - let _ = data.update(Message::Connect); + let action = data.update(Message::Connect); - match data.slots_error.as_deref() { - Some(message) if message.contains("too large") => Ok(()), - other => bail!("expected too-large error, got {other:?}"), + assert!(matches!(action, Action::None)); + match &data.slots_count { + SlotCountField::Invalid { error, .. } => assert!(error.contains("required")), + _ => anyhow::bail!("expected required-error invalid state"), } + + Ok(()) } #[test] - fn connecting_with_a_malformed_slot_count_falls_back_to_generic_invalid_error() -> Result<()> { + fn connecting_with_a_zero_slot_count_records_must_be_greater_than_zero_error() -> Result<()> { + let bound = paddler_ports::bind_ephemeral_port::bind_ephemeral_port()?; let mut data = JoinBalancerFormData { - balancer_address: "127.0.0.1:8060".to_owned(), - slots_count: "abc".to_owned(), + balancer_address: AddressField::Bound { + raw: bound.socket_addr.to_string(), + port: bound, + }, + slots_count: SlotCountField::from_user_input("0".to_owned()), ..JoinBalancerFormData::default() }; - let _ = data.update(Message::Connect); + let action = data.update(Message::Connect); - match data.slots_error.as_deref() { - Some(message) if message.contains("Invalid number of slots") => Ok(()), - other => bail!("expected generic invalid-slots error, got {other:?}"), + assert!(matches!(action, Action::None)); + match &data.slots_count { + SlotCountField::Invalid { error, .. } => { + assert!(error.contains("greater than zero")); + } + _ => anyhow::bail!("expected zero-slot error"), } + + Ok(()) } #[test] fn connecting_with_valid_input_and_no_agent_name_yields_connect_agent_with_name_none() -> Result<()> { + let bound = paddler_ports::bind_ephemeral_port::bind_ephemeral_port()?; + let raw_address = bound.socket_addr.to_string(); let mut data = JoinBalancerFormData { - balancer_address: "127.0.0.1:8060".to_owned(), - slots_count: "4".to_owned(), + balancer_address: AddressField::Bound { + raw: raw_address.clone(), + port: bound, + }, + slots_count: SlotCountField::Valid { + raw: "4".to_owned(), + value: 4, + }, ..JoinBalancerFormData::default() }; - match data.update(Message::Connect) { + let action = data.update(Message::Connect); + + match action { Action::ConnectAgent { agent_name, management_address, slots, } => { - if agent_name.is_some() { - bail!("expected agent_name to be None when field is empty"); - } - if management_address != "127.0.0.1:8060" { - bail!("expected management_address to be forwarded verbatim"); - } - if slots != 4 { - bail!("expected slots=4 to be forwarded"); - } - Ok(()) + assert!(agent_name.is_none()); + assert_eq!(management_address, raw_address); + assert_eq!(slots, 4); } - _ => bail!("expected Action::ConnectAgent"), + _ => anyhow::bail!("expected Action::ConnectAgent"), } + + Ok(()) } #[test] fn connecting_with_valid_input_and_a_filled_agent_name_yields_connect_agent_with_some_name() -> Result<()> { + let bound = paddler_ports::bind_ephemeral_port::bind_ephemeral_port()?; let mut data = JoinBalancerFormData { - balancer_address: "127.0.0.1:8060".to_owned(), - agent_name: "primary-agent".to_owned(), - slots_count: "2".to_owned(), - ..JoinBalancerFormData::default() + agent_name: "primary".to_owned(), + balancer_address: AddressField::Bound { + raw: bound.socket_addr.to_string(), + port: bound, + }, + slots_count: SlotCountField::Valid { + raw: "2".to_owned(), + value: 2, + }, }; - match data.update(Message::Connect) { - Action::ConnectAgent { agent_name, .. } => match agent_name.as_deref() { - Some("primary-agent") => Ok(()), - other => bail!("expected agent_name=Some(\"primary-agent\"), got {other:?}"), - }, - _ => bail!("expected Action::ConnectAgent"), + let action = data.update(Message::Connect); + + match action { + Action::ConnectAgent { agent_name, .. } => { + assert_eq!(agent_name.as_deref(), Some("primary")); + } + _ => anyhow::bail!("expected Action::ConnectAgent"), } + + Ok(()) } } diff --git a/paddler_gui/src/lib.rs b/paddler_gui/src/lib.rs index 8b8d76fc..70de6d23 100644 --- a/paddler_gui/src/lib.rs +++ b/paddler_gui/src/lib.rs @@ -1,3 +1,4 @@ +pub mod address_field; pub mod agent_running_data; pub mod agent_running_handler; pub mod app; @@ -18,6 +19,7 @@ pub mod running_balancer_handler; pub mod running_balancer_snapshot; #[expect(unsafe_code, reason = "statum macros generate link_section statics")] pub mod screen; +pub mod slot_count_field; pub mod start_balancer_form_data; pub mod start_balancer_form_handler; pub mod ui; diff --git a/paddler_gui/src/screen.rs b/paddler_gui/src/screen.rs index a820abc0..40b5acbb 100644 --- a/paddler_gui/src/screen.rs +++ b/paddler_gui/src/screen.rs @@ -6,6 +6,7 @@ use statum::machine; use statum::state; use statum::transition; +use crate::address_field::AddressField; use crate::agent_running_data::AgentRunningData; use crate::detect_network_interfaces::detect_network_interfaces; use crate::home_data::HomeData; @@ -40,15 +41,16 @@ impl Screen { self.transition_with(StartBalancerFormData { add_model_later: false, - balancer_address: format!("{suggested_address}:8060"), - balancer_address_error: None, - inference_address: format!("{suggested_address}:8061"), - inference_address_error: None, + balancer_address: AddressField::required_from_user_input(format!( + "{suggested_address}:8060" + )), + inference_address: AddressField::required_from_user_input(format!( + "{suggested_address}:8061" + )), model_error: None, selected_model: None, starting: false, - web_admin_panel_address: String::new(), - web_admin_panel_address_error: None, + web_admin_panel_address: AddressField::Empty, web_admin_panel_address_placeholder: format!("{suggested_address}:8062"), }) } @@ -69,7 +71,7 @@ impl Screen { }; AgentRunningData { - balancer_address: form_data.balancer_address, + balancer_address: form_data.balancer_address.raw_text().to_owned(), connected: false, snapshot: AgentControllerSnapshot { desired_slots_total: 0, @@ -108,12 +110,18 @@ impl Screen { } pub fn balancer_started(self) -> Screen { - self.transition_map(|form_data: StartBalancerFormData| RunningBalancerData { - balancer_address: form_data.balancer_address, - snapshot: RunningBalancerSnapshot::default(), - stopping: false, - web_admin_panel_address: Some(form_data.web_admin_panel_address) - .filter(|address| !address.is_empty()), + self.transition_map(|form_data: StartBalancerFormData| { + let balancer_address = form_data.balancer_address.raw_text().to_owned(); + let web_admin_panel_address = match form_data.web_admin_panel_address { + AddressField::Empty => None, + AddressField::Bound { raw, .. } | AddressField::Invalid { raw, .. } => Some(raw), + }; + RunningBalancerData { + balancer_address, + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address, + } }) } @@ -138,6 +146,7 @@ mod tests { use anyhow::Result; use anyhow::bail; + use super::AddressField; use super::AgentRunning; use super::HomeData; use super::JoinBalancerForm; @@ -182,15 +191,18 @@ mod tests { fn fresh_start_form_data() -> StartBalancerFormData { StartBalancerFormData { add_model_later: false, - balancer_address: "127.0.0.1:8060".to_owned(), - balancer_address_error: None, - inference_address: "127.0.0.1:8061".to_owned(), - inference_address_error: None, + balancer_address: AddressField::Invalid { + raw: "127.0.0.1:8060".to_owned(), + error: "placeholder".to_owned(), + }, + inference_address: AddressField::Invalid { + raw: "127.0.0.1:8061".to_owned(), + error: "placeholder".to_owned(), + }, model_error: None, selected_model: None, starting: true, - web_admin_panel_address: String::new(), - web_admin_panel_address_error: None, + web_admin_panel_address: AddressField::Empty, web_admin_panel_address_placeholder: String::new(), } } @@ -239,14 +251,14 @@ mod tests { fn home_to_start_balancer_form_seeds_addresses_from_detected_interfaces() -> Result<()> { let next = home().start_balancer(); - if next.state_data.balancer_address.is_empty() { + if next.state_data.balancer_address.raw_text().is_empty() { bail!("expected start form to be seeded with a balancer_address"); } - if !next.state_data.balancer_address.ends_with(":8060") { + if !next.state_data.balancer_address.raw_text().ends_with(":8060") { bail!( "expected default balancer port suffix :8060, got {}", - next.state_data.balancer_address + next.state_data.balancer_address.raw_text() ); } @@ -267,7 +279,10 @@ mod tests { fn join_balancer_form_connect_with_empty_agent_name_sets_name_none_on_agent_screen() -> Result<()> { let form = join_form(JoinBalancerFormData { - balancer_address: "127.0.0.1:8060".to_owned(), + balancer_address: AddressField::Invalid { + raw: "127.0.0.1:8060".to_owned(), + error: "placeholder".to_owned(), + }, ..JoinBalancerFormData::default() }); @@ -284,7 +299,10 @@ mod tests { -> Result<()> { let form = join_form(JoinBalancerFormData { agent_name: "primary".to_owned(), - balancer_address: "127.0.0.1:8060".to_owned(), + balancer_address: AddressField::Invalid { + raw: "127.0.0.1:8060".to_owned(), + error: "placeholder".to_owned(), + }, ..JoinBalancerFormData::default() }); @@ -328,7 +346,10 @@ mod tests { fn start_balancer_form_balancer_started_with_web_admin_address_carries_it_forward() -> Result<()> { let form = start_form(StartBalancerFormData { - web_admin_panel_address: "127.0.0.1:8062".to_owned(), + web_admin_panel_address: AddressField::Invalid { + raw: "127.0.0.1:8062".to_owned(), + error: "placeholder".to_owned(), + }, ..fresh_start_form_data() }); @@ -344,7 +365,7 @@ mod tests { fn start_balancer_form_balancer_started_with_empty_web_admin_address_resolves_to_none() -> Result<()> { let form = start_form(StartBalancerFormData { - web_admin_panel_address: String::new(), + web_admin_panel_address: AddressField::Empty, ..fresh_start_form_data() }); diff --git a/paddler_gui/src/slot_count_field.rs b/paddler_gui/src/slot_count_field.rs new file mode 100644 index 00000000..eb9095aa --- /dev/null +++ b/paddler_gui/src/slot_count_field.rs @@ -0,0 +1,61 @@ +use std::num::IntErrorKind; + +pub enum SlotCountField { + Empty, + Valid { raw: String, value: i32 }, + Invalid { raw: String, error: String }, +} + +impl SlotCountField { + pub fn from_user_input(raw: String) -> Self { + if raw.is_empty() { + return Self::Empty; + } + + if !raw.chars().all(|character| character.is_ascii_digit()) { + return Self::Invalid { + raw, + error: "Invalid number of slots.".to_owned(), + }; + } + + match raw.parse::() { + Ok(value) if value > 0 => Self::Valid { raw, value }, + Ok(_) => Self::Invalid { + raw, + error: "Invalid number of slots (the number should be greater than zero)." + .to_owned(), + }, + Err(error) => { + let message = match error.kind() { + IntErrorKind::PosOverflow => "Number of slots is too large.", + _ => "Invalid number of slots.", + }; + Self::Invalid { + raw, + error: message.to_owned(), + } + } + } + } + + pub fn raw_text(&self) -> &str { + match self { + Self::Empty => "", + Self::Valid { raw, .. } | Self::Invalid { raw, .. } => raw, + } + } + + pub fn error_text(&self) -> Option<&str> { + match self { + Self::Invalid { error, .. } => Some(error), + _ => None, + } + } +} + +impl Default for SlotCountField { + fn default() -> Self { + Self::Empty + } +} diff --git a/paddler_gui/src/start_balancer_form_data.rs b/paddler_gui/src/start_balancer_form_data.rs index 662c13de..35075193 100644 --- a/paddler_gui/src/start_balancer_form_data.rs +++ b/paddler_gui/src/start_balancer_form_data.rs @@ -1,15 +1,13 @@ +use crate::address_field::AddressField; use crate::model_preset::ModelPreset; pub struct StartBalancerFormData { pub add_model_later: bool, - pub balancer_address: String, - pub balancer_address_error: Option, - pub inference_address: String, - pub inference_address_error: Option, + pub balancer_address: AddressField, + pub inference_address: AddressField, pub model_error: Option, pub selected_model: Option, pub starting: bool, - pub web_admin_panel_address: String, - pub web_admin_panel_address_error: Option, + pub web_admin_panel_address: AddressField, pub web_admin_panel_address_placeholder: String, } diff --git a/paddler_gui/src/start_balancer_form_handler.rs b/paddler_gui/src/start_balancer_form_handler.rs index 2b6d9171..d6d90d7d 100644 --- a/paddler_gui/src/start_balancer_form_handler.rs +++ b/paddler_gui/src/start_balancer_form_handler.rs @@ -1,46 +1,12 @@ -use std::io; -use std::net::SocketAddr; -use std::net::TcpListener; +use std::mem; +use paddler_ports::bound_port::BoundPort; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::address_field::AddressField; use crate::model_preset::ModelPreset; use crate::start_balancer_form_data::StartBalancerFormData; -enum PortCheck { - Available, - InUse, - BindFailed(io::Error), -} - -fn check_port(address: &SocketAddr) -> PortCheck { - match TcpListener::bind(address) { - Ok(_) => PortCheck::Available, - Err(error) if error.kind() == io::ErrorKind::AddrInUse => PortCheck::InUse, - Err(error) => PortCheck::BindFailed(error), - } -} - -fn validate_optional_address(raw: &str) -> Result, String> { - if raw.is_empty() { - return Ok(None); - } - - let addr = raw - .parse::() - .map_err(|error| format!("Invalid address ({error}), expected format: IP:port"))?; - - match check_port(&addr) { - PortCheck::Available => Ok(Some(addr)), - PortCheck::InUse => Err(format!("Port {} is already in use", addr.port())), - PortCheck::BindFailed(error) => Err(format!("Cannot bind to {addr}: {error}")), - } -} - -fn validate_required_address(raw: &str) -> Result { - validate_optional_address(raw)?.ok_or_else(|| "Address is required.".to_owned()) -} - #[expect( clippy::large_enum_variant, reason = "ephemeral value, immediately consumed" @@ -56,17 +22,13 @@ pub enum Message { Cancel, } -#[expect( - clippy::large_enum_variant, - reason = "ephemeral value, immediately consumed" -)] pub enum Action { None, Cancel, StartBalancer { - management_addr: SocketAddr, - inference_addr: SocketAddr, - web_admin_panel_addr: Option, + management_port: BoundPort, + inference_port: BoundPort, + web_admin_panel_port: Option, desired_state: BalancerDesiredState, }, } @@ -81,20 +43,17 @@ impl StartBalancerFormData { Action::None } Message::SetBalancerAddress(address) => { - self.balancer_address = address; - self.balancer_address_error = None; + self.balancer_address = AddressField::required_from_user_input(address); Action::None } Message::SetInferenceAddress(address) => { - self.inference_address = address; - self.inference_address_error = None; + self.inference_address = AddressField::required_from_user_input(address); Action::None } Message::SetWebAdminPanelAddress(address) => { - self.web_admin_panel_address = address; - self.web_admin_panel_address_error = None; + self.web_admin_panel_address = AddressField::optional_from_user_input(address); Action::None } @@ -113,51 +72,64 @@ impl StartBalancerFormData { } fn validate_and_confirm(&mut self) -> Action { - self.balancer_address_error = None; - self.inference_address_error = None; - self.web_admin_panel_address_error = None; self.model_error = None; if !self.add_model_later && self.selected_model.is_none() { self.model_error = Some("Please select a model.".to_owned()); } - let management_addr = match validate_required_address(&self.balancer_address) { - Ok(addr) => Some(addr), - Err(message) => { - self.balancer_address_error = Some(message); - None - } - }; - - let inference_addr = match validate_required_address(&self.inference_address) { - Ok(addr) => Some(addr), - Err(message) => { - self.inference_address_error = Some(message); - None - } - }; - - let web_admin_panel_addr = match validate_optional_address(&self.web_admin_panel_address) { - Ok(addr) => addr, - Err(message) => { - self.web_admin_panel_address_error = Some(message); - None - } - }; - - if self.model_error.is_some() - || self.balancer_address_error.is_some() - || self.inference_address_error.is_some() - || self.web_admin_panel_address_error.is_some() - { + let balancer_address = mem::take(&mut self.balancer_address); + let inference_address = mem::take(&mut self.inference_address); + let web_admin_panel_address = mem::take(&mut self.web_admin_panel_address); + + let model_error_present = self.model_error.is_some(); + let any_required_address_invalid = matches!( + balancer_address, + AddressField::Empty | AddressField::Invalid { .. } + ) || matches!( + inference_address, + AddressField::Empty | AddressField::Invalid { .. } + ); + let web_admin_panel_invalid = matches!(web_admin_panel_address, AddressField::Invalid { .. }); + + if model_error_present || any_required_address_invalid || web_admin_panel_invalid { + self.balancer_address = match balancer_address { + AddressField::Empty => AddressField::Invalid { + raw: String::new(), + error: "Address is required.".to_owned(), + }, + other => other, + }; + self.inference_address = match inference_address { + AddressField::Empty => AddressField::Invalid { + raw: String::new(), + error: "Address is required.".to_owned(), + }, + other => other, + }; + self.web_admin_panel_address = web_admin_panel_address; return Action::None; } - let (Some(management_addr), Some(inference_addr)) = (management_addr, inference_addr) + let AddressField::Bound { + port: management_port, + .. + } = balancer_address else { return Action::None; }; + let AddressField::Bound { + port: inference_port, + .. + } = inference_address + else { + return Action::None; + }; + let web_admin_panel_port = match web_admin_panel_address { + AddressField::Bound { port, .. } => Some(port), + AddressField::Empty => None, + AddressField::Invalid { .. } => return Action::None, + }; let desired_state = if self.add_model_later { BalancerDesiredState::default() @@ -171,9 +143,9 @@ impl StartBalancerFormData { self.starting = true; Action::StartBalancer { - management_addr, - inference_addr, - web_admin_panel_addr, + management_port, + inference_port, + web_admin_panel_port, desired_state, } } @@ -181,85 +153,14 @@ impl StartBalancerFormData { #[cfg(test)] mod tests { - use std::net::SocketAddr; - use std::net::TcpListener; - use anyhow::Result; use anyhow::bail; - - use super::PortCheck; - use super::check_port; - use super::validate_optional_address; - use super::validate_required_address; - - const LOOPBACK_ANY_PORT: &str = "127.0.0.1:0"; - const UNASSIGNED_TEST_NET_ADDRESS: &str = "192.0.2.1:0"; - - #[test] - fn reports_in_use_when_port_is_bound() -> Result<()> { - let listener = TcpListener::bind(LOOPBACK_ANY_PORT)?; - let bound_address = listener.local_addr()?; - - match check_port(&bound_address) { - PortCheck::InUse => Ok(()), - PortCheck::Available => bail!("bound port reported as Available"), - PortCheck::BindFailed(error) => { - bail!("bound port reported as BindFailed: {error}") - } - } - } - - #[test] - fn reports_available_when_port_is_free() -> Result<()> { - let listener = TcpListener::bind(LOOPBACK_ANY_PORT)?; - let bound_address = listener.local_addr()?; - - drop(listener); - - match check_port(&bound_address) { - PortCheck::Available => Ok(()), - PortCheck::InUse => bail!("free port reported as InUse"), - PortCheck::BindFailed(error) => { - bail!("free port reported as BindFailed: {error}") - } - } - } - - #[test] - fn reports_bind_failed_for_non_addr_in_use_error() -> Result<()> { - let unassigned_address: SocketAddr = UNASSIGNED_TEST_NET_ADDRESS.parse()?; - - match check_port(&unassigned_address) { - PortCheck::BindFailed(_) => Ok(()), - PortCheck::InUse => { - bail!("non-AddrInUse bind failure reported as InUse") - } - PortCheck::Available => { - bail!("bind should fail against an unassigned address") - } - } - } - - #[test] - fn required_address_rejects_empty_input() -> Result<()> { - match validate_required_address("") { - Err(_) => Ok(()), - Ok(address) => bail!("empty required input should not parse, got {address}"), - } - } - - #[test] - fn optional_address_treats_empty_as_none() -> Result<()> { - match validate_optional_address("") { - Ok(None) => Ok(()), - Ok(Some(address)) => bail!("empty optional input should not parse to {address}"), - Err(error) => bail!("empty optional input should not error: {error}"), - } - } - + use paddler_ports::bind_ephemeral_port::bind_ephemeral_port; + use paddler_ports::bound_port::BoundPort; use paddler_types::agent_desired_model::AgentDesiredModel; use super::Action; + use super::AddressField; use super::Message; use super::StartBalancerFormData; use crate::model_preset::ModelPreset; @@ -267,15 +168,12 @@ mod tests { fn empty_form() -> StartBalancerFormData { StartBalancerFormData { add_model_later: false, - balancer_address: String::new(), - balancer_address_error: None, - inference_address: String::new(), - inference_address_error: None, + balancer_address: AddressField::Empty, + inference_address: AddressField::Empty, model_error: None, selected_model: None, starting: false, - web_admin_panel_address: String::new(), - web_admin_panel_address_error: None, + web_admin_panel_address: AddressField::Empty, web_admin_panel_address_placeholder: String::new(), } } @@ -287,67 +185,82 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("at least one preset must exist")) } - fn loopback_socket_with_free_port() -> Result { - let listener = TcpListener::bind(LOOPBACK_ANY_PORT)?; - let addr = listener.local_addr()?; - drop(listener); - Ok(addr) + fn bound_address_field() -> Result { + let bound: BoundPort = bind_ephemeral_port()?; + Ok(AddressField::Bound { + raw: bound.socket_addr.to_string(), + port: bound, + }) } #[test] - fn selecting_a_model_clears_a_previously_set_model_error() -> Result<()> { + fn set_balancer_address_with_unparseable_input_records_invalid_address() -> Result<()> { let mut data = empty_form(); - data.model_error = Some("stale".to_owned()); - - let _ = data.update(Message::SelectModel(first_preset()?)); - if data.model_error.is_some() { - bail!("expected model_error to be cleared after SelectModel"); - } + let _action = data.update(Message::SetBalancerAddress("not-a-socket-addr".to_owned())); + assert!(matches!( + data.balancer_address, + AddressField::Invalid { .. } + )); Ok(()) } #[test] - fn setting_balancer_address_clears_a_previously_set_balancer_address_error() -> Result<()> { + fn set_balancer_address_with_bindable_input_records_bound_listener() -> Result<()> { + let bound = bind_ephemeral_port()?; + let raw = bound.socket_addr.to_string(); + drop(bound); + let mut data = empty_form(); - data.balancer_address_error = Some("stale".to_owned()); - let _ = data.update(Message::SetBalancerAddress("127.0.0.1:8060".to_owned())); + let _action = data.update(Message::SetBalancerAddress(raw)); - if data.balancer_address_error.is_some() { - bail!("expected balancer_address_error to be cleared"); - } - if data.balancer_address != "127.0.0.1:8060" { - bail!("expected balancer_address to be updated"); - } + assert!(matches!(data.balancer_address, AddressField::Bound { .. })); Ok(()) } #[test] - fn setting_inference_address_clears_a_previously_set_inference_address_error() -> Result<()> { + fn set_balancer_address_with_empty_input_records_empty_state() -> Result<()> { let mut data = empty_form(); - data.inference_address_error = Some("stale".to_owned()); + data.balancer_address = AddressField::Invalid { + raw: "stale".to_owned(), + error: "stale".to_owned(), + }; - let _ = data.update(Message::SetInferenceAddress("127.0.0.1:8061".to_owned())); + let _action = data.update(Message::SetBalancerAddress(String::new())); - if data.inference_address_error.is_some() { - bail!("expected inference_address_error to be cleared"); - } + assert!(matches!(data.balancer_address, AddressField::Empty)); Ok(()) } #[test] - fn setting_web_admin_panel_address_clears_a_previously_set_web_admin_panel_address_error() - -> Result<()> { + fn set_inference_address_with_empty_input_records_empty_state() -> Result<()> { let mut data = empty_form(); - data.web_admin_panel_address_error = Some("stale".to_owned()); + let _action = data.update(Message::SetInferenceAddress(String::new())); + assert!(matches!(data.inference_address, AddressField::Empty)); + Ok(()) + } - let _ = data.update(Message::SetWebAdminPanelAddress("127.0.0.1:8062".to_owned())); + #[test] + fn set_web_admin_panel_address_with_empty_input_records_empty_state() -> Result<()> { + let mut data = empty_form(); + let _action = data.update(Message::SetWebAdminPanelAddress(String::new())); + assert!(matches!(data.web_admin_panel_address, AddressField::Empty)); + Ok(()) + } + + #[test] + fn selecting_a_model_clears_a_previously_set_model_error() -> Result<()> { + let mut data = empty_form(); + data.model_error = Some("stale".to_owned()); - if data.web_admin_panel_address_error.is_some() { - bail!("expected web_admin_panel_address_error to be cleared"); + let _ = data.update(Message::SelectModel(first_preset()?)); + + if data.model_error.is_some() { + bail!("expected model_error to be cleared after SelectModel"); } + Ok(()) } @@ -358,12 +271,8 @@ mod tests { let _ = data.update(Message::ToggleAddModelLater(true)); - if !data.add_model_later { - bail!("expected add_model_later to flip to true"); - } - if data.model_error.is_some() { - bail!("expected model_error to be cleared when toggling on"); - } + assert!(data.add_model_later); + assert!(data.model_error.is_none()); Ok(()) } @@ -375,12 +284,8 @@ mod tests { let _ = data.update(Message::ToggleAddModelLater(false)); - if data.add_model_later { - bail!("expected add_model_later to flip to false"); - } - if data.model_error.as_deref() != Some("preserved") { - bail!("expected model_error to be preserved when toggling off"); - } + assert!(!data.add_model_later); + assert_eq!(data.model_error.as_deref(), Some("preserved")); Ok(()) } @@ -388,81 +293,77 @@ mod tests { fn cancel_message_returns_cancel_action() -> Result<()> { let mut data = empty_form(); - match data.update(Message::Cancel) { - Action::Cancel => Ok(()), - _ => bail!("expected Action::Cancel"), - } + assert!(matches!(data.update(Message::Cancel), Action::Cancel)); + Ok(()) } #[test] fn confirming_without_a_selected_model_records_a_model_required_error() -> Result<()> { let mut data = empty_form(); - let management_addr = loopback_socket_with_free_port()?; - let inference_addr = loopback_socket_with_free_port()?; - data.balancer_address = management_addr.to_string(); - data.inference_address = inference_addr.to_string(); - - match data.update(Message::Confirm) { - Action::None => {} - _ => bail!("expected Action::None when validation fails"), - } + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; - if data.model_error.is_none() { - bail!("expected model_error to be set when no model is selected"); - } + let action = data.update(Message::Confirm); + assert!(matches!(action, Action::None)); + assert!(data.model_error.is_some()); Ok(()) } #[test] - fn confirming_with_an_in_use_balancer_port_records_an_address_error() -> Result<()> { - let bound_listener = TcpListener::bind(LOOPBACK_ANY_PORT)?; - let bound_address = bound_listener.local_addr()?; - + fn confirming_with_empty_balancer_address_records_a_required_error() -> Result<()> { let mut data = empty_form(); - data.balancer_address = bound_address.to_string(); - data.inference_address = loopback_socket_with_free_port()?.to_string(); + data.inference_address = bound_address_field()?; data.selected_model = Some(first_preset()?); - let _ = data.update(Message::Confirm); + let action = data.update(Message::Confirm); - match data.balancer_address_error.as_deref() { - Some(message) if message.contains("already in use") => Ok(()), - other => bail!("expected in-use error, got {other:?}"), - } + assert!(matches!(action, Action::None)); + assert!(matches!( + data.balancer_address, + AddressField::Invalid { .. } + )); + Ok(()) } #[test] - fn confirming_with_an_unparseable_inference_address_records_an_inference_error() -> Result<()> { + fn confirming_with_invalid_inference_address_keeps_the_invalid_state() -> Result<()> { let mut data = empty_form(); - data.balancer_address = loopback_socket_with_free_port()?.to_string(); - data.inference_address = "not-a-socket-addr".to_owned(); + data.balancer_address = bound_address_field()?; + data.inference_address = AddressField::Invalid { + raw: "not-a-socket".to_owned(), + error: "Invalid address".to_owned(), + }; data.selected_model = Some(first_preset()?); - let _ = data.update(Message::Confirm); - - if data.inference_address_error.is_none() { - bail!("expected inference_address_error to be set for unparseable input"); - } + let action = data.update(Message::Confirm); + assert!(matches!(action, Action::None)); + assert!(matches!( + data.inference_address, + AddressField::Invalid { .. } + )); Ok(()) } #[test] - fn confirming_with_an_unparseable_web_admin_panel_address_records_a_web_admin_error() - -> Result<()> { + fn confirming_with_invalid_web_admin_panel_address_keeps_the_invalid_state() -> Result<()> { let mut data = empty_form(); - data.balancer_address = loopback_socket_with_free_port()?.to_string(); - data.inference_address = loopback_socket_with_free_port()?.to_string(); - data.web_admin_panel_address = "not-a-socket-addr".to_owned(); + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; + data.web_admin_panel_address = AddressField::Invalid { + raw: "not-a-socket".to_owned(), + error: "Invalid address".to_owned(), + }; data.selected_model = Some(first_preset()?); - let _ = data.update(Message::Confirm); - - if data.web_admin_panel_address_error.is_none() { - bail!("expected web_admin_panel_address_error to be set for unparseable input"); - } + let action = data.update(Message::Confirm); + assert!(matches!(action, Action::None)); + assert!(matches!( + data.web_admin_panel_address, + AddressField::Invalid { .. } + )); Ok(()) } @@ -470,19 +371,17 @@ mod tests { fn confirming_with_valid_input_and_selected_model_returns_start_balancer_action() -> Result<()> { let mut data = empty_form(); - data.balancer_address = loopback_socket_with_free_port()?.to_string(); - data.inference_address = loopback_socket_with_free_port()?.to_string(); + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; data.selected_model = Some(first_preset()?); - match data.update(Message::Confirm) { + let action = data.update(Message::Confirm); + + match action { Action::StartBalancer { desired_state, .. } => { - if !data.starting { - bail!("expected starting flag to be set after StartBalancer action"); - } - match desired_state.model { - AgentDesiredModel::HuggingFace(_) => Ok(()), - other => bail!("expected HuggingFace model from preset, got {other:?}"), - } + assert!(data.starting); + assert!(matches!(desired_state.model, AgentDesiredModel::HuggingFace(_))); + Ok(()) } _ => bail!("expected Action::StartBalancer for valid input with preset"), } @@ -492,18 +391,40 @@ mod tests { fn confirming_with_add_model_later_returns_start_balancer_with_default_desired_state() -> Result<()> { let mut data = empty_form(); - data.balancer_address = loopback_socket_with_free_port()?.to_string(); - data.inference_address = loopback_socket_with_free_port()?.to_string(); + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; data.add_model_later = true; - match data.update(Message::Confirm) { + let action = data.update(Message::Confirm); + + match action { Action::StartBalancer { desired_state, .. } => { - match desired_state.model { - AgentDesiredModel::None => Ok(()), - other => bail!("expected default (None) model, got {other:?}"), - } + assert!(matches!(desired_state.model, AgentDesiredModel::None)); + Ok(()) } _ => bail!("expected Action::StartBalancer for valid input with add_model_later"), } } + + #[test] + fn confirming_with_valid_web_admin_panel_address_carries_a_bound_port() -> Result<()> { + let mut data = empty_form(); + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; + data.web_admin_panel_address = bound_address_field()?; + data.selected_model = Some(first_preset()?); + + let action = data.update(Message::Confirm); + + match action { + Action::StartBalancer { + web_admin_panel_port, + .. + } => { + assert!(web_admin_panel_port.is_some()); + Ok(()) + } + _ => bail!("expected Action::StartBalancer"), + } + } } diff --git a/paddler_gui/src/ui/view_join_balancer_form.rs b/paddler_gui/src/ui/view_join_balancer_form.rs index 5b6ccd47..c3e0c22f 100644 --- a/paddler_gui/src/ui/view_join_balancer_form.rs +++ b/paddler_gui/src/ui/view_join_balancer_form.rs @@ -30,7 +30,7 @@ pub fn view_join_balancer_form(data: &JoinBalancerFormData) -> Element<'_, Messa .style(button::text) .on_press(Message::Cancel); - let balancer_address_input = text_input("IP:port", &data.balancer_address) + let balancer_address_input = text_input("IP:port", data.balancer_address.raw_text()) .on_input(Message::SetBalancerAddress) .padding(SPACING_BASE) .style(style_field_text_input) @@ -42,12 +42,15 @@ pub fn view_join_balancer_form(data: &JoinBalancerFormData) -> Element<'_, Messa .style(style_field_text_input) .into(); - let slots_input = text_input("e.g. 1", &data.slots_count) + let slots_input = text_input("e.g. 1", data.slots_count.raw_text()) .on_input(Message::SetSlotsCount) .padding(SPACING_BASE) .style(style_field_text_input) .into(); + let balancer_address_error = data.balancer_address.error_text().map(str::to_owned); + let slots_error = data.slots_count.error_text().map(str::to_owned); + column![ container(text("Join a cluster").size(FONT_SIZE_L2).font(BOLD)) .padding([0.0, SPACING_BASE]), @@ -56,10 +59,10 @@ pub fn view_join_balancer_form(data: &JoinBalancerFormData) -> Element<'_, Messa view_form_field( "Cluster address", balancer_address_input, - data.balancer_address_error.as_ref() + balancer_address_error.as_ref() ), view_form_field("Agent name (optional)", agent_name_input, None), - view_form_field("Slots", slots_input, data.slots_error.as_ref()), + view_form_field("Slots", slots_input, slots_error.as_ref()), container( row![cancel_button, confirm_button] .align_y(Center) diff --git a/paddler_gui/src/ui/view_start_balancer_form.rs b/paddler_gui/src/ui/view_start_balancer_form.rs index 8d400a02..69e1666d 100644 --- a/paddler_gui/src/ui/view_start_balancer_form.rs +++ b/paddler_gui/src/ui/view_start_balancer_form.rs @@ -48,13 +48,13 @@ pub fn view_start_balancer_form(data: &StartBalancerFormData) -> Element<'_, Mes .style(button::text) .on_press(Message::Cancel); - let balancer_address_input = text_input("IP:port", &data.balancer_address) + let balancer_address_input = text_input("IP:port", data.balancer_address.raw_text()) .on_input(Message::SetBalancerAddress) .padding(SPACING_BASE) .style(style_field_text_input) .into(); - let inference_address_input = text_input("IP:port", &data.inference_address) + let inference_address_input = text_input("IP:port", data.inference_address.raw_text()) .on_input(Message::SetInferenceAddress) .padding(SPACING_BASE) .style(style_field_text_input) @@ -62,13 +62,17 @@ pub fn view_start_balancer_form(data: &StartBalancerFormData) -> Element<'_, Mes let web_admin_panel_address_input = text_input( &data.web_admin_panel_address_placeholder, - &data.web_admin_panel_address, + data.web_admin_panel_address.raw_text(), ) .on_input(Message::SetWebAdminPanelAddress) .padding(SPACING_BASE) .style(style_field_text_input) .into(); + let balancer_address_error = data.balancer_address.error_text().map(str::to_owned); + let inference_address_error = data.inference_address.error_text().map(str::to_owned); + let web_admin_panel_address_error = data.web_admin_panel_address.error_text().map(str::to_owned); + let model_input: Element<'_, Message> = if data.add_model_later { text_input("Model will be added later", "") .padding(SPACING_BASE) @@ -119,17 +123,17 @@ pub fn view_start_balancer_form(data: &StartBalancerFormData) -> Element<'_, Mes view_form_field( "Cluster address", balancer_address_input, - data.balancer_address_error.as_ref() + balancer_address_error.as_ref() ), view_form_field( "Inference address", inference_address_input, - data.inference_address_error.as_ref() + inference_address_error.as_ref() ), view_form_field( "Web admin panel (optional)", web_admin_panel_address_input, - data.web_admin_panel_address_error.as_ref() + web_admin_panel_address_error.as_ref() ), model_field, row![cancel_button, confirm_button] diff --git a/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs b/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs index d8b9a621..6f2d4446 100644 --- a/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs +++ b/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs @@ -1,14 +1,22 @@ use anyhow::Result; use anyhow::bail; use iced_test::simulator; +use paddler_gui::address_field::AddressField; use paddler_gui::join_balancer_form_data::JoinBalancerFormData; +use paddler_gui::slot_count_field::SlotCountField; use paddler_gui::ui::view_join_balancer_form::view_join_balancer_form; #[test] fn the_cluster_address_and_slots_errors_render_under_their_inputs_when_set() -> Result<()> { let data = JoinBalancerFormData { - balancer_address_error: Some("Cluster address is required.".to_owned()), - slots_error: Some("Number of slots is required.".to_owned()), + balancer_address: AddressField::Invalid { + raw: String::new(), + error: "Cluster address is required.".to_owned(), + }, + slots_count: SlotCountField::Invalid { + raw: String::new(), + error: "Number of slots is required.".to_owned(), + }, ..JoinBalancerFormData::default() }; let mut simulator = simulator(view_join_balancer_form(&data)); diff --git a/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs b/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs index 963bf08d..2bac7cbb 100644 --- a/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs +++ b/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs @@ -1,6 +1,7 @@ use anyhow::Result; use anyhow::bail; use iced_test::simulator; +use paddler_gui::address_field::AddressField; use paddler_gui::start_balancer_form_data::StartBalancerFormData; use paddler_gui::start_balancer_form_handler::Message; use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; @@ -8,15 +9,12 @@ use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; fn empty_form() -> StartBalancerFormData { StartBalancerFormData { add_model_later: false, - balancer_address: String::new(), - balancer_address_error: None, - inference_address: String::new(), - inference_address_error: None, + balancer_address: AddressField::Empty, + inference_address: AddressField::Empty, model_error: None, selected_model: None, starting: false, - web_admin_panel_address: String::new(), - web_admin_panel_address_error: None, + web_admin_panel_address: AddressField::Empty, web_admin_panel_address_placeholder: String::new(), } } diff --git a/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs b/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs index 041c7c27..fac8b85d 100644 --- a/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs +++ b/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs @@ -1,21 +1,19 @@ use anyhow::Result; use anyhow::bail; use iced_test::simulator; +use paddler_gui::address_field::AddressField; use paddler_gui::start_balancer_form_data::StartBalancerFormData; use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; fn empty_form() -> StartBalancerFormData { StartBalancerFormData { add_model_later: false, - balancer_address: String::new(), - balancer_address_error: None, - inference_address: String::new(), - inference_address_error: None, + balancer_address: AddressField::Empty, + inference_address: AddressField::Empty, model_error: None, selected_model: None, starting: false, - web_admin_panel_address: String::new(), - web_admin_panel_address_error: None, + web_admin_panel_address: AddressField::Empty, web_admin_panel_address_placeholder: String::new(), } } diff --git a/paddler_gui_tests/tests/starting_a_balancer_shows_validation_errors_to_the_user.rs b/paddler_gui_tests/tests/starting_a_balancer_shows_validation_errors_to_the_user.rs index 4f2d4b73..01b16f06 100644 --- a/paddler_gui_tests/tests/starting_a_balancer_shows_validation_errors_to_the_user.rs +++ b/paddler_gui_tests/tests/starting_a_balancer_shows_validation_errors_to_the_user.rs @@ -1,6 +1,7 @@ use anyhow::Result; use anyhow::bail; use iced_test::simulator; +use paddler_gui::address_field::AddressField; use paddler_gui::start_balancer_form_data::StartBalancerFormData; use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; @@ -8,15 +9,21 @@ use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; fn each_address_field_renders_its_own_error_below_the_input_when_set() -> Result<()> { let data = StartBalancerFormData { add_model_later: false, - balancer_address: String::new(), - balancer_address_error: Some("Address is required.".to_owned()), - inference_address: String::new(), - inference_address_error: Some("Invalid inference address.".to_owned()), + balancer_address: AddressField::Invalid { + raw: String::new(), + error: "Address is required.".to_owned(), + }, + inference_address: AddressField::Invalid { + raw: String::new(), + error: "Invalid inference address.".to_owned(), + }, model_error: Some("Please select a model.".to_owned()), selected_model: None, starting: false, - web_admin_panel_address: String::new(), - web_admin_panel_address_error: Some("Invalid web admin address.".to_owned()), + web_admin_panel_address: AddressField::Invalid { + raw: String::new(), + error: "Invalid web admin address.".to_owned(), + }, web_admin_panel_address_placeholder: String::new(), }; let mut simulator = simulator(view_start_balancer_form(&data)); From 3318f74595175fc91f1e2240c5a6797380d38040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 16 May 2026 17:53:36 +0200 Subject: [PATCH 05/13] replace match...bail! with assert!(matches!()) in inline tests to remove unreachable arms --- paddler_gui/src/agent_running_data.rs | 55 ++-- paddler_gui/src/agent_running_handler.rs | 24 +- paddler_gui/src/app.rs | 273 ++++++++++-------- paddler_gui/src/current_screen.rs | 16 +- paddler_gui/src/detect_network_interfaces.rs | 18 +- paddler_gui/src/home_handler.rs | 23 +- paddler_gui/src/join_balancer_form_handler.rs | 68 ++--- paddler_gui/src/lib.rs | 24 +- paddler_gui/src/model_preset.rs | 37 ++- paddler_gui/src/running_balancer_handler.rs | 54 ++-- paddler_gui/src/screen.rs | 108 +++---- .../src/start_balancer_form_handler.rs | 52 ++-- paddler_gui/src/ui/agent_status_label.rs | 33 +-- paddler_gui/src/ui/display_last_path_part.rs | 17 +- paddler_gui/src/ui/format_desired_model.rs | 19 +- paddler_gui/src/ui/style_agent_container.rs | 18 +- paddler_gui/src/ui/style_button_disconnect.rs | 11 +- paddler_gui/src/ui/style_button_primary.rs | 17 +- paddler_gui/src/ui/style_card_container.rs | 9 +- .../src/ui/style_download_progress_bar.rs | 11 +- paddler_gui/src/ui/style_field_checkbox.rs | 21 +- paddler_gui/src/ui/style_field_container.rs | 17 +- paddler_gui/src/ui/style_field_pick_list.rs | 8 +- .../src/ui/style_field_pick_list_menu.rs | 8 +- paddler_gui/src/ui/style_field_text_input.rs | 16 +- paddler_gui/src/ui/style_status_indicator.rs | 18 +- .../src/ui/view_start_balancer_form.rs | 3 +- 27 files changed, 495 insertions(+), 483 deletions(-) diff --git a/paddler_gui/src/agent_running_data.rs b/paddler_gui/src/agent_running_data.rs index 6e28ddcd..5195ea2f 100644 --- a/paddler_gui/src/agent_running_data.rs +++ b/paddler_gui/src/agent_running_data.rs @@ -32,7 +32,6 @@ mod tests { use std::collections::BTreeSet; use anyhow::Result; - use anyhow::bail; use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use paddler_types::agent_state_application_status::AgentStateApplicationStatus; use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; @@ -77,33 +76,33 @@ mod tests { data.apply_status(status); - if !data.connected { - bail!("expected connected to flip to true"); - } - - if data.snapshot.name.as_deref() != Some("agent-fixture") { - bail!("expected existing name to be preserved"); - } - - if !data.snapshot.id.is_empty() { - bail!("expected id to be cleared by apply_status"); - } - - if data.snapshot.desired_slots_total != 6 { - bail!("expected desired_slots_total to be copied from status"); - } - - if data.snapshot.slots_processing != 2 { - bail!("expected slots_processing to be copied from status"); - } - - if data.snapshot.model_path.as_deref() != Some("/models/model.gguf") { - bail!("expected model_path to be copied from status"); - } - - if !data.snapshot.uses_chat_template_override { - bail!("expected uses_chat_template_override to be copied from status"); - } + assert!(data.connected, "expected connected to flip to true"); + assert_eq!( + data.snapshot.name.as_deref(), + Some("agent-fixture"), + "expected existing name to be preserved" + ); + assert!( + data.snapshot.id.is_empty(), + "expected id to be cleared by apply_status" + ); + assert_eq!( + data.snapshot.desired_slots_total, 6, + "expected desired_slots_total to be copied from status" + ); + assert_eq!( + data.snapshot.slots_processing, 2, + "expected slots_processing to be copied from status" + ); + assert_eq!( + data.snapshot.model_path.as_deref(), + Some("/models/model.gguf"), + "expected model_path to be copied from status" + ); + assert!( + data.snapshot.uses_chat_template_override, + "expected uses_chat_template_override to be copied from status" + ); Ok(()) } diff --git a/paddler_gui/src/agent_running_handler.rs b/paddler_gui/src/agent_running_handler.rs index 2524065b..18845704 100644 --- a/paddler_gui/src/agent_running_handler.rs +++ b/paddler_gui/src/agent_running_handler.rs @@ -31,7 +31,6 @@ mod tests { use std::collections::BTreeSet; use anyhow::Result; - use anyhow::bail; use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use paddler_types::agent_state_application_status::AgentStateApplicationStatus; use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; @@ -83,14 +82,11 @@ mod tests { let action = data.update(Message::AgentStatusUpdated(applied_status())); - match action { - Action::None => {} - Action::Disconnect => bail!("expected Action::None"), - } - - if !data.connected { - bail!("expected connected flag to flip to true after status update"); - } + assert!(matches!(action, Action::None)); + assert!( + data.connected, + "expected connected flag to flip to true after status update" + ); Ok(()) } @@ -99,9 +95,11 @@ mod tests { fn disconnect_message_returns_disconnect_action() -> Result<()> { let mut data = fresh_running_data(); - match data.update(Message::Disconnect) { - Action::Disconnect => Ok(()), - Action::None => bail!("expected Action::Disconnect"), - } + assert!(matches!( + data.update(Message::Disconnect), + Action::Disconnect + )); + + Ok(()) } } diff --git a/paddler_gui/src/app.rs b/paddler_gui/src/app.rs index bf66df98..368fbba2 100644 --- a/paddler_gui/src/app.rs +++ b/paddler_gui/src/app.rs @@ -485,7 +485,6 @@ impl App { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use super::*; use crate::agent_running_data::AgentRunningData; @@ -558,7 +557,9 @@ mod tests { fn screen_join_form(form: JoinBalancerFormData) -> CurrentScreen { CurrentScreen::JoinBalancerForm( - Screen::::builder().state_data(form).build(), + Screen::::builder() + .state_data(form) + .build(), ) } @@ -572,14 +573,14 @@ mod tests { fn screen_running(data: RunningBalancerData) -> CurrentScreen { CurrentScreen::RunningBalancer( - Screen::::builder().state_data(data).build(), + Screen::::builder() + .state_data(data) + .build(), ) } fn screen_agent_running(data: AgentRunningData) -> CurrentScreen { - CurrentScreen::AgentRunning( - Screen::::builder().state_data(data).build(), - ) + CurrentScreen::AgentRunning(Screen::::builder().state_data(data).build()) } fn bound_address_field() -> Result { @@ -602,10 +603,11 @@ mod tests { } fn assert_screen_is_home(app: &App) -> Result<()> { - match app.current_screen_for_test() { - CurrentScreen::Home(_) => Ok(()), - _ => bail!("expected Home screen"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::Home(_) + )); + Ok(()) } #[test] @@ -613,15 +615,17 @@ mod tests { let (mut app, _initial_task) = App::new(); let shutdown = app.shutdown_token_for_test(); - if shutdown.is_cancelled() { - bail!("expected shutdown token to start uncancelled"); - } + assert!( + !shutdown.is_cancelled(), + "expected shutdown token to start uncancelled" + ); let _exit_task = app.update(Message::Quit); - if !shutdown.is_cancelled() { - bail!("expected Quit to cancel shutdown token"); - } + assert!( + shutdown.is_cancelled(), + "expected Quit to cancel shutdown token" + ); Ok(()) } @@ -631,12 +635,14 @@ mod tests { let _exit_task = app.update(Message::Quit); - if app.agent_cancel_for_test().is_some() { - bail!("expected Quit to drop agent_cancel"); - } - if app.balancer_cancel_for_test().is_some() { - bail!("expected Quit to drop balancer_cancel"); - } + assert!( + app.agent_cancel_for_test().is_none(), + "expected Quit to drop agent_cancel" + ); + assert!( + app.balancer_cancel_for_test().is_none(), + "expected Quit to drop balancer_cancel" + ); Ok(()) } @@ -653,10 +659,11 @@ mod tests { let _ = app.update(Message::Home(home_handler::Message::StartBalancer)); - match app.current_screen_for_test() { - CurrentScreen::StartBalancerForm(_) => Ok(()), - _ => bail!("expected StartBalancerForm screen"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::StartBalancerForm(_) + )); + Ok(()) } #[test] @@ -665,10 +672,11 @@ mod tests { let _ = app.update(Message::Home(home_handler::Message::JoinBalancer)); - match app.current_screen_for_test() { - CurrentScreen::JoinBalancerForm(_) => Ok(()), - _ => bail!("expected JoinBalancerForm screen"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::JoinBalancerForm(_) + )); + Ok(()) } #[test] @@ -679,10 +687,11 @@ mod tests { join_balancer_form_handler::Message::SetAgentName("alice".to_owned()), )); - match app.current_screen_for_test() { - CurrentScreen::JoinBalancerForm(_) => Ok(()), - _ => bail!("expected to stay on JoinBalancerForm"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::JoinBalancerForm(_) + )); + Ok(()) } #[test] @@ -705,14 +714,14 @@ mod tests { join_balancer_form_handler::Message::Connect, )); - match app.current_screen_for_test() { - CurrentScreen::AgentRunning(_) => {} - _ => bail!("expected AgentRunning screen"), - } - - if app.agent_cancel_for_test().is_none() { - bail!("expected agent_cancel token to be set"); - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::AgentRunning(_) + )); + assert!( + app.agent_cancel_for_test().is_some(), + "expected agent_cancel token to be set" + ); // Stop the spawned task immediately so the test does not leave a runner. if let Some(token) = app.agent_cancel_for_test() { @@ -730,10 +739,11 @@ mod tests { start_balancer_form_handler::Message::SetBalancerAddress("127.0.0.1:0".to_owned()), )); - match app.current_screen_for_test() { - CurrentScreen::StartBalancerForm(_) => Ok(()), - _ => bail!("expected to stay on StartBalancerForm"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::StartBalancerForm(_) + )); + Ok(()) } #[test] @@ -746,9 +756,10 @@ mod tests { start_balancer_form_handler::Message::Cancel, )); - if !token.is_cancelled() { - bail!("expected balancer_cancel token to be cancelled"); - } + assert!( + token.is_cancelled(), + "expected balancer_cancel token to be cancelled" + ); assert_screen_is_home(&app) } @@ -768,14 +779,14 @@ mod tests { start_balancer_form_handler::Message::Confirm, )); - match app.current_screen_for_test() { - CurrentScreen::StartBalancerForm(_) => {} - _ => bail!("expected to stay on StartBalancerForm during spawn"), - } - - if app.balancer_cancel_for_test().is_none() { - bail!("expected balancer_cancel token to be set after Confirm"); - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::StartBalancerForm(_) + )); + assert!( + app.balancer_cancel_for_test().is_some(), + "expected balancer_cancel token to be set after Confirm" + ); if let Some(token) = app.balancer_cancel_for_test() { token.cancel(); @@ -790,10 +801,11 @@ mod tests { let _ = app.update(Message::BalancerStarted); - match app.current_screen_for_test() { - CurrentScreen::RunningBalancer(_) => Ok(()), - _ => bail!("expected RunningBalancer screen"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::RunningBalancer(_) + )); + Ok(()) } #[test] @@ -803,18 +815,15 @@ mod tests { let _ = app.update(Message::BalancerFailed("bind error".to_owned())); - match app.current_screen_for_test() { - CurrentScreen::Home(home) => { - if home.state_data.error.as_deref() != Some("bind error") { - bail!("expected home error to carry the failure message"); - } - if app.balancer_cancel_for_test().is_some() { - bail!("expected balancer_cancel to be dropped"); - } - Ok(()) - } - _ => bail!("expected Home screen"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::Home(home) if home.state_data.error.as_deref() == Some("bind error") + )); + assert!( + app.balancer_cancel_for_test().is_none(), + "expected balancer_cancel to be dropped" + ); + Ok(()) } #[test] @@ -827,10 +836,11 @@ mod tests { )), )); - match app.current_screen_for_test() { - CurrentScreen::RunningBalancer(_) => Ok(()), - _ => bail!("expected to stay on RunningBalancer"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::RunningBalancer(_) + )); + Ok(()) } #[test] @@ -843,14 +853,15 @@ mod tests { running_balancer_handler::Message::Stop, )); - if !token.is_cancelled() { - bail!("expected Stop to cancel balancer_cancel token"); - } - - match app.current_screen_for_test() { - CurrentScreen::RunningBalancer(_) => Ok(()), - _ => bail!("expected to stay on RunningBalancer"), - } + assert!( + token.is_cancelled(), + "expected Stop to cancel balancer_cancel token" + ); + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::RunningBalancer(_) + )); + Ok(()) } #[test] @@ -861,25 +872,27 @@ mod tests { running_balancer_handler::Message::CopyToClipboard("text".to_owned()), )); - match app.current_screen_for_test() { - CurrentScreen::RunningBalancer(_) => Ok(()), - _ => bail!("expected to stay on RunningBalancer"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::RunningBalancer(_) + )); + Ok(()) } #[test] - fn running_balancer_open_url_with_invalid_url_logs_error_but_keeps_user_on_screen() - -> Result<()> { + fn running_balancer_open_url_with_invalid_url_logs_error_but_keeps_user_on_screen() -> Result<()> + { let mut app = app_with_screen(screen_running(fresh_running_data())); let _ = app.update(Message::RunningBalancer( running_balancer_handler::Message::OpenUrl("not-a-real-scheme://broken".to_owned()), )); - match app.current_screen_for_test() { - CurrentScreen::RunningBalancer(_) => Ok(()), - _ => bail!("expected to stay on RunningBalancer"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::RunningBalancer(_) + )); + Ok(()) } #[test] @@ -889,27 +902,26 @@ mod tests { let _ = app.update(Message::BalancerStopped); - if app.balancer_cancel_for_test().is_some() { - bail!("expected balancer_cancel to be dropped"); - } + assert!( + app.balancer_cancel_for_test().is_none(), + "expected balancer_cancel to be dropped" + ); assert_screen_is_home(&app) } #[test] - fn balancer_failed_message_from_running_balancer_returns_user_to_home_with_error() - -> Result<()> { + fn balancer_failed_message_from_running_balancer_returns_user_to_home_with_error() -> Result<()> + { let mut app = app_with_screen(screen_running(fresh_running_data())); let _ = app.update(Message::BalancerFailed("crash".to_owned())); - match app.current_screen_for_test() { - CurrentScreen::Home(home) => match home.state_data.error.as_deref() { - Some("crash") => Ok(()), - _ => bail!("expected error message to be carried over"), - }, - _ => bail!("expected Home screen"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::Home(home) if home.state_data.error.as_deref() == Some("crash") + )); + Ok(()) } #[test] @@ -937,10 +949,11 @@ mod tests { }), )); - match app.current_screen_for_test() { - CurrentScreen::AgentRunning(_) => Ok(()), - _ => bail!("expected to stay on AgentRunning"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::AgentRunning(_) + )); + Ok(()) } #[test] @@ -953,9 +966,10 @@ mod tests { agent_running_handler::Message::Disconnect, )); - if !token.is_cancelled() { - bail!("expected Disconnect to cancel agent_cancel token"); - } + assert!( + token.is_cancelled(), + "expected Disconnect to cancel agent_cancel token" + ); assert_screen_is_home(&app) } @@ -967,9 +981,10 @@ mod tests { let _ = app.update(Message::AgentStopped); - if app.agent_cancel_for_test().is_some() { - bail!("expected agent_cancel to be dropped on AgentStopped"); - } + assert!( + app.agent_cancel_for_test().is_none(), + "expected agent_cancel to be dropped on AgentStopped" + ); assert_screen_is_home(&app) } @@ -980,13 +995,11 @@ mod tests { let _ = app.update(Message::AgentFailed("agent failure".to_owned())); - match app.current_screen_for_test() { - CurrentScreen::Home(home) => match home.state_data.error.as_deref() { - Some("agent failure") => Ok(()), - _ => bail!("expected agent failure message on home"), - }, - _ => bail!("expected Home screen"), - } + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::Home(home) if home.state_data.error.as_deref() == Some("agent failure") + )); + Ok(()) } #[test] @@ -1063,11 +1076,15 @@ mod tests { start_balancer_form_handler::Message::Confirm, )); - let Some(token) = app.balancer_cancel_for_test() else { - bail!("expected balancer_cancel token to be set after Confirm with web admin address"); - }; + let token = app.balancer_cancel_for_test(); + assert!( + token.is_some(), + "expected balancer_cancel token to be set after Confirm with web admin address" + ); - token.cancel(); + if let Some(token) = token { + token.cancel(); + } Ok(()) } diff --git a/paddler_gui/src/current_screen.rs b/paddler_gui/src/current_screen.rs index d495aa8b..e5eff3a2 100644 --- a/paddler_gui/src/current_screen.rs +++ b/paddler_gui/src/current_screen.rs @@ -28,7 +28,6 @@ impl Default for CurrentScreen { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use super::CurrentScreen; @@ -36,14 +35,11 @@ mod tests { fn default_current_screen_is_home_with_no_error() -> Result<()> { let screen = CurrentScreen::default(); - match screen { - CurrentScreen::Home(home) => { - if home.state_data.error.is_some() { - bail!("expected default home to carry no error"); - } - Ok(()) - } - _ => bail!("expected default to be Home"), - } + assert!(matches!( + screen, + CurrentScreen::Home(home) if home.state_data.error.is_none() + )); + + Ok(()) } } diff --git a/paddler_gui/src/detect_network_interfaces.rs b/paddler_gui/src/detect_network_interfaces.rs index 085e11e8..541d65ba 100644 --- a/paddler_gui/src/detect_network_interfaces.rs +++ b/paddler_gui/src/detect_network_interfaces.rs @@ -28,20 +28,22 @@ pub fn detect_network_interfaces() -> Vec { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use super::detect_network_interfaces; #[test] fn detected_addresses_are_ipv4_and_not_loopback() -> Result<()> { for address in detect_network_interfaces() { - if !address.ip_address.is_ipv4() { - bail!("expected only ipv4 addresses, got {}", address.ip_address); - } - - if address.ip_address.is_loopback() { - bail!("expected loopback to be filtered, got {}", address.ip_address); - } + assert!( + address.ip_address.is_ipv4(), + "expected only ipv4 addresses, got {}", + address.ip_address + ); + assert!( + !address.ip_address.is_loopback(), + "expected loopback to be filtered, got {}", + address.ip_address + ); } Ok(()) diff --git a/paddler_gui/src/home_handler.rs b/paddler_gui/src/home_handler.rs index 77a9cb55..f94a367b 100644 --- a/paddler_gui/src/home_handler.rs +++ b/paddler_gui/src/home_handler.rs @@ -22,26 +22,23 @@ impl HomeData { #[cfg(test)] mod tests { - use anyhow::Result; - use anyhow::bail; - use super::Action; use super::HomeData; use super::Message; #[test] - fn start_balancer_message_dispatches_to_start_balancer_action() -> Result<()> { - match HomeData::update(Message::StartBalancer) { - Action::StartBalancer => Ok(()), - Action::JoinBalancer => bail!("expected StartBalancer action"), - } + fn start_balancer_message_dispatches_to_start_balancer_action() { + assert!(matches!( + HomeData::update(Message::StartBalancer), + Action::StartBalancer + )); } #[test] - fn join_balancer_message_dispatches_to_join_balancer_action() -> Result<()> { - match HomeData::update(Message::JoinBalancer) { - Action::JoinBalancer => Ok(()), - Action::StartBalancer => bail!("expected JoinBalancer action"), - } + fn join_balancer_message_dispatches_to_join_balancer_action() { + assert!(matches!( + HomeData::update(Message::JoinBalancer), + Action::JoinBalancer + )); } } diff --git a/paddler_gui/src/join_balancer_form_handler.rs b/paddler_gui/src/join_balancer_form_handler.rs index 75ac07a9..12330ac8 100644 --- a/paddler_gui/src/join_balancer_form_handler.rs +++ b/paddler_gui/src/join_balancer_form_handler.rs @@ -74,7 +74,11 @@ impl JoinBalancerFormData { return Action::None; } - let AddressField::Bound { raw: address_raw, port: _ } = required_balancer_address else { + let AddressField::Bound { + raw: address_raw, + port: _, + } = required_balancer_address + else { return Action::None; }; let SlotCountField::Valid { value: slots, .. } = required_slots_count else { @@ -122,9 +126,7 @@ mod tests { fn set_balancer_address_with_unparseable_input_records_invalid_state() -> Result<()> { let mut data = JoinBalancerFormData::default(); - let _ = data.update(Message::SetBalancerAddress( - "not-a-socket-addr".to_owned(), - )); + let _ = data.update(Message::SetBalancerAddress("not-a-socket-addr".to_owned())); assert!(matches!( data.balancer_address, @@ -212,14 +214,10 @@ mod tests { let action = data.update(Message::Connect); assert!(matches!(action, Action::None)); - match &data.balancer_address { - AddressField::Invalid { error, .. } => assert!(error.contains("required")), - other => panic!("expected required-error invalid state, got {other:?}", other = match other { - AddressField::Empty => "Empty", - AddressField::Bound { .. } => "Bound", - AddressField::Invalid { .. } => "Invalid", - }), - } + assert!(matches!( + &data.balancer_address, + AddressField::Invalid { error, .. } if error.contains("required") + )); Ok(()) } @@ -238,10 +236,10 @@ mod tests { let action = data.update(Message::Connect); assert!(matches!(action, Action::None)); - match &data.slots_count { - SlotCountField::Invalid { error, .. } => assert!(error.contains("required")), - _ => anyhow::bail!("expected required-error invalid state"), - } + assert!(matches!( + &data.slots_count, + SlotCountField::Invalid { error, .. } if error.contains("required") + )); Ok(()) } @@ -261,12 +259,10 @@ mod tests { let action = data.update(Message::Connect); assert!(matches!(action, Action::None)); - match &data.slots_count { - SlotCountField::Invalid { error, .. } => { - assert!(error.contains("greater than zero")); - } - _ => anyhow::bail!("expected zero-slot error"), - } + assert!(matches!( + &data.slots_count, + SlotCountField::Invalid { error, .. } if error.contains("greater than zero") + )); Ok(()) } @@ -290,18 +286,14 @@ mod tests { let action = data.update(Message::Connect); - match action { + assert!(matches!( + action, Action::ConnectAgent { - agent_name, - management_address, - slots, - } => { - assert!(agent_name.is_none()); - assert_eq!(management_address, raw_address); - assert_eq!(slots, 4); - } - _ => anyhow::bail!("expected Action::ConnectAgent"), - } + agent_name: None, + ref management_address, + slots: 4, + } if management_address == &raw_address + )); Ok(()) } @@ -324,12 +316,10 @@ mod tests { let action = data.update(Message::Connect); - match action { - Action::ConnectAgent { agent_name, .. } => { - assert_eq!(agent_name.as_deref(), Some("primary")); - } - _ => anyhow::bail!("expected Action::ConnectAgent"), - } + assert!(matches!( + action, + Action::ConnectAgent { ref agent_name, .. } if agent_name.as_deref() == Some("primary") + )); Ok(()) } diff --git a/paddler_gui/src/lib.rs b/paddler_gui/src/lib.rs index 70de6d23..fd18f268 100644 --- a/paddler_gui/src/lib.rs +++ b/paddler_gui/src/lib.rs @@ -82,7 +82,6 @@ pub fn run() -> iced::Result { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use clap::Parser as _; use super::Cli; @@ -100,9 +99,10 @@ mod tests { fn cli_without_subcommand_parses_as_default_launch_intent() -> Result<()> { let cli = Cli::try_parse_from(["paddler_gui"])?; - if cli.command.is_some() { - bail!("expected no subcommand to leave Cli.command as None"); - } + assert!( + cli.command.is_none(), + "expected no subcommand to leave Cli.command as None" + ); Ok(()) } @@ -111,17 +111,17 @@ mod tests { fn cli_with_launch_subcommand_parses_into_launch_variant() -> Result<()> { let cli = Cli::try_parse_from(["paddler_gui", "launch"])?; - match cli.command { - Some(Commands::Launch) => Ok(()), - other => bail!("expected Some(Commands::Launch), got {other:?}"), - } + assert!(matches!(cli.command, Some(Commands::Launch))); + + Ok(()) } #[test] fn cli_rejects_unknown_subcommands() -> Result<()> { - match Cli::try_parse_from(["paddler_gui", "bogus"]) { - Err(_) => Ok(()), - Ok(cli) => bail!("expected error for unknown subcommand, got {cli:?}"), - } + let parse_result = Cli::try_parse_from(["paddler_gui", "bogus"]); + + assert!(parse_result.is_err()); + + Ok(()) } } diff --git a/paddler_gui/src/model_preset.rs b/paddler_gui/src/model_preset.rs index a9e6b8dc..476781f0 100644 --- a/paddler_gui/src/model_preset.rs +++ b/paddler_gui/src/model_preset.rs @@ -70,7 +70,6 @@ impl fmt::Display for ModelPreset { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use paddler_types::agent_desired_model::AgentDesiredModel; use super::ModelPreset; @@ -79,9 +78,11 @@ mod tests { fn available_presets_returns_at_least_one_preset_per_supported_model() -> Result<()> { let presets = ModelPreset::available_presets(); - if presets.len() < 2 { - bail!("expected at least two presets, got {}", presets.len()); - } + assert!( + presets.len() >= 2, + "expected at least two presets, got {}", + presets.len() + ); Ok(()) } @@ -95,10 +96,12 @@ mod tests { let desired = preset.to_balancer_desired_state(); - match desired.multimodal_projection { - AgentDesiredModel::None => Ok(()), - other => bail!("expected AgentDesiredModel::None, got {other:?}"), - } + assert!(matches!( + desired.multimodal_projection, + AgentDesiredModel::None + )); + + Ok(()) } #[test] @@ -110,10 +113,12 @@ mod tests { let desired = preset.to_balancer_desired_state(); - match desired.multimodal_projection { - AgentDesiredModel::HuggingFace(_) => Ok(()), - other => bail!("expected AgentDesiredModel::HuggingFace, got {other:?}"), - } + assert!(matches!( + desired.multimodal_projection, + AgentDesiredModel::HuggingFace(_) + )); + + Ok(()) } #[test] @@ -123,9 +128,11 @@ mod tests { .next() .ok_or_else(|| anyhow::anyhow!("expected at least one preset"))?; - if format!("{preset}") != preset.display_name { - bail!("Display impl did not match display_name"); - } + assert_eq!( + format!("{preset}"), + preset.display_name, + "Display impl did not match display_name" + ); Ok(()) } diff --git a/paddler_gui/src/running_balancer_handler.rs b/paddler_gui/src/running_balancer_handler.rs index 8a38942f..2679ec66 100644 --- a/paddler_gui/src/running_balancer_handler.rs +++ b/paddler_gui/src/running_balancer_handler.rs @@ -38,7 +38,6 @@ impl RunningBalancerData { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; @@ -67,29 +66,28 @@ mod tests { ..RunningBalancerSnapshot::default() }; - match data.update(Message::SnapshotUpdated(Box::new(new_snapshot))) { - Action::None => {} - _ => bail!("expected Action::None for SnapshotUpdated"), - } + let action = data.update(Message::SnapshotUpdated(Box::new(new_snapshot))); - match &data.snapshot.balancer_desired_state.model { - AgentDesiredModel::LocalToAgent(path) if path == "/some/model.gguf" => Ok(()), - other => bail!("expected snapshot's model to be replaced, got {other:?}"), - } + assert!(matches!(action, Action::None)); + assert!(matches!( + &data.snapshot.balancer_desired_state.model, + AgentDesiredModel::LocalToAgent(path) if path == "/some/model.gguf" + )); + + Ok(()) } #[test] fn stop_message_sets_stopping_flag_and_returns_stop_action() -> Result<()> { let mut data = fresh_data(); - match data.update(Message::Stop) { - Action::Stop => {} - _ => bail!("expected Action::Stop"), - } + let action = data.update(Message::Stop); - if !data.stopping { - bail!("expected stopping flag to flip to true after Stop"); - } + assert!(matches!(action, Action::Stop)); + assert!( + data.stopping, + "expected stopping flag to flip to true after Stop" + ); Ok(()) } @@ -98,19 +96,27 @@ mod tests { fn copy_to_clipboard_message_forwards_content_as_action() -> Result<()> { let mut data = fresh_data(); - match data.update(Message::CopyToClipboard("address-value".to_owned())) { - Action::CopyToClipboard(content) if content == "address-value" => Ok(()), - _ => bail!("expected Action::CopyToClipboard with the forwarded content"), - } + let action = data.update(Message::CopyToClipboard("address-value".to_owned())); + + assert!(matches!( + action, + Action::CopyToClipboard(content) if content == "address-value" + )); + + Ok(()) } #[test] fn open_url_message_forwards_url_as_action() -> Result<()> { let mut data = fresh_data(); - match data.update(Message::OpenUrl("http://example.test".to_owned())) { - Action::OpenUrl(url) if url == "http://example.test" => Ok(()), - _ => bail!("expected Action::OpenUrl with the forwarded url"), - } + let action = data.update(Message::OpenUrl("http://example.test".to_owned())); + + assert!(matches!( + action, + Action::OpenUrl(url) if url == "http://example.test" + )); + + Ok(()) } } diff --git a/paddler_gui/src/screen.rs b/paddler_gui/src/screen.rs index 40b5acbb..5c6fc204 100644 --- a/paddler_gui/src/screen.rs +++ b/paddler_gui/src/screen.rs @@ -144,7 +144,6 @@ impl Screen { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use super::AddressField; use super::AgentRunning; @@ -251,16 +250,18 @@ mod tests { fn home_to_start_balancer_form_seeds_addresses_from_detected_interfaces() -> Result<()> { let next = home().start_balancer(); - if next.state_data.balancer_address.raw_text().is_empty() { - bail!("expected start form to be seeded with a balancer_address"); - } - - if !next.state_data.balancer_address.raw_text().ends_with(":8060") { - bail!( - "expected default balancer port suffix :8060, got {}", - next.state_data.balancer_address.raw_text() - ); - } + assert!( + !next.state_data.balancer_address.raw_text().is_empty(), + "expected start form to be seeded with a balancer_address" + ); + assert!( + next.state_data + .balancer_address + .raw_text() + .ends_with(":8060"), + "expected default balancer port suffix :8060, got {}", + next.state_data.balancer_address.raw_text() + ); Ok(()) } @@ -269,9 +270,10 @@ mod tests { fn join_balancer_form_cancel_returns_home_without_error() -> Result<()> { let next = join_form(JoinBalancerFormData::default()).cancel(); - if next.state_data.error.is_some() { - bail!("cancel should not surface an error on home"); - } + assert!( + next.state_data.error.is_none(), + "cancel should not surface an error on home" + ); Ok(()) } @@ -288,9 +290,10 @@ mod tests { let next = form.connect(); - if next.state_data.snapshot.name.is_some() { - bail!("expected agent name None when form field empty"); - } + assert!( + next.state_data.snapshot.name.is_none(), + "expected agent name None when form field empty" + ); Ok(()) } @@ -308,18 +311,21 @@ mod tests { let next = form.connect(); - if next.state_data.snapshot.name.as_deref() != Some("primary") { - bail!("expected agent name Some(\"primary\")"); - } + assert_eq!( + next.state_data.snapshot.name.as_deref(), + Some("primary"), + "expected agent name Some(\"primary\")" + ); Ok(()) } #[test] fn agent_running_disconnect_returns_home_without_error() -> Result<()> { let next = agent_running(fresh_agent_running_data()).disconnect(); - if next.state_data.error.is_some() { - bail!("disconnect should not surface an error on home"); - } + assert!( + next.state_data.error.is_none(), + "disconnect should not surface an error on home" + ); Ok(()) } @@ -327,24 +333,24 @@ mod tests { fn agent_running_agent_failed_returns_home_with_error_message() -> Result<()> { let next = agent_running(fresh_agent_running_data()).agent_failed("oops".to_owned()); - match next.state_data.error.as_deref() { - Some("oops") => Ok(()), - other => bail!("expected error Some(\"oops\"), got {other:?}"), - } + assert_eq!(next.state_data.error.as_deref(), Some("oops")); + + Ok(()) } #[test] fn start_balancer_form_cancel_returns_home_without_error() -> Result<()> { let next = start_form(fresh_start_form_data()).cancel(); - if next.state_data.error.is_some() { - bail!("cancel should not surface an error on home"); - } + assert!( + next.state_data.error.is_none(), + "cancel should not surface an error on home" + ); Ok(()) } #[test] - fn start_balancer_form_balancer_started_with_web_admin_address_carries_it_forward() - -> Result<()> { + fn start_balancer_form_balancer_started_with_web_admin_address_carries_it_forward() -> Result<()> + { let form = start_form(StartBalancerFormData { web_admin_panel_address: AddressField::Invalid { raw: "127.0.0.1:8062".to_owned(), @@ -355,10 +361,12 @@ mod tests { let next = form.balancer_started(); - match next.state_data.web_admin_panel_address.as_deref() { - Some("127.0.0.1:8062") => Ok(()), - other => bail!("expected web_admin_panel_address forwarded, got {other:?}"), - } + assert_eq!( + next.state_data.web_admin_panel_address.as_deref(), + Some("127.0.0.1:8062") + ); + + Ok(()) } #[test] @@ -371,9 +379,10 @@ mod tests { let next = form.balancer_started(); - if next.state_data.web_admin_panel_address.is_some() { - bail!("expected empty web_admin_panel_address to become None"); - } + assert!( + next.state_data.web_admin_panel_address.is_none(), + "expected empty web_admin_panel_address to become None" + ); Ok(()) } @@ -381,18 +390,18 @@ mod tests { fn start_balancer_form_balancer_failed_returns_home_with_error_message() -> Result<()> { let next = start_form(fresh_start_form_data()).balancer_failed("nope".to_owned()); - match next.state_data.error.as_deref() { - Some("nope") => Ok(()), - other => bail!("expected error Some(\"nope\"), got {other:?}"), - } + assert_eq!(next.state_data.error.as_deref(), Some("nope")); + + Ok(()) } #[test] fn running_balancer_balancer_stopped_returns_home_without_error() -> Result<()> { let next = running(fresh_running_data()).balancer_stopped(); - if next.state_data.error.is_some() { - bail!("balancer_stopped should not surface an error on home"); - } + assert!( + next.state_data.error.is_none(), + "balancer_stopped should not surface an error on home" + ); Ok(()) } @@ -400,10 +409,9 @@ mod tests { fn running_balancer_balancer_failed_returns_home_with_error_message() -> Result<()> { let next = running(fresh_running_data()).balancer_failed("kaboom".to_owned()); - match next.state_data.error.as_deref() { - Some("kaboom") => Ok(()), - other => bail!("expected error Some(\"kaboom\"), got {other:?}"), - } + assert_eq!(next.state_data.error.as_deref(), Some("kaboom")); + + Ok(()) } use paddler_types::agent_state_application_status::AgentStateApplicationStatus; diff --git a/paddler_gui/src/start_balancer_form_handler.rs b/paddler_gui/src/start_balancer_form_handler.rs index d6d90d7d..6cb82713 100644 --- a/paddler_gui/src/start_balancer_form_handler.rs +++ b/paddler_gui/src/start_balancer_form_handler.rs @@ -90,7 +90,8 @@ impl StartBalancerFormData { inference_address, AddressField::Empty | AddressField::Invalid { .. } ); - let web_admin_panel_invalid = matches!(web_admin_panel_address, AddressField::Invalid { .. }); + let web_admin_panel_invalid = + matches!(web_admin_panel_address, AddressField::Invalid { .. }); if model_error_present || any_required_address_invalid || web_admin_panel_invalid { self.balancer_address = match balancer_address { @@ -154,7 +155,6 @@ impl StartBalancerFormData { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use paddler_ports::bind_ephemeral_port::bind_ephemeral_port; use paddler_ports::bound_port::BoundPort; use paddler_types::agent_desired_model::AgentDesiredModel; @@ -257,9 +257,10 @@ mod tests { let _ = data.update(Message::SelectModel(first_preset()?)); - if data.model_error.is_some() { - bail!("expected model_error to be cleared after SelectModel"); - } + assert!( + data.model_error.is_none(), + "expected model_error to be cleared after SelectModel" + ); Ok(()) } @@ -377,14 +378,13 @@ mod tests { let action = data.update(Message::Confirm); - match action { - Action::StartBalancer { desired_state, .. } => { - assert!(data.starting); - assert!(matches!(desired_state.model, AgentDesiredModel::HuggingFace(_))); - Ok(()) - } - _ => bail!("expected Action::StartBalancer for valid input with preset"), - } + assert!(matches!( + action, + Action::StartBalancer { desired_state, .. } if matches!(desired_state.model, AgentDesiredModel::HuggingFace(_)) + )); + assert!(data.starting); + + Ok(()) } #[test] @@ -397,13 +397,12 @@ mod tests { let action = data.update(Message::Confirm); - match action { - Action::StartBalancer { desired_state, .. } => { - assert!(matches!(desired_state.model, AgentDesiredModel::None)); - Ok(()) - } - _ => bail!("expected Action::StartBalancer for valid input with add_model_later"), - } + assert!(matches!( + action, + Action::StartBalancer { desired_state, .. } if matches!(desired_state.model, AgentDesiredModel::None) + )); + + Ok(()) } #[test] @@ -416,15 +415,14 @@ mod tests { let action = data.update(Message::Confirm); - match action { + assert!(matches!( + action, Action::StartBalancer { - web_admin_panel_port, + web_admin_panel_port: Some(_), .. - } => { - assert!(web_admin_panel_port.is_some()); - Ok(()) } - _ => bail!("expected Action::StartBalancer"), - } + )); + + Ok(()) } } diff --git a/paddler_gui/src/ui/agent_status_label.rs b/paddler_gui/src/ui/agent_status_label.rs index 00bdb5da..664964e2 100644 --- a/paddler_gui/src/ui/agent_status_label.rs +++ b/paddler_gui/src/ui/agent_status_label.rs @@ -34,7 +34,6 @@ mod tests { use std::collections::BTreeSet; use anyhow::Result; - use anyhow::bail; use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use paddler_types::agent_state_application_status::AgentStateApplicationStatus; @@ -66,20 +65,16 @@ mod tests { fn label_reports_download_progress_percentage_when_a_download_is_in_progress() -> Result<()> { let snapshot = snapshot_with(25, 100, None, AgentStateApplicationStatus::Fresh); - if agent_status_label(&snapshot) != "Downloading (25%)" { - bail!("expected Downloading (25%)"); - } + assert_eq!(agent_status_label(&snapshot), "Downloading (25%)"); Ok(()) } #[test] - fn label_says_waiting_for_model_when_no_model_is_loaded_and_no_download_is_active() - -> Result<()> { + fn label_says_waiting_for_model_when_no_model_is_loaded_and_no_download_is_active() -> Result<()> + { let snapshot = snapshot_with(0, 0, None, AgentStateApplicationStatus::Fresh); - if agent_status_label(&snapshot) != "Waiting for model..." { - bail!("expected the waiting-for-model copy"); - } + assert_eq!(agent_status_label(&snapshot), "Waiting for model..."); Ok(()) } @@ -92,9 +87,7 @@ mod tests { AgentStateApplicationStatus::Applied, ); - if agent_status_label(&snapshot) != "OK" { - bail!("expected OK for Applied"); - } + assert_eq!(agent_status_label(&snapshot), "OK"); Ok(()) } @@ -107,9 +100,7 @@ mod tests { AgentStateApplicationStatus::Fresh, ); - if agent_status_label(&snapshot) != "Pending" { - bail!("expected Pending for Fresh"); - } + assert_eq!(agent_status_label(&snapshot), "Pending"); Ok(()) } @@ -122,9 +113,7 @@ mod tests { AgentStateApplicationStatus::AttemptedAndRetrying, ); - if agent_status_label(&snapshot) != "Retrying" { - bail!("expected Retrying"); - } + assert_eq!(agent_status_label(&snapshot), "Retrying"); Ok(()) } @@ -137,9 +126,7 @@ mod tests { AgentStateApplicationStatus::Stuck, ); - if agent_status_label(&snapshot) != "Retrying, but seems stuck?" { - bail!("expected stuck copy"); - } + assert_eq!(agent_status_label(&snapshot), "Retrying, but seems stuck?"); Ok(()) } @@ -152,9 +139,7 @@ mod tests { AgentStateApplicationStatus::AttemptedAndNotAppliable, ); - if agent_status_label(&snapshot) != "Needs your help" { - bail!("expected needs-help copy"); - } + assert_eq!(agent_status_label(&snapshot), "Needs your help"); Ok(()) } } diff --git a/paddler_gui/src/ui/display_last_path_part.rs b/paddler_gui/src/ui/display_last_path_part.rs index e9dd471c..4e4e98dc 100644 --- a/paddler_gui/src/ui/display_last_path_part.rs +++ b/paddler_gui/src/ui/display_last_path_part.rs @@ -11,23 +11,26 @@ pub fn display_last_path_part(path: &str) -> String { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use super::display_last_path_part; #[test] fn returns_only_the_file_name_when_path_contains_separators() -> Result<()> { - if display_last_path_part("/var/models/model.gguf") != "model.gguf" { - bail!("expected the file name portion"); - } + assert_eq!( + display_last_path_part("/var/models/model.gguf"), + "model.gguf", + "expected the file name portion" + ); Ok(()) } #[test] fn returns_the_input_unchanged_when_there_is_no_separator() -> Result<()> { - if display_last_path_part("just-a-name") != "just-a-name" { - bail!("expected the original input when no separator is present"); - } + assert_eq!( + display_last_path_part("just-a-name"), + "just-a-name", + "expected the original input when no separator is present" + ); Ok(()) } } diff --git a/paddler_gui/src/ui/format_desired_model.rs b/paddler_gui/src/ui/format_desired_model.rs index 9cd40f11..7f7d9450 100644 --- a/paddler_gui/src/ui/format_desired_model.rs +++ b/paddler_gui/src/ui/format_desired_model.rs @@ -16,7 +16,6 @@ pub fn format_desired_model(desired_model: &AgentDesiredModel) -> String { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::huggingface_model_reference::HuggingFaceModelReference; @@ -30,9 +29,10 @@ mod tests { revision: "main".to_owned(), }); - if format_desired_model(&model) != "HuggingFace org/repo/model.gguf (main)" { - bail!("HuggingFace formatting does not match the expected layout"); - } + assert_eq!( + format_desired_model(&model), + "HuggingFace org/repo/model.gguf (main)" + ); Ok(()) } @@ -41,18 +41,17 @@ mod tests { fn formats_local_to_agent_with_path_prefix() -> Result<()> { let model = AgentDesiredModel::LocalToAgent("/var/models/model.gguf".to_owned()); - if format_desired_model(&model) != "Local: /var/models/model.gguf" { - bail!("LocalToAgent formatting does not match the expected layout"); - } + assert_eq!( + format_desired_model(&model), + "Local: /var/models/model.gguf" + ); Ok(()) } #[test] fn formats_none_as_not_set_placeholder() -> Result<()> { - if format_desired_model(&AgentDesiredModel::None) != "(not set)" { - bail!("None formatting does not match the expected placeholder"); - } + assert_eq!(format_desired_model(&AgentDesiredModel::None), "(not set)"); Ok(()) } diff --git a/paddler_gui/src/ui/style_agent_container.rs b/paddler_gui/src/ui/style_agent_container.rs index 4cc1e4a0..2c93f217 100644 --- a/paddler_gui/src/ui/style_agent_container.rs +++ b/paddler_gui/src/ui/style_agent_container.rs @@ -23,7 +23,6 @@ pub fn style_agent_container(theme: &Theme) -> container::Style { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use iced::Background; use iced::Theme; @@ -35,14 +34,15 @@ mod tests { fn agent_container_paints_orange_background_with_black_border() -> Result<()> { let style = style_agent_container(&Theme::Light); - match style.background { - Some(Background::Color(color)) if color == COLOR_AGENT_BACKGROUND => {} - other => bail!("expected COLOR_AGENT_BACKGROUND, got {other:?}"), - } - - if style.border.color != COLOR_BORDER { - bail!("expected COLOR_BORDER border, got {:?}", style.border.color); - } + assert!(matches!( + style.background, + Some(Background::Color(color)) if color == COLOR_AGENT_BACKGROUND + )); + assert_eq!( + style.border.color, COLOR_BORDER, + "expected COLOR_BORDER border, got {:?}", + style.border.color + ); Ok(()) } diff --git a/paddler_gui/src/ui/style_button_disconnect.rs b/paddler_gui/src/ui/style_button_disconnect.rs index f422851f..343c8fcc 100644 --- a/paddler_gui/src/ui/style_button_disconnect.rs +++ b/paddler_gui/src/ui/style_button_disconnect.rs @@ -24,7 +24,6 @@ pub fn style_button_disconnect(theme: &Theme, status: button::Status) -> button: #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use iced::Background; use iced::Theme; use iced::widget::button; @@ -36,9 +35,11 @@ mod tests { fn disconnect_button_paints_error_red_background() -> Result<()> { let style = style_button_disconnect(&Theme::Light, button::Status::Active); - match style.background { - Some(Background::Color(color)) if color == COLOR_ERROR => Ok(()), - other => bail!("expected COLOR_ERROR background, got {other:?}"), - } + assert!(matches!( + style.background, + Some(Background::Color(color)) if color == COLOR_ERROR + )); + + Ok(()) } } diff --git a/paddler_gui/src/ui/style_button_primary.rs b/paddler_gui/src/ui/style_button_primary.rs index 393ea642..537cdc12 100644 --- a/paddler_gui/src/ui/style_button_primary.rs +++ b/paddler_gui/src/ui/style_button_primary.rs @@ -24,7 +24,6 @@ pub fn style_button_primary(theme: &Theme, status: button::Status) -> button::St #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use iced::Background; use iced::Theme; use iced::widget::button; @@ -37,14 +36,14 @@ mod tests { fn primary_button_paints_border_color_background_with_body_background_text() -> Result<()> { let style = style_button_primary(&Theme::Light, button::Status::Active); - match style.background { - Some(Background::Color(color)) if color == COLOR_BORDER => {} - other => bail!("expected COLOR_BORDER background, got {other:?}"), - } - - if style.text_color != COLOR_BODY_BACKGROUND { - bail!("expected text_color == COLOR_BODY_BACKGROUND"); - } + assert!(matches!( + style.background, + Some(Background::Color(color)) if color == COLOR_BORDER + )); + assert_eq!( + style.text_color, COLOR_BODY_BACKGROUND, + "expected text_color == COLOR_BODY_BACKGROUND" + ); Ok(()) } diff --git a/paddler_gui/src/ui/style_card_container.rs b/paddler_gui/src/ui/style_card_container.rs index 6d7c52e1..444c87cc 100644 --- a/paddler_gui/src/ui/style_card_container.rs +++ b/paddler_gui/src/ui/style_card_container.rs @@ -23,7 +23,6 @@ pub fn style_card_container(theme: &Theme) -> container::Style { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use iced::Theme; use super::COLOR_BORDER; @@ -33,9 +32,11 @@ mod tests { fn card_container_has_border_in_outline_color() -> Result<()> { let style = style_card_container(&Theme::Light); - if style.border.color != COLOR_BORDER { - bail!("expected COLOR_BORDER border, got {:?}", style.border.color); - } + assert_eq!( + style.border.color, COLOR_BORDER, + "expected COLOR_BORDER border, got {:?}", + style.border.color + ); Ok(()) } } diff --git a/paddler_gui/src/ui/style_download_progress_bar.rs b/paddler_gui/src/ui/style_download_progress_bar.rs index f73c92f9..d8081924 100644 --- a/paddler_gui/src/ui/style_download_progress_bar.rs +++ b/paddler_gui/src/ui/style_download_progress_bar.rs @@ -21,7 +21,6 @@ pub fn style_download_progress_bar(_theme: &Theme) -> progress_bar::Style { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use iced::Background; use iced::Theme; @@ -32,9 +31,11 @@ mod tests { fn download_progress_bar_fills_with_border_color() -> Result<()> { let style = style_download_progress_bar(&Theme::Light); - match style.bar { - Background::Color(color) if color == COLOR_BORDER => Ok(()), - other => bail!("expected COLOR_BORDER bar, got {other:?}"), - } + assert!(matches!( + style.bar, + Background::Color(color) if color == COLOR_BORDER + )); + + Ok(()) } } diff --git a/paddler_gui/src/ui/style_field_checkbox.rs b/paddler_gui/src/ui/style_field_checkbox.rs index ca059f8b..a19ead9c 100644 --- a/paddler_gui/src/ui/style_field_checkbox.rs +++ b/paddler_gui/src/ui/style_field_checkbox.rs @@ -36,7 +36,6 @@ pub fn style_field_checkbox(_theme: &Theme, status: checkbox::Status) -> checkbo #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use iced::Background; use iced::Theme; use iced::widget::checkbox; @@ -50,10 +49,12 @@ mod tests { let style = style_field_checkbox(&Theme::Light, checkbox::Status::Active { is_checked: true }); - match style.background { - Background::Color(color) if color == COLOR_BORDER => Ok(()), - other => bail!("expected COLOR_BORDER background when checked, got {other:?}"), - } + assert!(matches!( + style.background, + Background::Color(color) if color == COLOR_BORDER + )); + + Ok(()) } #[test] @@ -63,9 +64,11 @@ mod tests { checkbox::Status::Active { is_checked: false }, ); - match style.background { - Background::Color(color) if color == COLOR_BODY_BACKGROUND => Ok(()), - other => bail!("expected COLOR_BODY_BACKGROUND when unchecked, got {other:?}"), - } + assert!(matches!( + style.background, + Background::Color(color) if color == COLOR_BODY_BACKGROUND + )); + + Ok(()) } } diff --git a/paddler_gui/src/ui/style_field_container.rs b/paddler_gui/src/ui/style_field_container.rs index a237b773..04fc174a 100644 --- a/paddler_gui/src/ui/style_field_container.rs +++ b/paddler_gui/src/ui/style_field_container.rs @@ -21,7 +21,6 @@ pub fn style_field_container(theme: &Theme) -> container::Style { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use iced::Theme; use super::COLOR_BORDER; @@ -31,13 +30,15 @@ mod tests { fn field_container_casts_a_solid_offset_shadow_in_border_color() -> Result<()> { let style = style_field_container(&Theme::Light); - if style.shadow.color != COLOR_BORDER { - bail!("expected shadow color == COLOR_BORDER"); - } - - if style.shadow.offset.x != 4.0 || style.shadow.offset.y != 4.0 { - bail!("expected shadow offset (4.0, 4.0)"); - } + assert_eq!( + style.shadow.color, COLOR_BORDER, + "expected shadow color == COLOR_BORDER" + ); + assert!( + (style.shadow.offset.x - 4.0).abs() <= f32::EPSILON + && (style.shadow.offset.y - 4.0).abs() <= f32::EPSILON, + "expected shadow offset (4.0, 4.0)" + ); Ok(()) } diff --git a/paddler_gui/src/ui/style_field_pick_list.rs b/paddler_gui/src/ui/style_field_pick_list.rs index 6cd9d7ec..2c38f3cb 100644 --- a/paddler_gui/src/ui/style_field_pick_list.rs +++ b/paddler_gui/src/ui/style_field_pick_list.rs @@ -26,7 +26,6 @@ pub fn style_field_pick_list(theme: &Theme, status: pick_list::Status) -> pick_l #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use iced::Theme; use iced::widget::pick_list; @@ -37,9 +36,10 @@ mod tests { fn pick_list_displays_outlined_border_in_body_font_color() -> Result<()> { let style = style_field_pick_list(&Theme::Light, pick_list::Status::Active); - if style.border.color != COLOR_BORDER { - bail!("expected border in COLOR_BORDER"); - } + assert_eq!( + style.border.color, COLOR_BORDER, + "expected border in COLOR_BORDER" + ); Ok(()) } diff --git a/paddler_gui/src/ui/style_field_pick_list_menu.rs b/paddler_gui/src/ui/style_field_pick_list_menu.rs index e90855b5..c993c809 100644 --- a/paddler_gui/src/ui/style_field_pick_list_menu.rs +++ b/paddler_gui/src/ui/style_field_pick_list_menu.rs @@ -28,7 +28,6 @@ pub fn style_field_pick_list_menu(theme: &Theme) -> menu::Style { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use iced::Theme; use super::COLOR_BORDER; @@ -38,9 +37,10 @@ mod tests { fn pick_list_open_menu_outlines_options_with_border_color() -> Result<()> { let style = style_field_pick_list_menu(&Theme::Light); - if style.border.color != COLOR_BORDER { - bail!("expected menu border in COLOR_BORDER"); - } + assert_eq!( + style.border.color, COLOR_BORDER, + "expected menu border in COLOR_BORDER" + ); Ok(()) } diff --git a/paddler_gui/src/ui/style_field_text_input.rs b/paddler_gui/src/ui/style_field_text_input.rs index 02fbba24..6f3ac6c3 100644 --- a/paddler_gui/src/ui/style_field_text_input.rs +++ b/paddler_gui/src/ui/style_field_text_input.rs @@ -27,7 +27,6 @@ pub fn style_field_text_input(theme: &Theme, status: text_input::Status) -> text #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use iced::Theme; use iced::widget::text_input; @@ -38,13 +37,14 @@ mod tests { fn text_input_outlines_with_border_color_at_two_pixels() -> Result<()> { let style = style_field_text_input(&Theme::Light, text_input::Status::Active); - if style.border.color != COLOR_BORDER { - bail!("expected border in COLOR_BORDER"); - } - - if (style.border.width - 2.0).abs() > f32::EPSILON { - bail!("expected border width of 2.0"); - } + assert_eq!( + style.border.color, COLOR_BORDER, + "expected border in COLOR_BORDER" + ); + assert!( + (style.border.width - 2.0).abs() <= f32::EPSILON, + "expected border width of 2.0" + ); Ok(()) } diff --git a/paddler_gui/src/ui/style_status_indicator.rs b/paddler_gui/src/ui/style_status_indicator.rs index d429c1bc..acefad80 100644 --- a/paddler_gui/src/ui/style_status_indicator.rs +++ b/paddler_gui/src/ui/style_status_indicator.rs @@ -21,7 +21,6 @@ pub fn style_status_indicator(theme: &Theme) -> container::Style { #[cfg(test)] mod tests { use anyhow::Result; - use anyhow::bail; use iced::Background; use iced::Color; use iced::Theme; @@ -32,14 +31,15 @@ mod tests { fn status_indicator_paints_a_pale_green_pill_against_a_muted_green_border() -> Result<()> { let style = style_status_indicator(&Theme::Light); - match style.background { - Some(Background::Color(color)) if color == Color::from_rgb8(0xEE, 0xFF, 0xEE) => {} - other => bail!("expected pale green background, got {other:?}"), - } - - if style.border.color != Color::from_rgb8(0xCC, 0xDD, 0xCC) { - bail!("expected muted green border"); - } + assert!(matches!( + style.background, + Some(Background::Color(color)) if color == Color::from_rgb8(0xEE, 0xFF, 0xEE) + )); + assert_eq!( + style.border.color, + Color::from_rgb8(0xCC, 0xDD, 0xCC), + "expected muted green border" + ); Ok(()) } diff --git a/paddler_gui/src/ui/view_start_balancer_form.rs b/paddler_gui/src/ui/view_start_balancer_form.rs index 69e1666d..3dacc21d 100644 --- a/paddler_gui/src/ui/view_start_balancer_form.rs +++ b/paddler_gui/src/ui/view_start_balancer_form.rs @@ -71,7 +71,8 @@ pub fn view_start_balancer_form(data: &StartBalancerFormData) -> Element<'_, Mes let balancer_address_error = data.balancer_address.error_text().map(str::to_owned); let inference_address_error = data.inference_address.error_text().map(str::to_owned); - let web_admin_panel_address_error = data.web_admin_panel_address.error_text().map(str::to_owned); + let web_admin_panel_address_error = + data.web_admin_panel_address.error_text().map(str::to_owned); let model_input: Element<'_, Message> = if data.add_model_later { text_input("Model will be added later", "") From 2f21a5a879b796960b4264ce106e8dea6dda6def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 16 May 2026 18:14:31 +0200 Subject: [PATCH 06/13] add #[must_use] attributes and clean up clippy lint violations across paddler_gui and paddler_ports --- paddler/src/balancer/inference_service/mod.rs | 2 +- paddler_gui/src/address_field.rs | 12 ++++++------ paddler_gui/src/agent_running_data.rs | 5 +++++ paddler_gui/src/agent_running_handler.rs | 5 +++++ paddler_gui/src/app.rs | 19 ++++++++++++++----- paddler_gui/src/current_screen.rs | 5 +++++ paddler_gui/src/detect_network_interfaces.rs | 6 ++++++ paddler_gui/src/home_handler.rs | 1 + paddler_gui/src/join_balancer_form_handler.rs | 5 +++++ paddler_gui/src/lib.rs | 5 +++++ paddler_gui/src/model_preset.rs | 6 ++++++ paddler_gui/src/running_balancer_handler.rs | 5 +++++ paddler_gui/src/screen.rs | 16 ++++++++++++++++ paddler_gui/src/slot_count_field.rs | 11 +++++------ .../src/start_balancer_form_handler.rs | 9 +++++++++ paddler_gui/src/ui/agent_status_label.rs | 6 ++++++ paddler_gui/src/ui/display_last_path_part.rs | 6 ++++++ paddler_gui/src/ui/format_desired_model.rs | 6 ++++++ paddler_gui/src/ui/style_agent_container.rs | 6 ++++++ paddler_gui/src/ui/style_button_disconnect.rs | 6 ++++++ paddler_gui/src/ui/style_button_primary.rs | 6 ++++++ paddler_gui/src/ui/style_card_container.rs | 6 ++++++ .../src/ui/style_download_progress_bar.rs | 6 ++++++ paddler_gui/src/ui/style_field_checkbox.rs | 6 ++++++ paddler_gui/src/ui/style_field_container.rs | 6 ++++++ paddler_gui/src/ui/style_field_pick_list.rs | 6 ++++++ .../src/ui/style_field_pick_list_menu.rs | 6 ++++++ paddler_gui/src/ui/style_field_text_input.rs | 6 ++++++ paddler_gui/src/ui/style_status_indicator.rs | 6 ++++++ paddler_gui_tests/src/bind_addresses.rs | 1 + .../src/make_agent_controller_snapshot.rs | 1 + .../src/make_balancer_runner_params.rs | 4 ++-- ...stops_cleanly_when_the_user_disconnects.rs | 4 ++++ ...er_collects_and_submits_cluster_details.rs | 4 ++++ ...he_user_choose_a_model_or_add_one_later.rs | 4 ++++ ...ng_a_balancer_succeeds_or_fails_visibly.rs | 4 ++++ paddler_ports/src/check_port.rs | 7 +------ paddler_ports/src/port_check_error.rs | 1 + 38 files changed, 200 insertions(+), 26 deletions(-) diff --git a/paddler/src/balancer/inference_service/mod.rs b/paddler/src/balancer/inference_service/mod.rs index c827abd9..8a71bf92 100644 --- a/paddler/src/balancer/inference_service/mod.rs +++ b/paddler/src/balancer/inference_service/mod.rs @@ -61,7 +61,6 @@ impl Service for InferenceService { let taken_listener = self.listener.take(); let configured_addr = self.configuration.addr; - #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] let bound = HttpServer::new(move || { App::new() .wrap(create_cors_middleware(&cors_allowed_hosts_arc)) @@ -77,6 +76,7 @@ impl Service for InferenceService { }) .disable_signals(); + #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] let bound = match taken_listener { Some(listener) => bound.listen(listener), None => bound.bind(configured_addr), diff --git a/paddler_gui/src/address_field.rs b/paddler_gui/src/address_field.rs index a98aaebc..51af5610 100644 --- a/paddler_gui/src/address_field.rs +++ b/paddler_gui/src/address_field.rs @@ -2,13 +2,16 @@ use paddler_ports::bound_port::BoundPort; use paddler_ports::check_optional_port::check_optional_port; use paddler_ports::check_port::check_port; +#[derive(Default)] pub enum AddressField { + #[default] Empty, Bound { raw: String, port: BoundPort }, Invalid { raw: String, error: String }, } impl AddressField { + #[must_use] pub fn required_from_user_input(raw: String) -> Self { match check_port(&raw) { Ok(port) => Self::Bound { raw, port }, @@ -25,6 +28,7 @@ impl AddressField { } } + #[must_use] pub fn optional_from_user_input(raw: String) -> Self { match check_optional_port(&raw) { Ok(Some(port)) => Self::Bound { raw, port }, @@ -36,6 +40,7 @@ impl AddressField { } } + #[must_use] pub fn raw_text(&self) -> &str { match self { Self::Empty => "", @@ -43,6 +48,7 @@ impl AddressField { } } + #[must_use] pub fn error_text(&self) -> Option<&str> { match self { Self::Invalid { error, .. } => Some(error), @@ -50,9 +56,3 @@ impl AddressField { } } } - -impl Default for AddressField { - fn default() -> Self { - Self::Empty - } -} diff --git a/paddler_gui/src/agent_running_data.rs b/paddler_gui/src/agent_running_data.rs index 5195ea2f..f0729181 100644 --- a/paddler_gui/src/agent_running_data.rs +++ b/paddler_gui/src/agent_running_data.rs @@ -29,6 +29,11 @@ impl AgentRunningData { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use std::collections::BTreeSet; use anyhow::Result; diff --git a/paddler_gui/src/agent_running_handler.rs b/paddler_gui/src/agent_running_handler.rs index 18845704..64000b14 100644 --- a/paddler_gui/src/agent_running_handler.rs +++ b/paddler_gui/src/agent_running_handler.rs @@ -28,6 +28,11 @@ impl AgentRunningData { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use std::collections::BTreeSet; use anyhow::Result; diff --git a/paddler_gui/src/app.rs b/paddler_gui/src/app.rs index 368fbba2..002e968b 100644 --- a/paddler_gui/src/app.rs +++ b/paddler_gui/src/app.rs @@ -274,10 +274,6 @@ impl App { } } - #[expect( - clippy::unused_self, - reason = "signature required by iced application API" - )] pub fn subscription(&self) -> Subscription { Subscription::batch([ keyboard::listen().filter_map(|event| match event { @@ -363,16 +359,19 @@ impl App { } #[cfg(test)] + #[must_use] pub fn shutdown_token_for_test(&self) -> CancellationToken { self.shutdown.clone() } #[cfg(test)] + #[must_use] pub fn agent_cancel_for_test(&self) -> Option { self.agent_cancel.clone() } #[cfg(test)] + #[must_use] pub fn balancer_cancel_for_test(&self) -> Option { self.balancer_cancel.clone() } @@ -388,7 +387,8 @@ impl App { } #[cfg(test)] - pub fn current_screen_for_test(&self) -> &CurrentScreen { + #[must_use] + pub const fn current_screen_for_test(&self) -> &CurrentScreen { &self.screen } @@ -484,6 +484,11 @@ impl App { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use super::*; @@ -827,6 +832,10 @@ mod tests { } #[test] + #[expect( + clippy::box_default, + reason = "explicit Box::new construction reads more clearly than Box::default in this test" + )] fn running_balancer_snapshot_update_keeps_user_on_the_running_balancer_screen() -> Result<()> { let mut app = app_with_screen(screen_running(fresh_running_data())); diff --git a/paddler_gui/src/current_screen.rs b/paddler_gui/src/current_screen.rs index e5eff3a2..525dc755 100644 --- a/paddler_gui/src/current_screen.rs +++ b/paddler_gui/src/current_screen.rs @@ -27,6 +27,11 @@ impl Default for CurrentScreen { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use super::CurrentScreen; diff --git a/paddler_gui/src/detect_network_interfaces.rs b/paddler_gui/src/detect_network_interfaces.rs index 541d65ba..51e10d39 100644 --- a/paddler_gui/src/detect_network_interfaces.rs +++ b/paddler_gui/src/detect_network_interfaces.rs @@ -1,5 +1,6 @@ use crate::network_interface_address::NetworkInterfaceAddress; +#[must_use] pub fn detect_network_interfaces() -> Vec { let interfaces = match if_addrs::get_if_addrs() { Ok(interfaces) => interfaces, @@ -27,6 +28,11 @@ pub fn detect_network_interfaces() -> Vec { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use super::detect_network_interfaces; diff --git a/paddler_gui/src/home_handler.rs b/paddler_gui/src/home_handler.rs index f94a367b..deef14ed 100644 --- a/paddler_gui/src/home_handler.rs +++ b/paddler_gui/src/home_handler.rs @@ -12,6 +12,7 @@ pub enum Action { } impl HomeData { + #[must_use] pub const fn update(message: Message) -> Action { match message { Message::StartBalancer => Action::StartBalancer, diff --git a/paddler_gui/src/join_balancer_form_handler.rs b/paddler_gui/src/join_balancer_form_handler.rs index 12330ac8..bc1816db 100644 --- a/paddler_gui/src/join_balancer_form_handler.rs +++ b/paddler_gui/src/join_balancer_form_handler.rs @@ -101,6 +101,11 @@ impl JoinBalancerFormData { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use super::Action; diff --git a/paddler_gui/src/lib.rs b/paddler_gui/src/lib.rs index fd18f268..736bba5b 100644 --- a/paddler_gui/src/lib.rs +++ b/paddler_gui/src/lib.rs @@ -81,6 +81,11 @@ pub fn run() -> iced::Result { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use clap::Parser as _; diff --git a/paddler_gui/src/model_preset.rs b/paddler_gui/src/model_preset.rs index 476781f0..1b9a972f 100644 --- a/paddler_gui/src/model_preset.rs +++ b/paddler_gui/src/model_preset.rs @@ -14,6 +14,7 @@ pub struct ModelPreset { } impl ModelPreset { + #[must_use] pub fn available_presets() -> Vec { vec![ Self { @@ -43,6 +44,7 @@ impl ModelPreset { ] } + #[must_use] pub fn to_balancer_desired_state(&self) -> BalancerDesiredState { let multimodal_projection = self .multimodal_projection @@ -75,6 +77,10 @@ mod tests { use super::ModelPreset; #[test] + #[expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] fn available_presets_returns_at_least_one_preset_per_supported_model() -> Result<()> { let presets = ModelPreset::available_presets(); diff --git a/paddler_gui/src/running_balancer_handler.rs b/paddler_gui/src/running_balancer_handler.rs index 2679ec66..09ffb2bb 100644 --- a/paddler_gui/src/running_balancer_handler.rs +++ b/paddler_gui/src/running_balancer_handler.rs @@ -37,6 +37,11 @@ impl RunningBalancerData { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; diff --git a/paddler_gui/src/screen.rs b/paddler_gui/src/screen.rs index 5c6fc204..fe5eee28 100644 --- a/paddler_gui/src/screen.rs +++ b/paddler_gui/src/screen.rs @@ -29,10 +29,12 @@ pub struct Screen {} #[transition] impl Screen { + #[must_use] pub fn join_balancer(self) -> Screen { self.transition_with(JoinBalancerFormData::default()) } + #[must_use] pub fn start_balancer(self) -> Screen { let suggested_address = detect_network_interfaces() .first() @@ -58,10 +60,12 @@ impl Screen { #[transition] impl Screen { + #[must_use] pub fn cancel(self) -> Screen { self.transition_with(HomeData { error: None }) } + #[must_use] pub fn connect(self) -> Screen { self.transition_map(|form_data: JoinBalancerFormData| { let name = if form_data.agent_name.is_empty() { @@ -94,10 +98,12 @@ impl Screen { #[transition] impl Screen { + #[must_use] pub fn disconnect(self) -> Screen { self.transition_with(HomeData { error: None }) } + #[must_use] pub fn agent_failed(self, error: String) -> Screen { self.transition_with(HomeData { error: Some(error) }) } @@ -105,10 +111,12 @@ impl Screen { #[transition] impl Screen { + #[must_use] pub fn cancel(self) -> Screen { self.transition_with(HomeData { error: None }) } + #[must_use] pub fn balancer_started(self) -> Screen { self.transition_map(|form_data: StartBalancerFormData| { let balancer_address = form_data.balancer_address.raw_text().to_owned(); @@ -125,6 +133,7 @@ impl Screen { }) } + #[must_use] pub fn balancer_failed(self, error: String) -> Screen { self.transition_with(HomeData { error: Some(error) }) } @@ -132,10 +141,12 @@ impl Screen { #[transition] impl Screen { + #[must_use] pub fn balancer_stopped(self) -> Screen { self.transition_with(HomeData { error: None }) } + #[must_use] pub fn balancer_failed(self, error: String) -> Screen { self.transition_with(HomeData { error: Some(error) }) } @@ -143,6 +154,11 @@ impl Screen { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use super::AddressField; diff --git a/paddler_gui/src/slot_count_field.rs b/paddler_gui/src/slot_count_field.rs index eb9095aa..f6d7c894 100644 --- a/paddler_gui/src/slot_count_field.rs +++ b/paddler_gui/src/slot_count_field.rs @@ -1,12 +1,15 @@ use std::num::IntErrorKind; +#[derive(Default)] pub enum SlotCountField { + #[default] Empty, Valid { raw: String, value: i32 }, Invalid { raw: String, error: String }, } impl SlotCountField { + #[must_use] pub fn from_user_input(raw: String) -> Self { if raw.is_empty() { return Self::Empty; @@ -39,6 +42,7 @@ impl SlotCountField { } } + #[must_use] pub fn raw_text(&self) -> &str { match self { Self::Empty => "", @@ -46,6 +50,7 @@ impl SlotCountField { } } + #[must_use] pub fn error_text(&self) -> Option<&str> { match self { Self::Invalid { error, .. } => Some(error), @@ -53,9 +58,3 @@ impl SlotCountField { } } } - -impl Default for SlotCountField { - fn default() -> Self { - Self::Empty - } -} diff --git a/paddler_gui/src/start_balancer_form_handler.rs b/paddler_gui/src/start_balancer_form_handler.rs index 6cb82713..f11d58a1 100644 --- a/paddler_gui/src/start_balancer_form_handler.rs +++ b/paddler_gui/src/start_balancer_form_handler.rs @@ -22,6 +22,10 @@ pub enum Message { Cancel, } +#[expect( + clippy::large_enum_variant, + reason = "ephemeral value, immediately consumed" +)] pub enum Action { None, Cancel, @@ -154,6 +158,11 @@ impl StartBalancerFormData { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use paddler_ports::bind_ephemeral_port::bind_ephemeral_port; use paddler_ports::bound_port::BoundPort; diff --git a/paddler_gui/src/ui/agent_status_label.rs b/paddler_gui/src/ui/agent_status_label.rs index 664964e2..640ec7e3 100644 --- a/paddler_gui/src/ui/agent_status_label.rs +++ b/paddler_gui/src/ui/agent_status_label.rs @@ -1,6 +1,7 @@ use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use paddler_types::agent_state_application_status::AgentStateApplicationStatus; +#[must_use] pub fn agent_status_label(snapshot: &AgentControllerSnapshot) -> String { let is_downloading = snapshot.download_total > 0 && snapshot.download_current < snapshot.download_total; @@ -31,6 +32,11 @@ pub fn agent_status_label(snapshot: &AgentControllerSnapshot) -> String { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use std::collections::BTreeSet; use anyhow::Result; diff --git a/paddler_gui/src/ui/display_last_path_part.rs b/paddler_gui/src/ui/display_last_path_part.rs index 4e4e98dc..ba18fbad 100644 --- a/paddler_gui/src/ui/display_last_path_part.rs +++ b/paddler_gui/src/ui/display_last_path_part.rs @@ -1,5 +1,6 @@ use std::path::Path; +#[must_use] pub fn display_last_path_part(path: &str) -> String { Path::new(path) .file_name() @@ -10,6 +11,11 @@ pub fn display_last_path_part(path: &str) -> String { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use super::display_last_path_part; diff --git a/paddler_gui/src/ui/format_desired_model.rs b/paddler_gui/src/ui/format_desired_model.rs index 7f7d9450..3260e5a1 100644 --- a/paddler_gui/src/ui/format_desired_model.rs +++ b/paddler_gui/src/ui/format_desired_model.rs @@ -1,5 +1,6 @@ use paddler_types::agent_desired_model::AgentDesiredModel; +#[must_use] pub fn format_desired_model(desired_model: &AgentDesiredModel) -> String { match desired_model { AgentDesiredModel::HuggingFace(reference) => { @@ -15,6 +16,11 @@ pub fn format_desired_model(desired_model: &AgentDesiredModel) -> String { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::huggingface_model_reference::HuggingFaceModelReference; diff --git a/paddler_gui/src/ui/style_agent_container.rs b/paddler_gui/src/ui/style_agent_container.rs index 2c93f217..60ef374b 100644 --- a/paddler_gui/src/ui/style_agent_container.rs +++ b/paddler_gui/src/ui/style_agent_container.rs @@ -6,6 +6,7 @@ use iced::widget::container; use super::variables::COLOR_AGENT_BACKGROUND; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_agent_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); @@ -22,6 +23,11 @@ pub fn style_agent_container(theme: &Theme) -> container::Style { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use iced::Background; use iced::Theme; diff --git a/paddler_gui/src/ui/style_button_disconnect.rs b/paddler_gui/src/ui/style_button_disconnect.rs index 343c8fcc..b3aed33f 100644 --- a/paddler_gui/src/ui/style_button_disconnect.rs +++ b/paddler_gui/src/ui/style_button_disconnect.rs @@ -6,6 +6,7 @@ use iced::widget::button; use super::variables::COLOR_ERROR; +#[must_use] pub fn style_button_disconnect(theme: &Theme, status: button::Status) -> button::Style { let base = button::primary(theme, status); @@ -23,6 +24,11 @@ pub fn style_button_disconnect(theme: &Theme, status: button::Status) -> button: #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use iced::Background; use iced::Theme; diff --git a/paddler_gui/src/ui/style_button_primary.rs b/paddler_gui/src/ui/style_button_primary.rs index 537cdc12..e9fb393f 100644 --- a/paddler_gui/src/ui/style_button_primary.rs +++ b/paddler_gui/src/ui/style_button_primary.rs @@ -6,6 +6,7 @@ use iced::widget::button; use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_button_primary(theme: &Theme, status: button::Status) -> button::Style { let base = button::primary(theme, status); @@ -23,6 +24,11 @@ pub fn style_button_primary(theme: &Theme, status: button::Status) -> button::St #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use iced::Background; use iced::Theme; diff --git a/paddler_gui/src/ui/style_card_container.rs b/paddler_gui/src/ui/style_card_container.rs index 444c87cc..d9814e17 100644 --- a/paddler_gui/src/ui/style_card_container.rs +++ b/paddler_gui/src/ui/style_card_container.rs @@ -6,6 +6,7 @@ use iced::widget::container; use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_card_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); @@ -22,6 +23,11 @@ pub fn style_card_container(theme: &Theme) -> container::Style { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use iced::Theme; diff --git a/paddler_gui/src/ui/style_download_progress_bar.rs b/paddler_gui/src/ui/style_download_progress_bar.rs index d8081924..498196d1 100644 --- a/paddler_gui/src/ui/style_download_progress_bar.rs +++ b/paddler_gui/src/ui/style_download_progress_bar.rs @@ -6,6 +6,7 @@ use iced::widget::progress_bar; use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_download_progress_bar(_theme: &Theme) -> progress_bar::Style { progress_bar::Style { background: Background::Color(COLOR_BODY_BACKGROUND), @@ -20,6 +21,11 @@ pub fn style_download_progress_bar(_theme: &Theme) -> progress_bar::Style { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use iced::Background; use iced::Theme; diff --git a/paddler_gui/src/ui/style_field_checkbox.rs b/paddler_gui/src/ui/style_field_checkbox.rs index a19ead9c..a7e1d413 100644 --- a/paddler_gui/src/ui/style_field_checkbox.rs +++ b/paddler_gui/src/ui/style_field_checkbox.rs @@ -7,6 +7,7 @@ use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BODY_FONT; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_field_checkbox(_theme: &Theme, status: checkbox::Status) -> checkbox::Style { let is_checked = matches!( status, @@ -35,6 +36,11 @@ pub fn style_field_checkbox(_theme: &Theme, status: checkbox::Status) -> checkbo #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use iced::Background; use iced::Theme; diff --git a/paddler_gui/src/ui/style_field_container.rs b/paddler_gui/src/ui/style_field_container.rs index 04fc174a..03cc055b 100644 --- a/paddler_gui/src/ui/style_field_container.rs +++ b/paddler_gui/src/ui/style_field_container.rs @@ -5,6 +5,7 @@ use iced::widget::container; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_field_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); @@ -20,6 +21,11 @@ pub fn style_field_container(theme: &Theme) -> container::Style { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use iced::Theme; diff --git a/paddler_gui/src/ui/style_field_pick_list.rs b/paddler_gui/src/ui/style_field_pick_list.rs index 2c38f3cb..7a8e0296 100644 --- a/paddler_gui/src/ui/style_field_pick_list.rs +++ b/paddler_gui/src/ui/style_field_pick_list.rs @@ -7,6 +7,7 @@ use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BODY_FONT; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_field_pick_list(theme: &Theme, status: pick_list::Status) -> pick_list::Style { let base = pick_list::default(theme, status); @@ -25,6 +26,11 @@ pub fn style_field_pick_list(theme: &Theme, status: pick_list::Status) -> pick_l #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use iced::Theme; use iced::widget::pick_list; diff --git a/paddler_gui/src/ui/style_field_pick_list_menu.rs b/paddler_gui/src/ui/style_field_pick_list_menu.rs index c993c809..e922c1dd 100644 --- a/paddler_gui/src/ui/style_field_pick_list_menu.rs +++ b/paddler_gui/src/ui/style_field_pick_list_menu.rs @@ -8,6 +8,7 @@ use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BODY_FONT; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_field_pick_list_menu(theme: &Theme) -> menu::Style { let base = menu::default(theme); @@ -27,6 +28,11 @@ pub fn style_field_pick_list_menu(theme: &Theme) -> menu::Style { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use iced::Theme; diff --git a/paddler_gui/src/ui/style_field_text_input.rs b/paddler_gui/src/ui/style_field_text_input.rs index 6f3ac6c3..6fd96ce0 100644 --- a/paddler_gui/src/ui/style_field_text_input.rs +++ b/paddler_gui/src/ui/style_field_text_input.rs @@ -7,6 +7,7 @@ use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BODY_FONT; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_field_text_input(theme: &Theme, status: text_input::Status) -> text_input::Style { let base = text_input::default(theme, status); @@ -26,6 +27,11 @@ pub fn style_field_text_input(theme: &Theme, status: text_input::Status) -> text #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use iced::Theme; use iced::widget::text_input; diff --git a/paddler_gui/src/ui/style_status_indicator.rs b/paddler_gui/src/ui/style_status_indicator.rs index acefad80..2c075ba8 100644 --- a/paddler_gui/src/ui/style_status_indicator.rs +++ b/paddler_gui/src/ui/style_status_indicator.rs @@ -4,6 +4,7 @@ use iced::Color; use iced::Theme; use iced::widget::container; +#[must_use] pub fn style_status_indicator(theme: &Theme) -> container::Style { let base = container::transparent(theme); @@ -20,6 +21,11 @@ pub fn style_status_indicator(theme: &Theme) -> container::Style { #[cfg(test)] mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + use anyhow::Result; use iced::Background; use iced::Color; diff --git a/paddler_gui_tests/src/bind_addresses.rs b/paddler_gui_tests/src/bind_addresses.rs index 8e842f79..58cee5bd 100644 --- a/paddler_gui_tests/src/bind_addresses.rs +++ b/paddler_gui_tests/src/bind_addresses.rs @@ -1,5 +1,6 @@ use std::net::SocketAddr; +#[derive(Clone, Copy)] pub struct BindAddresses { pub inference_addr: SocketAddr, pub management_addr: SocketAddr, diff --git a/paddler_gui_tests/src/make_agent_controller_snapshot.rs b/paddler_gui_tests/src/make_agent_controller_snapshot.rs index 1456c202..bf325b2c 100644 --- a/paddler_gui_tests/src/make_agent_controller_snapshot.rs +++ b/paddler_gui_tests/src/make_agent_controller_snapshot.rs @@ -33,6 +33,7 @@ impl Default for AgentSnapshotFixture { } } +#[must_use] pub fn make_agent_controller_snapshot(fixture: AgentSnapshotFixture) -> AgentControllerSnapshot { AgentControllerSnapshot { desired_slots_total: fixture.desired_slots_total, diff --git a/paddler_gui_tests/src/make_balancer_runner_params.rs b/paddler_gui_tests/src/make_balancer_runner_params.rs index cebdb462..d7392e7d 100644 --- a/paddler_gui_tests/src/make_balancer_runner_params.rs +++ b/paddler_gui_tests/src/make_balancer_runner_params.rs @@ -4,11 +4,11 @@ use paddler::balancer::inference_service::configuration::Configuration as Infere use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; use paddler::balancer::state_database_type::StateDatabaseType; use paddler_bootstrap::balancer_runner::BalancerRunnerParams; -use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio_util::sync::CancellationToken; use crate::bind_addresses::BindAddresses; +#[must_use] pub fn make_balancer_runner_params( addrs: BindAddresses, cancellation_token: CancellationToken, @@ -30,7 +30,7 @@ pub fn make_balancer_runner_params( openai_listener: None, openai_service_configuration: None, cancellation_token, - state_database_type: StateDatabaseType::Memory(Box::new(BalancerDesiredState::default())), + state_database_type: StateDatabaseType::Memory(Box::default()), statsd_prefix: "paddler_test_".to_owned(), statsd_service_configuration: None, #[cfg(feature = "web_admin_panel")] diff --git a/paddler_gui_tests/tests/running_an_agent_stops_cleanly_when_the_user_disconnects.rs b/paddler_gui_tests/tests/running_an_agent_stops_cleanly_when_the_user_disconnects.rs index 239f2f83..c63933e6 100644 --- a/paddler_gui_tests/tests/running_an_agent_stops_cleanly_when_the_user_disconnects.rs +++ b/paddler_gui_tests/tests/running_an_agent_stops_cleanly_when_the_user_disconnects.rs @@ -17,6 +17,10 @@ fn ephemeral_management_address() -> Result { } #[tokio::test] +#[expect( + clippy::needless_continue, + reason = "explicit continue documents the no-op branch for readability" +)] async fn cancellation_token_makes_the_agent_send_a_stopped_message_and_finish() -> Result<()> { let cancellation_token = CancellationToken::new(); let params = AgentRunnerParams { diff --git a/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs b/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs index 2bac7cbb..1ff0a1a4 100644 --- a/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs +++ b/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs @@ -6,6 +6,10 @@ use paddler_gui::start_balancer_form_data::StartBalancerFormData; use paddler_gui::start_balancer_form_handler::Message; use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; +#[expect( + clippy::missing_const_for_fn, + reason = "non-const helper keeps the surface uniform with other fixture helpers" +)] fn empty_form() -> StartBalancerFormData { StartBalancerFormData { add_model_later: false, diff --git a/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs b/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs index fac8b85d..913f07f1 100644 --- a/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs +++ b/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs @@ -5,6 +5,10 @@ use paddler_gui::address_field::AddressField; use paddler_gui::start_balancer_form_data::StartBalancerFormData; use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; +#[expect( + clippy::missing_const_for_fn, + reason = "non-const helper keeps the surface uniform with other fixture helpers" +)] fn empty_form() -> StartBalancerFormData { StartBalancerFormData { add_model_later: false, diff --git a/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs b/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs index c5c5e436..40960aed 100644 --- a/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs +++ b/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs @@ -54,6 +54,10 @@ async fn an_invalid_bind_address_tells_the_user_the_balancer_failed_to_start() - } #[tokio::test] +#[expect( + clippy::needless_continue, + reason = "explicit continue documents the no-op branch for readability" +)] async fn a_running_balancer_reports_started_and_then_stopped_when_the_user_cancels() -> Result<()> { let cancellation_token = CancellationToken::new(); let params = make_balancer_runner_params( diff --git a/paddler_ports/src/check_port.rs b/paddler_ports/src/check_port.rs index d6c8878f..457d9d88 100644 --- a/paddler_ports/src/check_port.rs +++ b/paddler_ports/src/check_port.rs @@ -72,12 +72,7 @@ mod tests { let address = listener.local_addr()?; drop(listener); - let result = check_port(&address.to_string()); - - let bound = match result { - Ok(bound) => bound, - Err(error) => return Err(error.into()), - }; + let bound = check_port(&address.to_string())?; if bound.socket_addr.port() != address.port() { anyhow::bail!("expected port {} to be preserved", address.port()); diff --git a/paddler_ports/src/port_check_error.rs b/paddler_ports/src/port_check_error.rs index 04d31969..7d996622 100644 --- a/paddler_ports/src/port_check_error.rs +++ b/paddler_ports/src/port_check_error.rs @@ -23,6 +23,7 @@ pub enum PortCheckError { } impl PortCheckError { + #[must_use] pub fn user_facing_message(&self) -> String { self.to_string() } From a0af486f5d464f7f2486dd3a0699b289fe8233e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 16 May 2026 18:17:54 +0200 Subject: [PATCH 07/13] cover AddressField and SlotCountField helper methods with focused unit tests --- paddler_gui/src/address_field.rs | 90 +++++++++++++++++++++++++++++ paddler_gui/src/slot_count_field.rs | 88 ++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) diff --git a/paddler_gui/src/address_field.rs b/paddler_gui/src/address_field.rs index 51af5610..843c45ea 100644 --- a/paddler_gui/src/address_field.rs +++ b/paddler_gui/src/address_field.rs @@ -56,3 +56,93 @@ impl AddressField { } } } + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + + use super::AddressField; + + #[test] + fn required_with_empty_input_resolves_to_empty() { + assert!(matches!( + AddressField::required_from_user_input(String::new()), + AddressField::Empty + )); + } + + #[test] + fn required_with_unparseable_input_resolves_to_invalid() { + assert!(matches!( + AddressField::required_from_user_input("not-a-socket".to_owned()), + AddressField::Invalid { .. } + )); + } + + #[test] + fn required_with_in_use_port_resolves_to_invalid() -> anyhow::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let addr = listener.local_addr()?; + + assert!(matches!( + AddressField::required_from_user_input(addr.to_string()), + AddressField::Invalid { .. } + )); + Ok(()) + } + + #[test] + fn optional_with_empty_input_resolves_to_empty() { + assert!(matches!( + AddressField::optional_from_user_input(String::new()), + AddressField::Empty + )); + } + + #[test] + fn optional_with_unparseable_input_resolves_to_invalid() { + assert!(matches!( + AddressField::optional_from_user_input("not-a-socket".to_owned()), + AddressField::Invalid { .. } + )); + } + + #[test] + fn optional_with_bindable_input_resolves_to_bound() -> anyhow::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + drop(listener); + + assert!(matches!( + AddressField::optional_from_user_input(address.to_string()), + AddressField::Bound { .. } + )); + Ok(()) + } + + #[test] + fn raw_text_returns_inner_raw_for_bound_and_invalid_variants() { + assert_eq!(AddressField::Empty.raw_text(), ""); + assert_eq!( + AddressField::Invalid { + raw: "raw-text".to_owned(), + error: "ignored".to_owned() + } + .raw_text(), + "raw-text" + ); + } + + #[test] + fn error_text_returns_error_only_for_invalid_variant() { + assert!(AddressField::Empty.error_text().is_none()); + assert_eq!( + AddressField::Invalid { + raw: String::new(), + error: "the error".to_owned() + } + .error_text(), + Some("the error") + ); + } +} diff --git a/paddler_gui/src/slot_count_field.rs b/paddler_gui/src/slot_count_field.rs index f6d7c894..453aee1b 100644 --- a/paddler_gui/src/slot_count_field.rs +++ b/paddler_gui/src/slot_count_field.rs @@ -58,3 +58,91 @@ impl SlotCountField { } } } + +#[cfg(test)] +mod tests { + use super::SlotCountField; + + #[test] + fn empty_input_resolves_to_empty_variant() { + assert!(matches!( + SlotCountField::from_user_input(String::new()), + SlotCountField::Empty + )); + } + + #[test] + fn positive_digit_input_resolves_to_valid_variant() { + assert!(matches!( + SlotCountField::from_user_input("42".to_owned()), + SlotCountField::Valid { value: 42, .. } + )); + } + + #[test] + fn zero_input_resolves_to_invalid_with_greater_than_zero_message() { + assert!(matches!( + SlotCountField::from_user_input("0".to_owned()), + SlotCountField::Invalid { error, .. } if error.contains("greater than zero") + )); + } + + #[test] + fn non_digit_input_resolves_to_invalid_with_generic_message() { + assert!(matches!( + SlotCountField::from_user_input("abc".to_owned()), + SlotCountField::Invalid { error, .. } if error == "Invalid number of slots." + )); + } + + #[test] + fn digit_input_overflowing_i32_resolves_to_too_large_message() { + assert!(matches!( + SlotCountField::from_user_input("9999999999".to_owned()), + SlotCountField::Invalid { error, .. } if error == "Number of slots is too large." + )); + } + + #[test] + fn raw_text_returns_inner_raw_for_valid_and_invalid_variants() { + assert_eq!(SlotCountField::Empty.raw_text(), ""); + assert_eq!( + SlotCountField::Valid { + raw: "5".to_owned(), + value: 5 + } + .raw_text(), + "5" + ); + assert_eq!( + SlotCountField::Invalid { + raw: "abc".to_owned(), + error: "Invalid number of slots.".to_owned() + } + .raw_text(), + "abc" + ); + } + + #[test] + fn error_text_returns_error_only_for_invalid_variant() { + assert!(SlotCountField::Empty.error_text().is_none()); + assert!( + SlotCountField::Valid { + raw: "5".to_owned(), + value: 5 + } + .error_text() + .is_none() + ); + assert_eq!( + SlotCountField::Invalid { + raw: "abc".to_owned(), + error: "Invalid number of slots.".to_owned() + } + .error_text(), + Some("Invalid number of slots.") + ); + } + +} From da1e5b9088cbe09b085245941c6a6b5bf1fc85a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 16 May 2026 18:23:34 +0200 Subject: [PATCH 08/13] cover the balancer-start-failed-with-disconnected-ui defensive log path --- ...ng_a_balancer_succeeds_or_fails_visibly.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs b/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs index 40960aed..67d2ed10 100644 --- a/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs +++ b/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs @@ -130,3 +130,25 @@ async fn when_the_ui_goes_away_the_balancer_stream_exits_without_panicking() -> Ok(()) } + +#[tokio::test] +async fn a_failed_start_with_a_disconnected_ui_logs_the_error_and_exits_cleanly() -> Result<()> { + let invalid: SocketAddr = INVALID_BIND_ADDR.parse()?; + let cancellation_token = CancellationToken::new(); + let params = make_balancer_runner_params( + BindAddresses { + inference_addr: invalid, + management_addr: invalid, + }, + cancellation_token, + ); + + let (output, receiver) = mpsc::channel::(1); + drop(receiver); + + let driver = tokio::spawn(drive_balancer_stream(params, output)); + + tokio::time::timeout(BALANCER_STREAM_TIMEOUT, driver).await??; + + Ok(()) +} From 0176f9f49c4328fbde2f56add6f5e712495e2d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 16 May 2026 18:33:09 +0200 Subject: [PATCH 09/13] extract drive_agent_stream_inner so snapshot and completion failure paths become test-injectable --- paddler_gui/src/drive_agent_stream.rs | 64 +------ paddler_gui/src/drive_agent_stream_inner.rs | 81 +++++++++ paddler_gui/src/lib.rs | 1 + ...nt_handles_snapshot_and_update_failures.rs | 172 ++++++++++++++++++ 4 files changed, 258 insertions(+), 60 deletions(-) create mode 100644 paddler_gui/src/drive_agent_stream_inner.rs create mode 100644 paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs diff --git a/paddler_gui/src/drive_agent_stream.rs b/paddler_gui/src/drive_agent_stream.rs index f1eaf785..e0bec877 100644 --- a/paddler_gui/src/drive_agent_stream.rs +++ b/paddler_gui/src/drive_agent_stream.rs @@ -1,71 +1,15 @@ -use iced::futures::SinkExt as _; use iced::futures::channel::mpsc::Sender; -use paddler::produces_snapshot::ProducesSnapshot as _; -use paddler::subscribes_to_updates::SubscribesToUpdates as _; use paddler_bootstrap::agent_runner::AgentRunner; use paddler_bootstrap::agent_runner::AgentRunnerParams; -use crate::agent_running_handler; +use crate::drive_agent_stream_inner::drive_agent_stream_inner; use crate::message::Message; -pub async fn drive_agent_stream(params: AgentRunnerParams, mut output: Sender) { +pub async fn drive_agent_stream(params: AgentRunnerParams, output: Sender) { let mut runner = AgentRunner::start(params); - let slot_aggregated_status = runner.slot_aggregated_status.clone(); - let mut update_rx = slot_aggregated_status.subscribe_to_updates(); + let snapshot_source = runner.slot_aggregated_status.clone(); let completion_future = runner.wait_for_completion(); - tokio::pin!(completion_future); - loop { - match slot_aggregated_status.make_snapshot() { - Ok(snapshot) => { - if output - .send(Message::AgentRunning( - agent_running_handler::Message::AgentStatusUpdated(snapshot), - )) - .await - .is_err() - { - return; - } - } - Err(error) => { - log::error!("Failed to make agent status snapshot: {error}"); - - return; - } - } - - tokio::select! { - changed = update_rx.changed() => { - if changed.is_err() { - return; - } - } - result = &mut completion_future => { - match result { - Ok(()) => { - if let Err(err) = output.send(Message::AgentStopped).await { - log::warn!( - "Failed to deliver AgentStopped to UI (receiver dropped): {err}" - ); - } - } - Err(error) => { - let detail = error.to_string(); - if let Err(err) = output - .send(Message::AgentFailed(detail.clone())) - .await - { - log::error!( - "Failed to deliver AgentFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" - ); - } - } - } - - return; - } - } - } + drive_agent_stream_inner(snapshot_source, completion_future, output).await; } diff --git a/paddler_gui/src/drive_agent_stream_inner.rs b/paddler_gui/src/drive_agent_stream_inner.rs new file mode 100644 index 00000000..1ddaab4d --- /dev/null +++ b/paddler_gui/src/drive_agent_stream_inner.rs @@ -0,0 +1,81 @@ +use std::future::Future; +use std::sync::Arc; + +use anyhow::Result; +use iced::futures::SinkExt as _; +use iced::futures::channel::mpsc::Sender; +use paddler::produces_snapshot::ProducesSnapshot; +use paddler::subscribes_to_updates::SubscribesToUpdates; +use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; + +use crate::agent_running_handler; +use crate::message::Message; + +pub async fn drive_agent_stream_inner( + snapshot_source: Arc, + completion_future: TCompletion, + mut output: Sender, +) where + TSource: ProducesSnapshot + + SubscribesToUpdates + + Send + + Sync + + 'static, + TCompletion: Future> + Send, +{ + let mut update_rx = snapshot_source.subscribe_to_updates(); + tokio::pin!(completion_future); + + loop { + match snapshot_source.make_snapshot() { + Ok(snapshot) => { + if output + .send(Message::AgentRunning( + agent_running_handler::Message::AgentStatusUpdated(snapshot), + )) + .await + .is_err() + { + return; + } + } + Err(error) => { + log::error!("Failed to make agent status snapshot: {error}"); + + return; + } + } + + tokio::select! { + changed = update_rx.changed() => { + if changed.is_err() { + return; + } + } + result = &mut completion_future => { + match result { + Ok(()) => { + if let Err(err) = output.send(Message::AgentStopped).await { + log::warn!( + "Failed to deliver AgentStopped to UI (receiver dropped): {err}" + ); + } + } + Err(error) => { + let detail = error.to_string(); + if let Err(err) = output + .send(Message::AgentFailed(detail.clone())) + .await + { + log::error!( + "Failed to deliver AgentFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" + ); + } + } + } + + return; + } + } + } +} diff --git a/paddler_gui/src/lib.rs b/paddler_gui/src/lib.rs index 736bba5b..1c7800ae 100644 --- a/paddler_gui/src/lib.rs +++ b/paddler_gui/src/lib.rs @@ -5,6 +5,7 @@ pub mod app; pub mod current_screen; pub mod detect_network_interfaces; pub mod drive_agent_stream; +pub mod drive_agent_stream_inner; pub mod drive_balancer_stream; pub mod drive_shutdown_signal_stream; pub mod home_data; diff --git a/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs b/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs new file mode 100644 index 00000000..f2a0c79d --- /dev/null +++ b/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs @@ -0,0 +1,172 @@ +use std::future::pending; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use iced::futures::StreamExt as _; +use iced::futures::channel::mpsc; +use paddler::produces_snapshot::ProducesSnapshot; +use paddler::subscribes_to_updates::SubscribesToUpdates; +use paddler_gui::drive_agent_stream_inner::drive_agent_stream_inner; +use paddler_gui::message::Message; +use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; +use tokio::sync::watch; + +const AGENT_STREAM_TIMEOUT: Duration = Duration::from_secs(5); + +struct FailingSnapshotSource; + +impl ProducesSnapshot for FailingSnapshotSource { + type Snapshot = SlotAggregatedStatusSnapshot; + + fn make_snapshot(&self) -> anyhow::Result { + Err(anyhow!("simulated snapshot failure")) + } +} + +impl SubscribesToUpdates for FailingSnapshotSource { + fn subscribe_to_updates(&self) -> watch::Receiver<()> { + let (_, rx) = watch::channel(()); + rx + } +} + +struct ImmediatelyDisconnectedUpdateSource; + +impl ProducesSnapshot for ImmediatelyDisconnectedUpdateSource { + type Snapshot = SlotAggregatedStatusSnapshot; + + fn make_snapshot(&self) -> anyhow::Result { + Ok(SlotAggregatedStatusSnapshot::default()) + } +} + +impl SubscribesToUpdates for ImmediatelyDisconnectedUpdateSource { + fn subscribe_to_updates(&self) -> watch::Receiver<()> { + let (_, rx) = watch::channel(()); + rx + } +} + +#[tokio::test] +async fn snapshot_failure_exits_the_agent_stream_before_emitting_any_message() -> Result<()> { + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_agent_stream_inner( + Arc::new(FailingSnapshotSource), + pending::>(), + output, + )); + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + assert!(receiver.next().await.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn update_channel_disconnection_exits_the_agent_stream_after_the_first_snapshot() +-> Result<()> { + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_agent_stream_inner( + Arc::new(ImmediatelyDisconnectedUpdateSource), + pending::>(), + output, + )); + + let first_message = + tokio::time::timeout(AGENT_STREAM_TIMEOUT, receiver.next()).await?; + + assert!(matches!( + first_message, + Some(Message::AgentRunning(_)) + )); + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + Ok(()) +} + +#[tokio::test] +async fn agent_runner_completion_with_error_emits_agent_failed_message() -> Result<()> { + let (update_tx, update_rx) = watch::channel(()); + + struct StaticUpdateSource(watch::Receiver<()>); + + impl ProducesSnapshot for StaticUpdateSource { + type Snapshot = SlotAggregatedStatusSnapshot; + + fn make_snapshot(&self) -> anyhow::Result { + Ok(SlotAggregatedStatusSnapshot::default()) + } + } + + impl SubscribesToUpdates for StaticUpdateSource { + fn subscribe_to_updates(&self) -> watch::Receiver<()> { + self.0.clone() + } + } + + let source = Arc::new(StaticUpdateSource(update_rx)); + let (output, mut receiver) = mpsc::channel::(8); + + let completion = async { Err(anyhow!("agent runner exited unexpectedly")) }; + + let driver = tokio::spawn(drive_agent_stream_inner(source, completion, output)); + + // Hold the sender alive long enough to ensure the completion future resolves first. + let collected = async { + let mut observed_failed = false; + while let Some(message) = receiver.next().await { + if matches!(message, Message::AgentFailed(_)) { + observed_failed = true; + break; + } + } + observed_failed + }; + + let observed_failed = tokio::time::timeout(AGENT_STREAM_TIMEOUT, collected).await?; + drop(update_tx); + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + assert!(observed_failed); + Ok(()) +} + +#[tokio::test] +async fn snapshot_send_failure_after_first_iteration_exits_the_agent_stream() -> Result<()> { + struct StaticSource; + + impl ProducesSnapshot for StaticSource { + type Snapshot = SlotAggregatedStatusSnapshot; + + fn make_snapshot(&self) -> anyhow::Result { + Ok(SlotAggregatedStatusSnapshot::default()) + } + } + + impl SubscribesToUpdates for StaticSource { + fn subscribe_to_updates(&self) -> watch::Receiver<()> { + let (_, rx) = watch::channel(()); + rx + } + } + + let (output, receiver) = mpsc::channel::(8); + drop(receiver); + + let driver = tokio::spawn(drive_agent_stream_inner( + Arc::new(StaticSource), + pending::>(), + output, + )); + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + Ok(()) +} From 9e61a66e87922ec5a0ae14f2863bb797919fdafb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 16 May 2026 18:38:40 +0200 Subject: [PATCH 10/13] extract drive_balancer_loop_inner so snapshot, update, and completion failure paths become test-injectable --- paddler_gui/src/drive_balancer_loop_inner.rs | 106 +++++++ paddler_gui/src/drive_balancer_stream.rs | 107 ++----- paddler_gui/src/lib.rs | 1 + ...snapshot_update_and_completion_failures.rs | 288 ++++++++++++++++++ ...nt_handles_snapshot_and_update_failures.rs | 53 ++-- 5 files changed, 442 insertions(+), 113 deletions(-) create mode 100644 paddler_gui/src/drive_balancer_loop_inner.rs create mode 100644 paddler_gui_tests/tests/running_a_balancer_loop_handles_snapshot_update_and_completion_failures.rs diff --git a/paddler_gui/src/drive_balancer_loop_inner.rs b/paddler_gui/src/drive_balancer_loop_inner.rs new file mode 100644 index 00000000..5d234342 --- /dev/null +++ b/paddler_gui/src/drive_balancer_loop_inner.rs @@ -0,0 +1,106 @@ +use std::future::Future; + +use anyhow::Result; +use iced::futures::SinkExt as _; +use iced::futures::channel::mpsc::Sender; +use paddler_types::balancer_desired_state::BalancerDesiredState; +use tokio::sync::broadcast; +use tokio::sync::watch; + +use crate::message::Message; +use crate::running_balancer_handler; +use crate::running_balancer_snapshot::RunningBalancerSnapshot; + +pub async fn drive_balancer_loop_inner( + initial_desired_state: BalancerDesiredState, + mut pool_update_rx: watch::Receiver<()>, + mut holder_update_rx: watch::Receiver<()>, + mut desired_state_rx: broadcast::Receiver, + completion_future: TCompletion, + mut snapshot_fn: TSnapshotFn, + mut output: Sender, +) where + TSnapshotFn: FnMut(&BalancerDesiredState) -> Result + Send, + TCompletion: Future> + Send, +{ + let mut current_desired_state = initial_desired_state; + tokio::pin!(completion_future); + + loop { + match snapshot_fn(¤t_desired_state) { + Ok(snapshot) => { + if output + .send(Message::RunningBalancer( + running_balancer_handler::Message::SnapshotUpdated(Box::new(snapshot)), + )) + .await + .is_err() + { + return; + } + } + Err(error) => { + log::error!("Failed to build running balancer snapshot: {error}"); + + return; + } + } + + tokio::select! { + changed = pool_update_rx.changed() => { + if changed.is_err() { + return; + } + } + changed = holder_update_rx.changed() => { + if changed.is_err() { + return; + } + } + desired_state_result = desired_state_rx.recv() => { + match desired_state_result { + Ok(new_desired_state) => { + current_desired_state = new_desired_state; + } + Err(broadcast::error::RecvError::Lagged(missed)) => { + log::warn!( + "Desired-state broadcast lagged by {missed} messages; \ + continuing with the last known state" + ); + } + Err(broadcast::error::RecvError::Closed) => { + log::info!( + "Desired-state broadcast closed; ending snapshot stream" + ); + + return; + } + } + } + result = &mut completion_future => { + match result { + Ok(()) => { + if let Err(err) = output.send(Message::BalancerStopped).await { + log::warn!( + "Failed to deliver BalancerStopped to UI (receiver dropped): {err}" + ); + } + } + Err(error) => { + let detail = error.to_string(); + if let Err(err) = output + .send(Message::BalancerFailed(detail.clone())) + .await + { + log::error!( + "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" + ); + } + } + } + + return; + } + } + } +} diff --git a/paddler_gui/src/drive_balancer_stream.rs b/paddler_gui/src/drive_balancer_stream.rs index 01f7f9e1..b5063a5f 100644 --- a/paddler_gui/src/drive_balancer_stream.rs +++ b/paddler_gui/src/drive_balancer_stream.rs @@ -3,10 +3,10 @@ use iced::futures::channel::mpsc::Sender; use paddler::subscribes_to_updates::SubscribesToUpdates as _; use paddler_bootstrap::balancer_runner::BalancerRunner; use paddler_bootstrap::balancer_runner::BalancerRunnerParams; -use tokio::sync::broadcast; +use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::drive_balancer_loop_inner::drive_balancer_loop_inner; use crate::message::Message; -use crate::running_balancer_handler; use crate::running_balancer_snapshot::RunningBalancerSnapshot; pub async fn drive_balancer_stream(params: BalancerRunnerParams, mut output: Sender) { @@ -25,98 +25,33 @@ pub async fn drive_balancer_stream(params: BalancerRunnerParams, mut output: Sen }; let completion_future = runner.wait_for_completion(); - tokio::pin!(completion_future); if output.send(Message::BalancerStarted).await.is_err() { return; } - let mut desired_state_rx = runner.balancer_desired_state_tx.subscribe(); - let mut current_desired_state = runner.initial_desired_state.clone(); - let mut pool_update_rx = runner.agent_controller_pool.subscribe_to_updates(); - let mut holder_update_rx = runner + let desired_state_rx = runner.balancer_desired_state_tx.subscribe(); + let initial_desired_state = runner.initial_desired_state.clone(); + let pool_update_rx = runner.agent_controller_pool.subscribe_to_updates(); + let holder_update_rx = runner .balancer_applicable_state_holder .subscribe_to_updates(); - loop { - match RunningBalancerSnapshot::build( - &runner.agent_controller_pool, - &runner.balancer_applicable_state_holder, - current_desired_state.clone(), - ) { - Ok(snapshot) => { - if output - .send(Message::RunningBalancer( - running_balancer_handler::Message::SnapshotUpdated(Box::new(snapshot)), - )) - .await - .is_err() - { - return; - } - } - Err(error) => { - log::error!("Failed to build running balancer snapshot: {error}"); - - return; - } - } - - tokio::select! { - changed = pool_update_rx.changed() => { - if changed.is_err() { - return; - } - } - changed = holder_update_rx.changed() => { - if changed.is_err() { - return; - } - } - desired_state_result = desired_state_rx.recv() => { - match desired_state_result { - Ok(new_desired_state) => { - current_desired_state = new_desired_state; - } - Err(broadcast::error::RecvError::Lagged(missed)) => { - log::warn!( - "Desired-state broadcast lagged by {missed} messages; \ - continuing with the last known state" - ); - } - Err(broadcast::error::RecvError::Closed) => { - log::info!( - "Desired-state broadcast closed; ending snapshot stream" - ); + let pool = runner.agent_controller_pool.clone(); + let holder = runner.balancer_applicable_state_holder.clone(); - return; - } - } - } - result = &mut completion_future => { - match result { - Ok(()) => { - if let Err(err) = output.send(Message::BalancerStopped).await { - log::warn!( - "Failed to deliver BalancerStopped to UI (receiver dropped): {err}" - ); - } - } - Err(error) => { - let detail = error.to_string(); - if let Err(err) = output - .send(Message::BalancerFailed(detail.clone())) - .await - { - log::error!( - "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" - ); - } - } - } + let snapshot_fn = move |state: &BalancerDesiredState| { + RunningBalancerSnapshot::build(&pool, &holder, state.clone()) + }; - return; - } - } - } + drive_balancer_loop_inner( + initial_desired_state, + pool_update_rx, + holder_update_rx, + desired_state_rx, + completion_future, + snapshot_fn, + output, + ) + .await; } diff --git a/paddler_gui/src/lib.rs b/paddler_gui/src/lib.rs index 1c7800ae..a49e2142 100644 --- a/paddler_gui/src/lib.rs +++ b/paddler_gui/src/lib.rs @@ -6,6 +6,7 @@ pub mod current_screen; pub mod detect_network_interfaces; pub mod drive_agent_stream; pub mod drive_agent_stream_inner; +pub mod drive_balancer_loop_inner; pub mod drive_balancer_stream; pub mod drive_shutdown_signal_stream; pub mod home_data; diff --git a/paddler_gui_tests/tests/running_a_balancer_loop_handles_snapshot_update_and_completion_failures.rs b/paddler_gui_tests/tests/running_a_balancer_loop_handles_snapshot_update_and_completion_failures.rs new file mode 100644 index 00000000..95b2dbd4 --- /dev/null +++ b/paddler_gui_tests/tests/running_a_balancer_loop_handles_snapshot_update_and_completion_failures.rs @@ -0,0 +1,288 @@ +use std::future::pending; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use iced::futures::StreamExt as _; +use iced::futures::channel::mpsc; +use paddler_gui::drive_balancer_loop_inner::drive_balancer_loop_inner; +use paddler_gui::message::Message; +use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; +use paddler_types::balancer_desired_state::BalancerDesiredState; +use tokio::sync::broadcast; +use tokio::sync::watch; + +const BALANCER_LOOP_TIMEOUT: Duration = Duration::from_secs(5); + +#[tokio::test] +async fn snapshot_build_failure_exits_the_balancer_loop() -> Result<()> { + let (_, pool_update_rx) = watch::channel(()); + let (_, holder_update_rx) = watch::channel(()); + let (_, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + pending::>(), + |_| Err(anyhow!("snapshot build failed")), + output, + )); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + assert!(receiver.next().await.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn pool_update_channel_closure_exits_the_balancer_loop_after_a_snapshot() -> Result<()> { + let (_, holder_update_rx) = watch::channel(()); + let (_, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(8); + + // Drop the pool sender so `changed()` will fail on the first await. + let (_, pool_update_rx) = watch::channel(()); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + pending::>(), + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + let first_message = tokio::time::timeout(BALANCER_LOOP_TIMEOUT, receiver.next()).await?; + assert!(matches!(first_message, Some(Message::RunningBalancer(_)))); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + Ok(()) +} + +#[tokio::test] +async fn desired_state_broadcast_closure_exits_the_balancer_loop_after_a_snapshot() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (desired_state_tx, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + pending::>(), + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + // Wait for the first snapshot to land, then close the desired-state broadcast. + let first_message = tokio::time::timeout(BALANCER_LOOP_TIMEOUT, receiver.next()).await?; + assert!(matches!(first_message, Some(Message::RunningBalancer(_)))); + + drop(desired_state_tx); + drop(pool_update_tx); + drop(holder_update_tx); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + Ok(()) +} + +#[tokio::test] +async fn completion_with_ok_emits_balancer_stopped_message() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (_desired_state_tx, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + async { Ok(()) }, + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + let mut observed_stopped = false; + let collect = async { + while let Some(message) = receiver.next().await { + if matches!(message, Message::BalancerStopped) { + observed_stopped = true; + break; + } + } + }; + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, collect).await?; + + drop(pool_update_tx); + drop(holder_update_tx); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + assert!(observed_stopped); + Ok(()) +} + +#[tokio::test] +async fn completion_with_error_emits_balancer_failed_message() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (_desired_state_tx, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + async { Err(anyhow!("runner crashed")) }, + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + let mut observed_failed = false; + let collect = async { + while let Some(message) = receiver.next().await { + if matches!(message, Message::BalancerFailed(_)) { + observed_failed = true; + break; + } + } + }; + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, collect).await?; + + drop(pool_update_tx); + drop(holder_update_tx); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + assert!(observed_failed); + Ok(()) +} + +#[tokio::test] +async fn snapshot_send_failure_after_first_iteration_exits_the_balancer_loop() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (desired_state_tx, desired_state_rx) = broadcast::channel::(16); + let (output, receiver) = mpsc::channel::(1); + drop(receiver); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + pending::>(), + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + drop(pool_update_tx); + drop(holder_update_tx); + drop(desired_state_tx); + + Ok(()) +} + +#[tokio::test] +async fn desired_state_lagged_broadcast_continues_the_balancer_loop() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (desired_state_tx, desired_state_rx) = broadcast::channel::(1); + let (output, mut receiver) = mpsc::channel::(64); + + // Fill the broadcast buffer beyond capacity to trigger Lagged on first recv. + for _ in 0..3 { + desired_state_tx.send(BalancerDesiredState::default()).ok(); + } + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + async { Ok(()) }, + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + let mut observed_stopped = false; + let collect = async { + while let Some(message) = receiver.next().await { + if matches!(message, Message::BalancerStopped) { + observed_stopped = true; + break; + } + } + }; + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, collect).await?; + + drop(pool_update_tx); + drop(holder_update_tx); + drop(desired_state_tx); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + assert!(observed_stopped); + Ok(()) +} + +#[tokio::test] +async fn desired_state_update_replaces_current_state_in_the_balancer_loop() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (desired_state_tx, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(64); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + async { Ok(()) }, + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + // Wait for the first snapshot, then publish a desired-state update. + let first = tokio::time::timeout(BALANCER_LOOP_TIMEOUT, receiver.next()).await?; + assert!(matches!(first, Some(Message::RunningBalancer(_)))); + + desired_state_tx.send(BalancerDesiredState::default()).ok(); + + let mut observed_stopped = false; + let collect = async { + while let Some(message) = receiver.next().await { + if matches!(message, Message::BalancerStopped) { + observed_stopped = true; + break; + } + } + }; + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, collect).await?; + + drop(pool_update_tx); + drop(holder_update_tx); + drop(desired_state_tx); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + assert!(observed_stopped); + Ok(()) +} diff --git a/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs b/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs index f2a0c79d..f737fe76 100644 --- a/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs +++ b/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs @@ -90,26 +90,25 @@ async fn update_channel_disconnection_exits_the_agent_stream_after_the_first_sna Ok(()) } -#[tokio::test] -async fn agent_runner_completion_with_error_emits_agent_failed_message() -> Result<()> { - let (update_tx, update_rx) = watch::channel(()); - - struct StaticUpdateSource(watch::Receiver<()>); +struct StaticUpdateSource(watch::Receiver<()>); - impl ProducesSnapshot for StaticUpdateSource { - type Snapshot = SlotAggregatedStatusSnapshot; +impl ProducesSnapshot for StaticUpdateSource { + type Snapshot = SlotAggregatedStatusSnapshot; - fn make_snapshot(&self) -> anyhow::Result { - Ok(SlotAggregatedStatusSnapshot::default()) - } + fn make_snapshot(&self) -> anyhow::Result { + Ok(SlotAggregatedStatusSnapshot::default()) } +} - impl SubscribesToUpdates for StaticUpdateSource { - fn subscribe_to_updates(&self) -> watch::Receiver<()> { - self.0.clone() - } +impl SubscribesToUpdates for StaticUpdateSource { + fn subscribe_to_updates(&self) -> watch::Receiver<()> { + self.0.clone() } +} +#[tokio::test] +async fn agent_runner_completion_with_error_emits_agent_failed_message() -> Result<()> { + let (update_tx, update_rx) = watch::channel(()); let source = Arc::new(StaticUpdateSource(update_rx)); let (output, mut receiver) = mpsc::channel::(8); @@ -138,25 +137,25 @@ async fn agent_runner_completion_with_error_emits_agent_failed_message() -> Resu Ok(()) } -#[tokio::test] -async fn snapshot_send_failure_after_first_iteration_exits_the_agent_stream() -> Result<()> { - struct StaticSource; +struct StaticSource; - impl ProducesSnapshot for StaticSource { - type Snapshot = SlotAggregatedStatusSnapshot; +impl ProducesSnapshot for StaticSource { + type Snapshot = SlotAggregatedStatusSnapshot; - fn make_snapshot(&self) -> anyhow::Result { - Ok(SlotAggregatedStatusSnapshot::default()) - } + fn make_snapshot(&self) -> anyhow::Result { + Ok(SlotAggregatedStatusSnapshot::default()) } +} - impl SubscribesToUpdates for StaticSource { - fn subscribe_to_updates(&self) -> watch::Receiver<()> { - let (_, rx) = watch::channel(()); - rx - } +impl SubscribesToUpdates for StaticSource { + fn subscribe_to_updates(&self) -> watch::Receiver<()> { + let (_, rx) = watch::channel(()); + rx } +} +#[tokio::test] +async fn snapshot_send_failure_after_first_iteration_exits_the_agent_stream() -> Result<()> { let (output, receiver) = mpsc::channel::(8); drop(receiver); From d79a53b2d176ee5c3b9ca43b9637e9eec2dc8308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 16 May 2026 20:08:28 +0200 Subject: [PATCH 11/13] release reserved listeners before spawning paddler subprocess so it can bind --- Makefile | 2 +- paddler_tests/src/start_subprocess_cluster.rs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 1c023dd5..9031d115 100644 --- a/Makefile +++ b/Makefile @@ -118,7 +118,7 @@ test.integration.metal: target/metal/debug/paddler .PHONY: test.unit test.unit: esbuild-meta.json - cargo test --features web_admin_panel + cargo test --workspace --features web_admin_panel .PHONY: build.client.js build.client.js: diff --git a/paddler_tests/src/start_subprocess_cluster.rs b/paddler_tests/src/start_subprocess_cluster.rs index b676e8c7..936435f4 100644 --- a/paddler_tests/src/start_subprocess_cluster.rs +++ b/paddler_tests/src/start_subprocess_cluster.rs @@ -30,7 +30,7 @@ pub async fn start_subprocess_cluster( wait_for_slots_ready, }: SubprocessClusterParams, ) -> Result { - let addresses = BalancerAddresses::pick()?; + let mut addresses = BalancerAddresses::pick()?; let mut balancer_command = paddler_command(); @@ -65,6 +65,13 @@ pub async fn start_subprocess_cluster( .arg(allowed_host); } + // Release the reserved listeners so the subprocess can bind to the same addresses. + // The reservation prevented another concurrent test from claiming the same port; once + // the subprocess is spawned with those addresses on its CLI, it does its own bind. + addresses.inference_listener = None; + addresses.management_listener = None; + addresses.compat_openai_listener = None; + let balancer = balancer_command .spawn() .context("failed to spawn paddler balancer subprocess")?; From 57dc28f18f227d7cc2dab0477b79e2a6ca5667ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 16 May 2026 20:10:09 +0200 Subject: [PATCH 12/13] format code --- paddler_gui/src/address_field.rs | 10 ++++++++-- paddler_gui/src/slot_count_field.rs | 11 ++++++++--- ...admin_panel_from_the_running_balancer_screen.rs | 3 +-- paddler_gui_tests/tests/os_signals_quit_the_app.rs | 14 ++++++++------ ...n_agent_handles_snapshot_and_update_failures.rs | 12 ++++-------- .../tests/stopping_a_running_balancer.rs | 3 +-- 6 files changed, 30 insertions(+), 23 deletions(-) diff --git a/paddler_gui/src/address_field.rs b/paddler_gui/src/address_field.rs index 843c45ea..78ea3b16 100644 --- a/paddler_gui/src/address_field.rs +++ b/paddler_gui/src/address_field.rs @@ -6,8 +6,14 @@ use paddler_ports::check_port::check_port; pub enum AddressField { #[default] Empty, - Bound { raw: String, port: BoundPort }, - Invalid { raw: String, error: String }, + Bound { + raw: String, + port: BoundPort, + }, + Invalid { + raw: String, + error: String, + }, } impl AddressField { diff --git a/paddler_gui/src/slot_count_field.rs b/paddler_gui/src/slot_count_field.rs index 453aee1b..d2cda642 100644 --- a/paddler_gui/src/slot_count_field.rs +++ b/paddler_gui/src/slot_count_field.rs @@ -4,8 +4,14 @@ use std::num::IntErrorKind; pub enum SlotCountField { #[default] Empty, - Valid { raw: String, value: i32 }, - Invalid { raw: String, error: String }, + Valid { + raw: String, + value: i32, + }, + Invalid { + raw: String, + error: String, + }, } impl SlotCountField { @@ -144,5 +150,4 @@ mod tests { Some("Invalid number of slots.") ); } - } diff --git a/paddler_gui_tests/tests/opening_the_web_admin_panel_from_the_running_balancer_screen.rs b/paddler_gui_tests/tests/opening_the_web_admin_panel_from_the_running_balancer_screen.rs index 3c6d6f98..87466b7b 100644 --- a/paddler_gui_tests/tests/opening_the_web_admin_panel_from_the_running_balancer_screen.rs +++ b/paddler_gui_tests/tests/opening_the_web_admin_panel_from_the_running_balancer_screen.rs @@ -16,8 +16,7 @@ fn data_with_panel(address: Option<&str>) -> RunningBalancerData { } #[test] -fn clicking_open_in_browser_sends_the_open_url_message_when_a_panel_address_is_set() -> Result<()> -{ +fn clicking_open_in_browser_sends_the_open_url_message_when_a_panel_address_is_set() -> Result<()> { let data = data_with_panel(Some("127.0.0.1:8062")); let mut simulator = simulator(view_running_balancer(&data)); diff --git a/paddler_gui_tests/tests/os_signals_quit_the_app.rs b/paddler_gui_tests/tests/os_signals_quit_the_app.rs index 04723517..43e11a48 100644 --- a/paddler_gui_tests/tests/os_signals_quit_the_app.rs +++ b/paddler_gui_tests/tests/os_signals_quit_the_app.rs @@ -16,12 +16,15 @@ const SIGNAL_DELIVERY_TIMEOUT: Duration = Duration::from_secs(5); #[tokio::test] #[serial] -async fn if_signal_registration_fails_the_app_logs_and_keeps_running_without_quitting() --> Result<()> { +async fn if_signal_registration_fails_the_app_logs_and_keeps_running_without_quitting() -> Result<()> +{ let (output, mut receiver) = mpsc::channel::(1); - drive_shutdown_signal_stream(Err(anyhow::anyhow!("simulated registration failure")), output) - .await; + drive_shutdown_signal_stream( + Err(anyhow::anyhow!("simulated registration failure")), + output, + ) + .await; match receiver.next().await { None => Ok(()), @@ -41,8 +44,7 @@ async fn sigterm_delivered_to_the_process_makes_the_app_quit() -> Result<()> { tokio::task::yield_now().await; kill(Pid::this(), Signal::SIGTERM)?; - let received = - tokio::time::timeout(SIGNAL_DELIVERY_TIMEOUT, receiver.next()).await?; + let received = tokio::time::timeout(SIGNAL_DELIVERY_TIMEOUT, receiver.next()).await?; match received { Some(Message::Quit) => {} diff --git a/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs b/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs index f737fe76..4bc73596 100644 --- a/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs +++ b/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs @@ -67,8 +67,8 @@ async fn snapshot_failure_exits_the_agent_stream_before_emitting_any_message() - } #[tokio::test] -async fn update_channel_disconnection_exits_the_agent_stream_after_the_first_snapshot() --> Result<()> { +async fn update_channel_disconnection_exits_the_agent_stream_after_the_first_snapshot() -> Result<()> +{ let (output, mut receiver) = mpsc::channel::(8); let driver = tokio::spawn(drive_agent_stream_inner( @@ -77,13 +77,9 @@ async fn update_channel_disconnection_exits_the_agent_stream_after_the_first_sna output, )); - let first_message = - tokio::time::timeout(AGENT_STREAM_TIMEOUT, receiver.next()).await?; + let first_message = tokio::time::timeout(AGENT_STREAM_TIMEOUT, receiver.next()).await?; - assert!(matches!( - first_message, - Some(Message::AgentRunning(_)) - )); + assert!(matches!(first_message, Some(Message::AgentRunning(_)))); tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; diff --git a/paddler_gui_tests/tests/stopping_a_running_balancer.rs b/paddler_gui_tests/tests/stopping_a_running_balancer.rs index 6776609a..0b0f7025 100644 --- a/paddler_gui_tests/tests/stopping_a_running_balancer.rs +++ b/paddler_gui_tests/tests/stopping_a_running_balancer.rs @@ -29,8 +29,7 @@ fn clicking_stop_cluster_sends_the_stop_message_when_the_balancer_is_running() - } #[test] -fn the_stop_button_does_not_react_to_clicks_while_the_balancer_is_already_stopping() -> Result<()> -{ +fn the_stop_button_does_not_react_to_clicks_while_the_balancer_is_already_stopping() -> Result<()> { let data = data_with_stopping(true); let mut simulator = simulator(view_running_balancer(&data)); From bd00f74bfd4a5beff9f9f9f5d0ec3a774a7d5aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Mon, 18 May 2026 12:39:17 +0200 Subject: [PATCH 13/13] restore agent form acceptance of bindable addresses and the cluster address and web admin panel in the running view --- paddler_gui/src/app.rs | 62 ++++++++- paddler_gui/src/connect_address_field.rs | 118 ++++++++++++++++++ paddler_gui/src/drive_balancer_stream.rs | 13 +- paddler_gui/src/join_balancer_form_data.rs | 4 +- paddler_gui/src/join_balancer_form_handler.rs | 78 +++++++----- paddler_gui/src/lib.rs | 2 + paddler_gui/src/message.rs | 3 +- paddler_gui/src/screen.rs | 57 ++++----- .../src/start_balancer_form_handler.rs | 70 ++++++++++- paddler_gui/src/started_balancer_display.rs | 5 + .../src/ui/view_start_balancer_form.rs | 2 +- ...cer_shows_validation_errors_to_the_user.rs | 4 +- ...ng_a_balancer_succeeds_or_fails_visibly.rs | 39 +++++- 13 files changed, 368 insertions(+), 89 deletions(-) create mode 100644 paddler_gui/src/connect_address_field.rs create mode 100644 paddler_gui/src/started_balancer_display.rs diff --git a/paddler_gui/src/app.rs b/paddler_gui/src/app.rs index 002e968b..c8312c04 100644 --- a/paddler_gui/src/app.rs +++ b/paddler_gui/src/app.rs @@ -46,6 +46,7 @@ use crate::running_balancer_handler; use crate::screen::AgentRunning; use crate::screen::Screen; use crate::start_balancer_form_handler; +use crate::started_balancer_display::StartedBalancerDisplay; use crate::ui::variables::SPACING_2X; use crate::ui::variables::SPACING_BASE; use crate::ui::view_agent_running::view_agent_running; @@ -152,21 +153,33 @@ impl App { management_port, inference_port, web_admin_panel_port, + balancer_display_address, + web_admin_panel_display_address, desired_state, } => { self.screen = CurrentScreen::StartBalancerForm(form); + let display = StartedBalancerDisplay { + balancer_address: balancer_display_address, + web_admin_panel_address: web_admin_panel_display_address, + }; + self.spawn_balancer( management_port, inference_port, web_admin_panel_port, + display, &desired_state, ) } } } - (CurrentScreen::StartBalancerForm(form), Message::BalancerStarted) => { - self.screen = CurrentScreen::RunningBalancer(form.balancer_started()); + (CurrentScreen::StartBalancerForm(form), Message::BalancerStarted(display)) => { + self.screen = + CurrentScreen::RunningBalancer(form.balancer_started( + display.balancer_address, + display.web_admin_panel_address, + )); Task::none() } @@ -409,6 +422,7 @@ impl App { ) )] web_admin_panel_port: Option, + started_display: StartedBalancerDisplay, desired_state: &BalancerDesiredState, ) -> Task { let cancel = self.shutdown.child_token(); @@ -477,7 +491,7 @@ impl App { }; Task::stream(iced::stream::channel(1, move |output| { - drive_balancer_stream(params, output) + drive_balancer_stream(params, started_display, output) })) } } @@ -490,6 +504,7 @@ mod tests { )] use anyhow::Result; + use anyhow::bail; use super::*; use crate::agent_running_data::AgentRunningData; @@ -596,10 +611,16 @@ mod tests { }) } + fn valid_connect_address_field() -> Result { + let raw = "127.0.0.1:9100".to_owned(); + let socket_addr: std::net::SocketAddr = raw.parse()?; + Ok(crate::connect_address_field::ConnectAddressField::Valid { raw, socket_addr }) + } + fn bound_join_form_data() -> Result { Ok(JoinBalancerFormData { agent_name: String::new(), - balancer_address: bound_address_field()?, + balancer_address: valid_connect_address_field()?, slots_count: crate::slot_count_field::SlotCountField::Valid { raw: "2".to_owned(), value: 2, @@ -804,7 +825,10 @@ mod tests { fn balancer_started_message_transitions_from_start_form_to_running_balancer() -> Result<()> { let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); - let _ = app.update(Message::BalancerStarted); + let _ = app.update(Message::BalancerStarted(StartedBalancerDisplay { + balancer_address: "127.0.0.1:8060".to_owned(), + web_admin_panel_address: None, + })); assert!(matches!( app.current_screen_for_test(), @@ -813,6 +837,29 @@ mod tests { Ok(()) } + #[test] + fn balancer_started_message_populates_the_running_view_with_user_typed_addresses() -> Result<()> + { + let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); + + let _ = app.update(Message::BalancerStarted(StartedBalancerDisplay { + balancer_address: "127.0.0.1:8060".to_owned(), + web_admin_panel_address: Some("127.0.0.1:8062".to_owned()), + })); + + match app.current_screen_for_test() { + CurrentScreen::RunningBalancer(screen) => { + assert_eq!(screen.state_data.balancer_address, "127.0.0.1:8060"); + assert_eq!( + screen.state_data.web_admin_panel_address.as_deref(), + Some("127.0.0.1:8062") + ); + } + _ => bail!("expected RunningBalancer screen after BalancerStarted"), + } + Ok(()) + } + #[test] fn balancer_failed_message_during_startup_returns_user_to_home_with_error() -> Result<()> { let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); @@ -1030,7 +1077,10 @@ mod tests { fn an_unhandled_message_for_the_current_screen_is_logged_and_keeps_the_screen() -> Result<()> { let (mut app, _) = App::new(); // BalancerStarted is only meaningful from the StartBalancerForm screen. - let _ = app.update(Message::BalancerStarted); + let _ = app.update(Message::BalancerStarted(StartedBalancerDisplay { + balancer_address: "127.0.0.1:8060".to_owned(), + web_admin_panel_address: None, + })); assert_screen_is_home(&app) } diff --git a/paddler_gui/src/connect_address_field.rs b/paddler_gui/src/connect_address_field.rs new file mode 100644 index 00000000..18db58f4 --- /dev/null +++ b/paddler_gui/src/connect_address_field.rs @@ -0,0 +1,118 @@ +use std::net::SocketAddr; + +#[derive(Default)] +pub enum ConnectAddressField { + #[default] + Empty, + Valid { + raw: String, + socket_addr: SocketAddr, + }, + Invalid { + raw: String, + error: String, + }, +} + +impl ConnectAddressField { + #[must_use] + pub fn from_user_input(raw: String) -> Self { + if raw.is_empty() { + return Self::Empty; + } + + match raw.parse::() { + Ok(socket_addr) => Self::Valid { raw, socket_addr }, + Err(_) => Self::Invalid { + raw, + error: "Invalid address, expected format: IP:port".to_owned(), + }, + } + } + + #[must_use] + pub fn raw_text(&self) -> &str { + match self { + Self::Empty => "", + Self::Valid { raw, .. } | Self::Invalid { raw, .. } => raw, + } + } + + #[must_use] + pub fn error_text(&self) -> Option<&str> { + match self { + Self::Invalid { error, .. } => Some(error), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + + use super::ConnectAddressField; + + #[test] + fn empty_input_resolves_to_empty() { + assert!(matches!( + ConnectAddressField::from_user_input(String::new()), + ConnectAddressField::Empty + )); + } + + #[test] + fn unparseable_input_resolves_to_invalid() { + assert!(matches!( + ConnectAddressField::from_user_input("not-a-socket".to_owned()), + ConnectAddressField::Invalid { .. } + )); + } + + #[test] + fn well_formed_input_resolves_to_valid_without_binding() { + assert!(matches!( + ConnectAddressField::from_user_input("127.0.0.1:8061".to_owned()), + ConnectAddressField::Valid { .. } + )); + } + + #[test] + fn an_address_currently_bound_by_another_process_is_still_accepted_as_valid() + -> anyhow::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let addr = listener.local_addr()?; + + assert!(matches!( + ConnectAddressField::from_user_input(addr.to_string()), + ConnectAddressField::Valid { .. } + )); + Ok(()) + } + + #[test] + fn raw_text_returns_inner_raw_for_valid_and_invalid_variants() { + assert_eq!(ConnectAddressField::Empty.raw_text(), ""); + assert_eq!( + ConnectAddressField::Invalid { + raw: "raw-text".to_owned(), + error: "ignored".to_owned() + } + .raw_text(), + "raw-text" + ); + } + + #[test] + fn error_text_returns_error_only_for_invalid_variant() { + assert!(ConnectAddressField::Empty.error_text().is_none()); + assert_eq!( + ConnectAddressField::Invalid { + raw: String::new(), + error: "the error".to_owned() + } + .error_text(), + Some("the error") + ); + } +} diff --git a/paddler_gui/src/drive_balancer_stream.rs b/paddler_gui/src/drive_balancer_stream.rs index b5063a5f..21857208 100644 --- a/paddler_gui/src/drive_balancer_stream.rs +++ b/paddler_gui/src/drive_balancer_stream.rs @@ -8,8 +8,13 @@ use paddler_types::balancer_desired_state::BalancerDesiredState; use crate::drive_balancer_loop_inner::drive_balancer_loop_inner; use crate::message::Message; use crate::running_balancer_snapshot::RunningBalancerSnapshot; +use crate::started_balancer_display::StartedBalancerDisplay; -pub async fn drive_balancer_stream(params: BalancerRunnerParams, mut output: Sender) { +pub async fn drive_balancer_stream( + params: BalancerRunnerParams, + started_display: StartedBalancerDisplay, + mut output: Sender, +) { let mut runner = match BalancerRunner::start(params).await { Ok(runner) => runner, Err(error) => { @@ -26,7 +31,11 @@ pub async fn drive_balancer_stream(params: BalancerRunnerParams, mut output: Sen let completion_future = runner.wait_for_completion(); - if output.send(Message::BalancerStarted).await.is_err() { + if output + .send(Message::BalancerStarted(started_display)) + .await + .is_err() + { return; } diff --git a/paddler_gui/src/join_balancer_form_data.rs b/paddler_gui/src/join_balancer_form_data.rs index d8031761..680204ff 100644 --- a/paddler_gui/src/join_balancer_form_data.rs +++ b/paddler_gui/src/join_balancer_form_data.rs @@ -1,9 +1,9 @@ -use crate::address_field::AddressField; +use crate::connect_address_field::ConnectAddressField; use crate::slot_count_field::SlotCountField; #[derive(Default)] pub struct JoinBalancerFormData { pub agent_name: String, - pub balancer_address: AddressField, + pub balancer_address: ConnectAddressField, pub slots_count: SlotCountField, } diff --git a/paddler_gui/src/join_balancer_form_handler.rs b/paddler_gui/src/join_balancer_form_handler.rs index bc1816db..c54c9e2b 100644 --- a/paddler_gui/src/join_balancer_form_handler.rs +++ b/paddler_gui/src/join_balancer_form_handler.rs @@ -1,6 +1,6 @@ use std::mem; -use crate::address_field::AddressField; +use crate::connect_address_field::ConnectAddressField; use crate::join_balancer_form_data::JoinBalancerFormData; use crate::slot_count_field::SlotCountField; @@ -32,7 +32,7 @@ impl JoinBalancerFormData { Action::None } Message::SetBalancerAddress(address) => { - self.balancer_address = AddressField::required_from_user_input(address); + self.balancer_address = ConnectAddressField::from_user_input(address); Action::None } @@ -51,7 +51,7 @@ impl JoinBalancerFormData { let slots_count = mem::take(&mut self.slots_count); let required_balancer_address = match balancer_address { - AddressField::Empty => AddressField::Invalid { + ConnectAddressField::Empty => ConnectAddressField::Invalid { raw: String::new(), error: "Cluster address is required.".to_owned(), }, @@ -65,18 +65,17 @@ impl JoinBalancerFormData { other => other, }; - let address_bound = matches!(required_balancer_address, AddressField::Bound { .. }); + let address_valid = matches!(required_balancer_address, ConnectAddressField::Valid { .. }); let slots_valid = matches!(required_slots_count, SlotCountField::Valid { .. }); - if !address_bound || !slots_valid { + if !address_valid || !slots_valid { self.balancer_address = required_balancer_address; self.slots_count = required_slots_count; return Action::None; } - let AddressField::Bound { - raw: address_raw, - port: _, + let ConnectAddressField::Valid { + raw: address_raw, .. } = required_balancer_address else { return Action::None; @@ -106,14 +105,25 @@ mod tests { reason = "tests use Result<()> uniformly so the ? operator can be added without churn" )] + use std::net::SocketAddr; + use std::net::TcpListener; + use anyhow::Result; use super::Action; - use super::AddressField; + use super::ConnectAddressField; use super::JoinBalancerFormData; use super::Message; use super::SlotCountField; + fn valid_field(raw: &str) -> Result { + let socket_addr: SocketAddr = raw.parse()?; + Ok(ConnectAddressField::Valid { + raw: raw.to_owned(), + socket_addr, + }) + } + #[test] fn set_agent_name_records_typed_value_into_form_state() -> Result<()> { let mut data = JoinBalancerFormData::default(); @@ -135,7 +145,7 @@ mod tests { assert!(matches!( data.balancer_address, - AddressField::Invalid { .. } + ConnectAddressField::Invalid { .. } )); Ok(()) @@ -144,7 +154,7 @@ mod tests { #[test] fn set_balancer_address_with_empty_input_records_empty_state() -> Result<()> { let mut data = JoinBalancerFormData { - balancer_address: AddressField::Invalid { + balancer_address: ConnectAddressField::Invalid { raw: "stale".to_owned(), error: "stale".to_owned(), }, @@ -153,7 +163,23 @@ mod tests { let _ = data.update(Message::SetBalancerAddress(String::new())); - assert!(matches!(data.balancer_address, AddressField::Empty)); + assert!(matches!(data.balancer_address, ConnectAddressField::Empty)); + + Ok(()) + } + + #[test] + fn set_balancer_address_with_already_bound_port_still_records_valid_state() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let addr = listener.local_addr()?; + + let mut data = JoinBalancerFormData::default(); + let _ = data.update(Message::SetBalancerAddress(addr.to_string())); + + assert!(matches!( + data.balancer_address, + ConnectAddressField::Valid { .. } + )); Ok(()) } @@ -221,7 +247,7 @@ mod tests { assert!(matches!(action, Action::None)); assert!(matches!( &data.balancer_address, - AddressField::Invalid { error, .. } if error.contains("required") + ConnectAddressField::Invalid { error, .. } if error.contains("required") )); Ok(()) @@ -229,12 +255,8 @@ mod tests { #[test] fn connecting_without_a_slot_count_records_required_error() -> Result<()> { - let bound = paddler_ports::bind_ephemeral_port::bind_ephemeral_port()?; let mut data = JoinBalancerFormData { - balancer_address: AddressField::Bound { - raw: bound.socket_addr.to_string(), - port: bound, - }, + balancer_address: valid_field("127.0.0.1:9001")?, ..JoinBalancerFormData::default() }; @@ -251,12 +273,8 @@ mod tests { #[test] fn connecting_with_a_zero_slot_count_records_must_be_greater_than_zero_error() -> Result<()> { - let bound = paddler_ports::bind_ephemeral_port::bind_ephemeral_port()?; let mut data = JoinBalancerFormData { - balancer_address: AddressField::Bound { - raw: bound.socket_addr.to_string(), - port: bound, - }, + balancer_address: valid_field("127.0.0.1:9002")?, slots_count: SlotCountField::from_user_input("0".to_owned()), ..JoinBalancerFormData::default() }; @@ -275,13 +293,9 @@ mod tests { #[test] fn connecting_with_valid_input_and_no_agent_name_yields_connect_agent_with_name_none() -> Result<()> { - let bound = paddler_ports::bind_ephemeral_port::bind_ephemeral_port()?; - let raw_address = bound.socket_addr.to_string(); + let raw_address = "127.0.0.1:9003".to_owned(); let mut data = JoinBalancerFormData { - balancer_address: AddressField::Bound { - raw: raw_address.clone(), - port: bound, - }, + balancer_address: valid_field(&raw_address)?, slots_count: SlotCountField::Valid { raw: "4".to_owned(), value: 4, @@ -306,13 +320,9 @@ mod tests { #[test] fn connecting_with_valid_input_and_a_filled_agent_name_yields_connect_agent_with_some_name() -> Result<()> { - let bound = paddler_ports::bind_ephemeral_port::bind_ephemeral_port()?; let mut data = JoinBalancerFormData { agent_name: "primary".to_owned(), - balancer_address: AddressField::Bound { - raw: bound.socket_addr.to_string(), - port: bound, - }, + balancer_address: valid_field("127.0.0.1:9004")?, slots_count: SlotCountField::Valid { raw: "2".to_owned(), value: 2, diff --git a/paddler_gui/src/lib.rs b/paddler_gui/src/lib.rs index a49e2142..c7073f45 100644 --- a/paddler_gui/src/lib.rs +++ b/paddler_gui/src/lib.rs @@ -2,6 +2,7 @@ pub mod address_field; pub mod agent_running_data; pub mod agent_running_handler; pub mod app; +pub mod connect_address_field; pub mod current_screen; pub mod detect_network_interfaces; pub mod drive_agent_stream; @@ -24,6 +25,7 @@ pub mod screen; pub mod slot_count_field; pub mod start_balancer_form_data; pub mod start_balancer_form_handler; +pub mod started_balancer_display; pub mod ui; use clap::Parser; diff --git a/paddler_gui/src/message.rs b/paddler_gui/src/message.rs index 026c2ebf..e91cfd18 100644 --- a/paddler_gui/src/message.rs +++ b/paddler_gui/src/message.rs @@ -3,6 +3,7 @@ use crate::home_handler; use crate::join_balancer_form_handler; use crate::running_balancer_handler; use crate::start_balancer_form_handler; +use crate::started_balancer_display::StartedBalancerDisplay; #[derive(Debug, Clone)] pub enum Message { @@ -11,7 +12,7 @@ pub enum Message { JoinBalancerForm(join_balancer_form_handler::Message), RunningBalancer(running_balancer_handler::Message), AgentRunning(agent_running_handler::Message), - BalancerStarted, + BalancerStarted(StartedBalancerDisplay), BalancerStopped, BalancerFailed(String), AgentStopped, diff --git a/paddler_gui/src/screen.rs b/paddler_gui/src/screen.rs index fe5eee28..3f7dd417 100644 --- a/paddler_gui/src/screen.rs +++ b/paddler_gui/src/screen.rs @@ -117,19 +117,16 @@ impl Screen { } #[must_use] - pub fn balancer_started(self) -> Screen { - self.transition_map(|form_data: StartBalancerFormData| { - let balancer_address = form_data.balancer_address.raw_text().to_owned(); - let web_admin_panel_address = match form_data.web_admin_panel_address { - AddressField::Empty => None, - AddressField::Bound { raw, .. } | AddressField::Invalid { raw, .. } => Some(raw), - }; - RunningBalancerData { - balancer_address, - snapshot: RunningBalancerSnapshot::default(), - stopping: false, - web_admin_panel_address, - } + pub fn balancer_started( + self, + balancer_address: String, + web_admin_panel_address: Option, + ) -> Screen { + self.transition_map(|_form_data: StartBalancerFormData| RunningBalancerData { + balancer_address, + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address, }) } @@ -172,6 +169,7 @@ mod tests { use super::Screen; use super::StartBalancerForm; use super::StartBalancerFormData; + use crate::connect_address_field::ConnectAddressField; fn home() -> Screen { Screen::::builder() @@ -297,7 +295,7 @@ mod tests { fn join_balancer_form_connect_with_empty_agent_name_sets_name_none_on_agent_screen() -> Result<()> { let form = join_form(JoinBalancerFormData { - balancer_address: AddressField::Invalid { + balancer_address: ConnectAddressField::Invalid { raw: "127.0.0.1:8060".to_owned(), error: "placeholder".to_owned(), }, @@ -318,7 +316,7 @@ mod tests { -> Result<()> { let form = join_form(JoinBalancerFormData { agent_name: "primary".to_owned(), - balancer_address: AddressField::Invalid { + balancer_address: ConnectAddressField::Invalid { raw: "127.0.0.1:8060".to_owned(), error: "placeholder".to_owned(), }, @@ -365,18 +363,16 @@ mod tests { } #[test] - fn start_balancer_form_balancer_started_with_web_admin_address_carries_it_forward() -> Result<()> - { - let form = start_form(StartBalancerFormData { - web_admin_panel_address: AddressField::Invalid { - raw: "127.0.0.1:8062".to_owned(), - error: "placeholder".to_owned(), - }, - ..fresh_start_form_data() - }); + fn start_balancer_form_balancer_started_carries_addresses_into_running_balancer_data() + -> Result<()> { + let form = start_form(fresh_start_form_data()); - let next = form.balancer_started(); + let next = form.balancer_started( + "127.0.0.1:8060".to_owned(), + Some("127.0.0.1:8062".to_owned()), + ); + assert_eq!(next.state_data.balancer_address, "127.0.0.1:8060"); assert_eq!( next.state_data.web_admin_panel_address.as_deref(), Some("127.0.0.1:8062") @@ -386,18 +382,15 @@ mod tests { } #[test] - fn start_balancer_form_balancer_started_with_empty_web_admin_address_resolves_to_none() + fn start_balancer_form_balancer_started_with_no_web_admin_address_resolves_to_none() -> Result<()> { - let form = start_form(StartBalancerFormData { - web_admin_panel_address: AddressField::Empty, - ..fresh_start_form_data() - }); + let form = start_form(fresh_start_form_data()); - let next = form.balancer_started(); + let next = form.balancer_started("127.0.0.1:8060".to_owned(), None); assert!( next.state_data.web_admin_panel_address.is_none(), - "expected empty web_admin_panel_address to become None" + "expected None web_admin_panel_address to remain None" ); Ok(()) } diff --git a/paddler_gui/src/start_balancer_form_handler.rs b/paddler_gui/src/start_balancer_form_handler.rs index f11d58a1..e8ce000e 100644 --- a/paddler_gui/src/start_balancer_form_handler.rs +++ b/paddler_gui/src/start_balancer_form_handler.rs @@ -33,6 +33,8 @@ pub enum Action { management_port: BoundPort, inference_port: BoundPort, web_admin_panel_port: Option, + balancer_display_address: String, + web_admin_panel_display_address: Option, desired_state: BalancerDesiredState, }, } @@ -117,8 +119,8 @@ impl StartBalancerFormData { } let AddressField::Bound { + raw: balancer_display_address, port: management_port, - .. } = balancer_address else { return Action::None; @@ -130,9 +132,10 @@ impl StartBalancerFormData { else { return Action::None; }; - let web_admin_panel_port = match web_admin_panel_address { - AddressField::Bound { port, .. } => Some(port), - AddressField::Empty => None, + let (web_admin_panel_port, web_admin_panel_display_address) = match web_admin_panel_address + { + AddressField::Bound { raw, port } => (Some(port), Some(raw)), + AddressField::Empty => (None, None), AddressField::Invalid { .. } => return Action::None, }; @@ -151,6 +154,8 @@ impl StartBalancerFormData { management_port, inference_port, web_admin_panel_port, + balancer_display_address, + web_admin_panel_display_address, desired_state, } } @@ -434,4 +439,61 @@ mod tests { Ok(()) } + + #[test] + fn confirming_with_valid_addresses_carries_the_typed_strings_forward() -> Result<()> { + let balancer_bound = bind_ephemeral_port()?; + let inference_bound = bind_ephemeral_port()?; + let web_admin_bound = bind_ephemeral_port()?; + let typed_balancer = balancer_bound.socket_addr.to_string(); + let typed_web_admin = web_admin_bound.socket_addr.to_string(); + + let mut data = empty_form(); + data.balancer_address = AddressField::Bound { + raw: typed_balancer.clone(), + port: balancer_bound, + }; + data.inference_address = AddressField::Bound { + raw: inference_bound.socket_addr.to_string(), + port: inference_bound, + }; + data.web_admin_panel_address = AddressField::Bound { + raw: typed_web_admin.clone(), + port: web_admin_bound, + }; + data.selected_model = Some(first_preset()?); + + let action = data.update(Message::Confirm); + + assert!(matches!( + action, + Action::StartBalancer { + ref balancer_display_address, + web_admin_panel_display_address: Some(ref web_admin), + .. + } if balancer_display_address == &typed_balancer && web_admin == &typed_web_admin + )); + + Ok(()) + } + + #[test] + fn confirming_with_no_web_admin_panel_address_carries_none_display() -> Result<()> { + let mut data = empty_form(); + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; + data.selected_model = Some(first_preset()?); + + let action = data.update(Message::Confirm); + + assert!(matches!( + action, + Action::StartBalancer { + web_admin_panel_display_address: None, + .. + } + )); + + Ok(()) + } } diff --git a/paddler_gui/src/started_balancer_display.rs b/paddler_gui/src/started_balancer_display.rs new file mode 100644 index 00000000..e46b66f8 --- /dev/null +++ b/paddler_gui/src/started_balancer_display.rs @@ -0,0 +1,5 @@ +#[derive(Debug, Clone)] +pub struct StartedBalancerDisplay { + pub balancer_address: String, + pub web_admin_panel_address: Option, +} diff --git a/paddler_gui/src/ui/view_start_balancer_form.rs b/paddler_gui/src/ui/view_start_balancer_form.rs index 3dacc21d..a342d4bd 100644 --- a/paddler_gui/src/ui/view_start_balancer_form.rs +++ b/paddler_gui/src/ui/view_start_balancer_form.rs @@ -37,7 +37,7 @@ pub fn view_start_balancer_form(data: &StartBalancerFormData) -> Element<'_, Mes .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_primary) } else { - button(text("Start cluster").font(BOLD)) + button(text("Start a cluster").font(BOLD)) .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_primary) .on_press(Message::Confirm) diff --git a/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs b/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs index 6f2d4446..5d7b8d18 100644 --- a/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs +++ b/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs @@ -1,7 +1,7 @@ use anyhow::Result; use anyhow::bail; use iced_test::simulator; -use paddler_gui::address_field::AddressField; +use paddler_gui::connect_address_field::ConnectAddressField; use paddler_gui::join_balancer_form_data::JoinBalancerFormData; use paddler_gui::slot_count_field::SlotCountField; use paddler_gui::ui::view_join_balancer_form::view_join_balancer_form; @@ -9,7 +9,7 @@ use paddler_gui::ui::view_join_balancer_form::view_join_balancer_form; #[test] fn the_cluster_address_and_slots_errors_render_under_their_inputs_when_set() -> Result<()> { let data = JoinBalancerFormData { - balancer_address: AddressField::Invalid { + balancer_address: ConnectAddressField::Invalid { raw: String::new(), error: "Cluster address is required.".to_owned(), }, diff --git a/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs b/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs index 67d2ed10..6a3d88df 100644 --- a/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs +++ b/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs @@ -7,6 +7,7 @@ use iced::futures::StreamExt as _; use iced::futures::channel::mpsc; use paddler_gui::drive_balancer_stream::drive_balancer_stream; use paddler_gui::message::Message; +use paddler_gui::started_balancer_display::StartedBalancerDisplay; use paddler_gui_tests::bind_addresses::BindAddresses; use paddler_gui_tests::bind_ephemeral_socket::bind_ephemeral_socket; use paddler_gui_tests::make_balancer_runner_params::make_balancer_runner_params; @@ -29,7 +30,14 @@ async fn an_invalid_bind_address_tells_the_user_the_balancer_failed_to_start() - ); let (output, mut receiver) = mpsc::channel::(8); - let driver = tokio::spawn(drive_balancer_stream(params, output)); + let driver = tokio::spawn(drive_balancer_stream( + params, + StartedBalancerDisplay { + balancer_address: INVALID_BIND_ADDR.to_owned(), + web_admin_panel_address: None, + }, + output, + )); let collect = async { let mut observed_failed = false; @@ -69,7 +77,14 @@ async fn a_running_balancer_reports_started_and_then_stopped_when_the_user_cance ); let (output, mut receiver) = mpsc::channel::(16); - let driver = tokio::spawn(drive_balancer_stream(params, output)); + let driver = tokio::spawn(drive_balancer_stream( + params, + StartedBalancerDisplay { + balancer_address: "ignored-during-test".to_owned(), + web_admin_panel_address: None, + }, + output, + )); let mut observed_started = false; let mut observed_stopped = false; @@ -77,7 +92,7 @@ async fn a_running_balancer_reports_started_and_then_stopped_when_the_user_cance let collect = async { while let Some(message) = receiver.next().await { match message { - Message::BalancerStarted => { + Message::BalancerStarted(_) => { observed_started = true; cancellation_token.cancel(); } @@ -122,7 +137,14 @@ async fn when_the_ui_goes_away_the_balancer_stream_exits_without_panicking() -> let (output, receiver) = mpsc::channel::(1); drop(receiver); - let driver = tokio::spawn(drive_balancer_stream(params, output)); + let driver = tokio::spawn(drive_balancer_stream( + params, + StartedBalancerDisplay { + balancer_address: "ignored-during-test".to_owned(), + web_admin_panel_address: None, + }, + output, + )); cancellation_token.cancel(); @@ -146,7 +168,14 @@ async fn a_failed_start_with_a_disconnected_ui_logs_the_error_and_exits_cleanly( let (output, receiver) = mpsc::channel::(1); drop(receiver); - let driver = tokio::spawn(drive_balancer_stream(params, output)); + let driver = tokio::spawn(drive_balancer_stream( + params, + StartedBalancerDisplay { + balancer_address: INVALID_BIND_ADDR.to_owned(), + web_admin_panel_address: None, + }, + output, + )); tokio::time::timeout(BALANCER_STREAM_TIMEOUT, driver).await??;