@@ -3,7 +3,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
33
44import type { SwapSuggestion } from "@/lib/rebalancer" ;
55
6- import { SwapRoute } from "./SwapRoute" ;
6+ import {
7+ detectCircularRoutes ,
8+ isCircularRoute ,
9+ SwapRoute ,
10+ validateSwapRoute ,
11+ } from "./SwapRoute" ;
712
813const SWAP : SwapSuggestion = {
914 from : "XLM" ,
@@ -107,4 +112,185 @@ describe("SwapRoute", () => {
107112 // The swap fee uses a different format so the two are not conflated.
108113 expect ( screen . getByText ( "0.30%" ) ) . toBeInTheDocument ( ) ;
109114 } ) ;
115+
116+ // ─── Issue #922 acceptance criteria ─────────────────────────────────────────
117+
118+ describe ( "Multi-hop routes with fee tiers and liquidity depth (Issue #922)" , ( ) => {
119+ it ( "displays fee tiers and pool liquidity depth for intermediate hops" , ( ) => {
120+ const multiHopSwap : SwapSuggestion = {
121+ ...SWAP ,
122+ from : "XLM" ,
123+ to : "BTC" ,
124+ hops : [
125+ {
126+ asset : "USDC" ,
127+ feeTier : "0.3%" ,
128+ liquidityDepth : "$1.2M" ,
129+ } ,
130+ ] ,
131+ } ;
132+
133+ render ( < SwapRoute swaps = { [ multiHopSwap ] } /> ) ;
134+
135+ // Asset pills for source, intermediate hop, and destination
136+ expect ( screen . getAllByText ( "XLM" ) . length ) . toBeGreaterThan ( 0 ) ;
137+ expect ( screen . getAllByText ( "USDC" ) . length ) . toBeGreaterThan ( 0 ) ;
138+ expect ( screen . getAllByText ( "BTC" ) . length ) . toBeGreaterThan ( 0 ) ;
139+
140+ // Fee tier badge for intermediate hop
141+ expect ( screen . getByText ( "0.3%" ) ) . toBeInTheDocument ( ) ;
142+ expect ( screen . getByTestId ( "fee-tier-badge" ) ) . toHaveTextContent ( "0.3%" ) ;
143+
144+ // Pool liquidity depth indicator
145+ expect ( screen . getByText ( "$1.2M" ) ) . toBeInTheDocument ( ) ;
146+ expect ( screen . getByTestId ( "liquidity-indicator" ) ) . toHaveTextContent ( "$1.2M" ) ;
147+ } ) ;
148+
149+ it ( "displays multiple intermediate hops with their respective fee tiers and depths" , ( ) => {
150+ const twoHopSwap : SwapSuggestion = {
151+ ...SWAP ,
152+ from : "XLM" ,
153+ to : "BTC" ,
154+ hops : [
155+ { asset : "USDC" , feeTier : 0.05 , poolDepth : 500000 } ,
156+ { asset : "EURC" , feeTier : "0.3%" , liquidityDepth : "$2.5M" } ,
157+ ] ,
158+ } ;
159+
160+ render ( < SwapRoute swaps = { [ twoHopSwap ] } /> ) ;
161+
162+ // Both intermediate nodes rendered
163+ expect ( screen . getAllByTestId ( "intermediate-hop-node" ) ) . toHaveLength ( 2 ) ;
164+ expect ( screen . getByText ( "0.05%" ) ) . toBeInTheDocument ( ) ;
165+ expect ( screen . getByText ( "$500k" ) ) . toBeInTheDocument ( ) ;
166+ expect ( screen . getByText ( "0.3%" ) ) . toBeInTheDocument ( ) ;
167+ expect ( screen . getByText ( "$2.5M" ) ) . toBeInTheDocument ( ) ;
168+ } ) ;
169+
170+ it ( "renders responsive vertical arrow connectors for small viewports (<640px) and horizontal connectors for desktop" , ( ) => {
171+ const multiHopSwap : SwapSuggestion = {
172+ ...SWAP ,
173+ from : "XLM" ,
174+ to : "BTC" ,
175+ hops : [ { asset : "USDC" , feeTier : "0.3%" , liquidityDepth : "$1.2M" } ] ,
176+ } ;
177+
178+ render ( < SwapRoute swaps = { [ multiHopSwap ] } /> ) ;
179+
180+ const connectors = screen . getAllByTestId ( "route-connector" ) ;
181+ // 2 connectors: XLM -> USDC and USDC -> BTC
182+ expect ( connectors ) . toHaveLength ( 2 ) ;
183+
184+ // Verify vertical connectors (block sm:hidden) for mobile viewports under 640px
185+ const verticalConnectors = screen . getAllByTestId ( "vertical-connector" ) ;
186+ expect ( verticalConnectors ) . toHaveLength ( 2 ) ;
187+ verticalConnectors . forEach ( ( vc ) => {
188+ expect ( vc ) . toHaveClass ( "block" ) ;
189+ expect ( vc ) . toHaveClass ( "sm:hidden" ) ;
190+ } ) ;
191+
192+ // Verify horizontal connectors (hidden sm:block) for desktop viewports
193+ const horizontalConnectors = screen . getAllByTestId ( "horizontal-connector" ) ;
194+ expect ( horizontalConnectors ) . toHaveLength ( 2 ) ;
195+ horizontalConnectors . forEach ( ( hc ) => {
196+ expect ( hc ) . toHaveClass ( "hidden" ) ;
197+ expect ( hc ) . toHaveClass ( "sm:block" ) ;
198+ } ) ;
199+ } ) ;
200+ } ) ;
201+
202+ describe ( "Circular route detection and rejection (Issue #922)" , ( ) => {
203+ it ( "detects circular multi-hop route (e.g. XLM -> USDC -> XLM)" , ( ) => {
204+ const circularSwap : SwapSuggestion = {
205+ ...SWAP ,
206+ from : "XLM" ,
207+ to : "XLM" ,
208+ hops : [ { asset : "USDC" , feeTier : "0.3%" , liquidityDepth : "$1.2M" } ] ,
209+ } ;
210+
211+ expect ( isCircularRoute ( circularSwap ) ) . toBe ( true ) ;
212+
213+ const validation = validateSwapRoute ( circularSwap ) ;
214+ expect ( validation . isValid ) . toBe ( false ) ;
215+ expect ( validation . isCircular ) . toBe ( true ) ;
216+ expect ( validation . error ) . toMatch ( / c i r c u l a r r o u t e r e j e c t e d / i) ;
217+ expect ( validation . path ) . toEqual ( [ "XLM" , "USDC" , "XLM" ] ) ;
218+ } ) ;
219+
220+ it ( "detects circular route when destination matches origin directly (e.g. XLM -> XLM)" , ( ) => {
221+ const directCircularSwap : SwapSuggestion = {
222+ ...SWAP ,
223+ from : "XLM" ,
224+ to : "XLM" ,
225+ } ;
226+
227+ expect ( isCircularRoute ( directCircularSwap ) ) . toBe ( true ) ;
228+ expect ( validateSwapRoute ( directCircularSwap ) . isValid ) . toBe ( false ) ;
229+ } ) ;
230+
231+ it ( "detects circular route defined via path array (e.g. ['XLM', 'USDC', 'XLM'])" , ( ) => {
232+ expect ( isCircularRoute ( [ "XLM" , "USDC" , "XLM" ] ) ) . toBe ( true ) ;
233+ expect ( isCircularRoute ( [ "XLM" , "USDC" , "BTC" ] ) ) . toBe ( false ) ;
234+
235+ const pathSwap : SwapSuggestion = {
236+ ...SWAP ,
237+ from : "XLM" ,
238+ to : "BTC" ,
239+ path : [ "XLM" , "USDC" , "XLM" , "BTC" ] ,
240+ } ;
241+ expect ( isCircularRoute ( pathSwap ) ) . toBe ( true ) ;
242+ } ) ;
243+
244+ it ( "detects circular cycles across multiple swaps (e.g. XLM -> USDC and USDC -> XLM)" , ( ) => {
245+ const swap1 : SwapSuggestion = { ...SWAP , from : "XLM" , to : "USDC" } ;
246+ const swap2 : SwapSuggestion = { ...SWAP , from : "USDC" , to : "XLM" } ;
247+
248+ const cycleResult = detectCircularRoutes ( [ swap1 , swap2 ] ) ;
249+ expect ( cycleResult . hasCycle ) . toBe ( true ) ;
250+ expect ( cycleResult . circularIndices ) . toContain ( 0 ) ;
251+ expect ( cycleResult . circularIndices ) . toContain ( 1 ) ;
252+ } ) ;
253+
254+ it ( "validates that a valid non-circular route is accepted" , ( ) => {
255+ const validSwap : SwapSuggestion = {
256+ ...SWAP ,
257+ from : "XLM" ,
258+ to : "BTC" ,
259+ hops : [ { asset : "USDC" , feeTier : "0.3%" , liquidityDepth : "$1.2M" } ] ,
260+ } ;
261+
262+ expect ( isCircularRoute ( validSwap ) ) . toBe ( false ) ;
263+ const validation = validateSwapRoute ( validSwap ) ;
264+ expect ( validation . isValid ) . toBe ( true ) ;
265+ expect ( validation . isCircular ) . toBe ( false ) ;
266+ expect ( validation . error ) . toBeUndefined ( ) ;
267+ } ) ;
268+
269+ it ( "rejects circular routes in the UI by displaying rejection banner and rejected badge" , ( ) => {
270+ const circularSwap : SwapSuggestion = {
271+ ...SWAP ,
272+ from : "XLM" ,
273+ to : "XLM" ,
274+ hops : [ { asset : "USDC" , feeTier : "0.3%" , liquidityDepth : "$1.2M" } ] ,
275+ } ;
276+
277+ render ( < SwapRoute swaps = { [ circularSwap ] } statuses = { [ "success" ] } /> ) ;
278+
279+ // Alert banner is rendered
280+ expect ( screen . getByRole ( "alert" ) ) . toHaveTextContent ( / c i r c u l a r r o u t e d e t e c t e d a n d r e j e c t e d / i) ;
281+
282+ // Badge displays "Rejected" rather than "Done"
283+ expect ( screen . getByText ( "Rejected" ) ) . toBeInTheDocument ( ) ;
284+ expect ( screen . queryByText ( "Done" ) ) . not . toBeInTheDocument ( ) ;
285+
286+ // Error message explains circular route rejection
287+ expect ( screen . getByText ( / c i r c u l a r r o u t e r e j e c t e d .* X L M .* U S D C .* X L M / i) ) . toBeInTheDocument ( ) ;
288+
289+ // Row aria-label marks circular route as rejected
290+ expect (
291+ screen . getByRole ( "listitem" , { name : / c i r c u l a r r o u t e r e j e c t e d / i } ) ,
292+ ) . toBeInTheDocument ( ) ;
293+ } ) ;
294+ } ) ;
110295} ) ;
296+
0 commit comments