gloomberb 0.10.2 → 0.10.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 +3 -3
- package/README.zh-CN.md +2 -2
- package/package.json +2 -1
- package/src/api-client/auth.ts +8 -0
- package/src/api-client/index.ts +70 -0
- package/src/api-client/types.ts +49 -0
- package/src/app/runtime/desktop-deeplink.test.ts +8 -0
- package/src/app/runtime/desktop-deeplink.ts +14 -1
- package/src/components/chart/composite/composite-chart.test.tsx +340 -17
- package/src/components/chart/composite/composite-chart.tsx +859 -68
- package/src/components/chart/composite/interactions.test.ts +78 -22
- package/src/components/chart/composite/interactions.ts +45 -17
- package/src/components/chart/composite/rasterizer.ts +8 -2
- package/src/components/chart/composite/renderers.test.ts +61 -1
- package/src/components/chart/composite/scene.ts +14 -4
- package/src/components/chart/composite/tools.test.ts +371 -0
- package/src/components/chart/composite/tools.ts +508 -0
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/native/raster/primitives.ts +18 -0
- package/src/components/chart/price-axis-labels.tsx +42 -2
- package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
- package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
- package/src/components/command-bar/action-runtime.ts +3 -0
- package/src/components/command-bar/assist/inventory.test.ts +93 -0
- package/src/components/command-bar/assist/inventory.ts +92 -0
- package/src/components/command-bar/assist/model.test.ts +104 -0
- package/src/components/command-bar/assist/model.ts +169 -0
- package/src/components/command-bar/assist/runtime.ts +185 -0
- package/src/components/command-bar/commands/direct/index.ts +1 -1
- package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
- package/src/components/command-bar/list/model.ts +15 -2
- package/src/components/command-bar/list/navigation.ts +6 -0
- package/src/components/command-bar/list/view.tsx +16 -3
- package/src/components/command-bar/pane-templates/workflow.ts +1 -0
- package/src/components/command-bar/panel/body.tsx +2 -0
- package/src/components/command-bar/panel/index.tsx +3 -0
- package/src/components/command-bar/panel/runtime.ts +6 -0
- package/src/components/command-bar/panel/types.ts +1 -0
- package/src/components/command-bar/routes/root/results.ts +28 -2
- package/src/components/command-bar/routes/root/runtime.ts +66 -36
- package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
- package/src/components/command-bar/routing/navigation-state.ts +17 -0
- package/src/components/command-bar/selection-runtime.ts +26 -0
- package/src/components/command-bar/surface/assist.test.tsx +276 -0
- package/src/components/command-bar/surface/index.tsx +93 -2
- package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
- package/src/components/command-bar/view-model.ts +7 -1
- package/src/components/command-bar/workflow/broker.test.ts +22 -0
- package/src/components/command-bar/workflow/broker.ts +1 -0
- package/src/components/index.ts +1 -1
- package/src/components/layout/detached-pane-shell.tsx +40 -2
- package/src/components/layout/floating-pane.tsx +4 -1
- package/src/components/layout/pane/header.tsx +64 -6
- package/src/components/layout/pane/index.tsx +4 -1
- package/src/components/layout/shell/index.tsx +23 -0
- package/src/components/layout/shell/pane/layers.tsx +5 -0
- package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
- package/src/components/onboarding/account-step/form-panel.tsx +157 -0
- package/src/components/onboarding/account-step/index.tsx +58 -0
- package/src/components/onboarding/account-step/model.test.ts +67 -0
- package/src/components/onboarding/account-step/model.ts +126 -0
- package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
- package/src/components/onboarding/onboarding-steps.tsx +91 -55
- package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
- package/src/components/onboarding/onboarding-wizard.tsx +93 -9
- package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
- package/src/components/onboarding/wizard-account.ts +235 -0
- package/src/components/onboarding/wizard-keyboard.ts +67 -1
- package/src/components/onboarding/wizard-model.ts +2 -2
- package/src/components/ui/data-table/index.tsx +1 -0
- package/src/components/ui/data-table/opentui/index.tsx +44 -2
- package/src/components/ui/data-table/types.ts +10 -0
- package/src/components/ui/data-table/visible-range.test.ts +29 -0
- package/src/components/ui/data-table/visible-range.ts +21 -0
- package/src/components/ui/ui.test.tsx +23 -1
- package/src/i18n/es.ts +1093 -0
- package/src/i18n/index.test.ts +5 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/ja.ts +66 -3
- package/src/i18n/ko.ts +66 -3
- package/src/i18n/languages.test.ts +4 -1
- package/src/i18n/languages.ts +12 -1
- package/src/i18n/zh-cn.ts +66 -3
- package/src/i18n/zh-tw.ts +66 -3
- package/src/market-data/coordinator/index.ts +1 -1
- package/src/market-data/coordinator/quotes.ts +64 -1
- package/src/market-data/coordinator/subscriptions.test.ts +72 -0
- package/src/market-data/quotes/cadence.ts +2 -0
- package/src/plugins/builtin/account-management/footer.ts +9 -3
- package/src/plugins/builtin/account-management/model.ts +53 -1
- package/src/plugins/builtin/account-management/pane.tsx +107 -24
- package/src/plugins/builtin/ai/index.tsx +7 -2
- package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
- package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
- package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
- package/src/plugins/builtin/chart-composer/index.tsx +10 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
- package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
- package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
- package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
- package/src/plugins/builtin/cloud/plugin.tsx +9 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
- package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
- package/src/plugins/builtin/help/index.tsx +38 -0
- package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
- package/src/plugins/builtin/options/footer.test.ts +20 -112
- package/src/plugins/builtin/options/footer.ts +35 -78
- package/src/plugins/builtin/options/index.tsx +6 -0
- package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
- package/src/plugins/builtin/options/live-quotes.ts +93 -39
- package/src/plugins/builtin/options/view.test.tsx +5 -2
- package/src/plugins/builtin/options/view.tsx +49 -9
- package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
- package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
- package/src/plugins/builtin/shared/live-streaming.ts +60 -0
- package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
- package/src/plugins/builtin/shared/plan-access.ts +109 -0
- package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
- package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
- package/src/plugins/registry/index.test.ts +51 -0
- package/src/plugins/registry/index.ts +12 -0
- package/src/plugins/registry/pane-settings.ts +29 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
- package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
- package/src/renderers/electrobun/view/host/box.tsx +3 -0
- package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
- package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
- package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
- package/src/renderers/electrobun/view/host/input.tsx +16 -1
- package/src/renderers/electrobun/view/host/mouse.ts +1 -0
- package/src/renderers/electrobun/view/host/style.ts +1 -1
- package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
- package/src/renderers/electrobun/view/main.tsx +2 -0
- package/src/renderers/opentui/chart-surface.tsx +2 -0
- package/src/renderers/opentui/host.tsx +3 -0
- package/src/sources/gloomberb-cloud/index.test.ts +8 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -4
- package/src/sources/provider-router/history.test.ts +3 -3
- package/src/sources/provider-router/history.ts +15 -4
- package/src/sources/yahoo-finance/history.test.ts +47 -0
- package/src/sources/yahoo-finance/history.ts +5 -1
- package/src/state/hooks/quote-streaming.test.tsx +181 -1
- package/src/state/hooks/quote-streaming.ts +93 -7
- package/src/theme/colors.ts +20 -1
- package/src/theme/themes.test.ts +3 -0
- package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
- package/src/time-series/resolution.test.ts +51 -0
- package/src/time-series/resolution.ts +41 -0
- package/src/time-series/resolve.test.ts +207 -1
- package/src/time-series/resolve.ts +196 -29
- package/src/time-series/spec-studies.test.ts +4 -0
- package/src/time-series/spec.ts +15 -1
- package/src/time-series/types.ts +7 -0
- package/src/time-series/use-chart-resolution.test.tsx +37 -0
- package/src/time-series/use-chart-resolution.ts +17 -1
- package/src/types/plugin.ts +9 -0
- package/src/ui/host.tsx +17 -0
|
@@ -15,7 +15,9 @@ import { measurePerf } from "../../../../utils/perf-marks";
|
|
|
15
15
|
import type {
|
|
16
16
|
DataTableColumn,
|
|
17
17
|
DataTableProps,
|
|
18
|
+
DataTableVisibleRange,
|
|
18
19
|
} from "../../../../components/ui/data-table";
|
|
20
|
+
import { resolveDataTableVisibleRange } from "../../../../components/ui/data-table/visible-range";
|
|
19
21
|
import {
|
|
20
22
|
buildTableGridTemplateColumns,
|
|
21
23
|
getTableWidth,
|
|
@@ -57,6 +59,8 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
57
59
|
onSelect,
|
|
58
60
|
onActivate,
|
|
59
61
|
onTableMouseDown,
|
|
62
|
+
visibleRangeKey,
|
|
63
|
+
onVisibleRangeChange,
|
|
60
64
|
onRowMouseDown,
|
|
61
65
|
onRowContextMenu,
|
|
62
66
|
rowContextMenuSurface = false,
|
|
@@ -80,6 +84,10 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
80
84
|
const dispatch = useAppDispatch();
|
|
81
85
|
const paneInstanceId = usePaneInstance()?.instanceId ?? null;
|
|
82
86
|
const bodyElementRef = useRef<HTMLDivElement | null>(null);
|
|
87
|
+
const lastVisibleRangeRef = useRef<{
|
|
88
|
+
key: string | number | undefined;
|
|
89
|
+
range: DataTableVisibleRange;
|
|
90
|
+
} | null>(null);
|
|
83
91
|
const headerHorizontal = useScrollbarState(false);
|
|
84
92
|
const headerVertical = useScrollbarState(false);
|
|
85
93
|
const bodyHorizontal = useScrollbarState(showHorizontalScrollbar);
|
|
@@ -106,10 +114,32 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
106
114
|
dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
|
|
107
115
|
}, [dispatch, paneInstanceId]);
|
|
108
116
|
|
|
117
|
+
const emitVisibleRange = useCallback(() => {
|
|
118
|
+
if (!onVisibleRangeChange) return;
|
|
119
|
+
const element = bodyElementRef.current;
|
|
120
|
+
if (!element) return;
|
|
121
|
+
const range = resolveDataTableVisibleRange({
|
|
122
|
+
itemCount: items.length,
|
|
123
|
+
rowSize: WEB_CELL_HEIGHT,
|
|
124
|
+
scrollOffset: element.scrollTop,
|
|
125
|
+
viewportSize: Math.max(0, element.clientHeight - WEB_CELL_HEIGHT),
|
|
126
|
+
});
|
|
127
|
+
const previous = lastVisibleRangeRef.current;
|
|
128
|
+
if (
|
|
129
|
+
previous !== null
|
|
130
|
+
&& previous.key === visibleRangeKey
|
|
131
|
+
&& previous.range.start === range.start
|
|
132
|
+
&& previous.range.end === range.end
|
|
133
|
+
) return;
|
|
134
|
+
lastVisibleRangeRef.current = { key: visibleRangeKey, range };
|
|
135
|
+
onVisibleRangeChange(range);
|
|
136
|
+
}, [items.length, onVisibleRangeChange, visibleRangeKey]);
|
|
109
137
|
const handleBodyScrollActivity = useCallback(() => {
|
|
110
138
|
onBodyScrollActivity();
|
|
111
|
-
|
|
139
|
+
emitVisibleRange();
|
|
140
|
+
}, [emitVisibleRange, onBodyScrollActivity]);
|
|
112
141
|
const scheduleBodyScrollActivity = useRafCallback(handleBodyScrollActivity);
|
|
142
|
+
const scheduleVisibleRangeMeasure = useRafCallback(emitVisibleRange);
|
|
113
143
|
|
|
114
144
|
const rowVirtualizer = useVirtualizer({
|
|
115
145
|
count: items.length,
|
|
@@ -154,12 +184,20 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
154
184
|
|
|
155
185
|
useEffect(() => {
|
|
156
186
|
measureViewportWidth();
|
|
187
|
+
scheduleVisibleRangeMeasure();
|
|
157
188
|
const element = bodyElementRef.current;
|
|
158
189
|
if (!element || typeof ResizeObserver === "undefined") return;
|
|
159
|
-
const observer = new ResizeObserver(
|
|
190
|
+
const observer = new ResizeObserver(() => {
|
|
191
|
+
scheduleViewportWidthMeasure();
|
|
192
|
+
scheduleVisibleRangeMeasure();
|
|
193
|
+
});
|
|
160
194
|
observer.observe(element);
|
|
161
195
|
return () => observer.disconnect();
|
|
162
|
-
}, [measureViewportWidth, scheduleViewportWidthMeasure]);
|
|
196
|
+
}, [measureViewportWidth, scheduleViewportWidthMeasure, scheduleVisibleRangeMeasure]);
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
scheduleVisibleRangeMeasure();
|
|
200
|
+
}, [items.length, scheduleVisibleRangeMeasure, visibleRangeKey]);
|
|
163
201
|
|
|
164
202
|
useEffect(() => {
|
|
165
203
|
if (scrollToIndex == null || items.length === 0) return;
|
|
@@ -168,6 +206,7 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
168
206
|
rowVirtualizer.scrollToIndex(targetIndex, {
|
|
169
207
|
align: scrollToIndexAlign === "center" ? "center" : "auto",
|
|
170
208
|
});
|
|
209
|
+
scheduleVisibleRangeMeasure();
|
|
171
210
|
return;
|
|
172
211
|
}
|
|
173
212
|
const element = bodyElementRef.current;
|
|
@@ -185,12 +224,14 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
185
224
|
if (nextTop !== currentTop) {
|
|
186
225
|
element.scrollTop = nextTop * WEB_CELL_HEIGHT;
|
|
187
226
|
}
|
|
227
|
+
scheduleVisibleRangeMeasure();
|
|
188
228
|
}, [
|
|
189
229
|
items.length,
|
|
190
230
|
rowVirtualizer,
|
|
191
231
|
scrollToIndex,
|
|
192
232
|
scrollToIndexAlign,
|
|
193
233
|
scrollToIndexVersion,
|
|
234
|
+
scheduleVisibleRangeMeasure,
|
|
194
235
|
virtualize,
|
|
195
236
|
]);
|
|
196
237
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** @jsxImportSource react */
|
|
2
|
+
import { Window } from "happy-dom";
|
|
3
|
+
|
|
4
|
+
// The desktop host is the only renderer with a real DOM underneath it, so its
|
|
5
|
+
// mouse contract needs a DOM to be tested against.
|
|
6
|
+
const testWindow = new Window({ url: "http://localhost" });
|
|
7
|
+
Object.assign(globalThis, {
|
|
8
|
+
window: testWindow,
|
|
9
|
+
document: testWindow.document,
|
|
10
|
+
navigator: testWindow.navigator,
|
|
11
|
+
MouseEvent: testWindow.MouseEvent,
|
|
12
|
+
Event: testWindow.Event,
|
|
13
|
+
HTMLElement: testWindow.HTMLElement,
|
|
14
|
+
Node: testWindow.Node,
|
|
15
|
+
requestAnimationFrame: (callback: (time: number) => void) => setTimeout(() => callback(Date.now()), 8),
|
|
16
|
+
cancelAnimationFrame: (id: number) => clearTimeout(id),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
import { expect, test } from "bun:test";
|
|
20
|
+
import { act } from "react";
|
|
21
|
+
import { createRoot } from "react-dom/client";
|
|
22
|
+
import { WebChartSurface } from "./chart-surface";
|
|
23
|
+
|
|
24
|
+
async function settle() {
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("reports a drag as a drag, never as a move, and keeps its modifiers", async () => {
|
|
29
|
+
const seen: string[] = [];
|
|
30
|
+
const modifiers: Array<{ shift: boolean; alt: boolean }> = [];
|
|
31
|
+
const container = testWindow.document.createElement("div");
|
|
32
|
+
testWindow.document.body.appendChild(container);
|
|
33
|
+
const root = createRoot(container as unknown as HTMLElement);
|
|
34
|
+
await act(async () => {
|
|
35
|
+
root.render(
|
|
36
|
+
<WebChartSurface
|
|
37
|
+
width={40}
|
|
38
|
+
height={10}
|
|
39
|
+
onMouseMove={() => seen.push("move")}
|
|
40
|
+
onMouseDown={(event: { modifiers: { shift: boolean; alt: boolean } }) => {
|
|
41
|
+
seen.push("down");
|
|
42
|
+
modifiers.push(event.modifiers);
|
|
43
|
+
}}
|
|
44
|
+
onMouseDrag={(event: { modifiers: { shift: boolean; alt: boolean } }) => {
|
|
45
|
+
seen.push("drag");
|
|
46
|
+
modifiers.push(event.modifiers);
|
|
47
|
+
}}
|
|
48
|
+
onMouseUp={() => seen.push("up")}
|
|
49
|
+
/>,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
const surface = container.firstElementChild as unknown as HTMLElement;
|
|
53
|
+
const mouse = (type: string, target: { dispatchEvent: (event: unknown) => unknown }, clientX: number) => {
|
|
54
|
+
target.dispatchEvent(new MouseEvent(type, { bubbles: true, button: 0, clientX, clientY: 24, shiftKey: true }));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
await act(async () => {
|
|
58
|
+
mouse("mousemove", surface, 10);
|
|
59
|
+
await settle();
|
|
60
|
+
});
|
|
61
|
+
await act(async () => mouse("mousedown", surface, 10));
|
|
62
|
+
await act(async () => {
|
|
63
|
+
// The DOM delivers both while a button is held; only the drag may surface.
|
|
64
|
+
mouse("mousemove", surface, 80);
|
|
65
|
+
mouse("mousemove", testWindow.document as never, 80);
|
|
66
|
+
await settle();
|
|
67
|
+
});
|
|
68
|
+
await act(async () => mouse("mouseup", testWindow.document as never, 80));
|
|
69
|
+
|
|
70
|
+
expect(seen).toEqual(["move", "down", "drag", "up"]);
|
|
71
|
+
expect(modifiers.every((entry) => entry.shift)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a tab bar occupies exactly the one row panes reserve for it", async () => {
|
|
75
|
+
const { WebTabs } = await import("./tabs");
|
|
76
|
+
const { WEB_CELL_HEIGHT } = await import("../input-host");
|
|
77
|
+
const container = testWindow.document.createElement("div");
|
|
78
|
+
testWindow.document.body.appendChild(container);
|
|
79
|
+
const root = createRoot(container as unknown as HTMLElement);
|
|
80
|
+
await act(async () => {
|
|
81
|
+
root.render(
|
|
82
|
+
<WebTabs
|
|
83
|
+
tabs={[{ label: "Overview", value: "overview" }, { label: "Chart", value: "chart" }]}
|
|
84
|
+
activeValue="chart"
|
|
85
|
+
onSelect={() => {}}
|
|
86
|
+
palette={{} as never}
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
await settle();
|
|
91
|
+
const list = container.querySelector('[data-gloom-role="tab-list"]') as unknown as HTMLElement;
|
|
92
|
+
// Panes size their content as `height - 1` for the tab bar. Any extra pixel
|
|
93
|
+
// here is a pixel the content is told it owns and the pane then clips.
|
|
94
|
+
expect(list.style.height).toBe(`${WEB_CELL_HEIGHT}px`);
|
|
95
|
+
expect(list.style.marginBottom === "" || list.style.marginBottom === "0px").toBe(true);
|
|
96
|
+
await act(async () => root.unmount());
|
|
97
|
+
});
|
|
@@ -65,6 +65,9 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
const scheduleFrameMouseHandler = (event: MouseLikeEvent, type: "move" | "drag") => {
|
|
68
|
+
// The DOM keeps firing mousemove while a button is held. A move means no
|
|
69
|
+
// button anywhere else in the app, so drags must not surface as moves.
|
|
70
|
+
if (type === "move" && draggingRef.current) return;
|
|
68
71
|
if (type === "move" && typeof propsRef.current.onMouseMove !== "function") return;
|
|
69
72
|
if (type === "drag"
|
|
70
73
|
&& typeof propsRef.current.onMouse !== "function"
|
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
type ReactNode,
|
|
9
9
|
type Ref,
|
|
10
10
|
} from "react";
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
BitmapSurface,
|
|
13
|
+
BoxRenderable,
|
|
14
|
+
ChartCrosshairOverlay,
|
|
15
|
+
ChartVectorShape,
|
|
16
|
+
} from "../../../../ui/host";
|
|
12
17
|
import { WebBox } from "./box";
|
|
13
18
|
|
|
14
19
|
const CanvasBitmap = memo(function CanvasBitmap({ bitmap }: { bitmap: BitmapSurface }) {
|
|
@@ -54,6 +59,83 @@ const BoxLayer = memo(function BoxLayer({ bitmap, index }: { bitmap: BitmapSurfa
|
|
|
54
59
|
);
|
|
55
60
|
});
|
|
56
61
|
|
|
62
|
+
const ChartVectors = memo(function ChartVectors({ vectors }: { vectors: readonly ChartVectorShape[] }) {
|
|
63
|
+
if (vectors.length === 0) return null;
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<svg
|
|
67
|
+
viewBox="0 0 1 1"
|
|
68
|
+
preserveAspectRatio="none"
|
|
69
|
+
// An SVG is a replaced element: without an explicit size it falls back
|
|
70
|
+
// to its intrinsic box and squeezes the whole overlay into a corner.
|
|
71
|
+
style={{
|
|
72
|
+
position: "absolute",
|
|
73
|
+
inset: 0,
|
|
74
|
+
width: "100%",
|
|
75
|
+
height: "100%",
|
|
76
|
+
pointerEvents: "none",
|
|
77
|
+
zIndex: 9,
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{vectors.map((shape) => {
|
|
81
|
+
const strokeWidth = shape.strokeWidth ?? 1.4;
|
|
82
|
+
const [first, second] = shape.points;
|
|
83
|
+
if (shape.box && first && second) {
|
|
84
|
+
return (
|
|
85
|
+
<rect
|
|
86
|
+
key={shape.id}
|
|
87
|
+
x={Math.min(first.x, second.x)}
|
|
88
|
+
y={Math.min(first.y, second.y)}
|
|
89
|
+
width={Math.abs(second.x - first.x)}
|
|
90
|
+
height={Math.abs(second.y - first.y)}
|
|
91
|
+
fill={shape.color}
|
|
92
|
+
fillOpacity={shape.fillOpacity ?? 0.18}
|
|
93
|
+
stroke={shape.color}
|
|
94
|
+
strokeWidth={strokeWidth}
|
|
95
|
+
vectorEffect="non-scaling-stroke"
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return (
|
|
100
|
+
<polyline
|
|
101
|
+
key={shape.id}
|
|
102
|
+
points={shape.points.map((point) => `${point.x},${point.y}`).join(" ")}
|
|
103
|
+
fill="none"
|
|
104
|
+
stroke={shape.color}
|
|
105
|
+
strokeWidth={strokeWidth}
|
|
106
|
+
strokeLinecap="round"
|
|
107
|
+
strokeLinejoin="round"
|
|
108
|
+
vectorEffect="non-scaling-stroke"
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</svg>
|
|
113
|
+
{/* Ratio space is anisotropic, so handles are placed instead of scaled. */}
|
|
114
|
+
{vectors.flatMap((shape) => shape.handles
|
|
115
|
+
? shape.points.map((point, index) => (
|
|
116
|
+
<div
|
|
117
|
+
key={`${shape.id}:${index}`}
|
|
118
|
+
style={{
|
|
119
|
+
position: "absolute",
|
|
120
|
+
left: `${point.x * 100}%`,
|
|
121
|
+
top: `${point.y * 100}%`,
|
|
122
|
+
width: 9,
|
|
123
|
+
height: 9,
|
|
124
|
+
borderRadius: 999,
|
|
125
|
+
border: `2px solid ${shape.color}`,
|
|
126
|
+
backgroundColor: "var(--gloom-bg)",
|
|
127
|
+
boxSizing: "border-box",
|
|
128
|
+
transform: "translate(-50%, -50%)",
|
|
129
|
+
pointerEvents: "none",
|
|
130
|
+
zIndex: 10,
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
))
|
|
134
|
+
: [])}
|
|
135
|
+
</>
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
57
139
|
function ChartCrosshair({
|
|
58
140
|
bitmap,
|
|
59
141
|
crosshair,
|
|
@@ -122,6 +204,7 @@ export const WebChartSurface = forwardRef<BoxRenderable, Record<string, unknown>
|
|
|
122
204
|
const bitmaps = (props.bitmaps ?? null) as readonly BitmapSurface[] | null;
|
|
123
205
|
const layers = bitmaps ?? (bitmap ? [bitmap] : []);
|
|
124
206
|
const crosshair = (props.crosshair ?? null) as ChartCrosshairOverlay | null;
|
|
207
|
+
const vectors = (props.vectors ?? null) as readonly ChartVectorShape[] | null;
|
|
125
208
|
const baseLayer = layers[0] ?? null;
|
|
126
209
|
return (
|
|
127
210
|
<WebBox
|
|
@@ -135,6 +218,7 @@ export const WebChartSurface = forwardRef<BoxRenderable, Record<string, unknown>
|
|
|
135
218
|
<BoxLayer key={`layer:${index}`} index={index} bitmap={layer} />
|
|
136
219
|
))
|
|
137
220
|
: children as ReactNode}
|
|
221
|
+
<ChartVectors vectors={vectors ?? []} />
|
|
138
222
|
<ChartCrosshair bitmap={baseLayer} crosshair={crosshair} />
|
|
139
223
|
</WebBox>
|
|
140
224
|
);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isEditableTarget, shouldReleaseFocus } from "./focus-scope";
|
|
3
|
+
|
|
4
|
+
function field(scope?: { contains: (node: unknown) => boolean }) {
|
|
5
|
+
return {
|
|
6
|
+
tagName: "INPUT",
|
|
7
|
+
closest: () => scope ?? null,
|
|
8
|
+
contains: (node: unknown) => node === undefined,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("focus scope release", () => {
|
|
13
|
+
test("releases focus for a press outside the widget that owns it", () => {
|
|
14
|
+
expect(shouldReleaseFocus(field(), { tagName: "DIV" })).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("keeps focus for a press on the owning widget's own chrome", () => {
|
|
18
|
+
const suggestion = { tagName: "DIV" };
|
|
19
|
+
expect(shouldReleaseFocus(field({ contains: (n) => n === suggestion }), suggestion)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("ignores presses while nothing editable holds focus", () => {
|
|
23
|
+
expect(shouldReleaseFocus({ tagName: "DIV", closest: () => null }, {})).toBe(false);
|
|
24
|
+
expect(shouldReleaseFocus(null, {})).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("leaves a press inside a dialog alone, which owns the pointer itself", () => {
|
|
28
|
+
const inDialog = { tagName: "INPUT", closest: (selector: string) => selector === ".gloom-dialog" ? {} : null };
|
|
29
|
+
expect(shouldReleaseFocus(field(), inDialog)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("recognises the field a press is about to focus", () => {
|
|
33
|
+
expect(isEditableTarget({ tagName: "textarea" })).toBe(true);
|
|
34
|
+
expect(isEditableTarget({ isContentEditable: true })).toBe(true);
|
|
35
|
+
expect(isEditableTarget({ tagName: "canvas" })).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser gives up a field's focus when you press somewhere else. Panes,
|
|
3
|
+
* charts and rows all consume their own mousedown, which is exactly what
|
|
4
|
+
* suppresses that, so a field can hold the keyboard long after it looks done.
|
|
5
|
+
*
|
|
6
|
+
* One rule restores the default: a press outside the widget that owns the focus
|
|
7
|
+
* releases it. Widgets whose own chrome must not take focus from their field,
|
|
8
|
+
* such as a suggestion list under an input, mark it with the scope attribute.
|
|
9
|
+
*
|
|
10
|
+
* A dialog is exempt. It owns the pointer while it is open, and releasing a
|
|
11
|
+
* field underneath it re-renders whatever hosts the dialog, which costs the
|
|
12
|
+
* dialog the focus it just took.
|
|
13
|
+
*/
|
|
14
|
+
const FOCUS_SCOPE_ATTRIBUTE = "data-gloom-focus-scope";
|
|
15
|
+
const DIALOG_CLASS_SELECTOR = ".gloom-dialog";
|
|
16
|
+
|
|
17
|
+
interface FocusableLike {
|
|
18
|
+
tagName?: string;
|
|
19
|
+
isContentEditable?: boolean;
|
|
20
|
+
blur?: () => void;
|
|
21
|
+
focus?: () => void;
|
|
22
|
+
closest?: (selector: string) => { contains?: (node: unknown) => boolean } | null;
|
|
23
|
+
contains?: (node: unknown) => boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function asFocusable(node: unknown): FocusableLike | null {
|
|
27
|
+
return node && typeof node === "object" ? node as FocusableLike : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isEditableTarget(node: unknown): boolean {
|
|
31
|
+
const element = asFocusable(node);
|
|
32
|
+
if (!element) return false;
|
|
33
|
+
if (element.isContentEditable === true) return true;
|
|
34
|
+
const tag = element.tagName?.toUpperCase();
|
|
35
|
+
return tag === "INPUT" || tag === "TEXTAREA";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function shouldReleaseFocus(active: unknown, target: unknown): boolean {
|
|
39
|
+
if (!isEditableTarget(active) || !target || active === target) return false;
|
|
40
|
+
if (asFocusable(target)?.closest?.(DIALOG_CLASS_SELECTOR)) return false;
|
|
41
|
+
const element = asFocusable(active)!;
|
|
42
|
+
const scope = element.closest?.(`[${FOCUS_SCOPE_ATTRIBUTE}]`) ?? element;
|
|
43
|
+
return !scope?.contains?.(target);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function installFocusScopeRelease(): () => void {
|
|
47
|
+
const doc = (globalThis as { document?: Document }).document;
|
|
48
|
+
if (!doc) return () => {};
|
|
49
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
50
|
+
const active = doc.activeElement;
|
|
51
|
+
if (!shouldReleaseFocus(active, event.target)) return;
|
|
52
|
+
asFocusable(active)?.blur?.();
|
|
53
|
+
};
|
|
54
|
+
doc.addEventListener("mousedown", handleMouseDown, true);
|
|
55
|
+
return () => doc.removeEventListener("mousedown", handleMouseDown, true);
|
|
56
|
+
}
|
|
@@ -54,11 +54,25 @@ function callTextHandler(handler: unknown, value: string): void {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A press on a field must focus it, even when the owner's controlled flag never
|
|
59
|
+
* changed. Panes and charts consume their own mousedown, which suppresses the
|
|
60
|
+
* default focus move, and a field that lost focus externally would otherwise
|
|
61
|
+
* stay dead until its flag happened to toggle.
|
|
62
|
+
*/
|
|
63
|
+
function focusOnPress(element: HTMLInputElement | HTMLTextAreaElement | null): void {
|
|
64
|
+
if (!element || document.activeElement === element) return;
|
|
65
|
+
element.focus();
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
function applyDomCursorOffset(
|
|
58
69
|
element: HTMLInputElement | HTMLTextAreaElement | null,
|
|
59
70
|
offset: number,
|
|
60
71
|
) {
|
|
61
|
-
|
|
72
|
+
// Placing a caret in a field nobody is typing in means nothing, and WebKit
|
|
73
|
+
// hands the field the focus back when you try, which silently undoes a
|
|
74
|
+
// release that happened a frame earlier.
|
|
75
|
+
if (!element || document.activeElement !== element) return;
|
|
62
76
|
const clampedOffset = Math.max(0, Math.min(offset, element.value.length));
|
|
63
77
|
element.setSelectionRange(clampedOffset, clampedOffset);
|
|
64
78
|
}
|
|
@@ -262,6 +276,7 @@ export const WebInput = forwardRef<InputRenderable, Record<string, unknown>>(fun
|
|
|
262
276
|
placeholder={getStringProp(props, "placeholder")}
|
|
263
277
|
onInput={(event) => handleValueChange(event.currentTarget.value)}
|
|
264
278
|
onChange={(event) => handleValueChange(event.currentTarget.value)}
|
|
279
|
+
onMouseDown={() => focusOnPress(elementRef.current)}
|
|
265
280
|
onFocus={() => {
|
|
266
281
|
setDomFocused(true);
|
|
267
282
|
callTextHandler(propsRef.current.onFocus, elementRef.current?.value ?? valueRef.current);
|
|
@@ -52,6 +52,7 @@ export function cellMouseEvent(event: MouseLikeEvent, type?: string) {
|
|
|
52
52
|
pixelX: event.clientX,
|
|
53
53
|
pixelY: event.clientY,
|
|
54
54
|
button: event.button,
|
|
55
|
+
target: (event as { target?: unknown }).target,
|
|
55
56
|
detail: "detail" in event && typeof event.detail === "number" ? event.detail : 0,
|
|
56
57
|
timeStamp: event.timeStamp,
|
|
57
58
|
modifiers: {
|
|
@@ -103,7 +103,7 @@ export function cleanDomProps(props: Record<string, unknown>): Record<string, un
|
|
|
103
103
|
"focused", "focusedBackgroundColor", "textColor", "focusedTextColor", "placeholderColor",
|
|
104
104
|
"cursorColor", "selectionBg", "selectionFg", "showCursor", "keyBindings", "wrapText", "wrapMode",
|
|
105
105
|
"initialValue", "value", "onInput", "onChange", "onSubmit", "onEscape", "onCursorChange", "onMouse",
|
|
106
|
-
"scrollX", "scrollY", "focusable", "bitmap", "bitmaps", "crosshair", "text", "font",
|
|
106
|
+
"scrollX", "scrollY", "focusable", "bitmap", "bitmaps", "crosshair", "vectors", "text", "font",
|
|
107
107
|
"hoverBackgroundColor",
|
|
108
108
|
]) {
|
|
109
109
|
delete next[key];
|
|
@@ -27,7 +27,10 @@ export function WebTabs({
|
|
|
27
27
|
const activeTabRef = useRef<HTMLButtonElement | null>(null);
|
|
28
28
|
const [hoveredValue, setHoveredValue] = useState<string | null>(null);
|
|
29
29
|
const showUnderline = variant === "underline" && !compact;
|
|
30
|
-
|
|
30
|
+
// A tab bar occupies exactly the one row every caller reserves for it. Any
|
|
31
|
+
// extra pixels here are pixels the pane's children are told they own and the
|
|
32
|
+
// pane then clips, which is how a chart's time axis ends up behind a footer.
|
|
33
|
+
const listHeight = showUnderline || compact ? WEB_CELL_HEIGHT : "100%";
|
|
31
34
|
const tabFontSize = compact || showUnderline ? 12 : 13;
|
|
32
35
|
const tabPaddingInline = dense ? 5 : showUnderline ? 10 : 8;
|
|
33
36
|
const tabPaddingBlock = variant === "bare" || variant === "pill" ? 2 : 0;
|
|
@@ -83,7 +86,6 @@ export function WebTabs({
|
|
|
83
86
|
overflowY: "hidden",
|
|
84
87
|
paddingInline: variant === "underline" || dense ? 0 : 4,
|
|
85
88
|
paddingBlock: tabPaddingBlock,
|
|
86
|
-
marginBottom: showUnderline ? 4 : 0,
|
|
87
89
|
boxSizing: "border-box",
|
|
88
90
|
}}
|
|
89
91
|
>
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
setElectrobunRemoteRequestHandler,
|
|
12
12
|
} from "./backend-rpc";
|
|
13
13
|
import { installElectrobunAiHost } from "./ai-host";
|
|
14
|
+
import { installFocusScopeRelease } from "./host/focus-scope";
|
|
14
15
|
import { installElectrobunBrokerRemoteClient } from "./broker-remote-client";
|
|
15
16
|
import { installElectrobunConfigStoreHost } from "./config-host";
|
|
16
17
|
import { WebDialogHostProvider } from "./dialog-host";
|
|
@@ -88,6 +89,7 @@ async function boot() {
|
|
|
88
89
|
installElectrobunUpdateHost();
|
|
89
90
|
const init = await measurePerfAsync("startup.electrobun.backend-init", () => backendInitPromise);
|
|
90
91
|
installElectrobunAiHost();
|
|
92
|
+
installFocusScopeRelease();
|
|
91
93
|
const desktopSnapshot = init.windowKind === "detached" && init.paneId && init.desktopSnapshot
|
|
92
94
|
? prepareDetachedSnapshot(init.desktopSnapshot, init.paneId)
|
|
93
95
|
: init.desktopSnapshot;
|
|
@@ -96,6 +96,8 @@ export const OpenTuiChartSurface = forwardRef<unknown, ChartSurfaceProps>(functi
|
|
|
96
96
|
const surfaceId = useRef(`opentui-chart:${nextChartSurfaceId++}`).current;
|
|
97
97
|
const renderableRef = useRef<NativeRenderableNode | null>(null);
|
|
98
98
|
const [target, setTarget] = useState<SurfaceTarget | null>(null);
|
|
99
|
+
// Only the base layer reaches the terminal: each extra layer would need its own
|
|
100
|
+
// kitty surface and z-index. Composite overlays into the bitmap you pass here.
|
|
99
101
|
const firstBitmap = Array.isArray(bitmaps) ? bitmaps[0] : null;
|
|
100
102
|
const nativeBitmap = (firstBitmap ?? bitmap ?? null) as NativeChartBitmap | null;
|
|
101
103
|
const nativeCrosshair = (crosshair ?? null) as ChartCrosshairOverlay | null;
|
|
@@ -145,6 +145,9 @@ export async function createOpenTuiHost(): Promise<OpenTuiHost> {
|
|
|
145
145
|
"--profile=sw-fast",
|
|
146
146
|
"--vo=kitty",
|
|
147
147
|
"--vo-kitty-auto-multiplexer-passthrough=yes",
|
|
148
|
+
"--demuxer-lavf-probe-info=yes",
|
|
149
|
+
"--demuxer-lavf-analyzeduration=10",
|
|
150
|
+
"--demuxer-lavf-probesize=25000000",
|
|
148
151
|
"--ytdl=no",
|
|
149
152
|
`--mute=${options?.muted === false ? "no" : "yes"}`,
|
|
150
153
|
...(title ? [`--title=${title}`] : []),
|
|
@@ -339,6 +339,14 @@ describe("GloomberbCloudProvider", () => {
|
|
|
339
339
|
test("does not advertise unsupported 45 minute chart resolution", () => {
|
|
340
340
|
const provider = new GloomberbCloudProvider();
|
|
341
341
|
expect(provider.getChartResolutionCapabilities()).not.toContain("45m");
|
|
342
|
+
expect(provider.getChartResolutionSupport()).toContainEqual({
|
|
343
|
+
resolution: "1d",
|
|
344
|
+
maxRange: "5Y",
|
|
345
|
+
});
|
|
346
|
+
expect(provider.getChartResolutionSupport()).toContainEqual({
|
|
347
|
+
resolution: "1wk",
|
|
348
|
+
maxRange: "5Y",
|
|
349
|
+
});
|
|
342
350
|
});
|
|
343
351
|
|
|
344
352
|
test("normalizes sub-unit cloud quotes to their main currency", async () => {
|
|
@@ -29,7 +29,7 @@ import { resolvePriceHistoryCurrencyUnit } from "../../utils/currency-units";
|
|
|
29
29
|
import { canonicalTickerKey } from "../../utils/exchanges";
|
|
30
30
|
import { normalizePriceHistory } from "../../utils/price-history";
|
|
31
31
|
import { createProviderMiss } from "../provider-errors";
|
|
32
|
-
import {
|
|
32
|
+
import { hasMalformedIntradayHistory } from "../../time-series/history-quality";
|
|
33
33
|
import {
|
|
34
34
|
cloudNewsParams,
|
|
35
35
|
mapCloudNewsArticle,
|
|
@@ -55,8 +55,8 @@ const CLOUD_RESOLUTION_SUPPORT = normalizeChartResolutionSupport([
|
|
|
55
55
|
{ resolution: "15m", maxRange: "3M" },
|
|
56
56
|
{ resolution: "30m", maxRange: "6M" },
|
|
57
57
|
{ resolution: "1h", maxRange: "1Y" },
|
|
58
|
-
{ resolution: "1d", maxRange: "
|
|
59
|
-
{ resolution: "1wk", maxRange: "
|
|
58
|
+
{ resolution: "1d", maxRange: "5Y" },
|
|
59
|
+
{ resolution: "1wk", maxRange: "5Y" },
|
|
60
60
|
{ resolution: "1mo", maxRange: "ALL" },
|
|
61
61
|
]);
|
|
62
62
|
const CLOUD_PROVIDER_MISS_PATTERNS = [
|
|
@@ -113,7 +113,7 @@ function mapCloudPriceHistory(
|
|
|
113
113
|
if (
|
|
114
114
|
/(min|h)$/i.test(interval)
|
|
115
115
|
&& upstream !== "yahoo"
|
|
116
|
-
&&
|
|
116
|
+
&& hasMalformedIntradayHistory(points)
|
|
117
117
|
) {
|
|
118
118
|
throw createProviderMiss(`Cloud chart data failed OHLC validation for ${ticker}`);
|
|
119
119
|
}
|
|
@@ -146,7 +146,7 @@ describe("AssetDataRouter chart history", () => {
|
|
|
146
146
|
namespace: "market",
|
|
147
147
|
kind: "price-history",
|
|
148
148
|
entityKey: "META",
|
|
149
|
-
variantKey: "exchange=NASDAQ;range=1M;resolution=5m;version=
|
|
149
|
+
variantKey: "exchange=NASDAQ;range=1M;resolution=5m;version=3",
|
|
150
150
|
sourceKey: "provider:gloomberb-cloud",
|
|
151
151
|
},
|
|
152
152
|
[
|
|
@@ -181,8 +181,8 @@ describe("AssetDataRouter chart history", () => {
|
|
|
181
181
|
.query("SELECT variant_key FROM resource_cache WHERE namespace = ? AND kind = ? AND entity_key = ? ORDER BY variant_key")
|
|
182
182
|
.all("market", "price-history", "META") as Array<{ variant_key: string }>;
|
|
183
183
|
expect(cachedRows.map((row) => row.variant_key)).toEqual([
|
|
184
|
-
"exchange=NASDAQ;range=1M;resolution=5m;version=2",
|
|
185
184
|
"exchange=NASDAQ;range=1M;resolution=5m;version=3",
|
|
185
|
+
"exchange=NASDAQ;range=1M;resolution=5m;version=4",
|
|
186
186
|
]);
|
|
187
187
|
|
|
188
188
|
persistence.close();
|
|
@@ -233,7 +233,7 @@ describe("AssetDataRouter chart history", () => {
|
|
|
233
233
|
.all("market", "price-history", "FTC") as Array<{ variant_key: string }>;
|
|
234
234
|
expect(cachedRows.map((row) => row.variant_key)).toEqual([
|
|
235
235
|
"exchange=LSE;range=ALL;resolution=1wk",
|
|
236
|
-
"exchange=LSE;range=ALL;resolution=1wk;version=
|
|
236
|
+
"exchange=LSE;range=ALL;resolution=1wk;version=4;unit=GBP",
|
|
237
237
|
]);
|
|
238
238
|
|
|
239
239
|
persistence.close();
|