diff --git a/apps/finance-ai-agent-demo/backend/agent/sprawl_tools.py b/apps/finance-ai-agent-demo/backend/agent/sprawl_tools.py index faebcb77..6496a71e 100644 --- a/apps/finance-ai-agent-demo/backend/agent/sprawl_tools.py +++ b/apps/finance-ai-agent-demo/backend/agent/sprawl_tools.py @@ -581,3 +581,135 @@ def convergent_search_sprawl( return f"No results found for account '{account_id}'" return json.dumps(all_results, default=str) + + +# --------------------------------------------------------------------------- +# suggest_portfolio_hedge (PostgreSQL sprawl mode) +# --------------------------------------------------------------------------- + + +def suggest_portfolio_hedge_sprawl(pg_conn, args, query_logger): + """Hedge recommendation tool for sprawl/PostgreSQL mode. + + Mirrors _suggest_portfolio_hedge in tools.py but uses PostgreSQL syntax + (%(param)s placeholders, JSONB operators, window functions). + """ + import json as _json + + account_id = args.get("account_id", "") + risk_focus = args.get("risk_focus", "all") + + sql = """ + WITH holdings AS ( + SELECT + ph.holding_id, + ph.asset_class, + ph.instrument_name, + ph.ticker, + ph.sector, + ph.region, + ph.risk_rating, + ph.current_value, + SUM(ph.current_value) OVER () AS total_value, + ROUND( + ph.current_value / NULLIF(SUM(ph.current_value) OVER (), 0) * 100, 2 + ) AS pct_of_portfolio, + ca.risk_profile, + ca.metadata->>'esg_mandate' AS esg_mandate, + ca.metadata->>'max_single_position' AS max_position, + ca.metadata->'excluded_sectors' AS excluded_sectors + FROM portfolio_holdings ph + JOIN client_accounts ca ON ca.account_id = ph.account_id + WHERE ph.account_id = %(account_id)s + ), + sector_exposure AS ( + SELECT sector, + ROUND(SUM(pct_of_portfolio)::numeric, 2) AS sector_pct + FROM holdings + GROUP BY sector + ORDER BY sector_pct DESC + ), + region_exposure AS ( + SELECT region, + ROUND(SUM(pct_of_portfolio)::numeric, 2) AS region_pct + FROM holdings + GROUP BY region + ORDER BY region_pct DESC + ), + asset_class_exposure AS ( + SELECT asset_class, + ROUND(SUM(pct_of_portfolio)::numeric, 2) AS asset_class_pct + FROM holdings + GROUP BY asset_class + ORDER BY asset_class_pct DESC + ), + high_risk_positions AS ( + SELECT holding_id, instrument_name, ticker, sector, region, + risk_rating, pct_of_portfolio + FROM holdings + WHERE risk_rating >= 7 + ORDER BY pct_of_portfolio DESC + ) + SELECT 'HOLDING' AS row_type, + h.holding_id AS id, + h.instrument_name AS label, + h.ticker AS ticker, + h.sector AS sector, + h.region AS region, + h.asset_class AS asset_class, + h.risk_rating AS risk_rating, + h.pct_of_portfolio AS pct, + h.risk_profile AS risk_profile, + h.esg_mandate AS esg_mandate, + h.max_position AS max_position, + h.excluded_sectors::text AS excluded_sectors + FROM holdings h + UNION ALL + SELECT 'SECTOR', sector, sector, NULL, NULL, NULL, NULL, + NULL, sector_pct, NULL, NULL, NULL, NULL + FROM sector_exposure + UNION ALL + SELECT 'REGION', region, region, NULL, NULL, NULL, NULL, + NULL, region_pct, NULL, NULL, NULL, NULL + FROM region_exposure + UNION ALL + SELECT 'ASSET_CLASS', asset_class, asset_class, NULL, NULL, NULL, NULL, + NULL, asset_class_pct, NULL, NULL, NULL, NULL + FROM asset_class_exposure + UNION ALL + SELECT 'HIGH_RISK', holding_id, instrument_name, ticker, sector, region, + NULL, risk_rating, pct_of_portfolio, NULL, NULL, NULL, NULL + FROM high_risk_positions + """ + + columns = [ + "ROW_TYPE", + "ID", + "LABEL", + "TICKER", + "SECTOR", + "REGION", + "ASSET_CLASS", + "RISK_RATING", + "PCT", + "RISK_PROFILE", + "ESG_MANDATE", + "MAX_POSITION", + "EXCLUDED_SECTORS", + ] + + rows, _ = execute_query( + pg_conn, + sql, + {"account_id": account_id}, + query_logger, + description=f"Hedge analysis (PostgreSQL): {account_id}", + ) + + if not rows: + return f"No holdings found for account '{account_id}'" + + from agent.tools import _build_hedge_recommendations + + results = [dict(zip(columns, row, strict=False)) for row in rows] + return _json.dumps(_build_hedge_recommendations(results, account_id, risk_focus), default=str) diff --git a/apps/finance-ai-agent-demo/backend/agent/tools.py b/apps/finance-ai-agent-demo/backend/agent/tools.py index 9fef65b6..d34eefb5 100644 --- a/apps/finance-ai-agent-demo/backend/agent/tools.py +++ b/apps/finance-ai-agent-demo/backend/agent/tools.py @@ -235,6 +235,39 @@ }, }, }, + { + "type": "function", + "function": { + "name": "suggest_portfolio_hedge", + "description": ( + "Analyze a portfolio's risk factors — sector concentration, regional exposure, " + "asset class distribution, and individual position risk ratings — then recommend " + "specific hedging instruments (inverse ETFs, commodities, bonds, defensive equities, " + "options strategies) with clear reasoning tied to the actual holdings. " + "Use this when a client asks how to protect their portfolio, reduce risk, or hedge " + "against market downturns, sector-specific events, or geopolitical risks." + ), + "parameters": { + "type": "object", + "properties": { + "account_id": { + "type": "string", + "description": "The account ID to analyze and generate hedge recommendations for", + }, + "risk_focus": { + "type": "string", + "enum": ["market", "sector", "regional", "currency", "all"], + "description": ( + "The type of risk to hedge against: 'market' for broad market downturns, " + "'sector' for sector-specific concentration risk, 'regional' for geographic " + "exposure, 'currency' for FX risk, 'all' for a comprehensive hedge plan" + ), + }, + }, + "required": ["account_id"], + }, + }, + }, ] # Preloaded tools: always available to the LLM regardless of query or DB state. @@ -302,6 +335,10 @@ def execute_tool(tool_name, tool_args): return convergent_search_sprawl( pg_conn, neo4j_driver, qdrant_client, embedding_model, tool_args, query_logger ) + elif tool_name == "suggest_portfolio_hedge": + from agent.sprawl_tools import suggest_portfolio_hedge_sprawl + + return suggest_portfolio_hedge_sprawl(pg_conn, tool_args, query_logger) else: return f"Error: Unknown tool '{tool_name}'" else: @@ -324,6 +361,8 @@ def execute_tool(tool_name, tool_args): return _find_nearby_clients(conn, tool_args, query_logger) elif tool_name == "convergent_search": return _convergent_search(conn, embedding_model, tool_args, query_logger) + elif tool_name == "suggest_portfolio_hedge": + return _suggest_portfolio_hedge(conn, tool_args, query_logger) else: return f"Error: Unknown tool '{tool_name}'" @@ -634,6 +673,437 @@ def _find_nearby_clients(conn, args, query_logger): return json.dumps(results, default=str) +def _suggest_portfolio_hedge(conn, args, query_logger): + """Analyze portfolio risk factors and recommend hedging instruments (Oracle mode). + + Uses a single SQL statement (Relational + JSON + Analytics) to compute: + - Sector concentration and dominant sectors + - Regional exposure breakdown + - Asset class distribution + - High-risk positions (risk_rating >= 7) + - Account-level risk profile and ESG mandate from JSON metadata + """ + account_id = args.get("account_id", "") + risk_focus = args.get("risk_focus", "all") + + # Single query: Relational + JSON + window-function analytics + sql = """ + WITH holdings AS ( + SELECT + ph.holding_id, + ph.asset_class, + ph.instrument_name, + ph.ticker, + ph.sector, + ph.region, + ph.risk_rating, + ph.current_value, + SUM(ph.current_value) OVER () AS total_value, + ROUND(ph.current_value / NULLIF(SUM(ph.current_value) OVER (), 0) * 100, 2) AS pct_of_portfolio, + ca.risk_profile, + JSON_VALUE(ca.metadata, '$.investment_preferences.esg_mandate') AS esg_mandate, + JSON_VALUE(ca.metadata, '$.investment_preferences.max_single_position') AS max_position, + JSON_QUERY(ca.metadata, '$.investment_preferences.excluded_sectors') AS excluded_sectors + FROM portfolio_holdings ph + JOIN client_accounts ca ON ca.account_id = ph.account_id + WHERE ph.account_id = :account_id + ), + sector_exposure AS ( + SELECT sector, + ROUND(SUM(pct_of_portfolio), 2) AS sector_pct + FROM holdings + GROUP BY sector + ORDER BY sector_pct DESC + ), + region_exposure AS ( + SELECT region, + ROUND(SUM(pct_of_portfolio), 2) AS region_pct + FROM holdings + GROUP BY region + ORDER BY region_pct DESC + ), + asset_class_exposure AS ( + SELECT asset_class, + ROUND(SUM(pct_of_portfolio), 2) AS asset_class_pct + FROM holdings + GROUP BY asset_class + ORDER BY asset_class_pct DESC + ), + high_risk_positions AS ( + SELECT holding_id, instrument_name, ticker, sector, region, + risk_rating, pct_of_portfolio + FROM holdings + WHERE risk_rating >= 7 + ORDER BY pct_of_portfolio DESC + ) + SELECT + 'HOLDING' AS row_type, + h.holding_id AS id, + h.instrument_name AS label, + h.ticker AS ticker, + h.sector AS sector, + h.region AS region, + h.asset_class AS asset_class, + h.risk_rating AS risk_rating, + h.pct_of_portfolio AS pct, + h.risk_profile AS risk_profile, + h.esg_mandate AS esg_mandate, + h.max_position AS max_position, + h.excluded_sectors AS excluded_sectors + FROM holdings h + UNION ALL + SELECT 'SECTOR', sector, sector, NULL, NULL, NULL, NULL, + NULL, sector_pct, NULL, NULL, NULL, NULL + FROM sector_exposure + UNION ALL + SELECT 'REGION', region, region, NULL, NULL, NULL, NULL, + NULL, region_pct, NULL, NULL, NULL, NULL + FROM region_exposure + UNION ALL + SELECT 'ASSET_CLASS', asset_class, asset_class, NULL, NULL, NULL, NULL, + NULL, asset_class_pct, NULL, NULL, NULL, NULL + FROM asset_class_exposure + UNION ALL + SELECT 'HIGH_RISK', holding_id, instrument_name, ticker, sector, region, + NULL, risk_rating, pct_of_portfolio, NULL, NULL, NULL, NULL + FROM high_risk_positions + """ + + columns = [ + "ROW_TYPE", + "ID", + "LABEL", + "TICKER", + "SECTOR", + "REGION", + "ASSET_CLASS", + "RISK_RATING", + "PCT", + "RISK_PROFILE", + "ESG_MANDATE", + "MAX_POSITION", + "EXCLUDED_SECTORS", + ] + + rows, _ = execute_query( + conn, + sql, + {"account_id": account_id}, + query_logger, + description=f"Hedge analysis (Relational + JSON + Analytics): {account_id}", + ) + + if not rows: + return f"No holdings found for account '{account_id}'" + + results = [dict(zip(columns, row, strict=False)) for row in rows] + return json.dumps(_build_hedge_recommendations(results, account_id, risk_focus), default=str) + + +# --------------------------------------------------------------------------- +# Hedge recommendation engine — pure Python, no DB calls +# --------------------------------------------------------------------------- + +# Catalogue of hedge instruments keyed by risk dimension +_HEDGE_CATALOGUE = { + "market": [ + { + "ticker": "SH", + "name": "ProShares Short S&P500 ETF", + "type": "Inverse ETF", + "rationale": "Broad market hedge; gains when S&P 500 declines.", + "risk_level": "medium", + }, + { + "ticker": "SQQQ", + "name": "ProShares UltraPro Short QQQ", + "type": "Leveraged Inverse ETF", + "rationale": "Aggressive tech/growth hedge (3× inverse NASDAQ-100). Suitable only for high-risk-tolerance accounts.", + "risk_level": "high", + }, + { + "ticker": "GLD", + "name": "SPDR Gold Shares", + "type": "Commodity ETF", + "rationale": "Safe-haven asset; historically negatively correlated with equities during downturns.", + "risk_level": "low", + }, + { + "ticker": "TLT", + "name": "iShares 20+ Year Treasury Bond ETF", + "type": "Bond ETF", + "rationale": "Long-duration Treasuries typically rise during risk-off equity sell-offs.", + "risk_level": "low", + }, + ], + "sector": { + "Technology": [ + { + "ticker": "REK", + "name": "ProShares Short Real Estate / rotate to Value", + "type": "Sector Rotation", + "rationale": "Rotate overweight tech into value/defensive sectors to reduce concentration.", + "risk_level": "low", + }, + { + "ticker": "PUT", + "name": "Put options on QQQ", + "type": "Options Strategy", + "rationale": "Buy QQQ put options as targeted downside protection for tech-heavy portfolios.", + "risk_level": "medium", + }, + ], + "Energy": [ + { + "ticker": "ERY", + "name": "Direxion Daily Energy Bear 2× ETF", + "type": "Inverse ETF", + "rationale": "Inverse energy exposure; hedges oil/gas concentration risk.", + "risk_level": "high", + }, + { + "ticker": "XLU", + "name": "Utilities Select Sector SPDR", + "type": "Defensive Equity ETF", + "rationale": "Utilities are low-beta defensives that offset cyclical energy holdings.", + "risk_level": "low", + }, + ], + "Financials": [ + { + "ticker": "SKF", + "name": "ProShares Ultra Short Financials", + "type": "Inverse ETF", + "rationale": "Inverse financials ETF; hedges bank and credit exposure.", + "risk_level": "high", + }, + ], + "Healthcare": [ + { + "ticker": "RXD", + "name": "ProShares Ultra Short Health Care", + "type": "Inverse ETF", + "rationale": "Hedges overweight healthcare/biotech concentration.", + "risk_level": "high", + }, + ], + "DEFAULT": [ + { + "ticker": "VXUS", + "name": "Vanguard Total International Stock ETF", + "type": "Diversification", + "rationale": "Broad international diversification reduces domestic sector concentration.", + "risk_level": "low", + }, + ], + }, + "regional": { + "North America": [ + { + "ticker": "EFA", + "name": "iShares MSCI EAFE ETF", + "type": "Geographic Diversification", + "rationale": "Developed-market international exposure offsets US-heavy concentration.", + "risk_level": "low", + }, + ], + "Europe": [ + { + "ticker": "EPV", + "name": "ProShares Ultra Short FTSE Europe", + "type": "Inverse ETF", + "rationale": "Inverse European equities ETF; hedges European macro/political risk.", + "risk_level": "high", + }, + ], + "Asia": [ + { + "ticker": "EWJ", + "name": "iShares MSCI Japan ETF + short futures", + "type": "Hedged Exposure", + "rationale": "Currency-hedged Japan ETF reduces yen FX risk on Asian holdings.", + "risk_level": "medium", + }, + ], + "DEFAULT": [ + { + "ticker": "ACWX", + "name": "iShares MSCI ACWI ex US ETF", + "type": "Geographic Diversification", + "rationale": "Broad ex-US exposure balances home-country bias.", + "risk_level": "low", + }, + ], + }, + "currency": [ + { + "ticker": "UUP", + "name": "Invesco DB US Dollar Index Bullish Fund", + "type": "Currency Hedge", + "rationale": "Long USD hedge against foreign-currency denominated international holdings.", + "risk_level": "medium", + }, + { + "ticker": "FXE", + "name": "Invesco CurrencyShares Euro Trust", + "type": "Currency Hedge", + "rationale": "Direct EUR exposure hedge for portfolios with significant European assets.", + "risk_level": "medium", + }, + ], +} + + +def _build_hedge_recommendations(rows, account_id, risk_focus): + """Derive risk factors from query rows and map to hedge recommendations.""" + holdings = [r for r in rows if r["ROW_TYPE"] == "HOLDING"] + sectors = [r for r in rows if r["ROW_TYPE"] == "SECTOR"] + regions = [r for r in rows if r["ROW_TYPE"] == "REGION"] + asset_classes = [r for r in rows if r["ROW_TYPE"] == "ASSET_CLASS"] + high_risk = [r for r in rows if r["ROW_TYPE"] == "HIGH_RISK"] + + # Pull account-level metadata from first holding row + meta = holdings[0] if holdings else {} + risk_profile = meta.get("RISK_PROFILE", "unknown") + esg_mandate = (meta.get("ESG_MANDATE") or "no").lower() == "yes" + excluded_sectors = [] + try: + raw = meta.get("EXCLUDED_SECTORS") or "[]" + excluded_sectors = json.loads(raw) if isinstance(raw, str) else raw + except Exception: + excluded_sectors = [] + + # Build risk factor summary + risk_factors = [] + recommendations = [] + + # --- Market risk --- + if risk_focus in ("market", "all"): + equity_pct = sum( + float(r["PCT"] or 0) for r in asset_classes if "equity" in (r["ID"] or "").lower() + ) + if equity_pct > 60: + risk_factors.append( + f"High equity concentration: {equity_pct:.1f}% of portfolio is in equities." + ) + for h in _HEDGE_CATALOGUE["market"]: + if esg_mandate and h["ticker"] in ("SQQQ",): + continue # Skip leveraged products for ESG/conservative accounts + recommendations.append( + {**h, "hedge_dimension": "market", "trigger": f"equity_pct={equity_pct:.1f}%"} + ) + + if high_risk: + high_risk_pct = sum(float(r["PCT"] or 0) for r in high_risk) + risk_factors.append( + f"{len(high_risk)} high-risk positions (risk_rating ≥ 7) totalling {high_risk_pct:.1f}% of portfolio." + ) + recommendations.append( + { + "ticker": "GLD", + "name": "SPDR Gold Shares", + "type": "Safe Haven", + "rationale": f"Gold allocation offsets tail risk from {len(high_risk)} high-rated positions.", + "risk_level": "low", + "hedge_dimension": "market", + "trigger": f"high_risk_positions={len(high_risk)}", + } + ) + + # --- Sector risk --- + if risk_focus in ("sector", "all") and sectors: + top_sector = sectors[0] + top_sector_name = top_sector["ID"] or "Unknown" + top_sector_pct = float(top_sector["PCT"] or 0) + if top_sector_pct > 30: + risk_factors.append( + f"Sector concentration: {top_sector_name} represents {top_sector_pct:.1f}% of portfolio." + ) + sector_hedges = _HEDGE_CATALOGUE["sector"].get( + top_sector_name, _HEDGE_CATALOGUE["sector"]["DEFAULT"] + ) + for h in sector_hedges: + if esg_mandate and h["risk_level"] == "high": + continue + if top_sector_name in excluded_sectors: + continue + recommendations.append( + { + **h, + "hedge_dimension": "sector", + "trigger": f"{top_sector_name}={top_sector_pct:.1f}%", + } + ) + + # --- Regional risk --- + if risk_focus in ("regional", "all") and regions: + top_region = regions[0] + top_region_name = top_region["ID"] or "Unknown" + top_region_pct = float(top_region["PCT"] or 0) + if top_region_pct > 50: + risk_factors.append( + f"Regional concentration: {top_region_name} represents {top_region_pct:.1f}% of portfolio." + ) + region_hedges = _HEDGE_CATALOGUE["regional"].get( + top_region_name, _HEDGE_CATALOGUE["regional"]["DEFAULT"] + ) + for h in region_hedges: + recommendations.append( + { + **h, + "hedge_dimension": "regional", + "trigger": f"{top_region_name}={top_region_pct:.1f}%", + } + ) + + # --- Currency risk --- + if risk_focus in ("currency", "all"): + intl_pct = sum( + float(r["PCT"] or 0) + for r in regions + if (r["ID"] or "").lower() not in ("north america", "usa", "us") + ) + if intl_pct > 25: + risk_factors.append( + f"International exposure: {intl_pct:.1f}% of portfolio is outside North America, creating FX risk." + ) + for h in _HEDGE_CATALOGUE["currency"]: + recommendations.append( + {**h, "hedge_dimension": "currency", "trigger": f"intl_pct={intl_pct:.1f}%"} + ) + + # De-duplicate by ticker, keeping first occurrence + seen_tickers = set() + unique_recommendations = [] + for r in recommendations: + if r["ticker"] not in seen_tickers: + seen_tickers.add(r["ticker"]) + unique_recommendations.append(r) + + return { + "account_id": account_id, + "risk_profile": risk_profile, + "esg_mandate": esg_mandate, + "risk_focus": risk_focus, + "portfolio_summary": { + "total_holdings": len(holdings), + "high_risk_positions": len(high_risk), + "sector_breakdown": [{"sector": r["ID"], "pct": r["PCT"]} for r in sectors], + "region_breakdown": [{"region": r["ID"], "pct": r["PCT"]} for r in regions], + "asset_class_breakdown": [ + {"asset_class": r["ID"], "pct": r["PCT"]} for r in asset_classes + ], + }, + "risk_factors_identified": risk_factors, + "hedge_recommendations": unique_recommendations, + "disclaimer": ( + "These recommendations are generated algorithmically for illustrative purposes only. " + "All hedging decisions should be reviewed by a qualified financial advisor and assessed " + "for suitability against the client's investment policy statement." + ), + } + + def _convergent_search(conn, embedding_model, args, query_logger): """Convergent query: Relational + Graph + Vector + Spatial in a single SQL statement. diff --git a/apps/finance-ai-agent-demo/backend/pytest.ini b/apps/finance-ai-agent-demo/backend/pytest.ini new file mode 100644 index 00000000..9855d94e --- /dev/null +++ b/apps/finance-ai-agent-demo/backend/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short diff --git a/apps/finance-ai-agent-demo/backend/requirements.txt b/apps/finance-ai-agent-demo/backend/requirements.txt index 19ef71c5..dbfdf3b5 100644 --- a/apps/finance-ai-agent-demo/backend/requirements.txt +++ b/apps/finance-ai-agent-demo/backend/requirements.txt @@ -24,3 +24,5 @@ pandas>=2.0 pydantic>=2.0 python-dotenv>=1.0 tiktoken>=0.7 +pytest>=8.0 +pytest-mock>=3.12 diff --git a/apps/finance-ai-agent-demo/backend/tests/__init__.py b/apps/finance-ai-agent-demo/backend/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/finance-ai-agent-demo/backend/tests/conftest.py b/apps/finance-ai-agent-demo/backend/tests/conftest.py new file mode 100644 index 00000000..9d60e49e --- /dev/null +++ b/apps/finance-ai-agent-demo/backend/tests/conftest.py @@ -0,0 +1,270 @@ +"""Shared pytest fixtures for the finance-ai-agent backend test suite.""" + +import pytest + +# --------------------------------------------------------------------------- +# Minimal portfolio rows as returned by _suggest_portfolio_hedge's SQL query +# --------------------------------------------------------------------------- + + +def _holding( + holding_id, + instrument_name, + ticker, + sector, + region, + asset_class, + risk_rating, + pct, + risk_profile="moderate", + esg_mandate=None, + max_position="0.10", + excluded_sectors=None, +): + return { + "ROW_TYPE": "HOLDING", + "ID": holding_id, + "LABEL": instrument_name, + "TICKER": ticker, + "SECTOR": sector, + "REGION": region, + "ASSET_CLASS": asset_class, + "RISK_RATING": risk_rating, + "PCT": pct, + "RISK_PROFILE": risk_profile, + "ESG_MANDATE": esg_mandate, + "MAX_POSITION": max_position, + "EXCLUDED_SECTORS": excluded_sectors or "[]", + } + + +def _sector(name, pct): + return { + "ROW_TYPE": "SECTOR", + "ID": name, + "LABEL": name, + "TICKER": None, + "SECTOR": None, + "REGION": None, + "ASSET_CLASS": None, + "RISK_RATING": None, + "PCT": pct, + "RISK_PROFILE": None, + "ESG_MANDATE": None, + "MAX_POSITION": None, + "EXCLUDED_SECTORS": None, + } + + +def _region(name, pct): + return { + "ROW_TYPE": "REGION", + "ID": name, + "LABEL": name, + "TICKER": None, + "SECTOR": None, + "REGION": None, + "ASSET_CLASS": None, + "RISK_RATING": None, + "PCT": pct, + "RISK_PROFILE": None, + "ESG_MANDATE": None, + "MAX_POSITION": None, + "EXCLUDED_SECTORS": None, + } + + +def _asset_class(name, pct): + return { + "ROW_TYPE": "ASSET_CLASS", + "ID": name, + "LABEL": name, + "TICKER": None, + "SECTOR": None, + "REGION": None, + "ASSET_CLASS": None, + "RISK_RATING": None, + "PCT": pct, + "RISK_PROFILE": None, + "ESG_MANDATE": None, + "MAX_POSITION": None, + "EXCLUDED_SECTORS": None, + } + + +def _high_risk(holding_id, instrument_name, ticker, sector, region, risk_rating, pct): + return { + "ROW_TYPE": "HIGH_RISK", + "ID": holding_id, + "LABEL": instrument_name, + "TICKER": ticker, + "SECTOR": sector, + "REGION": region, + "ASSET_CLASS": None, + "RISK_RATING": risk_rating, + "PCT": pct, + "RISK_PROFILE": None, + "ESG_MANDATE": None, + "MAX_POSITION": None, + "EXCLUDED_SECTORS": None, + } + + +@pytest.fixture +def tech_heavy_rows(): + """Portfolio that is tech-heavy (40% sector) and equity-heavy (80% equities), + located primarily in North America (70%), with two high-risk positions.""" + return [ + _holding( + "H001", + "Apple Inc", + "AAPL", + "Technology", + "North America", + "Equity", + 6, + 25.0, + risk_profile="aggressive", + ), + _holding( + "H002", + "Microsoft Corp", + "MSFT", + "Technology", + "North America", + "Equity", + 5, + 15.0, + risk_profile="aggressive", + ), + _holding( + "H003", + "ExxonMobil", + "XOM", + "Energy", + "North America", + "Equity", + 4, + 20.0, + risk_profile="aggressive", + ), + _holding( + "H004", + "Riskco Ltd", + "RSK", + "Technology", + "Europe", + "Equity", + 8, + 10.0, + risk_profile="aggressive", + ), + _holding( + "H005", + "DangerFund", + "DNG", + "Financials", + "Asia", + "Equity", + 9, + 10.0, + risk_profile="aggressive", + ), + _holding( + "H006", + "Safe Bond A", + "SBA", + "Government", + "North America", + "Bond", + 2, + 20.0, + risk_profile="aggressive", + ), + _sector("Technology", 40.0), + _sector("Energy", 20.0), + _sector("Government", 20.0), + _sector("Financials", 10.0), + _region("North America", 65.0), + _region("Europe", 20.0), + _region("Asia", 15.0), + _asset_class("Equity", 80.0), + _asset_class("Bond", 20.0), + _high_risk("H004", "Riskco Ltd", "RSK", "Technology", "Europe", 8, 10.0), + _high_risk("H005", "DangerFund", "DNG", "Financials", "Asia", 9, 10.0), + ] + + +@pytest.fixture +def esg_rows(): + """Portfolio with ESG mandate set — leveraged/high-risk instruments should be filtered.""" + return [ + _holding( + "H001", + "Green Energy ETF", + "GRN", + "Energy", + "North America", + "Equity", + 3, + 70.0, + risk_profile="conservative", + esg_mandate="yes", + ), + _holding( + "H002", + "Clean Tech Fund", + "CLT", + "Technology", + "North America", + "Equity", + 4, + 30.0, + risk_profile="conservative", + esg_mandate="yes", + ), + _sector("Energy", 70.0), + _sector("Technology", 30.0), + _region("North America", 100.0), + _asset_class("Equity", 100.0), + ] + + +@pytest.fixture +def balanced_rows(): + """Well-diversified portfolio — should trigger few or no risk factors.""" + return [ + _holding("H001", "US Equity Fund", "USQ", "Financials", "North America", "Equity", 3, 20.0), + _holding("H002", "EU Bond Fund", "EUB", "Government", "Europe", "Bond", 2, 20.0), + _holding("H003", "Asia Growth", "ASG", "Technology", "Asia", "Equity", 4, 20.0), + _holding("H004", "Gold ETF", "GLD", "Commodities", "North America", "Commodity", 2, 20.0), + _holding("H005", "EM Equity", "EMQ", "Financials", "Asia", "Equity", 5, 20.0), + _sector("Financials", 25.0), + _sector("Government", 20.0), + _sector("Technology", 20.0), + _sector("Commodities", 20.0), + _region("North America", 40.0), + _region("Europe", 20.0), + _region("Asia", 40.0), + _asset_class("Equity", 60.0), + _asset_class("Bond", 20.0), + _asset_class("Commodity", 20.0), + ] + + +@pytest.fixture +def intl_heavy_rows(): + """Portfolio with 60% outside North America — should trigger currency hedge.""" + return [ + _holding("H001", "EU Stocks", "EUS", "Financials", "Europe", "Equity", 4, 35.0), + _holding("H002", "Japan Growth", "JPG", "Technology", "Asia", "Equity", 5, 25.0), + _holding("H003", "US Bonds", "USB", "Government", "North America", "Bond", 2, 40.0), + _sector("Financials", 35.0), + _sector("Technology", 25.0), + _sector("Government", 40.0), + _region("Europe", 35.0), + _region("Asia", 25.0), + _region("North America", 40.0), + _asset_class("Equity", 60.0), + _asset_class("Bond", 40.0), + ] diff --git a/apps/finance-ai-agent-demo/backend/tests/test_hedge_recommendations.py b/apps/finance-ai-agent-demo/backend/tests/test_hedge_recommendations.py new file mode 100644 index 00000000..b5c144c2 --- /dev/null +++ b/apps/finance-ai-agent-demo/backend/tests/test_hedge_recommendations.py @@ -0,0 +1,389 @@ +"""Unit tests for the hedge recommendation engine (_build_hedge_recommendations). + +All tests run without a database connection — they call the pure-Python +recommendation logic directly with pre-built row fixtures. +""" + +import json +import os +import sys + +# Ensure the backend package root is on the path so imports resolve +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from agent.tools import _build_hedge_recommendations + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def tickers(recommendations): + return [r["ticker"] for r in recommendations] + + +def dimensions(recommendations): + return {r["hedge_dimension"] for r in recommendations} + + +# --------------------------------------------------------------------------- +# Output structure +# --------------------------------------------------------------------------- + + +class TestOutputStructure: + def test_returns_dict_with_required_keys(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all") + for key in ( + "account_id", + "risk_profile", + "esg_mandate", + "risk_focus", + "portfolio_summary", + "risk_factors_identified", + "hedge_recommendations", + "disclaimer", + ): + assert key in result, f"Missing key: {key}" + + def test_account_id_preserved(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-042", "all") + assert result["account_id"] == "ACC-042" + + def test_risk_focus_preserved(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "sector") + assert result["risk_focus"] == "sector" + + def test_portfolio_summary_contains_breakdowns(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all") + summary = result["portfolio_summary"] + assert isinstance(summary["sector_breakdown"], list) + assert isinstance(summary["region_breakdown"], list) + assert isinstance(summary["asset_class_breakdown"], list) + assert summary["total_holdings"] == 6 + assert summary["high_risk_positions"] == 2 + + def test_disclaimer_present_and_non_empty(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all") + assert len(result["disclaimer"]) > 20 + + def test_recommendations_are_list(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all") + assert isinstance(result["hedge_recommendations"], list) + + def test_each_recommendation_has_required_fields(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all") + for rec in result["hedge_recommendations"]: + for field in ( + "ticker", + "name", + "type", + "rationale", + "risk_level", + "hedge_dimension", + "trigger", + ): + assert field in rec, f"Recommendation missing field: {field}" + + def test_no_duplicate_tickers(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all") + seen = [r["ticker"] for r in result["hedge_recommendations"]] + assert len(seen) == len(set(seen)), f"Duplicate tickers found: {seen}" + + def test_json_serialisable(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all") + # Should not raise + json.dumps(result) + + +# --------------------------------------------------------------------------- +# Market risk detection +# --------------------------------------------------------------------------- + + +class TestMarketRiskDetection: + def test_equity_heavy_triggers_market_risk_factor(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market") + assert any("equity" in f.lower() for f in result["risk_factors_identified"]) + + def test_market_hedge_instruments_included_for_equity_heavy(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market") + t = tickers(result["hedge_recommendations"]) + # At least one of the core market hedges must appear + assert any(tk in t for tk in ("SH", "GLD", "TLT")), f"No market hedge found: {t}" + + def test_high_risk_positions_trigger_market_risk_factor(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market") + assert any("high-risk" in f.lower() for f in result["risk_factors_identified"]) + + def test_high_risk_positions_trigger_gold_recommendation(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market") + assert "GLD" in tickers(result["hedge_recommendations"]) + + def test_balanced_portfolio_no_market_risk_factor(self, balanced_rows): + result = _build_hedge_recommendations(balanced_rows, "ACC-001", "market") + # Equity is only 60% — below the 60% threshold, no market risk factor + assert not any("equity" in f.lower() for f in result["risk_factors_identified"]) + + def test_market_focus_only_returns_market_dimension(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market") + dims = dimensions(result["hedge_recommendations"]) + assert dims <= {"market"}, f"Unexpected dimensions: {dims}" + + +# --------------------------------------------------------------------------- +# Sector risk detection +# --------------------------------------------------------------------------- + + +class TestSectorRiskDetection: + def test_tech_overweight_triggers_sector_risk_factor(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "sector") + assert any("technology" in f.lower() for f in result["risk_factors_identified"]) + + def test_tech_overweight_triggers_tech_hedges(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "sector") + # Tech hedges include PUT (QQQ puts) or REK + t = tickers(result["hedge_recommendations"]) + assert any(tk in t for tk in ("PUT", "REK", "VXUS")), f"No sector hedge found: {t}" + + def test_balanced_portfolio_no_sector_risk_factor(self, balanced_rows): + result = _build_hedge_recommendations(balanced_rows, "ACC-001", "sector") + # No sector > 30%, so no sector risk factor expected + assert not any("sector" in f.lower() or "%" in f for f in result["risk_factors_identified"]) + + def test_sector_focus_only_returns_sector_dimension(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "sector") + dims = dimensions(result["hedge_recommendations"]) + assert dims <= {"sector"}, f"Unexpected dimensions: {dims}" + + +# --------------------------------------------------------------------------- +# Regional risk detection +# --------------------------------------------------------------------------- + + +class TestRegionalRiskDetection: + def test_na_heavy_triggers_regional_risk_factor(self, tech_heavy_rows): + # North America is 65% — above the 50% threshold + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "regional") + assert any("north america" in f.lower() for f in result["risk_factors_identified"]) + + def test_na_heavy_triggers_geographic_diversification_hedge(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "regional") + t = tickers(result["hedge_recommendations"]) + assert any(tk in t for tk in ("EFA", "ACWX")), f"No regional hedge found: {t}" + + def test_balanced_no_regional_risk_factor(self, balanced_rows): + result = _build_hedge_recommendations(balanced_rows, "ACC-001", "regional") + # No region > 50% in balanced_rows + assert not result["risk_factors_identified"] + + def test_regional_focus_only_returns_regional_dimension(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "regional") + dims = dimensions(result["hedge_recommendations"]) + assert dims <= {"regional"}, f"Unexpected dimensions: {dims}" + + +# --------------------------------------------------------------------------- +# Currency risk detection +# --------------------------------------------------------------------------- + + +class TestCurrencyRiskDetection: + def test_intl_heavy_triggers_currency_risk_factor(self, intl_heavy_rows): + result = _build_hedge_recommendations(intl_heavy_rows, "ACC-001", "currency") + assert any( + "fx" in f.lower() or "international" in f.lower() + for f in result["risk_factors_identified"] + ) + + def test_intl_heavy_includes_currency_hedge_instruments(self, intl_heavy_rows): + result = _build_hedge_recommendations(intl_heavy_rows, "ACC-001", "currency") + t = tickers(result["hedge_recommendations"]) + assert any(tk in t for tk in ("UUP", "FXE")), f"No currency hedge found: {t}" + + def test_na_only_no_currency_risk(self, esg_rows): + # esg_rows is 100% North America + result = _build_hedge_recommendations(esg_rows, "ACC-001", "currency") + assert not result["risk_factors_identified"] + + def test_currency_focus_only_returns_currency_dimension(self, intl_heavy_rows): + result = _build_hedge_recommendations(intl_heavy_rows, "ACC-001", "currency") + dims = dimensions(result["hedge_recommendations"]) + assert dims <= {"currency"}, f"Unexpected dimensions: {dims}" + + +# --------------------------------------------------------------------------- +# ESG mandate filtering +# --------------------------------------------------------------------------- + + +class TestEsgMandateFiltering: + def test_esg_mandate_detected_from_rows(self, esg_rows): + result = _build_hedge_recommendations(esg_rows, "ACC-001", "all") + assert result["esg_mandate"] is True + + def test_no_esg_mandate_when_field_absent(self, tech_heavy_rows): + # tech_heavy_rows have esg_mandate=None + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all") + assert result["esg_mandate"] is False + + def test_esg_filters_out_sqqq(self, esg_rows): + # Energy is 70% — triggers market risk; SQQQ should be absent for ESG accounts + result = _build_hedge_recommendations(esg_rows, "ACC-001", "market") + assert "SQQQ" not in tickers(result["hedge_recommendations"]) + + def test_esg_filters_high_risk_level_sector_hedges(self, esg_rows): + # Sector hedges with risk_level='high' should be excluded for ESG accounts + result = _build_hedge_recommendations(esg_rows, "ACC-001", "sector") + for rec in result["hedge_recommendations"]: + assert rec["risk_level"] != "high", ( + f"High-risk instrument {rec['ticker']} slipped through ESG filter" + ) + + +# --------------------------------------------------------------------------- +# risk_focus scoping +# --------------------------------------------------------------------------- + + +class TestRiskFocusScoping: + def test_all_can_return_multiple_dimensions(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all") + dims = dimensions(result["hedge_recommendations"]) + # With tech_heavy_rows, at minimum market + sector + regional should fire + assert len(dims) >= 2, f"Expected multiple dimensions, got: {dims}" + + def test_market_focus_excludes_sector_dimension(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market") + assert "sector" not in dimensions(result["hedge_recommendations"]) + + def test_sector_focus_excludes_market_dimension(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "sector") + assert "market" not in dimensions(result["hedge_recommendations"]) + + def test_regional_focus_excludes_market_dimension(self, tech_heavy_rows): + result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "regional") + assert "market" not in dimensions(result["hedge_recommendations"]) + + def test_currency_focus_excludes_sector_dimension(self, intl_heavy_rows): + result = _build_hedge_recommendations(intl_heavy_rows, "ACC-001", "currency") + assert "sector" not in dimensions(result["hedge_recommendations"]) + + +# --------------------------------------------------------------------------- +# Empty / edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + def test_empty_rows_returns_no_risk_factors_or_recommendations(self): + # No holdings at all — _suggest_portfolio_hedge would catch this upstream, + # but _build_hedge_recommendations should handle gracefully + result = _build_hedge_recommendations([], "ACC-999", "all") + assert result["risk_factors_identified"] == [] + assert result["hedge_recommendations"] == [] + + def test_none_pct_values_do_not_crash(self): + rows = [ + { + "ROW_TYPE": "HOLDING", + "ID": "H001", + "LABEL": "Foo", + "TICKER": "FOO", + "SECTOR": "Technology", + "REGION": "North America", + "ASSET_CLASS": "Equity", + "RISK_RATING": 5, + "PCT": None, + "RISK_PROFILE": "moderate", + "ESG_MANDATE": None, + "MAX_POSITION": "0.10", + "EXCLUDED_SECTORS": "[]", + }, + { + "ROW_TYPE": "ASSET_CLASS", + "ID": "Equity", + "LABEL": "Equity", + "TICKER": None, + "SECTOR": None, + "REGION": None, + "ASSET_CLASS": None, + "RISK_RATING": None, + "PCT": None, + "RISK_PROFILE": None, + "ESG_MANDATE": None, + "MAX_POSITION": None, + "EXCLUDED_SECTORS": None, + }, + ] + # Should not raise even with None PCT values + result = _build_hedge_recommendations(rows, "ACC-999", "all") + assert isinstance(result, dict) + + def test_malformed_excluded_sectors_does_not_crash(self): + rows = [ + { + "ROW_TYPE": "HOLDING", + "ID": "H001", + "LABEL": "Foo", + "TICKER": "FOO", + "SECTOR": "Technology", + "REGION": "North America", + "ASSET_CLASS": "Equity", + "RISK_RATING": 5, + "PCT": 50.0, + "RISK_PROFILE": "moderate", + "ESG_MANDATE": None, + "MAX_POSITION": "0.10", + "EXCLUDED_SECTORS": "NOT_VALID_JSON", + }, + { + "ROW_TYPE": "SECTOR", + "ID": "Technology", + "LABEL": "Technology", + "TICKER": None, + "SECTOR": None, + "REGION": None, + "ASSET_CLASS": None, + "RISK_RATING": None, + "PCT": 50.0, + "RISK_PROFILE": None, + "ESG_MANDATE": None, + "MAX_POSITION": None, + "EXCLUDED_SECTORS": None, + }, + { + "ROW_TYPE": "REGION", + "ID": "North America", + "LABEL": "North America", + "TICKER": None, + "SECTOR": None, + "REGION": None, + "ASSET_CLASS": None, + "RISK_RATING": None, + "PCT": 100.0, + "RISK_PROFILE": None, + "ESG_MANDATE": None, + "MAX_POSITION": None, + "EXCLUDED_SECTORS": None, + }, + { + "ROW_TYPE": "ASSET_CLASS", + "ID": "Equity", + "LABEL": "Equity", + "TICKER": None, + "SECTOR": None, + "REGION": None, + "ASSET_CLASS": None, + "RISK_RATING": None, + "PCT": 100.0, + "RISK_PROFILE": None, + "ESG_MANDATE": None, + "MAX_POSITION": None, + "EXCLUDED_SECTORS": None, + }, + ] + result = _build_hedge_recommendations(rows, "ACC-999", "all") + assert isinstance(result, dict) diff --git a/apps/finance-ai-agent-demo/backend/tests/test_suggest_portfolio_hedge_integration.py b/apps/finance-ai-agent-demo/backend/tests/test_suggest_portfolio_hedge_integration.py new file mode 100644 index 00000000..345ce7ab --- /dev/null +++ b/apps/finance-ai-agent-demo/backend/tests/test_suggest_portfolio_hedge_integration.py @@ -0,0 +1,209 @@ +"""Integration-style tests for _suggest_portfolio_hedge. + +These tests call _suggest_portfolio_hedge directly with a mock DB connection +that returns controlled row data — no real Oracle instance required. +The goal is to verify the full pipeline: SQL execution → row parsing → +_build_hedge_recommendations → JSON output. +""" + +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +# --------------------------------------------------------------------------- +# Minimal mock connection and execute_query that return fixture rows +# --------------------------------------------------------------------------- + + +class MockQueryLogger: + def __init__(self): + self.calls = [] + + def log(self, *args, **kwargs): + self.calls.append((args, kwargs)) + + +def make_execute_query_stub(rows): + """Return a drop-in for execute_query that yields `rows` with column names.""" + columns = [ + "ROW_TYPE", + "ID", + "LABEL", + "TICKER", + "SECTOR", + "REGION", + "ASSET_CLASS", + "RISK_RATING", + "PCT", + "RISK_PROFILE", + "ESG_MANDATE", + "MAX_POSITION", + "EXCLUDED_SECTORS", + ] + + def _execute_query(conn, sql, params, query_logger, description=""): + # Convert dict rows to tuples matching column order + tuple_rows = [tuple(r.get(c) for c in columns) for r in rows] + return tuple_rows, columns + + return _execute_query + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestSuggestPortfolioHedgeOutput: + def test_returns_valid_json_string(self, tech_heavy_rows, monkeypatch): + import agent.tools as tools_module + + monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub(tech_heavy_rows)) + + result_str = tools_module._suggest_portfolio_hedge( + conn=object(), + args={"account_id": "ACC-001", "risk_focus": "all"}, + query_logger=MockQueryLogger(), + ) + result = json.loads(result_str) + assert isinstance(result, dict) + + def test_result_contains_hedge_recommendations(self, tech_heavy_rows, monkeypatch): + import agent.tools as tools_module + + monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub(tech_heavy_rows)) + + result = json.loads( + tools_module._suggest_portfolio_hedge( + conn=object(), + args={"account_id": "ACC-001", "risk_focus": "all"}, + query_logger=MockQueryLogger(), + ) + ) + assert len(result["hedge_recommendations"]) > 0 + + def test_no_holdings_returns_error_message(self, monkeypatch): + import agent.tools as tools_module + + monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub([])) + + result = tools_module._suggest_portfolio_hedge( + conn=object(), + args={"account_id": "ACC-999"}, + query_logger=MockQueryLogger(), + ) + assert "No holdings found" in result + assert "ACC-999" in result + + def test_default_risk_focus_is_all(self, tech_heavy_rows, monkeypatch): + import agent.tools as tools_module + + monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub(tech_heavy_rows)) + + # Omit risk_focus — should default to 'all' + result = json.loads( + tools_module._suggest_portfolio_hedge( + conn=object(), + args={"account_id": "ACC-001"}, + query_logger=MockQueryLogger(), + ) + ) + assert result["risk_focus"] == "all" + + def test_query_logger_is_called(self, tech_heavy_rows, monkeypatch): + import agent.tools as tools_module + + logger = MockQueryLogger() + called = [] + + def _stub(conn, sql, params, query_logger, description=""): + called.append(description) + columns = [ + "ROW_TYPE", + "ID", + "LABEL", + "TICKER", + "SECTOR", + "REGION", + "ASSET_CLASS", + "RISK_RATING", + "PCT", + "RISK_PROFILE", + "ESG_MANDATE", + "MAX_POSITION", + "EXCLUDED_SECTORS", + ] + return [tuple(r.get(c) for c in columns) for r in tech_heavy_rows], columns + + monkeypatch.setattr("agent.tools.execute_query", _stub) + tools_module._suggest_portfolio_hedge( + conn=object(), + args={"account_id": "ACC-001"}, + query_logger=logger, + ) + assert any("ACC-001" in d for d in called) + + def test_esg_portfolio_excludes_leveraged_products(self, esg_rows, monkeypatch): + import agent.tools as tools_module + + monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub(esg_rows)) + + result = json.loads( + tools_module._suggest_portfolio_hedge( + conn=object(), + args={"account_id": "ACC-ESG", "risk_focus": "all"}, + query_logger=MockQueryLogger(), + ) + ) + rec_tickers = [r["ticker"] for r in result["hedge_recommendations"]] + assert "SQQQ" not in rec_tickers + + def test_market_focus_filters_non_market_dimensions(self, tech_heavy_rows, monkeypatch): + import agent.tools as tools_module + + monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub(tech_heavy_rows)) + + result = json.loads( + tools_module._suggest_portfolio_hedge( + conn=object(), + args={"account_id": "ACC-001", "risk_focus": "market"}, + query_logger=MockQueryLogger(), + ) + ) + dims = {r["hedge_dimension"] for r in result["hedge_recommendations"]} + assert dims <= {"market"} + + def test_account_id_in_params_passed_to_sql(self, tech_heavy_rows, monkeypatch): + import agent.tools as tools_module + + received_params = {} + + def _stub(conn, sql, params, query_logger, description=""): + received_params.update(params) + columns = [ + "ROW_TYPE", + "ID", + "LABEL", + "TICKER", + "SECTOR", + "REGION", + "ASSET_CLASS", + "RISK_RATING", + "PCT", + "RISK_PROFILE", + "ESG_MANDATE", + "MAX_POSITION", + "EXCLUDED_SECTORS", + ] + return [tuple(r.get(c) for c in columns) for r in tech_heavy_rows], columns + + monkeypatch.setattr("agent.tools.execute_query", _stub) + tools_module._suggest_portfolio_hedge( + conn=object(), + args={"account_id": "ACC-007"}, + query_logger=MockQueryLogger(), + ) + assert received_params.get("account_id") == "ACC-007" diff --git a/apps/finance-ai-agent-demo/backend/tests/test_tool_schema.py b/apps/finance-ai-agent-demo/backend/tests/test_tool_schema.py new file mode 100644 index 00000000..abea0677 --- /dev/null +++ b/apps/finance-ai-agent-demo/backend/tests/test_tool_schema.py @@ -0,0 +1,124 @@ +"""Tests for the suggest_portfolio_hedge tool schema registration. + +Verifies the tool is correctly declared in TOOL_SCHEMAS and PRELOADED_TOOLS +so the LLM will always have access to it. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from agent.tools import PRELOADED_TOOLS, TOOL_SCHEMAS + + +def _find_tool(tools, name): + return next((t for t in tools if t["function"]["name"] == name), None) + + +class TestToolSchemaRegistration: + def test_tool_present_in_tool_schemas(self): + tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge") + assert tool is not None, "suggest_portfolio_hedge missing from TOOL_SCHEMAS" + + def test_tool_present_in_preloaded_tools(self): + tool = _find_tool(PRELOADED_TOOLS, "suggest_portfolio_hedge") + assert tool is not None, "suggest_portfolio_hedge missing from PRELOADED_TOOLS" + + def test_tool_has_correct_type(self): + tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge") + assert tool["type"] == "function" + + def test_account_id_is_required_parameter(self): + tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge") + params = tool["function"]["parameters"] + assert "account_id" in params["required"] + assert "account_id" in params["properties"] + + def test_risk_focus_is_optional(self): + tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge") + params = tool["function"]["parameters"] + assert "risk_focus" in params["properties"] + assert "risk_focus" not in params.get("required", []) + + def test_risk_focus_enum_values(self): + tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge") + enum_vals = tool["function"]["parameters"]["properties"]["risk_focus"]["enum"] + assert set(enum_vals) == {"market", "sector", "regional", "currency", "all"} + + def test_description_is_non_empty(self): + tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge") + assert len(tool["function"]["description"]) > 20 + + def test_account_id_has_description(self): + tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge") + desc = tool["function"]["parameters"]["properties"]["account_id"].get("description", "") + assert len(desc) > 5 + + def test_preloaded_tools_is_superset_of_tool_schemas(self): + schema_names = {t["function"]["name"] for t in TOOL_SCHEMAS} + preloaded_names = {t["function"]["name"] for t in PRELOADED_TOOLS} + assert schema_names == preloaded_names + + +class TestToolDispatcherRouting: + """Verify that the dispatcher routes suggest_portfolio_hedge without error. + + We mock the DB connection and query_helper so no real Oracle instance is needed. + """ + + def test_oracle_mode_routes_to_correct_handler(self, monkeypatch): + import agent.tools as tools_module + + # Patch execute_query to return empty rows (no DB needed) + monkeypatch.setattr( + "agent.tools.execute_query", + lambda conn, sql, params, query_logger, description="": ([], []), + ) + # Patch ARCH_MODE to converged + monkeypatch.setattr( + "agent.tools.os.getenv", lambda k, d="": "converged" if k == "ARCH_MODE" else d + ) + + import types + + fake_config = types.ModuleType("config") + fake_config.ARCH_MODE = "converged" + monkeypatch.setitem(sys.modules, "config", fake_config) + + execute_tool = tools_module.create_tool_executor( + conn=object(), + embedding_model=None, + memory_manager=None, + llm_client=None, + query_logger=None, + ) + + result = execute_tool("suggest_portfolio_hedge", {"account_id": "ACC-001"}) + # Empty rows → "No holdings found" message + assert "No holdings found" in result or isinstance(result, str) + + def test_unknown_tool_returns_error_string(self, monkeypatch): + import types + + fake_config = types.ModuleType("config") + fake_config.ARCH_MODE = "converged" + monkeypatch.setitem(sys.modules, "config", fake_config) + + import agent.tools as tools_module + + monkeypatch.setattr( + "agent.tools.execute_query", + lambda *a, **kw: ([], []), + ) + + execute_tool = tools_module.create_tool_executor( + conn=object(), + embedding_model=None, + memory_manager=None, + llm_client=None, + query_logger=None, + ) + + result = execute_tool("nonexistent_tool_xyz", {}) + assert "Error" in result or "Unknown" in result diff --git a/apps/finance-ai-agent-demo/frontend/package-lock.json b/apps/finance-ai-agent-demo/frontend/package-lock.json index d6ad4e96..b6bc4576 100644 --- a/apps/finance-ai-agent-demo/frontend/package-lock.json +++ b/apps/finance-ai-agent-demo/frontend/package-lock.json @@ -16,15 +16,28 @@ "socket.io-client": "^4.8.3" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "^4.1.7", "autoprefixer": "^10.4.19", + "jsdom": "^29.1.1", "postcss": "^8.4.39", "tailwindcss": "^3.4.6", - "vite": "^6.4.2" + "vite": "^6.4.2", + "vitest": "^4.1.7" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -38,6 +51,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -181,9 +245,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -191,9 +255,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -225,13 +289,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -272,6 +336,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -307,19 +381,182 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -762,6 +999,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1213,6 +1468,111 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1258,6 +1618,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1267,6 +1638,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1365,6 +1743,175 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1393,6 +1940,45 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", + "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -1453,6 +2039,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1554,6 +2150,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -1659,6 +2265,27 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1678,6 +2305,20 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1695,6 +2336,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -1744,6 +2392,14 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.302", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", @@ -1773,6 +2429,26 @@ "node": ">=10.0.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1847,6 +2523,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -1968,6 +2664,16 @@ "node": ">=10.13.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2021,6 +2727,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -2031,6 +2757,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -2155,6 +2891,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2171,6 +2953,57 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2249,13 +3082,75 @@ "yallist": "^3.0.2" } }, - "node_modules/lucide-react": { - "version": "0.400.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz", - "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==", + "node_modules/lucide-react": { + "version": "0.400.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz", + "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/markdown-table": { @@ -2538,6 +3433,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3125,6 +4027,16 @@ "node": ">=8.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3199,6 +4111,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -3224,6 +4147,19 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3231,6 +4167,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3434,6 +4377,22 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -3444,6 +4403,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3490,6 +4459,14 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-markdown": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", @@ -3550,6 +4527,20 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -3616,6 +4607,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3717,6 +4718,19 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3736,6 +4750,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -3784,6 +4805,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -3798,6 +4833,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -3839,6 +4887,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3852,6 +4913,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -3913,6 +4981,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3961,6 +5046,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz", + "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.0" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.1.tgz", + "integrity": "sha512-sc2nGvGbixlJRHwTh/qQdPXTxJU1UDJboGPQm4d/01YUJ9r/u6aeIulQvEaxUlvKDN7hb1qCLjax+jhVAPLa/g==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3974,6 +5089,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -4001,6 +5142,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -4260,6 +5411,174 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -4281,6 +5600,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/apps/finance-ai-agent-demo/frontend/package.json b/apps/finance-ai-agent-demo/frontend/package.json index a75d4c48..ed846051 100644 --- a/apps/finance-ai-agent-demo/frontend/package.json +++ b/apps/finance-ai-agent-demo/frontend/package.json @@ -6,7 +6,10 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "lucide-react": "^0.400.0", @@ -17,12 +20,18 @@ "socket.io-client": "^4.8.3" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "^4.1.7", "autoprefixer": "^10.4.19", + "jsdom": "^29.1.1", "postcss": "^8.4.39", "tailwindcss": "^3.4.6", - "vite": "^6.4.2" + "vite": "^6.4.2", + "vitest": "^4.1.7" } } diff --git a/apps/finance-ai-agent-demo/frontend/src/test/ToolCallBubble.test.jsx b/apps/finance-ai-agent-demo/frontend/src/test/ToolCallBubble.test.jsx new file mode 100644 index 00000000..84cc6d6f --- /dev/null +++ b/apps/finance-ai-agent-demo/frontend/src/test/ToolCallBubble.test.jsx @@ -0,0 +1,178 @@ +/** + * Tests for ToolCallBubble rendering the suggest_portfolio_hedge tool call. + * + * Covers: + * - Running state (spinner, no output toggle) + * - Success state (tick, elapsed time, expandable output) + * - Error state (cross icon, output visible) + * - Output expand/collapse toggle + * - Pretty-printed JSON output for hedge results + * - Args rendered correctly in the header + */ + +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import ToolCallBubble from "../components/ToolCallBubble"; +import { makeToolCall, makeRunningToolCall, makeErrorToolCall, hedgeOutput } from "./hedgeFixtures"; + +// --------------------------------------------------------------------------- +// Running state +// --------------------------------------------------------------------------- + +describe("ToolCallBubble — running state", () => { + it("shows tool name and account_id arg in header", () => { + render(); + expect(screen.getByText(/suggest_portfolio_hedge/)).toBeInTheDocument(); + expect(screen.getByText(/account_id="ACC-001"/)).toBeInTheDocument(); + }); + + it("renders a spinner element (animate-spin class)", () => { + const { container } = render(); + expect(container.querySelector(".animate-spin")).toBeInTheDocument(); + }); + + it("does not show elapsed time while running", () => { + render(); + expect(screen.queryByText(/ms/)).not.toBeInTheDocument(); + }); + + it("does not show output toggle while running", () => { + render(); + expect(screen.queryByText(/output/i)).not.toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Success state +// --------------------------------------------------------------------------- + +describe("ToolCallBubble — success state", () => { + it("shows tool name in header", () => { + render(); + expect(screen.getByText(/suggest_portfolio_hedge/)).toBeInTheDocument(); + }); + + it("renders risk_focus arg in header", () => { + render(); + expect(screen.getByText(/risk_focus="all"/)).toBeInTheDocument(); + }); + + it("shows elapsed time", () => { + render(); + expect(screen.getByText(/142ms/)).toBeInTheDocument(); + }); + + it("shows success checkmark (✓)", () => { + render(); + // ✓ is rendered as ✓ which is the Unicode CHECK MARK + expect(screen.getByText("✓")).toBeInTheDocument(); + }); + + it("shows 'Show output' button when output is present", () => { + render(); + expect(screen.getByText(/show output/i)).toBeInTheDocument(); + }); + + it("output is collapsed by default", () => { + render(); + expect(screen.queryByText(/hedge_recommendations/i)).not.toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Output expand / collapse +// --------------------------------------------------------------------------- + +describe("ToolCallBubble — expand/collapse output", () => { + it("expands output on 'Show output' click", () => { + const { container } = render(); + fireEvent.click(screen.getByText(/show output/i)); + // Pretty-printed JSON inside the
 block should contain hedge_recommendations
+    const pre = container.querySelector("pre");
+    expect(pre).toBeInTheDocument();
+    expect(pre.textContent).toContain("hedge_recommendations");
+  });
+
+  it("shows 'Hide output' after expanding", () => {
+    render();
+    fireEvent.click(screen.getByText(/show output/i));
+    expect(screen.getByText(/hide output/i)).toBeInTheDocument();
+  });
+
+  it("collapses output on second click", () => {
+    const { container } = render();
+    fireEvent.click(screen.getByText(/show output/i));
+    fireEvent.click(screen.getByText(/hide output/i));
+    // The 
 block should be gone after collapsing
+    expect(container.querySelector("pre")).not.toBeInTheDocument();
+    expect(screen.getByText(/show output/i)).toBeInTheDocument();
+  });
+
+  it("pretty-prints hedge recommendation JSON when expanded", () => {
+    render();
+    fireEvent.click(screen.getByText(/show output/i));
+    expect(screen.getByText(/hedge_recommendations/)).toBeInTheDocument();
+    expect(screen.getByText(/risk_factors_identified/)).toBeInTheDocument();
+  });
+
+  it("shows disclaimer text when expanded", () => {
+    render();
+    fireEvent.click(screen.getByText(/show output/i));
+    expect(screen.getByText(/algorithmically/i)).toBeInTheDocument();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Error state
+// ---------------------------------------------------------------------------
+
+describe("ToolCallBubble — error state", () => {
+  it("shows error cross (✗) icon", () => {
+    render();
+    expect(screen.getByText("✗")).toBeInTheDocument();
+  });
+
+  it("shows elapsed time on error", () => {
+    render();
+    expect(screen.getByText(/55ms/)).toBeInTheDocument();
+  });
+
+  it("shows error output when expanded", () => {
+    render();
+    fireEvent.click(screen.getByText(/show output/i));
+    expect(screen.getByText(/Database connection failed/)).toBeInTheDocument();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Args rendering edge cases
+// ---------------------------------------------------------------------------
+
+describe("ToolCallBubble — args rendering", () => {
+  it("renders only account_id when risk_focus is omitted", () => {
+    const toolCall = makeToolCall({ args: { account_id: "ACC-007" } });
+    render();
+    expect(screen.getByText(/account_id="ACC-007"/)).toBeInTheDocument();
+  });
+
+  it("renders both args when risk_focus is provided", () => {
+    const toolCall = makeToolCall({
+      args: { account_id: "ACC-003", risk_focus: "sector" },
+    });
+    render();
+    expect(screen.getByText(/risk_focus="sector"/)).toBeInTheDocument();
+  });
+
+  it("handles empty args object gracefully", () => {
+    const toolCall = makeToolCall({ args: {} });
+    render();
+    // Should render tool name with empty parens, no crash
+    expect(screen.getByText(/suggest_portfolio_hedge/)).toBeInTheDocument();
+  });
+
+  it("handles null args gracefully", () => {
+    const toolCall = makeToolCall({ args: null });
+    render();
+    expect(screen.getByText(/suggest_portfolio_hedge/)).toBeInTheDocument();
+  });
+});
diff --git a/apps/finance-ai-agent-demo/frontend/src/test/chatReducer.test.js b/apps/finance-ai-agent-demo/frontend/src/test/chatReducer.test.js
new file mode 100644
index 00000000..2f0142d5
--- /dev/null
+++ b/apps/finance-ai-agent-demo/frontend/src/test/chatReducer.test.js
@@ -0,0 +1,345 @@
+/**
+ * Tests for the chatReducer handling suggest_portfolio_hedge tool call events.
+ *
+ * Exercises the ADD_TOOL_CALL_START → UPDATE_TOOL_CALL → AGENT_COMPLETE
+ * lifecycle that the ToolCallBubble ultimately renders.
+ */
+
+import { describe, it, expect } from "vitest";
+import { makeToolCall, makeRunningToolCall, hedgeOutput } from "./hedgeFixtures";
+
+// Import the reducer directly — it is not exported from useChat.js, so we
+// replicate just the relevant cases here to keep tests self-contained and fast.
+// If the reducer is ever extracted, these tests can import it directly.
+
+// ---------------------------------------------------------------------------
+// Inline reducer (mirrors useChat.js chatReducer exactly for the relevant cases)
+// ---------------------------------------------------------------------------
+
+function chatReducer(state, action) {
+  switch (action.type) {
+    case "ADD_TOOL_CALL_START":
+      return {
+        ...state,
+        toolCalls: [
+          ...state.toolCalls,
+          {
+            id: action.payload.tool_call_id,
+            name: action.payload.tool_name,
+            args: action.payload.tool_args,
+            status: "running",
+            output: null,
+            elapsed_ms: null,
+          },
+        ],
+      };
+
+    case "UPDATE_TOOL_CALL":
+      return {
+        ...state,
+        toolCalls: state.toolCalls.map((tc) =>
+          tc.id === action.payload.tool_call_id
+            ? {
+                ...tc,
+                status: action.payload.status,
+                output: action.payload.output,
+                elapsed_ms: action.payload.elapsed_ms,
+              }
+            : tc
+        ),
+      };
+
+    case "AGENT_COMPLETE": {
+      const streamingExists = state.messages.find((m) => m.id === action.payload.message_id);
+      if (streamingExists) {
+        return {
+          ...state,
+          messages: state.messages.map((m) =>
+            m.id === action.payload.message_id
+              ? {
+                  ...m,
+                  content: action.payload.response || m.content,
+                  isStreaming: false,
+                  toolCalls: [...state.toolCalls],
+                }
+              : m
+          ),
+          isLoading: false,
+          toolCalls: [],
+        };
+      }
+      return {
+        ...state,
+        messages: [
+          ...state.messages,
+          {
+            id: action.payload.message_id,
+            role: "assistant",
+            content: action.payload.response,
+            timestamp: new Date().toISOString(),
+            toolCalls: [...state.toolCalls],
+          },
+        ],
+        isLoading: false,
+        toolCalls: [],
+      };
+    }
+
+    default:
+      return state;
+  }
+}
+
+const emptyState = { messages: [], toolCalls: [], isLoading: true };
+
+// ---------------------------------------------------------------------------
+// ADD_TOOL_CALL_START
+// ---------------------------------------------------------------------------
+
+describe("chatReducer — ADD_TOOL_CALL_START for suggest_portfolio_hedge", () => {
+  const startPayload = {
+    tool_call_id: "tc-001",
+    tool_name: "suggest_portfolio_hedge",
+    tool_args: { account_id: "ACC-001", risk_focus: "all" },
+  };
+
+  it("adds tool call to toolCalls list", () => {
+    const state = chatReducer(emptyState, { type: "ADD_TOOL_CALL_START", payload: startPayload });
+    expect(state.toolCalls).toHaveLength(1);
+  });
+
+  it("sets correct tool name", () => {
+    const state = chatReducer(emptyState, { type: "ADD_TOOL_CALL_START", payload: startPayload });
+    expect(state.toolCalls[0].name).toBe("suggest_portfolio_hedge");
+  });
+
+  it("sets status to running", () => {
+    const state = chatReducer(emptyState, { type: "ADD_TOOL_CALL_START", payload: startPayload });
+    expect(state.toolCalls[0].status).toBe("running");
+  });
+
+  it("sets output and elapsed_ms to null initially", () => {
+    const state = chatReducer(emptyState, { type: "ADD_TOOL_CALL_START", payload: startPayload });
+    expect(state.toolCalls[0].output).toBeNull();
+    expect(state.toolCalls[0].elapsed_ms).toBeNull();
+  });
+
+  it("preserves args on the tool call", () => {
+    const state = chatReducer(emptyState, { type: "ADD_TOOL_CALL_START", payload: startPayload });
+    expect(state.toolCalls[0].args).toEqual({ account_id: "ACC-001", risk_focus: "all" });
+  });
+
+  it("does not mutate existing toolCalls", () => {
+    const existingCall = {
+      id: "tc-existing",
+      name: "get_account_details",
+      args: {},
+      status: "success",
+      output: "{}",
+      elapsed_ms: 50,
+    };
+    const stateWithExisting = { ...emptyState, toolCalls: [existingCall] };
+    const next = chatReducer(stateWithExisting, {
+      type: "ADD_TOOL_CALL_START",
+      payload: startPayload,
+    });
+    expect(next.toolCalls).toHaveLength(2);
+    expect(next.toolCalls[0]).toEqual(existingCall);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// UPDATE_TOOL_CALL
+// ---------------------------------------------------------------------------
+
+describe("chatReducer — UPDATE_TOOL_CALL for suggest_portfolio_hedge", () => {
+  const stateWithRunning = chatReducer(emptyState, {
+    type: "ADD_TOOL_CALL_START",
+    payload: {
+      tool_call_id: "tc-001",
+      tool_name: "suggest_portfolio_hedge",
+      tool_args: { account_id: "ACC-001" },
+    },
+  });
+
+  const completePayload = {
+    tool_call_id: "tc-001",
+    status: "success",
+    output: JSON.stringify(hedgeOutput),
+    elapsed_ms: 142,
+  };
+
+  it("updates status to success", () => {
+    const state = chatReducer(stateWithRunning, {
+      type: "UPDATE_TOOL_CALL",
+      payload: completePayload,
+    });
+    expect(state.toolCalls[0].status).toBe("success");
+  });
+
+  it("sets output on the correct tool call", () => {
+    const state = chatReducer(stateWithRunning, {
+      type: "UPDATE_TOOL_CALL",
+      payload: completePayload,
+    });
+    const output = JSON.parse(state.toolCalls[0].output);
+    expect(output.account_id).toBe("ACC-001");
+    expect(output.hedge_recommendations).toBeInstanceOf(Array);
+  });
+
+  it("sets elapsed_ms", () => {
+    const state = chatReducer(stateWithRunning, {
+      type: "UPDATE_TOOL_CALL",
+      payload: completePayload,
+    });
+    expect(state.toolCalls[0].elapsed_ms).toBe(142);
+  });
+
+  it("does not affect other tool calls", () => {
+    const stateTwo = chatReducer(stateWithRunning, {
+      type: "ADD_TOOL_CALL_START",
+      payload: { tool_call_id: "tc-002", tool_name: "get_account_details", tool_args: {} },
+    });
+    const updated = chatReducer(stateTwo, { type: "UPDATE_TOOL_CALL", payload: completePayload });
+    expect(updated.toolCalls[1].status).toBe("running"); // tc-002 unchanged
+  });
+
+  it("handles error status correctly", () => {
+    const errorPayload = {
+      tool_call_id: "tc-001",
+      status: "error",
+      output: "DB error",
+      elapsed_ms: 10,
+    };
+    const state = chatReducer(stateWithRunning, {
+      type: "UPDATE_TOOL_CALL",
+      payload: errorPayload,
+    });
+    expect(state.toolCalls[0].status).toBe("error");
+    expect(state.toolCalls[0].output).toBe("DB error");
+  });
+});
+
+// ---------------------------------------------------------------------------
+// AGENT_COMPLETE — tool calls attached to assistant message
+// ---------------------------------------------------------------------------
+
+describe("chatReducer — AGENT_COMPLETE attaches hedge tool calls to message", () => {
+  // Build state: start hedge tool call, complete it, then agent_complete
+  let state = emptyState;
+  state = chatReducer(state, {
+    type: "ADD_TOOL_CALL_START",
+    payload: {
+      tool_call_id: "tc-001",
+      tool_name: "suggest_portfolio_hedge",
+      tool_args: { account_id: "ACC-001" },
+    },
+  });
+  state = chatReducer(state, {
+    type: "UPDATE_TOOL_CALL",
+    payload: {
+      tool_call_id: "tc-001",
+      status: "success",
+      output: JSON.stringify(hedgeOutput),
+      elapsed_ms: 142,
+    },
+  });
+
+  const completeAction = {
+    type: "AGENT_COMPLETE",
+    payload: {
+      message_id: "msg-001",
+      response: "Here are hedge recommendations for ACC-001...",
+      query_summary: null,
+    },
+  };
+
+  it("clears toolCalls from live state after agent_complete", () => {
+    const next = chatReducer(state, completeAction);
+    expect(next.toolCalls).toHaveLength(0);
+  });
+
+  it("attaches tool calls to the new assistant message", () => {
+    const next = chatReducer(state, completeAction);
+    const assistantMsg = next.messages.find((m) => m.role === "assistant");
+    expect(assistantMsg).toBeDefined();
+    expect(assistantMsg.toolCalls).toHaveLength(1);
+    expect(assistantMsg.toolCalls[0].name).toBe("suggest_portfolio_hedge");
+  });
+
+  it("assistant message contains hedge output in its tool call", () => {
+    const next = chatReducer(state, completeAction);
+    const assistantMsg = next.messages.find((m) => m.role === "assistant");
+    const output = JSON.parse(assistantMsg.toolCalls[0].output);
+    expect(output.hedge_recommendations.length).toBeGreaterThan(0);
+  });
+
+  it("sets isLoading to false", () => {
+    const next = chatReducer(state, completeAction);
+    expect(next.isLoading).toBe(false);
+  });
+
+  it("agent_complete on a streaming message updates it in-place", () => {
+    // Simulate a streaming message already existing
+    const streamingState = {
+      ...state,
+      messages: [
+        {
+          id: "msg-001",
+          role: "assistant",
+          content: "partial...",
+          isStreaming: true,
+          toolCalls: [],
+        },
+      ],
+    };
+    const next = chatReducer(streamingState, completeAction);
+    const assistantMsg = next.messages.find((m) => m.id === "msg-001");
+    expect(assistantMsg.isStreaming).toBe(false);
+    expect(assistantMsg.content).toBe("Here are hedge recommendations for ACC-001...");
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Full lifecycle simulation
+// ---------------------------------------------------------------------------
+
+describe("chatReducer — full suggest_portfolio_hedge lifecycle", () => {
+  it("running → success → attached to message without errors", () => {
+    let s = emptyState;
+
+    // 1. Tool call starts
+    s = chatReducer(s, {
+      type: "ADD_TOOL_CALL_START",
+      payload: {
+        tool_call_id: "tc-hedge",
+        tool_name: "suggest_portfolio_hedge",
+        tool_args: { account_id: "ACC-005", risk_focus: "sector" },
+      },
+    });
+    expect(s.toolCalls[0].status).toBe("running");
+
+    // 2. Tool call completes
+    s = chatReducer(s, {
+      type: "UPDATE_TOOL_CALL",
+      payload: {
+        tool_call_id: "tc-hedge",
+        status: "success",
+        output: JSON.stringify(hedgeOutput),
+        elapsed_ms: 200,
+      },
+    });
+    expect(s.toolCalls[0].status).toBe("success");
+
+    // 3. Agent completes
+    s = chatReducer(s, {
+      type: "AGENT_COMPLETE",
+      payload: { message_id: "msg-final", response: "Done.", query_summary: null },
+    });
+    expect(s.toolCalls).toHaveLength(0);
+
+    const msg = s.messages.find((m) => m.id === "msg-final");
+    expect(msg.toolCalls[0].name).toBe("suggest_portfolio_hedge");
+    expect(msg.toolCalls[0].elapsed_ms).toBe(200);
+  });
+});
diff --git a/apps/finance-ai-agent-demo/frontend/src/test/hedgeFixtures.js b/apps/finance-ai-agent-demo/frontend/src/test/hedgeFixtures.js
new file mode 100644
index 00000000..4995d793
--- /dev/null
+++ b/apps/finance-ai-agent-demo/frontend/src/test/hedgeFixtures.js
@@ -0,0 +1,97 @@
+/**
+ * Shared test fixtures for suggest_portfolio_hedge frontend tests.
+ */
+
+export const hedgeOutput = {
+  account_id: "ACC-001",
+  risk_profile: "aggressive",
+  esg_mandate: false,
+  risk_focus: "all",
+  portfolio_summary: {
+    total_holdings: 6,
+    high_risk_positions: 2,
+    sector_breakdown: [
+      { sector: "Technology", pct: 40.0 },
+      { sector: "Energy", pct: 20.0 },
+    ],
+    region_breakdown: [
+      { region: "North America", pct: 65.0 },
+      { region: "Europe", pct: 20.0 },
+    ],
+    asset_class_breakdown: [
+      { asset_class: "Equity", pct: 80.0 },
+      { asset_class: "Bond", pct: 20.0 },
+    ],
+  },
+  risk_factors_identified: [
+    "High equity concentration: 80.0% of portfolio is in equities.",
+    "2 high-risk positions (risk_rating ≥ 7) totalling 20.0% of portfolio.",
+    "Sector concentration: Technology represents 40.0% of portfolio.",
+    "Regional concentration: North America represents 65.0% of portfolio.",
+  ],
+  hedge_recommendations: [
+    {
+      ticker: "SH",
+      name: "ProShares Short S&P500 ETF",
+      type: "Inverse ETF",
+      rationale: "Broad market hedge; gains when S&P 500 declines.",
+      risk_level: "medium",
+      hedge_dimension: "market",
+      trigger: "equity_pct=80.0%",
+    },
+    {
+      ticker: "GLD",
+      name: "SPDR Gold Shares",
+      type: "Safe Haven",
+      rationale: "Gold allocation offsets tail risk from 2 high-rated positions.",
+      risk_level: "low",
+      hedge_dimension: "market",
+      trigger: "high_risk_positions=2",
+    },
+    {
+      ticker: "PUT",
+      name: "Put options on QQQ",
+      type: "Options Strategy",
+      rationale: "Buy QQQ put options as targeted downside protection for tech-heavy portfolios.",
+      risk_level: "medium",
+      hedge_dimension: "sector",
+      trigger: "Technology=40.0%",
+    },
+    {
+      ticker: "EFA",
+      name: "iShares MSCI EAFE ETF",
+      type: "Geographic Diversification",
+      rationale: "Developed-market international exposure offsets US-heavy concentration.",
+      risk_level: "low",
+      hedge_dimension: "regional",
+      trigger: "North America=65.0%",
+    },
+  ],
+  disclaimer:
+    "These recommendations are generated algorithmically for illustrative purposes only. " +
+    "All hedging decisions should be reviewed by a qualified financial advisor.",
+};
+
+export function makeToolCall({
+  id = "tc-hedge-001",
+  name = "suggest_portfolio_hedge",
+  args = { account_id: "ACC-001", risk_focus: "all" },
+  status = "success",
+  output = JSON.stringify(hedgeOutput),
+  elapsed_ms = 142,
+} = {}) {
+  return { id, name, args, status, output, elapsed_ms };
+}
+
+export function makeRunningToolCall(overrides = {}) {
+  return makeToolCall({ status: "running", output: null, elapsed_ms: null, ...overrides });
+}
+
+export function makeErrorToolCall(overrides = {}) {
+  return makeToolCall({
+    status: "error",
+    output: "Database connection failed",
+    elapsed_ms: 55,
+    ...overrides,
+  });
+}
diff --git a/apps/finance-ai-agent-demo/frontend/src/test/setup.js b/apps/finance-ai-agent-demo/frontend/src/test/setup.js
new file mode 100644
index 00000000..d0de870d
--- /dev/null
+++ b/apps/finance-ai-agent-demo/frontend/src/test/setup.js
@@ -0,0 +1 @@
+import "@testing-library/jest-dom";
diff --git a/apps/finance-ai-agent-demo/frontend/vite.config.js b/apps/finance-ai-agent-demo/frontend/vite.config.js
index 94c0fa01..212e5277 100644
--- a/apps/finance-ai-agent-demo/frontend/vite.config.js
+++ b/apps/finance-ai-agent-demo/frontend/vite.config.js
@@ -3,6 +3,15 @@ import react from "@vitejs/plugin-react";
 
 export default defineConfig({
   plugins: [react()],
+  test: {
+    environment: "jsdom",
+    globals: true,
+    setupFiles: "./src/test/setup.js",
+    coverage: {
+      reporter: ["text", "lcov"],
+      include: ["src/components/**", "src/hooks/**"],
+    },
+  },
   server: {
     port: 3000,
     proxy: {