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

471 lines
16 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PositionsTab } from './PositionsTab';
import type { BotState } from '../hooks/useWebSocket';
const {
authState,
fetchPositionsBootstrapMock,
getPlatformAccessTokenMock,
canonicalLifecycleState
} = vi.hoisted(() => ({
authState: {
user: { id: 'user-1' } as any,
profile: { role: 'admin' } as any,
session: { access_token: 'session-token' } as any
},
fetchPositionsBootstrapMock: vi.fn(),
getPlatformAccessTokenMock: vi.fn(),
canonicalLifecycleState: {
snapshot: {
lifecycleRows: [{ tradeId: 'TRD-CANONICAL-1' }],
realizedTrades: [],
openPositions: [{
id: 'canonical-open-1',
profileId: 'p1',
profileName: 'High Risk Scalper',
symbol: 'BTC/USDT',
side: 'BUY',
size: 1,
entryPrice: 100,
currentPrice: 125,
pnl: 25,
pnlPercent: 25,
stopLoss: 90,
takeProfit: 130,
tradeId: 'TRD-POS-1',
lastEventAt: 1
}],
diagnostics: { truncated: false }
} as any
}
}));
vi.mock('../components/AuthContext', () => ({
useAuth: () => authState
}));
vi.mock('../lib/positionsApi', () => ({
fetchPositionsBootstrap: fetchPositionsBootstrapMock
}));
vi.mock('../lib/authSession', () => ({
getPlatformAccessToken: getPlatformAccessTokenMock
}));
vi.mock('../hooks/useCanonicalLifecycle', () => ({
useCanonicalLifecycle: () => ({
snapshot: canonicalLifecycleState.snapshot,
loading: false,
error: null
})
}));
const buildBotState = (now: number): BotState => {
const sharedSymbolMeta = {
change24h: 0,
changeToday: 0,
session: 'NY',
volatility: 'High',
signal: 'HOLD',
activePosition: null,
priceHistory: [],
rules: {},
indicators: {}
} as any;
const botOrders = Array.from({ length: 12 }).map((_, idx) => ({
id: `bot-order-${idx}`,
order_id: `bot-order-${idx}`,
profileId: idx % 2 === 0 ? 'p1' : 'p2',
symbol: idx % 2 === 0 ? 'BTC/USDT' : 'ETH/USDT',
type: 'Market',
side: idx % 2 === 0 ? 'BUY' : 'SELL',
qty: 0.5 + idx * 0.01,
price: 100 + idx,
status: idx === 1 ? 'expired' : idx === 2 ? 'unknown' : idx === 3 ? 'pending_new' : 'filled',
timestamp: now - idx * 30_000,
trade_id: `TRD-BOT-${idx}`,
action: idx % 2 === 0 ? 'ENTRY' : 'EXIT',
source: 'BOT'
}));
botOrders.push({
id: 'merge-1',
order_id: 'merge-1',
profileId: 'p1',
symbol: 'BTC/USDT',
type: 'Market',
side: 'BUY',
qty: 1.2,
price: 150,
status: 'filled',
timestamp: now - 5000,
trade_id: 'TRD-MERGE',
action: 'ENTRY',
source: 'BOT'
} as any);
return {
symbols: {
'BTC/USDT': { ...sharedSymbolMeta, price: 125, signal: 'BUY' },
'ETH/USDT': { ...sharedSymbolMeta, price: 2200, signal: 'SELL' },
'SOL/USDT': { ...sharedSymbolMeta, price: 100, signal: 'HOLD' },
'XRP/USDT': { ...sharedSymbolMeta, price: 1.1, signal: 'HOLD' },
'DOGE/USDT': { ...sharedSymbolMeta, price: 0.1, signal: 'HOLD' },
'UNI/USDT': { ...sharedSymbolMeta, price: 10, signal: 'HOLD' },
'LINK/USDT': { ...sharedSymbolMeta, price: 20, signal: 'HOLD' }
} as any,
alerts: [],
positions: [
{
id: 'bot-1',
symbol: 'BTC/USDT',
side: 'BUY',
size: 1,
entryPrice: 100,
currentPrice: 125,
stopLoss: 90,
takeProfit: 130,
unrealizedPnl: 25,
unrealizedPnlPercent: 25,
marketValue: 125,
profileId: 'p1',
profileName: 'High Risk Scalper',
tradeId: 'TRD-POS-1'
},
{
id: 'bot-1-dupe',
symbol: 'BTC/USDT',
side: 'BUY',
size: 2,
entryPrice: 100,
currentPrice: 125,
stopLoss: 90,
takeProfit: 130,
unrealizedPnl: 50,
unrealizedPnlPercent: 25,
marketValue: 250,
profileId: 'p1',
profileName: 'High Risk Scalper',
tradeId: 'TRD-POS-1'
},
{
id: 'manual-gap',
symbol: 'ETH/USDT',
side: 'BUY',
size: 1,
entryPrice: 2000,
currentPrice: 2200,
stopLoss: 1900,
takeProfit: 2300,
unrealizedPnl: 200,
unrealizedPnlPercent: 10,
marketValue: 2200,
profileId: 'p2',
profileName: 'Conservative Bag'
}
] as any,
orders: botOrders as any,
history: [],
settings: {
executionMode: 'Paper',
riskPerTrade: 0.01,
totalCapital: 15000,
maxOpenTrades: 6,
isAlgoEnabled: true,
enabledRules: ['TrendBiasRule']
},
uptime: 1_000_000
};
};
describe('PositionsTab DOM behavior', () => {
beforeEach(() => {
authState.user = { id: 'user-1' };
authState.profile = { role: 'admin' };
authState.session = { access_token: 'session-token' };
fetchPositionsBootstrapMock.mockReset();
getPlatformAccessTokenMock.mockReset();
canonicalLifecycleState.snapshot = {
lifecycleRows: [{ tradeId: 'TRD-CANONICAL-1' }],
realizedTrades: [],
openPositions: [{
id: 'canonical-open-1',
profileId: 'p1',
profileName: 'High Risk Scalper',
symbol: 'BTC/USDT',
side: 'BUY',
size: 1,
entryPrice: 100,
currentPrice: 125,
pnl: 25,
pnlPercent: 25,
stopLoss: 90,
takeProfit: 130,
tradeId: 'TRD-POS-1',
lastEventAt: 1
}],
diagnostics: { truncated: false }
};
vi.stubGlobal('confirm', vi.fn(() => true));
vi.stubGlobal('alert', vi.fn());
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
json: async () => ({ success: true })
}));
});
it('renders positions, lifecycle, and controls from the bootstrap api', async () => {
const now = Date.now();
fetchPositionsBootstrapMock.mockResolvedValue({
entries: [
{
stock_instance_id: 'manual-live',
symbol: 'BTC/USDT',
quantity: 1,
buy_price: 100,
drop_threshold_for_buy: 95,
gain_threshold_for_sell: 115,
active: true,
status: 'active'
}
],
orders: [
{
id: 'db-profile',
order_id: 'db-profile',
profile_id: 'p2',
symbol: 'BTC/USDT',
type: 'Market',
side: 'BUY',
qty: 1,
price: 2000,
status: 'filled',
timestamp: now - 120_000,
created_at: new Date(now - 120_000).toISOString(),
trade_id: 'TRD-PROFILE-MISMATCH',
action: 'ENTRY',
source: 'BOT'
}
],
historyTradeKeys: [{ trade_id: 'TRD-BOT-3', profile_id: 'p2' }],
profiles: [
{ id: 'p1', name: 'High Risk Scalper' },
{ id: 'p2', name: 'Conservative Bag' }
]
});
const user = userEvent.setup();
const { container } = render(<PositionsTab botState={buildBotState(now)} />);
await waitFor(() => {
expect(screen.getByText('Order Activity')).toBeInTheDocument();
expect(screen.getByText('Lifecycle Trace')).toBeInTheDocument();
expect(screen.getByText('Trade cycle tracing active')).toBeInTheDocument();
});
expect(fetchPositionsBootstrapMock).toHaveBeenCalledWith({ scope: 'all', limit: 5000 });
expect(screen.getAllByText('Conservative Bag').length).toBeGreaterThan(0);
expect(screen.getByText(/Lifecycle Mismatch Diagnostics/)).toBeInTheDocument();
expect(screen.queryByText('No active positions for this selection.')).not.toBeInTheDocument();
const selects = container.querySelectorAll('select');
expect(selects.length).toBeGreaterThanOrEqual(2);
await user.selectOptions(selects[0] as HTMLSelectElement, 'p1');
await user.selectOptions(selects[1] as HTMLSelectElement, 'p1');
const nextButtons = screen.getAllByRole('button', { name: 'Next' });
const prevButtons = screen.getAllByRole('button', { name: 'Prev' });
await user.click(nextButtons[0]);
await user.click(prevButtons[0]);
});
it('handles square-off workflow for cancel, auth failure, api failure, and success', async () => {
const now = Date.now();
fetchPositionsBootstrapMock.mockResolvedValue({
entries: [],
orders: [{
id: 'entry-order',
order_id: 'entry-order',
profile_id: 'p1',
symbol: 'BTC/USDT',
type: 'Market',
side: 'BUY',
qty: 1,
price: 100,
status: 'filled',
timestamp: now - 1_000,
created_at: new Date(now - 1_000).toISOString(),
trade_id: 'TRD-POS-1',
action: 'ENTRY',
source: 'BOT'
}],
historyTradeKeys: [],
profiles: [{ id: 'p1', name: 'High Risk Scalper' }]
});
const user = userEvent.setup();
render(<PositionsTab botState={buildBotState(now)} />);
await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' }));
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Square Off' }).length).toBeGreaterThan(0);
});
vi.mocked(confirm)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(true);
getPlatformAccessTokenMock
.mockRejectedValueOnce(new Error('Not authenticated'))
.mockResolvedValueOnce('token-1')
.mockResolvedValueOnce('token-2');
vi.mocked(fetch)
.mockResolvedValueOnce({ json: async () => ({ success: false, error: 'broker rejected' }) } as any)
.mockResolvedValueOnce({ json: async () => ({ success: true }) } as any);
const button = screen.getAllByRole('button', { name: 'Square Off' })[0];
await user.click(button);
expect(fetch).not.toHaveBeenCalled();
await user.click(button);
await waitFor(() => {
expect(alert).toHaveBeenCalledWith(expect.stringContaining('Error: Not authenticated'));
});
await user.click(button);
await waitFor(() => {
expect(alert).toHaveBeenCalledWith(expect.stringContaining('Failed to close: broker rejected'));
});
await user.click(button);
await waitFor(() => {
expect(alert).toHaveBeenCalledWith(expect.stringContaining('Successfully closed'));
});
});
it('handles non-admin bootstrap failures with empty-state fallback', async () => {
authState.user = { id: 'user-2' };
authState.profile = { role: 'trader' };
canonicalLifecycleState.snapshot = null;
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
fetchPositionsBootstrapMock.mockRejectedValueOnce(new Error('bootstrap failed'));
render(<PositionsTab botState={{
symbols: {},
alerts: [],
positions: [],
orders: [],
history: [],
settings: {
executionMode: 'Paper',
riskPerTrade: 0.01,
totalCapital: 1000,
maxOpenTrades: 3,
isAlgoEnabled: true,
enabledRules: []
},
uptime: 1000
}} />);
await waitFor(() => {
expect(screen.getByText('No recent orders for this cluster.')).toBeInTheDocument();
expect(screen.getByText('No active positions for this selection.')).toBeInTheDocument();
});
expect(fetchPositionsBootstrapMock).toHaveBeenCalledWith({ scope: 'user', limit: 5000 });
expect(errorSpy).toHaveBeenCalledWith('[PositionsTab] Failed loading positions bootstrap:', 'bootstrap failed');
});
it('does not render fake market price or breach badges for manual entries without live pricing', async () => {
canonicalLifecycleState.snapshot = null;
fetchPositionsBootstrapMock.mockResolvedValue({
entries: [
{
stock_instance_id: 'manual-live',
symbol: 'BTC/USDT',
quantity: 1,
buy_price: 100,
drop_threshold_for_buy: 95,
gain_threshold_for_sell: 115,
active: true,
status: 'active'
}
],
orders: [],
historyTradeKeys: [],
profiles: []
});
render(<PositionsTab botState={{
symbols: {},
alerts: [],
positions: [],
orders: [],
history: [],
settings: {
executionMode: 'Paper',
riskPerTrade: 0.01,
totalCapital: 1000,
maxOpenTrades: 3,
isAlgoEnabled: true,
enabledRules: []
},
uptime: 1000
}} />);
await waitFor(() => {
expect(screen.getByText('BTC/USDT')).toBeInTheDocument();
});
expect(screen.queryByText('SL breached')).not.toBeInTheDocument();
expect(screen.queryByText('$0')).not.toBeInTheDocument();
});
it('does not refetch bootstrap just because websocket order/history counts change', async () => {
const now = Date.now();
fetchPositionsBootstrapMock.mockResolvedValue({
entries: [],
orders: [],
historyTradeKeys: [],
profiles: []
});
const { rerender } = render(<PositionsTab botState={buildBotState(now)} />);
await waitFor(() => {
expect(fetchPositionsBootstrapMock).toHaveBeenCalledTimes(1);
});
const nextBotState = buildBotState(now);
nextBotState.orders = [...nextBotState.orders, {
id: 'new-order',
order_id: 'new-order',
profileId: 'p1',
symbol: 'BTC/USDT',
type: 'Market',
side: 'BUY',
qty: 1,
price: 101,
status: 'filled',
timestamp: now + 1_000,
trade_id: 'TRD-NEW',
action: 'ENTRY',
source: 'BOT'
} as any];
nextBotState.history = [{ id: 'history-1' } as any];
rerender(<PositionsTab botState={nextBotState} />);
await waitFor(() => {
expect(fetchPositionsBootstrapMock).toHaveBeenCalledTimes(1);
});
});
});