@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.
Files changed (231) hide show
  1. package/README.md +201 -201
  2. package/README.zh-CN.md +201 -201
  3. package/dist/engine/renderers/webgl/candleSurface.js +47 -47
  4. package/dist/version.d.ts +1 -1
  5. package/dist/version.d.ts.map +1 -1
  6. package/dist/version.js +1 -2
  7. package/dist/version.js.map +1 -1
  8. package/package.json +129 -122
  9. package/src/__tests__/signal.test.ts +124 -124
  10. package/src/config/chartSettings.ts +66 -66
  11. package/src/controllers/__tests__/drawing.test.ts +214 -214
  12. package/src/controllers/__tests__/indicatorSelector.test.ts +481 -481
  13. package/src/controllers/__tests__/toolbar.test.ts +225 -225
  14. package/src/controllers/createChartController.ts +665 -665
  15. package/src/controllers/createDrawingController.ts +96 -96
  16. package/src/controllers/createIndicatorSelectorController.ts +307 -307
  17. package/src/controllers/createToolbarController.ts +146 -146
  18. package/src/controllers/index.ts +19 -19
  19. package/src/controllers/types.ts +284 -284
  20. package/src/engine/__tests__/chart.dpr.test.ts +401 -401
  21. package/src/engine/__tests__/paneRenderer.resize.test.ts +92 -92
  22. package/src/engine/chart-store.ts +121 -121
  23. package/src/engine/chart.d.ts +617 -617
  24. package/src/engine/chart.ts +2815 -2815
  25. package/src/engine/controller/__tests__/interaction.dpr.test.ts +259 -259
  26. package/src/engine/controller/interaction.ts +722 -722
  27. package/src/engine/controller/markerInteraction.ts +130 -130
  28. package/src/engine/controller/pinchTracker.ts +82 -82
  29. package/src/engine/controller/tooltipPosition.ts +48 -48
  30. package/src/engine/draw/__tests__/pixelAlign.spec.ts +176 -176
  31. package/src/engine/draw/pixelAlign.ts +259 -259
  32. package/src/engine/drawing/index.ts +655 -655
  33. package/src/engine/drawing/interaction.ts +842 -842
  34. package/src/engine/drawing/plugin.ts +343 -343
  35. package/src/engine/indicators/__tests__/__fixtures__/golden/atr.json +38 -38
  36. package/src/engine/indicators/__tests__/__fixtures__/golden/dema.json +14 -14
  37. package/src/engine/indicators/__tests__/__fixtures__/golden/hma.json +14 -14
  38. package/src/engine/indicators/__tests__/__fixtures__/golden/index.ts +55 -55
  39. package/src/engine/indicators/__tests__/__fixtures__/golden/kama.json +14 -14
  40. package/src/engine/indicators/__tests__/__fixtures__/golden/tema.json +14 -14
  41. package/src/engine/indicators/__tests__/__fixtures__/golden/wma.json +40 -40
  42. package/src/engine/indicators/__tests__/__fixtures__/synthetic.ts +65 -65
  43. package/src/engine/indicators/__tests__/_propertyAssertions.ts +76 -76
  44. package/src/engine/indicators/__tests__/atr.test.ts +153 -153
  45. package/src/engine/indicators/__tests__/calculators.test.ts +614 -614
  46. package/src/engine/indicators/__tests__/cmf-mfi.test.ts +100 -100
  47. package/src/engine/indicators/__tests__/dema.test.ts +73 -73
  48. package/src/engine/indicators/__tests__/donchian.test.ts +70 -70
  49. package/src/engine/indicators/__tests__/hma.test.ts +73 -73
  50. package/src/engine/indicators/__tests__/ichimoku.test.ts +105 -105
  51. package/src/engine/indicators/__tests__/kama.test.ts +80 -80
  52. package/src/engine/indicators/__tests__/keltner.test.ts +65 -65
  53. package/src/engine/indicators/__tests__/pivot-fib.test.ts +110 -110
  54. package/src/engine/indicators/__tests__/roc.test.ts +68 -68
  55. package/src/engine/indicators/__tests__/sar.test.ts +86 -86
  56. package/src/engine/indicators/__tests__/scheduler.test.ts +831 -831
  57. package/src/engine/indicators/__tests__/soa.test.ts +533 -533
  58. package/src/engine/indicators/__tests__/structure.test.ts +110 -110
  59. package/src/engine/indicators/__tests__/supertrend.test.ts +65 -65
  60. package/src/engine/indicators/__tests__/tema.test.ts +68 -68
  61. package/src/engine/indicators/__tests__/trix.test.ts +70 -70
  62. package/src/engine/indicators/__tests__/volatility.test.ts +117 -117
  63. package/src/engine/indicators/__tests__/volume.test.ts +115 -115
  64. package/src/engine/indicators/__tests__/volumeProfile.test.ts +74 -74
  65. package/src/engine/indicators/__tests__/vwap.test.ts +69 -69
  66. package/src/engine/indicators/__tests__/wma.test.ts +112 -112
  67. package/src/engine/indicators/__tests__/zones.test.ts +95 -95
  68. package/src/engine/indicators/atrState.ts +27 -27
  69. package/src/engine/indicators/bollState.ts +51 -51
  70. package/src/engine/indicators/calculators.ts +2593 -2593
  71. package/src/engine/indicators/cciState.ts +25 -25
  72. package/src/engine/indicators/chaikinVolState.ts +32 -32
  73. package/src/engine/indicators/cmfState.ts +27 -27
  74. package/src/engine/indicators/demaState.ts +27 -27
  75. package/src/engine/indicators/donchianState.ts +43 -43
  76. package/src/engine/indicators/eneState.ts +43 -43
  77. package/src/engine/indicators/expmaState.ts +43 -43
  78. package/src/engine/indicators/fastkState.ts +25 -25
  79. package/src/engine/indicators/fibState.ts +41 -41
  80. package/src/engine/indicators/hmaState.ts +27 -27
  81. package/src/engine/indicators/hvState.ts +28 -28
  82. package/src/engine/indicators/ichimokuState.ts +70 -70
  83. package/src/engine/indicators/indicator.worker.ts +169 -169
  84. package/src/engine/indicators/indicatorDefinitionRegistry.ts +62 -62
  85. package/src/engine/indicators/indicatorMetadata.ts +110 -110
  86. package/src/engine/indicators/indicatorRegistry.ts +106 -106
  87. package/src/engine/indicators/indicatorRuntime.ts +1548 -1548
  88. package/src/engine/indicators/kamaState.ts +34 -34
  89. package/src/engine/indicators/keltnerState.ts +49 -49
  90. package/src/engine/indicators/kstState.ts +42 -42
  91. package/src/engine/indicators/maState.ts +36 -36
  92. package/src/engine/indicators/macdState.ts +76 -76
  93. package/src/engine/indicators/mfiState.ts +27 -27
  94. package/src/engine/indicators/momState.ts +25 -25
  95. package/src/engine/indicators/obvState.ts +25 -25
  96. package/src/engine/indicators/parkinsonState.ts +28 -28
  97. package/src/engine/indicators/pivotState.ts +51 -51
  98. package/src/engine/indicators/pvtState.ts +25 -25
  99. package/src/engine/indicators/rocState.ts +27 -27
  100. package/src/engine/indicators/rsiState.ts +65 -65
  101. package/src/engine/indicators/sarState.ts +41 -41
  102. package/src/engine/indicators/scheduler.ts +1205 -1205
  103. package/src/engine/indicators/soa.ts +352 -352
  104. package/src/engine/indicators/stateComposer.ts +1262 -1262
  105. package/src/engine/indicators/stochState.ts +26 -26
  106. package/src/engine/indicators/structureState.ts +69 -69
  107. package/src/engine/indicators/supertrendState.ts +37 -37
  108. package/src/engine/indicators/temaState.ts +27 -27
  109. package/src/engine/indicators/trixState.ts +35 -35
  110. package/src/engine/indicators/vmaState.ts +27 -27
  111. package/src/engine/indicators/volumeProfileState.ts +63 -63
  112. package/src/engine/indicators/vwapState.ts +29 -29
  113. package/src/engine/indicators/wmaState.ts +27 -27
  114. package/src/engine/indicators/wmsrState.ts +25 -25
  115. package/src/engine/indicators/workerProtocol.ts +613 -613
  116. package/src/engine/indicators/zonesState.ts +47 -47
  117. package/src/engine/layout/pane.ts +161 -161
  118. package/src/engine/marker/registry.ts +265 -265
  119. package/src/engine/paneRenderer.ts +169 -169
  120. package/src/engine/renderers/Indicator/atr.ts +237 -237
  121. package/src/engine/renderers/Indicator/boll.ts +317 -317
  122. package/src/engine/renderers/Indicator/cci.ts +275 -275
  123. package/src/engine/renderers/Indicator/chaikinVol.ts +138 -138
  124. package/src/engine/renderers/Indicator/cmf.ts +137 -137
  125. package/src/engine/renderers/Indicator/dema.ts +136 -136
  126. package/src/engine/renderers/Indicator/donchian.ts +137 -137
  127. package/src/engine/renderers/Indicator/ene.ts +271 -271
  128. package/src/engine/renderers/Indicator/expma.ts +197 -197
  129. package/src/engine/renderers/Indicator/fastk.ts +316 -316
  130. package/src/engine/renderers/Indicator/fib.ts +141 -141
  131. package/src/engine/renderers/Indicator/hma.ts +136 -136
  132. package/src/engine/renderers/Indicator/hv.ts +124 -124
  133. package/src/engine/renderers/Indicator/ichimoku.ts +181 -181
  134. package/src/engine/renderers/Indicator/index.ts +241 -241
  135. package/src/engine/renderers/Indicator/indicatorData.ts +650 -650
  136. package/src/engine/renderers/Indicator/kama.ts +136 -136
  137. package/src/engine/renderers/Indicator/keltner.ts +137 -137
  138. package/src/engine/renderers/Indicator/kst.ts +302 -302
  139. package/src/engine/renderers/Indicator/ma.ts +200 -200
  140. package/src/engine/renderers/Indicator/macd.ts +477 -477
  141. package/src/engine/renderers/Indicator/macdLegend.ts +141 -141
  142. package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +272 -272
  143. package/src/engine/renderers/Indicator/mfi.ts +142 -142
  144. package/src/engine/renderers/Indicator/mom.ts +311 -311
  145. package/src/engine/renderers/Indicator/obv.ts +123 -123
  146. package/src/engine/renderers/Indicator/parkinson.ts +124 -124
  147. package/src/engine/renderers/Indicator/pivot.ts +131 -131
  148. package/src/engine/renderers/Indicator/pvt.ts +123 -123
  149. package/src/engine/renderers/Indicator/roc.ts +143 -143
  150. package/src/engine/renderers/Indicator/rsi.ts +390 -390
  151. package/src/engine/renderers/Indicator/sar.ts +113 -113
  152. package/src/engine/renderers/Indicator/scale/atr_scale.ts +19 -19
  153. package/src/engine/renderers/Indicator/scale/cci_scale.ts +19 -19
  154. package/src/engine/renderers/Indicator/scale/fastk_scale.ts +19 -19
  155. package/src/engine/renderers/Indicator/scale/indicator_scale.ts +204 -204
  156. package/src/engine/renderers/Indicator/scale/kst_scale.ts +19 -19
  157. package/src/engine/renderers/Indicator/scale/macd_scale.ts +22 -22
  158. package/src/engine/renderers/Indicator/scale/mom_scale.ts +19 -19
  159. package/src/engine/renderers/Indicator/scale/rsi_scale.ts +19 -19
  160. package/src/engine/renderers/Indicator/scale/stoch_scale.ts +19 -19
  161. package/src/engine/renderers/Indicator/scale/volume_scale.ts +26 -26
  162. package/src/engine/renderers/Indicator/scale/wmsr_scale.ts +19 -19
  163. package/src/engine/renderers/Indicator/stoch.ts +359 -359
  164. package/src/engine/renderers/Indicator/structure.ts +126 -126
  165. package/src/engine/renderers/Indicator/subPaneConfig.ts +265 -265
  166. package/src/engine/renderers/Indicator/supertrend.ts +115 -115
  167. package/src/engine/renderers/Indicator/tema.ts +136 -136
  168. package/src/engine/renderers/Indicator/trix.ts +158 -158
  169. package/src/engine/renderers/Indicator/vma.ts +124 -124
  170. package/src/engine/renderers/Indicator/volumeProfile.ts +125 -125
  171. package/src/engine/renderers/Indicator/vwap.ts +123 -123
  172. package/src/engine/renderers/Indicator/wma.ts +136 -136
  173. package/src/engine/renderers/Indicator/wmsr.ts +328 -328
  174. package/src/engine/renderers/Indicator/zones.ts +104 -104
  175. package/src/engine/renderers/__tests__/boll.renderer.test.ts +314 -314
  176. package/src/engine/renderers/__tests__/ene.renderer.test.ts +305 -305
  177. package/src/engine/renderers/__tests__/expma.renderer.test.ts +279 -279
  178. package/src/engine/renderers/__tests__/ma.renderer.test.ts +426 -426
  179. package/src/engine/renderers/__tests__/mainIndicatorLegend.renderer.test.ts +502 -502
  180. package/src/engine/renderers/__tests__/yAxis.renderer.test.ts +173 -173
  181. package/src/engine/renderers/candle.ts +459 -459
  182. package/src/engine/renderers/crosshair.ts +69 -69
  183. package/src/engine/renderers/customMarkers.ts +162 -162
  184. package/src/engine/renderers/extremaMarkers.ts +246 -246
  185. package/src/engine/renderers/gridLines.ts +90 -90
  186. package/src/engine/renderers/lastPrice.ts +97 -97
  187. package/src/engine/renderers/paneTitle.ts +136 -136
  188. package/src/engine/renderers/subVolume.ts +236 -236
  189. package/src/engine/renderers/timeAxis.ts +121 -121
  190. package/src/engine/renderers/webgl/candleSurface.ts +955 -955
  191. package/src/engine/renderers/webgl/sharedWebGLSurface.ts +146 -146
  192. package/src/engine/renderers/yAxis.ts +105 -105
  193. package/src/engine/scale/__tests__/logFormula.spec.ts +148 -148
  194. package/src/engine/scale/logFormula.ts +130 -130
  195. package/src/engine/scale/price.ts +39 -39
  196. package/src/engine/scale/priceScale.ts +264 -264
  197. package/src/engine/subPaneManager.ts +427 -427
  198. package/src/engine/theme/colors.ts +642 -642
  199. package/src/engine/theme/fonts.ts +20 -20
  200. package/src/engine/utils/klineConfig.ts +49 -49
  201. package/src/engine/utils/tickCount.ts +11 -11
  202. package/src/engine/utils/tickPosition.ts +214 -214
  203. package/src/engine/utils/zoom.ts +83 -83
  204. package/src/engine/viewport/viewport.ts +67 -67
  205. package/src/index.ts +3 -3
  206. package/src/plugin/ConfigManager.ts +93 -93
  207. package/src/plugin/EventBus.ts +77 -77
  208. package/src/plugin/HookSystem.ts +106 -106
  209. package/src/plugin/PluginHost.ts +243 -243
  210. package/src/plugin/PluginRegistry.ts +92 -92
  211. package/src/plugin/StateStore.ts +73 -73
  212. package/src/plugin/index.ts +19 -19
  213. package/src/plugin/rendererPluginManager.ts +368 -368
  214. package/src/plugin/stateKeys.ts +8 -8
  215. package/src/plugin/types.ts +526 -526
  216. package/src/reactivity/index.ts +2 -2
  217. package/src/reactivity/signal.ts +119 -119
  218. package/src/semantic/controller.ts +251 -251
  219. package/src/semantic/drawShape.ts +260 -260
  220. package/src/semantic/index.ts +28 -28
  221. package/src/semantic/schema.json +256 -256
  222. package/src/semantic/types.ts +251 -251
  223. package/src/semantic/validator.ts +349 -349
  224. package/src/types/kLine.ts +13 -13
  225. package/src/types/price.ts +56 -56
  226. package/src/types/volumePrice.ts +33 -33
  227. package/src/utils/dateFormat.ts +208 -208
  228. package/src/utils/kLineDraw/axis.ts +562 -562
  229. package/src/utils/priceToY.ts +34 -34
  230. package/src/utils/volumePrice.ts +202 -202
  231. package/src/version.ts +1 -1
@@ -1,831 +1,831 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
2
- import { IndicatorScheduler } from '../scheduler'
3
- import { MA_STATE_KEY, EMPTY_MA_STATE, type MARenderState } from '../maState'
4
- import { BOLL_STATE_KEY, EMPTY_BOLL_STATE, type BOLLRenderState } from '../bollState'
5
- import { EXPMA_STATE_KEY, EMPTY_EXPMA_STATE, type EXPMARenderState } from '../expmaState'
6
- import { ENE_STATE_KEY, EMPTY_ENE_STATE, type ENERenderState } from '../eneState'
7
- import { createRSIStateKey, EMPTY_RSI_STATE, type RSIRenderState } from '../rsiState'
8
- import type { KLineData } from '@/types/price'
9
- import type { PluginHost } from '@/plugin'
10
-
11
- /**
12
- * 创建测试用的 K 线数据
13
- */
14
- function createTestData(length: number, startPrice = 100): KLineData[] {
15
- return Array.from({ length }, (_, i) => ({
16
- timestamp: 1000000000000 + i * 60000,
17
- open: startPrice + i,
18
- high: startPrice + i + 1,
19
- low: startPrice + i - 1,
20
- close: startPrice + i,
21
- volume: 1000 + i * 100,
22
- }))
23
- }
24
-
25
- /**
26
- * 创建 mock PluginHost
27
- */
28
- function createMockPluginHost(): PluginHost {
29
- const stateStore = new Map<string, unknown>()
30
-
31
- return {
32
- setSharedState: vi.fn((key: string, state: unknown, _owner: string) => {
33
- stateStore.set(key, state)
34
- }),
35
- getSharedState: vi.fn(<T>(key: string): T | undefined => {
36
- return stateStore.get(key) as T | undefined
37
- }),
38
- clearByOwner: vi.fn(),
39
- registerService: vi.fn(),
40
- getService: vi.fn(),
41
- getCanvas: vi.fn(),
42
- getMainPane: vi.fn(),
43
- getSubPane: vi.fn(),
44
- getAllSubPanes: vi.fn(),
45
- getTheme: vi.fn(),
46
- getStyles: vi.fn(),
47
- getBarStyles: vi.fn(),
48
- getConfig: vi.fn(),
49
- setConfig: vi.fn(),
50
- on: vi.fn(),
51
- off: vi.fn(),
52
- once: vi.fn(),
53
- emit: vi.fn(),
54
- } as unknown as PluginHost
55
- }
56
-
57
- /**
58
- * 从 mock 调用中获取指定 key 的状态(最后一次)
59
- */
60
- function getStateFromMockCalls<T>(mockHost: PluginHost, key: string): T | undefined {
61
- const calls = vi.mocked(mockHost.setSharedState).mock.calls
62
- // 从后往前找,获取最后一次写入该 key 的状态
63
- for (let i = calls.length - 1; i >= 0; i--) {
64
- if (calls[i]![0] === key) {
65
- return calls[i]![1] as T
66
- }
67
- }
68
- return undefined
69
- }
70
-
71
- describe('IndicatorScheduler', () => {
72
- let scheduler: IndicatorScheduler
73
- let mockHost: PluginHost
74
-
75
- beforeEach(() => {
76
- scheduler = new IndicatorScheduler()
77
- mockHost = createMockPluginHost()
78
- scheduler.setPluginHost(mockHost)
79
- })
80
-
81
- describe('initialization', () => {
82
- it('should not write to state store before first update', () => {
83
- expect(mockHost.setSharedState).not.toHaveBeenCalled()
84
- })
85
-
86
- it('should accept plugin host', () => {
87
- const newScheduler = new IndicatorScheduler()
88
- newScheduler.setPluginHost(mockHost)
89
- // Should not throw
90
- expect(() => newScheduler.recompute()).not.toThrow()
91
- })
92
- })
93
-
94
- describe('data update', () => {
95
- it('should write MARenderState to StateStore after update', () => {
96
- const data = createTestData(100)
97
- const visibleRange = { start: 0, end: 100 }
98
-
99
- scheduler.update(data, visibleRange)
100
-
101
- expect(mockHost.setSharedState).toHaveBeenCalledWith(
102
- MA_STATE_KEY,
103
- expect.objectContaining({
104
- timestamp: expect.any(Number),
105
- series: expect.any(Object),
106
- enabledPeriods: expect.any(Array),
107
- visibleMin: expect.any(Number),
108
- visibleMax: expect.any(Number),
109
- }),
110
- 'ma_scheduler'
111
- )
112
- })
113
-
114
- it('should calculate all default MA periods', () => {
115
- const data = createTestData(100)
116
- const visibleRange = { start: 0, end: 100 }
117
-
118
- scheduler.update(data, visibleRange)
119
-
120
- const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
121
- expect(state).toBeDefined()
122
-
123
- expect(state!.enabledPeriods).toContain(5)
124
- expect(state!.enabledPeriods).toContain(10)
125
- expect(state!.enabledPeriods).toContain(20)
126
- expect(state!.enabledPeriods).toContain(30)
127
- expect(state!.enabledPeriods).toContain(60)
128
- })
129
-
130
- it('should set correct visibleMin and visibleMax for full range', () => {
131
- // Data: 100, 101, 102, ... 199
132
- const data = createTestData(100, 100)
133
- const visibleRange = { start: 60, end: 70 } // Viewing prices 160-169
134
-
135
- scheduler.update(data, visibleRange)
136
-
137
- const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
138
- expect(state).toBeDefined()
139
-
140
- // MA5 of prices 160-169 should be between 156-169
141
- expect(state!.visibleMin).toBeLessThan(state!.visibleMax)
142
- expect(state!.visibleMax).toBeGreaterThan(150)
143
- })
144
-
145
- it('should handle empty data', () => {
146
- const data: KLineData[] = []
147
- const visibleRange = { start: 0, end: 0 }
148
-
149
- scheduler.update(data, visibleRange)
150
-
151
- const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
152
- expect(state).toBeDefined()
153
-
154
- expect(state!.visibleMin).toBe(Infinity)
155
- expect(state!.visibleMax).toBe(-Infinity)
156
- })
157
- })
158
-
159
- describe('MA config update', () => {
160
- it('should update enabled periods based on config', () => {
161
- const data = createTestData(100)
162
- const visibleRange = { start: 0, end: 100 }
163
-
164
- scheduler.update(data, visibleRange)
165
-
166
- // Disable some periods
167
- scheduler.updateMAConfig({
168
- ma5: true,
169
- ma10: false,
170
- ma20: true,
171
- ma30: false,
172
- ma60: false,
173
- })
174
-
175
- const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
176
- expect(state).toBeDefined()
177
-
178
- expect(state!.enabledPeriods).toContain(5)
179
- expect(state!.enabledPeriods).toContain(20)
180
- expect(state!.enabledPeriods).not.toContain(10)
181
- expect(state!.enabledPeriods).not.toContain(30)
182
- expect(state!.enabledPeriods).not.toContain(60)
183
- })
184
-
185
- it('should disable all periods when all flags are false', () => {
186
- const data = createTestData(100)
187
- const visibleRange = { start: 0, end: 100 }
188
-
189
- scheduler.update(data, visibleRange)
190
- scheduler.updateMAConfig({
191
- ma5: false,
192
- ma10: false,
193
- ma20: false,
194
- ma30: false,
195
- ma60: false,
196
- })
197
-
198
- const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
199
- expect(state).toBeDefined()
200
-
201
- expect(state!.enabledPeriods).toHaveLength(0)
202
- expect(state!.visibleMin).toBe(Infinity)
203
- expect(state!.visibleMax).toBe(-Infinity)
204
- })
205
- })
206
-
207
- describe('visible range update (dual dirty flags)', () => {
208
- it('should recalculate extremes but not series on viewport change only', () => {
209
- // Mark sub-indicators active so their states get real extremes (not the EMPTY sentinels)
210
- scheduler.setActiveSubPaneProvider(() => [
211
- 'sub_RSI', 'sub_CCI', 'sub_STOCH', 'sub_MOM', 'sub_WMSR', 'sub_KST', 'sub_FASTK', 'sub_MACD',
212
- 'sub_ATR', 'sub_WMA', 'sub_DEMA', 'sub_TEMA', 'sub_HMA', 'sub_KAMA', 'sub_SAR',
213
- 'sub_SuperTrend', 'sub_Keltner', 'sub_Donchian', 'sub_Ichimoku',
214
- 'sub_ROC', 'sub_TRIX',
215
- 'sub_HV', 'sub_Parkinson', 'sub_ChaikinVol',
216
- 'sub_VMA', 'sub_OBV', 'sub_PVT',
217
- 'sub_VWAP',
218
- 'sub_CMF', 'sub_MFI',
219
- 'sub_Pivot', 'sub_Fib', 'sub_Structure', 'sub_Zones', 'sub_VolumeProfile',
220
- ])
221
-
222
- const data = createTestData(100)
223
-
224
- // First update with full range
225
- scheduler.update(data, { start: 0, end: 100 })
226
-
227
- // Reset mock to track only the viewport change
228
- vi.mocked(mockHost.setSharedState).mockClear()
229
-
230
- // Update only viewport
231
- scheduler.updateVisibleRange({ start: 50, end: 60 })
232
-
233
- // updateVisibleStatesOnly writes the 32 sub-indicators.
234
- // Main indicators (MA, BOLL, EXPMA, ENE) are not rewritten on viewport-only changes.
235
- expect(mockHost.setSharedState).toHaveBeenCalledTimes(35)
236
-
237
- // Inspect a sub-indicator (RSI) since main indicators are not rewritten on viewport-only updates
238
- const rsiKey = createRSIStateKey('sub_RSI')
239
- const state = getStateFromMockCalls<RSIRenderState>(mockHost, rsiKey)
240
- expect(state).toBeDefined()
241
-
242
- // Extremes should be recalculated for the new viewport (finite, not the Infinity sentinels)
243
- expect(Number.isFinite(state!.visibleMin)).toBe(true)
244
- expect(Number.isFinite(state!.visibleMax)).toBe(true)
245
- expect(state!.visibleMin).toBeLessThanOrEqual(state!.visibleMax)
246
- })
247
-
248
- it('should recalculate series on data change', () => {
249
- const data1 = createTestData(100)
250
- scheduler.update(data1, { start: 0, end: 100 })
251
-
252
- const data2 = createTestData(100, 200)
253
- scheduler.update(data2, { start: 0, end: 100 })
254
-
255
- // Should be called 64 times (32 indicators × 2 data updates)
256
- expect(mockHost.setSharedState).toHaveBeenCalledTimes(78)
257
- })
258
- })
259
-
260
- describe('recompute', () => {
261
- it('should force full recalculation', () => {
262
- const data = createTestData(100)
263
- scheduler.update(data, { start: 0, end: 100 })
264
-
265
- vi.mocked(mockHost.setSharedState).mockClear()
266
-
267
- scheduler.recompute()
268
-
269
- // Should write all 32 indicator states (31 from PR 7 + VWAP)
270
- expect(mockHost.setSharedState).toHaveBeenCalledTimes(39)
271
- })
272
-
273
- it('should recalculate with same data and range', () => {
274
- const data = createTestData(100)
275
- scheduler.update(data, { start: 0, end: 100 })
276
-
277
- const firstState = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
278
- expect(firstState).toBeDefined()
279
-
280
- // Small delay to ensure different timestamp
281
- const start = Date.now()
282
- while (Date.now() < start + 2) { /* busy wait */ }
283
-
284
- scheduler.recompute()
285
-
286
- const secondState = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
287
- expect(secondState).toBeDefined()
288
-
289
- // Timestamps should be different (or at least not earlier)
290
- expect(secondState!.timestamp).toBeGreaterThanOrEqual(firstState!.timestamp)
291
- })
292
- })
293
-
294
- describe('series data structure', () => {
295
- it('should store series as Record with period keys', () => {
296
- const data = createTestData(100)
297
- scheduler.update(data, { start: 0, end: 100 })
298
-
299
- const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
300
- expect(state).toBeDefined()
301
-
302
- // Series should be a Record/object with string keys (numbers become strings in JS objects)
303
- expect(typeof state!.series).toBe('object')
304
- expect(state!.series[5]).toBeDefined()
305
- expect(Array.isArray(state!.series[5])).toBe(true)
306
- expect(state!.series[5]).toHaveLength(100)
307
- })
308
-
309
- it('should have undefined values for indices before period-1', () => {
310
- const data = createTestData(100)
311
- scheduler.update(data, { start: 0, end: 100 })
312
-
313
- const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
314
- expect(state).toBeDefined()
315
-
316
- // First 4 values of MA5 should be undefined
317
- expect(state!.series[5][0]).toBeUndefined()
318
- expect(state!.series[5][3]).toBeUndefined()
319
- expect(state!.series[5][4]).toBeDefined()
320
- })
321
- })
322
- })
323
-
324
- describe('EMPTY_MA_STATE', () => {
325
- it('should have correct structure', () => {
326
- expect(EMPTY_MA_STATE).toEqual({
327
- timestamp: 0,
328
- series: {},
329
- enabledPeriods: [],
330
- visibleMin: Infinity,
331
- visibleMax: -Infinity,
332
- })
333
- })
334
-
335
- it('should indicate no data when visibleMin > visibleMax', () => {
336
- expect(EMPTY_MA_STATE.visibleMin).toBeGreaterThan(EMPTY_MA_STATE.visibleMax)
337
- })
338
- })
339
-
340
- describe('BOLL State in scheduler', () => {
341
- let scheduler: IndicatorScheduler
342
- let mockHost: PluginHost
343
-
344
- beforeEach(() => {
345
- scheduler = new IndicatorScheduler()
346
- mockHost = createMockPluginHost()
347
- scheduler.setPluginHost(mockHost)
348
- })
349
-
350
- it('should write BOLLRenderState to StateStore after update', () => {
351
- const data = createTestData(100)
352
- scheduler.update(data, { start: 0, end: 100 })
353
-
354
- const state = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
355
- expect(state).toBeDefined()
356
- expect(state!.timestamp).toBeGreaterThan(0)
357
- expect(state!.series).toHaveLength(100)
358
- expect(state!.params.period).toBe(20)
359
- expect(state!.params.multiplier).toBe(2)
360
- })
361
-
362
- it('should have sparse BOLL series with undefined before period-1', () => {
363
- const data = createTestData(100)
364
- scheduler.update(data, { start: 0, end: 100 })
365
-
366
- const state = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
367
- expect(state).toBeDefined()
368
-
369
- // First 19 values should be undefined (period=20)
370
- for (let i = 0; i < 19; i++) {
371
- expect(state!.series[i]).toBeUndefined()
372
- }
373
- expect(state!.series[19]).toBeDefined()
374
- })
375
-
376
- it('should pass BOLL params including show flags', () => {
377
- const data = createTestData(100)
378
- scheduler.update(data, { start: 0, end: 100 })
379
-
380
- const state = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
381
- expect(state!.params.showUpper).toBe(true)
382
- expect(state!.params.showMiddle).toBe(true)
383
- expect(state!.params.showLower).toBe(true)
384
- expect(state!.params.showBand).toBe(true)
385
- })
386
-
387
- it('should update BOLL config via updateBOLLConfig', () => {
388
- const data = createTestData(100)
389
- scheduler.update(data, { start: 0, end: 100 })
390
-
391
- scheduler.updateBOLLConfig({ period: 10, multiplier: 3 })
392
-
393
- const state = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
394
- expect(state!.params.period).toBe(10)
395
- expect(state!.params.multiplier).toBe(3)
396
- })
397
-
398
- it('should recalculate BOLL series when config changes', () => {
399
- const data = createTestData(100)
400
- scheduler.update(data, { start: 0, end: 100 })
401
-
402
- const stateBefore = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
403
- const seriesBefore = stateBefore!.series[19]
404
-
405
- scheduler.updateBOLLConfig({ period: 10 })
406
-
407
- const stateAfter = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
408
- // With period=10, index 9 is first valid point, index 19 should differ
409
- expect(stateAfter!.series[9]).toBeDefined()
410
- })
411
-
412
- it('should handle empty data', () => {
413
- scheduler.update([], { start: 0, end: 0 })
414
-
415
- const state = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
416
- expect(state).toBeDefined()
417
- expect(state!.visibleMin).toBe(Infinity)
418
- expect(state!.visibleMax).toBe(-Infinity)
419
- })
420
- })
421
-
422
- describe('EXPMA State in scheduler', () => {
423
- let scheduler: IndicatorScheduler
424
- let mockHost: PluginHost
425
-
426
- beforeEach(() => {
427
- scheduler = new IndicatorScheduler()
428
- mockHost = createMockPluginHost()
429
- scheduler.setPluginHost(mockHost)
430
- })
431
-
432
- it('should write EXPMARenderState to StateStore after update', () => {
433
- const data = createTestData(100)
434
- scheduler.update(data, { start: 0, end: 100 })
435
-
436
- const state = getStateFromMockCalls<EXPMARenderState>(mockHost, EXPMA_STATE_KEY)
437
- expect(state).toBeDefined()
438
- expect(state!.timestamp).toBeGreaterThan(0)
439
- expect(state!.series).toHaveLength(100)
440
- expect(state!.params.fastPeriod).toBe(12)
441
- expect(state!.params.slowPeriod).toBe(50)
442
- })
443
-
444
- it('should have dense EXPMA series from index 0', () => {
445
- const data = createTestData(100)
446
- scheduler.update(data, { start: 0, end: 100 })
447
-
448
- const state = getStateFromMockCalls<EXPMARenderState>(mockHost, EXPMA_STATE_KEY)
449
- expect(state!.series[0]).toBeDefined()
450
- expect(state!.series[0]!.fast).toBeDefined()
451
- expect(state!.series[0]!.slow).toBeDefined()
452
- })
453
-
454
- it('should update EXPMA config via updateEXPMAConfig', () => {
455
- const data = createTestData(100)
456
- scheduler.update(data, { start: 0, end: 100 })
457
-
458
- scheduler.updateEXPMAConfig({ fastPeriod: 6, slowPeriod: 30 })
459
-
460
- const state = getStateFromMockCalls<EXPMARenderState>(mockHost, EXPMA_STATE_KEY)
461
- expect(state!.params.fastPeriod).toBe(6)
462
- expect(state!.params.slowPeriod).toBe(30)
463
- })
464
-
465
- it('should handle empty data', () => {
466
- scheduler.update([], { start: 0, end: 0 })
467
-
468
- const state = getStateFromMockCalls<EXPMARenderState>(mockHost, EXPMA_STATE_KEY)
469
- expect(state).toBeDefined()
470
- expect(state!.visibleMin).toBe(Infinity)
471
- expect(state!.visibleMax).toBe(-Infinity)
472
- })
473
- })
474
-
475
- describe('ENE State in scheduler', () => {
476
- let scheduler: IndicatorScheduler
477
- let mockHost: PluginHost
478
-
479
- beforeEach(() => {
480
- scheduler = new IndicatorScheduler()
481
- mockHost = createMockPluginHost()
482
- scheduler.setPluginHost(mockHost)
483
- })
484
-
485
- it('should write ENERenderState to StateStore after update', () => {
486
- const data = createTestData(100)
487
- scheduler.update(data, { start: 0, end: 100 })
488
-
489
- const state = getStateFromMockCalls<ENERenderState>(mockHost, ENE_STATE_KEY)
490
- expect(state).toBeDefined()
491
- expect(state!.timestamp).toBeGreaterThan(0)
492
- expect(state!.series).toHaveLength(100)
493
- expect(state!.params.period).toBe(10)
494
- expect(state!.params.deviation).toBe(11)
495
- })
496
-
497
- it('should have sparse ENE series with undefined before period-1', () => {
498
- const data = createTestData(100)
499
- scheduler.update(data, { start: 0, end: 100 })
500
-
501
- const state = getStateFromMockCalls<ENERenderState>(mockHost, ENE_STATE_KEY)
502
- expect(state).toBeDefined()
503
-
504
- for (let i = 0; i < 9; i++) {
505
- expect(state!.series[i]).toBeUndefined()
506
- }
507
- expect(state!.series[9]).toBeDefined()
508
- })
509
-
510
- it('should update ENE config via updateENEConfig', () => {
511
- const data = createTestData(100)
512
- scheduler.update(data, { start: 0, end: 100 })
513
-
514
- scheduler.updateENEConfig({ period: 20, deviation: 8 })
515
-
516
- const state = getStateFromMockCalls<ENERenderState>(mockHost, ENE_STATE_KEY)
517
- expect(state!.params.period).toBe(20)
518
- expect(state!.params.deviation).toBe(8)
519
- })
520
-
521
- it('should handle empty data', () => {
522
- scheduler.update([], { start: 0, end: 0 })
523
-
524
- const state = getStateFromMockCalls<ENERenderState>(mockHost, ENE_STATE_KEY)
525
- expect(state).toBeDefined()
526
- expect(state!.visibleMin).toBe(Infinity)
527
- expect(state!.visibleMax).toBe(-Infinity)
528
- })
529
- })
530
-
531
- describe('Per-indicator dirty flags', () => {
532
- let scheduler: IndicatorScheduler
533
- let mockHost: PluginHost
534
-
535
- beforeEach(() => {
536
- scheduler = new IndicatorScheduler()
537
- mockHost = createMockPluginHost()
538
- scheduler.setPluginHost(mockHost)
539
- })
540
-
541
- it('updateBOLLConfig should not recalculate MA series', () => {
542
- const data = createTestData(100)
543
- scheduler.update(data, { start: 0, end: 100 })
544
-
545
- // Capture MA state after initial update
546
- const maStateBefore = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
547
- const maSeriesBefore = maStateBefore!.series[5]
548
-
549
- // Reset mock to track new calls
550
- vi.mocked(mockHost.setSharedState).mockClear()
551
-
552
- scheduler.updateBOLLConfig({ period: 10 })
553
-
554
- // MA state should NOT be written (only BOLL state should be written)
555
- // because MA's dirty flags are not set
556
- const maStateAfter = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
557
- expect(maStateAfter).toBeUndefined()
558
- // Verify BOLL state was written
559
- const bollStateAfter = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
560
- expect(bollStateAfter).toBeDefined()
561
- expect(bollStateAfter!.params.period).toBe(10)
562
- })
563
-
564
- it('updateEXPMAConfig should not recalculate MA series', () => {
565
- const data = createTestData(100)
566
- scheduler.update(data, { start: 0, end: 100 })
567
-
568
- const maStateBefore = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
569
- const maSeriesBefore = maStateBefore!.series[5]
570
-
571
- vi.mocked(mockHost.setSharedState).mockClear()
572
-
573
- scheduler.updateEXPMAConfig({ fastPeriod: 6 })
574
-
575
- // MA state should NOT be written (only EXPMA state should be written)
576
- const maStateAfter = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
577
- expect(maStateAfter).toBeUndefined()
578
- // Verify EXPMA state was written
579
- const expmaStateAfter = getStateFromMockCalls<EXPMARenderState>(mockHost, EXPMA_STATE_KEY)
580
- expect(expmaStateAfter).toBeDefined()
581
- expect(expmaStateAfter!.params.fastPeriod).toBe(6)
582
- })
583
-
584
- it('updateENEConfig should not recalculate MA series', () => {
585
- const data = createTestData(100)
586
- scheduler.update(data, { start: 0, end: 100 })
587
-
588
- const maStateBefore = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
589
- const maSeriesBefore = maStateBefore!.series[5]
590
-
591
- vi.mocked(mockHost.setSharedState).mockClear()
592
-
593
- scheduler.updateENEConfig({ period: 20 })
594
-
595
- // MA state should NOT be written (only ENE state should be written)
596
- const maStateAfter = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
597
- expect(maStateAfter).toBeUndefined()
598
- // Verify ENE state was written
599
- const eneStateAfter = getStateFromMockCalls<ENERenderState>(mockHost, ENE_STATE_KEY)
600
- expect(eneStateAfter).toBeDefined()
601
- expect(eneStateAfter!.params.period).toBe(20)
602
- })
603
-
604
- it('updateBOLLConfig should recalculate BOLL extremes', () => {
605
- const data = createTestData(100)
606
- scheduler.update(data, { start: 0, end: 100 })
607
-
608
- const bollStateBefore = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
609
-
610
- scheduler.updateBOLLConfig({ period: 10 })
611
-
612
- const bollStateAfter = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
613
- // Extremes should be recalculated
614
- expect(bollStateAfter!.visibleMin).toBeLessThan(bollStateAfter!.visibleMax)
615
- })
616
- })
617
-
618
- describe('EMPTY_BOLL_STATE', () => {
619
- it('should have correct structure', () => {
620
- expect(EMPTY_BOLL_STATE).toEqual({
621
- timestamp: 0,
622
- series: [],
623
- params: {
624
- period: 20,
625
- multiplier: 2,
626
- showUpper: true,
627
- showMiddle: true,
628
- showLower: true,
629
- showBand: true,
630
- },
631
- visibleMin: Infinity,
632
- visibleMax: -Infinity,
633
- })
634
- })
635
-
636
- it('should indicate no data when visibleMin > visibleMax', () => {
637
- expect(EMPTY_BOLL_STATE.visibleMin).toBeGreaterThan(EMPTY_BOLL_STATE.visibleMax)
638
- })
639
- })
640
-
641
- describe('EMPTY_EXPMA_STATE', () => {
642
- it('should have correct structure', () => {
643
- expect(EMPTY_EXPMA_STATE).toEqual({
644
- timestamp: 0,
645
- series: [],
646
- params: {
647
- fastPeriod: 12,
648
- slowPeriod: 50,
649
- },
650
- visibleMin: Infinity,
651
- visibleMax: -Infinity,
652
- })
653
- })
654
-
655
- it('should indicate no data when visibleMin > visibleMax', () => {
656
- expect(EMPTY_EXPMA_STATE.visibleMin).toBeGreaterThan(EMPTY_EXPMA_STATE.visibleMax)
657
- })
658
- })
659
-
660
- describe('EMPTY_ENE_STATE', () => {
661
- it('should have correct structure', () => {
662
- expect(EMPTY_ENE_STATE).toEqual({
663
- timestamp: 0,
664
- series: [],
665
- params: {
666
- period: 10,
667
- deviation: 11,
668
- },
669
- visibleMin: Infinity,
670
- visibleMax: -Infinity,
671
- })
672
- })
673
-
674
- it('should indicate no data when visibleMin > visibleMax', () => {
675
- expect(EMPTY_ENE_STATE.visibleMin).toBeGreaterThan(EMPTY_ENE_STATE.visibleMax)
676
- })
677
- })
678
-
679
- describe('RSI State in scheduler', () => {
680
- let scheduler: IndicatorScheduler
681
- let mockHost: PluginHost
682
-
683
- beforeEach(() => {
684
- scheduler = new IndicatorScheduler()
685
- mockHost = createMockPluginHost()
686
- scheduler.setPluginHost(mockHost)
687
- })
688
-
689
- it('should write RSIRenderState to StateStore after update', () => {
690
- const data = createTestData(50)
691
- scheduler.update(data, { start: 0, end: 20 })
692
-
693
- const rsiKey = createRSIStateKey('sub_RSI')
694
- const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
695
- const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === rsiKey)
696
- expect(rsiCall).toBeDefined()
697
-
698
- const rsiState = rsiCall?.[1] as RSIRenderState
699
- expect(rsiState.series).toBeDefined()
700
- expect(rsiState.enabledPeriods).toEqual([6, 12, 24])
701
- expect(rsiState.params.period1).toBe(6)
702
- expect(rsiState.params.period2).toBe(12)
703
- expect(rsiState.params.period3).toBe(24)
704
- expect(rsiState.valueMin).toBe(0)
705
- expect(rsiState.valueMax).toBe(100)
706
- })
707
-
708
- it('should have sparse RSI series (first period+1 entries undefined)', () => {
709
- const data = createTestData(50)
710
- scheduler.update(data, { start: 0, end: 30 })
711
-
712
- const rsiKey = createRSIStateKey('sub_RSI')
713
- const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
714
- const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === rsiKey)
715
- const rsiState = rsiCall?.[1] as RSIRenderState
716
-
717
- // RSI(6): indices 0-5 should be undefined, index 6 should be valid
718
- expect(rsiState.series[6][0]).toBeUndefined()
719
- expect(rsiState.series[6][5]).toBeUndefined()
720
- expect(rsiState.series[6][6]).toBeDefined()
721
- })
722
-
723
- it('should pass RSI params including show flags', () => {
724
- scheduler.updateRSIConfig({ showRSI1: true, showRSI2: false, showRSI3: true }, 'sub_RSI')
725
- const data = createTestData(50)
726
- scheduler.update(data, { start: 0, end: 20 })
727
-
728
- const rsiKey = createRSIStateKey('sub_RSI')
729
- const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
730
- const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === rsiKey)
731
- const rsiState = rsiCall?.[1] as RSIRenderState
732
-
733
- expect(rsiState.params.showRSI1).toBe(true)
734
- expect(rsiState.params.showRSI2).toBe(false)
735
- expect(rsiState.params.showRSI3).toBe(true)
736
- // Only RSI1 and RSI3 should be in series and enabledPeriods
737
- expect(rsiState.enabledPeriods).toContain(6)
738
- expect(rsiState.enabledPeriods).not.toContain(12)
739
- expect(rsiState.enabledPeriods).toContain(24)
740
- })
741
-
742
- it('should update RSI config via updateRSIConfig', () => {
743
- scheduler.updateRSIConfig({ period1: 10, period2: 20, period3: 30 }, 'sub_RSI')
744
- const data = createTestData(100)
745
- scheduler.update(data, { start: 0, end: 50 })
746
-
747
- const rsiKey = createRSIStateKey('sub_RSI')
748
- const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
749
- const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === rsiKey)
750
- const rsiState = rsiCall?.[1] as RSIRenderState
751
-
752
- expect(rsiState.params.period1).toBe(10)
753
- expect(rsiState.params.period2).toBe(20)
754
- expect(rsiState.params.period3).toBe(30)
755
- })
756
-
757
- it('should not recalculate MA series on RSI config change', () => {
758
- const data = createTestData(50)
759
- scheduler.update(data, { start: 0, end: 20 })
760
-
761
- // Get the MA state after first update
762
- const maStateBefore = (mockHost.setSharedState as ReturnType<typeof vi.fn>).mock.calls.find(
763
- (call: unknown[]) => call[0] === MA_STATE_KEY
764
- )?.[1] as MARenderState
765
-
766
- // Update RSI config only
767
- scheduler.updateRSIConfig({ period1: 14 }, 'sub_RSI')
768
-
769
- // Get the MA state after RSI config update
770
- const maStateAfter = (mockHost.setSharedState as ReturnType<typeof vi.fn>).mock.calls.find(
771
- (call: unknown[]) => call[0] === MA_STATE_KEY
772
- )?.[1] as MARenderState
773
-
774
- // MA series should remain the same reference (not recalculated)
775
- expect(maStateAfter.series).toBe(maStateBefore.series)
776
- })
777
-
778
- it('should use dynamic paneId in state key', () => {
779
- scheduler.updateRSIConfig({}, 'custom_RSI_pane')
780
- const data = createTestData(50)
781
- scheduler.update(data, { start: 0, end: 20 })
782
-
783
- const expectedKey = createRSIStateKey('custom_RSI_pane')
784
- const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
785
- const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === expectedKey)
786
- expect(rsiCall).toBeDefined()
787
- })
788
-
789
- it('should have visibleMin=Infinity visibleMax=-Infinity when no data', () => {
790
- scheduler.update([], { start: 0, end: 0 })
791
-
792
- const rsiKey = createRSIStateKey('sub_RSI')
793
- const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
794
- const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === rsiKey)
795
- const rsiState = rsiCall?.[1] as RSIRenderState
796
-
797
- expect(rsiState.visibleMin).toBe(Infinity)
798
- expect(rsiState.visibleMax).toBe(-Infinity)
799
- })
800
- })
801
-
802
- describe('EMPTY_RSI_STATE', () => {
803
- it('should have correct structure', () => {
804
- expect(EMPTY_RSI_STATE).toEqual({
805
- timestamp: 0,
806
- series: {},
807
- enabledPeriods: [],
808
- params: {
809
- period1: 6,
810
- period2: 12,
811
- period3: 24,
812
- showRSI1: true,
813
- showRSI2: true,
814
- showRSI3: true,
815
- },
816
- valueMin: 0,
817
- valueMax: 100,
818
- visibleMin: Infinity,
819
- visibleMax: -Infinity,
820
- })
821
- })
822
-
823
- it('should indicate no data when visibleMin > visibleMax', () => {
824
- expect(EMPTY_RSI_STATE.visibleMin).toBeGreaterThan(EMPTY_RSI_STATE.visibleMax)
825
- })
826
-
827
- it('should have fixed valueMin and valueMax', () => {
828
- expect(EMPTY_RSI_STATE.valueMin).toBe(0)
829
- expect(EMPTY_RSI_STATE.valueMax).toBe(100)
830
- })
831
- })
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { IndicatorScheduler } from '../scheduler'
3
+ import { MA_STATE_KEY, EMPTY_MA_STATE, type MARenderState } from '../maState'
4
+ import { BOLL_STATE_KEY, EMPTY_BOLL_STATE, type BOLLRenderState } from '../bollState'
5
+ import { EXPMA_STATE_KEY, EMPTY_EXPMA_STATE, type EXPMARenderState } from '../expmaState'
6
+ import { ENE_STATE_KEY, EMPTY_ENE_STATE, type ENERenderState } from '../eneState'
7
+ import { createRSIStateKey, EMPTY_RSI_STATE, type RSIRenderState } from '../rsiState'
8
+ import type { KLineData } from '@/types/price'
9
+ import type { PluginHost } from '@/plugin'
10
+
11
+ /**
12
+ * 创建测试用的 K 线数据
13
+ */
14
+ function createTestData(length: number, startPrice = 100): KLineData[] {
15
+ return Array.from({ length }, (_, i) => ({
16
+ timestamp: 1000000000000 + i * 60000,
17
+ open: startPrice + i,
18
+ high: startPrice + i + 1,
19
+ low: startPrice + i - 1,
20
+ close: startPrice + i,
21
+ volume: 1000 + i * 100,
22
+ }))
23
+ }
24
+
25
+ /**
26
+ * 创建 mock PluginHost
27
+ */
28
+ function createMockPluginHost(): PluginHost {
29
+ const stateStore = new Map<string, unknown>()
30
+
31
+ return {
32
+ setSharedState: vi.fn((key: string, state: unknown, _owner: string) => {
33
+ stateStore.set(key, state)
34
+ }),
35
+ getSharedState: vi.fn(<T>(key: string): T | undefined => {
36
+ return stateStore.get(key) as T | undefined
37
+ }),
38
+ clearByOwner: vi.fn(),
39
+ registerService: vi.fn(),
40
+ getService: vi.fn(),
41
+ getCanvas: vi.fn(),
42
+ getMainPane: vi.fn(),
43
+ getSubPane: vi.fn(),
44
+ getAllSubPanes: vi.fn(),
45
+ getTheme: vi.fn(),
46
+ getStyles: vi.fn(),
47
+ getBarStyles: vi.fn(),
48
+ getConfig: vi.fn(),
49
+ setConfig: vi.fn(),
50
+ on: vi.fn(),
51
+ off: vi.fn(),
52
+ once: vi.fn(),
53
+ emit: vi.fn(),
54
+ } as unknown as PluginHost
55
+ }
56
+
57
+ /**
58
+ * 从 mock 调用中获取指定 key 的状态(最后一次)
59
+ */
60
+ function getStateFromMockCalls<T>(mockHost: PluginHost, key: string): T | undefined {
61
+ const calls = vi.mocked(mockHost.setSharedState).mock.calls
62
+ // 从后往前找,获取最后一次写入该 key 的状态
63
+ for (let i = calls.length - 1; i >= 0; i--) {
64
+ if (calls[i]![0] === key) {
65
+ return calls[i]![1] as T
66
+ }
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ describe('IndicatorScheduler', () => {
72
+ let scheduler: IndicatorScheduler
73
+ let mockHost: PluginHost
74
+
75
+ beforeEach(() => {
76
+ scheduler = new IndicatorScheduler()
77
+ mockHost = createMockPluginHost()
78
+ scheduler.setPluginHost(mockHost)
79
+ })
80
+
81
+ describe('initialization', () => {
82
+ it('should not write to state store before first update', () => {
83
+ expect(mockHost.setSharedState).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('should accept plugin host', () => {
87
+ const newScheduler = new IndicatorScheduler()
88
+ newScheduler.setPluginHost(mockHost)
89
+ // Should not throw
90
+ expect(() => newScheduler.recompute()).not.toThrow()
91
+ })
92
+ })
93
+
94
+ describe('data update', () => {
95
+ it('should write MARenderState to StateStore after update', () => {
96
+ const data = createTestData(100)
97
+ const visibleRange = { start: 0, end: 100 }
98
+
99
+ scheduler.update(data, visibleRange)
100
+
101
+ expect(mockHost.setSharedState).toHaveBeenCalledWith(
102
+ MA_STATE_KEY,
103
+ expect.objectContaining({
104
+ timestamp: expect.any(Number),
105
+ series: expect.any(Object),
106
+ enabledPeriods: expect.any(Array),
107
+ visibleMin: expect.any(Number),
108
+ visibleMax: expect.any(Number),
109
+ }),
110
+ 'ma_scheduler'
111
+ )
112
+ })
113
+
114
+ it('should calculate all default MA periods', () => {
115
+ const data = createTestData(100)
116
+ const visibleRange = { start: 0, end: 100 }
117
+
118
+ scheduler.update(data, visibleRange)
119
+
120
+ const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
121
+ expect(state).toBeDefined()
122
+
123
+ expect(state!.enabledPeriods).toContain(5)
124
+ expect(state!.enabledPeriods).toContain(10)
125
+ expect(state!.enabledPeriods).toContain(20)
126
+ expect(state!.enabledPeriods).toContain(30)
127
+ expect(state!.enabledPeriods).toContain(60)
128
+ })
129
+
130
+ it('should set correct visibleMin and visibleMax for full range', () => {
131
+ // Data: 100, 101, 102, ... 199
132
+ const data = createTestData(100, 100)
133
+ const visibleRange = { start: 60, end: 70 } // Viewing prices 160-169
134
+
135
+ scheduler.update(data, visibleRange)
136
+
137
+ const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
138
+ expect(state).toBeDefined()
139
+
140
+ // MA5 of prices 160-169 should be between 156-169
141
+ expect(state!.visibleMin).toBeLessThan(state!.visibleMax)
142
+ expect(state!.visibleMax).toBeGreaterThan(150)
143
+ })
144
+
145
+ it('should handle empty data', () => {
146
+ const data: KLineData[] = []
147
+ const visibleRange = { start: 0, end: 0 }
148
+
149
+ scheduler.update(data, visibleRange)
150
+
151
+ const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
152
+ expect(state).toBeDefined()
153
+
154
+ expect(state!.visibleMin).toBe(Infinity)
155
+ expect(state!.visibleMax).toBe(-Infinity)
156
+ })
157
+ })
158
+
159
+ describe('MA config update', () => {
160
+ it('should update enabled periods based on config', () => {
161
+ const data = createTestData(100)
162
+ const visibleRange = { start: 0, end: 100 }
163
+
164
+ scheduler.update(data, visibleRange)
165
+
166
+ // Disable some periods
167
+ scheduler.updateMAConfig({
168
+ ma5: true,
169
+ ma10: false,
170
+ ma20: true,
171
+ ma30: false,
172
+ ma60: false,
173
+ })
174
+
175
+ const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
176
+ expect(state).toBeDefined()
177
+
178
+ expect(state!.enabledPeriods).toContain(5)
179
+ expect(state!.enabledPeriods).toContain(20)
180
+ expect(state!.enabledPeriods).not.toContain(10)
181
+ expect(state!.enabledPeriods).not.toContain(30)
182
+ expect(state!.enabledPeriods).not.toContain(60)
183
+ })
184
+
185
+ it('should disable all periods when all flags are false', () => {
186
+ const data = createTestData(100)
187
+ const visibleRange = { start: 0, end: 100 }
188
+
189
+ scheduler.update(data, visibleRange)
190
+ scheduler.updateMAConfig({
191
+ ma5: false,
192
+ ma10: false,
193
+ ma20: false,
194
+ ma30: false,
195
+ ma60: false,
196
+ })
197
+
198
+ const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
199
+ expect(state).toBeDefined()
200
+
201
+ expect(state!.enabledPeriods).toHaveLength(0)
202
+ expect(state!.visibleMin).toBe(Infinity)
203
+ expect(state!.visibleMax).toBe(-Infinity)
204
+ })
205
+ })
206
+
207
+ describe('visible range update (dual dirty flags)', () => {
208
+ it('should recalculate extremes but not series on viewport change only', () => {
209
+ // Mark sub-indicators active so their states get real extremes (not the EMPTY sentinels)
210
+ scheduler.setActiveSubPaneProvider(() => [
211
+ 'sub_RSI', 'sub_CCI', 'sub_STOCH', 'sub_MOM', 'sub_WMSR', 'sub_KST', 'sub_FASTK', 'sub_MACD',
212
+ 'sub_ATR', 'sub_WMA', 'sub_DEMA', 'sub_TEMA', 'sub_HMA', 'sub_KAMA', 'sub_SAR',
213
+ 'sub_SuperTrend', 'sub_Keltner', 'sub_Donchian', 'sub_Ichimoku',
214
+ 'sub_ROC', 'sub_TRIX',
215
+ 'sub_HV', 'sub_Parkinson', 'sub_ChaikinVol',
216
+ 'sub_VMA', 'sub_OBV', 'sub_PVT',
217
+ 'sub_VWAP',
218
+ 'sub_CMF', 'sub_MFI',
219
+ 'sub_Pivot', 'sub_Fib', 'sub_Structure', 'sub_Zones', 'sub_VolumeProfile',
220
+ ])
221
+
222
+ const data = createTestData(100)
223
+
224
+ // First update with full range
225
+ scheduler.update(data, { start: 0, end: 100 })
226
+
227
+ // Reset mock to track only the viewport change
228
+ vi.mocked(mockHost.setSharedState).mockClear()
229
+
230
+ // Update only viewport
231
+ scheduler.updateVisibleRange({ start: 50, end: 60 })
232
+
233
+ // updateVisibleStatesOnly writes the 32 sub-indicators.
234
+ // Main indicators (MA, BOLL, EXPMA, ENE) are not rewritten on viewport-only changes.
235
+ expect(mockHost.setSharedState).toHaveBeenCalledTimes(35)
236
+
237
+ // Inspect a sub-indicator (RSI) since main indicators are not rewritten on viewport-only updates
238
+ const rsiKey = createRSIStateKey('sub_RSI')
239
+ const state = getStateFromMockCalls<RSIRenderState>(mockHost, rsiKey)
240
+ expect(state).toBeDefined()
241
+
242
+ // Extremes should be recalculated for the new viewport (finite, not the Infinity sentinels)
243
+ expect(Number.isFinite(state!.visibleMin)).toBe(true)
244
+ expect(Number.isFinite(state!.visibleMax)).toBe(true)
245
+ expect(state!.visibleMin).toBeLessThanOrEqual(state!.visibleMax)
246
+ })
247
+
248
+ it('should recalculate series on data change', () => {
249
+ const data1 = createTestData(100)
250
+ scheduler.update(data1, { start: 0, end: 100 })
251
+
252
+ const data2 = createTestData(100, 200)
253
+ scheduler.update(data2, { start: 0, end: 100 })
254
+
255
+ // Should be called 64 times (32 indicators × 2 data updates)
256
+ expect(mockHost.setSharedState).toHaveBeenCalledTimes(78)
257
+ })
258
+ })
259
+
260
+ describe('recompute', () => {
261
+ it('should force full recalculation', () => {
262
+ const data = createTestData(100)
263
+ scheduler.update(data, { start: 0, end: 100 })
264
+
265
+ vi.mocked(mockHost.setSharedState).mockClear()
266
+
267
+ scheduler.recompute()
268
+
269
+ // Should write all 32 indicator states (31 from PR 7 + VWAP)
270
+ expect(mockHost.setSharedState).toHaveBeenCalledTimes(39)
271
+ })
272
+
273
+ it('should recalculate with same data and range', () => {
274
+ const data = createTestData(100)
275
+ scheduler.update(data, { start: 0, end: 100 })
276
+
277
+ const firstState = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
278
+ expect(firstState).toBeDefined()
279
+
280
+ // Small delay to ensure different timestamp
281
+ const start = Date.now()
282
+ while (Date.now() < start + 2) { /* busy wait */ }
283
+
284
+ scheduler.recompute()
285
+
286
+ const secondState = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
287
+ expect(secondState).toBeDefined()
288
+
289
+ // Timestamps should be different (or at least not earlier)
290
+ expect(secondState!.timestamp).toBeGreaterThanOrEqual(firstState!.timestamp)
291
+ })
292
+ })
293
+
294
+ describe('series data structure', () => {
295
+ it('should store series as Record with period keys', () => {
296
+ const data = createTestData(100)
297
+ scheduler.update(data, { start: 0, end: 100 })
298
+
299
+ const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
300
+ expect(state).toBeDefined()
301
+
302
+ // Series should be a Record/object with string keys (numbers become strings in JS objects)
303
+ expect(typeof state!.series).toBe('object')
304
+ expect(state!.series[5]).toBeDefined()
305
+ expect(Array.isArray(state!.series[5])).toBe(true)
306
+ expect(state!.series[5]).toHaveLength(100)
307
+ })
308
+
309
+ it('should have undefined values for indices before period-1', () => {
310
+ const data = createTestData(100)
311
+ scheduler.update(data, { start: 0, end: 100 })
312
+
313
+ const state = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
314
+ expect(state).toBeDefined()
315
+
316
+ // First 4 values of MA5 should be undefined
317
+ expect(state!.series[5][0]).toBeUndefined()
318
+ expect(state!.series[5][3]).toBeUndefined()
319
+ expect(state!.series[5][4]).toBeDefined()
320
+ })
321
+ })
322
+ })
323
+
324
+ describe('EMPTY_MA_STATE', () => {
325
+ it('should have correct structure', () => {
326
+ expect(EMPTY_MA_STATE).toEqual({
327
+ timestamp: 0,
328
+ series: {},
329
+ enabledPeriods: [],
330
+ visibleMin: Infinity,
331
+ visibleMax: -Infinity,
332
+ })
333
+ })
334
+
335
+ it('should indicate no data when visibleMin > visibleMax', () => {
336
+ expect(EMPTY_MA_STATE.visibleMin).toBeGreaterThan(EMPTY_MA_STATE.visibleMax)
337
+ })
338
+ })
339
+
340
+ describe('BOLL State in scheduler', () => {
341
+ let scheduler: IndicatorScheduler
342
+ let mockHost: PluginHost
343
+
344
+ beforeEach(() => {
345
+ scheduler = new IndicatorScheduler()
346
+ mockHost = createMockPluginHost()
347
+ scheduler.setPluginHost(mockHost)
348
+ })
349
+
350
+ it('should write BOLLRenderState to StateStore after update', () => {
351
+ const data = createTestData(100)
352
+ scheduler.update(data, { start: 0, end: 100 })
353
+
354
+ const state = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
355
+ expect(state).toBeDefined()
356
+ expect(state!.timestamp).toBeGreaterThan(0)
357
+ expect(state!.series).toHaveLength(100)
358
+ expect(state!.params.period).toBe(20)
359
+ expect(state!.params.multiplier).toBe(2)
360
+ })
361
+
362
+ it('should have sparse BOLL series with undefined before period-1', () => {
363
+ const data = createTestData(100)
364
+ scheduler.update(data, { start: 0, end: 100 })
365
+
366
+ const state = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
367
+ expect(state).toBeDefined()
368
+
369
+ // First 19 values should be undefined (period=20)
370
+ for (let i = 0; i < 19; i++) {
371
+ expect(state!.series[i]).toBeUndefined()
372
+ }
373
+ expect(state!.series[19]).toBeDefined()
374
+ })
375
+
376
+ it('should pass BOLL params including show flags', () => {
377
+ const data = createTestData(100)
378
+ scheduler.update(data, { start: 0, end: 100 })
379
+
380
+ const state = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
381
+ expect(state!.params.showUpper).toBe(true)
382
+ expect(state!.params.showMiddle).toBe(true)
383
+ expect(state!.params.showLower).toBe(true)
384
+ expect(state!.params.showBand).toBe(true)
385
+ })
386
+
387
+ it('should update BOLL config via updateBOLLConfig', () => {
388
+ const data = createTestData(100)
389
+ scheduler.update(data, { start: 0, end: 100 })
390
+
391
+ scheduler.updateBOLLConfig({ period: 10, multiplier: 3 })
392
+
393
+ const state = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
394
+ expect(state!.params.period).toBe(10)
395
+ expect(state!.params.multiplier).toBe(3)
396
+ })
397
+
398
+ it('should recalculate BOLL series when config changes', () => {
399
+ const data = createTestData(100)
400
+ scheduler.update(data, { start: 0, end: 100 })
401
+
402
+ const stateBefore = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
403
+ const seriesBefore = stateBefore!.series[19]
404
+
405
+ scheduler.updateBOLLConfig({ period: 10 })
406
+
407
+ const stateAfter = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
408
+ // With period=10, index 9 is first valid point, index 19 should differ
409
+ expect(stateAfter!.series[9]).toBeDefined()
410
+ })
411
+
412
+ it('should handle empty data', () => {
413
+ scheduler.update([], { start: 0, end: 0 })
414
+
415
+ const state = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
416
+ expect(state).toBeDefined()
417
+ expect(state!.visibleMin).toBe(Infinity)
418
+ expect(state!.visibleMax).toBe(-Infinity)
419
+ })
420
+ })
421
+
422
+ describe('EXPMA State in scheduler', () => {
423
+ let scheduler: IndicatorScheduler
424
+ let mockHost: PluginHost
425
+
426
+ beforeEach(() => {
427
+ scheduler = new IndicatorScheduler()
428
+ mockHost = createMockPluginHost()
429
+ scheduler.setPluginHost(mockHost)
430
+ })
431
+
432
+ it('should write EXPMARenderState to StateStore after update', () => {
433
+ const data = createTestData(100)
434
+ scheduler.update(data, { start: 0, end: 100 })
435
+
436
+ const state = getStateFromMockCalls<EXPMARenderState>(mockHost, EXPMA_STATE_KEY)
437
+ expect(state).toBeDefined()
438
+ expect(state!.timestamp).toBeGreaterThan(0)
439
+ expect(state!.series).toHaveLength(100)
440
+ expect(state!.params.fastPeriod).toBe(12)
441
+ expect(state!.params.slowPeriod).toBe(50)
442
+ })
443
+
444
+ it('should have dense EXPMA series from index 0', () => {
445
+ const data = createTestData(100)
446
+ scheduler.update(data, { start: 0, end: 100 })
447
+
448
+ const state = getStateFromMockCalls<EXPMARenderState>(mockHost, EXPMA_STATE_KEY)
449
+ expect(state!.series[0]).toBeDefined()
450
+ expect(state!.series[0]!.fast).toBeDefined()
451
+ expect(state!.series[0]!.slow).toBeDefined()
452
+ })
453
+
454
+ it('should update EXPMA config via updateEXPMAConfig', () => {
455
+ const data = createTestData(100)
456
+ scheduler.update(data, { start: 0, end: 100 })
457
+
458
+ scheduler.updateEXPMAConfig({ fastPeriod: 6, slowPeriod: 30 })
459
+
460
+ const state = getStateFromMockCalls<EXPMARenderState>(mockHost, EXPMA_STATE_KEY)
461
+ expect(state!.params.fastPeriod).toBe(6)
462
+ expect(state!.params.slowPeriod).toBe(30)
463
+ })
464
+
465
+ it('should handle empty data', () => {
466
+ scheduler.update([], { start: 0, end: 0 })
467
+
468
+ const state = getStateFromMockCalls<EXPMARenderState>(mockHost, EXPMA_STATE_KEY)
469
+ expect(state).toBeDefined()
470
+ expect(state!.visibleMin).toBe(Infinity)
471
+ expect(state!.visibleMax).toBe(-Infinity)
472
+ })
473
+ })
474
+
475
+ describe('ENE State in scheduler', () => {
476
+ let scheduler: IndicatorScheduler
477
+ let mockHost: PluginHost
478
+
479
+ beforeEach(() => {
480
+ scheduler = new IndicatorScheduler()
481
+ mockHost = createMockPluginHost()
482
+ scheduler.setPluginHost(mockHost)
483
+ })
484
+
485
+ it('should write ENERenderState to StateStore after update', () => {
486
+ const data = createTestData(100)
487
+ scheduler.update(data, { start: 0, end: 100 })
488
+
489
+ const state = getStateFromMockCalls<ENERenderState>(mockHost, ENE_STATE_KEY)
490
+ expect(state).toBeDefined()
491
+ expect(state!.timestamp).toBeGreaterThan(0)
492
+ expect(state!.series).toHaveLength(100)
493
+ expect(state!.params.period).toBe(10)
494
+ expect(state!.params.deviation).toBe(11)
495
+ })
496
+
497
+ it('should have sparse ENE series with undefined before period-1', () => {
498
+ const data = createTestData(100)
499
+ scheduler.update(data, { start: 0, end: 100 })
500
+
501
+ const state = getStateFromMockCalls<ENERenderState>(mockHost, ENE_STATE_KEY)
502
+ expect(state).toBeDefined()
503
+
504
+ for (let i = 0; i < 9; i++) {
505
+ expect(state!.series[i]).toBeUndefined()
506
+ }
507
+ expect(state!.series[9]).toBeDefined()
508
+ })
509
+
510
+ it('should update ENE config via updateENEConfig', () => {
511
+ const data = createTestData(100)
512
+ scheduler.update(data, { start: 0, end: 100 })
513
+
514
+ scheduler.updateENEConfig({ period: 20, deviation: 8 })
515
+
516
+ const state = getStateFromMockCalls<ENERenderState>(mockHost, ENE_STATE_KEY)
517
+ expect(state!.params.period).toBe(20)
518
+ expect(state!.params.deviation).toBe(8)
519
+ })
520
+
521
+ it('should handle empty data', () => {
522
+ scheduler.update([], { start: 0, end: 0 })
523
+
524
+ const state = getStateFromMockCalls<ENERenderState>(mockHost, ENE_STATE_KEY)
525
+ expect(state).toBeDefined()
526
+ expect(state!.visibleMin).toBe(Infinity)
527
+ expect(state!.visibleMax).toBe(-Infinity)
528
+ })
529
+ })
530
+
531
+ describe('Per-indicator dirty flags', () => {
532
+ let scheduler: IndicatorScheduler
533
+ let mockHost: PluginHost
534
+
535
+ beforeEach(() => {
536
+ scheduler = new IndicatorScheduler()
537
+ mockHost = createMockPluginHost()
538
+ scheduler.setPluginHost(mockHost)
539
+ })
540
+
541
+ it('updateBOLLConfig should not recalculate MA series', () => {
542
+ const data = createTestData(100)
543
+ scheduler.update(data, { start: 0, end: 100 })
544
+
545
+ // Capture MA state after initial update
546
+ const maStateBefore = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
547
+ const maSeriesBefore = maStateBefore!.series[5]
548
+
549
+ // Reset mock to track new calls
550
+ vi.mocked(mockHost.setSharedState).mockClear()
551
+
552
+ scheduler.updateBOLLConfig({ period: 10 })
553
+
554
+ // MA state should NOT be written (only BOLL state should be written)
555
+ // because MA's dirty flags are not set
556
+ const maStateAfter = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
557
+ expect(maStateAfter).toBeUndefined()
558
+ // Verify BOLL state was written
559
+ const bollStateAfter = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
560
+ expect(bollStateAfter).toBeDefined()
561
+ expect(bollStateAfter!.params.period).toBe(10)
562
+ })
563
+
564
+ it('updateEXPMAConfig should not recalculate MA series', () => {
565
+ const data = createTestData(100)
566
+ scheduler.update(data, { start: 0, end: 100 })
567
+
568
+ const maStateBefore = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
569
+ const maSeriesBefore = maStateBefore!.series[5]
570
+
571
+ vi.mocked(mockHost.setSharedState).mockClear()
572
+
573
+ scheduler.updateEXPMAConfig({ fastPeriod: 6 })
574
+
575
+ // MA state should NOT be written (only EXPMA state should be written)
576
+ const maStateAfter = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
577
+ expect(maStateAfter).toBeUndefined()
578
+ // Verify EXPMA state was written
579
+ const expmaStateAfter = getStateFromMockCalls<EXPMARenderState>(mockHost, EXPMA_STATE_KEY)
580
+ expect(expmaStateAfter).toBeDefined()
581
+ expect(expmaStateAfter!.params.fastPeriod).toBe(6)
582
+ })
583
+
584
+ it('updateENEConfig should not recalculate MA series', () => {
585
+ const data = createTestData(100)
586
+ scheduler.update(data, { start: 0, end: 100 })
587
+
588
+ const maStateBefore = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
589
+ const maSeriesBefore = maStateBefore!.series[5]
590
+
591
+ vi.mocked(mockHost.setSharedState).mockClear()
592
+
593
+ scheduler.updateENEConfig({ period: 20 })
594
+
595
+ // MA state should NOT be written (only ENE state should be written)
596
+ const maStateAfter = getStateFromMockCalls<MARenderState>(mockHost, MA_STATE_KEY)
597
+ expect(maStateAfter).toBeUndefined()
598
+ // Verify ENE state was written
599
+ const eneStateAfter = getStateFromMockCalls<ENERenderState>(mockHost, ENE_STATE_KEY)
600
+ expect(eneStateAfter).toBeDefined()
601
+ expect(eneStateAfter!.params.period).toBe(20)
602
+ })
603
+
604
+ it('updateBOLLConfig should recalculate BOLL extremes', () => {
605
+ const data = createTestData(100)
606
+ scheduler.update(data, { start: 0, end: 100 })
607
+
608
+ const bollStateBefore = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
609
+
610
+ scheduler.updateBOLLConfig({ period: 10 })
611
+
612
+ const bollStateAfter = getStateFromMockCalls<BOLLRenderState>(mockHost, BOLL_STATE_KEY)
613
+ // Extremes should be recalculated
614
+ expect(bollStateAfter!.visibleMin).toBeLessThan(bollStateAfter!.visibleMax)
615
+ })
616
+ })
617
+
618
+ describe('EMPTY_BOLL_STATE', () => {
619
+ it('should have correct structure', () => {
620
+ expect(EMPTY_BOLL_STATE).toEqual({
621
+ timestamp: 0,
622
+ series: [],
623
+ params: {
624
+ period: 20,
625
+ multiplier: 2,
626
+ showUpper: true,
627
+ showMiddle: true,
628
+ showLower: true,
629
+ showBand: true,
630
+ },
631
+ visibleMin: Infinity,
632
+ visibleMax: -Infinity,
633
+ })
634
+ })
635
+
636
+ it('should indicate no data when visibleMin > visibleMax', () => {
637
+ expect(EMPTY_BOLL_STATE.visibleMin).toBeGreaterThan(EMPTY_BOLL_STATE.visibleMax)
638
+ })
639
+ })
640
+
641
+ describe('EMPTY_EXPMA_STATE', () => {
642
+ it('should have correct structure', () => {
643
+ expect(EMPTY_EXPMA_STATE).toEqual({
644
+ timestamp: 0,
645
+ series: [],
646
+ params: {
647
+ fastPeriod: 12,
648
+ slowPeriod: 50,
649
+ },
650
+ visibleMin: Infinity,
651
+ visibleMax: -Infinity,
652
+ })
653
+ })
654
+
655
+ it('should indicate no data when visibleMin > visibleMax', () => {
656
+ expect(EMPTY_EXPMA_STATE.visibleMin).toBeGreaterThan(EMPTY_EXPMA_STATE.visibleMax)
657
+ })
658
+ })
659
+
660
+ describe('EMPTY_ENE_STATE', () => {
661
+ it('should have correct structure', () => {
662
+ expect(EMPTY_ENE_STATE).toEqual({
663
+ timestamp: 0,
664
+ series: [],
665
+ params: {
666
+ period: 10,
667
+ deviation: 11,
668
+ },
669
+ visibleMin: Infinity,
670
+ visibleMax: -Infinity,
671
+ })
672
+ })
673
+
674
+ it('should indicate no data when visibleMin > visibleMax', () => {
675
+ expect(EMPTY_ENE_STATE.visibleMin).toBeGreaterThan(EMPTY_ENE_STATE.visibleMax)
676
+ })
677
+ })
678
+
679
+ describe('RSI State in scheduler', () => {
680
+ let scheduler: IndicatorScheduler
681
+ let mockHost: PluginHost
682
+
683
+ beforeEach(() => {
684
+ scheduler = new IndicatorScheduler()
685
+ mockHost = createMockPluginHost()
686
+ scheduler.setPluginHost(mockHost)
687
+ })
688
+
689
+ it('should write RSIRenderState to StateStore after update', () => {
690
+ const data = createTestData(50)
691
+ scheduler.update(data, { start: 0, end: 20 })
692
+
693
+ const rsiKey = createRSIStateKey('sub_RSI')
694
+ const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
695
+ const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === rsiKey)
696
+ expect(rsiCall).toBeDefined()
697
+
698
+ const rsiState = rsiCall?.[1] as RSIRenderState
699
+ expect(rsiState.series).toBeDefined()
700
+ expect(rsiState.enabledPeriods).toEqual([6, 12, 24])
701
+ expect(rsiState.params.period1).toBe(6)
702
+ expect(rsiState.params.period2).toBe(12)
703
+ expect(rsiState.params.period3).toBe(24)
704
+ expect(rsiState.valueMin).toBe(0)
705
+ expect(rsiState.valueMax).toBe(100)
706
+ })
707
+
708
+ it('should have sparse RSI series (first period+1 entries undefined)', () => {
709
+ const data = createTestData(50)
710
+ scheduler.update(data, { start: 0, end: 30 })
711
+
712
+ const rsiKey = createRSIStateKey('sub_RSI')
713
+ const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
714
+ const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === rsiKey)
715
+ const rsiState = rsiCall?.[1] as RSIRenderState
716
+
717
+ // RSI(6): indices 0-5 should be undefined, index 6 should be valid
718
+ expect(rsiState.series[6][0]).toBeUndefined()
719
+ expect(rsiState.series[6][5]).toBeUndefined()
720
+ expect(rsiState.series[6][6]).toBeDefined()
721
+ })
722
+
723
+ it('should pass RSI params including show flags', () => {
724
+ scheduler.updateRSIConfig({ showRSI1: true, showRSI2: false, showRSI3: true }, 'sub_RSI')
725
+ const data = createTestData(50)
726
+ scheduler.update(data, { start: 0, end: 20 })
727
+
728
+ const rsiKey = createRSIStateKey('sub_RSI')
729
+ const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
730
+ const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === rsiKey)
731
+ const rsiState = rsiCall?.[1] as RSIRenderState
732
+
733
+ expect(rsiState.params.showRSI1).toBe(true)
734
+ expect(rsiState.params.showRSI2).toBe(false)
735
+ expect(rsiState.params.showRSI3).toBe(true)
736
+ // Only RSI1 and RSI3 should be in series and enabledPeriods
737
+ expect(rsiState.enabledPeriods).toContain(6)
738
+ expect(rsiState.enabledPeriods).not.toContain(12)
739
+ expect(rsiState.enabledPeriods).toContain(24)
740
+ })
741
+
742
+ it('should update RSI config via updateRSIConfig', () => {
743
+ scheduler.updateRSIConfig({ period1: 10, period2: 20, period3: 30 }, 'sub_RSI')
744
+ const data = createTestData(100)
745
+ scheduler.update(data, { start: 0, end: 50 })
746
+
747
+ const rsiKey = createRSIStateKey('sub_RSI')
748
+ const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
749
+ const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === rsiKey)
750
+ const rsiState = rsiCall?.[1] as RSIRenderState
751
+
752
+ expect(rsiState.params.period1).toBe(10)
753
+ expect(rsiState.params.period2).toBe(20)
754
+ expect(rsiState.params.period3).toBe(30)
755
+ })
756
+
757
+ it('should not recalculate MA series on RSI config change', () => {
758
+ const data = createTestData(50)
759
+ scheduler.update(data, { start: 0, end: 20 })
760
+
761
+ // Get the MA state after first update
762
+ const maStateBefore = (mockHost.setSharedState as ReturnType<typeof vi.fn>).mock.calls.find(
763
+ (call: unknown[]) => call[0] === MA_STATE_KEY
764
+ )?.[1] as MARenderState
765
+
766
+ // Update RSI config only
767
+ scheduler.updateRSIConfig({ period1: 14 }, 'sub_RSI')
768
+
769
+ // Get the MA state after RSI config update
770
+ const maStateAfter = (mockHost.setSharedState as ReturnType<typeof vi.fn>).mock.calls.find(
771
+ (call: unknown[]) => call[0] === MA_STATE_KEY
772
+ )?.[1] as MARenderState
773
+
774
+ // MA series should remain the same reference (not recalculated)
775
+ expect(maStateAfter.series).toBe(maStateBefore.series)
776
+ })
777
+
778
+ it('should use dynamic paneId in state key', () => {
779
+ scheduler.updateRSIConfig({}, 'custom_RSI_pane')
780
+ const data = createTestData(50)
781
+ scheduler.update(data, { start: 0, end: 20 })
782
+
783
+ const expectedKey = createRSIStateKey('custom_RSI_pane')
784
+ const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
785
+ const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === expectedKey)
786
+ expect(rsiCall).toBeDefined()
787
+ })
788
+
789
+ it('should have visibleMin=Infinity visibleMax=-Infinity when no data', () => {
790
+ scheduler.update([], { start: 0, end: 0 })
791
+
792
+ const rsiKey = createRSIStateKey('sub_RSI')
793
+ const setSharedState = mockHost.setSharedState as ReturnType<typeof vi.fn>
794
+ const rsiCall = setSharedState.mock.calls.find((call: unknown[]) => call[0] === rsiKey)
795
+ const rsiState = rsiCall?.[1] as RSIRenderState
796
+
797
+ expect(rsiState.visibleMin).toBe(Infinity)
798
+ expect(rsiState.visibleMax).toBe(-Infinity)
799
+ })
800
+ })
801
+
802
+ describe('EMPTY_RSI_STATE', () => {
803
+ it('should have correct structure', () => {
804
+ expect(EMPTY_RSI_STATE).toEqual({
805
+ timestamp: 0,
806
+ series: {},
807
+ enabledPeriods: [],
808
+ params: {
809
+ period1: 6,
810
+ period2: 12,
811
+ period3: 24,
812
+ showRSI1: true,
813
+ showRSI2: true,
814
+ showRSI3: true,
815
+ },
816
+ valueMin: 0,
817
+ valueMax: 100,
818
+ visibleMin: Infinity,
819
+ visibleMax: -Infinity,
820
+ })
821
+ })
822
+
823
+ it('should indicate no data when visibleMin > visibleMax', () => {
824
+ expect(EMPTY_RSI_STATE.visibleMin).toBeGreaterThan(EMPTY_RSI_STATE.visibleMax)
825
+ })
826
+
827
+ it('should have fixed valueMin and valueMax', () => {
828
+ expect(EMPTY_RSI_STATE.valueMin).toBe(0)
829
+ expect(EMPTY_RSI_STATE.valueMax).toBe(100)
830
+ })
831
+ })