@363045841yyt/klinechart-core 0.7.3 → 0.7.5-alpha.2
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 +201 -201
- package/README.zh-CN.md +201 -201
- package/dist/engine/renderers/webgl/candleSurface.js +47 -47
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -2
- package/dist/version.js.map +1 -1
- package/package.json +129 -122
- package/src/__tests__/signal.test.ts +124 -124
- package/src/config/chartSettings.ts +66 -66
- package/src/controllers/__tests__/drawing.test.ts +214 -214
- package/src/controllers/__tests__/indicatorSelector.test.ts +481 -481
- package/src/controllers/__tests__/toolbar.test.ts +225 -225
- package/src/controllers/createChartController.ts +665 -665
- package/src/controllers/createDrawingController.ts +96 -96
- package/src/controllers/createIndicatorSelectorController.ts +307 -307
- package/src/controllers/createToolbarController.ts +146 -146
- package/src/controllers/index.ts +19 -19
- package/src/controllers/types.ts +284 -284
- package/src/engine/__tests__/chart.dpr.test.ts +401 -401
- package/src/engine/__tests__/paneRenderer.resize.test.ts +92 -92
- package/src/engine/chart-store.ts +121 -121
- package/src/engine/chart.d.ts +617 -617
- package/src/engine/chart.ts +2815 -2815
- package/src/engine/controller/__tests__/interaction.dpr.test.ts +259 -259
- package/src/engine/controller/interaction.ts +722 -722
- package/src/engine/controller/markerInteraction.ts +130 -130
- package/src/engine/controller/pinchTracker.ts +82 -82
- package/src/engine/controller/tooltipPosition.ts +48 -48
- package/src/engine/draw/__tests__/pixelAlign.spec.ts +176 -176
- package/src/engine/draw/pixelAlign.ts +259 -259
- package/src/engine/drawing/index.ts +655 -655
- package/src/engine/drawing/interaction.ts +842 -842
- package/src/engine/drawing/plugin.ts +343 -343
- package/src/engine/indicators/__tests__/__fixtures__/golden/atr.json +38 -38
- package/src/engine/indicators/__tests__/__fixtures__/golden/dema.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/hma.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/index.ts +55 -55
- package/src/engine/indicators/__tests__/__fixtures__/golden/kama.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/tema.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/wma.json +40 -40
- package/src/engine/indicators/__tests__/__fixtures__/synthetic.ts +65 -65
- package/src/engine/indicators/__tests__/_propertyAssertions.ts +76 -76
- package/src/engine/indicators/__tests__/atr.test.ts +153 -153
- package/src/engine/indicators/__tests__/calculators.test.ts +614 -614
- package/src/engine/indicators/__tests__/cmf-mfi.test.ts +100 -100
- package/src/engine/indicators/__tests__/dema.test.ts +73 -73
- package/src/engine/indicators/__tests__/donchian.test.ts +70 -70
- package/src/engine/indicators/__tests__/hma.test.ts +73 -73
- package/src/engine/indicators/__tests__/ichimoku.test.ts +105 -105
- package/src/engine/indicators/__tests__/kama.test.ts +80 -80
- package/src/engine/indicators/__tests__/keltner.test.ts +65 -65
- package/src/engine/indicators/__tests__/pivot-fib.test.ts +110 -110
- package/src/engine/indicators/__tests__/roc.test.ts +68 -68
- package/src/engine/indicators/__tests__/sar.test.ts +86 -86
- package/src/engine/indicators/__tests__/scheduler.test.ts +831 -831
- package/src/engine/indicators/__tests__/soa.test.ts +533 -533
- package/src/engine/indicators/__tests__/structure.test.ts +110 -110
- package/src/engine/indicators/__tests__/supertrend.test.ts +65 -65
- package/src/engine/indicators/__tests__/tema.test.ts +68 -68
- package/src/engine/indicators/__tests__/trix.test.ts +70 -70
- package/src/engine/indicators/__tests__/volatility.test.ts +117 -117
- package/src/engine/indicators/__tests__/volume.test.ts +115 -115
- package/src/engine/indicators/__tests__/volumeProfile.test.ts +74 -74
- package/src/engine/indicators/__tests__/vwap.test.ts +69 -69
- package/src/engine/indicators/__tests__/wma.test.ts +112 -112
- package/src/engine/indicators/__tests__/zones.test.ts +95 -95
- package/src/engine/indicators/atrState.ts +27 -27
- package/src/engine/indicators/bollState.ts +51 -51
- package/src/engine/indicators/calculators.ts +2593 -2593
- package/src/engine/indicators/cciState.ts +25 -25
- package/src/engine/indicators/chaikinVolState.ts +32 -32
- package/src/engine/indicators/cmfState.ts +27 -27
- package/src/engine/indicators/demaState.ts +27 -27
- package/src/engine/indicators/donchianState.ts +43 -43
- package/src/engine/indicators/eneState.ts +43 -43
- package/src/engine/indicators/expmaState.ts +43 -43
- package/src/engine/indicators/fastkState.ts +25 -25
- package/src/engine/indicators/fibState.ts +41 -41
- package/src/engine/indicators/hmaState.ts +27 -27
- package/src/engine/indicators/hvState.ts +28 -28
- package/src/engine/indicators/ichimokuState.ts +70 -70
- package/src/engine/indicators/indicator.worker.ts +169 -169
- package/src/engine/indicators/indicatorDefinitionRegistry.ts +62 -62
- package/src/engine/indicators/indicatorMetadata.ts +110 -110
- package/src/engine/indicators/indicatorRegistry.ts +106 -106
- package/src/engine/indicators/indicatorRuntime.ts +1548 -1548
- package/src/engine/indicators/kamaState.ts +34 -34
- package/src/engine/indicators/keltnerState.ts +49 -49
- package/src/engine/indicators/kstState.ts +42 -42
- package/src/engine/indicators/maState.ts +36 -36
- package/src/engine/indicators/macdState.ts +76 -76
- package/src/engine/indicators/mfiState.ts +27 -27
- package/src/engine/indicators/momState.ts +25 -25
- package/src/engine/indicators/obvState.ts +25 -25
- package/src/engine/indicators/parkinsonState.ts +28 -28
- package/src/engine/indicators/pivotState.ts +51 -51
- package/src/engine/indicators/pvtState.ts +25 -25
- package/src/engine/indicators/rocState.ts +27 -27
- package/src/engine/indicators/rsiState.ts +65 -65
- package/src/engine/indicators/sarState.ts +41 -41
- package/src/engine/indicators/scheduler.ts +1205 -1205
- package/src/engine/indicators/soa.ts +352 -352
- package/src/engine/indicators/stateComposer.ts +1262 -1262
- package/src/engine/indicators/stochState.ts +26 -26
- package/src/engine/indicators/structureState.ts +69 -69
- package/src/engine/indicators/supertrendState.ts +37 -37
- package/src/engine/indicators/temaState.ts +27 -27
- package/src/engine/indicators/trixState.ts +35 -35
- package/src/engine/indicators/vmaState.ts +27 -27
- package/src/engine/indicators/volumeProfileState.ts +63 -63
- package/src/engine/indicators/vwapState.ts +29 -29
- package/src/engine/indicators/wmaState.ts +27 -27
- package/src/engine/indicators/wmsrState.ts +25 -25
- package/src/engine/indicators/workerProtocol.ts +613 -613
- package/src/engine/indicators/zonesState.ts +47 -47
- package/src/engine/layout/pane.ts +161 -161
- package/src/engine/marker/registry.ts +265 -265
- package/src/engine/paneRenderer.ts +169 -169
- package/src/engine/renderers/Indicator/atr.ts +237 -237
- package/src/engine/renderers/Indicator/boll.ts +317 -317
- package/src/engine/renderers/Indicator/cci.ts +275 -275
- package/src/engine/renderers/Indicator/chaikinVol.ts +138 -138
- package/src/engine/renderers/Indicator/cmf.ts +137 -137
- package/src/engine/renderers/Indicator/dema.ts +136 -136
- package/src/engine/renderers/Indicator/donchian.ts +137 -137
- package/src/engine/renderers/Indicator/ene.ts +271 -271
- package/src/engine/renderers/Indicator/expma.ts +197 -197
- package/src/engine/renderers/Indicator/fastk.ts +316 -316
- package/src/engine/renderers/Indicator/fib.ts +141 -141
- package/src/engine/renderers/Indicator/hma.ts +136 -136
- package/src/engine/renderers/Indicator/hv.ts +124 -124
- package/src/engine/renderers/Indicator/ichimoku.ts +181 -181
- package/src/engine/renderers/Indicator/index.ts +241 -241
- package/src/engine/renderers/Indicator/indicatorData.ts +650 -650
- package/src/engine/renderers/Indicator/kama.ts +136 -136
- package/src/engine/renderers/Indicator/keltner.ts +137 -137
- package/src/engine/renderers/Indicator/kst.ts +302 -302
- package/src/engine/renderers/Indicator/ma.ts +200 -200
- package/src/engine/renderers/Indicator/macd.ts +477 -477
- package/src/engine/renderers/Indicator/macdLegend.ts +141 -141
- package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +272 -272
- package/src/engine/renderers/Indicator/mfi.ts +142 -142
- package/src/engine/renderers/Indicator/mom.ts +311 -311
- package/src/engine/renderers/Indicator/obv.ts +123 -123
- package/src/engine/renderers/Indicator/parkinson.ts +124 -124
- package/src/engine/renderers/Indicator/pivot.ts +131 -131
- package/src/engine/renderers/Indicator/pvt.ts +123 -123
- package/src/engine/renderers/Indicator/roc.ts +143 -143
- package/src/engine/renderers/Indicator/rsi.ts +390 -390
- package/src/engine/renderers/Indicator/sar.ts +113 -113
- package/src/engine/renderers/Indicator/scale/atr_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/cci_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/fastk_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/indicator_scale.ts +204 -204
- package/src/engine/renderers/Indicator/scale/kst_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/macd_scale.ts +22 -22
- package/src/engine/renderers/Indicator/scale/mom_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/rsi_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/stoch_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/volume_scale.ts +26 -26
- package/src/engine/renderers/Indicator/scale/wmsr_scale.ts +19 -19
- package/src/engine/renderers/Indicator/stoch.ts +359 -359
- package/src/engine/renderers/Indicator/structure.ts +126 -126
- package/src/engine/renderers/Indicator/subPaneConfig.ts +265 -265
- package/src/engine/renderers/Indicator/supertrend.ts +115 -115
- package/src/engine/renderers/Indicator/tema.ts +136 -136
- package/src/engine/renderers/Indicator/trix.ts +158 -158
- package/src/engine/renderers/Indicator/vma.ts +124 -124
- package/src/engine/renderers/Indicator/volumeProfile.ts +125 -125
- package/src/engine/renderers/Indicator/vwap.ts +123 -123
- package/src/engine/renderers/Indicator/wma.ts +136 -136
- package/src/engine/renderers/Indicator/wmsr.ts +328 -328
- package/src/engine/renderers/Indicator/zones.ts +104 -104
- package/src/engine/renderers/__tests__/boll.renderer.test.ts +314 -314
- package/src/engine/renderers/__tests__/ene.renderer.test.ts +305 -305
- package/src/engine/renderers/__tests__/expma.renderer.test.ts +279 -279
- package/src/engine/renderers/__tests__/ma.renderer.test.ts +426 -426
- package/src/engine/renderers/__tests__/mainIndicatorLegend.renderer.test.ts +502 -502
- package/src/engine/renderers/__tests__/yAxis.renderer.test.ts +173 -173
- package/src/engine/renderers/candle.ts +459 -459
- package/src/engine/renderers/crosshair.ts +69 -69
- package/src/engine/renderers/customMarkers.ts +162 -162
- package/src/engine/renderers/extremaMarkers.ts +246 -246
- package/src/engine/renderers/gridLines.ts +90 -90
- package/src/engine/renderers/lastPrice.ts +97 -97
- package/src/engine/renderers/paneTitle.ts +136 -136
- package/src/engine/renderers/subVolume.ts +236 -236
- package/src/engine/renderers/timeAxis.ts +121 -121
- package/src/engine/renderers/webgl/candleSurface.ts +955 -955
- package/src/engine/renderers/webgl/sharedWebGLSurface.ts +146 -146
- package/src/engine/renderers/yAxis.ts +105 -105
- package/src/engine/scale/__tests__/logFormula.spec.ts +148 -148
- package/src/engine/scale/logFormula.ts +130 -130
- package/src/engine/scale/price.ts +39 -39
- package/src/engine/scale/priceScale.ts +264 -264
- package/src/engine/subPaneManager.ts +427 -427
- package/src/engine/theme/colors.ts +642 -642
- package/src/engine/theme/fonts.ts +20 -20
- package/src/engine/utils/klineConfig.ts +49 -49
- package/src/engine/utils/tickCount.ts +11 -11
- package/src/engine/utils/tickPosition.ts +214 -214
- package/src/engine/utils/zoom.ts +83 -83
- package/src/engine/viewport/viewport.ts +67 -67
- package/src/index.ts +3 -3
- package/src/plugin/ConfigManager.ts +93 -93
- package/src/plugin/EventBus.ts +77 -77
- package/src/plugin/HookSystem.ts +106 -106
- package/src/plugin/PluginHost.ts +243 -243
- package/src/plugin/PluginRegistry.ts +92 -92
- package/src/plugin/StateStore.ts +73 -73
- package/src/plugin/index.ts +19 -19
- package/src/plugin/rendererPluginManager.ts +368 -368
- package/src/plugin/stateKeys.ts +8 -8
- package/src/plugin/types.ts +526 -526
- package/src/reactivity/index.ts +2 -2
- package/src/reactivity/signal.ts +119 -119
- package/src/semantic/controller.ts +251 -251
- package/src/semantic/drawShape.ts +260 -260
- package/src/semantic/index.ts +28 -28
- package/src/semantic/schema.json +256 -256
- package/src/semantic/types.ts +251 -251
- package/src/semantic/validator.ts +349 -349
- package/src/types/kLine.ts +13 -13
- package/src/types/price.ts +56 -56
- package/src/types/volumePrice.ts +33 -33
- package/src/utils/dateFormat.ts +208 -208
- package/src/utils/kLineDraw/axis.ts +562 -562
- package/src/utils/priceToY.ts +34 -34
- package/src/utils/volumePrice.ts +202 -202
- package/src/version.ts +1 -1
|
@@ -1,481 +1,481 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
-
import { createIndicatorSelectorController } from '../createIndicatorSelectorController'
|
|
3
|
-
import type { IndicatorDefinition } from '../types'
|
|
4
|
-
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// Fixture catalog — 3 main + 3 sub, with param schemas modelled on the real
|
|
7
|
-
// indicator data in src/core/renderers/Indicator/indicatorData.ts
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
const fixtureCatalog: ReadonlyArray<IndicatorDefinition> = [
|
|
11
|
-
{
|
|
12
|
-
id: 'MA',
|
|
13
|
-
label: 'MA',
|
|
14
|
-
name: 'Moving Average',
|
|
15
|
-
role: 'main',
|
|
16
|
-
params: [
|
|
17
|
-
{ key: 'period', label: 'Period', type: 'number', default: 20, min: 2, max: 200, step: 1 },
|
|
18
|
-
],
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
id: 'BOLL',
|
|
22
|
-
label: 'BOLL',
|
|
23
|
-
name: 'Bollinger Bands',
|
|
24
|
-
role: 'main',
|
|
25
|
-
params: [
|
|
26
|
-
{ key: 'period', label: 'Period', type: 'number', default: 20, min: 2, max: 100, step: 1 },
|
|
27
|
-
{ key: 'multiplier', label: 'Multiplier', type: 'number', default: 2, min: 0.1, max: 5, step: 0.1 },
|
|
28
|
-
],
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
id: 'EXPMA',
|
|
32
|
-
label: 'EXPMA',
|
|
33
|
-
name: 'Exponential MA',
|
|
34
|
-
role: 'main',
|
|
35
|
-
params: [
|
|
36
|
-
{ key: 'fastPeriod', label: 'Fast', type: 'number', default: 12, min: 2, max: 100, step: 1 },
|
|
37
|
-
{ key: 'slowPeriod', label: 'Slow', type: 'number', default: 50, min: 2, max: 200, step: 1 },
|
|
38
|
-
],
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
id: 'KDJ',
|
|
42
|
-
label: 'KDJ',
|
|
43
|
-
name: 'Stochastic KDJ',
|
|
44
|
-
role: 'sub',
|
|
45
|
-
params: [
|
|
46
|
-
{ key: 'period', label: 'Period', type: 'number', default: 9, min: 2, max: 100, step: 1 },
|
|
47
|
-
],
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
id: 'MACD',
|
|
51
|
-
label: 'MACD',
|
|
52
|
-
name: 'MACD',
|
|
53
|
-
role: 'sub',
|
|
54
|
-
params: [
|
|
55
|
-
{ key: 'fast', label: 'Fast', type: 'number', default: 12, min: 2, max: 100, step: 1 },
|
|
56
|
-
{ key: 'slow', label: 'Slow', type: 'number', default: 26, min: 2, max: 200, step: 1 },
|
|
57
|
-
{ key: 'signal', label: 'Signal', type: 'number', default: 9, min: 2, max: 50, step: 1 },
|
|
58
|
-
],
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
id: 'RSI',
|
|
62
|
-
label: 'RSI',
|
|
63
|
-
name: 'Relative Strength Index',
|
|
64
|
-
role: 'sub',
|
|
65
|
-
params: [
|
|
66
|
-
{ key: 'period', label: 'Period', type: 'number', default: 14, min: 2, max: 100, step: 1 },
|
|
67
|
-
],
|
|
68
|
-
},
|
|
69
|
-
]
|
|
70
|
-
|
|
71
|
-
function makeController() {
|
|
72
|
-
return createIndicatorSelectorController({ catalog: fixtureCatalog })
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
// Construction
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
|
|
79
|
-
describe('createIndicatorSelectorController — construction', () => {
|
|
80
|
-
it('exposes the provided catalog', () => {
|
|
81
|
-
const c = makeController()
|
|
82
|
-
expect(c.catalog()).toEqual(fixtureCatalog)
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('starts with empty active list, closed menu and empty query', () => {
|
|
86
|
-
const c = makeController()
|
|
87
|
-
expect(c.active()).toEqual([])
|
|
88
|
-
expect(c.menuOpen()).toBe(false)
|
|
89
|
-
expect(c.searchQuery()).toBe('')
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('defaults catalog to empty when not provided', () => {
|
|
93
|
-
const c = createIndicatorSelectorController()
|
|
94
|
-
expect(c.catalog()).toEqual([])
|
|
95
|
-
expect(c.filteredMain()).toEqual([])
|
|
96
|
-
expect(c.filteredSub()).toEqual([])
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it('accepts initial active indicators', () => {
|
|
100
|
-
const c = createIndicatorSelectorController({
|
|
101
|
-
catalog: fixtureCatalog,
|
|
102
|
-
active: [
|
|
103
|
-
{
|
|
104
|
-
id: 'seed-1',
|
|
105
|
-
definitionId: 'MA',
|
|
106
|
-
label: 'MA',
|
|
107
|
-
name: 'Moving Average',
|
|
108
|
-
role: 'main',
|
|
109
|
-
params: { period: 20 },
|
|
110
|
-
},
|
|
111
|
-
],
|
|
112
|
-
})
|
|
113
|
-
expect(c.active()).toHaveLength(1)
|
|
114
|
-
expect(c.isActive('MA')).toBe(true)
|
|
115
|
-
})
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
// add()
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
|
|
122
|
-
describe('add', () => {
|
|
123
|
-
it('returns a new instance id and pushes onto active', () => {
|
|
124
|
-
const c = makeController()
|
|
125
|
-
const listener = vi.fn()
|
|
126
|
-
c.active.subscribe(listener)
|
|
127
|
-
|
|
128
|
-
const id = c.add('KDJ')
|
|
129
|
-
|
|
130
|
-
expect(id).not.toBeNull()
|
|
131
|
-
expect(c.active()).toHaveLength(1)
|
|
132
|
-
expect(c.active()[0]?.definitionId).toBe('KDJ')
|
|
133
|
-
expect(c.active()[0]?.id).toBe(id)
|
|
134
|
-
expect(listener).toHaveBeenCalledTimes(1)
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
it('seeds default params from the definition', () => {
|
|
138
|
-
const c = makeController()
|
|
139
|
-
c.add('BOLL')
|
|
140
|
-
const inst = c.active()[0]
|
|
141
|
-
expect(inst?.params).toEqual({ period: 20, multiplier: 2 })
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
it('returns null if the definition is already active', () => {
|
|
145
|
-
const c = makeController()
|
|
146
|
-
const first = c.add('RSI')
|
|
147
|
-
expect(first).not.toBeNull()
|
|
148
|
-
|
|
149
|
-
const listener = vi.fn()
|
|
150
|
-
c.active.subscribe(listener)
|
|
151
|
-
|
|
152
|
-
const second = c.add('RSI')
|
|
153
|
-
expect(second).toBeNull()
|
|
154
|
-
expect(c.active()).toHaveLength(1)
|
|
155
|
-
expect(listener).not.toHaveBeenCalled()
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
it('returns null when the definition id is unknown', () => {
|
|
159
|
-
const c = makeController()
|
|
160
|
-
expect(c.add('NOT_A_REAL_INDICATOR')).toBeNull()
|
|
161
|
-
expect(c.active()).toEqual([])
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
it('replaces previous main indicator (mutual exclusion)', () => {
|
|
165
|
-
const c = makeController()
|
|
166
|
-
c.add('MA')
|
|
167
|
-
c.add('BOLL')
|
|
168
|
-
const mains = c.active().filter((a) => a.role === 'main')
|
|
169
|
-
expect(mains).toHaveLength(1)
|
|
170
|
-
expect(mains[0]?.definitionId).toBe('BOLL')
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
it('keeps mains before subs in display order', () => {
|
|
174
|
-
const c = makeController()
|
|
175
|
-
c.add('KDJ')
|
|
176
|
-
c.add('MA')
|
|
177
|
-
c.add('MACD')
|
|
178
|
-
const order = c.active().map((a) => a.definitionId)
|
|
179
|
-
// main should be first; subs follow in their insertion order
|
|
180
|
-
expect(order[0]).toBe('MA')
|
|
181
|
-
expect(order.slice(1).sort()).toEqual(['KDJ', 'MACD'])
|
|
182
|
-
})
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
// ---------------------------------------------------------------------------
|
|
186
|
-
// remove()
|
|
187
|
-
// ---------------------------------------------------------------------------
|
|
188
|
-
|
|
189
|
-
describe('remove', () => {
|
|
190
|
-
it('returns true and emits when the instance exists', () => {
|
|
191
|
-
const c = makeController()
|
|
192
|
-
const id = c.add('KDJ') as string
|
|
193
|
-
const listener = vi.fn()
|
|
194
|
-
c.active.subscribe(listener)
|
|
195
|
-
|
|
196
|
-
const ok = c.remove(id)
|
|
197
|
-
expect(ok).toBe(true)
|
|
198
|
-
expect(c.active()).toEqual([])
|
|
199
|
-
expect(listener).toHaveBeenCalledTimes(1)
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
it('returns false and does NOT emit when the instance is missing', () => {
|
|
203
|
-
const c = makeController()
|
|
204
|
-
const listener = vi.fn()
|
|
205
|
-
c.active.subscribe(listener)
|
|
206
|
-
|
|
207
|
-
const ok = c.remove('nonexistent')
|
|
208
|
-
expect(ok).toBe(false)
|
|
209
|
-
expect(listener).not.toHaveBeenCalled()
|
|
210
|
-
})
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
// ---------------------------------------------------------------------------
|
|
214
|
-
// updateParams()
|
|
215
|
-
// ---------------------------------------------------------------------------
|
|
216
|
-
|
|
217
|
-
describe('updateParams', () => {
|
|
218
|
-
it('merges new param values immutably and emits', () => {
|
|
219
|
-
const c = makeController()
|
|
220
|
-
const id = c.add('BOLL') as string
|
|
221
|
-
const before = c.active()[0]
|
|
222
|
-
|
|
223
|
-
const listener = vi.fn()
|
|
224
|
-
c.active.subscribe(listener)
|
|
225
|
-
|
|
226
|
-
const ok = c.updateParams(id, { period: 30 })
|
|
227
|
-
expect(ok).toBe(true)
|
|
228
|
-
|
|
229
|
-
const after = c.active()[0]
|
|
230
|
-
expect(after?.params).toEqual({ period: 30, multiplier: 2 })
|
|
231
|
-
// immutability: the original instance object should not be mutated
|
|
232
|
-
expect(before?.params).toEqual({ period: 20, multiplier: 2 })
|
|
233
|
-
expect(after).not.toBe(before)
|
|
234
|
-
expect(listener).toHaveBeenCalledTimes(1)
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
it('supports number, string and boolean param values', () => {
|
|
238
|
-
const c = makeController()
|
|
239
|
-
const id = c.add('KDJ') as string
|
|
240
|
-
const ok = c.updateParams(id, { period: 21, label: 'fast', visible: false })
|
|
241
|
-
expect(ok).toBe(true)
|
|
242
|
-
expect(c.active()[0]?.params).toEqual({
|
|
243
|
-
period: 21,
|
|
244
|
-
label: 'fast',
|
|
245
|
-
visible: false,
|
|
246
|
-
})
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
it('returns false and does not emit for an unknown instance id', () => {
|
|
250
|
-
const c = makeController()
|
|
251
|
-
c.add('MA')
|
|
252
|
-
const listener = vi.fn()
|
|
253
|
-
c.active.subscribe(listener)
|
|
254
|
-
|
|
255
|
-
const ok = c.updateParams('nonexistent', { period: 5 })
|
|
256
|
-
expect(ok).toBe(false)
|
|
257
|
-
expect(listener).not.toHaveBeenCalled()
|
|
258
|
-
})
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
// ---------------------------------------------------------------------------
|
|
262
|
-
// reorder()
|
|
263
|
-
// ---------------------------------------------------------------------------
|
|
264
|
-
|
|
265
|
-
describe('reorder', () => {
|
|
266
|
-
it('moves a sub indicator from one position to another', () => {
|
|
267
|
-
const c = makeController()
|
|
268
|
-
const a = c.add('KDJ') as string
|
|
269
|
-
const b = c.add('MACD') as string
|
|
270
|
-
const cc = c.add('RSI') as string
|
|
271
|
-
|
|
272
|
-
// initial: [KDJ, MACD, RSI]
|
|
273
|
-
expect(c.active().map((x) => x.id)).toEqual([a, b, cc])
|
|
274
|
-
|
|
275
|
-
const ok = c.reorder(cc, a)
|
|
276
|
-
expect(ok).toBe(true)
|
|
277
|
-
// RSI now sits where KDJ was → [RSI, KDJ, MACD]
|
|
278
|
-
expect(c.active().map((x) => x.id)).toEqual([cc, a, b])
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
it('refuses to reorder when source is a main indicator', () => {
|
|
282
|
-
const c = makeController()
|
|
283
|
-
const mainId = c.add('MA') as string
|
|
284
|
-
const subId = c.add('KDJ') as string
|
|
285
|
-
const ok = c.reorder(mainId, subId)
|
|
286
|
-
expect(ok).toBe(false)
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
it('refuses to reorder when target is a main indicator', () => {
|
|
290
|
-
const c = makeController()
|
|
291
|
-
const mainId = c.add('MA') as string
|
|
292
|
-
const subId = c.add('KDJ') as string
|
|
293
|
-
const ok = c.reorder(subId, mainId)
|
|
294
|
-
expect(ok).toBe(false)
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
it('returns false when source equals target', () => {
|
|
298
|
-
const c = makeController()
|
|
299
|
-
const id = c.add('KDJ') as string
|
|
300
|
-
const ok = c.reorder(id, id)
|
|
301
|
-
expect(ok).toBe(false)
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
it('returns false when an instance id is unknown', () => {
|
|
305
|
-
const c = makeController()
|
|
306
|
-
const id = c.add('KDJ') as string
|
|
307
|
-
expect(c.reorder(id, 'nope')).toBe(false)
|
|
308
|
-
expect(c.reorder('nope', id)).toBe(false)
|
|
309
|
-
})
|
|
310
|
-
|
|
311
|
-
it('mains stay pinned to the front after a sub reorder', () => {
|
|
312
|
-
const c = makeController()
|
|
313
|
-
c.add('MA')
|
|
314
|
-
const k = c.add('KDJ') as string
|
|
315
|
-
const m = c.add('MACD') as string
|
|
316
|
-
c.reorder(m, k)
|
|
317
|
-
const roles = c.active().map((x) => x.role)
|
|
318
|
-
expect(roles[0]).toBe('main')
|
|
319
|
-
// remaining subs swapped order
|
|
320
|
-
expect(c.active().slice(1).map((x) => x.definitionId)).toEqual([
|
|
321
|
-
'MACD',
|
|
322
|
-
'KDJ',
|
|
323
|
-
])
|
|
324
|
-
})
|
|
325
|
-
})
|
|
326
|
-
|
|
327
|
-
// ---------------------------------------------------------------------------
|
|
328
|
-
// Menu state
|
|
329
|
-
// ---------------------------------------------------------------------------
|
|
330
|
-
|
|
331
|
-
describe('menu state', () => {
|
|
332
|
-
let c: ReturnType<typeof makeController>
|
|
333
|
-
beforeEach(() => {
|
|
334
|
-
c = makeController()
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
it('openMenu sets menuOpen to true', () => {
|
|
338
|
-
c.openMenu()
|
|
339
|
-
expect(c.menuOpen()).toBe(true)
|
|
340
|
-
})
|
|
341
|
-
|
|
342
|
-
it('closeMenu sets menuOpen to false', () => {
|
|
343
|
-
c.openMenu()
|
|
344
|
-
c.closeMenu()
|
|
345
|
-
expect(c.menuOpen()).toBe(false)
|
|
346
|
-
})
|
|
347
|
-
|
|
348
|
-
it('toggleMenu flips menuOpen', () => {
|
|
349
|
-
expect(c.menuOpen()).toBe(false)
|
|
350
|
-
c.toggleMenu()
|
|
351
|
-
expect(c.menuOpen()).toBe(true)
|
|
352
|
-
c.toggleMenu()
|
|
353
|
-
expect(c.menuOpen()).toBe(false)
|
|
354
|
-
})
|
|
355
|
-
|
|
356
|
-
it('notifies subscribers on each transition', () => {
|
|
357
|
-
const listener = vi.fn()
|
|
358
|
-
c.menuOpen.subscribe(listener)
|
|
359
|
-
c.openMenu()
|
|
360
|
-
c.openMenu() // idempotent — no second notification
|
|
361
|
-
c.closeMenu()
|
|
362
|
-
expect(listener).toHaveBeenCalledTimes(2)
|
|
363
|
-
})
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
// ---------------------------------------------------------------------------
|
|
367
|
-
// Search / filtering
|
|
368
|
-
// ---------------------------------------------------------------------------
|
|
369
|
-
|
|
370
|
-
describe('search / filtering', () => {
|
|
371
|
-
it('filteredMain and filteredSub default to all definitions in their role', () => {
|
|
372
|
-
const c = makeController()
|
|
373
|
-
expect(c.filteredMain().map((d) => d.id)).toEqual(['MA', 'BOLL', 'EXPMA'])
|
|
374
|
-
expect(c.filteredSub().map((d) => d.id)).toEqual(['KDJ', 'MACD', 'RSI'])
|
|
375
|
-
})
|
|
376
|
-
|
|
377
|
-
it('setSearchQuery does case-insensitive partial match on label', () => {
|
|
378
|
-
const c = makeController()
|
|
379
|
-
c.setSearchQuery('ma')
|
|
380
|
-
// matches MA (label), MACD (label), EXPMA (label suffix), Moving Average (name)
|
|
381
|
-
const ids = new Set(
|
|
382
|
-
c.filteredMain().concat(c.filteredSub()).map((d) => d.id),
|
|
383
|
-
)
|
|
384
|
-
expect(ids.has('MA')).toBe(true)
|
|
385
|
-
expect(ids.has('MACD')).toBe(true)
|
|
386
|
-
expect(ids.has('EXPMA')).toBe(true)
|
|
387
|
-
})
|
|
388
|
-
|
|
389
|
-
it('matches against name as well as label', () => {
|
|
390
|
-
const c = makeController()
|
|
391
|
-
c.setSearchQuery('Bollinger')
|
|
392
|
-
expect(c.filteredMain().map((d) => d.id)).toEqual(['BOLL'])
|
|
393
|
-
expect(c.filteredSub()).toEqual([])
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
it('upper / lower case does not matter', () => {
|
|
397
|
-
const c = makeController()
|
|
398
|
-
c.setSearchQuery('STOCHASTIC')
|
|
399
|
-
expect(c.filteredSub().map((d) => d.id)).toEqual(['KDJ'])
|
|
400
|
-
})
|
|
401
|
-
|
|
402
|
-
it('an empty query restores the full role-partitioned list', () => {
|
|
403
|
-
const c = makeController()
|
|
404
|
-
c.setSearchQuery('boll')
|
|
405
|
-
expect(c.filteredMain()).toHaveLength(1)
|
|
406
|
-
c.setSearchQuery('')
|
|
407
|
-
expect(c.filteredMain()).toHaveLength(3)
|
|
408
|
-
expect(c.filteredSub()).toHaveLength(3)
|
|
409
|
-
})
|
|
410
|
-
|
|
411
|
-
it('notifies subscribers when the query changes', () => {
|
|
412
|
-
const c = makeController()
|
|
413
|
-
const listener = vi.fn()
|
|
414
|
-
c.filteredSub.subscribe(listener)
|
|
415
|
-
c.setSearchQuery('rsi')
|
|
416
|
-
expect(listener).toHaveBeenCalled()
|
|
417
|
-
})
|
|
418
|
-
})
|
|
419
|
-
|
|
420
|
-
// ---------------------------------------------------------------------------
|
|
421
|
-
// isActive
|
|
422
|
-
// ---------------------------------------------------------------------------
|
|
423
|
-
|
|
424
|
-
describe('isActive', () => {
|
|
425
|
-
it('reflects the active state by definition id', () => {
|
|
426
|
-
const c = makeController()
|
|
427
|
-
expect(c.isActive('MA')).toBe(false)
|
|
428
|
-
c.add('MA')
|
|
429
|
-
expect(c.isActive('MA')).toBe(true)
|
|
430
|
-
expect(c.isActive('BOLL')).toBe(false)
|
|
431
|
-
})
|
|
432
|
-
|
|
433
|
-
it('reads from the definition id, not the instance id', () => {
|
|
434
|
-
const c = makeController()
|
|
435
|
-
const instId = c.add('RSI') as string
|
|
436
|
-
// not active by instance id
|
|
437
|
-
expect(c.isActive(instId)).toBe(false)
|
|
438
|
-
// active by definition id
|
|
439
|
-
expect(c.isActive('RSI')).toBe(true)
|
|
440
|
-
})
|
|
441
|
-
})
|
|
442
|
-
|
|
443
|
-
// ---------------------------------------------------------------------------
|
|
444
|
-
// dispose
|
|
445
|
-
// ---------------------------------------------------------------------------
|
|
446
|
-
|
|
447
|
-
describe('dispose', () => {
|
|
448
|
-
it('subsequent mutations do not emit to previously-attached listeners', () => {
|
|
449
|
-
const c = makeController()
|
|
450
|
-
const id = c.add('KDJ') as string
|
|
451
|
-
|
|
452
|
-
const activeListener = vi.fn()
|
|
453
|
-
const menuListener = vi.fn()
|
|
454
|
-
const queryListener = vi.fn()
|
|
455
|
-
c.active.subscribe(activeListener)
|
|
456
|
-
c.menuOpen.subscribe(menuListener)
|
|
457
|
-
c.searchQuery.subscribe(queryListener)
|
|
458
|
-
|
|
459
|
-
c.dispose()
|
|
460
|
-
|
|
461
|
-
c.add('MACD')
|
|
462
|
-
c.remove(id)
|
|
463
|
-
c.updateParams(id, { period: 99 })
|
|
464
|
-
c.openMenu()
|
|
465
|
-
c.toggleMenu()
|
|
466
|
-
c.setSearchQuery('anything')
|
|
467
|
-
|
|
468
|
-
expect(activeListener).not.toHaveBeenCalled()
|
|
469
|
-
expect(menuListener).not.toHaveBeenCalled()
|
|
470
|
-
expect(queryListener).not.toHaveBeenCalled()
|
|
471
|
-
})
|
|
472
|
-
|
|
473
|
-
it('is idempotent', () => {
|
|
474
|
-
const c = makeController()
|
|
475
|
-
expect(() => {
|
|
476
|
-
c.dispose()
|
|
477
|
-
c.dispose()
|
|
478
|
-
c.dispose()
|
|
479
|
-
}).not.toThrow()
|
|
480
|
-
})
|
|
481
|
-
})
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { createIndicatorSelectorController } from '../createIndicatorSelectorController'
|
|
3
|
+
import type { IndicatorDefinition } from '../types'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Fixture catalog — 3 main + 3 sub, with param schemas modelled on the real
|
|
7
|
+
// indicator data in src/core/renderers/Indicator/indicatorData.ts
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const fixtureCatalog: ReadonlyArray<IndicatorDefinition> = [
|
|
11
|
+
{
|
|
12
|
+
id: 'MA',
|
|
13
|
+
label: 'MA',
|
|
14
|
+
name: 'Moving Average',
|
|
15
|
+
role: 'main',
|
|
16
|
+
params: [
|
|
17
|
+
{ key: 'period', label: 'Period', type: 'number', default: 20, min: 2, max: 200, step: 1 },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'BOLL',
|
|
22
|
+
label: 'BOLL',
|
|
23
|
+
name: 'Bollinger Bands',
|
|
24
|
+
role: 'main',
|
|
25
|
+
params: [
|
|
26
|
+
{ key: 'period', label: 'Period', type: 'number', default: 20, min: 2, max: 100, step: 1 },
|
|
27
|
+
{ key: 'multiplier', label: 'Multiplier', type: 'number', default: 2, min: 0.1, max: 5, step: 0.1 },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'EXPMA',
|
|
32
|
+
label: 'EXPMA',
|
|
33
|
+
name: 'Exponential MA',
|
|
34
|
+
role: 'main',
|
|
35
|
+
params: [
|
|
36
|
+
{ key: 'fastPeriod', label: 'Fast', type: 'number', default: 12, min: 2, max: 100, step: 1 },
|
|
37
|
+
{ key: 'slowPeriod', label: 'Slow', type: 'number', default: 50, min: 2, max: 200, step: 1 },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'KDJ',
|
|
42
|
+
label: 'KDJ',
|
|
43
|
+
name: 'Stochastic KDJ',
|
|
44
|
+
role: 'sub',
|
|
45
|
+
params: [
|
|
46
|
+
{ key: 'period', label: 'Period', type: 'number', default: 9, min: 2, max: 100, step: 1 },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'MACD',
|
|
51
|
+
label: 'MACD',
|
|
52
|
+
name: 'MACD',
|
|
53
|
+
role: 'sub',
|
|
54
|
+
params: [
|
|
55
|
+
{ key: 'fast', label: 'Fast', type: 'number', default: 12, min: 2, max: 100, step: 1 },
|
|
56
|
+
{ key: 'slow', label: 'Slow', type: 'number', default: 26, min: 2, max: 200, step: 1 },
|
|
57
|
+
{ key: 'signal', label: 'Signal', type: 'number', default: 9, min: 2, max: 50, step: 1 },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'RSI',
|
|
62
|
+
label: 'RSI',
|
|
63
|
+
name: 'Relative Strength Index',
|
|
64
|
+
role: 'sub',
|
|
65
|
+
params: [
|
|
66
|
+
{ key: 'period', label: 'Period', type: 'number', default: 14, min: 2, max: 100, step: 1 },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
function makeController() {
|
|
72
|
+
return createIndicatorSelectorController({ catalog: fixtureCatalog })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Construction
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
describe('createIndicatorSelectorController — construction', () => {
|
|
80
|
+
it('exposes the provided catalog', () => {
|
|
81
|
+
const c = makeController()
|
|
82
|
+
expect(c.catalog()).toEqual(fixtureCatalog)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('starts with empty active list, closed menu and empty query', () => {
|
|
86
|
+
const c = makeController()
|
|
87
|
+
expect(c.active()).toEqual([])
|
|
88
|
+
expect(c.menuOpen()).toBe(false)
|
|
89
|
+
expect(c.searchQuery()).toBe('')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('defaults catalog to empty when not provided', () => {
|
|
93
|
+
const c = createIndicatorSelectorController()
|
|
94
|
+
expect(c.catalog()).toEqual([])
|
|
95
|
+
expect(c.filteredMain()).toEqual([])
|
|
96
|
+
expect(c.filteredSub()).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('accepts initial active indicators', () => {
|
|
100
|
+
const c = createIndicatorSelectorController({
|
|
101
|
+
catalog: fixtureCatalog,
|
|
102
|
+
active: [
|
|
103
|
+
{
|
|
104
|
+
id: 'seed-1',
|
|
105
|
+
definitionId: 'MA',
|
|
106
|
+
label: 'MA',
|
|
107
|
+
name: 'Moving Average',
|
|
108
|
+
role: 'main',
|
|
109
|
+
params: { period: 20 },
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
})
|
|
113
|
+
expect(c.active()).toHaveLength(1)
|
|
114
|
+
expect(c.isActive('MA')).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// add()
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
describe('add', () => {
|
|
123
|
+
it('returns a new instance id and pushes onto active', () => {
|
|
124
|
+
const c = makeController()
|
|
125
|
+
const listener = vi.fn()
|
|
126
|
+
c.active.subscribe(listener)
|
|
127
|
+
|
|
128
|
+
const id = c.add('KDJ')
|
|
129
|
+
|
|
130
|
+
expect(id).not.toBeNull()
|
|
131
|
+
expect(c.active()).toHaveLength(1)
|
|
132
|
+
expect(c.active()[0]?.definitionId).toBe('KDJ')
|
|
133
|
+
expect(c.active()[0]?.id).toBe(id)
|
|
134
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('seeds default params from the definition', () => {
|
|
138
|
+
const c = makeController()
|
|
139
|
+
c.add('BOLL')
|
|
140
|
+
const inst = c.active()[0]
|
|
141
|
+
expect(inst?.params).toEqual({ period: 20, multiplier: 2 })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('returns null if the definition is already active', () => {
|
|
145
|
+
const c = makeController()
|
|
146
|
+
const first = c.add('RSI')
|
|
147
|
+
expect(first).not.toBeNull()
|
|
148
|
+
|
|
149
|
+
const listener = vi.fn()
|
|
150
|
+
c.active.subscribe(listener)
|
|
151
|
+
|
|
152
|
+
const second = c.add('RSI')
|
|
153
|
+
expect(second).toBeNull()
|
|
154
|
+
expect(c.active()).toHaveLength(1)
|
|
155
|
+
expect(listener).not.toHaveBeenCalled()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('returns null when the definition id is unknown', () => {
|
|
159
|
+
const c = makeController()
|
|
160
|
+
expect(c.add('NOT_A_REAL_INDICATOR')).toBeNull()
|
|
161
|
+
expect(c.active()).toEqual([])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('replaces previous main indicator (mutual exclusion)', () => {
|
|
165
|
+
const c = makeController()
|
|
166
|
+
c.add('MA')
|
|
167
|
+
c.add('BOLL')
|
|
168
|
+
const mains = c.active().filter((a) => a.role === 'main')
|
|
169
|
+
expect(mains).toHaveLength(1)
|
|
170
|
+
expect(mains[0]?.definitionId).toBe('BOLL')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('keeps mains before subs in display order', () => {
|
|
174
|
+
const c = makeController()
|
|
175
|
+
c.add('KDJ')
|
|
176
|
+
c.add('MA')
|
|
177
|
+
c.add('MACD')
|
|
178
|
+
const order = c.active().map((a) => a.definitionId)
|
|
179
|
+
// main should be first; subs follow in their insertion order
|
|
180
|
+
expect(order[0]).toBe('MA')
|
|
181
|
+
expect(order.slice(1).sort()).toEqual(['KDJ', 'MACD'])
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// remove()
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
describe('remove', () => {
|
|
190
|
+
it('returns true and emits when the instance exists', () => {
|
|
191
|
+
const c = makeController()
|
|
192
|
+
const id = c.add('KDJ') as string
|
|
193
|
+
const listener = vi.fn()
|
|
194
|
+
c.active.subscribe(listener)
|
|
195
|
+
|
|
196
|
+
const ok = c.remove(id)
|
|
197
|
+
expect(ok).toBe(true)
|
|
198
|
+
expect(c.active()).toEqual([])
|
|
199
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('returns false and does NOT emit when the instance is missing', () => {
|
|
203
|
+
const c = makeController()
|
|
204
|
+
const listener = vi.fn()
|
|
205
|
+
c.active.subscribe(listener)
|
|
206
|
+
|
|
207
|
+
const ok = c.remove('nonexistent')
|
|
208
|
+
expect(ok).toBe(false)
|
|
209
|
+
expect(listener).not.toHaveBeenCalled()
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// updateParams()
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
describe('updateParams', () => {
|
|
218
|
+
it('merges new param values immutably and emits', () => {
|
|
219
|
+
const c = makeController()
|
|
220
|
+
const id = c.add('BOLL') as string
|
|
221
|
+
const before = c.active()[0]
|
|
222
|
+
|
|
223
|
+
const listener = vi.fn()
|
|
224
|
+
c.active.subscribe(listener)
|
|
225
|
+
|
|
226
|
+
const ok = c.updateParams(id, { period: 30 })
|
|
227
|
+
expect(ok).toBe(true)
|
|
228
|
+
|
|
229
|
+
const after = c.active()[0]
|
|
230
|
+
expect(after?.params).toEqual({ period: 30, multiplier: 2 })
|
|
231
|
+
// immutability: the original instance object should not be mutated
|
|
232
|
+
expect(before?.params).toEqual({ period: 20, multiplier: 2 })
|
|
233
|
+
expect(after).not.toBe(before)
|
|
234
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('supports number, string and boolean param values', () => {
|
|
238
|
+
const c = makeController()
|
|
239
|
+
const id = c.add('KDJ') as string
|
|
240
|
+
const ok = c.updateParams(id, { period: 21, label: 'fast', visible: false })
|
|
241
|
+
expect(ok).toBe(true)
|
|
242
|
+
expect(c.active()[0]?.params).toEqual({
|
|
243
|
+
period: 21,
|
|
244
|
+
label: 'fast',
|
|
245
|
+
visible: false,
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('returns false and does not emit for an unknown instance id', () => {
|
|
250
|
+
const c = makeController()
|
|
251
|
+
c.add('MA')
|
|
252
|
+
const listener = vi.fn()
|
|
253
|
+
c.active.subscribe(listener)
|
|
254
|
+
|
|
255
|
+
const ok = c.updateParams('nonexistent', { period: 5 })
|
|
256
|
+
expect(ok).toBe(false)
|
|
257
|
+
expect(listener).not.toHaveBeenCalled()
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// reorder()
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
describe('reorder', () => {
|
|
266
|
+
it('moves a sub indicator from one position to another', () => {
|
|
267
|
+
const c = makeController()
|
|
268
|
+
const a = c.add('KDJ') as string
|
|
269
|
+
const b = c.add('MACD') as string
|
|
270
|
+
const cc = c.add('RSI') as string
|
|
271
|
+
|
|
272
|
+
// initial: [KDJ, MACD, RSI]
|
|
273
|
+
expect(c.active().map((x) => x.id)).toEqual([a, b, cc])
|
|
274
|
+
|
|
275
|
+
const ok = c.reorder(cc, a)
|
|
276
|
+
expect(ok).toBe(true)
|
|
277
|
+
// RSI now sits where KDJ was → [RSI, KDJ, MACD]
|
|
278
|
+
expect(c.active().map((x) => x.id)).toEqual([cc, a, b])
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('refuses to reorder when source is a main indicator', () => {
|
|
282
|
+
const c = makeController()
|
|
283
|
+
const mainId = c.add('MA') as string
|
|
284
|
+
const subId = c.add('KDJ') as string
|
|
285
|
+
const ok = c.reorder(mainId, subId)
|
|
286
|
+
expect(ok).toBe(false)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('refuses to reorder when target is a main indicator', () => {
|
|
290
|
+
const c = makeController()
|
|
291
|
+
const mainId = c.add('MA') as string
|
|
292
|
+
const subId = c.add('KDJ') as string
|
|
293
|
+
const ok = c.reorder(subId, mainId)
|
|
294
|
+
expect(ok).toBe(false)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('returns false when source equals target', () => {
|
|
298
|
+
const c = makeController()
|
|
299
|
+
const id = c.add('KDJ') as string
|
|
300
|
+
const ok = c.reorder(id, id)
|
|
301
|
+
expect(ok).toBe(false)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('returns false when an instance id is unknown', () => {
|
|
305
|
+
const c = makeController()
|
|
306
|
+
const id = c.add('KDJ') as string
|
|
307
|
+
expect(c.reorder(id, 'nope')).toBe(false)
|
|
308
|
+
expect(c.reorder('nope', id)).toBe(false)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('mains stay pinned to the front after a sub reorder', () => {
|
|
312
|
+
const c = makeController()
|
|
313
|
+
c.add('MA')
|
|
314
|
+
const k = c.add('KDJ') as string
|
|
315
|
+
const m = c.add('MACD') as string
|
|
316
|
+
c.reorder(m, k)
|
|
317
|
+
const roles = c.active().map((x) => x.role)
|
|
318
|
+
expect(roles[0]).toBe('main')
|
|
319
|
+
// remaining subs swapped order
|
|
320
|
+
expect(c.active().slice(1).map((x) => x.definitionId)).toEqual([
|
|
321
|
+
'MACD',
|
|
322
|
+
'KDJ',
|
|
323
|
+
])
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Menu state
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
describe('menu state', () => {
|
|
332
|
+
let c: ReturnType<typeof makeController>
|
|
333
|
+
beforeEach(() => {
|
|
334
|
+
c = makeController()
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('openMenu sets menuOpen to true', () => {
|
|
338
|
+
c.openMenu()
|
|
339
|
+
expect(c.menuOpen()).toBe(true)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('closeMenu sets menuOpen to false', () => {
|
|
343
|
+
c.openMenu()
|
|
344
|
+
c.closeMenu()
|
|
345
|
+
expect(c.menuOpen()).toBe(false)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('toggleMenu flips menuOpen', () => {
|
|
349
|
+
expect(c.menuOpen()).toBe(false)
|
|
350
|
+
c.toggleMenu()
|
|
351
|
+
expect(c.menuOpen()).toBe(true)
|
|
352
|
+
c.toggleMenu()
|
|
353
|
+
expect(c.menuOpen()).toBe(false)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('notifies subscribers on each transition', () => {
|
|
357
|
+
const listener = vi.fn()
|
|
358
|
+
c.menuOpen.subscribe(listener)
|
|
359
|
+
c.openMenu()
|
|
360
|
+
c.openMenu() // idempotent — no second notification
|
|
361
|
+
c.closeMenu()
|
|
362
|
+
expect(listener).toHaveBeenCalledTimes(2)
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// Search / filtering
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
describe('search / filtering', () => {
|
|
371
|
+
it('filteredMain and filteredSub default to all definitions in their role', () => {
|
|
372
|
+
const c = makeController()
|
|
373
|
+
expect(c.filteredMain().map((d) => d.id)).toEqual(['MA', 'BOLL', 'EXPMA'])
|
|
374
|
+
expect(c.filteredSub().map((d) => d.id)).toEqual(['KDJ', 'MACD', 'RSI'])
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('setSearchQuery does case-insensitive partial match on label', () => {
|
|
378
|
+
const c = makeController()
|
|
379
|
+
c.setSearchQuery('ma')
|
|
380
|
+
// matches MA (label), MACD (label), EXPMA (label suffix), Moving Average (name)
|
|
381
|
+
const ids = new Set(
|
|
382
|
+
c.filteredMain().concat(c.filteredSub()).map((d) => d.id),
|
|
383
|
+
)
|
|
384
|
+
expect(ids.has('MA')).toBe(true)
|
|
385
|
+
expect(ids.has('MACD')).toBe(true)
|
|
386
|
+
expect(ids.has('EXPMA')).toBe(true)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('matches against name as well as label', () => {
|
|
390
|
+
const c = makeController()
|
|
391
|
+
c.setSearchQuery('Bollinger')
|
|
392
|
+
expect(c.filteredMain().map((d) => d.id)).toEqual(['BOLL'])
|
|
393
|
+
expect(c.filteredSub()).toEqual([])
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('upper / lower case does not matter', () => {
|
|
397
|
+
const c = makeController()
|
|
398
|
+
c.setSearchQuery('STOCHASTIC')
|
|
399
|
+
expect(c.filteredSub().map((d) => d.id)).toEqual(['KDJ'])
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('an empty query restores the full role-partitioned list', () => {
|
|
403
|
+
const c = makeController()
|
|
404
|
+
c.setSearchQuery('boll')
|
|
405
|
+
expect(c.filteredMain()).toHaveLength(1)
|
|
406
|
+
c.setSearchQuery('')
|
|
407
|
+
expect(c.filteredMain()).toHaveLength(3)
|
|
408
|
+
expect(c.filteredSub()).toHaveLength(3)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('notifies subscribers when the query changes', () => {
|
|
412
|
+
const c = makeController()
|
|
413
|
+
const listener = vi.fn()
|
|
414
|
+
c.filteredSub.subscribe(listener)
|
|
415
|
+
c.setSearchQuery('rsi')
|
|
416
|
+
expect(listener).toHaveBeenCalled()
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// isActive
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
describe('isActive', () => {
|
|
425
|
+
it('reflects the active state by definition id', () => {
|
|
426
|
+
const c = makeController()
|
|
427
|
+
expect(c.isActive('MA')).toBe(false)
|
|
428
|
+
c.add('MA')
|
|
429
|
+
expect(c.isActive('MA')).toBe(true)
|
|
430
|
+
expect(c.isActive('BOLL')).toBe(false)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('reads from the definition id, not the instance id', () => {
|
|
434
|
+
const c = makeController()
|
|
435
|
+
const instId = c.add('RSI') as string
|
|
436
|
+
// not active by instance id
|
|
437
|
+
expect(c.isActive(instId)).toBe(false)
|
|
438
|
+
// active by definition id
|
|
439
|
+
expect(c.isActive('RSI')).toBe(true)
|
|
440
|
+
})
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// dispose
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
describe('dispose', () => {
|
|
448
|
+
it('subsequent mutations do not emit to previously-attached listeners', () => {
|
|
449
|
+
const c = makeController()
|
|
450
|
+
const id = c.add('KDJ') as string
|
|
451
|
+
|
|
452
|
+
const activeListener = vi.fn()
|
|
453
|
+
const menuListener = vi.fn()
|
|
454
|
+
const queryListener = vi.fn()
|
|
455
|
+
c.active.subscribe(activeListener)
|
|
456
|
+
c.menuOpen.subscribe(menuListener)
|
|
457
|
+
c.searchQuery.subscribe(queryListener)
|
|
458
|
+
|
|
459
|
+
c.dispose()
|
|
460
|
+
|
|
461
|
+
c.add('MACD')
|
|
462
|
+
c.remove(id)
|
|
463
|
+
c.updateParams(id, { period: 99 })
|
|
464
|
+
c.openMenu()
|
|
465
|
+
c.toggleMenu()
|
|
466
|
+
c.setSearchQuery('anything')
|
|
467
|
+
|
|
468
|
+
expect(activeListener).not.toHaveBeenCalled()
|
|
469
|
+
expect(menuListener).not.toHaveBeenCalled()
|
|
470
|
+
expect(queryListener).not.toHaveBeenCalled()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('is idempotent', () => {
|
|
474
|
+
const c = makeController()
|
|
475
|
+
expect(() => {
|
|
476
|
+
c.dispose()
|
|
477
|
+
c.dispose()
|
|
478
|
+
c.dispose()
|
|
479
|
+
}).not.toThrow()
|
|
480
|
+
})
|
|
481
|
+
})
|