gloomberb 0.4.0 → 0.5.0
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 +2 -2
- package/package.json +1 -1
- package/src/app.tsx +184 -69
- package/src/cli/commands/ticker.ts +45 -15
- package/src/cli/helpers.ts +4 -2
- package/src/cli/registry.test.ts +42 -35
- package/src/cli.test.ts +4 -2
- package/src/components/chart/chart-control-hint.tsx +39 -0
- package/src/components/chart/chart-controller.test.ts +110 -0
- package/src/components/chart/chart-controller.ts +364 -2
- package/src/components/chart/chart-data.ts +177 -29
- package/src/components/chart/chart-indicator-selector.tsx +53 -0
- package/src/components/chart/chart-pane-settings.ts +20 -0
- package/src/components/chart/chart-renderer.ts +220 -30
- package/src/components/chart/chart-resolution.test.ts +67 -0
- package/src/components/chart/chart-resolution.ts +74 -4
- package/src/components/chart/chart-scroll.test.ts +69 -0
- package/src/components/chart/chart-scroll.ts +104 -0
- package/src/components/chart/chart-types.ts +12 -0
- package/src/components/chart/chart-viewport.test.ts +5 -1
- package/src/components/chart/chart-viewport.ts +1 -1
- package/src/components/chart/chart.test.ts +249 -10
- package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
- package/src/components/chart/comparison-chart-renderer.ts +40 -3
- package/src/components/chart/comparison-stock-chart.tsx +218 -123
- package/src/components/chart/indicators/bands.test.ts +50 -0
- package/src/components/chart/indicators/bands.ts +29 -0
- package/src/components/chart/indicators/moving-averages.test.ts +54 -0
- package/src/components/chart/indicators/moving-averages.ts +42 -0
- package/src/components/chart/indicators/options.test.ts +32 -0
- package/src/components/chart/indicators/options.ts +107 -0
- package/src/components/chart/indicators/oscillators.test.ts +79 -0
- package/src/components/chart/indicators/oscillators.ts +92 -0
- package/src/components/chart/indicators/types.ts +36 -0
- package/src/components/chart/native/chart-rasterizer.ts +51 -0
- package/src/components/chart/native/kitty.test.ts +68 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
- package/src/components/chart/stock-chart.test.ts +239 -0
- package/src/components/chart/stock-chart.tsx +2257 -372
- package/src/components/command-bar/command-bar.test.tsx +337 -5
- package/src/components/command-bar/command-bar.tsx +296 -120
- package/src/components/command-bar/helpers.ts +1 -1
- package/src/components/command-bar/root-shortcuts.ts +33 -4
- package/src/components/command-bar/view-model.test.ts +3 -3
- package/src/components/command-bar/view-model.ts +4 -4
- package/src/components/command-bar/workflow-ops.ts +10 -0
- package/src/components/layout/floating-pane.tsx +3 -1
- package/src/components/layout/header.tsx +5 -4
- package/src/components/layout/pane-sizing.ts +6 -0
- package/src/components/layout/pane.tsx +5 -1
- package/src/components/layout/shell.test.tsx +263 -32
- package/src/components/layout/shell.tsx +10 -6
- package/src/components/layout/status-bar.test.tsx +16 -7
- package/src/components/layout/status-bar.tsx +4 -1
- package/src/components/markdown-editor.tsx +1 -0
- package/src/components/pane-settings-dialog.tsx +13 -152
- package/src/components/pane-template-wizard.tsx +1 -0
- package/src/components/price-selector-dialog.tsx +12 -7
- package/src/components/ticker-badge-text.test.tsx +50 -0
- package/src/components/ticker-badge-text.tsx +17 -2
- package/src/components/ticker-badge.tsx +2 -2
- package/src/components/ticker-list-table.tsx +62 -2
- package/src/components/toggle-list.tsx +15 -2
- package/src/components/ui/data-table.tsx +82 -5
- package/src/components/ui/external-link.tsx +48 -11
- package/src/components/ui/fields.tsx +2 -1
- package/src/components/ui/index.ts +8 -1
- package/src/components/ui/multi-select-chips.tsx +69 -0
- package/src/components/ui/multi-select-dialog.tsx +267 -0
- package/src/components/ui/multi-select.ts +78 -0
- package/src/components/ui/ui.test.tsx +343 -1
- package/src/data/plugin-state-store.ts +30 -19
- package/src/data/resource-store.ts +133 -103
- package/src/data/session-store.ts +19 -12
- package/src/data/sqlite-cache.test.ts +37 -2
- package/src/data/sqlite-database.ts +8 -2
- package/src/data/sqlite-retry.test.ts +63 -0
- package/src/data/sqlite-retry.ts +66 -0
- package/src/data/ticker-store.ts +29 -18
- package/src/index.tsx +6 -1
- package/src/market-data/coordinator.test.ts +114 -5
- package/src/market-data/coordinator.ts +70 -1
- package/src/market-data/hooks.test.tsx +73 -2
- package/src/market-data/hooks.tsx +16 -4
- package/src/notifications/app-notifier.test.ts +177 -0
- package/src/notifications/app-notifier.ts +241 -0
- package/src/pane-settings.test.ts +2 -0
- package/src/plugins/builtin/ai/index.test.tsx +1 -1
- package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
- package/src/plugins/builtin/ai/ticker-context.ts +3 -2
- package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
- package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
- package/src/plugins/builtin/alerts/index.test.tsx +357 -0
- package/src/plugins/builtin/alerts/index.tsx +519 -0
- package/src/plugins/builtin/alerts/types.ts +14 -0
- package/src/plugins/builtin/analytics/index.test.tsx +300 -0
- package/src/plugins/builtin/analytics/index.tsx +775 -0
- package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
- package/src/plugins/builtin/analytics/metrics.ts +155 -0
- package/src/plugins/builtin/ask-ai.test.tsx +2 -0
- package/src/plugins/builtin/chat-controller.test.ts +92 -18
- package/src/plugins/builtin/chat-controller.ts +40 -11
- package/src/plugins/builtin/chat.test.tsx +715 -4
- package/src/plugins/builtin/chat.tsx +420 -119
- package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
- package/src/plugins/builtin/comparison-chart.tsx +1 -2
- package/src/plugins/builtin/correlation/compute.test.ts +147 -0
- package/src/plugins/builtin/correlation/compute.ts +115 -0
- package/src/plugins/builtin/correlation/index.tsx +385 -0
- package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
- package/src/plugins/builtin/correlation/settings.test.ts +34 -0
- package/src/plugins/builtin/correlation/settings.ts +95 -0
- package/src/plugins/builtin/debug.tsx +36 -50
- package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
- package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
- package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
- package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
- package/src/plugins/builtin/earnings/index.tsx +337 -0
- package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
- package/src/plugins/builtin/econ/calendar-source.ts +121 -0
- package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
- package/src/plugins/builtin/econ/fred-client.ts +79 -0
- package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
- package/src/plugins/builtin/econ/index.tsx +999 -0
- package/src/plugins/builtin/econ/types.ts +13 -0
- package/src/plugins/builtin/fred-settings.ts +10 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
- package/src/plugins/builtin/help.test.tsx +1 -5
- package/src/plugins/builtin/help.tsx +1 -16
- package/src/plugins/builtin/insider/index.tsx +417 -0
- package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
- package/src/plugins/builtin/insider/insider-data.ts +49 -0
- package/src/plugins/builtin/layout-manager.tsx +33 -16
- package/src/plugins/builtin/market-movers/index.tsx +573 -0
- package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
- package/src/plugins/builtin/market-movers/screener.ts +105 -0
- package/src/plugins/builtin/options.tsx +4 -3
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
- package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
- package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
- package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
- package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
- package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
- package/src/plugins/builtin/sec.tsx +114 -7
- package/src/plugins/builtin/sectors/index.tsx +359 -0
- package/src/plugins/builtin/sectors/sector-data.ts +18 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
- package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
- package/src/plugins/builtin/world-indices/index.tsx +410 -0
- package/src/plugins/builtin/world-indices/indices.ts +52 -0
- package/src/plugins/builtin/yield-curve/index.tsx +242 -0
- package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
- package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
- package/src/plugins/catalog.ts +22 -0
- package/src/plugins/gridlock-notification.ts +15 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
- package/src/plugins/ibkr/gateway-service.test.ts +22 -0
- package/src/plugins/ibkr/gateway-service.ts +6 -0
- package/src/plugins/ibkr/index.test.tsx +1 -1
- package/src/plugins/ibkr/index.tsx +8 -2
- package/src/plugins/ibkr/trade-tab.tsx +23 -5
- package/src/plugins/ibkr/trade-utils.test.ts +1 -1
- package/src/plugins/ibkr/trade-utils.ts +7 -6
- package/src/plugins/ibkr/trading-pane.tsx +8 -7
- package/src/plugins/plugin-runtime.test.tsx +2 -0
- package/src/plugins/plugin-runtime.tsx +10 -0
- package/src/plugins/prediction-markets/controller-data.ts +129 -14
- package/src/plugins/prediction-markets/controller.ts +1 -1
- package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
- package/src/plugins/prediction-markets/index.tsx +5 -1
- package/src/plugins/prediction-markets/pane.test.tsx +78 -0
- package/src/plugins/prediction-markets/pane.tsx +7 -3
- package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
- package/src/plugins/prediction-markets/services/fetch.ts +17 -7
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
- package/src/plugins/registry.ts +45 -13
- package/src/sources/provider-router.test.ts +3 -2
- package/src/sources/sec-edgar.ts +13 -1
- package/src/sources/yahoo-finance.ts +75 -1
- package/src/state/app-context-selector.test.tsx +17 -1
- package/src/state/app-context.test.tsx +24 -0
- package/src/state/app-context.tsx +29 -6
- package/src/theme/colors.ts +5 -0
- package/src/theme/themes.ts +25 -0
- package/src/types/config.test.ts +107 -0
- package/src/types/config.ts +30 -1
- package/src/types/data-provider.ts +13 -0
- package/src/types/plugin.ts +35 -1
- package/src/updater.test.ts +2 -0
- package/src/utils/api-client.ts +12 -0
- package/src/utils/format.test.ts +11 -1
- package/src/utils/format.ts +86 -8
- package/src/utils/inline-content-tokenizer.test.ts +22 -0
- package/src/utils/inline-content-tokenizer.ts +76 -0
- package/src/utils/link-tokenizer.test.ts +27 -0
- package/src/utils/link-tokenizer.ts +93 -0
- package/src/utils/market-format.test.ts +109 -0
- package/src/utils/market-format.ts +299 -0
- package/src/utils/market-status.ts +3 -2
- package/src/utils/selection-clipboard.test.ts +43 -0
- package/src/utils/selection-clipboard.ts +88 -0
- package/src/utils/terminal-input-reset.test.ts +50 -0
- package/src/utils/terminal-input-reset.ts +24 -0
- package/src/utils/throttled-fetch.test.ts +145 -0
- package/src/utils/throttled-fetch.ts +212 -0
- package/src/utils/ticker-tokenizer.test.ts +31 -0
- package/src/utils/ticker-tokenizer.ts +7 -42
- package/src/version.ts +1 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { formatCurrency } from "./format";
|
|
2
|
+
|
|
3
|
+
export type AssetDisplayKind = "cash" | "crypto" | "equity" | "contract" | "other";
|
|
4
|
+
|
|
5
|
+
export interface AssetDisplayContext {
|
|
6
|
+
isCashBalance?: boolean;
|
|
7
|
+
assetCategory?: string;
|
|
8
|
+
contractSecType?: string;
|
|
9
|
+
multiplier?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MarketFormatOptions extends AssetDisplayContext {
|
|
13
|
+
maxWidth?: number;
|
|
14
|
+
minimumFractionDigits?: number;
|
|
15
|
+
precisionOffset?: number;
|
|
16
|
+
priceRange?: number;
|
|
17
|
+
fixedFractionDigits?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CASH_TYPES = new Set(["CASH", "FX", "FOREX", "CCY", "CURRENCY", "CURRENCYPAIR"]);
|
|
21
|
+
const CRYPTO_TYPES = new Set(["CRYPTO", "CRYPTOCURRENCY", "COIN", "TOKEN"]);
|
|
22
|
+
const EQUITY_TYPES = new Set(["STK", "STOCK", "EQUITY", "ETF", "ETN", "ETP", "FUND", "MUTUALFUND", "CEF", "ADR"]);
|
|
23
|
+
const CONTRACT_TYPES = new Set(["OPT", "OPTION", "OPTIONS", "FUT", "FUTURE", "FUTURES", "FOP"]);
|
|
24
|
+
|
|
25
|
+
const currencySymbols = new Map<string, string>();
|
|
26
|
+
const numberFormatters = new Map<string, Intl.NumberFormat>();
|
|
27
|
+
|
|
28
|
+
function normalizeType(value?: string): string {
|
|
29
|
+
return (value ?? "").trim().toUpperCase().replace(/[\s_-]+/g, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getNumberFormatter(
|
|
33
|
+
minimumFractionDigits: number,
|
|
34
|
+
maximumFractionDigits: number,
|
|
35
|
+
useGrouping: boolean,
|
|
36
|
+
): Intl.NumberFormat {
|
|
37
|
+
const key = `${minimumFractionDigits}:${maximumFractionDigits}:${useGrouping ? 1 : 0}`;
|
|
38
|
+
let formatter = numberFormatters.get(key);
|
|
39
|
+
if (!formatter) {
|
|
40
|
+
formatter = new Intl.NumberFormat("en-US", {
|
|
41
|
+
minimumFractionDigits,
|
|
42
|
+
maximumFractionDigits,
|
|
43
|
+
useGrouping,
|
|
44
|
+
});
|
|
45
|
+
numberFormatters.set(key, formatter);
|
|
46
|
+
}
|
|
47
|
+
return formatter;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getCurrencySymbol(currency: string): string {
|
|
51
|
+
const normalizedCurrency = currency.trim().toUpperCase() || "USD";
|
|
52
|
+
const cached = currencySymbols.get(normalizedCurrency);
|
|
53
|
+
if (cached) return cached;
|
|
54
|
+
|
|
55
|
+
const formatter = new Intl.NumberFormat("en-US", {
|
|
56
|
+
style: "currency",
|
|
57
|
+
currency: normalizedCurrency,
|
|
58
|
+
currencyDisplay: "symbol",
|
|
59
|
+
minimumFractionDigits: 0,
|
|
60
|
+
maximumFractionDigits: 0,
|
|
61
|
+
});
|
|
62
|
+
const symbol = formatter.formatToParts(0).find((part) => part.type === "currency")?.value ?? normalizedCurrency;
|
|
63
|
+
currencySymbols.set(normalizedCurrency, symbol);
|
|
64
|
+
return symbol;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fitsWidth(text: string, maxWidth: number | undefined): boolean {
|
|
68
|
+
return maxWidth == null || text.length <= maxWidth;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isEffectivelyInteger(value: number): boolean {
|
|
72
|
+
return Math.abs(value - Math.round(value)) < 1e-9;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatVariableNumber(
|
|
76
|
+
value: number,
|
|
77
|
+
maxFractionDigits: number,
|
|
78
|
+
maxWidth: number | undefined,
|
|
79
|
+
minimumFractionDigits = 0,
|
|
80
|
+
): string {
|
|
81
|
+
const groupedModes = [true, false];
|
|
82
|
+
|
|
83
|
+
for (const useGrouping of groupedModes) {
|
|
84
|
+
for (let decimals = maxFractionDigits; decimals >= minimumFractionDigits; decimals -= 1) {
|
|
85
|
+
const formatter = getNumberFormatter(Math.min(minimumFractionDigits, decimals), decimals, useGrouping);
|
|
86
|
+
const formatted = formatter.format(value);
|
|
87
|
+
if (fitsWidth(formatted, maxWidth)) return formatted;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return getNumberFormatter(0, 0, false).format(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getQuantityMaxFractionDigits(kind: AssetDisplayKind, value: number): number {
|
|
95
|
+
if (isEffectivelyInteger(value)) return 0;
|
|
96
|
+
|
|
97
|
+
switch (kind) {
|
|
98
|
+
case "cash":
|
|
99
|
+
return 6;
|
|
100
|
+
case "crypto":
|
|
101
|
+
return 8;
|
|
102
|
+
case "equity":
|
|
103
|
+
return 4;
|
|
104
|
+
case "contract":
|
|
105
|
+
return 4;
|
|
106
|
+
case "other":
|
|
107
|
+
default:
|
|
108
|
+
return 4;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getBasePriceMaxFractionDigits(kind: AssetDisplayKind, value: number): number {
|
|
113
|
+
switch (kind) {
|
|
114
|
+
case "cash":
|
|
115
|
+
return 6;
|
|
116
|
+
case "crypto":
|
|
117
|
+
return 8;
|
|
118
|
+
case "equity":
|
|
119
|
+
return Math.abs(value) >= 1 ? 2 : 4;
|
|
120
|
+
case "contract":
|
|
121
|
+
return 4;
|
|
122
|
+
case "other":
|
|
123
|
+
default:
|
|
124
|
+
return Math.abs(value) >= 1 ? 2 : 4;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getAdaptivePriceFractionDigits(priceRange: number | undefined, precisionOffset = 0): number | null {
|
|
129
|
+
if (priceRange === undefined || !Number.isFinite(priceRange) || priceRange <= 0) return null;
|
|
130
|
+
|
|
131
|
+
// The chart price axis renders four labels by default, so the visible range
|
|
132
|
+
// divided across three intervals is a good proxy for the current zoom step.
|
|
133
|
+
const visibleStep = priceRange / 3;
|
|
134
|
+
if (!Number.isFinite(visibleStep) || visibleStep <= 0) return null;
|
|
135
|
+
|
|
136
|
+
return Math.max(0, Math.ceil(-Math.log10(visibleStep)) + precisionOffset);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getPriceMaxFractionDigits(
|
|
140
|
+
kind: AssetDisplayKind,
|
|
141
|
+
value: number,
|
|
142
|
+
priceRange: number | undefined,
|
|
143
|
+
precisionOffset: number,
|
|
144
|
+
): number {
|
|
145
|
+
const baseMaxFractionDigits = kind === "other" && priceRange !== undefined
|
|
146
|
+
? 6
|
|
147
|
+
: getBasePriceMaxFractionDigits(kind, value);
|
|
148
|
+
const adaptiveFractionDigits = getAdaptivePriceFractionDigits(priceRange, precisionOffset);
|
|
149
|
+
return adaptiveFractionDigits === null
|
|
150
|
+
? baseMaxFractionDigits
|
|
151
|
+
: Math.min(baseMaxFractionDigits, adaptiveFractionDigits);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getCostMaxFractionDigits(kind: AssetDisplayKind): number {
|
|
155
|
+
switch (kind) {
|
|
156
|
+
case "cash":
|
|
157
|
+
return 6;
|
|
158
|
+
case "crypto":
|
|
159
|
+
return 8;
|
|
160
|
+
case "contract":
|
|
161
|
+
return 4;
|
|
162
|
+
case "equity":
|
|
163
|
+
return 2;
|
|
164
|
+
case "other":
|
|
165
|
+
default:
|
|
166
|
+
return 2;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function compactScaledPrice(
|
|
171
|
+
value: number,
|
|
172
|
+
divisor: number,
|
|
173
|
+
suffix: string,
|
|
174
|
+
currency: string,
|
|
175
|
+
maxWidth: number | undefined,
|
|
176
|
+
): string {
|
|
177
|
+
const sign = value < 0 ? "-" : "";
|
|
178
|
+
const symbol = getCurrencySymbol(currency);
|
|
179
|
+
const numericWidth = maxWidth == null
|
|
180
|
+
? undefined
|
|
181
|
+
: Math.max(1, maxWidth - sign.length - symbol.length - suffix.length);
|
|
182
|
+
const formatted = formatVariableNumber(Math.abs(value) / divisor, 1, numericWidth, 1);
|
|
183
|
+
return `${sign}${symbol}${formatted}${suffix}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function resolveAssetDisplayKind({
|
|
187
|
+
isCashBalance,
|
|
188
|
+
assetCategory,
|
|
189
|
+
contractSecType,
|
|
190
|
+
multiplier,
|
|
191
|
+
}: AssetDisplayContext): AssetDisplayKind {
|
|
192
|
+
if (isCashBalance) return "cash";
|
|
193
|
+
|
|
194
|
+
const normalizedType = normalizeType(contractSecType || assetCategory);
|
|
195
|
+
if (CASH_TYPES.has(normalizedType) || normalizedType.includes("FOREX") || normalizedType.includes("CURRENCY")) return "cash";
|
|
196
|
+
if (CRYPTO_TYPES.has(normalizedType) || normalizedType.includes("CRYPTO")) return "crypto";
|
|
197
|
+
if (EQUITY_TYPES.has(normalizedType)) return "equity";
|
|
198
|
+
if (CONTRACT_TYPES.has(normalizedType)) return "contract";
|
|
199
|
+
if ((multiplier ?? 1) > 1) return "contract";
|
|
200
|
+
return "other";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function formatMarketQuantity(value: number | undefined, options: MarketFormatOptions = {}): string {
|
|
204
|
+
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
205
|
+
const kind = resolveAssetDisplayKind(options);
|
|
206
|
+
const maxFractionDigits = getQuantityMaxFractionDigits(kind, value);
|
|
207
|
+
return formatVariableNumber(value, maxFractionDigits, options.maxWidth);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function formatMarketPrice(value: number | undefined, options: MarketFormatOptions = {}): string {
|
|
211
|
+
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
212
|
+
const kind = resolveAssetDisplayKind(options);
|
|
213
|
+
const fixedFractionDigits = options.fixedFractionDigits;
|
|
214
|
+
if (fixedFractionDigits !== undefined) {
|
|
215
|
+
const maxFractionDigits = kind === "other" && options.priceRange !== undefined
|
|
216
|
+
? 6
|
|
217
|
+
: getBasePriceMaxFractionDigits(kind, value);
|
|
218
|
+
const clampedFixedFractionDigits = Math.max(0, Math.min(fixedFractionDigits, maxFractionDigits));
|
|
219
|
+
return formatVariableNumber(value, clampedFixedFractionDigits, options.maxWidth, clampedFixedFractionDigits);
|
|
220
|
+
}
|
|
221
|
+
const minimumFractionDigits = Math.max(
|
|
222
|
+
0,
|
|
223
|
+
Math.min(options.minimumFractionDigits ?? 0, getBasePriceMaxFractionDigits(kind, value)),
|
|
224
|
+
);
|
|
225
|
+
const maxFractionDigits = Math.max(
|
|
226
|
+
getPriceMaxFractionDigits(kind, value, options.priceRange, options.precisionOffset ?? 0),
|
|
227
|
+
minimumFractionDigits,
|
|
228
|
+
);
|
|
229
|
+
return formatVariableNumber(value, maxFractionDigits, options.maxWidth, minimumFractionDigits);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function formatMarketCost(value: number | undefined, options: MarketFormatOptions = {}): string {
|
|
233
|
+
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
234
|
+
const kind = resolveAssetDisplayKind(options);
|
|
235
|
+
return formatVariableNumber(value, getCostMaxFractionDigits(kind), options.maxWidth);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function formatSignedMarketPrice(value: number | undefined, options: MarketFormatOptions = {}): string {
|
|
239
|
+
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
240
|
+
if (value > 0) {
|
|
241
|
+
const maxWidth = options.maxWidth == null ? undefined : Math.max(1, options.maxWidth - 1);
|
|
242
|
+
return `+${formatMarketPrice(value, { ...options, maxWidth })}`;
|
|
243
|
+
}
|
|
244
|
+
return formatMarketPrice(value, options);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function formatMarketPriceWithCurrency(
|
|
248
|
+
value: number | undefined,
|
|
249
|
+
currency = "USD",
|
|
250
|
+
options: MarketFormatOptions = {},
|
|
251
|
+
): string {
|
|
252
|
+
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
253
|
+
const normalizedCurrency = currency.trim().toUpperCase() || "USD";
|
|
254
|
+
const sign = value < 0 ? "-" : "";
|
|
255
|
+
const symbol = getCurrencySymbol(normalizedCurrency);
|
|
256
|
+
const numericWidth = options.maxWidth == null
|
|
257
|
+
? undefined
|
|
258
|
+
: Math.max(1, options.maxWidth - sign.length - symbol.length);
|
|
259
|
+
const body = formatMarketPrice(Math.abs(value), { ...options, maxWidth: numericWidth });
|
|
260
|
+
return `${sign}${symbol}${body}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function formatMarketCostWithCurrency(
|
|
264
|
+
value: number | undefined,
|
|
265
|
+
currency = "USD",
|
|
266
|
+
options: MarketFormatOptions = {},
|
|
267
|
+
): string {
|
|
268
|
+
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
269
|
+
const normalizedCurrency = currency.trim().toUpperCase() || "USD";
|
|
270
|
+
const sign = value < 0 ? "-" : "";
|
|
271
|
+
const symbol = getCurrencySymbol(normalizedCurrency);
|
|
272
|
+
const numericWidth = options.maxWidth == null
|
|
273
|
+
? undefined
|
|
274
|
+
: Math.max(1, options.maxWidth - sign.length - symbol.length);
|
|
275
|
+
const body = formatMarketCost(Math.abs(value), { ...options, maxWidth: numericWidth });
|
|
276
|
+
return `${sign}${symbol}${body}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function formatCompactMarketPriceWithCurrency(
|
|
280
|
+
value: number | undefined,
|
|
281
|
+
currency = "USD",
|
|
282
|
+
options: MarketFormatOptions = {},
|
|
283
|
+
): string {
|
|
284
|
+
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
285
|
+
const abs = Math.abs(value);
|
|
286
|
+
|
|
287
|
+
if (abs >= 1e6) {
|
|
288
|
+
return compactScaledPrice(value, 1e6, "M", currency, options.maxWidth);
|
|
289
|
+
}
|
|
290
|
+
if (abs >= 1e3 && abs < 1e5) {
|
|
291
|
+
return compactScaledPrice(value, 1e3, "K", currency, options.maxWidth);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return formatMarketPriceWithCurrency(value, currency, options);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function formatMarketMoney(value: number | undefined, currency = "USD"): string {
|
|
298
|
+
return formatCurrency(value, currency);
|
|
299
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MarketState, Quote, QuoteFieldProvenance } from "../types/financials";
|
|
2
2
|
import { colors, priceColor } from "../theme/colors";
|
|
3
3
|
import { formatPercentRaw } from "./format";
|
|
4
|
+
import { formatMarketPrice } from "./market-format";
|
|
4
5
|
|
|
5
6
|
export interface ActiveQuoteDisplay {
|
|
6
7
|
price: number;
|
|
@@ -107,11 +108,11 @@ export function getExtendedHoursInfo(quote: Quote | null | undefined): { text: s
|
|
|
107
108
|
if (!quote) return null;
|
|
108
109
|
if ((quote.marketState === "PRE" || quote.marketState === "PREPRE") && quote.preMarketPrice != null) {
|
|
109
110
|
const chg = quote.preMarketChangePercent ?? 0;
|
|
110
|
-
return { text: `Pre ${quote.preMarketPrice
|
|
111
|
+
return { text: `Pre ${formatMarketPrice(quote.preMarketPrice)} ${formatPercentRaw(chg)}`, color: priceColor(chg) };
|
|
111
112
|
}
|
|
112
113
|
if ((quote.marketState === "POST" || quote.marketState === "POSTPOST") && quote.postMarketPrice != null) {
|
|
113
114
|
const chg = quote.postMarketChangePercent ?? 0;
|
|
114
|
-
return { text: `AH ${quote.postMarketPrice
|
|
115
|
+
return { text: `AH ${formatMarketPrice(quote.postMarketPrice)} ${formatPercentRaw(chg)}`, color: priceColor(chg) };
|
|
115
116
|
}
|
|
116
117
|
return null;
|
|
117
118
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { copyActiveSelection, isCopyShortcut, isPasteShortcut } from "./selection-clipboard";
|
|
3
|
+
|
|
4
|
+
describe("selection clipboard sync", () => {
|
|
5
|
+
test("copies the active selection when present", () => {
|
|
6
|
+
const renderer = {
|
|
7
|
+
copyToClipboardOSC52: (text: string) => text === "AAPL",
|
|
8
|
+
getSelection: () => ({
|
|
9
|
+
getSelectedText: () => "AAPL",
|
|
10
|
+
}),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
expect(copyActiveSelection(renderer as any)).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("ignores empty or missing selections", () => {
|
|
17
|
+
expect(copyActiveSelection({
|
|
18
|
+
copyToClipboardOSC52: () => true,
|
|
19
|
+
getSelection: () => null,
|
|
20
|
+
} as any)).toBe(false);
|
|
21
|
+
|
|
22
|
+
expect(copyActiveSelection({
|
|
23
|
+
copyToClipboardOSC52: () => true,
|
|
24
|
+
getSelection: () => ({
|
|
25
|
+
getSelectedText: () => "",
|
|
26
|
+
}),
|
|
27
|
+
} as any)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("matches explicit copy shortcuts only", () => {
|
|
31
|
+
expect(isCopyShortcut({ name: "c", super: true, meta: false, ctrl: false, shift: false } as any)).toBe(true);
|
|
32
|
+
expect(isCopyShortcut({ name: "c", super: false, meta: true, ctrl: false, shift: false } as any)).toBe(true);
|
|
33
|
+
expect(isCopyShortcut({ name: "c", super: false, meta: false, ctrl: true, shift: true } as any)).toBe(true);
|
|
34
|
+
expect(isCopyShortcut({ name: "c", super: false, ctrl: true, shift: false } as any)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("matches explicit paste shortcuts only", () => {
|
|
38
|
+
expect(isPasteShortcut({ name: "v", super: true, meta: false, ctrl: false, shift: false } as any)).toBe(true);
|
|
39
|
+
expect(isPasteShortcut({ name: "v", super: false, meta: true, ctrl: false, shift: false } as any)).toBe(true);
|
|
40
|
+
expect(isPasteShortcut({ name: "v", super: false, meta: false, ctrl: true, shift: true } as any)).toBe(true);
|
|
41
|
+
expect(isPasteShortcut({ name: "v", super: false, meta: false, ctrl: true, shift: false } as any)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { CliRenderer, KeyEvent } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
function readTextFromSystemClipboard(): string | null {
|
|
4
|
+
if (process.platform !== "darwin") {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const result = Bun.spawnSync(["pbpaste"], {
|
|
10
|
+
stdout: "pipe",
|
|
11
|
+
stderr: "ignore",
|
|
12
|
+
});
|
|
13
|
+
if (!result.success) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return result.stdout.toString().replace(/\r\n/g, "\n");
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function copyTextToSystemClipboard(text: string): boolean {
|
|
23
|
+
if (text.length === 0 || process.platform !== "darwin") {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const result = Bun.spawnSync(["pbcopy"], {
|
|
29
|
+
stdin: new Blob([text]),
|
|
30
|
+
stdout: "ignore",
|
|
31
|
+
stderr: "ignore",
|
|
32
|
+
});
|
|
33
|
+
return result.success;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function copySelectionText(
|
|
40
|
+
renderer: Pick<CliRenderer, "copyToClipboardOSC52">,
|
|
41
|
+
text: string,
|
|
42
|
+
): boolean {
|
|
43
|
+
if (text.length === 0) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return renderer.copyToClipboardOSC52(text) || copyTextToSystemClipboard(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function copyActiveSelection(
|
|
51
|
+
renderer: Pick<CliRenderer, "getSelection" | "copyToClipboardOSC52">,
|
|
52
|
+
): boolean {
|
|
53
|
+
const text = renderer.getSelection()?.getSelectedText() ?? "";
|
|
54
|
+
return copySelectionText(renderer, text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isCopyShortcut(
|
|
58
|
+
event: Pick<KeyEvent, "name" | "super" | "meta" | "ctrl" | "shift">,
|
|
59
|
+
): boolean {
|
|
60
|
+
const keyName = event.name.toLowerCase();
|
|
61
|
+
return keyName === "c" && (
|
|
62
|
+
event.super === true
|
|
63
|
+
|| event.meta === true
|
|
64
|
+
|| (event.ctrl === true && event.shift === true)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isPasteShortcut(
|
|
69
|
+
event: Pick<KeyEvent, "name" | "super" | "meta" | "ctrl" | "shift">,
|
|
70
|
+
): boolean {
|
|
71
|
+
const keyName = event.name.toLowerCase();
|
|
72
|
+
return keyName === "v" && (
|
|
73
|
+
event.super === true
|
|
74
|
+
|| event.meta === true
|
|
75
|
+
|| (event.ctrl === true && event.shift === true)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function pasteSystemClipboard(
|
|
80
|
+
renderer: Pick<CliRenderer, "keyInput">,
|
|
81
|
+
): boolean {
|
|
82
|
+
const text = readTextFromSystemClipboard();
|
|
83
|
+
if (!text) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
renderer.keyInput.processPaste(new TextEncoder().encode(text));
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resetTerminalInputState, TERMINAL_MOUSE_RESET_SEQUENCE } from "./terminal-input-reset";
|
|
3
|
+
|
|
4
|
+
describe("resetTerminalInputState", () => {
|
|
5
|
+
test("disables mouse tracking and exits raw mode before renderer startup", () => {
|
|
6
|
+
const writes: string[] = [];
|
|
7
|
+
const rawModeCalls: boolean[] = [];
|
|
8
|
+
|
|
9
|
+
resetTerminalInputState(
|
|
10
|
+
{
|
|
11
|
+
isTTY: true,
|
|
12
|
+
setRawMode(mode: boolean) {
|
|
13
|
+
rawModeCalls.push(mode);
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
isTTY: true,
|
|
18
|
+
write(chunk: string) {
|
|
19
|
+
writes.push(chunk);
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(writes).toEqual([TERMINAL_MOUSE_RESET_SEQUENCE]);
|
|
25
|
+
expect(rawModeCalls).toEqual([false]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("does not write terminal escapes when stdout is not a tty", () => {
|
|
29
|
+
const writes: string[] = [];
|
|
30
|
+
const rawModeCalls: boolean[] = [];
|
|
31
|
+
|
|
32
|
+
resetTerminalInputState(
|
|
33
|
+
{
|
|
34
|
+
isTTY: true,
|
|
35
|
+
setRawMode(mode: boolean) {
|
|
36
|
+
rawModeCalls.push(mode);
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
isTTY: false,
|
|
41
|
+
write(chunk: string) {
|
|
42
|
+
writes.push(chunk);
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(writes).toEqual([]);
|
|
48
|
+
expect(rawModeCalls).toEqual([false]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const TERMINAL_MOUSE_RESET_SEQUENCE = "\x1B[?1016l\x1B[?1006l\x1B[?1005l\x1B[?1015l\x1B[?1003l\x1B[?1002l\x1B[?1000l";
|
|
2
|
+
|
|
3
|
+
interface TerminalResetStdin {
|
|
4
|
+
isTTY?: boolean;
|
|
5
|
+
setRawMode?: (mode: boolean) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface TerminalResetStdout {
|
|
9
|
+
isTTY?: boolean;
|
|
10
|
+
write: (chunk: string) => unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resetTerminalInputState(
|
|
14
|
+
stdin: TerminalResetStdin = process.stdin,
|
|
15
|
+
stdout: TerminalResetStdout = process.stdout,
|
|
16
|
+
): void {
|
|
17
|
+
if (stdout.isTTY) {
|
|
18
|
+
stdout.write(TERMINAL_MOUSE_RESET_SEQUENCE);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (stdin.isTTY !== false && typeof stdin.setRawMode === "function") {
|
|
22
|
+
stdin.setRawMode(false);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
import { createThrottledFetch } from "./throttled-fetch";
|
|
3
|
+
|
|
4
|
+
// Mock global fetch
|
|
5
|
+
const originalFetch = globalThis.fetch;
|
|
6
|
+
let fetchMock: ReturnType<typeof mock>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
fetchMock = mock(() => Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })));
|
|
10
|
+
globalThis.fetch = fetchMock as any;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("createThrottledFetch", () => {
|
|
14
|
+
test("passes through a simple request", async () => {
|
|
15
|
+
const client = createThrottledFetch();
|
|
16
|
+
const resp = await client.fetch("https://api.example.com/test");
|
|
17
|
+
expect(resp.status).toBe(200);
|
|
18
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("merges default headers", async () => {
|
|
22
|
+
const client = createThrottledFetch({
|
|
23
|
+
defaultHeaders: { "X-Custom": "value" },
|
|
24
|
+
});
|
|
25
|
+
await client.fetch("https://api.example.com/test");
|
|
26
|
+
const callInit = fetchMock.mock.calls[0]![1] as RequestInit;
|
|
27
|
+
expect((callInit.headers as Record<string, string>)["X-Custom"]).toBe("value");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("deduplicates concurrent GET requests to same URL", async () => {
|
|
31
|
+
let resolveFirst: (r: Response) => void;
|
|
32
|
+
const slowResponse = new Promise<Response>((resolve) => { resolveFirst = resolve; });
|
|
33
|
+
fetchMock = mock(() => slowResponse);
|
|
34
|
+
globalThis.fetch = fetchMock as any;
|
|
35
|
+
|
|
36
|
+
const client = createThrottledFetch();
|
|
37
|
+
const p1 = client.fetch("https://api.example.com/same");
|
|
38
|
+
const p2 = client.fetch("https://api.example.com/same");
|
|
39
|
+
|
|
40
|
+
resolveFirst!(new Response("ok", { status: 200 }));
|
|
41
|
+
const [r1, r2] = await Promise.all([p1, p2]);
|
|
42
|
+
expect(r1).not.toBe(r2);
|
|
43
|
+
expect(await r1.text()).toBe("ok");
|
|
44
|
+
expect(await r2.text()).toBe("ok");
|
|
45
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("does not deduplicate different URLs", async () => {
|
|
49
|
+
const client = createThrottledFetch();
|
|
50
|
+
await Promise.all([
|
|
51
|
+
client.fetch("https://api.example.com/a"),
|
|
52
|
+
client.fetch("https://api.example.com/b"),
|
|
53
|
+
]);
|
|
54
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("retries on 429 with backoff", async () => {
|
|
58
|
+
let callCount = 0;
|
|
59
|
+
fetchMock = mock(() => {
|
|
60
|
+
callCount++;
|
|
61
|
+
if (callCount === 1) {
|
|
62
|
+
return Promise.resolve(new Response("rate limited", { status: 429 }));
|
|
63
|
+
}
|
|
64
|
+
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
65
|
+
});
|
|
66
|
+
globalThis.fetch = fetchMock as any;
|
|
67
|
+
|
|
68
|
+
const client = createThrottledFetch({ maxRetries: 1, backoffBaseMs: 0 });
|
|
69
|
+
const resp = await client.fetch("https://api.example.com/test");
|
|
70
|
+
expect(resp.status).toBe(200);
|
|
71
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("retries on 500 with backoff", async () => {
|
|
75
|
+
let callCount = 0;
|
|
76
|
+
fetchMock = mock(() => {
|
|
77
|
+
callCount++;
|
|
78
|
+
if (callCount === 1) {
|
|
79
|
+
return Promise.resolve(new Response("error", { status: 500 }));
|
|
80
|
+
}
|
|
81
|
+
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
82
|
+
});
|
|
83
|
+
globalThis.fetch = fetchMock as any;
|
|
84
|
+
|
|
85
|
+
const client = createThrottledFetch({ maxRetries: 1, backoffBaseMs: 0 });
|
|
86
|
+
const resp = await client.fetch("https://api.example.com/test");
|
|
87
|
+
expect(resp.status).toBe(200);
|
|
88
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("retries transient fetch failures", async () => {
|
|
92
|
+
let callCount = 0;
|
|
93
|
+
fetchMock = mock(() => {
|
|
94
|
+
callCount++;
|
|
95
|
+
if (callCount === 1) {
|
|
96
|
+
return Promise.reject(
|
|
97
|
+
Object.assign(new Error("The socket connection was closed unexpectedly."), {
|
|
98
|
+
code: "ECONNRESET",
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
103
|
+
});
|
|
104
|
+
globalThis.fetch = fetchMock as any;
|
|
105
|
+
|
|
106
|
+
const client = createThrottledFetch({ maxRetries: 1, backoffBaseMs: 0 });
|
|
107
|
+
const resp = await client.fetch("https://api.example.com/test");
|
|
108
|
+
expect(resp.status).toBe(200);
|
|
109
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("stops retrying after max retries", async () => {
|
|
113
|
+
fetchMock = mock(() => Promise.resolve(new Response("error", { status: 429 })));
|
|
114
|
+
globalThis.fetch = fetchMock as any;
|
|
115
|
+
|
|
116
|
+
const client = createThrottledFetch({ maxRetries: 1, backoffBaseMs: 0 });
|
|
117
|
+
const resp = await client.fetch("https://api.example.com/test");
|
|
118
|
+
expect(resp.status).toBe(429);
|
|
119
|
+
expect(fetchMock).toHaveBeenCalledTimes(2); // initial + 1 retry
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("fetchJson parses response and throws on error", async () => {
|
|
123
|
+
const client = createThrottledFetch();
|
|
124
|
+
const data = await client.fetchJson<{ ok: boolean }>("https://api.example.com/test");
|
|
125
|
+
expect(data).toEqual({ ok: true });
|
|
126
|
+
|
|
127
|
+
fetchMock = mock(() => Promise.resolve(new Response("not found", { status: 404 })));
|
|
128
|
+
globalThis.fetch = fetchMock as any;
|
|
129
|
+
const client2 = createThrottledFetch({ maxRetries: 0 });
|
|
130
|
+
await expect(client2.fetchJson("https://api.example.com/test")).rejects.toThrow("HTTP 404");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("fetchJson throws friendly message on 429", async () => {
|
|
134
|
+
fetchMock = mock(() => Promise.resolve(new Response("", { status: 429 })));
|
|
135
|
+
globalThis.fetch = fetchMock as any;
|
|
136
|
+
|
|
137
|
+
const client = createThrottledFetch({ maxRetries: 0 });
|
|
138
|
+
await expect(client.fetchJson("https://api.example.com/test")).rejects.toThrow("Rate limited");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Restore
|
|
143
|
+
afterAll(() => {
|
|
144
|
+
globalThis.fetch = originalFetch;
|
|
145
|
+
});
|