249 lines
9.7 KiB
TypeScript
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([]);
|
|
});
|
|
});
|