gloomberb 0.7.0 → 0.7.1
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/package.json +1 -1
- package/src/app.tsx +149 -51
- package/src/capabilities/factories.ts +3 -0
- package/src/components/chart/chart-renderer.ts +24 -12
- package/src/components/chart/chart.test.ts +43 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +15 -12
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +2 -0
- package/src/components/chart/stock-chart.tsx +31 -20
- package/src/components/chart/time-axis-label.tsx +78 -1
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +114 -31
- package/src/components/command-bar/command-bar.tsx +587 -335
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +1 -2
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -1
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +5 -2
- package/src/components/layout/header.tsx +2 -0
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +198 -0
- package/src/components/layout/shell.tsx +33 -2
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/speedometer-gauge.tsx +29 -25
- package/src/components/table-view-shared.tsx +63 -3
- package/src/components/ticker-list-table-view.tsx +16 -2
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/data-table.tsx +12 -9
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/table-layout.test.ts +27 -1
- package/src/components/ui/table-layout.ts +39 -1
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +68 -1
- package/src/core/state/app-state.ts +4 -14
- package/src/market-data/coordinator.test.ts +75 -0
- package/src/market-data/coordinator.ts +218 -48
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -0
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/chat-controller.test.ts +83 -1
- package/src/plugins/builtin/chat-controller.ts +491 -243
- package/src/plugins/builtin/chat.test.tsx +425 -7
- package/src/plugins/builtin/chat.tsx +1109 -211
- package/src/plugins/builtin/cloud-tweets.tsx +1 -0
- package/src/plugins/builtin/fear-greed/index.tsx +148 -39
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +54 -14
- package/src/plugins/builtin/market-movers/index.tsx +12 -18
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/notes.tsx +1 -0
- package/src/plugins/builtin/options.tsx +2 -16
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +159 -12
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/plugin-runtime.test.tsx +34 -0
- package/src/plugins/plugin-runtime.tsx +7 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/registry.ts +3 -3
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +1 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +111 -96
- package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
- package/src/renderers/electrobun/view/input-host.tsx +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +6 -8
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +42 -0
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -11
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +20 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.ts +13 -10
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +64 -3
- package/src/theme/theme-context.tsx +26 -0
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -1
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -1
- package/src/ui/host.tsx +6 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
|
@@ -8,9 +8,11 @@ import type {
|
|
|
8
8
|
CachedFinancialsTarget,
|
|
9
9
|
DataProvider,
|
|
10
10
|
MarketDataRequestContext,
|
|
11
|
+
QuoteBatchResult,
|
|
11
12
|
QuoteSubscriptionTarget,
|
|
12
13
|
SearchRequestContext,
|
|
13
14
|
SecFilingItem,
|
|
15
|
+
TickerFinancialsBatchResult,
|
|
14
16
|
} from "../types/data-provider";
|
|
15
17
|
import type { CapabilityRouteSource } from "../types/capability-route-source";
|
|
16
18
|
import { routeSourcePriority } from "../types/capability-route-source";
|
|
@@ -150,6 +152,16 @@ function hasAnalystResearchValue(data: AnalystResearchData): boolean {
|
|
|
150
152
|
|| data.revenueEstimates.length > 0;
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
function hasAnalystRatingPriceTargets(data: AnalystResearchData): boolean {
|
|
156
|
+
return data.ratings.some((rating) => (
|
|
157
|
+
rating.currentPriceTarget != null || rating.priorPriceTarget != null
|
|
158
|
+
));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isAnalystResearchMissingRatingTargets(data: AnalystResearchData): boolean {
|
|
162
|
+
return data.ratings.length > 0 && !hasAnalystRatingPriceTargets(data);
|
|
163
|
+
}
|
|
164
|
+
|
|
153
165
|
function hasCorporateActionsValue(data: CorporateActionsData): boolean {
|
|
154
166
|
return data.dividends.length > 0
|
|
155
167
|
|| data.splits.length > 0
|
|
@@ -345,6 +357,135 @@ export class AssetDataRouter implements DataProvider {
|
|
|
345
357
|
return results;
|
|
346
358
|
}
|
|
347
359
|
|
|
360
|
+
async getQuotesBatch(
|
|
361
|
+
targets: QuoteSubscriptionTarget[],
|
|
362
|
+
options: { forceRefresh?: boolean } = {},
|
|
363
|
+
): Promise<QuoteBatchResult[]> {
|
|
364
|
+
const forceRefresh = options.forceRefresh === true;
|
|
365
|
+
const results = new Array<QuoteBatchResult | null>(targets.length).fill(null);
|
|
366
|
+
const misses: Array<{ index: number; target: QuoteSubscriptionTarget }> = [];
|
|
367
|
+
|
|
368
|
+
targets.forEach((target, index) => {
|
|
369
|
+
const context = target.context;
|
|
370
|
+
const entityKey = this.getEntityKey(target.symbol, target.exchange, context?.instrument);
|
|
371
|
+
const variantKeys = this.getTickerVariantCandidates(target.exchange);
|
|
372
|
+
const sourceKeys = [
|
|
373
|
+
...this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate)),
|
|
374
|
+
...this.getProviderSourceKeys(),
|
|
375
|
+
];
|
|
376
|
+
const rawCached = this.selectCachedResource<Quote>("quote", entityKey, variantKeys, sourceKeys, false);
|
|
377
|
+
const cached = rawCached && !isQuoteStaleForCurrentSession(rawCached.value)
|
|
378
|
+
? rawCached
|
|
379
|
+
: null;
|
|
380
|
+
if (cached && !forceRefresh && !cached.stale) {
|
|
381
|
+
results[index] = { target, quote: cached.value };
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
misses.push({ index, target });
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const batchProvider = this.providersInPriorityOrder().find((provider) => provider.getQuotesBatch);
|
|
388
|
+
const providerMisses = misses.filter(({ target }) => !this.hasBrokerContext(target.context));
|
|
389
|
+
const providerIndexes = new Map<string, Array<{ index: number; target: QuoteSubscriptionTarget }>>();
|
|
390
|
+
if (batchProvider && providerMisses.length > 0) {
|
|
391
|
+
for (const entry of providerMisses) {
|
|
392
|
+
const key = this.quoteBatchKey(entry.target);
|
|
393
|
+
const bucket = providerIndexes.get(key) ?? [];
|
|
394
|
+
bucket.push(entry);
|
|
395
|
+
providerIndexes.set(key, bucket);
|
|
396
|
+
}
|
|
397
|
+
const uniqueTargets = [...providerIndexes.values()].map((bucket) => bucket[0]!.target);
|
|
398
|
+
const batchResults = await batchProvider.getQuotesBatch!(uniqueTargets, options).catch(() => []);
|
|
399
|
+
for (const item of batchResults) {
|
|
400
|
+
if (!item.quote) continue;
|
|
401
|
+
const key = this.quoteBatchKey(item.target);
|
|
402
|
+
const sourceKey = this.providerSourceKey(batchProvider);
|
|
403
|
+
for (const entry of providerIndexes.get(key) ?? []) {
|
|
404
|
+
const entityKey = this.getEntityKey(entry.target.symbol, entry.target.exchange, entry.target.context?.instrument);
|
|
405
|
+
const variantKey = this.getTickerVariantCandidates(entry.target.exchange)[0] ?? "";
|
|
406
|
+
this.cacheResource("quote", entityKey, variantKey, sourceKey, item.quote, this.resolveProviderPolicy("quote", batchProvider));
|
|
407
|
+
results[entry.index] = { target: entry.target, quote: item.quote };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
await Promise.all(misses.map(async ({ index, target }) => {
|
|
413
|
+
if (results[index]) return;
|
|
414
|
+
try {
|
|
415
|
+
const quote = await this.getQuote(target.symbol, target.exchange, {
|
|
416
|
+
...target.context,
|
|
417
|
+
cacheMode: forceRefresh ? "refresh" : "default",
|
|
418
|
+
});
|
|
419
|
+
results[index] = { target, quote };
|
|
420
|
+
} catch (error) {
|
|
421
|
+
results[index] = { target, quote: null, error };
|
|
422
|
+
}
|
|
423
|
+
}));
|
|
424
|
+
|
|
425
|
+
return results.map((result, index) => result ?? { target: targets[index]!, quote: null });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async getTickerFinancialsBatch(
|
|
429
|
+
targets: CachedFinancialsTarget[],
|
|
430
|
+
options: { forceRefresh?: boolean } = {},
|
|
431
|
+
): Promise<TickerFinancialsBatchResult[]> {
|
|
432
|
+
const forceRefresh = options.forceRefresh === true;
|
|
433
|
+
const results = new Array<TickerFinancialsBatchResult | null>(targets.length).fill(null);
|
|
434
|
+
const misses: Array<{ index: number; target: CachedFinancialsTarget }> = [];
|
|
435
|
+
|
|
436
|
+
targets.forEach((target, index) => {
|
|
437
|
+
const context = this.contextFromCachedTarget(target);
|
|
438
|
+
const cached = this.readCachedMergedFinancialsSelection(target.symbol, target.exchange, context, true);
|
|
439
|
+
if (cached.value && !forceRefresh) {
|
|
440
|
+
results[index] = { target, financials: cached.value };
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
misses.push({ index, target });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const batchProvider = this.providersInPriorityOrder().find((provider) => provider.getTickerFinancialsBatch);
|
|
447
|
+
const providerMisses = misses.filter(({ target }) => !this.hasCachedTargetBrokerContext(target));
|
|
448
|
+
const providerIndexes = new Map<string, Array<{ index: number; target: CachedFinancialsTarget }>>();
|
|
449
|
+
if (batchProvider && providerMisses.length > 0) {
|
|
450
|
+
for (const entry of providerMisses) {
|
|
451
|
+
const key = this.cachedFinancialsBatchKey(entry.target);
|
|
452
|
+
const bucket = providerIndexes.get(key) ?? [];
|
|
453
|
+
bucket.push(entry);
|
|
454
|
+
providerIndexes.set(key, bucket);
|
|
455
|
+
}
|
|
456
|
+
const uniqueTargets = [...providerIndexes.values()].map((bucket) => bucket[0]!.target);
|
|
457
|
+
const batchResults = await batchProvider.getTickerFinancialsBatch!(uniqueTargets, options).catch(() => []);
|
|
458
|
+
for (const item of batchResults) {
|
|
459
|
+
if (!item.financials) continue;
|
|
460
|
+
const key = this.cachedFinancialsBatchKey(item.target);
|
|
461
|
+
const value = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(item.financials));
|
|
462
|
+
if (!value) continue;
|
|
463
|
+
const sourceKey = this.providerSourceKey(batchProvider);
|
|
464
|
+
for (const entry of providerIndexes.get(key) ?? []) {
|
|
465
|
+
const entityKey = this.getEntityKey(entry.target.symbol, entry.target.exchange, entry.target.instrument ?? undefined);
|
|
466
|
+
const variantKey = this.getTickerVariantCandidates(entry.target.exchange)[0] ?? "";
|
|
467
|
+
this.cacheResource("financials", entityKey, variantKey, sourceKey, value, this.resolveProviderPolicy("financials", batchProvider));
|
|
468
|
+
results[entry.index] = { target: entry.target, financials: value };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
await Promise.all(misses.map(async ({ index, target }) => {
|
|
474
|
+
if (results[index]) return;
|
|
475
|
+
try {
|
|
476
|
+
const financials = await this.getTickerFinancials(target.symbol, target.exchange, {
|
|
477
|
+
...this.contextFromCachedTarget(target),
|
|
478
|
+
cacheMode: forceRefresh ? "refresh" : "default",
|
|
479
|
+
});
|
|
480
|
+
results[index] = { target, financials };
|
|
481
|
+
} catch (error) {
|
|
482
|
+
results[index] = { target, financials: null, error };
|
|
483
|
+
}
|
|
484
|
+
}));
|
|
485
|
+
|
|
486
|
+
return results.map((result, index) => result ?? { target: targets[index]!, financials: null });
|
|
487
|
+
}
|
|
488
|
+
|
|
348
489
|
async getTickerFinancials(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<TickerFinancials> {
|
|
349
490
|
const cached = this.readCachedMergedFinancialsSelection(ticker, exchange, context, false);
|
|
350
491
|
const forceRefresh = context?.cacheMode === "refresh";
|
|
@@ -518,9 +659,9 @@ export class AssetDataRouter implements DataProvider {
|
|
|
518
659
|
async getAnalystResearch(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<AnalystResearchData> {
|
|
519
660
|
const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
|
|
520
661
|
const variantKeys = this.getTickerVariantCandidates(exchange);
|
|
521
|
-
const cached = this.
|
|
662
|
+
const cached = this.selectCachedAnalystResearch(entityKey, variantKeys, this.getProviderSourceKeys(), false);
|
|
522
663
|
const forceRefresh = context?.cacheMode === "refresh";
|
|
523
|
-
if (cached && !forceRefresh) {
|
|
664
|
+
if (cached && !forceRefresh && !isAnalystResearchMissingRatingTargets(cached.value)) {
|
|
524
665
|
this.scheduleRevalidation(this.makeRevalidationKey("analystResearch", ticker, exchange, context), async () => {
|
|
525
666
|
await this.revalidateAnalystResearch(ticker, exchange, context);
|
|
526
667
|
});
|
|
@@ -876,7 +1017,17 @@ export class AssetDataRouter implements DataProvider {
|
|
|
876
1017
|
sourceKeys: string[],
|
|
877
1018
|
allowExpired: boolean,
|
|
878
1019
|
): CachedResourceRecord<T> | null {
|
|
879
|
-
return this.listCachedResources(kind, entityKey, variantKeys, sourceKeys, allowExpired)[0] ?? null;
|
|
1020
|
+
return this.listCachedResources<T>(kind, entityKey, variantKeys, sourceKeys, allowExpired)[0] ?? null;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
private selectCachedAnalystResearch(
|
|
1024
|
+
entityKey: string,
|
|
1025
|
+
variantKeys: string[],
|
|
1026
|
+
sourceKeys: string[],
|
|
1027
|
+
allowExpired: boolean,
|
|
1028
|
+
): CachedResourceRecord<AnalystResearchData> | null {
|
|
1029
|
+
const records = this.listCachedResources<AnalystResearchData>("analystResearch", entityKey, variantKeys, sourceKeys, allowExpired);
|
|
1030
|
+
return records.find((record) => !isAnalystResearchMissingRatingTargets(record.value)) ?? records[0] ?? null;
|
|
880
1031
|
}
|
|
881
1032
|
|
|
882
1033
|
private selectCachedArrayResource<T>(
|
|
@@ -1005,14 +1156,42 @@ export class AssetDataRouter implements DataProvider {
|
|
|
1005
1156
|
);
|
|
1006
1157
|
}
|
|
1007
1158
|
|
|
1008
|
-
private
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1159
|
+
private hasBrokerContext(context?: MarketDataRequestContext): boolean {
|
|
1160
|
+
return !!(
|
|
1161
|
+
context?.brokerId ||
|
|
1162
|
+
context?.brokerInstanceId ||
|
|
1163
|
+
context?.instrument?.brokerId ||
|
|
1164
|
+
context?.instrument?.brokerInstanceId
|
|
1014
1165
|
);
|
|
1015
|
-
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
private hasCachedTargetBrokerContext(target: CachedFinancialsTarget): boolean {
|
|
1169
|
+
return !!(
|
|
1170
|
+
target.brokerId ||
|
|
1171
|
+
target.brokerInstanceId ||
|
|
1172
|
+
target.instrument?.brokerId ||
|
|
1173
|
+
target.instrument?.brokerInstanceId
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
private contextFromCachedTarget(target: CachedFinancialsTarget): MarketDataRequestContext {
|
|
1178
|
+
return {
|
|
1179
|
+
brokerId: target.brokerId,
|
|
1180
|
+
brokerInstanceId: target.brokerInstanceId,
|
|
1181
|
+
instrument: target.instrument ?? null,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
private quoteBatchKey(target: QuoteSubscriptionTarget): string {
|
|
1186
|
+
return `${target.symbol.trim().toUpperCase()}:${canonicalExchange(target.exchange ?? "")}`;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
private cachedFinancialsBatchKey(target: CachedFinancialsTarget): string {
|
|
1190
|
+
return `${target.symbol.trim().toUpperCase()}:${canonicalExchange(target.exchange ?? "")}`;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
private getStreamingBrokerCandidate(target: QuoteSubscriptionTarget): BrokerCandidate | null {
|
|
1194
|
+
if (!this.hasBrokerContext(target.context)) return null;
|
|
1016
1195
|
return this.getBrokerCandidatesForContext(target.context, false)
|
|
1017
1196
|
.find((candidate) => typeof candidate.broker.subscribeQuotes === "function") ?? null;
|
|
1018
1197
|
}
|
|
@@ -1579,6 +1758,7 @@ export class AssetDataRouter implements DataProvider {
|
|
|
1579
1758
|
const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
|
|
1580
1759
|
const variantKey = this.getTickerVariantCandidates(exchange)[0] ?? "";
|
|
1581
1760
|
let firstEmptyResult: SourceResult<AnalystResearchData> | null = null;
|
|
1761
|
+
let firstIncompleteResult: SourceResult<AnalystResearchData> | null = null;
|
|
1582
1762
|
let lastError: unknown = null;
|
|
1583
1763
|
|
|
1584
1764
|
for (const provider of this.providersInPriorityOrder()) {
|
|
@@ -1587,7 +1767,13 @@ export class AssetDataRouter implements DataProvider {
|
|
|
1587
1767
|
const value = await provider.getAnalystResearch(ticker, exchange, context);
|
|
1588
1768
|
const sourceKey = this.providerSourceKey(provider);
|
|
1589
1769
|
this.cacheResource("analystResearch", entityKey, variantKey, sourceKey, value, this.resolveProviderPolicy("analystResearch", provider));
|
|
1590
|
-
if (hasAnalystResearchValue(value))
|
|
1770
|
+
if (hasAnalystResearchValue(value)) {
|
|
1771
|
+
if (isAnalystResearchMissingRatingTargets(value)) {
|
|
1772
|
+
firstIncompleteResult ??= { sourceKey, value };
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
return { sourceKey, value };
|
|
1776
|
+
}
|
|
1591
1777
|
firstEmptyResult ??= { sourceKey, value };
|
|
1592
1778
|
} catch (error) {
|
|
1593
1779
|
lastError = error;
|
|
@@ -1597,6 +1783,7 @@ export class AssetDataRouter implements DataProvider {
|
|
|
1597
1783
|
}
|
|
1598
1784
|
}
|
|
1599
1785
|
|
|
1786
|
+
if (firstIncompleteResult) return firstIncompleteResult;
|
|
1600
1787
|
if (firstEmptyResult) return firstEmptyResult;
|
|
1601
1788
|
if (lastError) throw lastError;
|
|
1602
1789
|
return null;
|
|
@@ -32,4 +32,48 @@ describe("YahooFinanceClient exchange aliases", () => {
|
|
|
32
32
|
});
|
|
33
33
|
expect(history[0]?.close).toBe(200);
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
test("preserves analyst rating price targets from upgrade history", async () => {
|
|
37
|
+
const provider = new YahooFinanceClient() as any;
|
|
38
|
+
let requestUrl = "";
|
|
39
|
+
provider.getSymbolsToTry = () => ["AMD"];
|
|
40
|
+
provider.fetchJsonWithCrumb = async (_label: string, url: string) => {
|
|
41
|
+
requestUrl = url;
|
|
42
|
+
return {
|
|
43
|
+
quoteSummary: {
|
|
44
|
+
result: [{
|
|
45
|
+
price: { symbol: "AMD", currency: "USD", exchangeName: "NasdaqGS" },
|
|
46
|
+
financialData: {},
|
|
47
|
+
recommendationTrend: { trend: [] },
|
|
48
|
+
earningsTrend: { trend: [] },
|
|
49
|
+
upgradeDowngradeHistory: {
|
|
50
|
+
history: [{
|
|
51
|
+
epochGradeDate: 1778188318,
|
|
52
|
+
firm: "Citigroup",
|
|
53
|
+
toGrade: "Neutral",
|
|
54
|
+
fromGrade: "Neutral",
|
|
55
|
+
action: "main",
|
|
56
|
+
priceTargetAction: "Raises",
|
|
57
|
+
currentPriceTarget: 358,
|
|
58
|
+
priorPriceTarget: 248,
|
|
59
|
+
}],
|
|
60
|
+
},
|
|
61
|
+
}],
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const research = await provider.getAnalystResearch("AMD", "NASDAQ");
|
|
67
|
+
|
|
68
|
+
expect(requestUrl).toContain("upgradeDowngradeHistory");
|
|
69
|
+
expect(research.ratings[0]).toMatchObject({
|
|
70
|
+
date: "2026-05-07",
|
|
71
|
+
firm: "Citigroup",
|
|
72
|
+
action: "Raises",
|
|
73
|
+
current: "Neutral",
|
|
74
|
+
prior: "Neutral",
|
|
75
|
+
currentPriceTarget: 358,
|
|
76
|
+
priorPriceTarget: 248,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
35
79
|
});
|
|
@@ -179,6 +179,8 @@ type QuoteSummaryResponse = {
|
|
|
179
179
|
priceTargetAction?: string;
|
|
180
180
|
toGrade?: string;
|
|
181
181
|
fromGrade?: string;
|
|
182
|
+
currentPriceTarget?: { raw?: number } | number | null;
|
|
183
|
+
priorPriceTarget?: { raw?: number } | number | null;
|
|
182
184
|
}>;
|
|
183
185
|
};
|
|
184
186
|
earningsTrend?: {
|
|
@@ -412,12 +414,16 @@ function mapYahooAnalystResearchResponse(
|
|
|
412
414
|
const action = normalizeYahooRatingAction(rating.action, rating.priceTargetAction);
|
|
413
415
|
const current = rating.toGrade?.trim();
|
|
414
416
|
const prior = rating.fromGrade?.trim();
|
|
417
|
+
const currentPriceTarget = financeRawNumber(rating.currentPriceTarget);
|
|
418
|
+
const priorPriceTarget = financeRawNumber(rating.priorPriceTarget);
|
|
415
419
|
return {
|
|
416
420
|
date,
|
|
417
421
|
firm,
|
|
418
422
|
...(action ? { action } : {}),
|
|
419
423
|
...(current ? { current } : {}),
|
|
420
424
|
...(prior ? { prior } : {}),
|
|
425
|
+
...(currentPriceTarget != null ? { currentPriceTarget } : {}),
|
|
426
|
+
...(priorPriceTarget != null ? { priorPriceTarget } : {}),
|
|
421
427
|
};
|
|
422
428
|
})
|
|
423
429
|
.filter((rating): rating is AnalystResearchData["ratings"][number] => rating !== null)
|
|
@@ -54,6 +54,8 @@ export interface InitializeAppStateArgs {
|
|
|
54
54
|
primeCachedFinancials?: (entries: Array<{ ticker: TickerRecord; financials: TickerFinancials }>) => void;
|
|
55
55
|
refreshTicker: (symbol: string, exchange?: string, tickerOverride?: TickerRecord | null, priority?: number) => void;
|
|
56
56
|
refreshQuote: (symbol: string, exchange?: string, tickerOverride?: TickerRecord | null, priority?: number) => void;
|
|
57
|
+
refreshTickersBatch?: (entries: Array<{ ticker: TickerRecord; priority: number }>) => void;
|
|
58
|
+
refreshQuotesBatch?: (entries: Array<{ ticker: TickerRecord; priority: number }>) => void;
|
|
57
59
|
autoImportBrokerPositions: (tickerMap: Map<string, TickerRecord>) => Promise<void>;
|
|
58
60
|
persistedBrokerAccounts?: Record<string, BrokerAccount[]>;
|
|
59
61
|
}
|
|
@@ -211,11 +213,11 @@ function buildCachedFinancialTarget(ticker: TickerRecord): CachedFinancialsTarge
|
|
|
211
213
|
};
|
|
212
214
|
}
|
|
213
215
|
|
|
214
|
-
function resolveCachedFinancialPrimeEntries(
|
|
216
|
+
async function resolveCachedFinancialPrimeEntries(
|
|
215
217
|
refreshPlan: RefreshPlanEntry[],
|
|
216
218
|
sessionSnapshot: AppSessionSnapshot | null | undefined,
|
|
217
219
|
dataProvider: DataProvider,
|
|
218
|
-
): Array<{ ticker: TickerRecord; financials: TickerFinancials }
|
|
220
|
+
): Promise<Array<{ ticker: TickerRecord; financials: TickerFinancials }>> {
|
|
219
221
|
if (!dataProvider.getCachedFinancialsForTargets) return [];
|
|
220
222
|
|
|
221
223
|
const sessionTargetsBySymbol = new Map<string, CachedFinancialsTarget>();
|
|
@@ -226,7 +228,7 @@ function resolveCachedFinancialPrimeEntries(
|
|
|
226
228
|
const financialEntries = refreshPlan.filter((entry) => entry.mode === "financials");
|
|
227
229
|
if (financialEntries.length === 0) return [];
|
|
228
230
|
|
|
229
|
-
const cachedFinancials = dataProvider.getCachedFinancialsForTargets(
|
|
231
|
+
const cachedFinancials = await dataProvider.getCachedFinancialsForTargets(
|
|
230
232
|
financialEntries.map(({ ticker }) => (
|
|
231
233
|
sessionTargetsBySymbol.get(ticker.metadata.ticker.trim().toUpperCase()) ?? buildCachedFinancialTarget(ticker)
|
|
232
234
|
)),
|
|
@@ -252,6 +254,8 @@ export async function initializeAppState({
|
|
|
252
254
|
primeCachedFinancials,
|
|
253
255
|
refreshTicker,
|
|
254
256
|
refreshQuote,
|
|
257
|
+
refreshTickersBatch,
|
|
258
|
+
refreshQuotesBatch,
|
|
255
259
|
autoImportBrokerPositions,
|
|
256
260
|
persistedBrokerAccounts = {},
|
|
257
261
|
}: InitializeAppStateArgs): Promise<void> {
|
|
@@ -341,7 +345,7 @@ export async function initializeAppState({
|
|
|
341
345
|
});
|
|
342
346
|
|
|
343
347
|
if (primeCachedFinancials) {
|
|
344
|
-
const cachedPrimeEntries =
|
|
348
|
+
const cachedPrimeEntries = await measurePerfAsync(
|
|
345
349
|
"startup.resolve-cached-financial-prime",
|
|
346
350
|
() => resolveCachedFinancialPrimeEntries(refreshPlan, sessionSnapshot, dataProvider),
|
|
347
351
|
{ financialRefreshCount: refreshPlan.filter((entry) => entry.mode === "financials").length },
|
|
@@ -362,10 +366,19 @@ export async function initializeAppState({
|
|
|
362
366
|
});
|
|
363
367
|
|
|
364
368
|
measurePerf("startup.enqueue-refresh-plan", () => {
|
|
365
|
-
|
|
366
|
-
|
|
369
|
+
const financialEntries = refreshPlan.filter((entry) => entry.mode === "financials");
|
|
370
|
+
const quoteEntries = refreshPlan.filter((entry) => entry.mode === "quote");
|
|
371
|
+
if (refreshTickersBatch) {
|
|
372
|
+
refreshTickersBatch(financialEntries.map((entry) => ({ ticker: entry.ticker, priority: entry.priority })));
|
|
373
|
+
} else {
|
|
374
|
+
for (const entry of financialEntries) {
|
|
367
375
|
refreshTicker(entry.ticker.metadata.ticker, entry.ticker.metadata.exchange, entry.ticker, entry.priority);
|
|
368
|
-
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (refreshQuotesBatch) {
|
|
379
|
+
refreshQuotesBatch(quoteEntries.map((entry) => ({ ticker: entry.ticker, priority: entry.priority })));
|
|
380
|
+
} else {
|
|
381
|
+
for (const entry of quoteEntries) {
|
|
369
382
|
refreshQuote(entry.ticker.metadata.ticker, entry.ticker.metadata.exchange, entry.ticker, entry.priority);
|
|
370
383
|
}
|
|
371
384
|
}
|
|
@@ -3,7 +3,9 @@ import { act, useRef, type Dispatch } from "react";
|
|
|
3
3
|
import { testRender } from "../renderers/opentui/test-utils";
|
|
4
4
|
import { AppProvider, PaneInstanceProvider, useAppDispatch, useAppSelector, usePaneTicker, type AppAction } from "./app-context";
|
|
5
5
|
import { cloneLayout, createDefaultConfig, type AppConfig } from "../types/config";
|
|
6
|
-
import { applyTheme
|
|
6
|
+
import { applyTheme } from "../theme/colors";
|
|
7
|
+
import { useThemeId } from "../theme/theme-context";
|
|
8
|
+
import type { DesktopSharedStateSnapshot, DesktopThemePreviewState, DesktopWindowBridge } from "../types/desktop-window";
|
|
7
9
|
|
|
8
10
|
const TEST_PANE_ID = "ticker-detail:test";
|
|
9
11
|
|
|
@@ -20,6 +22,7 @@ function createTickerDetailConfig(symbol: string): AppConfig {
|
|
|
20
22
|
binding: { kind: "fixed" as const, symbol },
|
|
21
23
|
}],
|
|
22
24
|
floating: [],
|
|
25
|
+
detached: [],
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
return {
|
|
@@ -43,7 +46,46 @@ function PaneTickerHarness() {
|
|
|
43
46
|
|
|
44
47
|
function ThemeSelectorHarness() {
|
|
45
48
|
const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
|
|
46
|
-
|
|
49
|
+
const themeId = useThemeId();
|
|
50
|
+
return <text>{`${focusedPaneId ?? "none"}:${themeId}`}</text>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function FocusSelectorHarness() {
|
|
54
|
+
const renderCountRef = useRef(0);
|
|
55
|
+
renderCountRef.current += 1;
|
|
56
|
+
const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
|
|
57
|
+
return <text>{`${focusedPaneId ?? "none"}:${renderCountRef.current}`}</text>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createDesktopBridge(
|
|
61
|
+
kind: "main" | "detached",
|
|
62
|
+
calls: {
|
|
63
|
+
mainSnapshots?: DesktopSharedStateSnapshot[];
|
|
64
|
+
themePreviews?: DesktopThemePreviewState[];
|
|
65
|
+
onThemePreviewSubscribe?: (listener: (preview: DesktopThemePreviewState) => void) => void;
|
|
66
|
+
} = {},
|
|
67
|
+
): DesktopWindowBridge {
|
|
68
|
+
return {
|
|
69
|
+
kind,
|
|
70
|
+
paneId: kind === "detached" ? TEST_PANE_ID : undefined,
|
|
71
|
+
syncMainState: kind === "main"
|
|
72
|
+
? async (snapshot) => {
|
|
73
|
+
calls.mainSnapshots?.push(snapshot);
|
|
74
|
+
}
|
|
75
|
+
: undefined,
|
|
76
|
+
syncThemePreview: kind === "main"
|
|
77
|
+
? async (preview) => {
|
|
78
|
+
calls.themePreviews?.push(preview);
|
|
79
|
+
}
|
|
80
|
+
: undefined,
|
|
81
|
+
subscribeState: () => () => {},
|
|
82
|
+
subscribeThemePreview: kind === "detached"
|
|
83
|
+
? (listener) => {
|
|
84
|
+
calls.onThemePreviewSubscribe?.(listener);
|
|
85
|
+
return () => {};
|
|
86
|
+
}
|
|
87
|
+
: undefined,
|
|
88
|
+
};
|
|
47
89
|
}
|
|
48
90
|
|
|
49
91
|
describe("pane selectors", () => {
|
|
@@ -100,7 +142,28 @@ describe("pane selectors", () => {
|
|
|
100
142
|
expect(testSetup.captureCharFrame()).toContain("AAPL:1");
|
|
101
143
|
});
|
|
102
144
|
|
|
103
|
-
test("
|
|
145
|
+
test("does not rerender non-theme selector consumers when the theme preview changes", async () => {
|
|
146
|
+
testSetup = await testRender(
|
|
147
|
+
<AppProvider config={createTickerDetailConfig("AAPL")}>
|
|
148
|
+
<DispatchCapture />
|
|
149
|
+
<FocusSelectorHarness />
|
|
150
|
+
</AppProvider>,
|
|
151
|
+
{ width: 32, height: 4 },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
await testSetup.renderOnce();
|
|
155
|
+
expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:1`);
|
|
156
|
+
|
|
157
|
+
await act(() => {
|
|
158
|
+
capturedDispatch?.({ type: "PREVIEW_THEME", theme: "green" });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await testSetup.renderOnce();
|
|
162
|
+
await testSetup.renderOnce();
|
|
163
|
+
expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:1`);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("rerenders theme hook consumers when the theme changes", async () => {
|
|
104
167
|
testSetup = await testRender(
|
|
105
168
|
<AppProvider config={createTickerDetailConfig("AAPL")}>
|
|
106
169
|
<DispatchCapture />
|
|
@@ -121,7 +184,7 @@ describe("pane selectors", () => {
|
|
|
121
184
|
expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
|
|
122
185
|
});
|
|
123
186
|
|
|
124
|
-
test("rerenders
|
|
187
|
+
test("rerenders theme hook consumers when the theme preview changes", async () => {
|
|
125
188
|
testSetup = await testRender(
|
|
126
189
|
<AppProvider config={createTickerDetailConfig("AAPL")}>
|
|
127
190
|
<DispatchCapture />
|
|
@@ -142,6 +205,33 @@ describe("pane selectors", () => {
|
|
|
142
205
|
expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
|
|
143
206
|
});
|
|
144
207
|
|
|
208
|
+
test("set theme updates the theme hook and clears the active preview", async () => {
|
|
209
|
+
testSetup = await testRender(
|
|
210
|
+
<AppProvider config={createTickerDetailConfig("AAPL")}>
|
|
211
|
+
<DispatchCapture />
|
|
212
|
+
<ThemeSelectorHarness />
|
|
213
|
+
</AppProvider>,
|
|
214
|
+
{ width: 32, height: 4 },
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
await testSetup.renderOnce();
|
|
218
|
+
await act(() => {
|
|
219
|
+
capturedDispatch?.({ type: "PREVIEW_THEME", theme: "green" });
|
|
220
|
+
});
|
|
221
|
+
await testSetup.renderOnce();
|
|
222
|
+
expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
|
|
223
|
+
|
|
224
|
+
await act(() => {
|
|
225
|
+
capturedDispatch?.({ type: "SET_THEME", theme: "red" });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await testSetup.renderOnce();
|
|
229
|
+
await testSetup.renderOnce();
|
|
230
|
+
const frame = testSetup.captureCharFrame();
|
|
231
|
+
expect(frame).toContain(`${TEST_PANE_ID}:red`);
|
|
232
|
+
expect(frame).not.toContain(`${TEST_PANE_ID}:green`);
|
|
233
|
+
});
|
|
234
|
+
|
|
145
235
|
test("falls back to the committed theme when theme preview clears", async () => {
|
|
146
236
|
testSetup = await testRender(
|
|
147
237
|
<AppProvider config={createTickerDetailConfig("AAPL")}>
|
|
@@ -181,4 +271,99 @@ describe("pane selectors", () => {
|
|
|
181
271
|
await testSetup.renderOnce();
|
|
182
272
|
expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
|
|
183
273
|
});
|
|
274
|
+
|
|
275
|
+
test("uses the initial desktop theme preview on the first provider render", async () => {
|
|
276
|
+
testSetup = await testRender(
|
|
277
|
+
<AppProvider config={createTickerDetailConfig("AAPL")} initialThemePreview={{ theme: "green" }}>
|
|
278
|
+
<ThemeSelectorHarness />
|
|
279
|
+
</AppProvider>,
|
|
280
|
+
{ width: 32, height: 4 },
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
await testSetup.renderOnce();
|
|
284
|
+
expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("desktop main preview sync does not publish the full state snapshot", async () => {
|
|
288
|
+
const mainSnapshots: DesktopSharedStateSnapshot[] = [];
|
|
289
|
+
const themePreviews: DesktopThemePreviewState[] = [];
|
|
290
|
+
const bridge = createDesktopBridge("main", { mainSnapshots, themePreviews });
|
|
291
|
+
|
|
292
|
+
testSetup = await testRender(
|
|
293
|
+
<AppProvider config={createTickerDetailConfig("AAPL")} desktopBridge={bridge}>
|
|
294
|
+
<DispatchCapture />
|
|
295
|
+
<ThemeSelectorHarness />
|
|
296
|
+
</AppProvider>,
|
|
297
|
+
{ width: 32, height: 4 },
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
await testSetup.renderOnce();
|
|
301
|
+
mainSnapshots.length = 0;
|
|
302
|
+
themePreviews.length = 0;
|
|
303
|
+
|
|
304
|
+
await act(() => {
|
|
305
|
+
capturedDispatch?.({ type: "PREVIEW_THEME", theme: "green" });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await testSetup.renderOnce();
|
|
309
|
+
await testSetup.renderOnce();
|
|
310
|
+
expect(themePreviews).toEqual([{ theme: "green" }]);
|
|
311
|
+
expect(mainSnapshots).toHaveLength(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("desktop committed theme changes still sync through the config snapshot", async () => {
|
|
315
|
+
const mainSnapshots: DesktopSharedStateSnapshot[] = [];
|
|
316
|
+
const themePreviews: DesktopThemePreviewState[] = [];
|
|
317
|
+
const bridge = createDesktopBridge("main", { mainSnapshots, themePreviews });
|
|
318
|
+
|
|
319
|
+
testSetup = await testRender(
|
|
320
|
+
<AppProvider config={createTickerDetailConfig("AAPL")} desktopBridge={bridge}>
|
|
321
|
+
<DispatchCapture />
|
|
322
|
+
<ThemeSelectorHarness />
|
|
323
|
+
</AppProvider>,
|
|
324
|
+
{ width: 32, height: 4 },
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
await testSetup.renderOnce();
|
|
328
|
+
mainSnapshots.length = 0;
|
|
329
|
+
themePreviews.length = 0;
|
|
330
|
+
|
|
331
|
+
await act(() => {
|
|
332
|
+
capturedDispatch?.({ type: "SET_THEME", theme: "green" });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await testSetup.renderOnce();
|
|
336
|
+
await testSetup.renderOnce();
|
|
337
|
+
expect(mainSnapshots).toHaveLength(1);
|
|
338
|
+
expect(mainSnapshots[0]?.config.theme).toBe("green");
|
|
339
|
+
expect(themePreviews).toHaveLength(0);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("desktop detached windows apply theme preview messages locally", async () => {
|
|
343
|
+
let previewListener: ((preview: DesktopThemePreviewState) => void) | null = null;
|
|
344
|
+
const bridge = createDesktopBridge("detached", {
|
|
345
|
+
onThemePreviewSubscribe: (listener) => {
|
|
346
|
+
previewListener = listener;
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
testSetup = await testRender(
|
|
351
|
+
<AppProvider config={createTickerDetailConfig("AAPL")} desktopBridge={bridge}>
|
|
352
|
+
<ThemeSelectorHarness />
|
|
353
|
+
</AppProvider>,
|
|
354
|
+
{ width: 32, height: 4 },
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
await testSetup.renderOnce();
|
|
358
|
+
expect(previewListener).not.toBeNull();
|
|
359
|
+
expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:amber`);
|
|
360
|
+
|
|
361
|
+
await act(() => {
|
|
362
|
+
previewListener?.({ theme: "green" });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await testSetup.renderOnce();
|
|
366
|
+
await testSetup.renderOnce();
|
|
367
|
+
expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
|
|
368
|
+
});
|
|
184
369
|
});
|