Some checks failed
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
nigig-map / test (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
p2p-intel / engine (push) Has been cancelled
p2p-intel / coverage (push) Has been cancelled
p2p-intel / makepad-app (push) Has been cancelled
p2p-intel / exchange-tab (push) Has been cancelled
The exchange tab in nigig-mpesa and nigig-pay was a swap mock-up -- Sell/Buy
cards showing hardcoded ETH and AAVE at $38,409.24, wired to nothing -- above
six rate labels, each built from the *first* cached advert for its exchange:
Binance P2P: USDT BUY @ 129.92 KES min 1000 max 200000 via BANK
That is a price, not an opportunity. It never compared the two sides of the
book, said nothing about whether the counterparty could be dealt with, and
ignored what it costs to move the money. Against the live KES book it would
have shown a 134.60 advert from a merchant with three completed trades.
Both pages now run the p2p-intel analyzer over the adverts the app already
caches. No new endpoint, no new traffic, api.rs untouched -- the data was
always there, nothing was being asked of it. The two files are
byte-identical and CI now fails if they drift apart, which they already had
once: data.rs differs between the apps by 188 lines of tests one copy has
and the other does not.
**Rails, because a spread alone is a lie.** 29 bps on a 10,000 KES trade is
29 KES of gross margin; M-Pesa Send Money costs 55 KES a leg, 110 round
trip. The trade is deeply negative and the spread says nothing about it.
rail.rs prices every route and ranks by what survives. M-Pesa fees come from
the published Safaricom bands in robius_ussd::mpesa_bands -- real tariff
rows, not estimates, and flat rather than percentage, which is exactly why
the same spread is ruinous at 500 KES and fine at 200,000. Both legs are
charged. An amount outside the tariff reports OutOfRange and an
unconfigured bank reports Unknown; neither reports zero, because zero is a
claim and it is the wrong one. Bank tariffs ship unconfigured for the same
reason: every bank differs and there is no table to default to.
**API drift capture.** These endpoints are internal and undocumented. When
one changes the symptom is an empty panel -- indistinguishable from a quiet
market -- and the response that broke it is gone by the time anyone looks.
Every parse failure is now recorded with the payload excerpt that caused it
and copyable as a plain-text report. Deduplicated, because a 30-second poll
against a changed endpoint fails 120 times an hour and 120 identical rows is
a log nobody reads; the excerpt is excluded from the equality check, since
two responses differing only in advert ids are the same drift. The exported
header states that it carries response excerpts only and never a request,
credential or account number -- the type can only be constructed from a
response body, so that is enforced rather than promised. Cutting the excerpt
is done on character boundaries: Binance really returns names like
BennyBoss and a byte slice would panic mid-character.
The repo's other clipboard code (nigig-build's crdt_widget.rs) answers a
Hit::TextCopy, which is the query-driven path the platform uses for Ctrl+C
on a focused widget. Right for a text editor, wrong for a button exporting a
report the user never selected, so this uses cx.copy_to_clipboard and
confirms in the UI -- a copy button with no feedback is one people press
three times.
On the four limits, the honest scoreboard is two closed, one usefully
sidestepped, one open. ADR 0035 records why, because rounding all four up to
"addressed" would have been the easy write-up and the wrong one.
The poll timer and the host clock are closed outright. cx.start_interval
delivers the tick as an ordinary UI-thread event, so there is no runtime and
nothing to join at shutdown, and the interval reads through
effective_poll_seconds so the fifteen-second floor still applies -- a config
file cannot be used to hammer a rate-limited endpoint, which a test pins by
name. Staleness is now shown, because a price from four minutes ago is not a
price. One ambiguity is recorded rather than hidden: last_scan_ms == 0 is the
"never scanned" sentinel, so a scan whose timestamp genuinely is 0 reads as
never. That only happens when the host clock is broken, which is exactly
when the UI should not claim the data is current.
The headless-backend limit is sidestepped, and the distinction matters
enough to write down. Xvfb is a real X11 server that draws into memory, so
the app gets the display it insists on and tools/test-p2p-app-smoke.sh runs
the real binary end to end. That earned its place immediately: it caught
`Row = <View> { ... }`, which is not valid in this fork's script language
and which cargo build is entirely silent about, because script_mod! is
parsed at *runtime* -- a broken widget tree compiles perfectly and then
renders nothing. The gate greps for [E] in the log for that reason; the
process exits 0. I verified it fails by reintroducing the bad syntax. What
it still cannot do is drive widgets: makepad_test's Selector::id(..).click()
needs the harness to own the event loop, so the six #[ignore]d interaction
tests stay ignored.
OS notifications stay open. There is no robius-notification crate beside
robius-sms and robius-ussd, and Makepad exposes none on any target; building
one means NotificationCompat on Android, UNUserNotification on iOS and a
D-Bus call on Linux. What shipped is the seam plus one implementation named
UnavailableNotifications -- named for what it is, because a DefaultNotifier
that silently discarded every alert would read like a working feature at
every call site. is_delivering() returns false and the UI shows the reason,
so the user is told that alerts stop with the window instead of assuming
they are covered. A no-op reporting success is the exact "declared versus
delivered" failure ADR 0017 exists to prevent.
223 tests, coverage 97.03% with fifteen per-file floors -- seven of them new
and all measured, after I noticed an earlier edit had silently failed to
apply and the new files were being counted in the total but gated by
nothing. CI gains the Xvfb smoke job and an exchange-tab job that builds
both host apps and diffs their pages.
208 lines
7.7 KiB
Rust
208 lines
7.7 KiB
Rust
//! The exchange tab's spread panel, end to end from cached-record shape.
|
|
//!
|
|
//! `nigig-mpesa` and `nigig-pay` cache P2P adverts as `OfflineP2PDealRecord`
|
|
//! and previously rendered one line per exchange from the *first* one. These
|
|
//! tests drive the replacement through the same conversion the pages use, so
|
|
//! the strings asserted here are the strings that reach the labels.
|
|
//!
|
|
//! The record shape is mirrored rather than imported: `p2p-analyzer` must not
|
|
//! depend on `nigig-core`, and the field list is stable. A drift would show
|
|
//! up as a compile error in the apps, which both build in CI.
|
|
|
|
use p2p_analyzer::{analyse, render_panel, snapshot_from_deals, summary_line, DealInput};
|
|
use p2p_core::QualityFilter;
|
|
|
|
/// Build the input the pages build, from the fields the cache holds.
|
|
fn cached(
|
|
exchange: &str,
|
|
advertiser: &str,
|
|
side: &str,
|
|
price: f64,
|
|
completion_rate: f64,
|
|
trades_count: u32,
|
|
) -> DealInput {
|
|
DealInput {
|
|
exchange: exchange.into(),
|
|
advertiser: advertiser.into(),
|
|
// Exactly what the pages do: the stored `side` is the tradeType the
|
|
// page *requested*, so "BUY" means an advert we can buy from.
|
|
we_buy: side.eq_ignore_ascii_case("BUY"),
|
|
crypto: "USDT".into(),
|
|
fiat: "KES".into(),
|
|
price,
|
|
available_amount: 5_000.0,
|
|
min_order: 1_000.0,
|
|
max_order: 200_000.0,
|
|
payment_methods: vec!["BANK".into()],
|
|
completion_rate,
|
|
trades_count,
|
|
}
|
|
}
|
|
|
|
fn report(deals: &[DealInput]) -> p2p_analyzer::SpreadReport {
|
|
let snap = snapshot_from_deals(deals, "Binance P2P", "KES", "USDT", 0);
|
|
analyse(&snap, &QualityFilter::default(), 0)
|
|
}
|
|
|
|
/// The headline case: a real spread, rendered for the card.
|
|
#[test]
|
|
fn a_two_sided_cache_renders_a_spread_card() {
|
|
let deals = vec![
|
|
cached("Binance P2P", "BennyBoss", "BUY", 129.92, 99.7, 4798),
|
|
cached("Binance P2P", "Twin_traders00", "SELL", 130.30, 99.0, 500),
|
|
];
|
|
let panel = render_panel(&report(&deals), 150);
|
|
|
|
assert_eq!(panel.headline, "USDT KES spread +0.29%");
|
|
assert!(panel.buy_line.contains("BennyBoss"));
|
|
assert!(panel.buy_line.contains("4798 orders"));
|
|
assert!(panel.sell_line.contains("Twin_traders00"));
|
|
assert!(panel.window_line.starts_with("Fillable"));
|
|
// 29 bps is under the 150 bps display threshold.
|
|
assert!(!panel.actionable);
|
|
}
|
|
|
|
/// What the page showed before, and what it shows now.
|
|
#[test]
|
|
fn the_summary_line_reports_a_spread_rather_than_one_adverts_price() {
|
|
let deals = vec![
|
|
cached("Binance P2P", "BennyBoss", "BUY", 129.92, 99.7, 4798),
|
|
cached("Binance P2P", "Twin_traders00", "SELL", 130.30, 99.0, 500),
|
|
];
|
|
let line = summary_line("Binance P2P", &report(&deals));
|
|
assert_eq!(line, "Binance P2P: buy 129.92 / sell 130.3 KES = +0.29%");
|
|
}
|
|
|
|
/// The defect the quality filter exists to prevent, through the real path.
|
|
#[test]
|
|
fn the_three_order_outlier_does_not_set_the_headline_price() {
|
|
// Reproduces the live KES book: the best-priced sell advert had three
|
|
// completed trades. The page's old "first cached deal" line would have
|
|
// shown it, and a naive best-price scan reads it as a 3.6% opportunity.
|
|
let deals = vec![
|
|
cached("Binance P2P", "BennyBoss", "BUY", 129.92, 99.7, 4798),
|
|
cached("Binance P2P", "tiero", "SELL", 134.60, 100.0, 3),
|
|
cached("Binance P2P", "Twin_traders00", "SELL", 130.30, 99.0, 500),
|
|
];
|
|
let r = report(&deals);
|
|
|
|
assert_eq!(r.best_sell.as_ref().unwrap().advertiser, "Twin_traders00");
|
|
assert_eq!(r.gross_bps, Some(29));
|
|
|
|
let panel = render_panel(&r, 150);
|
|
assert!(
|
|
!panel.headline.contains("3."),
|
|
"the outlier's spread leaked into the card"
|
|
);
|
|
assert!(panel.excluded_line.contains("1 advert excluded"));
|
|
}
|
|
|
|
/// An entirely unfillable book must not read as an opportunity.
|
|
#[test]
|
|
fn a_book_where_every_seller_fails_the_filter_says_so() {
|
|
// The honest state of the captured KES book at the default floor: the
|
|
// sell side had order counts of 3, 48, 25, 2 and 0.
|
|
let deals = vec![
|
|
cached("Binance P2P", "BennyBoss", "BUY", 129.92, 99.7, 4798),
|
|
cached("Binance P2P", "tiero", "SELL", 134.60, 100.0, 3),
|
|
cached("Binance P2P", "fougoumba1", "SELL", 130.23, 66.7, 2),
|
|
cached("Binance P2P", "Axchange01", "SELL", 130.00, 0.0, 0),
|
|
];
|
|
let r = report(&deals);
|
|
|
|
assert!(
|
|
r.best_buy.is_some(),
|
|
"the buy side had established merchants"
|
|
);
|
|
assert!(r.best_sell.is_none());
|
|
assert_eq!(r.net_bps, None);
|
|
|
|
let panel = render_panel(&r, 150);
|
|
assert!(panel.headline.contains("no spread from qualifying adverts"));
|
|
assert!(panel.sell_line.is_empty());
|
|
assert!(!panel.actionable);
|
|
|
|
let line = summary_line("Binance P2P", &r);
|
|
assert!(line.contains("below the quality floor"), "{line}");
|
|
}
|
|
|
|
/// An empty cache is a state, not a failure.
|
|
#[test]
|
|
fn an_empty_cache_reads_as_empty_rather_than_as_zero() {
|
|
let r = report(&[]);
|
|
let panel = render_panel(&r, 150);
|
|
assert!(panel.headline.contains("no adverts on either side"));
|
|
assert!(panel.buy_line.is_empty());
|
|
assert!(panel.window_line.is_empty());
|
|
assert_eq!(
|
|
summary_line("Bybit P2P", &r),
|
|
"Bybit P2P: no KES adverts cached"
|
|
);
|
|
}
|
|
|
|
/// The page caches several exchanges into one list; they must not be mixed.
|
|
#[test]
|
|
fn a_binance_buy_is_never_paired_with_a_bybit_sell() {
|
|
// Both exist in the same cached vector. A spread between them is not a
|
|
// trade anyone can execute in one place.
|
|
let deals = vec![
|
|
cached("Binance P2P", "BennyBoss", "BUY", 129.92, 99.7, 4798),
|
|
cached("Bybit P2P", "bybit_seller", "SELL", 140.00, 99.0, 900),
|
|
];
|
|
let r = report(&deals);
|
|
assert!(
|
|
r.best_sell.is_none(),
|
|
"a cross-exchange pair produced a phantom spread"
|
|
);
|
|
}
|
|
|
|
/// The inversion trap, asserted at the boundary the pages cross.
|
|
#[test]
|
|
fn the_stored_side_string_maps_to_our_action_not_the_advertisers() {
|
|
let buy = cached("Binance P2P", "x", "BUY", 129.92, 99.0, 500);
|
|
let sell = cached("Binance P2P", "y", "SELL", 130.30, 99.0, 500);
|
|
assert!(buy.we_buy, "a stored BUY is an advert we can buy from");
|
|
assert!(!sell.we_buy);
|
|
|
|
// And the resulting spread is positive, which it would not be if the
|
|
// two were swapped.
|
|
let r = report(&[buy, sell]);
|
|
assert!(r.gross_bps.unwrap() > 0);
|
|
}
|
|
|
|
/// A wide spread with no overlapping limits is the trap the card must show.
|
|
#[test]
|
|
fn limits_that_do_not_overlap_are_called_out_rather_than_hidden() {
|
|
let mut buy = cached("Binance P2P", "small", "BUY", 129.92, 99.0, 500);
|
|
buy.max_order = 13_000.0;
|
|
let mut sell = cached("Binance P2P", "large", "SELL", 130.30, 99.0, 500);
|
|
sell.min_order = 71_000.0;
|
|
|
|
let r = report(&[buy, sell]);
|
|
let panel = render_panel(&r, 20);
|
|
|
|
assert!(
|
|
panel.headline.contains("+0.29%"),
|
|
"the price spread looks fine"
|
|
);
|
|
assert_eq!(
|
|
panel.window_line,
|
|
"Limits do not overlap — no fillable amount"
|
|
);
|
|
assert!(summary_line("Binance P2P", &r).contains("limits do not overlap"));
|
|
}
|
|
|
|
/// Prices arrive as f64 from the page's JSON reader; they must not drift.
|
|
#[test]
|
|
fn a_cached_f64_price_survives_into_the_rendered_string_exactly() {
|
|
// 129.92_f64 is not exactly 129.92. If the conversion scaled the float
|
|
// directly it could land a unit low and render as 129.919999.
|
|
let deals = vec![
|
|
cached("Binance P2P", "a", "BUY", 129.92, 99.0, 500),
|
|
cached("Binance P2P", "b", "SELL", 130.30, 99.0, 500),
|
|
];
|
|
let panel = render_panel(&report(&deals), 150);
|
|
assert!(panel.buy_line.contains("129.92"), "{}", panel.buy_line);
|
|
assert!(!panel.buy_line.contains("129.919"), "{}", panel.buy_line);
|
|
assert!(panel.sell_line.contains("130.3"), "{}", panel.sell_line);
|
|
}
|