gloomberb 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +47 -7
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +67 -11
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/ui/host.tsx +8 -2
  124. package/src/ui/index.tsx +1 -0
  125. package/src/utils/api-client.test.ts +128 -0
  126. package/src/utils/api-client.ts +112 -7
  127. package/src/utils/fuzzy-search.ts +12 -1
  128. package/src/utils/keyboard.ts +31 -0
  129. package/src/utils/ticker-search.test.ts +2 -2
  130. package/src/utils/ticker-search.ts +48 -1
  131. package/src/version.ts +1 -1
@@ -5,6 +5,7 @@ import { join } from "path";
5
5
  import { act, useReducer } from "react";
6
6
  import { testRender } from "../../renderers/opentui/test-utils";
7
7
  import { AppPersistence } from "../../data/app-persistence";
8
+ import { TickerRepository } from "../../data/ticker-repository";
8
9
  import { AppContext, appReducer, createInitialState, PaneInstanceProvider, type AppAction } from "../../state/app-context";
9
10
  import { AssetDataRouter } from "../../sources/provider-router";
10
11
  import { cloneLayout, createDefaultConfig, type AppConfig, type BrokerInstanceConfig } from "../../types/config";
@@ -12,8 +13,10 @@ import type { DataProvider } from "../../types/data-provider";
12
13
  import type { Quote } from "../../types/financials";
13
14
  import type { TickerRecord } from "../../types/ticker";
14
15
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
16
+ import { instrumentFromTicker } from "../../market-data/request-types";
15
17
  import { createTestPluginRuntime } from "../../test-support/plugin-runtime";
16
18
  import { PluginRenderProvider, type PluginRuntimeAccess } from "../plugin-runtime";
19
+ import { PluginRegistry, setSharedMarketDataForTests, setSharedRegistryForTests } from "../registry";
17
20
  import type { BrokerAdapter } from "../../types/broker";
18
21
  import { portfolioListPlugin } from "./portfolio-list";
19
22
 
@@ -24,6 +27,7 @@ let harnessDispatch: React.Dispatch<AppAction> | null = null;
24
27
  let sharedCoordinator: MarketDataCoordinator | null = null;
25
28
  let harnessState: ReturnType<typeof createInitialState> | null = null;
26
29
  const tempPaths: string[] = [];
30
+ const tempPersistences: AppPersistence[] = [];
27
31
 
28
32
  const PortfolioPane = portfolioListPlugin.panes![0]!.component as (props: {
29
33
  paneId: string;
@@ -154,6 +158,75 @@ function createPortfolioConfigWithColumns(
154
158
  }
155
159
  return config;
156
160
  }
161
+
162
+ function createManualCollectionConfig(collectionId: string): AppConfig {
163
+ const config = createDefaultConfig("/tmp/gloomberb-portfolio-list");
164
+ const layout = {
165
+ dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
166
+ instances: [{
167
+ instanceId: TEST_PANE_ID,
168
+ paneId: "portfolio-list",
169
+ binding: { kind: "none" as const },
170
+ params: { collectionId },
171
+ }],
172
+ floating: [],
173
+ };
174
+
175
+ return {
176
+ ...config,
177
+ layout,
178
+ layouts: [{ name: "Default", layout: cloneLayout(layout) }],
179
+ };
180
+ }
181
+
182
+ function installQuickAddRegistry(provider: DataProvider): PluginRegistry {
183
+ const persistence = new AppPersistence(createTempDbPath("quick-add"));
184
+ tempPersistences.push(persistence);
185
+ const registry = new PluginRegistry(provider, new TickerRepository(persistence.tickers), persistence);
186
+ registry.getConfigFn = () => harnessState?.config ?? createDefaultConfig("/tmp/gloomberb-portfolio-list");
187
+ return registry;
188
+ }
189
+
190
+ function createQuickAddProvider(match = true): DataProvider {
191
+ return {
192
+ id: "quick-add-test",
193
+ name: "Quick Add Test",
194
+ async getTickerFinancials() {
195
+ return {
196
+ annualStatements: [],
197
+ quarterlyStatements: [],
198
+ priceHistory: [],
199
+ quote: makeQuote({ symbol: "MSFT", price: 420, change: 5.2, changePercent: 1.25, name: "Microsoft" }),
200
+ };
201
+ },
202
+ async getQuote(symbol) {
203
+ if (match && symbol === "MSFT") {
204
+ return makeQuote({ symbol: "MSFT", price: 420, change: 5.2, changePercent: 1.25, name: "Microsoft" });
205
+ }
206
+ throw new Error(`No quote for ${symbol}`);
207
+ },
208
+ async getExchangeRate() {
209
+ return 1;
210
+ },
211
+ async search(query) {
212
+ if (!match || query !== "MSFT") return [];
213
+ return [{
214
+ symbol: "MSFT",
215
+ name: "Microsoft",
216
+ exchange: "NASDAQ",
217
+ currency: "USD",
218
+ type: "STK",
219
+ }];
220
+ },
221
+ async getArticleSummary() {
222
+ return null;
223
+ },
224
+ async getPriceHistory() {
225
+ return [];
226
+ },
227
+ };
228
+ }
229
+
157
230
  function createPortfolioState(
158
231
  config: AppConfig,
159
232
  collectionId: string,
@@ -193,6 +266,7 @@ function PortfolioHarness({
193
266
  exchangeRates,
194
267
  stateMutator,
195
268
  runtime = createTestPluginRuntime(),
269
+ paneHeight = 24,
196
270
  }: {
197
271
  config: AppConfig;
198
272
  collectionId: string;
@@ -203,6 +277,7 @@ function PortfolioHarness({
203
277
  exchangeRates?: Map<string, number>;
204
278
  stateMutator?: (state: ReturnType<typeof createInitialState>) => void;
205
279
  runtime?: PluginRuntimeAccess;
280
+ paneHeight?: number;
206
281
  }) {
207
282
  const initialState = createPortfolioState(config, collectionId, expanded, {
208
283
  ticker,
@@ -224,7 +299,7 @@ function PortfolioHarness({
224
299
  paneType="portfolio-list"
225
300
  focused
226
301
  width={100}
227
- height={24}
302
+ height={paneHeight}
228
303
  />
229
304
  </PluginRenderProvider>
230
305
  </PaneInstanceProvider>
@@ -249,7 +324,12 @@ afterEach(async () => {
249
324
  harnessDispatch = null;
250
325
  sharedCoordinator = null;
251
326
  setSharedMarketDataCoordinator(null);
327
+ setSharedMarketDataForTests(undefined);
328
+ setSharedRegistryForTests(undefined);
252
329
  harnessState = null;
330
+ for (const persistence of tempPersistences.splice(0)) {
331
+ persistence.close();
332
+ }
253
333
  for (const path of tempPaths.splice(0)) {
254
334
  if (existsSync(path)) rmSync(path, { force: true });
255
335
  }
@@ -291,6 +371,151 @@ describe("PortfolioListPane cash and margin UI", () => {
291
371
  expect(pinned).toEqual([{ symbol: "AAPL", options: { floating: true, paneType: "ticker-detail" } }]);
292
372
  });
293
373
 
374
+ test("quick-add validates and adds an exact watchlist ticker", async () => {
375
+ const config = createManualCollectionConfig("watchlist");
376
+ const notifications: Array<{ type?: string; body: string }> = [];
377
+ installQuickAddRegistry(createQuickAddProvider(true));
378
+
379
+ testSetup = await testRender(
380
+ <PortfolioHarness
381
+ config={config}
382
+ collectionId="watchlist"
383
+ ticker={makeTicker({ portfolios: [], watchlists: [], positions: [] })}
384
+ runtime={createTestPluginRuntime({
385
+ notify: (notification) => notifications.push(notification),
386
+ })}
387
+ paneHeight={12}
388
+ />,
389
+ { width: 100, height: 12 },
390
+ );
391
+
392
+ await flushFrame();
393
+ await act(async () => {
394
+ testSetup!.mockInput.pressKey("a");
395
+ await testSetup!.renderOnce();
396
+ });
397
+ await act(async () => {
398
+ await testSetup!.mockInput.typeText("MSFT");
399
+ await testSetup!.renderOnce();
400
+ });
401
+ await act(async () => {
402
+ await new Promise((resolve) => setTimeout(resolve, 360));
403
+ });
404
+ await flushFrame();
405
+
406
+ const previewFrame = testSetup.captureCharFrame();
407
+ expect(previewFrame).toContain("420");
408
+ expect(previewFrame).not.toMatch(/MSFT\s+420/);
409
+ expect(previewFrame).toContain("+1.25%");
410
+
411
+ await act(async () => {
412
+ testSetup!.mockInput.pressEnter();
413
+ await Promise.resolve();
414
+ await testSetup!.renderOnce();
415
+ });
416
+ await flushFrame();
417
+
418
+ expect(harnessState?.tickers.get("MSFT")?.metadata.watchlists).toEqual(["watchlist"]);
419
+ expect(notifications.at(-1)).toMatchObject({
420
+ type: "success",
421
+ body: "Added MSFT to Watchlist.",
422
+ });
423
+ });
424
+
425
+ test("quick-add adds an exact ticker to a manual portfolio", async () => {
426
+ const config = createManualCollectionConfig("main");
427
+ const notifications: Array<{ type?: string; body: string }> = [];
428
+ installQuickAddRegistry(createQuickAddProvider(true));
429
+
430
+ testSetup = await testRender(
431
+ <PortfolioHarness
432
+ config={config}
433
+ collectionId="main"
434
+ ticker={makeTicker({ portfolios: [], watchlists: [], positions: [] })}
435
+ runtime={createTestPluginRuntime({
436
+ notify: (notification) => notifications.push(notification),
437
+ })}
438
+ paneHeight={12}
439
+ />,
440
+ { width: 100, height: 12 },
441
+ );
442
+
443
+ await flushFrame();
444
+ await act(async () => {
445
+ testSetup!.mockInput.pressKey("n");
446
+ await testSetup!.renderOnce();
447
+ });
448
+ await act(async () => {
449
+ await testSetup!.mockInput.typeText("MSFT");
450
+ await testSetup!.renderOnce();
451
+ });
452
+ await act(async () => {
453
+ await new Promise((resolve) => setTimeout(resolve, 360));
454
+ });
455
+ await flushFrame();
456
+
457
+ await act(async () => {
458
+ testSetup!.mockInput.pressEnter();
459
+ await Promise.resolve();
460
+ await testSetup!.renderOnce();
461
+ });
462
+ await flushFrame();
463
+
464
+ expect(harnessState?.tickers.get("MSFT")?.metadata.portfolios).toEqual(["main"]);
465
+ expect(notifications.at(-1)).toMatchObject({
466
+ type: "success",
467
+ body: "Added MSFT to Main Portfolio.",
468
+ });
469
+ });
470
+
471
+ test("quick-add rejects unresolved ticker input", async () => {
472
+ const config = createManualCollectionConfig("watchlist");
473
+ const notifications: Array<{ type?: string; body: string }> = [];
474
+ installQuickAddRegistry(createQuickAddProvider(false));
475
+
476
+ testSetup = await testRender(
477
+ <PortfolioHarness
478
+ config={config}
479
+ collectionId="watchlist"
480
+ ticker={makeTicker({ portfolios: [], watchlists: [], positions: [] })}
481
+ runtime={createTestPluginRuntime({
482
+ notify: (notification) => notifications.push(notification),
483
+ })}
484
+ paneHeight={12}
485
+ />,
486
+ { width: 100, height: 12 },
487
+ );
488
+
489
+ await flushFrame();
490
+ await act(async () => {
491
+ testSetup!.mockInput.pressKey("n");
492
+ await testSetup!.renderOnce();
493
+ });
494
+ await act(async () => {
495
+ await testSetup!.mockInput.typeText("NOPE");
496
+ await testSetup!.renderOnce();
497
+ });
498
+ await act(async () => {
499
+ await new Promise((resolve) => setTimeout(resolve, 360));
500
+ });
501
+ await flushFrame();
502
+
503
+ expect(testSetup.captureCharFrame()).toContain("No exact ticker match");
504
+
505
+ await act(async () => {
506
+ testSetup!.mockInput.pressEnter();
507
+ await Promise.resolve();
508
+ await testSetup!.renderOnce();
509
+ });
510
+ await flushFrame();
511
+
512
+ expect(harnessState?.tickers.has("NOPE")).toBe(false);
513
+ expect(notifications.at(-1)).toMatchObject({
514
+ type: "error",
515
+ body: "No exact ticker match",
516
+ });
517
+ });
518
+
294
519
  test("keeps non-broker portfolios unchanged", async () => {
295
520
  const config = createDefaultConfig("/tmp/gloomberb-portfolio-list");
296
521
  const layout = {
@@ -317,6 +542,43 @@ describe("PortfolioListPane cash and margin UI", () => {
317
542
  expect(frame).not.toContain("Cash & Margin");
318
543
  });
319
544
 
545
+ test("renders one-month sparkline column when price history is loaded", async () => {
546
+ const config = createPortfolioConfigWithColumns(
547
+ "broker:ibkr-flex:DU12345",
548
+ ["ticker", "price", "sparkline"],
549
+ [createBrokerInstance("flex")],
550
+ );
551
+
552
+ testSetup = await testRender(
553
+ <PortfolioHarness
554
+ config={config}
555
+ collectionId="broker:ibkr-flex:DU12345"
556
+ stateMutator={(state) => {
557
+ state.financials = new Map([[
558
+ "AAPL",
559
+ {
560
+ annualStatements: [],
561
+ quarterlyStatements: [],
562
+ quote: makeQuote(),
563
+ priceHistory: [118, 121, 119, 124, 127, 126, 130].map((close, index) => ({
564
+ date: `2026-03-${20 + index}T00:00:00Z` as unknown as Date,
565
+ close,
566
+ })),
567
+ },
568
+ ]]);
569
+ }}
570
+ />,
571
+ { width: 100, height: 12 },
572
+ );
573
+
574
+ await flushFrame();
575
+
576
+ const frame = testSetup.captureCharFrame();
577
+ expect(frame).toContain("1M");
578
+ const row = frame.split("\n").find((line) => line.includes("AAPL")) ?? "";
579
+ expect(row).toMatch(/[\u2800-\u28ff]/);
580
+ });
581
+
320
582
  test("keeps native price and avg cost while converting market value and pnl to base currency", async () => {
321
583
  const portfolioId = "broker:ibkr-flex:DU12345";
322
584
  const config = createPortfolioConfigWithColumns(
@@ -738,6 +1000,8 @@ describe("PortfolioListPane cash and margin UI", () => {
738
1000
  ["ticker", "market_cap", "pe", "forward_pe"],
739
1001
  [createBrokerInstance("flex")],
740
1002
  );
1003
+ const seededTicker = makeTicker();
1004
+ const seededQuote = makeQuote();
741
1005
  let calls = 0;
742
1006
  let resolveFinancials!: (value: {
743
1007
  annualStatements: [];
@@ -782,10 +1046,26 @@ describe("PortfolioListPane cash and margin UI", () => {
782
1046
  },
783
1047
  };
784
1048
  sharedCoordinator = new MarketDataCoordinator(provider);
1049
+ const instrument = instrumentFromTicker(seededTicker, seededTicker.metadata.ticker);
1050
+ if (!instrument) throw new Error("expected ticker instrument");
1051
+ sharedCoordinator.primeCachedFinancials([{
1052
+ instrument,
1053
+ financials: {
1054
+ annualStatements: [],
1055
+ quarterlyStatements: [],
1056
+ priceHistory: [],
1057
+ quote: seededQuote,
1058
+ },
1059
+ }]);
785
1060
  setSharedMarketDataCoordinator(sharedCoordinator);
786
1061
 
787
1062
  testSetup = await testRender(
788
- <PortfolioHarness config={config} collectionId="broker:ibkr-flex:DU12345" />,
1063
+ <PortfolioHarness
1064
+ config={config}
1065
+ collectionId="broker:ibkr-flex:DU12345"
1066
+ ticker={seededTicker}
1067
+ quote={seededQuote}
1068
+ />,
789
1069
  { width: 100, height: 12 },
790
1070
  );
791
1071
 
@@ -1,10 +1,13 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act } from "react";
2
3
  import { testRender } from "../../renderers/opentui/test-utils";
3
4
  import type { TickerFinancials } from "../../types/financials";
4
5
  import type { TickerRecord } from "../../types/ticker";
5
6
  import { createDefaultConfig } from "../../types/config";
6
7
  import { AppContext, createInitialState, PaneInstanceProvider } from "../../state/app-context";
7
8
  import { QuoteMonitorPane } from "./ticker-detail";
9
+ import { PluginRenderProvider, type PluginRuntimeAccess } from "../plugin-runtime";
10
+ import type { PinTickerOptions } from "../../types/plugin";
8
11
 
9
12
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
10
13
 
@@ -15,27 +18,57 @@ afterEach(() => {
15
18
  }
16
19
  });
17
20
 
18
- function createQuoteMonitorHarness() {
19
- const config = createDefaultConfig("/tmp/gloomberb-test");
20
- (config.layout as typeof config.layout & { docked: Array<{ instanceId: string; columnIndex: number; order: number }> }).docked = [
21
- { instanceId: "ticker-detail:main", columnIndex: 0, order: 0 },
22
- { instanceId: "quote-monitor:test", columnIndex: 1, order: 0 },
23
- ];
24
- config.layout.instances.push({
25
- instanceId: "quote-monitor:test",
26
- paneId: "quote-monitor",
27
- title: "MSFT",
28
- binding: { kind: "fixed", symbol: "MSFT" },
29
- });
21
+ interface PinTickerCall {
22
+ symbol: string;
23
+ options?: PinTickerOptions;
24
+ }
30
25
 
31
- const state = createInitialState(config);
26
+ function makeRuntime(options: {
27
+ pinCalls?: PinTickerCall[];
28
+ settingsCalls?: Array<string | undefined>;
29
+ } = {}): PluginRuntimeAccess {
30
+ return {
31
+ getMarketData: () => null,
32
+ getCapability: () => null,
33
+ getBrokerAdapter: () => null,
34
+ connectBrokerInstance: async () => {},
35
+ updateBrokerInstance: async () => {},
36
+ syncBrokerInstance: async () => {},
37
+ removeBrokerInstance: async () => {},
38
+ pinTicker: (symbol, pinOptions) => {
39
+ options.pinCalls?.push({ symbol, options: pinOptions });
40
+ },
41
+ navigateTicker: () => {},
42
+ selectTicker: () => {},
43
+ switchTab: () => {},
44
+ switchPanel: () => {},
45
+ openCommandBar: () => {},
46
+ showPane: () => {},
47
+ hidePane: () => {},
48
+ openPaneSettings: (paneId) => {
49
+ options.settingsCalls?.push(paneId);
50
+ },
51
+ openPluginCommandWorkflow: () => {},
52
+ notify: () => {},
53
+ subscribeResumeState: () => () => {},
54
+ getResumeState: () => null,
55
+ setResumeState: () => {},
56
+ deleteResumeState: () => {},
57
+ getConfigState: () => null,
58
+ setConfigState: async () => {},
59
+ setConfigStates: async () => {},
60
+ deleteConfigState: async () => {},
61
+ getConfigStateKeys: () => [],
62
+ };
63
+ }
32
64
 
33
- const ticker: TickerRecord = {
65
+ function makeTicker(symbol: string, name: string): TickerRecord {
66
+ return {
34
67
  metadata: {
35
- ticker: "MSFT",
68
+ ticker: symbol,
36
69
  exchange: "NASDAQ",
37
70
  currency: "USD",
38
- name: "Microsoft",
71
+ name,
39
72
  portfolios: [],
40
73
  watchlists: [],
41
74
  positions: [],
@@ -43,28 +76,71 @@ function createQuoteMonitorHarness() {
43
76
  tags: [],
44
77
  },
45
78
  };
79
+ }
46
80
 
47
- const financials: TickerFinancials = {
81
+ function makeFinancials(symbol: string, price: number, change: number, changePercent: number): TickerFinancials {
82
+ return {
48
83
  quote: {
49
- symbol: "MSFT",
50
- price: 356.15,
84
+ symbol,
85
+ price,
51
86
  currency: "USD",
52
- change: -9.82,
53
- changePercent: -2.68,
87
+ change,
88
+ changePercent,
54
89
  lastUpdated: Date.now(),
55
90
  },
56
91
  annualStatements: [],
57
92
  quarterlyStatements: [],
58
- priceHistory: [],
93
+ priceHistory: Array.from({ length: 12 }, (_, index) => ({
94
+ date: new Date(Date.UTC(2026, 0, index + 1)),
95
+ close: price - 6 + index,
96
+ })),
59
97
  };
98
+ }
99
+
100
+ function createQuoteMonitorHarness(options: {
101
+ symbols?: string[];
102
+ pinCalls?: PinTickerCall[];
103
+ settingsCalls?: Array<string | undefined>;
104
+ } = {}) {
105
+ const symbols = options.symbols ?? ["MSFT"];
106
+ const config = createDefaultConfig("/tmp/gloomberb-test");
107
+ (config.layout as typeof config.layout & { docked: Array<{ instanceId: string; columnIndex: number; order: number }> }).docked = [
108
+ { instanceId: "ticker-detail:main", columnIndex: 0, order: 0 },
109
+ { instanceId: "quote-monitor:test", columnIndex: 1, order: 0 },
110
+ ];
111
+ config.layout.instances.push({
112
+ instanceId: "quote-monitor:test",
113
+ paneId: "quote-monitor",
114
+ title: symbols.join(" · "),
115
+ binding: { kind: "fixed", symbol: symbols[0]! },
116
+ settings: {
117
+ symbol: symbols[0]!,
118
+ symbols,
119
+ symbolsText: symbols.join(", "),
120
+ },
121
+ });
122
+
123
+ const state = createInitialState(config);
60
124
 
61
- state.tickers = new Map([["MSFT", ticker]]);
62
- state.financials = new Map([["MSFT", financials]]);
125
+ const tickers = [
126
+ makeTicker("MSFT", "Microsoft"),
127
+ makeTicker("AAPL", "Apple"),
128
+ ];
129
+ state.tickers = new Map(tickers.map((ticker) => [ticker.metadata.ticker, ticker]));
130
+ state.financials = new Map([
131
+ ["MSFT", makeFinancials("MSFT", 356.15, -9.82, -2.68)],
132
+ ["AAPL", makeFinancials("AAPL", 202.12, 3.05, 1.53)],
133
+ ]);
63
134
 
64
135
  return (
65
136
  <AppContext value={{ state, dispatch: () => {} }}>
66
137
  <PaneInstanceProvider paneId="quote-monitor:test">
67
- <QuoteMonitorPane paneId="quote-monitor:test" paneType="quote-monitor" focused width={72} height={7} />
138
+ <PluginRenderProvider pluginId="ticker-detail" runtime={makeRuntime({
139
+ pinCalls: options.pinCalls,
140
+ settingsCalls: options.settingsCalls,
141
+ })}>
142
+ <QuoteMonitorPane paneId="quote-monitor:test" paneType="quote-monitor" focused width={72} height={7} />
143
+ </PluginRenderProvider>
68
144
  </PaneInstanceProvider>
69
145
  </AppContext>
70
146
  );
@@ -84,6 +160,63 @@ describe("QuoteMonitorPane", () => {
84
160
  expect(frame).toContain("$356.15");
85
161
  expect(frame).toContain("-2.68%");
86
162
  expect(frame).toContain("-9.82");
163
+ expect(frame).toMatch(/[⠁-⣿]/);
87
164
  });
88
165
 
166
+ test("renders multiple configured tickers", async () => {
167
+ testSetup = await testRender(createQuoteMonitorHarness({ symbols: ["MSFT", "AAPL"] }), {
168
+ width: 72,
169
+ height: 8,
170
+ });
171
+
172
+ await testSetup.renderOnce();
173
+
174
+ const frame = testSetup.captureCharFrame();
175
+ expect(frame).toContain("MSFT");
176
+ expect(frame).toContain("AAPL");
177
+ expect(frame).toContain("$356.15");
178
+ expect(frame).toContain("$202.12");
179
+ });
180
+
181
+ test("opens a ticker detail pane on the second card click", async () => {
182
+ const pinCalls: PinTickerCall[] = [];
183
+ testSetup = await testRender(createQuoteMonitorHarness({ pinCalls }), {
184
+ width: 72,
185
+ height: 7,
186
+ });
187
+
188
+ await testSetup.renderOnce();
189
+ const frame = testSetup.captureCharFrame();
190
+ const row = frame.split("\n").findIndex((line) => line.includes("MSFT"));
191
+ expect(row).toBeGreaterThanOrEqual(0);
192
+
193
+ await act(async () => {
194
+ await testSetup!.mockMouse.click(3, row);
195
+ await testSetup!.renderOnce();
196
+ await testSetup!.mockMouse.click(3, row);
197
+ await testSetup!.renderOnce();
198
+ });
199
+
200
+ expect(pinCalls).toEqual([{
201
+ symbol: "MSFT",
202
+ options: { paneType: "ticker-detail", floating: true },
203
+ }]);
204
+ });
205
+
206
+ test("opens pane settings when t is pressed", async () => {
207
+ const settingsCalls: Array<string | undefined> = [];
208
+ testSetup = await testRender(createQuoteMonitorHarness({ settingsCalls }), {
209
+ width: 72,
210
+ height: 7,
211
+ });
212
+
213
+ await testSetup.renderOnce();
214
+
215
+ await act(async () => {
216
+ testSetup!.mockInput.pressKey("t");
217
+ await testSetup!.renderOnce();
218
+ });
219
+
220
+ expect(settingsCalls).toEqual(["quote-monitor:test"]);
221
+ });
89
222
  });