gloomberb 0.7.2 → 0.7.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.
- package/README.md +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +158 -47
- package/src/components/chart/chart-types.ts +20 -2
- package/src/components/chart/chart.test.ts +179 -13
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +129 -129
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +108 -19
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
- package/src/components/chart/stock-chart.tsx +95 -49
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +535 -384
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +21 -0
- package/src/components/command-bar/view-model.ts +17 -4
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +21 -3
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +15 -66
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/core/state/session-persistence.ts +25 -0
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +200 -56
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +263 -48
- package/src/plugins/builtin/chat.test.tsx +312 -6
- package/src/plugins/builtin/chat.tsx +245 -66
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +1 -3
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +275 -43
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +6 -4
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +29 -13
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/utils/api-client.test.ts +248 -0
- package/src/utils/api-client.ts +178 -26
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
|
@@ -3,8 +3,10 @@ import { existsSync, rmSync } from "fs";
|
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { act, useReducer } from "react";
|
|
6
|
+
import { RGBA, TextAttributes } from "@opentui/core";
|
|
6
7
|
import { testRender } from "../../renderers/opentui/test-utils";
|
|
7
8
|
import { AppPersistence } from "../../data/app-persistence";
|
|
9
|
+
import { TickerRepository } from "../../data/ticker-repository";
|
|
8
10
|
import { AppContext, appReducer, createInitialState, PaneInstanceProvider, type AppAction } from "../../state/app-context";
|
|
9
11
|
import { AssetDataRouter } from "../../sources/provider-router";
|
|
10
12
|
import { cloneLayout, createDefaultConfig, type AppConfig, type BrokerInstanceConfig } from "../../types/config";
|
|
@@ -12,9 +14,12 @@ import type { DataProvider } from "../../types/data-provider";
|
|
|
12
14
|
import type { Quote } from "../../types/financials";
|
|
13
15
|
import type { TickerRecord } from "../../types/ticker";
|
|
14
16
|
import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
|
|
17
|
+
import { instrumentFromTicker } from "../../market-data/request-types";
|
|
15
18
|
import { createTestPluginRuntime } from "../../test-support/plugin-runtime";
|
|
16
19
|
import { PluginRenderProvider, type PluginRuntimeAccess } from "../plugin-runtime";
|
|
20
|
+
import { PluginRegistry, setSharedMarketDataForTests, setSharedRegistryForTests } from "../registry";
|
|
17
21
|
import type { BrokerAdapter } from "../../types/broker";
|
|
22
|
+
import { colors } from "../../theme/colors";
|
|
18
23
|
import { portfolioListPlugin } from "./portfolio-list";
|
|
19
24
|
|
|
20
25
|
const TEST_PANE_ID = "portfolio-list:test";
|
|
@@ -24,6 +29,7 @@ let harnessDispatch: React.Dispatch<AppAction> | null = null;
|
|
|
24
29
|
let sharedCoordinator: MarketDataCoordinator | null = null;
|
|
25
30
|
let harnessState: ReturnType<typeof createInitialState> | null = null;
|
|
26
31
|
const tempPaths: string[] = [];
|
|
32
|
+
const tempPersistences: AppPersistence[] = [];
|
|
27
33
|
|
|
28
34
|
const PortfolioPane = portfolioListPlugin.panes![0]!.component as (props: {
|
|
29
35
|
paneId: string;
|
|
@@ -154,6 +160,75 @@ function createPortfolioConfigWithColumns(
|
|
|
154
160
|
}
|
|
155
161
|
return config;
|
|
156
162
|
}
|
|
163
|
+
|
|
164
|
+
function createManualCollectionConfig(collectionId: string): AppConfig {
|
|
165
|
+
const config = createDefaultConfig("/tmp/gloomberb-portfolio-list");
|
|
166
|
+
const layout = {
|
|
167
|
+
dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
|
|
168
|
+
instances: [{
|
|
169
|
+
instanceId: TEST_PANE_ID,
|
|
170
|
+
paneId: "portfolio-list",
|
|
171
|
+
binding: { kind: "none" as const },
|
|
172
|
+
params: { collectionId },
|
|
173
|
+
}],
|
|
174
|
+
floating: [],
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
...config,
|
|
179
|
+
layout,
|
|
180
|
+
layouts: [{ name: "Default", layout: cloneLayout(layout) }],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function installQuickAddRegistry(provider: DataProvider): PluginRegistry {
|
|
185
|
+
const persistence = new AppPersistence(createTempDbPath("quick-add"));
|
|
186
|
+
tempPersistences.push(persistence);
|
|
187
|
+
const registry = new PluginRegistry(provider, new TickerRepository(persistence.tickers), persistence);
|
|
188
|
+
registry.getConfigFn = () => harnessState?.config ?? createDefaultConfig("/tmp/gloomberb-portfolio-list");
|
|
189
|
+
return registry;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function createQuickAddProvider(match = true): DataProvider {
|
|
193
|
+
return {
|
|
194
|
+
id: "quick-add-test",
|
|
195
|
+
name: "Quick Add Test",
|
|
196
|
+
async getTickerFinancials() {
|
|
197
|
+
return {
|
|
198
|
+
annualStatements: [],
|
|
199
|
+
quarterlyStatements: [],
|
|
200
|
+
priceHistory: [],
|
|
201
|
+
quote: makeQuote({ symbol: "MSFT", price: 420, change: 5.2, changePercent: 1.25, name: "Microsoft" }),
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
async getQuote(symbol) {
|
|
205
|
+
if (match && symbol === "MSFT") {
|
|
206
|
+
return makeQuote({ symbol: "MSFT", price: 420, change: 5.2, changePercent: 1.25, name: "Microsoft" });
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`No quote for ${symbol}`);
|
|
209
|
+
},
|
|
210
|
+
async getExchangeRate() {
|
|
211
|
+
return 1;
|
|
212
|
+
},
|
|
213
|
+
async search(query) {
|
|
214
|
+
if (!match || query !== "MSFT") return [];
|
|
215
|
+
return [{
|
|
216
|
+
symbol: "MSFT",
|
|
217
|
+
name: "Microsoft",
|
|
218
|
+
exchange: "NASDAQ",
|
|
219
|
+
currency: "USD",
|
|
220
|
+
type: "STK",
|
|
221
|
+
}];
|
|
222
|
+
},
|
|
223
|
+
async getArticleSummary() {
|
|
224
|
+
return null;
|
|
225
|
+
},
|
|
226
|
+
async getPriceHistory() {
|
|
227
|
+
return [];
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
157
232
|
function createPortfolioState(
|
|
158
233
|
config: AppConfig,
|
|
159
234
|
collectionId: string,
|
|
@@ -193,6 +268,7 @@ function PortfolioHarness({
|
|
|
193
268
|
exchangeRates,
|
|
194
269
|
stateMutator,
|
|
195
270
|
runtime = createTestPluginRuntime(),
|
|
271
|
+
paneHeight = 24,
|
|
196
272
|
}: {
|
|
197
273
|
config: AppConfig;
|
|
198
274
|
collectionId: string;
|
|
@@ -203,6 +279,7 @@ function PortfolioHarness({
|
|
|
203
279
|
exchangeRates?: Map<string, number>;
|
|
204
280
|
stateMutator?: (state: ReturnType<typeof createInitialState>) => void;
|
|
205
281
|
runtime?: PluginRuntimeAccess;
|
|
282
|
+
paneHeight?: number;
|
|
206
283
|
}) {
|
|
207
284
|
const initialState = createPortfolioState(config, collectionId, expanded, {
|
|
208
285
|
ticker,
|
|
@@ -224,7 +301,7 @@ function PortfolioHarness({
|
|
|
224
301
|
paneType="portfolio-list"
|
|
225
302
|
focused
|
|
226
303
|
width={100}
|
|
227
|
-
height={
|
|
304
|
+
height={paneHeight}
|
|
228
305
|
/>
|
|
229
306
|
</PluginRenderProvider>
|
|
230
307
|
</PaneInstanceProvider>
|
|
@@ -241,6 +318,10 @@ async function flushFrame() {
|
|
|
241
318
|
});
|
|
242
319
|
}
|
|
243
320
|
|
|
321
|
+
function lineText(line: { spans: Array<{ text: string }> }) {
|
|
322
|
+
return line.spans.map((span) => span.text).join("");
|
|
323
|
+
}
|
|
324
|
+
|
|
244
325
|
afterEach(async () => {
|
|
245
326
|
if (testSetup) {
|
|
246
327
|
testSetup.renderer.destroy();
|
|
@@ -249,7 +330,12 @@ afterEach(async () => {
|
|
|
249
330
|
harnessDispatch = null;
|
|
250
331
|
sharedCoordinator = null;
|
|
251
332
|
setSharedMarketDataCoordinator(null);
|
|
333
|
+
setSharedMarketDataForTests(undefined);
|
|
334
|
+
setSharedRegistryForTests(undefined);
|
|
252
335
|
harnessState = null;
|
|
336
|
+
for (const persistence of tempPersistences.splice(0)) {
|
|
337
|
+
persistence.close();
|
|
338
|
+
}
|
|
253
339
|
for (const path of tempPaths.splice(0)) {
|
|
254
340
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
255
341
|
}
|
|
@@ -291,6 +377,151 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
291
377
|
expect(pinned).toEqual([{ symbol: "AAPL", options: { floating: true, paneType: "ticker-detail" } }]);
|
|
292
378
|
});
|
|
293
379
|
|
|
380
|
+
test("quick-add validates and adds an exact watchlist ticker", async () => {
|
|
381
|
+
const config = createManualCollectionConfig("watchlist");
|
|
382
|
+
const notifications: Array<{ type?: string; body: string }> = [];
|
|
383
|
+
installQuickAddRegistry(createQuickAddProvider(true));
|
|
384
|
+
|
|
385
|
+
testSetup = await testRender(
|
|
386
|
+
<PortfolioHarness
|
|
387
|
+
config={config}
|
|
388
|
+
collectionId="watchlist"
|
|
389
|
+
ticker={makeTicker({ portfolios: [], watchlists: [], positions: [] })}
|
|
390
|
+
runtime={createTestPluginRuntime({
|
|
391
|
+
notify: (notification) => notifications.push(notification),
|
|
392
|
+
})}
|
|
393
|
+
paneHeight={12}
|
|
394
|
+
/>,
|
|
395
|
+
{ width: 100, height: 12 },
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
await flushFrame();
|
|
399
|
+
await act(async () => {
|
|
400
|
+
testSetup!.mockInput.pressKey("a");
|
|
401
|
+
await testSetup!.renderOnce();
|
|
402
|
+
});
|
|
403
|
+
await act(async () => {
|
|
404
|
+
await testSetup!.mockInput.typeText("MSFT");
|
|
405
|
+
await testSetup!.renderOnce();
|
|
406
|
+
});
|
|
407
|
+
await act(async () => {
|
|
408
|
+
await new Promise((resolve) => setTimeout(resolve, 360));
|
|
409
|
+
});
|
|
410
|
+
await flushFrame();
|
|
411
|
+
|
|
412
|
+
const previewFrame = testSetup.captureCharFrame();
|
|
413
|
+
expect(previewFrame).toContain("420");
|
|
414
|
+
expect(previewFrame).not.toMatch(/MSFT\s+420/);
|
|
415
|
+
expect(previewFrame).toContain("+1.25%");
|
|
416
|
+
|
|
417
|
+
await act(async () => {
|
|
418
|
+
testSetup!.mockInput.pressEnter();
|
|
419
|
+
await Promise.resolve();
|
|
420
|
+
await testSetup!.renderOnce();
|
|
421
|
+
});
|
|
422
|
+
await flushFrame();
|
|
423
|
+
|
|
424
|
+
expect(harnessState?.tickers.get("MSFT")?.metadata.watchlists).toEqual(["watchlist"]);
|
|
425
|
+
expect(notifications.at(-1)).toMatchObject({
|
|
426
|
+
type: "success",
|
|
427
|
+
body: "Added MSFT to Watchlist.",
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("quick-add adds an exact ticker to a manual portfolio", async () => {
|
|
432
|
+
const config = createManualCollectionConfig("main");
|
|
433
|
+
const notifications: Array<{ type?: string; body: string }> = [];
|
|
434
|
+
installQuickAddRegistry(createQuickAddProvider(true));
|
|
435
|
+
|
|
436
|
+
testSetup = await testRender(
|
|
437
|
+
<PortfolioHarness
|
|
438
|
+
config={config}
|
|
439
|
+
collectionId="main"
|
|
440
|
+
ticker={makeTicker({ portfolios: [], watchlists: [], positions: [] })}
|
|
441
|
+
runtime={createTestPluginRuntime({
|
|
442
|
+
notify: (notification) => notifications.push(notification),
|
|
443
|
+
})}
|
|
444
|
+
paneHeight={12}
|
|
445
|
+
/>,
|
|
446
|
+
{ width: 100, height: 12 },
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
await flushFrame();
|
|
450
|
+
await act(async () => {
|
|
451
|
+
testSetup!.mockInput.pressKey("n");
|
|
452
|
+
await testSetup!.renderOnce();
|
|
453
|
+
});
|
|
454
|
+
await act(async () => {
|
|
455
|
+
await testSetup!.mockInput.typeText("MSFT");
|
|
456
|
+
await testSetup!.renderOnce();
|
|
457
|
+
});
|
|
458
|
+
await act(async () => {
|
|
459
|
+
await new Promise((resolve) => setTimeout(resolve, 360));
|
|
460
|
+
});
|
|
461
|
+
await flushFrame();
|
|
462
|
+
|
|
463
|
+
await act(async () => {
|
|
464
|
+
testSetup!.mockInput.pressEnter();
|
|
465
|
+
await Promise.resolve();
|
|
466
|
+
await testSetup!.renderOnce();
|
|
467
|
+
});
|
|
468
|
+
await flushFrame();
|
|
469
|
+
|
|
470
|
+
expect(harnessState?.tickers.get("MSFT")?.metadata.portfolios).toEqual(["main"]);
|
|
471
|
+
expect(notifications.at(-1)).toMatchObject({
|
|
472
|
+
type: "success",
|
|
473
|
+
body: "Added MSFT to Main Portfolio.",
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("quick-add rejects unresolved ticker input", async () => {
|
|
478
|
+
const config = createManualCollectionConfig("watchlist");
|
|
479
|
+
const notifications: Array<{ type?: string; body: string }> = [];
|
|
480
|
+
installQuickAddRegistry(createQuickAddProvider(false));
|
|
481
|
+
|
|
482
|
+
testSetup = await testRender(
|
|
483
|
+
<PortfolioHarness
|
|
484
|
+
config={config}
|
|
485
|
+
collectionId="watchlist"
|
|
486
|
+
ticker={makeTicker({ portfolios: [], watchlists: [], positions: [] })}
|
|
487
|
+
runtime={createTestPluginRuntime({
|
|
488
|
+
notify: (notification) => notifications.push(notification),
|
|
489
|
+
})}
|
|
490
|
+
paneHeight={12}
|
|
491
|
+
/>,
|
|
492
|
+
{ width: 100, height: 12 },
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
await flushFrame();
|
|
496
|
+
await act(async () => {
|
|
497
|
+
testSetup!.mockInput.pressKey("n");
|
|
498
|
+
await testSetup!.renderOnce();
|
|
499
|
+
});
|
|
500
|
+
await act(async () => {
|
|
501
|
+
await testSetup!.mockInput.typeText("NOPE");
|
|
502
|
+
await testSetup!.renderOnce();
|
|
503
|
+
});
|
|
504
|
+
await act(async () => {
|
|
505
|
+
await new Promise((resolve) => setTimeout(resolve, 360));
|
|
506
|
+
});
|
|
507
|
+
await flushFrame();
|
|
508
|
+
|
|
509
|
+
expect(testSetup.captureCharFrame()).toContain("No exact ticker match");
|
|
510
|
+
|
|
511
|
+
await act(async () => {
|
|
512
|
+
testSetup!.mockInput.pressEnter();
|
|
513
|
+
await Promise.resolve();
|
|
514
|
+
await testSetup!.renderOnce();
|
|
515
|
+
});
|
|
516
|
+
await flushFrame();
|
|
517
|
+
|
|
518
|
+
expect(harnessState?.tickers.has("NOPE")).toBe(false);
|
|
519
|
+
expect(notifications.at(-1)).toMatchObject({
|
|
520
|
+
type: "error",
|
|
521
|
+
body: "No exact ticker match",
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
294
525
|
test("keeps non-broker portfolios unchanged", async () => {
|
|
295
526
|
const config = createDefaultConfig("/tmp/gloomberb-portfolio-list");
|
|
296
527
|
const layout = {
|
|
@@ -317,6 +548,43 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
317
548
|
expect(frame).not.toContain("Cash & Margin");
|
|
318
549
|
});
|
|
319
550
|
|
|
551
|
+
test("renders one-month sparkline column when price history is loaded", async () => {
|
|
552
|
+
const config = createPortfolioConfigWithColumns(
|
|
553
|
+
"broker:ibkr-flex:DU12345",
|
|
554
|
+
["ticker", "price", "sparkline"],
|
|
555
|
+
[createBrokerInstance("flex")],
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
testSetup = await testRender(
|
|
559
|
+
<PortfolioHarness
|
|
560
|
+
config={config}
|
|
561
|
+
collectionId="broker:ibkr-flex:DU12345"
|
|
562
|
+
stateMutator={(state) => {
|
|
563
|
+
state.financials = new Map([[
|
|
564
|
+
"AAPL",
|
|
565
|
+
{
|
|
566
|
+
annualStatements: [],
|
|
567
|
+
quarterlyStatements: [],
|
|
568
|
+
quote: makeQuote(),
|
|
569
|
+
priceHistory: [118, 121, 119, 124, 127, 126, 130].map((close, index) => ({
|
|
570
|
+
date: `2026-03-${20 + index}T00:00:00Z` as unknown as Date,
|
|
571
|
+
close,
|
|
572
|
+
})),
|
|
573
|
+
},
|
|
574
|
+
]]);
|
|
575
|
+
}}
|
|
576
|
+
/>,
|
|
577
|
+
{ width: 100, height: 12 },
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
await flushFrame();
|
|
581
|
+
|
|
582
|
+
const frame = testSetup.captureCharFrame();
|
|
583
|
+
expect(frame).toContain("1M");
|
|
584
|
+
const row = frame.split("\n").find((line) => line.includes("AAPL")) ?? "";
|
|
585
|
+
expect(row).toMatch(/[\u2800-\u28ff]/);
|
|
586
|
+
});
|
|
587
|
+
|
|
320
588
|
test("keeps native price and avg cost while converting market value and pnl to base currency", async () => {
|
|
321
589
|
const portfolioId = "broker:ibkr-flex:DU12345";
|
|
322
590
|
const config = createPortfolioConfigWithColumns(
|
|
@@ -738,6 +1006,8 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
738
1006
|
["ticker", "market_cap", "pe", "forward_pe"],
|
|
739
1007
|
[createBrokerInstance("flex")],
|
|
740
1008
|
);
|
|
1009
|
+
const seededTicker = makeTicker();
|
|
1010
|
+
const seededQuote = makeQuote();
|
|
741
1011
|
let calls = 0;
|
|
742
1012
|
let resolveFinancials!: (value: {
|
|
743
1013
|
annualStatements: [];
|
|
@@ -782,10 +1052,26 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
782
1052
|
},
|
|
783
1053
|
};
|
|
784
1054
|
sharedCoordinator = new MarketDataCoordinator(provider);
|
|
1055
|
+
const instrument = instrumentFromTicker(seededTicker, seededTicker.metadata.ticker);
|
|
1056
|
+
if (!instrument) throw new Error("expected ticker instrument");
|
|
1057
|
+
sharedCoordinator.primeCachedFinancials([{
|
|
1058
|
+
instrument,
|
|
1059
|
+
financials: {
|
|
1060
|
+
annualStatements: [],
|
|
1061
|
+
quarterlyStatements: [],
|
|
1062
|
+
priceHistory: [],
|
|
1063
|
+
quote: seededQuote,
|
|
1064
|
+
},
|
|
1065
|
+
}]);
|
|
785
1066
|
setSharedMarketDataCoordinator(sharedCoordinator);
|
|
786
1067
|
|
|
787
1068
|
testSetup = await testRender(
|
|
788
|
-
<PortfolioHarness
|
|
1069
|
+
<PortfolioHarness
|
|
1070
|
+
config={config}
|
|
1071
|
+
collectionId="broker:ibkr-flex:DU12345"
|
|
1072
|
+
ticker={seededTicker}
|
|
1073
|
+
quote={seededQuote}
|
|
1074
|
+
/>,
|
|
789
1075
|
{ width: 100, height: 12 },
|
|
790
1076
|
);
|
|
791
1077
|
|
|
@@ -842,10 +1128,11 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
842
1128
|
quote: makeQuote({
|
|
843
1129
|
symbol: "AAPL",
|
|
844
1130
|
price: 125,
|
|
845
|
-
marketCap:
|
|
1131
|
+
marketCap: 2_000_000_000,
|
|
846
1132
|
}),
|
|
847
1133
|
fundamentals: {
|
|
848
1134
|
trailingPE: 25,
|
|
1135
|
+
forwardPE: 22,
|
|
849
1136
|
},
|
|
850
1137
|
profile: {
|
|
851
1138
|
sector: "Technology",
|
|
@@ -1157,7 +1444,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1157
1444
|
expect(frame).toContain("+5.41%");
|
|
1158
1445
|
});
|
|
1159
1446
|
|
|
1160
|
-
test("quote flash
|
|
1447
|
+
test("quote flash dims cells without changing quote sign color or row background", async () => {
|
|
1161
1448
|
const config = createPortfolioConfigWithColumns(
|
|
1162
1449
|
"broker:ibkr-flex:DU12345",
|
|
1163
1450
|
["ticker", "price", "change", "latency"],
|
|
@@ -1174,7 +1461,18 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1174
1461
|
["MSFT", makeTicker({ ticker: "MSFT", name: "Microsoft" })],
|
|
1175
1462
|
]);
|
|
1176
1463
|
state.financials = new Map([
|
|
1177
|
-
["AAPL", {
|
|
1464
|
+
["AAPL", {
|
|
1465
|
+
annualStatements: [],
|
|
1466
|
+
quarterlyStatements: [],
|
|
1467
|
+
priceHistory: [],
|
|
1468
|
+
quote: makeQuote({
|
|
1469
|
+
price: 125,
|
|
1470
|
+
bid: 124.95,
|
|
1471
|
+
ask: 125.05,
|
|
1472
|
+
change: -5,
|
|
1473
|
+
changePercent: -4.17,
|
|
1474
|
+
}),
|
|
1475
|
+
}],
|
|
1178
1476
|
["MSFT", {
|
|
1179
1477
|
annualStatements: [],
|
|
1180
1478
|
quarterlyStatements: [],
|
|
@@ -1203,7 +1501,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1203
1501
|
|
|
1204
1502
|
await flushFrame();
|
|
1205
1503
|
const beforeFrame = testSetup.captureSpans();
|
|
1206
|
-
const beforeLine = beforeFrame.lines.find((line) => line
|
|
1504
|
+
const beforeLine = beforeFrame.lines.find((line) => lineText(line).includes("AAPL"));
|
|
1207
1505
|
expect(beforeLine).toBeDefined();
|
|
1208
1506
|
|
|
1209
1507
|
await act(async () => {
|
|
@@ -1214,8 +1512,8 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1214
1512
|
price: 126,
|
|
1215
1513
|
bid: 125.95,
|
|
1216
1514
|
ask: 126.05,
|
|
1217
|
-
change:
|
|
1218
|
-
changePercent:
|
|
1515
|
+
change: -4,
|
|
1516
|
+
changePercent: -3.08,
|
|
1219
1517
|
lastUpdated: Date.now() + 1_000,
|
|
1220
1518
|
}),
|
|
1221
1519
|
});
|
|
@@ -1223,11 +1521,15 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1223
1521
|
await flushFrame();
|
|
1224
1522
|
|
|
1225
1523
|
const frame = testSetup.captureSpans();
|
|
1226
|
-
const aaplLine = frame.lines.find((line) => line
|
|
1524
|
+
const aaplLine = frame.lines.find((line) => lineText(line).includes("AAPL"));
|
|
1227
1525
|
expect(aaplLine).toBeDefined();
|
|
1228
1526
|
|
|
1229
1527
|
const beforeBackgrounds = beforeLine!.spans.map((span) => span.bg.toInts().join(","));
|
|
1230
1528
|
const afterBackgrounds = aaplLine!.spans.map((span) => span.bg.toInts().join(","));
|
|
1231
1529
|
expect(afterBackgrounds).toEqual(beforeBackgrounds);
|
|
1530
|
+
const priceSpan = aaplLine!.spans.find((span) => span.text.includes("126"));
|
|
1531
|
+
expect(priceSpan).toBeDefined();
|
|
1532
|
+
expect(priceSpan!.fg.toInts().join(",")).toBe(RGBA.fromHex(colors.negative).toInts().join(","));
|
|
1533
|
+
expect(priceSpan!.attributes & TextAttributes.DIM).not.toBe(0);
|
|
1232
1534
|
});
|
|
1233
1535
|
});
|
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
function makeTicker(symbol: string, name: string): TickerRecord {
|
|
66
|
+
return {
|
|
34
67
|
metadata: {
|
|
35
|
-
ticker:
|
|
68
|
+
ticker: symbol,
|
|
36
69
|
exchange: "NASDAQ",
|
|
37
70
|
currency: "USD",
|
|
38
|
-
name
|
|
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
|
-
|
|
81
|
+
function makeFinancials(symbol: string, price: number, change: number, changePercent: number): TickerFinancials {
|
|
82
|
+
return {
|
|
48
83
|
quote: {
|
|
49
|
-
symbol
|
|
50
|
-
price
|
|
84
|
+
symbol,
|
|
85
|
+
price,
|
|
51
86
|
currency: "USD",
|
|
52
|
-
change
|
|
53
|
-
changePercent
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
<
|
|
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
|
});
|