gloomberb 0.9.3 → 0.9.4

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 (137) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/app/runtime/desktop-menu.ts +5 -1
  4. package/src/app/runtime/ticker-refresh.ts +4 -1
  5. package/src/app.tsx +10 -1
  6. package/src/brokers/account-cache.ts +1 -1
  7. package/src/brokers/sync-broker-instance.test.ts +107 -0
  8. package/src/brokers/sync-broker-instance.ts +22 -10
  9. package/src/cli/desktop-pane-shot.ts +2 -1
  10. package/src/cli/pane-functions/screenshot.ts +13 -1
  11. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  12. package/src/components/chart/bar-chart-renderer.ts +226 -27
  13. package/src/components/chart/core/pointer.ts +32 -6
  14. package/src/components/chart/price-axis-labels.tsx +3 -1
  15. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  16. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  17. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  18. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  19. package/src/components/chart/static/chart/surface.tsx +167 -24
  20. package/src/components/chart/stock-chart/index.test.ts +25 -0
  21. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  22. package/src/components/layout/detached-pane-shell.tsx +6 -3
  23. package/src/components/layout/floating-pane.tsx +3 -2
  24. package/src/components/layout/pane/content.tsx +20 -8
  25. package/src/components/layout/pane/footer/registration.tsx +6 -2
  26. package/src/components/layout/pane/frame.tsx +4 -4
  27. package/src/components/layout/pane/header.tsx +40 -4
  28. package/src/components/layout/pane/index.tsx +3 -1
  29. package/src/components/layout/pane/sizing.test.ts +43 -0
  30. package/src/components/layout/pane/sizing.ts +13 -4
  31. package/src/components/layout/shell/cursor-occlusion.ts +196 -0
  32. package/src/components/layout/shell/drag/runtime.ts +9 -2
  33. package/src/components/layout/shell/index.test.tsx +92 -2
  34. package/src/components/layout/shell/index.tsx +37 -2
  35. package/src/components/layout/shell/layout-state.test.ts +33 -0
  36. package/src/components/layout/shell/layout-state.ts +4 -9
  37. package/src/components/layout/shell/pane/layers.tsx +8 -2
  38. package/src/components/layout/shell/visible-layout.ts +17 -0
  39. package/src/components/layout/status-bar.test.tsx +6 -1
  40. package/src/components/layout/status-bar.tsx +5 -1
  41. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  42. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  43. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  44. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  45. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  46. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  47. package/src/components/table-view-shared.tsx +12 -4
  48. package/src/components/ui/data-table/opentui/index.tsx +25 -12
  49. package/src/components/ui/data-table/types.ts +4 -0
  50. package/src/components/ui/fields.tsx +10 -3
  51. package/src/components/ui/page-stack-view.tsx +6 -2
  52. package/src/components/ui/table-layout.test.ts +25 -0
  53. package/src/components/ui/table-layout.ts +22 -6
  54. package/src/components/ui/tabs.tsx +26 -10
  55. package/src/components/ui/ui.test.tsx +87 -0
  56. package/src/core/state/app/layout-reducer.ts +2 -2
  57. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  58. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  59. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  60. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  61. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  62. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  63. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  64. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  65. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  66. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  67. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  68. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  69. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  70. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  71. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  72. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  73. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  74. package/src/plugins/builtin/options/view.test.tsx +141 -4
  75. package/src/plugins/builtin/options/view.tsx +76 -15
  76. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  77. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  78. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  79. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -0
  80. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  81. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  82. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  83. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  84. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  85. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  86. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  87. package/src/plugins/catalog-ui.ts +2 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  89. package/src/plugins/ibkr/config.test.ts +31 -2
  90. package/src/plugins/ibkr/config.ts +9 -2
  91. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  92. package/src/plugins/ibkr/flex/index.ts +30 -0
  93. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  94. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  95. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  96. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  97. package/src/plugins/pane-manager/gridlock.ts +13 -5
  98. package/src/plugins/pane-manager/layout-state.ts +25 -0
  99. package/src/plugins/pane-manager.test.ts +33 -0
  100. package/src/plugins/pane-manager.ts +6 -1
  101. package/src/plugins/prediction-markets/chart.tsx +10 -4
  102. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  103. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  104. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  105. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  106. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  107. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  108. package/src/plugins/prediction-markets/pane.test.tsx +43 -0
  109. package/src/plugins/prediction-markets/pane.tsx +50 -22
  110. package/src/plugins/prediction-markets/test-helpers.tsx +41 -23
  111. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  112. package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
  113. package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
  114. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  115. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  116. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  117. package/src/renderers/electrobun/view/styles.css +11 -0
  118. package/src/renderers/opentui/host.tsx +17 -1
  119. package/src/renderers/opentui/ui-host.tsx +57 -25
  120. package/src/sources/provider-router/batches.ts +16 -2
  121. package/src/sources/provider-router/financial-routes.ts +5 -0
  122. package/src/sources/provider-router/financials.ts +11 -23
  123. package/src/sources/provider-router/index.test.ts +189 -0
  124. package/src/sources/provider-router/primary.ts +3 -2
  125. package/src/sources/sec-edgar.test.ts +83 -0
  126. package/src/sources/sec-edgar.ts +231 -0
  127. package/src/sources/yahoo-finance.test.ts +51 -0
  128. package/src/sources/yahoo-finance.ts +49 -1
  129. package/src/state/app/context/index.test.tsx +5 -8
  130. package/src/types/broker.ts +1 -0
  131. package/src/types/config.ts +10 -0
  132. package/src/types/trading.ts +2 -0
  133. package/src/ui/host.tsx +16 -0
  134. package/src/ui/index.tsx +2 -0
  135. package/src/utils/financial-statements.ts +23 -0
  136. package/src/version.ts +1 -1
  137. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
package/README.md CHANGED
@@ -60,6 +60,7 @@ Open command mode with `Ctrl+P`, then type a command. Press `` ` `` to open tick
60
60
  | `TOP` | Ranked market stories |
61
61
  | `MOST` | Market movers |
62
62
  | `PF` | Portfolios and watchlists |
63
+ | `KELLY AAPL` | Position sizing |
63
64
  | `HELP` | Full in-app shortcut list |
64
65
 
65
66
  ## What It Does
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "Market research and portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -54,7 +54,11 @@ export function useDesktopApplicationMenuRuntime({
54
54
  break;
55
55
  case "layout-gridlock": {
56
56
  const { width, height } = pluginRegistry.getTermSizeFn();
57
- pluginRegistry.updateLayoutFn(gridlockAllPanes(stateRef.current.config.layout, { x: 0, y: 0, width, height }));
57
+ pluginRegistry.updateLayoutFn(gridlockAllPanes(
58
+ stateRef.current.config.layout,
59
+ { x: 0, y: 0, width, height },
60
+ pluginRegistry.panes,
61
+ ));
58
62
  notifyGridlockComplete(pluginRegistry.notify.bind(pluginRegistry), () => {
59
63
  dispatch({ type: "UNDO_LAYOUT" });
60
64
  });
@@ -164,7 +164,10 @@ export function useTickerRefreshRuntime({
164
164
  dispatch({ type: "SET_REFRESHING", symbol, refreshing: true });
165
165
  }
166
166
  try {
167
- const entries = await marketData.loadSnapshotsBatch(instrumentEntries.map((entry) => entry.instrument));
167
+ const entries = await marketData.loadSnapshotsBatch(
168
+ instrumentEntries.map((entry) => entry.instrument),
169
+ { forceRefresh: true },
170
+ );
168
171
  entries.forEach((entry, index) => {
169
172
  const ticker = instrumentEntries[index]?.ticker;
170
173
  const data = entry.data ?? entry.lastGoodData;
package/src/app.tsx CHANGED
@@ -60,7 +60,16 @@ interface AppInnerProps {
60
60
  function ThemedAppRoot({ children }: { children: ReactNode }) {
61
61
  const themeColors = useThemeColors();
62
62
  return (
63
- <Box flexDirection="column" flexGrow={1} backgroundColor={themeColors.bg}>
63
+ <Box
64
+ flexDirection="column"
65
+ flexGrow={1}
66
+ flexShrink={1}
67
+ flexBasis={0}
68
+ minWidth={0}
69
+ minHeight={0}
70
+ overflow="hidden"
71
+ backgroundColor={themeColors.bg}
72
+ >
64
73
  {children}
65
74
  </Box>
66
75
  );
@@ -124,7 +124,7 @@ export function loadPersistedBrokerAccountMap(
124
124
 
125
125
  for (const instance of brokerInstances) {
126
126
  const broker = brokers.get(instance.brokerType);
127
- if (!broker?.listAccounts) continue;
127
+ if (!broker?.listAccounts && !broker?.importPortfolioSnapshot) continue;
128
128
  const accounts = loadPersistedBrokerAccounts(resources, instance, broker);
129
129
  if (!accounts || accounts.length === 0) continue;
130
130
  accountMap[instance.id] = accounts;
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
2
2
  import { createDefaultConfig, type BrokerInstanceConfig } from "../types/config";
3
3
  import type { BrokerAdapter } from "../types/broker";
4
4
  import type { TickerRecord } from "../types/ticker";
5
+ import { AppPersistence } from "../data/app-persistence";
6
+ import { loadPersistedBrokerAccounts, persistBrokerAccounts } from "./account-cache";
5
7
  import {
6
8
  restoreBrokerPortfoliosFromTickerPositions,
7
9
  syncBrokerInstance,
@@ -164,6 +166,111 @@ describe("syncBrokerInstance", () => {
164
166
  ]);
165
167
  });
166
168
 
169
+ test("imports account and position data from one broker portfolio snapshot", async () => {
170
+ const instance = createBrokerInstance();
171
+ const config = {
172
+ ...createDefaultConfig("/tmp/gloomberb-sync-broker-snapshot"),
173
+ portfolios: [],
174
+ brokerInstances: [instance],
175
+ };
176
+ const tickerRepository = createTickerRepository();
177
+ const persistence = new AppPersistence(":memory:");
178
+ let listAccountsCalled = false;
179
+ let importPositionsCalled = false;
180
+ const broker: BrokerAdapter = {
181
+ ...createDemoBroker(),
182
+ listAccounts: async () => {
183
+ listAccountsCalled = true;
184
+ return [];
185
+ },
186
+ importPositions: async () => {
187
+ importPositionsCalled = true;
188
+ return [];
189
+ },
190
+ importPortfolioSnapshot: async () => ({
191
+ accounts: [{
192
+ accountId: "ACC-1",
193
+ name: "Snapshot Account",
194
+ currency: "USD",
195
+ netLiquidation: 125_000,
196
+ grossPositionValue: 175_000,
197
+ totalCashValue: -50_000,
198
+ }],
199
+ positions: [{
200
+ ticker: "AAPL",
201
+ exchange: "NASDAQ",
202
+ shares: 12,
203
+ avgCost: 180,
204
+ currency: "USD",
205
+ accountId: "ACC-1",
206
+ name: "Apple Inc.",
207
+ assetCategory: "STK",
208
+ }],
209
+ }),
210
+ };
211
+
212
+ try {
213
+ const result = await syncBrokerInstance({
214
+ config,
215
+ instanceId: "demo-broker",
216
+ brokers: new Map([["demo", broker]]),
217
+ tickerRepository: tickerRepository as any,
218
+ resources: persistence.resources,
219
+ });
220
+
221
+ expect(listAccountsCalled).toBe(false);
222
+ expect(importPositionsCalled).toBe(false);
223
+ expect(result.brokerAccounts[0]?.netLiquidation).toBe(125_000);
224
+ expect(result.positions).toHaveLength(1);
225
+ expect(loadPersistedBrokerAccounts(persistence.resources, instance, broker)).toEqual(result.brokerAccounts);
226
+ } finally {
227
+ persistence.close();
228
+ }
229
+ });
230
+
231
+ test("preserves the last account snapshot when a broker portfolio snapshot fails", async () => {
232
+ const instance = createBrokerInstance();
233
+ const config = {
234
+ ...createDefaultConfig("/tmp/gloomberb-sync-broker-snapshot-failure"),
235
+ portfolios: [],
236
+ brokerInstances: [instance],
237
+ };
238
+ const tickerRepository = createTickerRepository();
239
+ const persistence = new AppPersistence(":memory:");
240
+ const broker: BrokerAdapter = {
241
+ ...createDemoBroker(),
242
+ importPortfolioSnapshot: async () => {
243
+ throw new Error("account snapshot unavailable");
244
+ },
245
+ };
246
+
247
+ try {
248
+ persistBrokerAccounts(persistence.resources, instance, broker, [{
249
+ accountId: "ACC-1",
250
+ name: "Stale Account",
251
+ currency: "USD",
252
+ netLiquidation: 99_000,
253
+ }]);
254
+
255
+ await expect(syncBrokerInstance({
256
+ config,
257
+ instanceId: "demo-broker",
258
+ brokers: new Map([["demo", broker]]),
259
+ tickerRepository: tickerRepository as any,
260
+ resources: persistence.resources,
261
+ })).rejects.toThrow("account snapshot unavailable");
262
+
263
+ expect(loadPersistedBrokerAccounts(persistence.resources, instance, broker)).toEqual([{
264
+ accountId: "ACC-1",
265
+ name: "Stale Account",
266
+ currency: "USD",
267
+ netLiquidation: 99_000,
268
+ }]);
269
+ } finally {
270
+ persistence.close();
271
+ }
272
+ });
273
+
167
274
  test("preserves broker portfolios across sequential profile syncs", async () => {
168
275
  const config = {
169
276
  ...createDefaultConfig("/tmp/gloomberb-sync-broker-instances"),
@@ -131,17 +131,30 @@ export async function syncBrokerInstance({
131
131
  const tickers = await loadTickerMap(tickerRepository, existingTickers);
132
132
 
133
133
  let brokerAccounts: BrokerAccount[] = [];
134
- if (broker.listAccounts) {
135
- try {
136
- brokerAccounts = await broker.listAccounts(instance);
137
- if (resources) {
138
- try {
139
- persistBrokerAccounts(resources, instance, broker, brokerAccounts);
140
- } catch {}
134
+ let positions: BrokerPosition[];
135
+ if (broker.importPortfolioSnapshot) {
136
+ const snapshot = await broker.importPortfolioSnapshot(instance);
137
+ brokerAccounts = snapshot.accounts;
138
+ positions = snapshot.positions;
139
+ if (resources) {
140
+ try {
141
+ persistBrokerAccounts(resources, instance, broker, brokerAccounts);
142
+ } catch {}
143
+ }
144
+ } else {
145
+ if (broker.listAccounts) {
146
+ try {
147
+ brokerAccounts = await broker.listAccounts(instance);
148
+ if (resources) {
149
+ try {
150
+ persistBrokerAccounts(resources, instance, broker, brokerAccounts);
151
+ } catch {}
152
+ }
153
+ } catch (error) {
154
+ throw error;
141
155
  }
142
- } catch {
143
- brokerAccounts = [];
144
156
  }
157
+ positions = await broker.importPositions(instance);
145
158
  }
146
159
 
147
160
  const accountMetadata = new Map(
@@ -154,7 +167,6 @@ export async function syncBrokerInstance({
154
167
  ]),
155
168
  );
156
169
 
157
- const positions = await broker.importPositions(instance);
158
170
  const syncedAt = Date.now();
159
171
 
160
172
  let nextConfig = config;
@@ -3,7 +3,7 @@ import { tmpdir } from "os";
3
3
  import { join } from "path";
4
4
  import { pathToFileURL } from "url";
5
5
  import type { AppConfig } from "../types/config";
6
- import type { TickerFinancials } from "../types/financials";
6
+ import type { OptionsChain, TickerFinancials } from "../types/financials";
7
7
  import type { TickerRecord } from "../types/ticker";
8
8
  import type { PaneRuntimeState } from "../core/state/app/state";
9
9
  import {
@@ -20,6 +20,7 @@ export interface DesktopPaneShotPayload {
20
20
  heightPx: number;
21
21
  tickers: TickerRecord[];
22
22
  financials: Array<[string, TickerFinancials]>;
23
+ optionsChains: Array<[string, OptionsChain]>;
23
24
  paneState: Record<string, PaneRuntimeState>;
24
25
  }
25
26
 
@@ -1,7 +1,7 @@
1
1
  import { dirname, resolve } from "path";
2
2
  import { mkdir } from "fs/promises";
3
3
  import type { PaneRuntimeState } from "../../core/state/app/state";
4
- import type { TickerFinancials } from "../../types/financials";
4
+ import type { OptionsChain, TickerFinancials } from "../../types/financials";
5
5
  import type { TickerRecord } from "../../types/ticker";
6
6
  import { slugifyName } from "../../utils/slugify";
7
7
  import { renderDesktopPaneScreenshot, type DesktopPaneShotPayload } from "../desktop-pane-shot";
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  const DESKTOP_CELL_WIDTH_PX = 8;
20
20
  const DESKTOP_CELL_HEIGHT_PX = 18;
21
+ const OPTIONS_PANE_ID = "options";
21
22
 
22
23
  export function defaultScreenshotPath(resolved: ResolvedPaneFunction, rawArg: string): string {
23
24
  const suffix = slugifyName([resolved.token, rawArg].filter(Boolean).join("-"), "pane");
@@ -70,11 +71,21 @@ async function buildDesktopShotPayload(
70
71
 
71
72
  const tickers: TickerRecord[] = [];
72
73
  const financials: Array<[string, TickerFinancials]> = [];
74
+ const optionsChains: Array<[string, OptionsChain]> = [];
75
+ const includeOptionsChains = resolved.pane.id === OPTIONS_PANE_ID || resolved.template?.paneId === OPTIONS_PANE_ID;
73
76
  for (const symbol of collectShotSymbols(resolved, rawArg)) {
74
77
  const entry = await fetchTickerFinancials(context, symbol);
75
78
  const data = await withShotPriceHistory(context, symbol, entry.tickerFile, entry.financials);
76
79
  tickers.push(entry.tickerFile ?? createFallbackTicker(symbol, data, context));
77
80
  financials.push([symbol, data]);
81
+ if (includeOptionsChains && context.dataProvider.getOptionsChain) {
82
+ const exchange = entry.tickerFile?.metadata.exchange
83
+ ?? data.quote?.listingExchangeName
84
+ ?? data.quote?.exchangeName
85
+ ?? "";
86
+ const chain = await context.dataProvider.getOptionsChain(symbol, exchange);
87
+ optionsChains.push([symbol, chain]);
88
+ }
78
89
  }
79
90
 
80
91
  return {
@@ -86,6 +97,7 @@ async function buildDesktopShotPayload(
86
97
  heightPx,
87
98
  tickers,
88
99
  financials,
100
+ optionsChains,
89
101
  paneState,
90
102
  };
91
103
  }
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { buildBarChartScene, renderBarChart, renderNativeBarChart } from "./bar-chart-renderer";
2
+ import {
3
+ buildBarChartScene,
4
+ renderBarChart,
5
+ renderBarChartAxis,
6
+ renderBarChartYAxis,
7
+ renderNativeBarChart,
8
+ resolveNativeBarPixelBounds,
9
+ resolveBarChartHover,
10
+ } from "./bar-chart-renderer";
3
11
 
4
12
  const series = [
5
13
  {
@@ -27,27 +35,124 @@ describe("bar chart renderer", () => {
27
35
  const scene = buildBarChartScene(series, {
28
36
  width: 24,
29
37
  height: 8,
30
- colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666" },
38
+ colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
31
39
  });
32
40
 
33
41
  expect(scene).not.toBeNull();
34
42
  expect(scene!.categories).toEqual(["2024", "2025"]);
35
43
  expect(scene!.bars).toHaveLength(4);
36
44
  expect(scene!.zeroRow).toBeGreaterThanOrEqual(0);
45
+ expect(scene!.bars.find((bar) => bar.value < 0)?.color).toBe("#ff5555");
37
46
  });
38
47
 
39
48
  test("renders text fallback and native bitmap output", () => {
40
49
  const scene = buildBarChartScene(series, {
41
50
  width: 24,
42
51
  height: 8,
43
- colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666" },
52
+ colors: {
53
+ bgColor: "#000000",
54
+ gridColor: "#333333",
55
+ axisColor: "#666666",
56
+ negativeColor: "#ff5555",
57
+ hoverColor: "#ffffff",
58
+ },
44
59
  })!;
45
60
 
46
61
  expect(renderBarChart(scene).join("\n")).toContain("█");
47
62
 
48
63
  const bitmap = renderNativeBarChart(scene, 96, 48);
64
+ const negativePixel = Array.from({ length: bitmap.pixels.length / 4 }).some((_, pixelIndex) => {
65
+ const index = pixelIndex * 4;
66
+ return bitmap.pixels[index]! > 200 && bitmap.pixels[index + 1]! < 100 && bitmap.pixels[index + 2]! < 100;
67
+ });
49
68
  expect(bitmap.width).toBe(96);
50
69
  expect(bitmap.height).toBe(48);
51
70
  expect(bitmap.pixels.some((value) => value > 0)).toBe(true);
71
+ expect(negativePixel).toBe(true);
72
+
73
+ const hover = resolveBarChartHover(scene, scene.bars[0]!.x);
74
+ expect(renderBarChart(scene, hover).join("\n")).toContain("▓");
75
+ const hoverBitmap = renderNativeBarChart(scene, 96, 48, hover);
76
+ const hoverPixel = Array.from({ length: hoverBitmap.pixels.length / 4 }).some((_, pixelIndex) => {
77
+ const index = pixelIndex * 4;
78
+ return hoverBitmap.pixels[index]! > 240 && hoverBitmap.pixels[index + 1]! > 240 && hoverBitmap.pixels[index + 2]! > 240;
79
+ });
80
+ expect(hoverBitmap.pixels).not.toEqual(bitmap.pixels);
81
+ expect(hoverPixel).toBe(true);
82
+ });
83
+
84
+ test("renders y-axis labels and resolves hover targets", () => {
85
+ const scene = buildBarChartScene(series, {
86
+ width: 24,
87
+ height: 8,
88
+ colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666" },
89
+ })!;
90
+
91
+ const yAxis = renderBarChartYAxis(scene, 8, (value) => String(Math.round(value)));
92
+ const labelCount = yAxis.filter((line) => line.trim().length > 0).length;
93
+ expect(labelCount).toBeGreaterThanOrEqual(5);
94
+ expect(yAxis.join("\n")).toContain("22");
95
+ expect(yAxis.join("\n")).toContain("0");
96
+
97
+ const hover = resolveBarChartHover(scene, scene.bars[1]!.x);
98
+ expect(hover).toMatchObject({
99
+ seriesId: "NVDA",
100
+ seriesLabel: "NVDA",
101
+ category: "2024",
102
+ value: 20,
103
+ x: scene.bars[1]!.x,
104
+ width: scene.bars[1]!.width,
105
+ row: scene.bars[1]!.row,
106
+ });
107
+ });
108
+
109
+ test("renders dense single-series bars with consistent separators and readable x-axis labels", () => {
110
+ const denseSeries = [{
111
+ id: "value",
112
+ label: "Value",
113
+ color: "#00ff66",
114
+ points: Array.from({ length: 14 }, (_unused, index) => ({
115
+ category: `${2015 + index} Q1`,
116
+ value: index + 1,
117
+ })),
118
+ }];
119
+ const scene = buildBarChartScene(denseSeries, {
120
+ width: 47,
121
+ height: 8,
122
+ colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
123
+ })!;
124
+
125
+ const lastBar = scene.bars.at(-1)!;
126
+ expect(lastBar.x + lastBar.width).toBe(scene.width);
127
+ expect(renderBarChart(scene)[scene.zeroRow]).not.toContain("─");
128
+
129
+ const bitmapWidth = 470;
130
+ const pixelBounds = scene.bars.map((bar) => resolveNativeBarPixelBounds(scene, bar, bitmapWidth));
131
+ const pixelWidths = pixelBounds.map((bounds) => bounds.rightExclusive - bounds.left);
132
+ expect(Math.max(...pixelWidths) - Math.min(...pixelWidths)).toBeLessThanOrEqual(1);
133
+ expect(pixelBounds[0]!.left).toBe(0);
134
+ expect(pixelBounds.at(-1)!.rightExclusive).toBe(bitmapWidth);
135
+ const pixelGaps: number[] = [];
136
+ for (let index = 1; index < pixelBounds.length; index++) {
137
+ pixelGaps.push(pixelBounds[index]!.left - pixelBounds[index - 1]!.rightExclusive);
138
+ }
139
+ expect(new Set(pixelGaps)).toEqual(new Set([1]));
140
+
141
+ const bitmap = renderNativeBarChart(scene, bitmapWidth, 80);
142
+ const zeroY = Math.round((scene.zeroRow / Math.max(scene.height - 1, 1)) * (bitmap.height - 1));
143
+ let separatorColumns = 0;
144
+ for (let x = 0; x < bitmap.width; x++) {
145
+ const index = (zeroY * bitmap.width + x) * 4;
146
+ if (bitmap.pixels[index + 1]! <= 120) separatorColumns++;
147
+ }
148
+ expect(separatorColumns).toBe(scene.categories.length - 1);
149
+
150
+ const axis = renderBarChartAxis(scene, 1);
151
+ expect(axis).toHaveLength(1);
152
+ expect(axis.every((line) => line.length === scene.width)).toBe(true);
153
+ expect(axis.join("\n")).toContain("2015");
154
+ expect(axis.join("\n")).not.toContain("Q1");
155
+ expect(axis.join("\n")).not.toContain("20 20");
156
+ expect(axis[0]!.endsWith(" ")).toBe(true);
52
157
  });
53
158
  });