//! 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); }