v0.2.12-pre.005

This commit is contained in:
2026-08-27 10:43:01 +02:00
parent 5ac2197b6e
commit 9d351dd1c3
14 changed files with 486 additions and 45 deletions

View File

@@ -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"] 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] [workspace.package]
version = "0.2.12-pre.4.fix.2" version = "0.2.12-pre.5"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-solprices-desk/frontend/main.html --> <!-- file: crates/ksp-app-solprices-desk/frontend/main.html -->
<!-- version: 5 --> <!-- version: 6 -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="fr">
@@ -47,7 +47,7 @@
<div class="d-flex align-items-center justify-content-between mb-4"> <div class="d-flex align-items-center justify-content-between mb-4">
<div> <div>
<h1 class="h3 mb-1">Prices</h1> <h1 class="h3 mb-1">Prices</h1>
<p class="text-body-secondary mb-0">Inventaire provider-neutral du registry Off-chain. Aucun refresh réseau n'est encore déclenché dans cette tranche.</p> <p class="text-body-secondary mb-0">Inventaire provider-neutral du registry Off-chain. Chaque ligne peut être rafraîchie explicitement sans polling ni fallback automatique.</p>
</div> </div>
</div> </div>
<div id="pricesLoading" class="app-placeholder"> <div id="pricesLoading" class="app-placeholder">
@@ -78,6 +78,7 @@
<th scope="col">Provider timestamp (ms)</th> <th scope="col">Provider timestamp (ms)</th>
<th scope="col">Received at (ms)</th> <th scope="col">Received at (ms)</th>
<th scope="col">Retry at (ms)</th> <th scope="col">Retry at (ms)</th>
<th scope="col">Action</th>
</tr> </tr>
</thead> </thead>
<tbody id="marketPriceRows"></tbody> <tbody id="marketPriceRows"></tbody>

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/frontend/ts/main.ts // file: crates/ksp-app-solprices-desk/frontend/ts/main.ts
// version: 4 // version: 5
import "bootstrap"; import "bootstrap";
import ResizeObserver from "resize-observer-polyfill"; import ResizeObserver from "resize-observer-polyfill";
@@ -20,6 +20,8 @@ const viewTitles: Record<ViewId, string> = {
diagnostics: "Diagnostics", diagnostics: "Diagnostics",
}; };
let currentMarketPriceRows: MarketPriceProviderRowDto[] = [];
function isViewId(value: string | undefined): value is ViewId { function isViewId(value: string | undefined): value is ViewId {
return value === "prices" || value === "diagnostics"; return value === "prices" || value === "diagnostics";
} }
@@ -60,6 +62,64 @@ function appendMarketPriceCell(row: HTMLTableRowElement, value: string): void {
row.append(cell); row.append(cell);
} }
function clearPricesDiagnostic(): void {
const diagnostic = document.querySelector<HTMLElement>("#pricesDiagnostic");
if (diagnostic) {
diagnostic.hidden = true;
diagnostic.textContent = "";
}
}
function showPricesDiagnostic(message: string): void {
const diagnostic = document.querySelector<HTMLElement>("#pricesDiagnostic");
if (diagnostic) {
diagnostic.hidden = false;
diagnostic.textContent = message;
}
}
async function refreshMarketPrice(providerId: string): Promise<void> {
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<MarketPriceProviderRowDto>("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
? '<i class="fa-solid fa-spinner fa-spin me-1" aria-hidden="true"></i>Refreshing…'
: '<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>Refresh';
button.addEventListener("click", () => {
void refreshMarketPrice(provider.providerId);
});
cell.append(button);
row.append(cell);
}
function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void { function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void {
const loading = document.querySelector<HTMLElement>("#pricesLoading"); const loading = document.querySelector<HTMLElement>("#pricesLoading");
const empty = document.querySelector<HTMLElement>("#pricesEmpty"); const empty = document.querySelector<HTMLElement>("#pricesEmpty");
@@ -77,6 +137,7 @@ function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void {
tableContainer.hidden = rows.length === 0; tableContainer.hidden = rows.length === 0;
for (const provider of rows) { for (const provider of rows) {
const row = document.createElement("tr"); const row = document.createElement("tr");
row.dataset.providerId = provider.providerId;
appendMarketPriceCell(row, provider.displayName); appendMarketPriceCell(row, provider.displayName);
appendMarketPriceCell(row, provider.pair); appendMarketPriceCell(row, provider.pair);
appendMarketPriceCell(row, provider.semantics); appendMarketPriceCell(row, provider.semantics);
@@ -86,6 +147,7 @@ function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void {
appendMarketPriceCell(row, provider.providerTimestampUnixMillis ?? "—"); appendMarketPriceCell(row, provider.providerTimestampUnixMillis ?? "—");
appendMarketPriceCell(row, provider.receivedAtUnixMillis ?? "—"); appendMarketPriceCell(row, provider.receivedAtUnixMillis ?? "—");
appendMarketPriceCell(row, provider.retryAtUnixMillis ?? "—"); appendMarketPriceCell(row, provider.retryAtUnixMillis ?? "—");
appendRefreshCell(row, provider);
tableBody.append(row); tableBody.append(row);
} }
frontendTrace("main", "SOL Prices Desk market-price registry rows rendered", { rowCount: rows.length }); 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<void> { async function loadMarketPrices(): Promise<void> {
frontendTrace("main", "SOL Prices Desk provider registry load started"); frontendTrace("main", "SOL Prices Desk provider registry load started");
const rows = await invokeKsp<MarketPriceProviderRowDto[]>("main", "list_market_prices"); const rows = await invokeKsp<MarketPriceProviderRowDto[]>("main", "list_market_prices");
currentMarketPriceRows = rows;
renderMarketPriceRows(rows); renderMarketPriceRows(rows);
frontendTrace("main", "SOL Prices Desk provider registry load completed", { rowCount: rows.length }); frontendTrace("main", "SOL Prices Desk provider registry load completed", { rowCount: rows.length });
} }
@@ -155,14 +218,10 @@ async function initializeMain(): Promise<void> {
await loadMarketPrices(); await loadMarketPrices();
} catch { } catch {
const loading = document.querySelector<HTMLElement>("#pricesLoading"); const loading = document.querySelector<HTMLElement>("#pricesLoading");
const diagnostic = document.querySelector<HTMLElement>("#pricesDiagnostic");
if (loading) { if (loading) {
loading.hidden = true; loading.hidden = true;
} }
if (diagnostic) { showPricesDiagnostic("L'inventaire provider-neutral n'a pas pu être chargé.");
diagnostic.hidden = false;
diagnostic.textContent = "L'inventaire provider-neutral n'a pas pu être chargé.";
}
frontendWarn("main", "SOL Prices Desk provider registry load failed"); frontendWarn("main", "SOL Prices Desk provider registry load failed");
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "ksp-app-solprices-desk", "name": "ksp-app-solprices-desk",
"private": true, "private": true,
"version": "0.2.12-pre.4.fix.2", "version": "0.2.12-pre.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/src/app_state.rs // 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. //! 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(); 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<crate::MarketPriceProviderRowDto> {
return self.market_price_runtime.refresh_one(provider_id).await;
}
/// Builds the safe runtime status exposed by the SOL Prices Desk shell. /// Builds the safe runtime status exposed by the SOL Prices Desk shell.
pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result<crate::MarketPriceRuntimeStatusDto> { pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result<crate::MarketPriceRuntimeStatusDto> {
let document_count = self.config_management.engine().registry().descriptors().count(); let document_count = self.config_management.engine().registry().descriptors().count();
@@ -111,7 +116,7 @@ impl crate::AppState {
fallback_logging_active: runtime.fallback_active, fallback_logging_active: runtime.fallback_active,
provider_count, provider_count,
ready_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(), startup_diagnostic: runtime.startup_diagnostic.clone(),
unavailable_provider_count, unavailable_provider_count,
}); });

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/src/market_price_runtime.rs // 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. //! Provider-neutral market-price presentation runtime owned by SOL Prices Desk.
@@ -22,7 +22,7 @@ pub(crate) struct MarketPriceProviderRowDto {
pub(crate) pair: String, pub(crate) pair: String,
/// Exact canonical SOL/USD decimal string, when an observation exists. /// Exact canonical SOL/USD decimal string, when an observation exists.
pub(crate) price: std::option::Option<String>, pub(crate) price: std::option::Option<String>,
/// Opaque provider identifier accepted by future refresh commands. /// Opaque provider identifier accepted by refresh commands.
pub(crate) provider_id: String, pub(crate) provider_id: String,
/// Provider-supplied observation timestamp in exact Unix milliseconds, when genuinely supplied. /// Provider-supplied observation timestamp in exact Unix milliseconds, when genuinely supplied.
pub(crate) provider_timestamp_unix_millis: std::option::Option<String>, pub(crate) provider_timestamp_unix_millis: std::option::Option<String>,
@@ -98,15 +98,10 @@ impl crate::MarketPriceRuntime {
/// Projects the current registry and in-memory observation state as deterministic frontend rows without network dispatch. /// 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<std::vec::Vec<crate::MarketPriceProviderRowDto>> { pub(crate) fn list_rows(&self) -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceProviderRowDto>> {
let registry = self.startup.resolved().service().registry(); let registry = self.startup.resolved().service().registry();
let presentation = self.presentation.lock(); let presentation = lock_presentation(&self.presentation);
let presentation = match presentation { let presentation = match presentation {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => { std::result::Result::Err(error) => return std::result::Result::Err(error),
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",
));
},
}; };
let mut rows = std::vec::Vec::with_capacity(registry.len()); let mut rows = std::vec::Vec::with_capacity(registry.len());
for entry in registry.entries() { for entry in registry.entries() {
@@ -125,6 +120,90 @@ impl crate::MarketPriceRuntime {
} }
return std::result::Result::Ok(rows); 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<crate::MarketPriceProviderRowDto> {
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<crate::MarketPriceProviderRowDto> {
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 { struct MarketPricePresentationEntry {
@@ -136,6 +215,27 @@ struct MarketPricePresentationState {
entries: std::collections::BTreeMap<String, MarketPricePresentationEntry>, entries: std::collections::BTreeMap<String, MarketPricePresentationEntry>,
} }
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 { fn auth_mode_code(auth_mode: ksp_offchain_transport_lib::MarketPriceProviderAuthMode) -> &'static str {
return match auth_mode { return match auth_mode {
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None => "none", ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None => "none",
@@ -170,6 +270,18 @@ fn count_to_u32(value: usize, field: &'static str) -> ksp_core_lib::Result<u32>
}; };
} }
fn lock_presentation(
presentation: &std::sync::Mutex<MarketPricePresentationState>,
) -> ksp_core_lib::Result<std::sync::MutexGuard<'_, MarketPricePresentationState>> {
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( fn project_registry_entry(
entry: &ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry, entry: &ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry,
presentation: &MarketPricePresentationEntry, 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)] #[cfg(test)]
#[path = "../unit_tests/market_price_runtime.rs"] #[path = "../unit_tests/market_price_runtime.rs"]
mod tests; mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/src/tauri.rs // file: crates/ksp-app-solprices-desk/src/tauri.rs
// version: 3 // version: 4
//! Tauri runtime assembly for the KSP SOL prices desktop application. //! Tauri runtime assembly for the KSP SOL prices desktop application.
@@ -72,7 +72,13 @@ fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<taur
#[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch. #[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch.
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> { fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
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::Wry>) -> tauri::Builder<tauri::Wry> { fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
@@ -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<crate::MarketPriceProviderRowDto, crate::CommandErrorDto> {
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] #[tauri::command]
async fn splash_frontend_ready( async fn splash_frontend_ready(
app: tauri::AppHandle, app: tauri::AppHandle,

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "KSP SOL Prices Desk", "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", "identifier": "com.sasedev.ksp-app-solprices-desk",
"build": { "build": {
"beforeDevCommand": { "beforeDevCommand": {

View File

@@ -192,7 +192,7 @@ fn pre_002_fix_001_reuses_the_common_ksp_desktop_template() {
} }
#[test] #[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 root = app_root();
let manifest = read_text(root.join("Cargo.toml").as_path()); let manifest = read_text(root.join("Cargo.toml").as_path());
assert!(manifest.contains(r#"ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }"#)); 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("mod market_price_runtime;"));
assert!(lib_rs.contains("MarketPriceProviderRowDto")); assert!(lib_rs.contains("MarketPriceProviderRowDto"));
assert!(lib_rs.contains("MarketPriceRuntime")); 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()); let tauri_rs = read_text(root.join("src/tauri.rs").as_path());
assert!(tauri_rs.contains("list_market_prices")); assert!(tauri_rs.contains("refresh_market_price"));
for forbidden in ["refresh_market_price", "refresh_market_prices", "refresh_all_market_prices"] { for forbidden in ["refresh_market_prices", "refresh_all_market_prices"] {
assert!(!tauri_rs.contains(forbidden)); assert!(!tauri_rs.contains(forbidden));
} }
let main_html = read_text(root.join("frontend/main.html").as_path()); 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="marketPriceTable""#));
assert!(main_html.contains(r#"id="marketPriceRows""#)); assert!(main_html.contains(">Action</th>"));
assert!(!main_html.contains("Refresh")); assert!(main_html.contains("rafraîchie explicitement"));
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path()); 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(r#""refresh_market_price""#));
assert!(main_typescript.contains("MarketPriceProviderRowDto")); assert!(main_typescript.contains("SOL Prices Desk market-price refresh control clicked"));
assert!(main_typescript.contains(r#"provider.price ?? "—""#)); 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("parseFloat("));
assert!(!main_typescript.contains("Number(provider.price")); assert!(!main_typescript.contains("Number(provider.price"));
} }

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/tests/desktop_security.rs // file: crates/ksp-app-solprices-desk/tests/desktop_security.rs
// version: 3 // version: 4
//! Security, ownership and frontend instrumentation canaries for SOL Prices Desk. //! 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] #[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 root = app_root();
let mut rust_files = std::vec::Vec::new(); let mut rust_files = std::vec::Vec::new();
collect_files(root.join("src").as_path(), "rs", &mut rust_files); 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 { } else {
assert_eq!(count, 0, "{} declares a Tauri command outside tauri.rs", path.display()); 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] #[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 root = app_root();
let main = read_text(root.join("frontend/ts/main.ts").as_path()); let main = read_text(root.join("frontend/ts/main.ts").as_path());
let invoke = read_text(root.join("frontend/ts/invoke.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!(invoke.contains("frontendTrace"));
assert!(main.contains("SOL Prices Desk provider registry load started")); 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 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"] { 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}"); assert!(!main.contains(forbidden), "main frontend logging must not serialize business/secret values: {forbidden}");
} }

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs // file: crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs
// version: 1 // version: 2
fn descriptor() -> std::option::Option<ksp_offchain_transport_lib::MarketPriceProviderDescriptor> { fn descriptor() -> std::option::Option<ksp_offchain_transport_lib::MarketPriceProviderDescriptor> {
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new("test-provider"); 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.loading);
assert!(row.refreshed); 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(());
}

85
deltas/0.2.12/pre.005.md Normal file
View File

@@ -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)
```

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md --> <!-- file: docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md -->
<!-- version: 9 --> <!-- version: 10 -->
# Plan `0.2.12` — SOL Prices Desk + intégration prix Wallet Desk # 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 #### `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é. 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 ### `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 ### `pre.006` — Refresh multiple et global

View File

@@ -1,5 +1,5 @@
<!-- file: docs/validation/015-V0_2_12_SOL_PRICES_DESK.md --> <!-- file: docs/validation/015-V0_2_12_SOL_PRICES_DESK.md -->
<!-- version: 8 --> <!-- version: 9 -->
# Validation `0.2.12` — SOL Prices Desk + intégration Wallet Desk # 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. 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
```