Brajmovech commited on
Commit
d7694d8
Β·
1 Parent(s): 95c7e7f

feat: add instant frontend ticker validation with debounced API verification

Browse files
.gitignore CHANGED
@@ -23,3 +23,4 @@ data/*_report.json
23
  data/feedback_logs.json
24
  data/feedback_staging/
25
  data/valid_tickers.json
 
 
23
  data/feedback_logs.json
24
  data/feedback_staging/
25
  data/valid_tickers.json
26
+ node_modules/
package-lock.json ADDED
@@ -0,0 +1,817 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "iris-ai-frontend-tests",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "iris-ai-frontend-tests",
9
+ "version": "1.0.0",
10
+ "devDependencies": {
11
+ "jsdom": "^25.0.0"
12
+ }
13
+ },
14
+ "node_modules/@asamuzakjp/css-color": {
15
+ "version": "3.2.0",
16
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
17
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
18
+ "dev": true,
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "@csstools/css-calc": "^2.1.3",
22
+ "@csstools/css-color-parser": "^3.0.9",
23
+ "@csstools/css-parser-algorithms": "^3.0.4",
24
+ "@csstools/css-tokenizer": "^3.0.3",
25
+ "lru-cache": "^10.4.3"
26
+ }
27
+ },
28
+ "node_modules/@csstools/color-helpers": {
29
+ "version": "5.1.0",
30
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
31
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
32
+ "dev": true,
33
+ "funding": [
34
+ {
35
+ "type": "github",
36
+ "url": "https://github.com/sponsors/csstools"
37
+ },
38
+ {
39
+ "type": "opencollective",
40
+ "url": "https://opencollective.com/csstools"
41
+ }
42
+ ],
43
+ "license": "MIT-0",
44
+ "engines": {
45
+ "node": ">=18"
46
+ }
47
+ },
48
+ "node_modules/@csstools/css-calc": {
49
+ "version": "2.1.4",
50
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
51
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
52
+ "dev": true,
53
+ "funding": [
54
+ {
55
+ "type": "github",
56
+ "url": "https://github.com/sponsors/csstools"
57
+ },
58
+ {
59
+ "type": "opencollective",
60
+ "url": "https://opencollective.com/csstools"
61
+ }
62
+ ],
63
+ "license": "MIT",
64
+ "engines": {
65
+ "node": ">=18"
66
+ },
67
+ "peerDependencies": {
68
+ "@csstools/css-parser-algorithms": "^3.0.5",
69
+ "@csstools/css-tokenizer": "^3.0.4"
70
+ }
71
+ },
72
+ "node_modules/@csstools/css-color-parser": {
73
+ "version": "3.1.0",
74
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
75
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
76
+ "dev": true,
77
+ "funding": [
78
+ {
79
+ "type": "github",
80
+ "url": "https://github.com/sponsors/csstools"
81
+ },
82
+ {
83
+ "type": "opencollective",
84
+ "url": "https://opencollective.com/csstools"
85
+ }
86
+ ],
87
+ "license": "MIT",
88
+ "dependencies": {
89
+ "@csstools/color-helpers": "^5.1.0",
90
+ "@csstools/css-calc": "^2.1.4"
91
+ },
92
+ "engines": {
93
+ "node": ">=18"
94
+ },
95
+ "peerDependencies": {
96
+ "@csstools/css-parser-algorithms": "^3.0.5",
97
+ "@csstools/css-tokenizer": "^3.0.4"
98
+ }
99
+ },
100
+ "node_modules/@csstools/css-parser-algorithms": {
101
+ "version": "3.0.5",
102
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
103
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
104
+ "dev": true,
105
+ "funding": [
106
+ {
107
+ "type": "github",
108
+ "url": "https://github.com/sponsors/csstools"
109
+ },
110
+ {
111
+ "type": "opencollective",
112
+ "url": "https://opencollective.com/csstools"
113
+ }
114
+ ],
115
+ "license": "MIT",
116
+ "peer": true,
117
+ "engines": {
118
+ "node": ">=18"
119
+ },
120
+ "peerDependencies": {
121
+ "@csstools/css-tokenizer": "^3.0.4"
122
+ }
123
+ },
124
+ "node_modules/@csstools/css-tokenizer": {
125
+ "version": "3.0.4",
126
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
127
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
128
+ "dev": true,
129
+ "funding": [
130
+ {
131
+ "type": "github",
132
+ "url": "https://github.com/sponsors/csstools"
133
+ },
134
+ {
135
+ "type": "opencollective",
136
+ "url": "https://opencollective.com/csstools"
137
+ }
138
+ ],
139
+ "license": "MIT",
140
+ "peer": true,
141
+ "engines": {
142
+ "node": ">=18"
143
+ }
144
+ },
145
+ "node_modules/agent-base": {
146
+ "version": "7.1.4",
147
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
148
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
149
+ "dev": true,
150
+ "license": "MIT",
151
+ "engines": {
152
+ "node": ">= 14"
153
+ }
154
+ },
155
+ "node_modules/asynckit": {
156
+ "version": "0.4.0",
157
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
158
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
159
+ "dev": true,
160
+ "license": "MIT"
161
+ },
162
+ "node_modules/call-bind-apply-helpers": {
163
+ "version": "1.0.2",
164
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
165
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
166
+ "dev": true,
167
+ "license": "MIT",
168
+ "dependencies": {
169
+ "es-errors": "^1.3.0",
170
+ "function-bind": "^1.1.2"
171
+ },
172
+ "engines": {
173
+ "node": ">= 0.4"
174
+ }
175
+ },
176
+ "node_modules/combined-stream": {
177
+ "version": "1.0.8",
178
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
179
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
180
+ "dev": true,
181
+ "license": "MIT",
182
+ "dependencies": {
183
+ "delayed-stream": "~1.0.0"
184
+ },
185
+ "engines": {
186
+ "node": ">= 0.8"
187
+ }
188
+ },
189
+ "node_modules/cssstyle": {
190
+ "version": "4.6.0",
191
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
192
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
193
+ "dev": true,
194
+ "license": "MIT",
195
+ "dependencies": {
196
+ "@asamuzakjp/css-color": "^3.2.0",
197
+ "rrweb-cssom": "^0.8.0"
198
+ },
199
+ "engines": {
200
+ "node": ">=18"
201
+ }
202
+ },
203
+ "node_modules/cssstyle/node_modules/rrweb-cssom": {
204
+ "version": "0.8.0",
205
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
206
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
207
+ "dev": true,
208
+ "license": "MIT"
209
+ },
210
+ "node_modules/data-urls": {
211
+ "version": "5.0.0",
212
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
213
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
214
+ "dev": true,
215
+ "license": "MIT",
216
+ "dependencies": {
217
+ "whatwg-mimetype": "^4.0.0",
218
+ "whatwg-url": "^14.0.0"
219
+ },
220
+ "engines": {
221
+ "node": ">=18"
222
+ }
223
+ },
224
+ "node_modules/debug": {
225
+ "version": "4.4.3",
226
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
227
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
228
+ "dev": true,
229
+ "license": "MIT",
230
+ "dependencies": {
231
+ "ms": "^2.1.3"
232
+ },
233
+ "engines": {
234
+ "node": ">=6.0"
235
+ },
236
+ "peerDependenciesMeta": {
237
+ "supports-color": {
238
+ "optional": true
239
+ }
240
+ }
241
+ },
242
+ "node_modules/decimal.js": {
243
+ "version": "10.6.0",
244
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
245
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
246
+ "dev": true,
247
+ "license": "MIT"
248
+ },
249
+ "node_modules/delayed-stream": {
250
+ "version": "1.0.0",
251
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
252
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
253
+ "dev": true,
254
+ "license": "MIT",
255
+ "engines": {
256
+ "node": ">=0.4.0"
257
+ }
258
+ },
259
+ "node_modules/dunder-proto": {
260
+ "version": "1.0.1",
261
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
262
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
263
+ "dev": true,
264
+ "license": "MIT",
265
+ "dependencies": {
266
+ "call-bind-apply-helpers": "^1.0.1",
267
+ "es-errors": "^1.3.0",
268
+ "gopd": "^1.2.0"
269
+ },
270
+ "engines": {
271
+ "node": ">= 0.4"
272
+ }
273
+ },
274
+ "node_modules/entities": {
275
+ "version": "6.0.1",
276
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
277
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
278
+ "dev": true,
279
+ "license": "BSD-2-Clause",
280
+ "engines": {
281
+ "node": ">=0.12"
282
+ },
283
+ "funding": {
284
+ "url": "https://github.com/fb55/entities?sponsor=1"
285
+ }
286
+ },
287
+ "node_modules/es-define-property": {
288
+ "version": "1.0.1",
289
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
290
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
291
+ "dev": true,
292
+ "license": "MIT",
293
+ "engines": {
294
+ "node": ">= 0.4"
295
+ }
296
+ },
297
+ "node_modules/es-errors": {
298
+ "version": "1.3.0",
299
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
300
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
301
+ "dev": true,
302
+ "license": "MIT",
303
+ "engines": {
304
+ "node": ">= 0.4"
305
+ }
306
+ },
307
+ "node_modules/es-object-atoms": {
308
+ "version": "1.1.1",
309
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
310
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
311
+ "dev": true,
312
+ "license": "MIT",
313
+ "dependencies": {
314
+ "es-errors": "^1.3.0"
315
+ },
316
+ "engines": {
317
+ "node": ">= 0.4"
318
+ }
319
+ },
320
+ "node_modules/es-set-tostringtag": {
321
+ "version": "2.1.0",
322
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
323
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
324
+ "dev": true,
325
+ "license": "MIT",
326
+ "dependencies": {
327
+ "es-errors": "^1.3.0",
328
+ "get-intrinsic": "^1.2.6",
329
+ "has-tostringtag": "^1.0.2",
330
+ "hasown": "^2.0.2"
331
+ },
332
+ "engines": {
333
+ "node": ">= 0.4"
334
+ }
335
+ },
336
+ "node_modules/form-data": {
337
+ "version": "4.0.5",
338
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
339
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
340
+ "dev": true,
341
+ "license": "MIT",
342
+ "dependencies": {
343
+ "asynckit": "^0.4.0",
344
+ "combined-stream": "^1.0.8",
345
+ "es-set-tostringtag": "^2.1.0",
346
+ "hasown": "^2.0.2",
347
+ "mime-types": "^2.1.12"
348
+ },
349
+ "engines": {
350
+ "node": ">= 6"
351
+ }
352
+ },
353
+ "node_modules/function-bind": {
354
+ "version": "1.1.2",
355
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
356
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
357
+ "dev": true,
358
+ "license": "MIT",
359
+ "funding": {
360
+ "url": "https://github.com/sponsors/ljharb"
361
+ }
362
+ },
363
+ "node_modules/get-intrinsic": {
364
+ "version": "1.3.0",
365
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
366
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
367
+ "dev": true,
368
+ "license": "MIT",
369
+ "dependencies": {
370
+ "call-bind-apply-helpers": "^1.0.2",
371
+ "es-define-property": "^1.0.1",
372
+ "es-errors": "^1.3.0",
373
+ "es-object-atoms": "^1.1.1",
374
+ "function-bind": "^1.1.2",
375
+ "get-proto": "^1.0.1",
376
+ "gopd": "^1.2.0",
377
+ "has-symbols": "^1.1.0",
378
+ "hasown": "^2.0.2",
379
+ "math-intrinsics": "^1.1.0"
380
+ },
381
+ "engines": {
382
+ "node": ">= 0.4"
383
+ },
384
+ "funding": {
385
+ "url": "https://github.com/sponsors/ljharb"
386
+ }
387
+ },
388
+ "node_modules/get-proto": {
389
+ "version": "1.0.1",
390
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
391
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
392
+ "dev": true,
393
+ "license": "MIT",
394
+ "dependencies": {
395
+ "dunder-proto": "^1.0.1",
396
+ "es-object-atoms": "^1.0.0"
397
+ },
398
+ "engines": {
399
+ "node": ">= 0.4"
400
+ }
401
+ },
402
+ "node_modules/gopd": {
403
+ "version": "1.2.0",
404
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
405
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
406
+ "dev": true,
407
+ "license": "MIT",
408
+ "engines": {
409
+ "node": ">= 0.4"
410
+ },
411
+ "funding": {
412
+ "url": "https://github.com/sponsors/ljharb"
413
+ }
414
+ },
415
+ "node_modules/has-symbols": {
416
+ "version": "1.1.0",
417
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
418
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
419
+ "dev": true,
420
+ "license": "MIT",
421
+ "engines": {
422
+ "node": ">= 0.4"
423
+ },
424
+ "funding": {
425
+ "url": "https://github.com/sponsors/ljharb"
426
+ }
427
+ },
428
+ "node_modules/has-tostringtag": {
429
+ "version": "1.0.2",
430
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
431
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
432
+ "dev": true,
433
+ "license": "MIT",
434
+ "dependencies": {
435
+ "has-symbols": "^1.0.3"
436
+ },
437
+ "engines": {
438
+ "node": ">= 0.4"
439
+ },
440
+ "funding": {
441
+ "url": "https://github.com/sponsors/ljharb"
442
+ }
443
+ },
444
+ "node_modules/hasown": {
445
+ "version": "2.0.2",
446
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
447
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
448
+ "dev": true,
449
+ "license": "MIT",
450
+ "dependencies": {
451
+ "function-bind": "^1.1.2"
452
+ },
453
+ "engines": {
454
+ "node": ">= 0.4"
455
+ }
456
+ },
457
+ "node_modules/html-encoding-sniffer": {
458
+ "version": "4.0.0",
459
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
460
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
461
+ "dev": true,
462
+ "license": "MIT",
463
+ "dependencies": {
464
+ "whatwg-encoding": "^3.1.1"
465
+ },
466
+ "engines": {
467
+ "node": ">=18"
468
+ }
469
+ },
470
+ "node_modules/http-proxy-agent": {
471
+ "version": "7.0.2",
472
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
473
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
474
+ "dev": true,
475
+ "license": "MIT",
476
+ "dependencies": {
477
+ "agent-base": "^7.1.0",
478
+ "debug": "^4.3.4"
479
+ },
480
+ "engines": {
481
+ "node": ">= 14"
482
+ }
483
+ },
484
+ "node_modules/https-proxy-agent": {
485
+ "version": "7.0.6",
486
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
487
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
488
+ "dev": true,
489
+ "license": "MIT",
490
+ "dependencies": {
491
+ "agent-base": "^7.1.2",
492
+ "debug": "4"
493
+ },
494
+ "engines": {
495
+ "node": ">= 14"
496
+ }
497
+ },
498
+ "node_modules/iconv-lite": {
499
+ "version": "0.6.3",
500
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
501
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
502
+ "dev": true,
503
+ "license": "MIT",
504
+ "dependencies": {
505
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
506
+ },
507
+ "engines": {
508
+ "node": ">=0.10.0"
509
+ }
510
+ },
511
+ "node_modules/is-potential-custom-element-name": {
512
+ "version": "1.0.1",
513
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
514
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
515
+ "dev": true,
516
+ "license": "MIT"
517
+ },
518
+ "node_modules/jsdom": {
519
+ "version": "25.0.1",
520
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
521
+ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
522
+ "dev": true,
523
+ "license": "MIT",
524
+ "dependencies": {
525
+ "cssstyle": "^4.1.0",
526
+ "data-urls": "^5.0.0",
527
+ "decimal.js": "^10.4.3",
528
+ "form-data": "^4.0.0",
529
+ "html-encoding-sniffer": "^4.0.0",
530
+ "http-proxy-agent": "^7.0.2",
531
+ "https-proxy-agent": "^7.0.5",
532
+ "is-potential-custom-element-name": "^1.0.1",
533
+ "nwsapi": "^2.2.12",
534
+ "parse5": "^7.1.2",
535
+ "rrweb-cssom": "^0.7.1",
536
+ "saxes": "^6.0.0",
537
+ "symbol-tree": "^3.2.4",
538
+ "tough-cookie": "^5.0.0",
539
+ "w3c-xmlserializer": "^5.0.0",
540
+ "webidl-conversions": "^7.0.0",
541
+ "whatwg-encoding": "^3.1.1",
542
+ "whatwg-mimetype": "^4.0.0",
543
+ "whatwg-url": "^14.0.0",
544
+ "ws": "^8.18.0",
545
+ "xml-name-validator": "^5.0.0"
546
+ },
547
+ "engines": {
548
+ "node": ">=18"
549
+ },
550
+ "peerDependencies": {
551
+ "canvas": "^2.11.2"
552
+ },
553
+ "peerDependenciesMeta": {
554
+ "canvas": {
555
+ "optional": true
556
+ }
557
+ }
558
+ },
559
+ "node_modules/lru-cache": {
560
+ "version": "10.4.3",
561
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
562
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
563
+ "dev": true,
564
+ "license": "ISC"
565
+ },
566
+ "node_modules/math-intrinsics": {
567
+ "version": "1.1.0",
568
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
569
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
570
+ "dev": true,
571
+ "license": "MIT",
572
+ "engines": {
573
+ "node": ">= 0.4"
574
+ }
575
+ },
576
+ "node_modules/mime-db": {
577
+ "version": "1.52.0",
578
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
579
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
580
+ "dev": true,
581
+ "license": "MIT",
582
+ "engines": {
583
+ "node": ">= 0.6"
584
+ }
585
+ },
586
+ "node_modules/mime-types": {
587
+ "version": "2.1.35",
588
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
589
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
590
+ "dev": true,
591
+ "license": "MIT",
592
+ "dependencies": {
593
+ "mime-db": "1.52.0"
594
+ },
595
+ "engines": {
596
+ "node": ">= 0.6"
597
+ }
598
+ },
599
+ "node_modules/ms": {
600
+ "version": "2.1.3",
601
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
602
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
603
+ "dev": true,
604
+ "license": "MIT"
605
+ },
606
+ "node_modules/nwsapi": {
607
+ "version": "2.2.23",
608
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
609
+ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
610
+ "dev": true,
611
+ "license": "MIT"
612
+ },
613
+ "node_modules/parse5": {
614
+ "version": "7.3.0",
615
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
616
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
617
+ "dev": true,
618
+ "license": "MIT",
619
+ "dependencies": {
620
+ "entities": "^6.0.0"
621
+ },
622
+ "funding": {
623
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
624
+ }
625
+ },
626
+ "node_modules/punycode": {
627
+ "version": "2.3.1",
628
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
629
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
630
+ "dev": true,
631
+ "license": "MIT",
632
+ "engines": {
633
+ "node": ">=6"
634
+ }
635
+ },
636
+ "node_modules/rrweb-cssom": {
637
+ "version": "0.7.1",
638
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
639
+ "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
640
+ "dev": true,
641
+ "license": "MIT"
642
+ },
643
+ "node_modules/safer-buffer": {
644
+ "version": "2.1.2",
645
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
646
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
647
+ "dev": true,
648
+ "license": "MIT"
649
+ },
650
+ "node_modules/saxes": {
651
+ "version": "6.0.0",
652
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
653
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
654
+ "dev": true,
655
+ "license": "ISC",
656
+ "dependencies": {
657
+ "xmlchars": "^2.2.0"
658
+ },
659
+ "engines": {
660
+ "node": ">=v12.22.7"
661
+ }
662
+ },
663
+ "node_modules/symbol-tree": {
664
+ "version": "3.2.4",
665
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
666
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
667
+ "dev": true,
668
+ "license": "MIT"
669
+ },
670
+ "node_modules/tldts": {
671
+ "version": "6.1.86",
672
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
673
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
674
+ "dev": true,
675
+ "license": "MIT",
676
+ "dependencies": {
677
+ "tldts-core": "^6.1.86"
678
+ },
679
+ "bin": {
680
+ "tldts": "bin/cli.js"
681
+ }
682
+ },
683
+ "node_modules/tldts-core": {
684
+ "version": "6.1.86",
685
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
686
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
687
+ "dev": true,
688
+ "license": "MIT"
689
+ },
690
+ "node_modules/tough-cookie": {
691
+ "version": "5.1.2",
692
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
693
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
694
+ "dev": true,
695
+ "license": "BSD-3-Clause",
696
+ "dependencies": {
697
+ "tldts": "^6.1.32"
698
+ },
699
+ "engines": {
700
+ "node": ">=16"
701
+ }
702
+ },
703
+ "node_modules/tr46": {
704
+ "version": "5.1.1",
705
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
706
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
707
+ "dev": true,
708
+ "license": "MIT",
709
+ "dependencies": {
710
+ "punycode": "^2.3.1"
711
+ },
712
+ "engines": {
713
+ "node": ">=18"
714
+ }
715
+ },
716
+ "node_modules/w3c-xmlserializer": {
717
+ "version": "5.0.0",
718
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
719
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
720
+ "dev": true,
721
+ "license": "MIT",
722
+ "dependencies": {
723
+ "xml-name-validator": "^5.0.0"
724
+ },
725
+ "engines": {
726
+ "node": ">=18"
727
+ }
728
+ },
729
+ "node_modules/webidl-conversions": {
730
+ "version": "7.0.0",
731
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
732
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
733
+ "dev": true,
734
+ "license": "BSD-2-Clause",
735
+ "engines": {
736
+ "node": ">=12"
737
+ }
738
+ },
739
+ "node_modules/whatwg-encoding": {
740
+ "version": "3.1.1",
741
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
742
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
743
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
744
+ "dev": true,
745
+ "license": "MIT",
746
+ "dependencies": {
747
+ "iconv-lite": "0.6.3"
748
+ },
749
+ "engines": {
750
+ "node": ">=18"
751
+ }
752
+ },
753
+ "node_modules/whatwg-mimetype": {
754
+ "version": "4.0.0",
755
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
756
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
757
+ "dev": true,
758
+ "license": "MIT",
759
+ "engines": {
760
+ "node": ">=18"
761
+ }
762
+ },
763
+ "node_modules/whatwg-url": {
764
+ "version": "14.2.0",
765
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
766
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
767
+ "dev": true,
768
+ "license": "MIT",
769
+ "dependencies": {
770
+ "tr46": "^5.1.0",
771
+ "webidl-conversions": "^7.0.0"
772
+ },
773
+ "engines": {
774
+ "node": ">=18"
775
+ }
776
+ },
777
+ "node_modules/ws": {
778
+ "version": "8.19.0",
779
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
780
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
781
+ "dev": true,
782
+ "license": "MIT",
783
+ "engines": {
784
+ "node": ">=10.0.0"
785
+ },
786
+ "peerDependencies": {
787
+ "bufferutil": "^4.0.1",
788
+ "utf-8-validate": ">=5.0.2"
789
+ },
790
+ "peerDependenciesMeta": {
791
+ "bufferutil": {
792
+ "optional": true
793
+ },
794
+ "utf-8-validate": {
795
+ "optional": true
796
+ }
797
+ }
798
+ },
799
+ "node_modules/xml-name-validator": {
800
+ "version": "5.0.0",
801
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
802
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
803
+ "dev": true,
804
+ "license": "Apache-2.0",
805
+ "engines": {
806
+ "node": ">=18"
807
+ }
808
+ },
809
+ "node_modules/xmlchars": {
810
+ "version": "2.2.0",
811
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
812
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
813
+ "dev": true,
814
+ "license": "MIT"
815
+ }
816
+ }
817
+ }
package.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "iris-ai-frontend-tests",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "Frontend unit tests for IRIS-AI ticker validation",
6
+ "scripts": {
7
+ "test": "node --test tests/test_ticker_validation.js"
8
+ },
9
+ "devDependencies": {
10
+ "jsdom": "^25.0.0"
11
+ }
12
+ }
static/app.js CHANGED
@@ -73,6 +73,162 @@ document.addEventListener('DOMContentLoaded', () => {
73
  let latestPredictedPrice = null;
74
  let latestAnalyzeHistory = [];
75
  let latestAnalyzeTimeframe = '6M';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  let historyRequestId = 0;
77
  const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
78
 
@@ -324,6 +480,7 @@ document.addEventListener('DOMContentLoaded', () => {
324
  dashboard.classList.remove('hidden');
325
  currentTicker = String(data?.meta?.symbol || normalizedTicker).toUpperCase();
326
  input.value = currentTicker;
 
327
  latestPredictedPrice = Number(data?.market?.predicted_price_next_session);
328
  latestAnalyzeHistory = normalizeHistoryPoints(data?.market?.history);
329
  latestAnalyzeTimeframe = getActiveTimeframe();
@@ -343,9 +500,8 @@ document.addEventListener('DOMContentLoaded', () => {
343
 
344
  form.addEventListener('submit', async (e) => {
345
  e.preventDefault();
346
- const ticker = input.value.trim().toUpperCase();
347
- if (!ticker) return;
348
- await loadTickerData(ticker, false);
349
  });
350
 
351
  timeframeButtons.forEach((btn) => {
@@ -471,7 +627,8 @@ document.addEventListener('DOMContentLoaded', () => {
471
  } else {
472
  btnText.classList.remove('hidden');
473
  spinner.classList.add('hidden');
474
- analyzeBtn.disabled = false;
 
475
  timeframeButtons.forEach((btn) => { btn.disabled = false; });
476
  }
477
  }
 
73
  let latestPredictedPrice = null;
74
  let latestAnalyzeHistory = [];
75
  let latestAnalyzeTimeframe = '6M';
76
+ // --- Validation UI elements ---
77
+ const inputWrapper = document.getElementById('ticker-input-wrapper');
78
+ const clearBtn = document.getElementById('ticker-clear');
79
+ const valIndicator = document.getElementById('ticker-val-indicator');
80
+ const validationHint = document.getElementById('validation-hint');
81
+ const validationMsgEl = document.getElementById('validation-msg');
82
+ const suggestionChips = document.getElementById('suggestion-chips');
83
+
84
+ // --- Validation state ---
85
+ let _validatedTicker = null; // non-null only when remote validation passed
86
+ let _debounceTimer = null;
87
+ let _abortController = null;
88
+
89
+ function _setInputState(state) {
90
+ // state: '' | 'error' | 'validating' | 'valid' | 'warn'
91
+ input.className = state ? `ticker-input--${state}` : '';
92
+ if (valIndicator) {
93
+ valIndicator.classList.toggle('is-spinning', state === 'validating');
94
+ valIndicator.classList.toggle('hidden', state !== 'validating');
95
+ }
96
+ }
97
+
98
+ function _showValidationHint(text, type) {
99
+ if (!validationMsgEl || !validationHint) return;
100
+ validationMsgEl.textContent = text;
101
+ validationMsgEl.className = `validation-msg validation-msg--${type}`;
102
+ validationHint.classList.remove('hidden');
103
+ }
104
+
105
+ function _clearValidationHint() {
106
+ if (validationHint) validationHint.classList.add('hidden');
107
+ if (validationMsgEl) validationMsgEl.textContent = '';
108
+ if (suggestionChips) suggestionChips.innerHTML = '';
109
+ }
110
+
111
+ function _renderSuggestions(suggestions) {
112
+ if (!suggestionChips) return;
113
+ suggestionChips.innerHTML = '';
114
+ if (!Array.isArray(suggestions) || !suggestions.length) return;
115
+ suggestions.forEach((s) => {
116
+ const chip = document.createElement('button');
117
+ chip.type = 'button';
118
+ chip.className = 'suggestion-chip';
119
+ chip.textContent = s;
120
+ chip.addEventListener('click', () => {
121
+ input.value = s;
122
+ _triggerValidation(s);
123
+ });
124
+ suggestionChips.appendChild(chip);
125
+ });
126
+ }
127
+
128
+ async function _triggerValidation(rawValue) {
129
+ const val = String(rawValue || '').trim().toUpperCase();
130
+
131
+ // Cancel previous in-flight remote request
132
+ if (_abortController) _abortController.abort();
133
+ _abortController = new AbortController();
134
+
135
+ // Instant format check
136
+ const fmt = (window.TickerValidation || {}).validateTickerFormat;
137
+ if (!fmt) return;
138
+ const fmtResult = fmt(val);
139
+ if (!fmtResult.valid) {
140
+ _validatedTicker = null;
141
+ analyzeBtn.disabled = true;
142
+ _setInputState('error');
143
+ _showValidationHint(fmtResult.error, 'error');
144
+ _renderSuggestions([]);
145
+ if (clearBtn) clearBtn.classList.toggle('hidden', !val);
146
+ return;
147
+ }
148
+
149
+ // Format OK β†’ remote check
150
+ _validatedTicker = null;
151
+ analyzeBtn.disabled = true;
152
+ _setInputState('validating');
153
+ _clearValidationHint();
154
+ if (clearBtn) clearBtn.classList.remove('hidden');
155
+
156
+ const { signal } = _abortController;
157
+ const remoteCheck = (window.TickerValidation || {}).validateTickerRemote;
158
+ if (!remoteCheck) return;
159
+
160
+ const result = await remoteCheck(val, signal);
161
+ if (!result) return; // cancelled by a newer keystroke
162
+
163
+ if (result.valid) {
164
+ _validatedTicker = val;
165
+ analyzeBtn.disabled = false;
166
+ _setInputState('valid');
167
+ _showValidationHint(`βœ“ ${result.company_name || val}`, 'success');
168
+ _renderSuggestions([]);
169
+ } else {
170
+ _setInputState('error');
171
+ _showValidationHint(result.error || 'Ticker not found.', 'error');
172
+ _renderSuggestions(result.suggestions);
173
+ }
174
+ }
175
+
176
+ if (input) {
177
+ input.addEventListener('input', () => {
178
+ // Auto-uppercase as user types
179
+ const pos = input.selectionStart;
180
+ input.value = input.value.toUpperCase();
181
+ try { input.setSelectionRange(pos, pos); } catch (_) {}
182
+
183
+ if (clearBtn) clearBtn.classList.toggle('hidden', !input.value);
184
+
185
+ const val = input.value.trim();
186
+ clearTimeout(_debounceTimer);
187
+
188
+ // Instant format check for immediate feedback
189
+ const fmt = (window.TickerValidation || {}).validateTickerFormat;
190
+ if (fmt) {
191
+ const fmtResult = fmt(val);
192
+ if (!fmtResult.valid) {
193
+ if (_abortController) _abortController.abort();
194
+ _validatedTicker = null;
195
+ analyzeBtn.disabled = true;
196
+ _setInputState(val ? 'error' : '');
197
+ if (val) {
198
+ _showValidationHint(fmtResult.error, 'error');
199
+ } else {
200
+ _clearValidationHint();
201
+ _setInputState('');
202
+ }
203
+ _renderSuggestions([]);
204
+ return;
205
+ }
206
+ }
207
+
208
+ // Format OK β€” debounce the remote call
209
+ _validatedTicker = null;
210
+ analyzeBtn.disabled = true;
211
+ _setInputState('validating');
212
+ _clearValidationHint();
213
+ _debounceTimer = setTimeout(() => _triggerValidation(val), 500);
214
+ });
215
+ }
216
+
217
+ if (clearBtn) {
218
+ clearBtn.addEventListener('click', () => {
219
+ input.value = '';
220
+ _validatedTicker = null;
221
+ analyzeBtn.disabled = true;
222
+ clearTimeout(_debounceTimer);
223
+ if (_abortController) _abortController.abort();
224
+ _setInputState('');
225
+ _clearValidationHint();
226
+ clearBtn.classList.add('hidden');
227
+ input.focus();
228
+ });
229
+ }
230
+ // --- End ticker validation ---
231
+
232
  let historyRequestId = 0;
233
  const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
234
 
 
480
  dashboard.classList.remove('hidden');
481
  currentTicker = String(data?.meta?.symbol || normalizedTicker).toUpperCase();
482
  input.value = currentTicker;
483
+ _validatedTicker = currentTicker; // keep in sync after successful analysis
484
  latestPredictedPrice = Number(data?.market?.predicted_price_next_session);
485
  latestAnalyzeHistory = normalizeHistoryPoints(data?.market?.history);
486
  latestAnalyzeTimeframe = getActiveTimeframe();
 
500
 
501
  form.addEventListener('submit', async (e) => {
502
  e.preventDefault();
503
+ if (!_validatedTicker) return;
504
+ await loadTickerData(_validatedTicker, false);
 
505
  });
506
 
507
  timeframeButtons.forEach((btn) => {
 
627
  } else {
628
  btnText.classList.remove('hidden');
629
  spinner.classList.add('hidden');
630
+ // Only re-enable if the current input is still validated
631
+ analyzeBtn.disabled = !_validatedTicker;
632
  timeframeButtons.forEach((btn) => { btn.disabled = false; });
633
  }
634
  }
static/style.css CHANGED
@@ -1206,4 +1206,124 @@ footer p {
1206
  .search-box button {
1207
  padding: 0.8rem 1.5rem;
1208
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1209
  }
 
1206
  .search-box button {
1207
  padding: 0.8rem 1.5rem;
1208
  }
1209
+ }
1210
+
1211
+ /* ============================================================
1212
+ Ticker input validation states
1213
+ ============================================================ */
1214
+
1215
+ /* -- Input wrapper (positions clear btn + indicator) -- */
1216
+ .input-wrapper {
1217
+ position: relative;
1218
+ flex: 1;
1219
+ display: flex;
1220
+ }
1221
+
1222
+ .input-wrapper input {
1223
+ width: 100%;
1224
+ padding-right: 3rem; /* room for clear btn */
1225
+ }
1226
+
1227
+ /* -- Clear button (Γ— inside the field) -- */
1228
+ .ticker-clear {
1229
+ position: absolute;
1230
+ right: 0.75rem;
1231
+ top: 50%;
1232
+ transform: translateY(-50%);
1233
+ background: none;
1234
+ border: none;
1235
+ cursor: pointer;
1236
+ color: var(--text-muted);
1237
+ font-size: 1.2rem;
1238
+ line-height: 1;
1239
+ padding: 0.2rem 0.3rem;
1240
+ border-radius: 4px;
1241
+ transition: color 0.2s ease;
1242
+ }
1243
+ .ticker-clear:hover {
1244
+ color: var(--text-main);
1245
+ }
1246
+
1247
+ /* -- Small inline validation indicator (spinner / checkmark) -- */
1248
+ .ticker-val-indicator {
1249
+ position: absolute;
1250
+ right: 2.2rem;
1251
+ top: 50%;
1252
+ transform: translateY(-50%);
1253
+ width: 16px;
1254
+ height: 16px;
1255
+ pointer-events: none;
1256
+ }
1257
+
1258
+ /* spinning ring while remote validation is running */
1259
+ .ticker-val-indicator.is-spinning {
1260
+ border: 2px solid var(--accent-blue-glow);
1261
+ border-top-color: var(--accent-blue);
1262
+ border-radius: 50%;
1263
+ animation: spin 0.8s linear infinite;
1264
+ }
1265
+
1266
+ /* -- Input border states -- */
1267
+ #ticker-input.ticker-input--error {
1268
+ border-color: var(--status-red);
1269
+ box-shadow: 0 0 0 3px var(--status-red-glow);
1270
+ }
1271
+
1272
+ #ticker-input.ticker-input--validating {
1273
+ border-color: var(--accent-blue);
1274
+ box-shadow: 0 0 0 3px var(--accent-blue-glow);
1275
+ }
1276
+
1277
+ #ticker-input.ticker-input--valid {
1278
+ border-color: var(--status-green);
1279
+ box-shadow: 0 0 0 3px var(--status-green-glow);
1280
+ }
1281
+
1282
+ #ticker-input.ticker-input--warn {
1283
+ border-color: var(--status-yellow);
1284
+ box-shadow: 0 0 0 3px var(--status-yellow-glow);
1285
+ }
1286
+
1287
+ /* -- Validation hint area below the search box -- */
1288
+ .validation-hint {
1289
+ margin-top: 0.6rem;
1290
+ text-align: left;
1291
+ }
1292
+
1293
+ .validation-msg {
1294
+ display: block;
1295
+ font-size: 0.88rem;
1296
+ font-weight: 500;
1297
+ margin-bottom: 0.4rem;
1298
+ }
1299
+
1300
+ .validation-msg--error { color: var(--status-red); }
1301
+ .validation-msg--success { color: var(--status-green); }
1302
+ .validation-msg--warn { color: var(--status-yellow); }
1303
+ .validation-msg--info { color: var(--text-muted); }
1304
+
1305
+ /* -- Suggestion chips -- */
1306
+ .suggestion-chips {
1307
+ display: flex;
1308
+ flex-wrap: wrap;
1309
+ gap: 0.4rem;
1310
+ margin-top: 0.25rem;
1311
+ }
1312
+
1313
+ .suggestion-chip {
1314
+ padding: 0.25rem 0.75rem;
1315
+ border-radius: 999px;
1316
+ border: 1px solid var(--panel-border);
1317
+ background: var(--bg-subtle);
1318
+ color: var(--accent-blue);
1319
+ font-size: 0.82rem;
1320
+ font-weight: 600;
1321
+ cursor: pointer;
1322
+ transition: background 0.2s ease, box-shadow 0.2s ease;
1323
+ }
1324
+
1325
+ .suggestion-chip:hover {
1326
+ background: var(--accent-blue);
1327
+ color: var(--text-inverse);
1328
+ box-shadow: 0 0 0 2px var(--accent-blue-glow);
1329
  }
static/tickerValidation.js ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Ticker validation utilities β€” usable in browser (sets window.TickerValidation)
3
+ * and in Node.js (module.exports) for unit testing.
4
+ */
5
+ (function (root, factory) {
6
+ if (typeof module !== 'undefined' && module.exports) {
7
+ module.exports = factory(); // Node.js / CommonJS
8
+ } else {
9
+ root.TickerValidation = factory(); // browser global
10
+ }
11
+ }(typeof self !== 'undefined' ? self : this, function () {
12
+ 'use strict';
13
+
14
+ const _RESERVED = new Set(['TEST', 'NULL', 'NONE', 'HELP', 'NA']);
15
+
16
+ /**
17
+ * Instant client-side format check β€” no network call.
18
+ * @param {string} input
19
+ * @returns {{ valid: boolean, error?: string, cleaned?: string }}
20
+ */
21
+ function validateTickerFormat(input) {
22
+ const cleaned = String(input == null ? '' : input).trim().toUpperCase();
23
+
24
+ if (!cleaned) {
25
+ return { valid: false, error: 'Please enter a stock ticker symbol.' };
26
+ }
27
+ if (!/^[A-Z]+$/.test(cleaned)) {
28
+ return { valid: false, error: 'Tickers contain only letters (e.g., AAPL, MSFT).' };
29
+ }
30
+ if (cleaned.length > 5) {
31
+ return { valid: false, error: 'US stock tickers are 1-5 letters long.' };
32
+ }
33
+ if (_RESERVED.has(cleaned)) {
34
+ return { valid: false, error: `"${cleaned}" is not a stock ticker.` };
35
+ }
36
+ return { valid: true, cleaned };
37
+ }
38
+
39
+ /**
40
+ * Server-side ticker verification via POST /api/validate-ticker.
41
+ * Accepts an optional AbortSignal for external cancellation.
42
+ * A 5-second internal timeout is always applied.
43
+ *
44
+ * @param {string} ticker β€” already-normalised uppercase ticker
45
+ * @param {AbortSignal|null} [externalSignal]
46
+ * @returns {Promise<{valid: boolean, ticker?: string, company_name?: string,
47
+ * error?: string, suggestions?: string[]} | null>}
48
+ * Returns null when cancelled via externalSignal (caller should ignore the result).
49
+ */
50
+ async function validateTickerRemote(ticker, externalSignal) {
51
+ const timeoutController = new AbortController();
52
+ const timeoutId = setTimeout(() => timeoutController.abort(), 5000);
53
+
54
+ // Forward external cancellation to our timeout controller
55
+ let onExternalAbort = null;
56
+ if (externalSignal) {
57
+ if (externalSignal.aborted) {
58
+ clearTimeout(timeoutId);
59
+ return null;
60
+ }
61
+ onExternalAbort = () => { clearTimeout(timeoutId); timeoutController.abort(); };
62
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true });
63
+ }
64
+
65
+ try {
66
+ const response = await fetch('/api/validate-ticker', {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({ ticker }),
70
+ signal: timeoutController.signal,
71
+ });
72
+ clearTimeout(timeoutId);
73
+ if (externalSignal && onExternalAbort) {
74
+ externalSignal.removeEventListener('abort', onExternalAbort);
75
+ }
76
+ return await response.json();
77
+ } catch (err) {
78
+ clearTimeout(timeoutId);
79
+ if (externalSignal && onExternalAbort) {
80
+ externalSignal.removeEventListener('abort', onExternalAbort);
81
+ }
82
+ if (err.name === 'AbortError') {
83
+ if (externalSignal && externalSignal.aborted) {
84
+ return null; // cancelled externally β€” caller should ignore
85
+ }
86
+ return { valid: false, error: 'Validation timed out. Please try again.' };
87
+ }
88
+ return { valid: false, error: 'Network error. Please check your connection.' };
89
+ }
90
+ }
91
+
92
+ return { validateTickerFormat, validateTickerRemote };
93
+ }));
templates/index.html CHANGED
@@ -49,14 +49,25 @@
49
  <h2>Enter Ticker to Analyze</h2>
50
  <form id="analyze-form">
51
  <div class="search-box">
52
- <input type="text" id="ticker-input" placeholder="e.g. AAPL, TSLA, NVDA" required
53
- autocomplete="off">
54
- <button type="submit" id="analyze-btn">
 
 
 
 
 
 
 
55
  <span class="btn-text">Analyze Risk</span>
56
  <div class="spinner hidden" id="loading-spinner"></div>
57
  </button>
58
  </div>
59
  </form>
 
 
 
 
60
  <div id="error-message" class="error-msg hidden"></div>
61
  </section>
62
 
@@ -158,7 +169,8 @@
158
  <!-- TradingView Lightweight Charts -->
159
  <script
160
  src="https://unpkg.com/lightweight-charts@4.1.1/dist/lightweight-charts.standalone.production.js?v=4"></script>
161
- <script src="/static/app.js?v=18"></script>
 
162
  </body>
163
 
164
  </html>
 
49
  <h2>Enter Ticker to Analyze</h2>
50
  <form id="analyze-form">
51
  <div class="search-box">
52
+ <div class="input-wrapper" id="ticker-input-wrapper">
53
+ <input type="text" id="ticker-input"
54
+ placeholder="Enter stock ticker (e.g., AAPL)"
55
+ autocomplete="off" spellcheck="false" maxlength="10">
56
+ <button type="button" id="ticker-clear" class="ticker-clear hidden"
57
+ aria-label="Clear input" tabindex="-1">Γ—</button>
58
+ <span id="ticker-val-indicator" class="ticker-val-indicator hidden"
59
+ aria-hidden="true"></span>
60
+ </div>
61
+ <button type="submit" id="analyze-btn" disabled>
62
  <span class="btn-text">Analyze Risk</span>
63
  <div class="spinner hidden" id="loading-spinner"></div>
64
  </button>
65
  </div>
66
  </form>
67
+ <div id="validation-hint" class="validation-hint hidden">
68
+ <span id="validation-msg" class="validation-msg"></span>
69
+ <div id="suggestion-chips" class="suggestion-chips"></div>
70
+ </div>
71
  <div id="error-message" class="error-msg hidden"></div>
72
  </section>
73
 
 
169
  <!-- TradingView Lightweight Charts -->
170
  <script
171
  src="https://unpkg.com/lightweight-charts@4.1.1/dist/lightweight-charts.standalone.production.js?v=4"></script>
172
+ <script src="/static/tickerValidation.js?v=1"></script>
173
+ <script src="/static/app.js?v=19"></script>
174
  </body>
175
 
176
  </html>
tests/test_ticker_validation.js ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+ /**
3
+ * Unit tests for tickerValidation.js and the TickerInput component behaviour.
4
+ * Run with: node --test tests/test_ticker_validation.js
5
+ * Requires: npm install (installs jsdom for DOM tests)
6
+ */
7
+
8
+ const { test, describe } = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const path = require('node:path');
11
+ const { JSDOM } = require('jsdom');
12
+
13
+ // Load the validation module (UMD β€” sets module.exports in Node.js)
14
+ const { validateTickerFormat, validateTickerRemote } = require(
15
+ path.join(__dirname, '..', 'static', 'tickerValidation.js')
16
+ );
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Pure format-validation tests (no DOM, no network)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ describe('validateTickerFormat', () => {
23
+
24
+ test('test_empty_input_shows_error β€” empty string returns error', () => {
25
+ const result = validateTickerFormat('');
26
+ assert.equal(result.valid, false);
27
+ assert.match(result.error, /Please enter/i);
28
+ });
29
+
30
+ test('test_empty_input_shows_error β€” whitespace-only returns error', () => {
31
+ const result = validateTickerFormat(' ');
32
+ assert.equal(result.valid, false);
33
+ assert.match(result.error, /Please enter/i);
34
+ });
35
+
36
+ test('test_numbers_rejected_instantly β€” digits in input', () => {
37
+ for (const bad of ['123', '1A', 'A1B', '9XYZ']) {
38
+ const result = validateTickerFormat(bad);
39
+ assert.equal(result.valid, false,
40
+ `Expected "${bad}" to be rejected`);
41
+ assert.match(result.error, /only letters/i);
42
+ }
43
+ });
44
+
45
+ test('test_too_long_ticker_rejected β€” 6+ letters rejected', () => {
46
+ const result = validateTickerFormat('ABCDEF');
47
+ assert.equal(result.valid, false);
48
+ assert.match(result.error, /1-5 letters/i);
49
+ });
50
+
51
+ test('reserved words rejected', () => {
52
+ for (const word of ['TEST', 'NULL', 'NONE', 'HELP', 'NA']) {
53
+ const result = validateTickerFormat(word);
54
+ assert.equal(result.valid, false,
55
+ `Expected "${word}" to be rejected as reserved`);
56
+ assert.match(result.error, /not a stock ticker/i);
57
+ }
58
+ });
59
+
60
+ test('test_valid_format_triggers_remote_check β€” valid formats pass', () => {
61
+ for (const good of ['AAPL', 'msft', ' TSLA ', 'A', 'GOOGL']) {
62
+ const result = validateTickerFormat(good);
63
+ assert.equal(result.valid, true,
64
+ `Expected "${good}" to pass format check`);
65
+ assert.equal(result.cleaned, good.trim().toUpperCase());
66
+ }
67
+ });
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // DOM behaviour tests (requires jsdom)
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /** Build a minimal DOM that mirrors the search section in index.html */
75
+ function buildDOM() {
76
+ const dom = new JSDOM(`<!DOCTYPE html>
77
+ <html><body>
78
+ <form id="analyze-form">
79
+ <div class="search-box">
80
+ <div class="input-wrapper" id="ticker-input-wrapper">
81
+ <input type="text" id="ticker-input" />
82
+ <button type="button" id="ticker-clear" class="ticker-clear hidden">Γ—</button>
83
+ <span id="ticker-val-indicator" class="ticker-val-indicator hidden"></span>
84
+ </div>
85
+ <button type="submit" id="analyze-btn" disabled>
86
+ <span class="btn-text">Analyze Risk</span>
87
+ </button>
88
+ </div>
89
+ </form>
90
+ <div id="validation-hint" class="hidden">
91
+ <span id="validation-msg"></span>
92
+ <div id="suggestion-chips"></div>
93
+ </div>
94
+ <div id="error-message" class="error-msg hidden"></div>
95
+ </body></html>`, { runScripts: 'dangerously', pretendToBeVisual: true });
96
+
97
+ const { window } = dom;
98
+
99
+ // Expose TickerValidation in the fake window before loading app logic
100
+ window.TickerValidation = { validateTickerFormat, validateTickerRemote };
101
+
102
+ return { window, document: window.document };
103
+ }
104
+
105
+ /**
106
+ * Wire up a minimal version of the validation logic from app.js so we can
107
+ * test component behaviour without importing the full 1 000-line app.js
108
+ * (which has heavyweight dependencies like LightweightCharts).
109
+ */
110
+ function wireValidation(window) {
111
+ const { document } = window;
112
+ const input = document.getElementById('ticker-input');
113
+ const analyzeBtn = document.getElementById('analyze-btn');
114
+ const clearBtn = document.getElementById('ticker-clear');
115
+ const validationHint = document.getElementById('validation-hint');
116
+ const validationMsgEl = document.getElementById('validation-msg');
117
+ const suggestionChips = document.getElementById('suggestion-chips');
118
+
119
+ let _validatedTicker = null;
120
+ let _debounceTimer = null;
121
+ let _abortController = null;
122
+
123
+ function getValidatedTicker() { return _validatedTicker; }
124
+
125
+ function _setInputState(state) {
126
+ input.className = state ? `ticker-input--${state}` : '';
127
+ }
128
+
129
+ function _showHint(text, type) {
130
+ validationMsgEl.textContent = text;
131
+ validationMsgEl.className = `validation-msg validation-msg--${type}`;
132
+ validationHint.classList.remove('hidden');
133
+ }
134
+
135
+ function _clearHint() {
136
+ validationHint.classList.add('hidden');
137
+ validationMsgEl.textContent = '';
138
+ suggestionChips.innerHTML = '';
139
+ }
140
+
141
+ function _renderSuggestions(suggestions) {
142
+ suggestionChips.innerHTML = '';
143
+ if (!Array.isArray(suggestions)) return;
144
+ suggestions.forEach((s) => {
145
+ const chip = document.createElement('button');
146
+ chip.type = 'button';
147
+ chip.className = 'suggestion-chip';
148
+ chip.textContent = s;
149
+ chip.addEventListener('click', () => {
150
+ input.value = s;
151
+ triggerValidation(s);
152
+ });
153
+ suggestionChips.appendChild(chip);
154
+ });
155
+ }
156
+
157
+ async function triggerValidation(rawValue) {
158
+ const val = String(rawValue || '').trim().toUpperCase();
159
+ if (_abortController) _abortController.abort();
160
+ _abortController = new window.AbortController();
161
+
162
+ const fmt = window.TickerValidation.validateTickerFormat(val);
163
+ if (!fmt.valid) {
164
+ _validatedTicker = null;
165
+ analyzeBtn.disabled = true;
166
+ _setInputState('error');
167
+ _showHint(fmt.error, 'error');
168
+ _renderSuggestions([]);
169
+ return;
170
+ }
171
+
172
+ _validatedTicker = null;
173
+ analyzeBtn.disabled = true;
174
+ _setInputState('validating');
175
+ _clearHint();
176
+
177
+ const { signal } = _abortController;
178
+ const result = await window.TickerValidation.validateTickerRemote(val, signal);
179
+ if (!result) return;
180
+
181
+ if (result.valid) {
182
+ _validatedTicker = val;
183
+ analyzeBtn.disabled = false;
184
+ _setInputState('valid');
185
+ _showHint(`βœ“ ${result.company_name || val}`, 'success');
186
+ _renderSuggestions([]);
187
+ } else {
188
+ _setInputState('error');
189
+ _showHint(result.error || 'Not found.', 'error');
190
+ _renderSuggestions(result.suggestions || []);
191
+ }
192
+ }
193
+
194
+ input.addEventListener('input', () => {
195
+ input.value = input.value.toUpperCase();
196
+ clearBtn.classList.toggle('hidden', !input.value);
197
+ const val = input.value.trim();
198
+ clearTimeout(_debounceTimer);
199
+
200
+ const fmt = window.TickerValidation.validateTickerFormat(val);
201
+ if (!fmt.valid) {
202
+ if (_abortController) _abortController.abort();
203
+ _validatedTicker = null;
204
+ analyzeBtn.disabled = true;
205
+ _setInputState(val ? 'error' : '');
206
+ if (val) _showHint(fmt.error, 'error');
207
+ else _clearHint();
208
+ _renderSuggestions([]);
209
+ return;
210
+ }
211
+
212
+ _validatedTicker = null;
213
+ analyzeBtn.disabled = true;
214
+ _setInputState('validating');
215
+ _clearHint();
216
+ _debounceTimer = setTimeout(() => triggerValidation(val), 500);
217
+ });
218
+
219
+ clearBtn.addEventListener('click', () => {
220
+ input.value = '';
221
+ _validatedTicker = null;
222
+ analyzeBtn.disabled = true;
223
+ clearTimeout(_debounceTimer);
224
+ if (_abortController) _abortController.abort();
225
+ _setInputState('');
226
+ _clearHint();
227
+ clearBtn.classList.add('hidden');
228
+ });
229
+
230
+ return { input, analyzeBtn, clearBtn, validationHint, validationMsgEl,
231
+ suggestionChips, triggerValidation, getValidatedTicker };
232
+ }
233
+
234
+ // Helper: fire input event
235
+ function fireInput(input, value) {
236
+ input.value = value;
237
+ input.dispatchEvent(new input.ownerDocument.defaultView.Event('input', { bubbles: true }));
238
+ }
239
+
240
+ describe('TickerInput DOM behaviour', () => {
241
+
242
+ test('test_submit_disabled_until_valid β€” button starts disabled', () => {
243
+ const { document } = buildDOM();
244
+ const btn = document.getElementById('analyze-btn');
245
+ assert.equal(btn.disabled, true);
246
+ });
247
+
248
+ test('test_suggestion_click_fills_input β€” clicking chip fills input', async () => {
249
+ const { window } = buildDOM();
250
+
251
+ // Mock validateTickerRemote to return suggestions for a bad ticker
252
+ window.TickerValidation.validateTickerRemote = async () => ({
253
+ valid: false,
254
+ error: 'Not found.',
255
+ suggestions: ['AAPL', 'APD'],
256
+ });
257
+
258
+ const { input, suggestionChips, triggerValidation } = wireValidation(window);
259
+
260
+ await triggerValidation('AAPL1'); // invalid format handled locally
261
+ // Use a format-valid but server-rejected ticker to get suggestions
262
+ // Bypass format check by directly calling with a valid-format ticker
263
+ // Override validateTickerFormat temporarily
264
+ const original = window.TickerValidation.validateTickerFormat;
265
+ window.TickerValidation.validateTickerFormat = () => ({ valid: true, cleaned: 'AAPX' });
266
+ await triggerValidation('AAPX');
267
+ window.TickerValidation.validateTickerFormat = original;
268
+
269
+ const chips = suggestionChips.querySelectorAll('.suggestion-chip');
270
+ assert.ok(chips.length > 0, 'Expected suggestion chips to be rendered');
271
+
272
+ // Click the first chip β€” it should fill the input
273
+ chips[0].click();
274
+ assert.equal(input.value, chips[0].textContent);
275
+ });
276
+
277
+ test('test_submit_disabled_until_valid β€” button enabled after valid remote result', async () => {
278
+ const { window } = buildDOM();
279
+ window.TickerValidation.validateTickerRemote = async () => ({
280
+ valid: true,
281
+ ticker: 'AAPL',
282
+ company_name: 'Apple Inc.',
283
+ });
284
+
285
+ const { analyzeBtn, triggerValidation } = wireValidation(window);
286
+
287
+ assert.equal(analyzeBtn.disabled, true, 'Should start disabled');
288
+ await triggerValidation('AAPL');
289
+ assert.equal(analyzeBtn.disabled, false, 'Should be enabled after valid result');
290
+ });
291
+ });