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
+```