diff --git a/web/src/components/layout/Header.dom.test.tsx b/web/src/components/layout/Header.dom.test.tsx index f1aa471..1052ee8 100644 --- a/web/src/components/layout/Header.dom.test.tsx +++ b/web/src/components/layout/Header.dom.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { AppContext, type AppContextValue } from '../../context/AppContext'; import { Header } from './Header'; @@ -87,4 +87,14 @@ describe('Header market index polling', () => { }); expect(fetchMarketIndicesMock).toHaveBeenCalledTimes(2); }); + + it('focuses ticker search with Cmd/Ctrl-K', () => { + renderHeader(); + + const search = screen.getByLabelText('Search ticker'); + expect(search).not.toHaveFocus(); + + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + expect(search).toHaveFocus(); + }); }); diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 29b5413..68ba94a 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -45,6 +45,18 @@ export function Header() { inputRef.current?.blur(); }; + useEffect(() => { + const handleShortcut = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + inputRef.current?.focus(); + } + }; + + window.addEventListener('keydown', handleShortcut); + return () => window.removeEventListener('keydown', handleShortcut); + }, []); + return ( setQuery(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleSearch(query); }} - placeholder="Ask about a company..." + placeholder="Ask about a company... (Cmd/Ctrl K)" + title="Focus search with Cmd/Ctrl+K" style={{ width: '100%', paddingLeft: 34, diff --git a/web/src/components/strategy/CodeStrategyEditor.dom.test.tsx b/web/src/components/strategy/CodeStrategyEditor.dom.test.tsx index f698a41..0057e17 100644 --- a/web/src/components/strategy/CodeStrategyEditor.dom.test.tsx +++ b/web/src/components/strategy/CodeStrategyEditor.dom.test.tsx @@ -22,6 +22,14 @@ vi.mock('../../lib/profileApi', () => ({ createTradeProfile: (...args: any[]) => createTradeProfileMock(...args), })); +vi.mock('../../lib/authSession', () => ({ + getPlatformAccessToken: vi.fn(async () => 'test-token'), +})); + +vi.mock('../../lib/runtime', () => ({ + tradingRuntime: { tradingApiUrl: 'https://trading.test' }, +})); + describe('CodeStrategyEditor save behavior', () => { let setItemSpy: ReturnType; @@ -33,6 +41,7 @@ describe('CodeStrategyEditor save behavior', () => { afterEach(() => { vi.useRealTimers(); + vi.unstubAllGlobals(); setItemSpy.mockRestore(); }); @@ -86,4 +95,26 @@ describe('CodeStrategyEditor save behavior', () => { expect(clearTimeoutSpy).toHaveBeenCalled(); clearTimeoutSpy.mockRestore(); }); + + it('saves with Cmd/Ctrl-S while focused in the editor', async () => { + render(); + + fireEvent.keyDown(screen.getByLabelText('strategy code'), { key: 's', metaKey: true }); + + await waitFor(() => expect(createTradeProfileMock).toHaveBeenCalledTimes(1)); + }); + + it('runs a backtest with Cmd/Ctrl-Enter while focused in the editor', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: { trades: 0 } }), + }); + vi.stubGlobal('fetch', fetchMock); + + render(); + fireEvent.keyDown(screen.getByLabelText('strategy code'), { key: 'Enter', metaKey: true }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(String(fetchMock.mock.calls[0][0])).toBe('https://trading.test/api/backtest/run'); + }); }); diff --git a/web/src/components/strategy/CodeStrategyEditor.tsx b/web/src/components/strategy/CodeStrategyEditor.tsx index d95c589..d898ed6 100644 --- a/web/src/components/strategy/CodeStrategyEditor.tsx +++ b/web/src/components/strategy/CodeStrategyEditor.tsx @@ -176,16 +176,32 @@ export function CodeStrategyEditor({ navigator.clipboard.writeText(code).catch(() => {}); }; + const handleShortcut = (event: React.KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey)) return; + const key = event.key.toLowerCase(); + + if (key === 's') { + event.preventDefault(); + if (!saving) void handleSave(); + } else if (event.key === 'Enter' && !running) { + event.preventDefault(); + void handleRunBacktest(); + } + }; + const fmt = (n: number | undefined, suffix = '') => n != null ? `${n.toFixed(2)}${suffix}` : '—'; return ( - + {/* Toolbar */} Code Editor — {symbol} + + Cmd/Ctrl-S save · Cmd/Ctrl-Enter backtest + @@ -195,7 +211,7 @@ export function CodeStrategyEditor({ style={toolBtn('#F9FAFB','#374151','#E5E7EB')}> Reset - {saving ? 'Saving...' : saved ? 'Saved!' : 'Save'} - { expect(clearTimeoutSpy).toHaveBeenCalled(); clearTimeoutSpy.mockRestore(); }); + + it('saves with Cmd/Ctrl-S inside the visual builder', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + + render(); + fireEvent.keyDown(screen.getByRole('button', { name: /save strategy/i }), { key: 's', metaKey: true }); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + }); + + it('runs a visual backtest with Cmd/Ctrl-Enter inside the visual builder', () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const onBacktest = vi.fn(); + + render(); + fireEvent.keyDown(screen.getByRole('button', { name: /run backtest/i }), { key: 'Enter', metaKey: true }); + + expect(onBacktest).toHaveBeenCalledTimes(1); + }); }); diff --git a/web/src/components/strategy/VisualRuleBuilder.tsx b/web/src/components/strategy/VisualRuleBuilder.tsx index dbfbe2e..19113b3 100644 --- a/web/src/components/strategy/VisualRuleBuilder.tsx +++ b/web/src/components/strategy/VisualRuleBuilder.tsx @@ -275,8 +275,20 @@ export function VisualRuleBuilder({ symbol, onSave, onBacktest }: Props) { } }; + const handleShortcut = (event: React.KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey)) return; + + if (event.key.toLowerCase() === 's') { + event.preventDefault(); + if (!saving && rules.length > 0) void handleSave(); + } else if (event.key === 'Enter' && onBacktest) { + event.preventDefault(); + onBacktest(rules); + } + }; + return ( - + {/* Header row */} @@ -299,6 +311,7 @@ export function VisualRuleBuilder({ symbol, onSave, onBacktest }: Props) { {onBacktest && ( onBacktest(rules)} + title="Run visual strategy backtest (Cmd/Ctrl+Enter)" style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', border: '1px solid #E5E7EB', borderRadius: 8, @@ -311,6 +324,7 @@ export function VisualRuleBuilder({ symbol, onSave, onBacktest }: Props) {