import { expect } from "bun:test"; import { useReducer } from "react"; import type { PluginRegistry } from "../../../plugins/registry"; import { useShortcut } from "../../../react/input"; import { TestDialogProvider } from "../../../renderers/opentui/test-utils"; import { AppContext, appReducer, createInitialState, getEffectiveThemeId, type AppAction, type AppState, } from "../../../state/app/context"; import { createTestDataProvider } from "../../../test-support/data-provider"; import { ThemeProvider, useThemeId } from "../../../theme/theme-context"; import { TICKER_RESEARCH_PANE_ID, cloneLayout, createDefaultConfig, type AppConfig } from "../../../types/config"; import type { DataProvider } from "../../../types/data-provider"; import type { PaneSettingField } from "../../../types/plugin"; import type { TickerRecord } from "../../../types/ticker"; import { Box, Text } from "../../../ui"; import { Header } from "../../layout/header"; import { CommandBar } from "./index"; export { createTestControls as createCommandBarTestControls, emitKeypress, settleFrame } from "../../../renderers/opentui/test-utils"; export function expectSingleBackControl(frame: string): void { expect(frame.match(/\bBack\b/g)?.length ?? 0).toBe(1); } export function makeTicker( symbol: string, name: string, overrides: Partial = {}, ): TickerRecord { return { metadata: { ticker: symbol, exchange: "NASDAQ", currency: "USD", name, portfolios: [], watchlists: [], positions: [], custom: {}, tags: [], ...overrides, }, }; } export function makeDataProvider(searchImpl: DataProvider["search"] = async () => []): DataProvider { return createTestDataProvider({ id: "test", search: searchImpl, }); } function makePluginRegistry(hasPaneSettings: (paneId: string) => boolean = () => false): PluginRegistry { return { panes: new Map([ ["portfolio-list", { id: "portfolio-list", name: "Portfolio List", component: () => null, defaultPosition: "left", }], [TICKER_RESEARCH_PANE_ID, { id: TICKER_RESEARCH_PANE_ID, name: "Ticker Research", component: () => null, defaultPosition: "right", }], ["chat", { id: "chat", name: "Chat", component: () => null, defaultPosition: "right", defaultMode: "floating", }], ["quote-monitor", { id: "quote-monitor", name: "Quote Monitor", component: () => null, defaultPosition: "right", defaultMode: "floating", }], ["ibkr-trading", { id: "ibkr-trading", name: "IBKR Console", component: () => null, defaultPosition: "right", defaultMode: "floating", }], ]), paneTemplates: new Map([ ["new-portfolio-pane", { id: "new-portfolio-pane", paneId: "portfolio-list", label: "New Portfolio Pane", description: "Open another portfolio list pane", shortcut: { prefix: "PF" }, }], ["new-watchlist-pane", { id: "new-watchlist-pane", paneId: "portfolio-list", label: "New Watchlist Pane", description: "Open another watchlist pane", }], ["new-ticker-detail-pane", { id: "new-ticker-detail-pane", paneId: TICKER_RESEARCH_PANE_ID, label: "Ticker Research", description: "Open another research pane", shortcut: { prefix: "T", argPlaceholder: "ticker", argKind: "ticker" }, }], ["new-chat-pane", { id: "new-chat-pane", paneId: "chat", label: "New Chat Pane", description: "Open another floating chat window", shortcut: { prefix: "CHAT", argPlaceholder: "channel", argKind: "text" }, }], ["quote-monitor-pane", { id: "quote-monitor-pane", paneId: "quote-monitor", label: "Quote Monitor", description: "Open a compact quote monitor for one or more tickers", shortcut: { prefix: "QQ", argPlaceholder: "tickers", argKind: "ticker-list" }, wizard: [{ key: "tickers", label: "Quote Tickers", type: "text" }], }], ["new-ibkr-trading-pane", { id: "new-ibkr-trading-pane", paneId: "ibkr-trading", label: "New IBKR Trading Pane", description: "Open another floating IBKR trading console", shortcut: { prefix: "IBKR" }, canCreate: (context: any) => context.config.brokerInstances.some((instance: any) => ( instance.brokerType === "ibkr" && instance.connectionMode === "gateway" && instance.enabled !== false )), }], ]), commands: new Map([ ["plugin:scan", { id: "plugin:scan", label: "Scan Movers", description: "Run a quick movers scan", category: "plugins", execute: async () => {}, }], ]), commandBarSearchProviders: new Map(), getCommandBarSearchProviderPluginId: () => undefined, getEnabledTickerActions() { return [...this.tickerActions.values()]; }, tickerActions: new Map([ ["pin", { id: "pin", label: "Pin Ticker", execute: async () => {}, }], ]), brokers: new Map(), allPlugins: new Map([ ["news", { id: "news", name: "News", version: "1.0.0", description: "Latest headlines", toggleable: true }], ["notes", { id: "notes", name: "Notes", version: "1.0.0", description: "Ticker notes", toggleable: true }], ]), getCommandPluginId: () => "news", getPaneTemplatePluginId: (templateId: string) => ( templateId === "new-chat-pane" ? "news" : "notes" ), getPluginPaneIds: () => [], getPluginPaneTemplateIds: () => [], hasPaneSettings, events: { emit: () => {} }, hidePane: () => {}, pinTicker: () => {}, showPane: () => {}, openWindowMode: () => {}, openWindowModeFn: () => {}, updateLayoutFn: () => {}, getTermSizeFn: () => ({ width: 80, height: 24 }), notify: () => {}, createPaneFromTemplateFn: () => {}, createPaneFromTemplateAsyncFn: async () => {}, openPaneSettingsFn: () => {}, applyPaneSettingValueFn: async () => {}, resolvePaneSettings: () => null, createBrokerInstanceFn: async () => { throw new Error("unused"); }, syncBrokerInstanceFn: async () => {}, removeBrokerInstanceFn: async () => {}, getConfigFn: () => createDefaultConfig("/tmp/gloomberb-test"), } as unknown as PluginRegistry; } export function makeQuoteMonitorPaneSettingsDescriptor( pluginRegistry: PluginRegistry, fields: PaneSettingField[], settings: Record = {}, ) { const config = createDefaultConfig("/tmp/gloomberb-test"); const pane = { instanceId: "quote-monitor:main", paneId: "quote-monitor", title: "Quote Monitor", settings, }; return { paneId: "quote-monitor:main", pane, paneDef: pluginRegistry.panes.get("quote-monitor")!, settingsDef: { title: "Quote Monitor Settings", fields, }, context: { config, layout: cloneLayout(config.layout), paneId: "quote-monitor:main", paneType: "quote-monitor", pane, settings, paneState: {}, activeTicker: "AAPL", activeCollectionId: "main", }, } as any; } function ThemeProbe() { return {`theme:${useThemeId()}`}; } function UnhandledEnterProbe({ onEnter }: { onEnter: () => void }) { useShortcut((event) => { if (event.name === "enter" || event.name === "return") { onEnter(); } }); return null; } export function CommandBarHarness({ query, disabledPlugins = [], selectedTicker, live = false, extraTickers = [], showQueryState = false, onSaveTicker, configureConfig, configureState, configurePluginRegistry, dataProvider = makeDataProvider(), hasPaneSettings, onCheckForUpdates, onAction, onUnhandledEnter, }: { query: string; disabledPlugins?: string[]; selectedTicker?: string; live?: boolean; extraTickers?: TickerRecord[]; showQueryState?: boolean; onSaveTicker?: (ticker: TickerRecord) => void; configureConfig?: (config: AppConfig) => AppConfig; configureState?: (state: AppState) => AppState; configurePluginRegistry?: (pluginRegistry: PluginRegistry) => void; dataProvider?: DataProvider; hasPaneSettings?: (paneId: string) => boolean; onCheckForUpdates?: () => void | Promise; onAction?: (action: AppAction) => void; onUnhandledEnter?: () => void; }) { let config = { ...createDefaultConfig("/tmp/gloomberb-test"), recentTickers: ["AAPL", "MSFT"], disabledPlugins, }; if (configureConfig) { config = configureConfig(config); } const tickers = [makeTicker("AAPL", "Apple Inc."), makeTicker("MSFT", "Microsoft Corp."), ...extraTickers]; let state: AppState = { ...createInitialState(config), commandBarOpen: true, commandBarQuery: query, focusedPaneId: "portfolio-list:main", tickers: new Map(tickers.map((ticker) => [ticker.metadata.ticker, ticker])), config: { ...config, disabledPlugins }, paneState: selectedTicker ? { "portfolio-list:main": { collectionId: "main", cursorSymbol: selectedTicker, }, } : createInitialState(config).paneState, }; if (configureState) { state = configureState(state); } const tickerRepository = { loadTicker: async () => null, createTicker: async (metadata: TickerRecord["metadata"]) => ({ metadata, }), saveTicker: async (ticker: TickerRecord) => { onSaveTicker?.(ticker); }, }; const pluginRegistry = makePluginRegistry(hasPaneSettings); configurePluginRegistry?.(pluginRegistry); const [liveState, dispatch] = useReducer(appReducer, state); const currentState = live ? liveState : state; const currentDispatch = live ? (action: AppAction) => { onAction?.(action); dispatch(action); } : (_action: AppAction) => {}; return ( {/* The header hosts the bar's input while it is open, so typing in a test needs the real header on the first row. */}
{/* The sheet spans the full width and can reach as far down as termHeight - 6 (panel/layout.ts), so probes live on the bottom row, the one place no sheet height can cover. */} {live && } {showQueryState && {`query:${currentState.commandBarQuery}`}} {currentState.commandBarOpen && ( {}} onCheckForUpdates={onCheckForUpdates} /> )} {onUnhandledEnter && } ); }