import { afterEach, describe, expect, test } from "bun:test"; import { act, useEffect, useReducer, type SetStateAction } from "react"; import { testRender } from "../../renderers/opentui/test-utils"; import { AppContext, PaneInstanceProvider, appReducer, createInitialState, } from "../../state/app/context"; import { createConfigBackedTestPluginRuntime, createTestPluginRuntime } from "../../test-support/plugin-runtime"; import { createDefaultConfig } from "../../types/config"; import { PluginRenderProvider, deletePluginPaneStateValue, getPluginPaneStateValue, setPluginPaneStateValue, usePluginAppActions, useDebouncedPluginPaneState, usePluginConfigState, usePluginPaneActions, usePluginPaneState, usePluginState, usePluginTickerActions, useSetPluginConfigStates, } from "./index"; let testSetup: Awaited> | undefined; afterEach(() => { if (testSetup) { testSetup.renderer.destroy(); testSetup = undefined; } }); describe("plugin runtime helpers", () => { test("reads, writes, and deletes nested pane state under the plugin namespace", () => { const initial = { pluginState: { news: { selected: 2, }, }, }; expect(getPluginPaneStateValue(initial, "news", "selected", 0)).toBe(2); expect(getPluginPaneStateValue(initial, "news", "missing", 0)).toBe(0); expect(setPluginPaneStateValue(initial, "news", "expanded", true)).toEqual({ news: { selected: 2, expanded: true, }, }); expect(deletePluginPaneStateValue(initial, "news", "selected")).toBeUndefined(); }); }); describe("plugin runtime hooks", () => { test("debounces pane state commits", async () => { const config = createDefaultConfig("/tmp/gloomberb-plugin-runtime-debounce"); const stateRef: { current: ReturnType | null } = { current: null }; let setSelection: ((value: SetStateAction) => void) | null = null; const runtime = createTestPluginRuntime(); function HookHarness() { const [state, dispatch] = useReducer(appReducer, createInitialState(config)); stateRef.current = state; return ( ); } function HookProbe() { const [selection, setDebouncedSelection] = useDebouncedPluginPaneState("selectedRowKey", "row-a", 20); setSelection = setDebouncedSelection; return {selection}; } testSetup = await testRender(, { width: 40, height: 5 }); await act(async () => { await testSetup!.renderOnce(); }); await act(async () => { setSelection?.("row-b"); await testSetup!.renderOnce(); await testSetup!.renderOnce(); }); expect( stateRef.current?.paneState["prediction-markets:main"]?.pluginState?.["prediction-markets"]?.selectedRowKey, ).toBeUndefined(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 30)); await testSetup!.renderOnce(); }); expect( stateRef.current?.paneState["prediction-markets:main"]?.pluginState?.["prediction-markets"]?.selectedRowKey, ).toBe("row-b"); }); test("updates pane, global resume, and config state through the plugin hooks", async () => { const config = createDefaultConfig("/tmp/gloomberb-plugin-runtime"); const stateRef: { current: ReturnType | null } = { current: null }; const dispatchRef: { current: React.Dispatch | null } = { current: null }; const runtime = createConfigBackedTestPluginRuntime({ getConfig: () => stateRef.current?.config, setConfig: (config) => dispatchRef.current?.({ type: "SET_CONFIG", config }), }); function HookHarness() { const [state, dispatch] = useReducer(appReducer, createInitialState(config)); stateRef.current = state; dispatchRef.current = dispatch; return ( ); } function HookProbe() { const [paneSelection, setPaneSelection] = usePluginPaneState("selectedIdx", 0); const [venueScope, setVenueScope] = usePluginPaneState("venueScope", "all"); const [provider, setProvider] = usePluginState("provider", "claude"); const [mode, setMode] = usePluginConfigState("displayMode", "compact"); const [layoutMode] = usePluginConfigState("layoutMode", "default"); const setConfigStates = useSetPluginConfigStates(); useEffect(() => { if (paneSelection === 0) setPaneSelection(3); if (venueScope === "all") setVenueScope("kalshi"); if (provider === "claude") setProvider("codex"); if (mode === "compact") { setMode("expanded"); } else if (layoutMode === "default") { setConfigStates({ layoutMode: "wide", density: "dense" }); } }, [ layoutMode, mode, paneSelection, provider, setConfigStates, setMode, setPaneSelection, setProvider, setVenueScope, venueScope, ]); return {`${paneSelection}|${venueScope}|${provider}|${mode}|${layoutMode}`}; } testSetup = await testRender(, { width: 40, height: 5 }); await act(async () => { await testSetup!.renderOnce(); await testSetup!.renderOnce(); await testSetup!.renderOnce(); }); const frame = testSetup.captureCharFrame(); expect(frame).toContain("3|kalshi|codex|expanded|wide"); expect(stateRef.current?.paneState["portfolio-list:main"]?.pluginState).toEqual({ news: { selectedIdx: 3, venueScope: "kalshi", }, }); expect(stateRef.current?.config.pluginConfig.news).toEqual({ density: "dense", displayMode: "expanded", layoutMode: "wide", }); expect(runtime.getResumeState("news", "provider")).toBe("codex"); }); test("exposes renderer app actions through the plugin hook", async () => { const calls: string[] = []; let actions: ReturnType | null = null; const runtime = createTestPluginRuntime({ openCommandBar(query?: string) { calls.push(`command:${query ?? ""}`); }, showPane(paneId: string) { calls.push(`show:${paneId}`); }, createPaneFromTemplate(templateId: string) { calls.push(`create:${templateId}`); }, hidePane(paneId: string) { calls.push(`hide:${paneId}`); }, openPluginCommandWorkflow(commandId: string) { calls.push(`workflow:${commandId}`); }, notify(notification) { calls.push(`notify:${notification.body}`); }, }); function HookProbe() { actions = usePluginAppActions(); return actions; } testSetup = await testRender( , { width: 40, height: 5 }, ); await act(async () => { await testSetup!.renderOnce(); }); actions?.openCommandBar("PL "); actions?.showPane("debug"); actions?.createPaneFromTemplate("twitter-feed-pane"); actions?.hidePane("chat"); actions?.openPluginCommandWorkflow("set-alert"); actions?.notify({ body: "Saved", type: "success" }); expect(calls).toEqual([ "command:PL ", "show:debug", "create:twitter-feed-pane", "hide:chat", "workflow:set-alert", "notify:Saved", ]); }); test("scopes ticker navigation to the rendering pane", async () => { const calls: string[] = []; let actions: ReturnType | null = null; const runtime = createTestPluginRuntime({ navigateTicker(symbol: string, options?: { sourcePaneId?: string | null }) { calls.push(`${symbol}:${options?.sourcePaneId ?? ""}`); }, }); function HookProbe() { actions = usePluginTickerActions(); return ticker-actions; } testSetup = await testRender( , { width: 40, height: 5 }, ); await act(async () => { await testSetup!.renderOnce(); }); actions?.navigateTicker("MSFT"); expect(calls).toEqual(["MSFT:comparison-chart:main"]); }); test("exposes renderer pane actions through the plugin hook", async () => { const calls: string[] = []; let actions: ReturnType | null = null; const runtime = createTestPluginRuntime({ selectTicker(symbol: string, paneId?: string) { calls.push(`select:${symbol}:${paneId ?? ""}`); }, switchTab(tabId: string, paneId?: string) { calls.push(`tab:${tabId}:${paneId ?? ""}`); }, switchPanel(panel: "left" | "right") { calls.push(`panel:${panel}`); }, }); function HookProbe() { actions = usePluginPaneActions(); return pane-actions; } testSetup = await testRender( , { width: 40, height: 5 }, ); await act(async () => { await testSetup!.renderOnce(); }); actions?.selectTicker("AAPL", "ibkr:main"); actions?.switchTab("ibkr-trade", "ibkr:main"); actions?.switchPanel("right"); expect(calls).toEqual([ "select:AAPL:ibkr:main", "tab:ibkr-trade:ibkr:main", "panel:right", ]); }); });