diff --git a/Cargo.toml b/Cargo.toml index 0fba77c..222bb6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.2.12-pre.4.fix.2" +version = "0.2.12-pre.5" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-app-solprices-desk/frontend/main.html b/crates/ksp-app-solprices-desk/frontend/main.html index c638ee9..5af3c32 100644 --- a/crates/ksp-app-solprices-desk/frontend/main.html +++ b/crates/ksp-app-solprices-desk/frontend/main.html @@ -1,5 +1,5 @@ - + @@ -47,7 +47,7 @@

Prices

-

Inventaire provider-neutral du registry Off-chain. Aucun refresh réseau n'est encore déclenché dans cette tranche.

+

Inventaire provider-neutral du registry Off-chain. Chaque ligne peut être rafraîchie explicitement sans polling ni fallback automatique.

@@ -78,6 +78,7 @@ Provider timestamp (ms) Received at (ms) Retry at (ms) + Action diff --git a/crates/ksp-app-solprices-desk/frontend/ts/main.ts b/crates/ksp-app-solprices-desk/frontend/ts/main.ts index c193b23..acc47b2 100644 --- a/crates/ksp-app-solprices-desk/frontend/ts/main.ts +++ b/crates/ksp-app-solprices-desk/frontend/ts/main.ts @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/frontend/ts/main.ts -// version: 4 +// version: 5 import "bootstrap"; import ResizeObserver from "resize-observer-polyfill"; @@ -20,6 +20,8 @@ const viewTitles: Record = { diagnostics: "Diagnostics", }; +let currentMarketPriceRows: MarketPriceProviderRowDto[] = []; + function isViewId(value: string | undefined): value is ViewId { return value === "prices" || value === "diagnostics"; } @@ -60,6 +62,64 @@ function appendMarketPriceCell(row: HTMLTableRowElement, value: string): void { row.append(cell); } +function clearPricesDiagnostic(): void { + const diagnostic = document.querySelector("#pricesDiagnostic"); + if (diagnostic) { + diagnostic.hidden = true; + diagnostic.textContent = ""; + } +} + +function showPricesDiagnostic(message: string): void { + const diagnostic = document.querySelector("#pricesDiagnostic"); + if (diagnostic) { + diagnostic.hidden = false; + diagnostic.textContent = message; + } +} + +async function refreshMarketPrice(providerId: string): Promise { + frontendDebug("main", "SOL Prices Desk market-price refresh control clicked", { providerId }); + clearPricesDiagnostic(); + currentMarketPriceRows = currentMarketPriceRows.map(provider => { + return provider.providerId === providerId ? { ...provider, loading: true } : provider; + }); + renderMarketPriceRows(currentMarketPriceRows); + try { + const refreshed = await invokeKsp("main", "refresh_market_price", { providerId }); + currentMarketPriceRows = currentMarketPriceRows.map(provider => { + return provider.providerId === refreshed.providerId ? refreshed : provider; + }); + renderMarketPriceRows(currentMarketPriceRows); + frontendTrace("main", "SOL Prices Desk market-price refresh row applied", { providerId }); + } catch { + frontendWarn("main", "SOL Prices Desk market-price refresh failed", { providerId }); + showPricesDiagnostic("Le provider sélectionné n'a pas pu être rafraîchi. L'état précédent est conservé lorsqu'il reste disponible."); + try { + await loadMarketPrices(); + } catch { + frontendWarn("main", "SOL Prices Desk market-price refresh recovery reload failed", { providerId }); + } + } +} + +function appendRefreshCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void { + const cell = document.createElement("td"); + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn btn-sm btn-outline-primary text-nowrap"; + button.disabled = provider.loading; + button.dataset.providerId = provider.providerId; + button.innerHTML = provider.loading + ? 'Refreshing…' + : 'Refresh'; + button.addEventListener("click", () => { + void refreshMarketPrice(provider.providerId); + }); + cell.append(button); + row.append(cell); +} + function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void { const loading = document.querySelector("#pricesLoading"); const empty = document.querySelector("#pricesEmpty"); @@ -77,6 +137,7 @@ function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void { tableContainer.hidden = rows.length === 0; for (const provider of rows) { const row = document.createElement("tr"); + row.dataset.providerId = provider.providerId; appendMarketPriceCell(row, provider.displayName); appendMarketPriceCell(row, provider.pair); appendMarketPriceCell(row, provider.semantics); @@ -86,6 +147,7 @@ function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void { appendMarketPriceCell(row, provider.providerTimestampUnixMillis ?? "—"); appendMarketPriceCell(row, provider.receivedAtUnixMillis ?? "—"); appendMarketPriceCell(row, provider.retryAtUnixMillis ?? "—"); + appendRefreshCell(row, provider); tableBody.append(row); } frontendTrace("main", "SOL Prices Desk market-price registry rows rendered", { rowCount: rows.length }); @@ -127,6 +189,7 @@ function renderRuntimeStatus(status: MarketPriceRuntimeStatusDto): void { async function loadMarketPrices(): Promise { frontendTrace("main", "SOL Prices Desk provider registry load started"); const rows = await invokeKsp("main", "list_market_prices"); + currentMarketPriceRows = rows; renderMarketPriceRows(rows); frontendTrace("main", "SOL Prices Desk provider registry load completed", { rowCount: rows.length }); } @@ -155,14 +218,10 @@ async function initializeMain(): Promise { await loadMarketPrices(); } catch { const loading = document.querySelector("#pricesLoading"); - const diagnostic = document.querySelector("#pricesDiagnostic"); if (loading) { loading.hidden = true; } - if (diagnostic) { - diagnostic.hidden = false; - diagnostic.textContent = "L'inventaire provider-neutral n'a pas pu être chargé."; - } + showPricesDiagnostic("L'inventaire provider-neutral n'a pas pu être chargé."); frontendWarn("main", "SOL Prices Desk provider registry load failed"); } } diff --git a/crates/ksp-app-solprices-desk/package.json b/crates/ksp-app-solprices-desk/package.json index eda2133..2d1e767 100644 --- a/crates/ksp-app-solprices-desk/package.json +++ b/crates/ksp-app-solprices-desk/package.json @@ -1,7 +1,7 @@ { "name": "ksp-app-solprices-desk", "private": true, - "version": "0.2.12-pre.4.fix.2", + "version": "0.2.12-pre.5", "type": "module", "scripts": { "dev": "vite", diff --git a/crates/ksp-app-solprices-desk/src/app_state.rs b/crates/ksp-app-solprices-desk/src/app_state.rs index 63ecc4b..d32b5c4 100644 --- a/crates/ksp-app-solprices-desk/src/app_state.rs +++ b/crates/ksp-app-solprices-desk/src/app_state.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/src/app_state.rs -// version: 3 +// version: 4 //! Shared backend state owned by the SOL Prices Desk Tauri application. @@ -68,6 +68,11 @@ impl crate::AppState { return self.market_price_runtime.list_rows(); } + /// Refreshes one provider by opaque identifier and returns its updated safe row projection. + pub(crate) async fn refresh_market_price(&self, provider_id: String) -> ksp_core_lib::Result { + return self.market_price_runtime.refresh_one(provider_id).await; + } + /// Builds the safe runtime status exposed by the SOL Prices Desk shell. pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result { let document_count = self.config_management.engine().registry().descriptors().count(); @@ -111,7 +116,7 @@ impl crate::AppState { fallback_logging_active: runtime.fallback_active, provider_count, ready_provider_count, - shell_phase: "pre.004-market-price-runtime".to_owned(), + shell_phase: "pre.005-single-refresh".to_owned(), startup_diagnostic: runtime.startup_diagnostic.clone(), unavailable_provider_count, }); diff --git a/crates/ksp-app-solprices-desk/src/market_price_runtime.rs b/crates/ksp-app-solprices-desk/src/market_price_runtime.rs index 7454282..7c7b762 100644 --- a/crates/ksp-app-solprices-desk/src/market_price_runtime.rs +++ b/crates/ksp-app-solprices-desk/src/market_price_runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/src/market_price_runtime.rs -// version: 1 +// version: 2 //! Provider-neutral market-price presentation runtime owned by SOL Prices Desk. @@ -22,7 +22,7 @@ pub(crate) struct MarketPriceProviderRowDto { pub(crate) pair: String, /// Exact canonical SOL/USD decimal string, when an observation exists. pub(crate) price: std::option::Option, - /// Opaque provider identifier accepted by future refresh commands. + /// Opaque provider identifier accepted by refresh commands. pub(crate) provider_id: String, /// Provider-supplied observation timestamp in exact Unix milliseconds, when genuinely supplied. pub(crate) provider_timestamp_unix_millis: std::option::Option, @@ -98,15 +98,10 @@ impl crate::MarketPriceRuntime { /// Projects the current registry and in-memory observation state as deterministic frontend rows without network dispatch. pub(crate) fn list_rows(&self) -> ksp_core_lib::Result> { let registry = self.startup.resolved().service().registry(); - let presentation = self.presentation.lock(); + let presentation = lock_presentation(&self.presentation); let presentation = match presentation { std::result::Result::Ok(value) => value, - std::result::Result::Err(_) => { - return std::result::Result::Err(ksp_core_lib::Error::new( - crate::ERROR_CODE_APP_STATE_LOCK_FAILED, - "SOL Prices Desk market-price presentation state lock is poisoned", - )); - }, + std::result::Result::Err(error) => return std::result::Result::Err(error), }; let mut rows = std::vec::Vec::with_capacity(registry.len()); for entry in registry.entries() { @@ -125,6 +120,90 @@ impl crate::MarketPriceRuntime { } return std::result::Result::Ok(rows); } + + /// Refreshes one opaque provider through Off-chain Transport and updates only its in-memory presentation row. + pub(crate) async fn refresh_one(&self, provider_id: String) -> ksp_core_lib::Result { + let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new(provider_id); + let provider_id = match provider_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let loading = self.set_loading(&provider_id, true); + if let std::result::Result::Err(error) = loading { + return std::result::Result::Err(error); + } + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, + provider_id = provider_id.as_str(), + "started SOL Prices Desk single-provider market-price refresh" + ); + let outcome = self.startup.resolved().service().refresh(&provider_id).await; + let outcome = match outcome { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + let cleared = self.set_loading(&provider_id, false); + if let std::result::Result::Err(clear_error) = cleared { + return std::result::Result::Err(clear_error); + } + return std::result::Result::Err(error); + }, + }; + let row = self.apply_outcome(&outcome); + let row = match row { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, + provider_id = provider_id.as_str(), + refreshed = outcome.refreshed(), + availability = row.availability.as_str(), + "completed SOL Prices Desk single-provider market-price refresh" + ); + return std::result::Result::Ok(row); + } + + fn apply_outcome(&self, outcome: &ksp_offchain_transport_lib::MarketPriceRefreshOutcome) -> ksp_core_lib::Result { + let registry = self.startup.resolved().service().registry(); + let registry_entry = registry.entry(outcome.provider_id()); + let registry_entry = match registry_entry { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk refresh outcome references an unknown registry provider") + .with_context("provider_id", outcome.provider_id().as_str()), + ); + }, + }; + let presentation = lock_presentation(&self.presentation); + let mut presentation = match presentation { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let applied = apply_outcome_to_state(&mut presentation, outcome); + if let std::result::Result::Err(error) = applied { + return std::result::Result::Err(error); + } + let presentation_entry = presentation.entries.get(outcome.provider_id().as_str()); + return match presentation_entry { + std::option::Option::Some(value) => std::result::Result::Ok(project_registry_entry(registry_entry, value)), + std::option::Option::None => std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price runtime is missing refreshed provider state") + .with_context("provider_id", outcome.provider_id().as_str()), + ), + }; + } + + fn set_loading(&self, provider_id: &ksp_offchain_transport_lib::MarketPriceProviderId, loading: bool) -> ksp_core_lib::Result<()> { + let presentation = lock_presentation(&self.presentation); + let mut presentation = match presentation { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return set_loading_in_state(&mut presentation, provider_id.as_str(), loading); + } } struct MarketPricePresentationEntry { @@ -136,6 +215,27 @@ struct MarketPricePresentationState { entries: std::collections::BTreeMap, } +fn apply_outcome_to_state( + presentation: &mut MarketPricePresentationState, + outcome: &ksp_offchain_transport_lib::MarketPriceRefreshOutcome, +) -> ksp_core_lib::Result<()> { + let entry = presentation.entries.get_mut(outcome.provider_id().as_str()); + let entry = match entry { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price presentation state is missing refresh provider") + .with_context("provider_id", outcome.provider_id().as_str()), + ); + }, + }; + entry.loading = false; + if let std::option::Option::Some(observation) = outcome.observation() { + entry.observation = std::option::Option::Some(observation.clone()); + } + return std::result::Result::Ok(()); +} + fn auth_mode_code(auth_mode: ksp_offchain_transport_lib::MarketPriceProviderAuthMode) -> &'static str { return match auth_mode { ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None => "none", @@ -170,6 +270,18 @@ fn count_to_u32(value: usize, field: &'static str) -> ksp_core_lib::Result }; } +fn lock_presentation( + presentation: &std::sync::Mutex, +) -> ksp_core_lib::Result> { + return match presentation.lock() { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_APP_STATE_LOCK_FAILED, + "SOL Prices Desk market-price presentation state lock is poisoned", + )), + }; +} + fn project_registry_entry( entry: &ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry, presentation: &MarketPricePresentationEntry, @@ -204,6 +316,20 @@ fn semantics_code(semantics: ksp_offchain_transport_lib::MarketPriceSemantics) - }; } +fn set_loading_in_state(presentation: &mut MarketPricePresentationState, provider_id: &str, loading: bool) -> ksp_core_lib::Result<()> { + let entry = presentation.entries.get_mut(provider_id); + return match entry { + std::option::Option::Some(value) => { + value.loading = loading; + std::result::Result::Ok(()) + }, + std::option::Option::None => std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "SOL Prices Desk market-price presentation state is missing provider") + .with_context("provider_id", provider_id), + ), + }; +} + #[cfg(test)] #[path = "../unit_tests/market_price_runtime.rs"] mod tests; diff --git a/crates/ksp-app-solprices-desk/src/tauri.rs b/crates/ksp-app-solprices-desk/src/tauri.rs index 1351e92..7cced74 100644 --- a/crates/ksp-app-solprices-desk/src/tauri.rs +++ b/crates/ksp-app-solprices-desk/src/tauri.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/src/tauri.rs -// version: 3 +// version: 4 //! Tauri runtime assembly for the KSP SOL prices desktop application. @@ -72,7 +72,13 @@ fn configure_plugins(builder: tauri::Builder) -> tauri::Builder) -> tauri::Builder { - return builder.invoke_handler(tauri::generate_handler![emit_frontend_log, get_runtime_status, list_market_prices, splash_frontend_ready,]); + return builder.invoke_handler(tauri::generate_handler![ + emit_frontend_log, + get_runtime_status, + list_market_prices, + refresh_market_price, + splash_frontend_ready, + ]); } fn configure_setup(builder: tauri::Builder) -> tauri::Builder { @@ -122,6 +128,18 @@ fn list_market_prices( }; } +#[tauri::command] +async fn refresh_market_price( + provider_id: String, + state: tauri::State<'_, crate::AppState>, +) -> std::result::Result { + let result = state.refresh_market_price(provider_id).await; + return match result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)), + }; +} + #[tauri::command] async fn splash_frontend_ready( app: tauri::AppHandle, diff --git a/crates/ksp-app-solprices-desk/tauri.conf.json b/crates/ksp-app-solprices-desk/tauri.conf.json index 8e14597..c6eb571 100644 --- a/crates/ksp-app-solprices-desk/tauri.conf.json +++ b/crates/ksp-app-solprices-desk/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "KSP SOL Prices Desk", - "version": "0.2.12-pre.4.fix.2", + "version": "0.2.12-pre.5", "identifier": "com.sasedev.ksp-app-solprices-desk", "build": { "beforeDevCommand": { diff --git a/crates/ksp-app-solprices-desk/tests/desktop_contract.rs b/crates/ksp-app-solprices-desk/tests/desktop_contract.rs index 47bfab0..3fc716d 100644 --- a/crates/ksp-app-solprices-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-solprices-desk/tests/desktop_contract.rs @@ -192,7 +192,7 @@ fn pre_002_fix_001_reuses_the_common_ksp_desktop_template() { } #[test] -fn pre_004_market_price_runtime_projects_registry_rows_without_refresh_controls() { +fn pre_005_market_price_runtime_adds_single_refresh_without_many_or_all_controls() { let root = app_root(); let manifest = read_text(root.join("Cargo.toml").as_path()); assert!(manifest.contains(r#"ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }"#)); @@ -201,21 +201,26 @@ fn pre_004_market_price_runtime_projects_registry_rows_without_refresh_controls( assert!(lib_rs.contains("mod market_price_runtime;")); assert!(lib_rs.contains("MarketPriceProviderRowDto")); assert!(lib_rs.contains("MarketPriceRuntime")); + let runtime_rs = read_text(root.join("src/market_price_runtime.rs").as_path()); + assert!(runtime_rs.contains("pub(crate) async fn refresh_one")); + assert!(runtime_rs.contains("service().refresh(&provider_id).await")); + assert!(runtime_rs.contains("self.set_loading(&provider_id, true)")); + assert!(runtime_rs.contains("apply_outcome_to_state")); let tauri_rs = read_text(root.join("src/tauri.rs").as_path()); - assert!(tauri_rs.contains("list_market_prices")); - for forbidden in ["refresh_market_price", "refresh_market_prices", "refresh_all_market_prices"] { + assert!(tauri_rs.contains("refresh_market_price")); + for forbidden in ["refresh_market_prices", "refresh_all_market_prices"] { assert!(!tauri_rs.contains(forbidden)); } let main_html = read_text(root.join("frontend/main.html").as_path()); - assert!(main_html.contains(r#"id="pricesLoading""#)); - assert!(main_html.contains(r#"id="pricesEmpty""#)); assert!(main_html.contains(r#"id="marketPriceTable""#)); - assert!(main_html.contains(r#"id="marketPriceRows""#)); - assert!(!main_html.contains("Refresh")); + assert!(main_html.contains(">Action")); + assert!(main_html.contains("rafraîchie explicitement")); let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path()); - assert!(main_typescript.contains(r#""list_market_prices""#)); - assert!(main_typescript.contains("MarketPriceProviderRowDto")); - assert!(main_typescript.contains(r#"provider.price ?? "—""#)); + assert!(main_typescript.contains(r#""refresh_market_price""#)); + assert!(main_typescript.contains("SOL Prices Desk market-price refresh control clicked")); + assert!(main_typescript.contains("provider.providerId")); + assert!(main_typescript.contains("provider.loading")); + assert!(main_typescript.contains("Refreshing…")); assert!(!main_typescript.contains("parseFloat(")); assert!(!main_typescript.contains("Number(provider.price")); } diff --git a/crates/ksp-app-solprices-desk/tests/desktop_security.rs b/crates/ksp-app-solprices-desk/tests/desktop_security.rs index 15a3b97..250a36b 100644 --- a/crates/ksp-app-solprices-desk/tests/desktop_security.rs +++ b/crates/ksp-app-solprices-desk/tests/desktop_security.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/tests/desktop_security.rs -// version: 3 +// version: 4 //! Security, ownership and frontend instrumentation canaries for SOL Prices Desk. @@ -69,7 +69,7 @@ fn pre_002_frontend_has_no_network_persistence_or_native_dialog_surface() { } #[test] -fn pre_004_tauri_commands_remain_centralized_and_refresh_is_absent() { +fn pre_005_tauri_commands_remain_centralized_and_only_single_refresh_is_present() { let root = app_root(); let mut rust_files = std::vec::Vec::new(); collect_files(root.join("src").as_path(), "rs", &mut rust_files); @@ -82,13 +82,19 @@ fn pre_004_tauri_commands_remain_centralized_and_refresh_is_absent() { } else { assert_eq!(count, 0, "{} declares a Tauri command outside tauri.rs", path.display()); } - assert!(!source.contains("refresh_market_price"), "{} advances refresh commands before their planned tranche", path.display()); + if path.file_name().and_then(std::ffi::OsStr::to_str) != std::option::Option::Some("tauri.rs") { + assert!(!source.contains("refresh_market_price"), "{} declares refresh command plumbing outside tauri.rs", path.display()); + } + assert!(!source.contains("refresh_market_prices"), "{} advances multi-refresh before pre.006", path.display()); + assert!(!source.contains("refresh_all_market_prices"), "{} advances global refresh before pre.006", path.display()); } - assert_eq!(command_count, 4); + assert_eq!(command_count, 5); + let tauri = read_text(root.join("src/tauri.rs").as_path()); + assert!(tauri.contains("refresh_market_price")); } #[test] -fn pre_004_frontend_registry_loading_is_logged_without_business_values() { +fn pre_005_frontend_refresh_interactions_are_logged_without_business_values() { let root = app_root(); let main = read_text(root.join("frontend/ts/main.ts").as_path()); let invoke = read_text(root.join("frontend/ts/invoke.ts").as_path()); @@ -101,6 +107,9 @@ fn pre_004_frontend_registry_loading_is_logged_without_business_values() { assert!(invoke.contains("frontendTrace")); assert!(main.contains("SOL Prices Desk provider registry load started")); assert!(main.contains("SOL Prices Desk market-price registry rows rendered")); + assert!(main.contains("SOL Prices Desk market-price refresh control clicked")); + assert!(main.contains("SOL Prices Desk market-price refresh row applied")); + assert!(main.contains("SOL Prices Desk market-price refresh failed")); for forbidden in ["JSON.stringify(status)", "JSON.stringify(payload)", "apiKey", "authorization"] { assert!(!main.contains(forbidden), "main frontend logging must not serialize business/secret values: {forbidden}"); } diff --git a/crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs b/crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs index 50f5cab..7dafcd7 100644 --- a/crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs +++ b/crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs -// version: 1 +// version: 2 fn descriptor() -> std::option::Option { let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new("test-provider"); @@ -138,3 +138,95 @@ fn observation_projection_keeps_exact_decimal_and_distinct_timestamps() { assert!(row.loading); assert!(row.refreshed); } + +#[tokio::test] +async fn disabled_refresh_clears_loading_without_inventing_observation() -> ksp_core_lib::Result<()> { + let settings = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let service = + match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(settings),]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let provider_id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut entries = std::collections::BTreeMap::new(); + entries.insert(provider_id.as_str().to_owned(), super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::None }); + let mut presentation = super::MarketPricePresentationState { entries }; + let outcome = match service.refresh(&provider_id).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert!(!outcome.refreshed()); + let applied = super::apply_outcome_to_state(&mut presentation, &outcome); + if let std::result::Result::Err(error) = applied { + return std::result::Result::Err(error); + } + let entry = presentation.entries.get(provider_id.as_str()); + assert!(entry.is_some()); + if let std::option::Option::Some(entry) = entry { + assert!(!entry.loading); + assert!(entry.observation.is_none()); + } + return std::result::Result::Ok(()); +} + +#[tokio::test] +async fn non_refresh_outcome_preserves_last_successful_observation() -> ksp_core_lib::Result<()> { + let settings = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let service = + match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(settings),]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let provider_id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let price = match ksp_offchain_transport_lib::MarketPriceDecimal::parse("101.2500") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let provenance = match ksp_offchain_transport_lib::MarketPriceProvenance::new("unit-test") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let observation = match ksp_offchain_transport_lib::MarketPriceObservation::new( + provider_id.clone(), + price, + ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket, + ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(10), + ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(30), + std::option::Option::Some(ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(20)), + provenance, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut entries = std::collections::BTreeMap::new(); + entries.insert(provider_id.as_str().to_owned(), super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::Some(observation) }); + let mut presentation = super::MarketPricePresentationState { entries }; + let outcome = match service.refresh(&provider_id).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert!(!outcome.refreshed()); + let applied = super::apply_outcome_to_state(&mut presentation, &outcome); + if let std::result::Result::Err(error) = applied { + return std::result::Result::Err(error); + } + let entry = presentation.entries.get(provider_id.as_str()); + assert!(entry.is_some()); + if let std::option::Option::Some(entry) = entry { + assert!(!entry.loading); + assert_eq!(entry.observation.as_ref().map(|value| return value.price().to_canonical_string()).as_deref(), std::option::Option::Some("101.25")); + } + return std::result::Result::Ok(()); +} diff --git a/deltas/0.2.12/pre.005.md b/deltas/0.2.12/pre.005.md new file mode 100644 index 0000000..72d3059 --- /dev/null +++ b/deltas/0.2.12/pre.005.md @@ -0,0 +1,85 @@ +# Delta `0.2.12-pre.005` — Refresh individuel SOL/USD + +## 1. Identité + +```text +release : 0.2.12 +tranche : pre.005 +version Cargo : 0.2.12-pre.5 +base : 0.2.12-pre.4.fix.2 +``` + +Le gate opérateur de la base est intégralement propre : audits Rust/Markdown, `cargo check --workspace`, Clippy, `cargo test -p ksp-app-solprices-desk`, `cargo test --workspace` et `cargo tauri dev` passent. + +## 2. Scope réalisé + +`pre.005` branche le premier chemin réseau explicite de SOL Prices Desk sans élargir la surface Off-chain Transport : + +```text +refresh individuel par provider_id opaque oui +MarketPriceService::refresh réutilisé directement +nouvel adapter/provider non +mutex présentation tenu pendant I/O non +état loading mémoire backend par ligne +succès nouvelle observation exacte mémorisée +outcome sans observation ancienne observation réussie conservée +availability / retry projection du registry Off-chain +commande Tauri refresh_market_price oui +refresh many / all non, réservé à pre.006 +polling / auto-refresh non +``` + +Le DTO de ligne existant reste lossless : prix et timestamps sont toujours des chaînes exactes. Aucune valeur `f64` n'est introduite. + +## 3. Frontend + +La table reçoit une colonne `Action` et un bouton `Refresh` par provider. Pendant l'appel, la ligne est localement affichée `loading` et le contrôle est désactivé ; la réponse backend remplace ensuite uniquement la ligne ciblée. + +Le logging suit le contrat commun des Desks : + +```text +clic Refresh debug +requête IPC debug +fin IPC trace +application de la row trace +échec warn +``` + +Seul le `provider_id` opaque peut accompagner ces événements. Aucun prix, payload provider, credential, header ou URL n'est journalisé. + +## 4. Tests/canaris + +Les canaris de la crate évoluent pour vérifier : + +- présence du seul `refresh_market_price` et absence des refresh many/all ; +- centralisation Tauri dans `tauri.rs` ; +- absence de `reqwest`/`fetch`/persistance navigateur ; +- conservation de la dernière observation lorsqu'un outcome ne produit aucun nouveau prix ; +- remise à `loading = false` pour un provider non éligible sans dispatch réseau ; +- logging frontend du clic et de l'IPC sans valeurs métier. + +## 5. Hors scope confirmé + +```text +refresh selected/many/all +scheduling/polling +consensus/fallback provider +prix canonique KSP +valorisation USD Wallet +persistance Config/Store/browser +``` + +## 6. Gate + +À exécuter par l'opérateur : + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12 +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-app-solprices-desk +cargo test --workspace +(cd crates/ksp-app-solprices-desk && cargo tauri dev) +``` diff --git a/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md b/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md index d1ad0b7..7eb43eb 100644 --- a/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md +++ b/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.2.12` — SOL Prices Desk + intégration prix Wallet Desk @@ -508,15 +508,15 @@ Correction du gate workspace révélé après `pre.004` : le registry Config con #### `pre.004-fix.002` — Canari de version desktop rendu version-neutral -**Statut : implémenté ; gate opérateur à confirmer.** +**Statut : réalisé ; gate opérateur intégralement PASS.** Le gate de `pre.004-fix.001` a révélé que `desktop_contract.rs` comparait encore `package.json` et `tauri.conf.json` à la chaîne figée `0.2.12-pre.4`. Le fix reprend la convention déjà utilisée par Config Desk et Wallet Desk : validation SemVer-like avec plancher compatible, puis égalité entre les deux métadonnées desktop. Les futures prereleases/fixes peuvent ainsi avancer la version sans rendre le canari historique `pre.002` artificiellement rouge. Aucun runtime prix, provider, DTO ou frontend n'est modifié. ### `pre.005` — Refresh individuel -**Statut : planifié.** +**Statut : implémenté ; gate opérateur à confirmer.** -Branchement du refresh d'une ligne via `MarketPriceService::refresh`, projection de l'observation exacte, de la fraîcheur et de l'availability, gestion d'erreur sûre et maintien du dernier état applicatif uniquement en mémoire backend. +Branchement du refresh d'une ligne via `MarketPriceService::refresh`, projection de l'observation exacte, de la fraîcheur et de l'availability, gestion d'erreur sûre et maintien du dernier état applicatif uniquement en mémoire backend. Le backend marque la ligne `loading` sans conserver le mutex de présentation pendant l'`await`, puis applique l'outcome provider-neutral ; une tentative sans nouvelle observation conserve la dernière observation réussie. L'UI ajoute un bouton `Refresh` par ligne, journalise le clic en `debug`, et n'expose encore aucun refresh multiple/global avant `pre.006`. ### `pre.006` — Refresh multiple et global diff --git a/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md b/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md index b334056..ac05c96 100644 --- a/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md +++ b/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md @@ -1,5 +1,5 @@ - + # Validation `0.2.12` — SOL Prices Desk + intégration Wallet Desk @@ -416,3 +416,44 @@ runtime prix / DTO / provider / frontend inchangés La version technique devient `0.2.12-pre.4.fix.2`. Le gate attendu doit confirmer que le canari reste vert avec ce suffixe et continuera de l'être lors des prochaines prereleases. +## 20. Gate `0.2.12-pre.005` + +Le gate opérateur de clôture de `pre.004-fix.002` est intégralement vert : + +```text +cargo fmt --all PASS +python3 scripts/audit_rust_workspace_rules.py PASS +python3 scripts/audit_markdown_tables.py ... deltas/0.2.12 PASS +cargo check --workspace PASS +cargo clippy --workspace --all-targets PASS +cargo test -p ksp-app-solprices-desk PASS +cargo test --workspace PASS +cargo tauri dev SOL Prices Desk PASS +``` + +La tranche `pre.005` ajoute uniquement le refresh individuel : + +```text +MarketPriceService::refresh branché par provider_id opaque +mutex présentation détenu pendant await non +loading backend par ligne oui +observation réussie exacte conservée sous forme string via DTO existant +absence de nouvelle observation dernière observation réussie conservée +availability/retry source Off-chain Transport, sans réinterprétation app +refresh_market_price implémenté +refresh_market_prices absent jusqu'à pre.006 +refresh_all_market_prices absent jusqu'à pre.006 +bouton Refresh par ligne implémenté +clic refresh frontend debug, provider_id opaque uniquement +IPC request/completion debug / trace via invokeKsp +prix/payload/header/URL/API key dans les logs absent par contrat +polling / scheduler consommateur absent +``` + +Validations sandbox après matérialisation : + +```text +python3 scripts/audit_rust_workspace_rules.py PASS +python3 scripts/audit_markdown_tables.py README.md ... deltas PASS +cargo fmt/check/clippy/test NOT RUN — cargo indisponible dans le sandbox +```