learning_ai_invt_trdg/web/src/tabs/OverviewTab.dom.test.tsx

249 lines
9.7 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { OverviewTab, dedupeLivePositions } from './OverviewTab';
import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket';
const { authMock, fetchTradeProfilesMock, canonicalLifecycleHookState } = vi.hoisted(() => {
return {
authMock: { user: { id: 'u1' }, profile: { role: 'user' } },
fetchTradeProfilesMock: vi.fn(),
canonicalLifecycleHookState: {
snapshot: null as any,
loading: false,
error: null as string | null
}
};
});
vi.mock('../components/AuthContext', () => ({
useAuth: () => ({
...authMock,
refreshProfile: vi.fn()
})
}));
vi.mock('../lib/profileApi', () => ({
fetchTradeProfiles: fetchTradeProfilesMock
}));
vi.mock('../hooks/useCanonicalLifecycle', () => ({
useCanonicalLifecycle: () => ({
snapshot: canonicalLifecycleHookState.snapshot,
loading: canonicalLifecycleHookState.loading,
error: canonicalLifecycleHookState.error
})
}));
describe('OverviewTab coverage maximization', () => {
let mockBotState: any;
beforeEach(() => {
vi.clearAllMocks();
authMock.profile.role = 'user';
canonicalLifecycleHookState.snapshot = null;
canonicalLifecycleHookState.loading = false;
canonicalLifecycleHookState.error = null;
mockBotState = JSON.parse(JSON.stringify(DEFAULT_BOT_STATE));
mockBotState.symbols = {
'BTC/USDT': {
signal: 'BUY',
volatility: 'LOW',
session: 'US',
indicators: { rsi_1h: 60, ema50_4h: 50000, ema200_4h: 40000 },
profileSignals: { 'p1': { signal: 'BUY', passed: true } }
}
};
fetchTradeProfilesMock.mockResolvedValue([
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 5000, strategy_config: { execution: { cooldownMinutes: 5 } } }
]);
});
it('covers all uptime and duration formatting paths', async () => {
mockBotState.uptime = 45000;
const { unmount } = render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText('45s')).toBeInTheDocument());
unmount();
mockBotState.uptime = 3600000 * 25 + 60000;
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText('25h 1m')).toBeInTheDocument());
});
it('covers Capital Sync Warning branch', async () => {
mockBotState.positions = [
{ symbol: 'BTC/USDT', side: 'BUY', entryPrice: 60000, size: 0.1, profileId: 'p1' }
];
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/Capital sync warning/i)).toBeInTheDocument());
});
it('covers fallback capital logic', async () => {
fetchTradeProfilesMock.mockResolvedValue([]);
mockBotState.settings.totalCapital = 25000;
render(<OverviewTab botState={mockBotState} />);
// The label in bot-status-bar is "Allocated:"
await waitFor(() => expect(screen.getByText(/^Allocated:$/i).parentElement).toHaveTextContent(/\$25,?000\.00/));
});
it('covers Signal Active branches', async () => {
fetchTradeProfilesMock.mockResolvedValueOnce([
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 0, strategy_config: { execution: { cooldownMinutes: 5 } } }
]);
const { unmount } = render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/Signal active, no allocation/i)).toBeInTheDocument());
unmount();
fetchTradeProfilesMock.mockResolvedValueOnce([
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 5000, strategy_config: { execution: { cooldownMinutes: 5 } } }
]);
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/Signal active, waiting entry/i)).toBeInTheDocument());
});
it('shows blocked state when directional signal is blocked by execution guard', async () => {
mockBotState.symbols['BTC/USDT'].profileSignals = {
p1: {
signal: 'BUY',
passed: true,
execution: {
status: 'BLOCKED',
code: 'trading_paused',
reason: 'Trading is paused by control plane.'
}
}
};
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/Signal blocked by guard/i)).toBeInTheDocument());
});
it('falls back to live profile positions when canonical lifecycle is unavailable', async () => {
mockBotState.positions = [
{
id: 'pos-live-1',
symbol: 'BTC/USDT',
side: 'BUY',
size: 1,
entryPrice: 1000,
currentPrice: 1010,
stopLoss: 950,
takeProfit: 1100,
unrealizedPnl: 10,
unrealizedPnlPercent: 1,
marketValue: 1010,
profileId: 'p1',
profileName: 'Alpha',
tradeId: 'TRD-LIVE-1'
}
];
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/In Position \(1 open\)/i)).toBeInTheDocument());
});
it('prefers live-flat utilization when canonical lifecycle has stale open notional', async () => {
canonicalLifecycleHookState.snapshot = {
generatedAt: Date.now(),
diagnostics: {
orderRows: 1,
lifecycleRows: 1,
openPositions: 1,
realizedTrades: 0,
truncated: false
},
profiles: [{
id: 'p1',
name: 'Alpha',
allocatedCapital: 5000,
isActive: true
}],
lifecycleRows: [],
openPositions: [],
realizedTrades: [],
aggregates: {
total: {
openTrades: 1,
openNotional: 20205.03,
realizedPnl: 0,
unrealizedPnl: 0,
netPnl: 0,
tradeCount: 0,
wins: 0,
winRate: 0
},
byProfile: {
p1: {
profileId: 'p1',
profileName: 'Alpha',
allocatedCapital: 5000,
isActive: true,
openTrades: 1,
openNotional: 20205.03,
realizedPnl: 0,
unrealizedPnl: 0,
netPnl: 0,
tradeCount: 0,
wins: 0,
winRate: 0,
lastClosedTradeAt: 0
}
}
}
};
mockBotState.positions = [];
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/Lifecycle sync pending/i)).toBeInTheDocument());
expect(screen.queryByText(/Capital sync warning/i)).not.toBeInTheDocument();
});
it('covers Admin role data fetching', async () => {
authMock.profile.role = 'admin';
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(fetchTradeProfilesMock).toHaveBeenCalledWith({ scope: 'all' }));
});
it('covers error handling in fetchData', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
fetchTradeProfilesMock.mockRejectedValueOnce(new Error('Query Fail'));
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(consoleSpy).toHaveBeenCalled());
consoleSpy.mockRestore();
});
it('covers Mixed signal label rendering for customer', async () => {
mockBotState.symbols['BTC/USDT'].signal = 'MIXED';
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/Conditions mixed — holding off/i)).toBeInTheDocument());
});
it('covers Mixed signal label rendering for admin', async () => {
authMock.profile.role = 'admin';
mockBotState.symbols['BTC/USDT'].signal = 'MIXED';
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/PROFILE SIGNALS MIXED/i)).toBeInTheDocument());
});
});
describe('dedupeLivePositions logic', () => {
it('merges positions with identical tradeId', () => {
const p1 = { symbol: 'BTC', side: 'BUY', tradeId: 'T1', profileId: 'P1', entryPrice: 1, size: 1 } as any;
const p2 = { symbol: 'BTC', side: 'BUY', tradeId: 'T1', profileId: 'P1', entryPrice: 1, size: 2 } as any;
const result = dedupeLivePositions([p1, p2]);
expect(result).toHaveLength(1);
expect(result[0].size).toBe(2);
});
it('merges positions using fallback key', () => {
const p1 = { symbol: 'BTC', side: 'BUY', tradeId: '', profileId: 'P1', entryPrice: 1, size: 1 } as any;
const p2 = { symbol: 'BTC', side: 'BUY', tradeId: '', profileId: 'P1', entryPrice: 1, size: 2 } as any;
const result = dedupeLivePositions([p1, p2]);
expect(result).toHaveLength(1);
expect(result[0].size).toBe(2);
});
it('handles null/undefined gracefully', () => {
expect(dedupeLivePositions(undefined)).toEqual([]);
expect(dedupeLivePositions(null as any)).toEqual([]);
});
});