v0.2.12-pre.005
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-solprices-desk/frontend/main.html -->
|
||||
<!-- version: 5 -->
|
||||
<!-- version: 6 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<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 id="pricesLoading" class="app-placeholder">
|
||||
@@ -78,6 +78,7 @@
|
||||
<th scope="col">Provider timestamp (ms)</th>
|
||||
<th scope="col">Received at (ms)</th>
|
||||
<th scope="col">Retry at (ms)</th>
|
||||
<th scope="col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="marketPriceRows"></tbody>
|
||||
|
||||
@@ -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<ViewId, string> = {
|
||||
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<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 {
|
||||
const loading = document.querySelector<HTMLElement>("#pricesLoading");
|
||||
const empty = document.querySelector<HTMLElement>("#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<void> {
|
||||
frontendTrace("main", "SOL Prices Desk provider registry load started");
|
||||
const rows = await invokeKsp<MarketPriceProviderRowDto[]>("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<void> {
|
||||
await loadMarketPrices();
|
||||
} catch {
|
||||
const loading = document.querySelector<HTMLElement>("#pricesLoading");
|
||||
const diagnostic = document.querySelector<HTMLElement>("#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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<crate::MarketPriceProviderRowDto> {
|
||||
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<crate::MarketPriceRuntimeStatusDto> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
@@ -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<std::vec::Vec<crate::MarketPriceProviderRowDto>> {
|
||||
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<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 {
|
||||
@@ -136,6 +215,27 @@ struct MarketPricePresentationState {
|
||||
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 {
|
||||
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<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(
|
||||
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;
|
||||
|
||||
@@ -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::Wry>) -> tauri::Builder<taur
|
||||
|
||||
#[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> {
|
||||
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> {
|
||||
@@ -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]
|
||||
async fn splash_frontend_ready(
|
||||
app: tauri::AppHandle,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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</th>"));
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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<ksp_offchain_transport_lib::MarketPriceProviderDescriptor> {
|
||||
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(());
|
||||
}
|
||||
|
||||
85
deltas/0.2.12/pre.005.md
Normal file
85
deltas/0.2.12/pre.005.md
Normal 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)
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- 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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user